WebFetch 与 WebSearch
两个联网工具的实现:WebFetch 的 URL 规范化、出站护栏、重定向与各项上限、手写的 HTML 转 Markdown、交给当前模型提炼与 15 分钟缓存;预批准域名与审批;WebSearch 怎样借模型提供方的原生 web_search,哪些端点支持,结果与引用怎样整理。
联网工具只有两个,实现都在 apps/zcode-cli/packages/core/src/tool/handlers。WebFetch 由 webfetch.ts 和十个 webfetch-*.ts 分工完成:从 Agent 所在的机器发 GET,把页面转成 Markdown,再交给当前模型按 prompt 提炼出答案。WebSearch(websearch.ts、websearch-results.ts、websearch-support.ts)自己不碰网络,而是另发一次模型请求,让模型提供方在服务端执行 Anthropic 协议的原生 web_search 工具。
预批准域名表在 apps/zcode-cli/packages/core/src/tool/webfetch-preapproved.ts,输入输出契约在 apps/zcode-cli/packages/contracts/src/tools/webfetch.ts 与 apps/zcode-cli/packages/contracts/src/tools/websearch.ts。WebFetch 的请求经 HttpClientPort 交给 apps/zcode-cli/packages/adapters/src/http,代理、证书与出口网络的统一处理见执行边界:子进程、环境与网络;工具怎样被调度、审批,见执行器:调度、审批、超时与结果与权限模式与规则。
怎么用
WebFetch | WebSearch | |
|---|---|---|
| 参数 | url、prompt | query(至少 2 个字符),allowed_domains 与 blocked_domains 二选一 |
| 何时可见 | 始终 | 当前模型的 supportsNativeWebSearch 为真时 |
| 给模型的结果 | 当前模型按 prompt 写出的回答 | 摘要加至多 20 条链接 |
| build 模式 | 询问;预批准 URL 免询问 | 不询问 |
| 超时 | 60 秒 | 60 秒 |
| 缓存 | 按 URL 缓存 15 分钟 | 无 |
出处:apps/zcode-cli/packages/contracts/src/tools/webfetch.ts:13、apps/zcode-cli/packages/contracts/src/tools/websearch.ts:12、apps/zcode-cli/packages/core/src/tool/handlers/webfetch.ts:195、apps/zcode-cli/packages/core/src/tool/handlers/websearch.ts:129、apps/zcode-cli/packages/core/src/runtime/methods/config.ts:141。另外几件用户能感知的事:
- WebFetch 的描述除了一句总述,只有三条提示:私有 URL 会失败,HTTP 升级为 HTTPS 且跨主机重定向要自己再调一次,结果按 URL 缓存 15 分钟(
handlers/webfetch.ts:39)。 - 桌面端审批框的摘要取 URL 而不是
prompt,注释说显示prompt会遮住真正需要确认的目标地址(packages/ui/src/ToolCallBlocks/renderers/search.tsx:69)。 - HTTP 客户端按配置里的
network.httpProxy、network.noProxy、network.caCertFile创建(apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:409)。每个请求都显式带 60 秒超时,所以network.timeout管不到 WebFetch。 - 自己配置的模型可以在模型设置里打开“原生联网搜索”,WebSearch 才会出现,但只有 Anthropic Messages 协议的模型真能用上,见下文(
packages/ui/src/i18n/locales/zh-CN.ts:2866,可编辑字段见packages/provider/src/config/manual-model-config.ts:14)。
WebFetch:一次调用经过什么
URL。normalizeWebFetchUrl(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-url.ts:10)依次检查:长度不超过 2000 字符(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-constants.ts:3)、能被 URL 解析、协议只能是 http 或 https、不许带用户名密码;然后把 http 改成 https(webfetch-url.ts:39)。主机名的形态检查在 webfetch-url.ts:103:空主机名、localhost、.localhost 与 .local 结尾、没有点的单段主机名(如 intranet)都拒绝;IP 字面量在这一层放过,留给下面的出站检查。
重定向。请求以 redirect: "manual" 发出(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-network.ts:77),遇到 301、302、303、307、308 由 isPermittedRedirect 判断能否自动跟随(webfetch-url.ts:63):
export function isPermittedRedirect(from: URL, to: URL): boolean {
if (to.username || to.password) {
return false;
}
if (!isPublicHost(to)) {
return false;
}
if (from.protocol !== to.protocol || effectivePort(from) !== effectivePort(to)) {
return false;
}
if (!sameHostModuloWww(from.hostname, to.hostname)) {
return false;
}
return true;
}只有协议、端口相同,主机名去掉开头的 www. 后也相同,才自动跟随,最多 10 次,超过报 TooManyRedirects(webfetch-constants.ts:9、webfetch-network.ts:151)。其余重定向不跟,而是返回一段 “REDIRECT DETECTED” 文本,列出原 URL、目标 URL 和状态码,请模型用同一个 prompt 再调一次(handlers/webfetch.ts:128)。新的调用是一次新的工具调用,会重新过权限检查。缺少 Location 的 3xx 按 HTTP 错误处理(webfetch-network.ts:188)。
出站护栏
每次真正发 GET 之前,包括每一跳重定向,都要过 assertWebFetchLiteralEgress(webfetch-network.ts:52,实现在 apps/zcode-cli/packages/core/src/tool/handlers/webfetch-egress-guard.ts:14):
export function assertWebFetchLiteralEgress(url: URL): void {
const hostname = normalizeHostname(url.hostname);
if (isLocalHostname(hostname)) {
throw webFetchError("EgressBlocked", "WebFetch cannot access private or local hostnames", {
hostname,
url: url.toString(),
});
}
// DNS preflight 在部分网络下 1s 内无法完成,会让公网 URL 在真实 fetch 前失败。
// 当前只保留 URL 字面量层面的本地/私网目标阻断,不对普通域名做本地 DNS 解析。
if (!isIpLiteral(hostname)) return;
assertPublicIpAddress(hostname, { hostname, url });
}- 主机名:
localhost与.localhost结尾的一律拦下。 - IP 字面量:用 ipaddr.js 分类,只有归为
unicast的地址才放行,回环、私网、链路本地(169.254.0.0/16,云主机元数据地址 169.254.169.254 就在其中)都不是;另外显式排除 198.18.0.0/15 基准测试网段和五个特殊用途的 IPv6 前缀(webfetch-egress-guard.ts:4、:89、:94)。 - IPv6 里藏着的 IPv4:IPv4 映射地址和 NAT64 前缀
64:ff9b::/96先还原出低 32 位,再按 IPv4 规则判(webfetch-egress-guard.ts:77)。 - 出口代理:响应头带
x-proxy-error: blocked-by-allowlist时,按出口白名单拦截处理,返回一段 JSON 错误(webfetch-network.ts:233)。
注释写得明白:普通域名不做 DNS 解析。一个解析到 10.x 或 169.254.169.254 的域名,或者做 DNS 重绑定的域名,都能通过这道检查。HTTP adapter 里其实有一套按 DNS 结果拦截私网地址的策略,只在请求带 egressPolicy: "public" 时启用(apps/zcode-cli/packages/adapters/src/http/index.ts:70、apps/zcode-cli/packages/adapters/src/http/public-egress-policy.ts:79),但 WebFetch 发请求时没有带这个字段(webfetch-network.ts:70),仓库里也没有别处设置它。webfetch-network.ts:252 为这种拦截准备的“不把解析出的内网 IP 透露给模型”的文案改写,目前走不到。
超时、大小与内容类型
- 时间:每个 GET 60 秒,整个工具调用也是 60 秒且不许调用方改(
webfetch-constants.ts:2、webfetch-network.ts:75、handlers/webfetch.ts:239)。工具内部的模型请求在进程级准入闸门前排队时,工具的 deadline 会暂停计时(apps/zcode-cli/packages/core/src/tool/executor/timeout.ts:13)。 - 大小:响应体不超过 10 MiB,先看
content-length,再在流式读取中累计(webfetch-constants.ts:4,apps/zcode-cli/packages/adapters/src/http/response-body.ts:18、:62)。 - 请求头:User-Agent 是
ZCode-WebFetch/0.1 (+https://zcode.ai; coding-agent-cli),Accept把text/markdown排在最前(webfetch-constants.ts:11、webfetch-network.ts:274)。 - 内容类型:只收
text/*、JSON、XML、JavaScript、+json、+xml以及缺省类型,其余报 “Unsupported WebFetch content type”,所以 PDF、图片链接抓不了(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-content.ts:8、:103)。正文一律按 UTF-8 解码,不看charset(webfetch-content.ts:14)。
HTML 转 Markdown 没有用任何库,是一串正则(webfetch-content.ts:59):删掉注释、script、style、noscript;h1 到 h6 换成对应层级的 #;链接换成 Markdown 链接;列表项换成 - ;br 与段落、表格行等块级结束标签换成换行;其余标签全部剥掉,只解码少数几个实体。最后每一行的连续空白压成一个空格并去掉首尾空白,所以 pre 里代码的缩进也会被压平。
转换后的文本超过 100000 字节时,全文写成会话级的工具结果附件(webfetch-content.ts:21),路径只出现在结构化输出里,模型看到的仍是提炼后的答案(handlers/webfetch.ts:95、:257)。
交给当前模型提炼
描述里说用 “a small fast model” 回答 prompt(handlers/webfetch.ts:40),实际用的是 context.model(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-processing.ts:30),也就是执行器交给工具的本轮模型(apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:186,字段注释见 apps/zcode-cli/packages/core/src/runtime/methods/turn-loop-state.ts:95),代码里没有另配小模型。它取最低一档的推理强度,输出不超过 4096 token,不带任何工具(webfetch-processing.ts:67,apps/zcode-cli/packages/core/src/model/auxiliary-model-options.ts:9)。正文先截到 100000 字符,结尾附一句截断说明(webfetch-content.ts:46)。提示词在 webfetch-processing.ts:106:
function buildProcessingPrompt(content: string, prompt: string, preapprovedUrl: boolean): string {
const instruction = preapprovedUrl
? "Provide a concise response based on the content above. Include relevant details, code examples, and documentation excerpts as needed."
: [
"Provide a concise response based only on the content above. In your response:",
" - Enforce a strict 125-character maximum for quotes from any source document. Open Source Software is ok as long as we respect the license.",
" - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.",
" - You are not a lawyer and never comment on the legality of your own prompts and responses.",
" - Never produce or reproduce exact song lyrics.",
].join("\n");
return `
Web page content:
---
${content}
---
${prompt}
${instruction}
`;
}非预批准的页面要求引文不超过 125 个字符、不逐字复述、不评论合法性、不复现歌词;预批准的文档站则鼓励给出细节、代码示例和文档摘录。模型返回空文本时,结果换成一句固定说明(webfetch-processing.ts:80)。
缓存是模块级的 Map(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-cache.ts:8),同一进程里的所有会话共用。键是模型传入的原始 URL 字符串(handlers/webfetch.ts:56),因此 http:// 与 https:// 两种写法各占一条,尽管实际请求相同。条目存活 15 分钟,总量不超过 50 MiB,命中时移到队尾,清理时先删过期再从最旧的删起(webfetch-constants.ts:7、:8,webfetch-cache.ts:24、:45)。只有成功抓到的正文进缓存,重定向与 HTTP 错误都不缓存(handlers/webfetch.ts:71)。缓存的是转换后的正文而不是答案:换一个 prompt 再问同一个 URL,不再联网,但仍会重新调一次模型,输出里 cacheHit 为真(handlers/webfetch.ts:79、:93)。
预批准域名
webfetch-preapproved.ts 里有 82 个整主机名和 4 个“主机加路径前缀”(apps/zcode-cli/packages/core/src/tool/webfetch-preapproved.ts:1、:86),全是公开的技术文档站:
| 类别 | 例子 |
|---|---|
| MCP 与技能 | modelcontextprotocol.io、agentskills.io |
| 语言与运行时 | docs.python.org、go.dev、doc.rust-lang.org、www.typescriptlang.org、nodejs.org |
| 前端 | react.dev、vuejs.org、nextjs.org、tailwindcss.com |
| 后端与数据 | docs.djangoproject.com、fastapi.tiangolo.com、pandas.pydata.org、pytorch.org |
| 数据库 | www.postgresql.org、redis.io、www.sqlite.org |
| 云与运维 | docs.aws.amazon.com、cloud.google.com、kubernetes.io、www.docker.com |
| 限定路径 | wordpress.org/documentation、huggingface.co/docs、www.kaggle.com/docs、vercel.com/docs |
主机名必须完全相等,子域名和父域名都不算。限定路径的四项要求路径正好是前缀或以“前缀加斜杠”开头,并且拒绝含 %2f、%5c、%2e(包括多重编码)的路径,防止用编码绕出前缀(webfetch-preapproved.ts:109)。
从代码看,预批准有两层作用:一是权限上免询问,在项目 deny、ask 规则与 plan 模式判断之后、按模式询问之前放行(apps/zcode-cli/packages/core/src/permission/service.ts:189);二是内容处理上更宽松,服务器返回 text/markdown 且不足 100000 字符时原文直接返回、不经过模型,否则用上面那段宽松的提示词(webfetch-processing.ts:95)。
审批
WebFetch 的元数据是只读、needsApproval: true、副作用范围 network(handlers/webfetch.ts:198);WebSearch 是只读、不需要审批(handlers/websearch.ts:139)。套进 checkPermission 的判定顺序(service.ts:97):
| 模式 | WebFetch | WebSearch |
|---|---|---|
| build | 预批准 URL 或 allow 规则命中则放行,否则询问 | 放行 |
| edit | 同 build | 放行 |
| plan | 按只读工具放行(service.ts:412) | 放行 |
| yolo | 放行(service.ts:136) | 放行 |
| auto | 拒绝,模式未实现(service.ts:140) | 拒绝 |
build 模式下 WebFetch 落到 “Tool has side effects and requires approval” 这一支(service.ts:505),WebSearch 走的是只读直通(service.ts:460)。规则匹配时,WebFetch 的比对对象是 domain:<主机名>(apps/zcode-cli/packages/core/src/permission/rule-matching.ts:9、service.ts:288)。而审批时默认给出的“始终允许”建议,是把输入里的完整 url 当作规则内容(apps/zcode-cli/packages/core/src/tool/executor/permission-suggestions.ts:4、:39);从代码看,这样的规则与 domain: 主体对不上,以后的请求仍会询问,本书没有在运行中验证。
WebSearch:借提供方的原生搜索
WebSearch 只有在当前模型 supportsNativeWebSearch 为真时才出现在工具清单里(config.ts:268),handler 执行时再查一遍(handlers/websearch.ts:71)。它另起一次请求(handlers/websearch.ts:82):
const request: Parameters<typeof model.streamText>[0] = {
messages: [
{
role: "system",
content: "You are an assistant for performing a web search tool use.",
},
{
role: "user",
content: `Perform a web search for the query: ${input.query}`,
},
],
tools: [createProviderNativeWebSearchContract(input)],
// BigModel 的 Anthropic 兼容端点会拒绝 named forced web_search tool_choice(1210)。
// 这里保持自动选择,依靠单工具请求和 prompt 触发 provider-native 搜索。
options: {
...auxiliaryModelOptions(model),
maxOutputTokens: Math.min(4096, model.optionSpecs.maxOutputTokens.max),
},
abortSignal: context.abortSignal,
};请求里唯一的工具是 provider-native 的 web_search(handlers/websearch.ts:156)。之所以走流式,注释说 BigModel 的 Anthropic 兼容端点在非流式 JSON 里会把内部搜索结果返回成 assistant 一侧的裸 tool_result,AI SDK 校验时会报错(handlers/websearch.ts:103)。适配层只会为 Anthropic Messages 协议编码这个工具,映射成 anthropic.tools.webSearch_20260209,其他 API 形态直接报错(apps/zcode-cli/packages/adapters/src/model/tool-transform.ts:259、:275)。maxUses 默认 8、上限 8,运行时接受,但不在给模型的 JSON Schema 里(apps/zcode-cli/packages/contracts/src/tools/websearch.ts:9、:26、:45)。
内置规则里把 supportsNativeWebSearch 设为真的端点:
| 端点 | 出处 |
|---|---|
Anthropic 官方 api.anthropic.com/v1 上的 claude-* | config/provider/zcode-builtin.json:4052 |
DeepSeek 的 /anthropic 端点 | zcode-builtin.json:4072 |
Z.ai 的 api.z.ai/api/anthropic | zcode-builtin.json:4095 |
智谱 BigModel 的 open.bigmodel.cn/api/anthropic | zcode-builtin.json:4106 |
ZCode Coding Plan 的 zcode-plan/anthropic | zcode-builtin.json:4117 |
闲时计划的 off-peak/anthropic 端点显式关掉(zcode-builtin.json:4128),其余模型的默认值为假(zcode-builtin.json:884)。账号与 Coding Plan 见账号、Coding Plan 与闲时计划,规则体系见Provider 规则、模型目录与选项映射。
结果与引用。收集流时只记文本、工具调用与结束事件(apps/zcode-cli/packages/core/src/tool/handlers/websearch.ts:179),流里的裸 tool_result 块又会被兼容层滤掉(apps/zcode-cli/packages/adapters/src/model/anthropic-stream-compat.ts:258),所以 results 通常为空,来源主要靠从摘要里抽取 Markdown 链接,注释也承认引用有时只出现在摘要里(apps/zcode-cli/packages/core/src/tool/handlers/websearch-results.ts:110)。给模型的内容是 “Web search results for query” 加摘要,再列至多 20 条链接,最后一句 “REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.”(websearch-results.ts:14、:42)。服务端实际搜了几次,取自用量里的 server_tool_use.web_search_requests(apps/zcode-cli/packages/adapters/src/model/runner-normalization.ts:21)。
工具描述每次读取时现算当前月份,并要求回答末尾附 “Sources:” 链接列表(handlers/websearch.ts:49、:136)。描述第一句写着 “US-only”,但从代码看,能不能搜只取决于模型配置,实际后端是各家提供方自己的搜索。
错误与给模型的提示
WebFetch 自己抛的错误都经 webFetchError 构造:类型是可恢复的 ToolExecutionFailed,上下文里带一个 webFetchCode,只有两类标记为可重试(apps/zcode-cli/packages/core/src/tool/handlers/webfetch-errors.ts:20、:24):
| 代码 | 触发 | 可重试 |
|---|---|---|
webfetch_invalid_url | URL 过长、无法解析、主机名不合法 | 否 |
webfetch_unsupported_protocol | 不是 http 或 https | 否 |
webfetch_credentials_in_url | URL 带用户名或密码 | 否 |
webfetch_unsafe_redirect | Location 不是合法 URL | 否 |
webfetch_egress_blocked | 本地主机名、非公网 IP 字面量,或出口代理拦截 | 否 |
webfetch_too_many_redirects | 同主机重定向超过 10 次 | 否 |
webfetch_response_too_large | 响应超过 10 MiB | 否 |
webfetch_fetch_failed | 网络错误、不支持的内容类型 | 是 |
webfetch_processing_failed | 模型提炼失败 | 是 |
映射表里还有 webfetch_missing_redirect_location,但没有抛出点。不支持的内容类型也归入可重试的 fetch_failed,重试并不会有别的结果。网络错误的文案会顺着 cause 链找到最底层的原因和错误码拼上去,免得只剩一句 “fetch failed”(webfetch-network.ts:281、:305)。
非 2xx 响应不算错误:工具返回一段以 “The server returned HTTP” 开头的说明,数字形式的 Retry-After 会附上,并提示需要认证的页面改用 gh 或带认证的 MCP 工具(handlers/webfetch.ts:161、webfetch-network.ts:220)。每次 GET 前后还会发一条 NetworkRequestStatus 会话事件,带上是否走代理、是否用了自定义 CA 等出口信息(webfetch-network.ts:58、:82,apps/zcode-cli/packages/adapters/src/http/index.ts:283)。
WebSearch 的失败要少得多:没有模型、模型不支持原生搜索分别报配置错误,后者可恢复(handlers/websearch.ts:64、:71);流里的错误原样抛出,非 Error 对象包成 “WebSearch stream failed”(handlers/websearch.ts:215)。
下一篇:Bash:解析、只读判定与后台任务——命令怎样被解析、哪些算只读、超时怎么定,以及长命令怎样转入后台。