@epoch-agent/plugin-web
网页抓取与搜索插件。两个工具:web_fetch(总是有)和 web_search
(有可用后端才注册 —— 缺省状态下就有,见下)。
- ✅ 做:HTTP GET → HTML→Markdown/Text/HTML、SSRF 校验、逐跳重定向校验、大小限制; web 搜索(native / tavily / brave / searxng / keyless 五个后端)
- ❌ 不做:不执行 JS、不渲染页面、不带浏览器;不抓搜索引擎的 HTML 结果页 (违反 ToS、随时会碎、要处理验证码 —— 要搜就走后端,不走爬虫)
- 依赖:
@epoch-agent/protocol+@epoch-agent/infra,两个都是dependencies(本包没有peerDependencies),HTML 转换是手写的(无第三方库)
工具参数
{ "url": "https://example.com/doc", "format": "markdown", "timeout": 30 }
{ "query": "node 22 fetch keepalive", "maxResults": 5, "recencyDays": 30 }
两个工具的参数表在 docs/TOOLS.md —— 那份是工具清单 的唯一真源,这里只写这个包自己的实现取舍。
web_search:搜什么,不抓什么
只返回标题 / URL / 摘要,不返回正文。 一次搜索 5 条结果全抓正文轻松几万 token,
而其中多半是模型看一眼摘要就丢掉的。分工是:搜 → 模型挑 → web_fetch 抓那一条。
五个后端,按凭据自动挑
五档(native / tavily / brave / searxng / keyless)各要什么凭据见
docs/TOOLS.md。
不配 search.provider 时按那张表的顺序探测,第一个可用的赢。显式配了哪家就只认哪家
—— 缺 key 时报诊断而不是悄悄换一家:换一家意味着查询发去了另一家公司,
那是用户没同意的事。
一个后端都没有时 web_search 不注册,启动诊断给 skipped 并逐档列出缺什么。
注册一个一调就报错的工具比不注册更糟:模型看见工具表里有它就会反复试,每次烧一轮。
两档不用办账号的(2026-09-15)
上面那句「没有后端就不注册」原来的实际效果是大多数嵌入宿主的用户都没有搜索 —— 三家老后端全要办账号,而宿主多半不会去配。这一档漏配是静默的:模型不知道自己本该 有搜索,就改用浏览器去抓搜索引擎结果页(正是本包第一节说「不做」的那件事)。
native(search/native.ts)借provider.apiKey,让当前 LLM provider 自己的 服务端搜索去干活。首版认deepseek/anthropic(共用 Anthropic Messages 协议)。 它的结果项没有明文摘要(正文是encrypted_content),信息走SearchOutcome.summary。keyless(search/keyless.ts)打 Exa / Parallel 的匿名 MCP 端点, 带游标轮转摊平两家免费额度。查询词会发给第三方,所以它排在最后、且能整档关掉。
两个开关缺省开,只有显式写 search.native: false / search.keyless: false 才关
(实现是 === false ? 关 : 开,不是「真值才开」)。
为什么是这两家(2026-09-15 实探,不是照抄)
四家里只有 epoch 原来会「什么都没有就把工具拿掉」,所以选型时把同级项目的做法 和这几家的实际表现一起量了一遍:
| 结论 | 证据 |
|---|---|
| Exa / Parallel 公开 MCP 端点匿名可用 | 无 Authorization,tools/call 返回 200 |
| 直连可达,不需要代理 | 绕开本机代理重测:两域名 200 / 0.7s,结果一致 |
| Exa 中文覆盖不差、但质量差 | 中文查 8/8 是中文结果,其中 3 条品牌软文、2 条豆丁网 |
| Parallel 有语义漂移 | 英文查 10 条里 3 条是 Curtain Wall(建筑幕墙) |
| DeepSeek 服务端搜索更好(所以 native 排第一) | POST /anthropic/v1/messages + web_search_20250305:10 条全中文、0 条软文,还捞到了 Exa / Parallel 都没有的平台维度数据 |
横向对照:codex 用 OpenAI Responses 的服务端 web_search、gemini-cli 用 Gemini
grounding(两家都是 native 那一档),opencode 打 Exa / Parallel 公开端点(就是
keyless 这一档),hermes-agent 做了最全的探测级联 + keyless ring。
⚠️ native 排在 tavily 前面是有代价的决定:只设了
TAVILY_API_KEY却没写search.provider的人,从这一版起会改走 native,那把 key 变成静默失效的配置。 要钉住一家就显式写search.provider。
SearXNG 默认没开 JSON 输出,实例的
settings.yml里要有search: { formats: [html, json] }。没开时它返回 403,我们把这句话写进了错误里 —— 否则用户会以为是自己地址写错了。
搜索结果是风险最高的一种不可信内容
它和抓来的网页一样带 openWorldHint: true(见下一节),但风险更高一档:
网页要等 agent 主动去抓,而搜索结果是攻击者可以针对某个查询词主动投放的
(SEO 投毒 + 提示注入)。所以 core 的 system prompt
在不可信内容策略里单独点了它的名。
结果里的 URL 不自动跟随。模型要抓时走 web_fetch,SSRF 校验和 network
权限判定一道都不会因为「这是搜出来的」而放松。
失败(key 错 / 配额用尽 / 网络不通)返回一条说明原因的失败结果,不抛异常 —— 搜索挂了不该让整轮对话断在这儿。同一个查询在同一会话内缓存,省配额。
SSRF 防护是三层,不是一层
- 协议白名单——只放
http/https - 主机名与 IP 字面量黑名单——
localhost、云元数据域名、私网段 (10/8、127/8、169.254/16、172.16/12、192.168/16、CGNAT、组播与保留段,IPv6 同理) - DNS 解析后再校验一次——攻击者控制的域名可以解析到
127.0.0.1, 光看主机名字面量挡不住。这是最常见的绕过手法
外加逐跳重定向校验:不用 fetch 的 redirect: 'follow',而是手动跟随、每一跳
重新做完整校验(最多 5 跳)。默认跟随只校验第一个 URL,一个公网地址 302 到
169.254.169.254 就能直接读到云元数据。
其余限制:响应上限 5 MB,Cloudflare 挑战会重试一次。
回执语言:给模型读的一律英文
web_fetch / web_search 的工具回执——失败说明、被拦截的原因、provider 报错、
「一条都没搜到」这类说明——全部是英文,判据同
core 的 code-mode/notice.ts 文件头(2026-11-05
拍的口径:回执的读者是模型,跟工具 description 同一档,不跟界面语言走)。
所以这几处 t() 调用显式传了 'en',check:i18n 那边也不要指望它们跟着 zh 变。
反过来,这些不在这一档里:
- 抓回来的网页正文 / 搜索结果的标题、摘要——那是别人的数据,原样回给模型
unavailableReason()与selectSearchProvider()的detail——启动诊断, 用户读的,照旧跟着界面语言走native档的buildPrompt()——发给对端模型的指令,用户读的正是它的回答, 界面切成英文却用中文问它,答案多半也是中文
拦截原因那一格有个额外要求:URL 和判定原因都得留着。url-safety 只给原因
(带主机名 / 解析到的地址),被拒的那个完整 URL 由 web-fetch 补在前面 ——
少了 URL 模型不知道是第一跳还是第五跳被拒,少了原因它只能盲目换个地址重试。
三个 annotation 不只是元数据
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true }
openWorldHint: true 会让 core 的 tool-executor 把输出包进
<tool_output untrusted="true">。抓来的网页内容作者既不是用户也不是我们,必须标成
「数据」而不是「指令」再回灌上下文。
describeTarget: (args) => args.url 让审批缓存按 URL 记——不给它就只能退回参数 JSON,
「批准抓这个站」下次照样弹窗。
文件
| 文件 | 内容 |
|---|---|
index.ts | webPlugin + createWebSearchTool(条件注册) |
tools/web-fetch.ts | 抓取 + 重定向 + 大小限制 + 重试 |
tools/web-search.ts | 搜索工具:参数钳制 + 会话内缓存 + 失败不炸轮 |
search/provider.ts | 后端接口 + 共用的 HTTP / 错误分类 |
search/registry.ts | 按配置 + 凭据挑后端(挑不到就不注册) |
search/{tavily,brave,searxng}.ts | 三个要办账号的后端各自的请求 / 响应翻译 |
search/native.ts | 借当前 LLM provider 的服务端搜索 |
search/keyless.ts | Exa / Parallel 免费端点环 + 游标轮转 |
search/mcp.ts | MCP tools/call(纯 JSON 和 SSE 两种回法) |
utils/url-safety.ts | 三层 SSRF 校验 |
utils/html-to-markdown.ts | 手写 HTML→Markdown / →Text |
开发
pnpm --filter @epoch-agent/plugin-web test
四个用例文件:ssrf.test.ts、url-safety.test.ts、html-to-markdown.test.ts、
web-search.test.ts。改 SSRF 判定必须先看前两个——里面记的是具体的绕过手法,
不是凑数的。搜索那份全部用假 fetch:真打网络的用例要么依赖别人的 API key、
要么在墙内直接红,两种都不该进 pnpm check。