Every agent gets the live web: web_search and fetch_url by default

"Be ashamed to guess APIs blindly; be proud to look things up carefully" —
until now the agents had no way to look anything up: no search, no fetch,
so market research, API usage, and competitive claims were all invented
from model memory.

agent-web-tools.ts, carried by every agent in every run (same pattern as
the workspace tools, no grant needed):
- web_search: keyless DuckDuckGo HTML backend; parses titles, unwraps the
  /l/?uddg= redirect hrefs, pairs snippets; reports honestly when nothing
  parses instead of fabricating results. Verified against the live endpoint.
- fetch_url: readable-text extraction (dependency-free HTML-to-text with
  entity decoding), 20k-char page cap, 2MB body cap read in chunks, 30s
  timeout.
- SSRF guard on both: hostnames resolve before connecting and loopback,
  private, link-local, CGNAT, and reserved ranges are refused — re-checked
  on every redirect hop, so a public page cannot bounce the agent into
  127.0.0.1 or the cloud metadata address. IPv6 and v4-mapped forms covered.
- The fetcher is injectable, so the test suite covers the guard, redirect
  policy, truncation, extraction, and the DuckDuckGo parser without
  touching the network.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-09 20:42:12 -04:00
1 parent 26de3129b1
commit 80e70649c3
4 files changed
+405 -2

No files matched your search

+4 -1
View File
@@ -1,5 +1,6 @@
import type { ProviderService } from "./provider-service";
import { AGENT_TOOLS, dispatchAgentTool, type AgentToolContext, type AgentToolDeps } from "./agent-tools";
import { executeWebTool, isWebTool, WEB_TOOLS } from "./agent-web-tools";
import { executeWorkspaceTool, isWorkspaceTool, WORKSPACE_TOOLS } from "./agent-workspace-tools";
import { canonicalMentionHandle } from "../../shared/mention-handles";
import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn";
@@ -38,7 +39,7 @@ export interface AgentRunLoopDeps {
* iteration cap is hit (which throws rather than silently truncating).
*/
export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopInput): Promise<AgentRunLoopResult> {
const allTools = [...AGENT_TOOLS, ...WORKSPACE_TOOLS, ...(input.externalTools ?? [])];
const allTools = [...AGENT_TOOLS, ...WORKSPACE_TOOLS, ...WEB_TOOLS, ...(input.externalTools ?? [])];
const messages: AgentTurnMessage[] = [{ role: "user", text: input.initialUserTurn }];
let toolCallCount = 0;
let lastRequestId = "";
@@ -73,6 +74,8 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
output = await executeComposioTool(deps.tools.db, input.orgId, input.toolContext.agent.id, call);
} else if (isWorkspaceTool(call.name)) {
output = await executeWorkspaceTool(input.toolContext, call);
} else if (isWebTool(call.name)) {
output = await executeWebTool(call);
} else {
output = dispatchAgentTool(deps.tools, input.toolContext, call, {
announceTaskAssignments: shouldAnnounceTaskAssignment(call, turn.toolCalls),
+290
View File
@@ -0,0 +1,290 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { requireString } from "./agent-tool-args";
import type { ToolCall, ToolDefinition } from "./provider-tool-turn";
const FETCH_TIMEOUT_MS = 30_000;
const MAX_REDIRECTS = 3;
const MAX_RESPONSE_BYTES = 2_000_000;
const PAGE_CHAR_LIMIT = 20_000;
const SEARCH_RESULT_LIMIT = 8;
const USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AgentCompany/1.0";
/** Subset of the fetch contract the tools rely on; injectable so tests stay off the network. */
export type WebFetcher = (url: string, init: { headers: Record<string, string>; redirect: "manual"; signal: AbortSignal }) => Promise<Response>;
/**
* Every agent carries live web access by default: web_search (keyless
* DuckDuckGo HTML backend) and fetch_url (readable text extraction). The
* doctrine is "be ashamed to guess, be proud to look things up" — an agent
* that can browse verifies APIs and facts instead of inventing them.
*
* These tools run on the operator's machine, so every request is guarded
* against SSRF: hostnames resolve before connecting and private, loopback,
* link-local, and reserved ranges are refused — re-checked on every redirect
* hop.
*/
export const WEB_TOOLS: ToolDefinition[] = [
{
name: "web_search",
description: "Search the live web. Use it to look up APIs, libraries, prices, competitors, and current facts instead of guessing. Returns titles, URLs, and snippets — follow up with fetch_url to read a promising result in full.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
query: { type: "string", description: "The search query." },
},
required: ["query"],
},
},
{
name: "fetch_url",
description: "Fetch a public web page and return its readable text. Use after web_search, or on any URL you were given. Only public hosts are reachable.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
url: { type: "string", description: "Absolute http(s) URL to fetch." },
},
required: ["url"],
},
},
];
const WEB_TOOL_NAMES = new Set(WEB_TOOLS.map((tool) => tool.name));
export function isWebTool(name: string): boolean {
return WEB_TOOL_NAMES.has(name);
}
export async function executeWebTool(call: ToolCall, fetcher: WebFetcher = fetch): Promise<string> {
try {
switch (call.name) {
case "web_search":
return await webSearch(requireString(call.arguments.query, "query"), fetcher);
case "fetch_url":
return await fetchUrl(requireString(call.arguments.url, "url"), fetcher);
default:
throw new Error(`Unknown web tool "${call.name}".`);
}
} catch (error) {
return `ERROR: ${error instanceof Error ? error.message : String(error)}`;
}
}
async function webSearch(query: string, fetcher: WebFetcher): Promise<string> {
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const body = await guardedFetch(url, fetcher);
const results = parseSearchResults(body.text);
if (results.length === 0) {
throw new Error("The search returned no parseable results — the backend may have blocked the request or changed its markup. Try fetch_url on a known site instead.");
}
return results
.slice(0, SEARCH_RESULT_LIMIT)
.map((result, index) => `${index + 1}. ${result.title}\n ${result.url}${result.snippet ? `\n ${result.snippet}` : ""}`)
.join("\n\n");
}
async function fetchUrl(rawUrl: string, fetcher: WebFetcher): Promise<string> {
const body = await guardedFetch(rawUrl, fetcher);
const text = looksLikeHtml(body.contentType, body.text) ? htmlToText(body.text) : body.text.trim();
if (!text) return `${body.finalUrl} returned an empty page (status ${body.status}).`;
const truncated = text.length > PAGE_CHAR_LIMIT
? `${text.slice(0, PAGE_CHAR_LIMIT)}\n\n[truncated: showing the first ${PAGE_CHAR_LIMIT} of ${text.length} characters]`
: text;
return `${body.finalUrl} (status ${body.status})\n\n${truncated}`;
}
interface GuardedResponse {
status: number;
contentType: string;
finalUrl: string;
text: string;
}
/** Fetches with the SSRF guard applied to the initial URL and every redirect hop. */
async function guardedFetch(rawUrl: string, fetcher: WebFetcher): Promise<GuardedResponse> {
let current = rawUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
const url = parseHttpUrl(current);
await assertPublicHost(url.hostname);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetcher(url.toString(), {
headers: { "user-agent": USER_AGENT, accept: "text/html,application/xhtml+xml,text/plain,*/*" },
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
throw new Error(`Fetching ${url.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
clearTimeout(timer);
}
const location = response.headers.get("location");
if (response.status >= 300 && response.status < 400 && location) {
current = new URL(location, url).toString();
continue;
}
const text = await readBodyCapped(response);
return {
status: response.status,
contentType: response.headers.get("content-type") ?? "",
finalUrl: url.toString(),
text,
};
}
throw new Error(`Too many redirects (more than ${MAX_REDIRECTS}).`);
}
function parseHttpUrl(rawUrl: string): URL {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`"${rawUrl}" is not a valid absolute URL.`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Only http(s) URLs are allowed, got "${url.protocol}".`);
}
return url;
}
/** Refuses hosts that resolve to loopback, private, link-local, or otherwise non-public addresses. */
async function assertPublicHost(hostname: string): Promise<void> {
const bare = hostname.replace(/^\[|\]$/g, "");
if (isIP(bare)) {
if (isPrivateAddress(bare)) throw new Error(`Refusing to fetch non-public address "${bare}".`);
return;
}
let addresses: Array<{ address: string }>;
try {
addresses = await lookup(bare, { all: true });
} catch {
throw new Error(`Could not resolve host "${bare}".`);
}
for (const { address } of addresses) {
if (isPrivateAddress(address)) throw new Error(`Refusing to fetch "${bare}" — it resolves to the non-public address ${address}.`);
}
}
function isPrivateAddress(ip: string): boolean {
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
if (mapped) return isPrivateAddress(mapped[1]);
return lower === "::" || lower === "::1" || lower.startsWith("fc") || lower.startsWith("fd") || lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb");
}
const octets = ip.split(".").map(Number);
if (octets.length !== 4 || octets.some((octet) => Number.isNaN(octet))) return true;
const [a, b] = octets;
return (
a === 0 || a === 10 || a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) ||
a >= 224
);
}
async function readBodyCapped(response: Response): Promise<string> {
const reader = response.body?.getReader();
if (!reader) return "";
const decoder = new TextDecoder("utf-8", { fatal: false });
let text = "";
let bytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
text += decoder.decode(value, { stream: true });
if (bytes >= MAX_RESPONSE_BYTES) {
await reader.cancel();
break;
}
}
return text + decoder.decode();
}
function looksLikeHtml(contentType: string, text: string): boolean {
if (contentType.includes("html")) return true;
return /<\s*(!doctype|html|body|div|p)\b/i.test(text.slice(0, 2000));
}
/** Dependency-free readable-text extraction: good enough to study a page, not a browser. */
export function htmlToText(html: string): string {
const withoutBlocks = html
.replace(/<script[\s\S]*?<\/script\s*>/gi, " ")
.replace(/<style[\s\S]*?<\/style\s*>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript\s*>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ");
const withBreaks = withoutBlocks
.replace(/<\/(p|div|li|h[1-6]|tr|section|article|blockquote|pre)\s*>/gi, "\n")
.replace(/<(br|hr)\s*\/?\s*>/gi, "\n");
const withoutTags = withBreaks.replace(/<[^>]+>/g, " ");
return decodeEntities(withoutTags)
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.filter((line, index, lines) => line.length > 0 || (index > 0 && lines[index - 1].length > 0))
.join("\n")
.trim();
}
function decodeEntities(text: string): string {
const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", mdash: "—", ndash: "–", hellip: "…", rsquo: "'", lsquo: "'", rdquo: '"', ldquo: '"' };
return text
.replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => safeCodePoint(parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec: string) => safeCodePoint(parseInt(dec, 10)))
.replace(/&([a-z]+);/gi, (match, name: string) => named[name.toLowerCase()] ?? match);
}
function safeCodePoint(code: number): string {
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "";
}
interface SearchResult {
title: string;
url: string;
snippet: string;
}
/**
* Parses DuckDuckGo's HTML endpoint. Result links carry class "result__a";
* their hrefs are usually /l/?uddg=<encoded-target> redirects, which are
* unwrapped to the real URL. Snippets carry class "result__snippet".
*/
export function parseSearchResults(html: string): SearchResult[] {
const results: SearchResult[] = [];
const anchorPattern = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
const snippetPattern = /class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/(a|td|div|span)>/gi;
const snippets: string[] = [];
for (let match = snippetPattern.exec(html); match; match = snippetPattern.exec(html)) {
snippets.push(htmlToText(match[1]));
}
for (let match = anchorPattern.exec(html); match; match = anchorPattern.exec(html)) {
const target = unwrapDuckDuckGoHref(decodeEntities(match[1]));
if (!target) continue;
results.push({
title: htmlToText(match[2]) || target,
url: target,
snippet: snippets[results.length] ?? "",
});
}
return results;
}
function unwrapDuckDuckGoHref(href: string): string | null {
const absolute = href.startsWith("//") ? `https:${href}` : href;
try {
const url = new URL(absolute, "https://duckduckgo.com");
const wrapped = url.searchParams.get("uddg");
if (wrapped) return decodeURIComponent(wrapped);
if (url.hostname.endsWith("duckduckgo.com") && url.pathname.startsWith("/l/")) return null;
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
+1 -1
View File
@@ -94,7 +94,7 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
`You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`,
roleVoice(input),
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone.`,
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
...(input.language === "zh"
? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"]
: []),
+110
View File
@@ -0,0 +1,110 @@
import { randomUUID } from "node:crypto";
import { describe, expect, test } from "vitest";
import { executeWebTool, htmlToText, isWebTool, parseSearchResults, WEB_TOOLS, type WebFetcher } from "../../src/main/services/agent-web-tools";
import type { ToolCall } from "../../src/main/services/provider-tool-turn";
function call(name: string, args: Record<string, unknown>): ToolCall {
return { id: `call_${randomUUID()}`, name, arguments: args };
}
function fetcherReturning(responses: Array<{ status?: number; headers?: Record<string, string>; body?: string }>): { fetcher: WebFetcher; requested: string[] } {
const requested: string[] = [];
const fetcher: WebFetcher = async (url) => {
requested.push(url);
const next = responses.shift() ?? { status: 404, body: "not found" };
return new Response(next.body ?? "", { status: next.status ?? 200, headers: next.headers ?? { "content-type": "text/html" } });
};
return { fetcher, requested };
}
const DDG_PAGE = `
<div class="result">
<a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fdocs&amp;rut=abc">Example <b>Docs</b></a>
<a class="result__snippet" href="#">The official documentation for Example.</a>
</div>
<div class="result">
<a rel="nofollow" class="result__a" href="https://other.example.org/page">Other page</a>
<a class="result__snippet" href="#">A second result snippet.</a>
</div>`;
describe("agent web tools", () => {
test("registers both tools and recognises them by name", () => {
expect(WEB_TOOLS.map((tool) => tool.name)).toEqual(["web_search", "fetch_url"]);
expect(isWebTool("web_search")).toBe(true);
expect(isWebTool("run_command")).toBe(false);
});
test("fetch_url refuses non-public hosts without ever connecting", async () => {
const { fetcher, requested } = fetcherReturning([]);
for (const url of [
"http://127.0.0.1/admin",
"http://10.0.0.8/x",
"http://192.168.1.1/router",
"http://169.254.169.254/latest/meta-data",
"http://172.20.3.4/internal",
"http://[::1]/x",
"file:///etc/passwd",
]) {
const output = await executeWebTool(call("fetch_url", { url }), fetcher);
expect(output, url).toContain("ERROR");
}
expect(requested).toEqual([]);
});
test("fetch_url extracts readable text from HTML", async () => {
const { fetcher } = fetcherReturning([{
body: "<html><head><style>.x{}</style><script>alert(1)</script></head><body><h1>Pricing &amp; Plans</h1><p>From $9/mo</p></body></html>",
}]);
const output = await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/pricing" }), fetcher);
expect(output).toContain("Pricing & Plans");
expect(output).toContain("From $9/mo");
expect(output).not.toContain("alert(1)");
expect(output).toContain("status 200");
});
test("fetch_url follows public redirects but refuses a hop into a private range", async () => {
const followed = fetcherReturning([
{ status: 302, headers: { location: "http://93.184.216.35/final" } },
{ body: "<p>landed</p>" },
]);
const output = await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/start" }), followed.fetcher);
expect(output).toContain("landed");
expect(followed.requested).toHaveLength(2);
const bounced = fetcherReturning([
{ status: 302, headers: { location: "http://169.254.169.254/creds" } },
]);
const blocked = await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/start" }), bounced.fetcher);
expect(blocked).toContain("ERROR");
expect(bounced.requested).toHaveLength(1);
});
test("fetch_url truncates oversized pages with an honest note", async () => {
const { fetcher } = fetcherReturning([{ body: `<p>${"word ".repeat(9000)}</p>` }]);
const output = await executeWebTool(call("fetch_url", { url: "http://93.184.216.34/big" }), fetcher);
expect(output).toContain("[truncated: showing the first");
});
test("web_search parses DuckDuckGo results and unwraps redirect hrefs", async () => {
const { fetcher } = fetcherReturning([{ body: DDG_PAGE }]);
const output = await executeWebTool(call("web_search", { query: "example docs" }), fetcher);
expect(output).toContain("Example Docs");
expect(output).toContain("https://example.com/docs");
expect(output).toContain("The official documentation for Example.");
expect(output).toContain("https://other.example.org/page");
});
test("web_search reports honestly when nothing parses", async () => {
const { fetcher } = fetcherReturning([{ body: "<html><body>blocked</body></html>" }]);
const output = await executeWebTool(call("web_search", { query: "anything" }), fetcher);
expect(output).toContain("ERROR");
expect(output).toContain("no parseable results");
});
test("htmlToText and parseSearchResults are exported pure helpers", () => {
expect(htmlToText("<p>a&nbsp;b</p><p>c &#169; d</p>")).toBe("a b\nc © d");
const results = parseSearchResults(DDG_PAGE);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({ title: "Example Docs", url: "https://example.com/docs" });
});
});