Files
metona-ai-desktop/electron/harness/tools/built-in/web-fetch.ts
T
thzxx 5b9d4d19b3
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m16s
CI / 全量测试 (Electron ABI) (push) Failing after 6m4s
CI / 产物编译验证 (push) Successful in 11m1s
feat: v0.8.0 流语义补全 · 会话可靠 · 恢复力 — finish_reason 全链路贯通根治"思考中停止" · 2445 用例全量回归
P0 会话可靠性收口(根治"模型思考着会话就停止"):
- P0-1 finish_reason 全链路贯通:DONE 事件与 IterationStep 新增 finishReason,OpenAI 共享 SSE / Anthropic message_delta.stop_reason / Ollama done_reason 三路采集,TRACE 层弃用硬编码 'stop' 记录真值
- P0-2 空响应守卫 + 降级重试:零产出流→可重试错误走退避;思考耗尽输出预算(reasoning-only + length)→自动关闭思考降级重试一次;仍失败→OUTPUT_LENGTH_EXCEEDED 结构化错误 + 故障转移;附带根治 abort 恰逢零工具调用轮被 COMPLETED 抢占的真实缺陷
- P0-3 思考×能力×预算三对齐:DeepSeek/MiMo/Agnes/Ollama 四家 supportsThinking=false 强制不发思考参数;小输出预算告警;设置页联动提示
- P0-4 渲染层可见性:截断/空完成/友好错误三类提示,i18n 全部出层
- P0-5 回归四件套:reasoning-only 终止判定、集成级空闲超时、504 引擎重试归类、思考中 abort→USER_INTERRUPT、P4-2 强制收尾路径

FEAT-1:LLM 设置新增「最大输出上限」——Provider 支持矩阵显隐 + 模型上限钳制提示 + 超限保存警告 + llm.maxTokens 热生效

P1 修复面收口:
- 渲染层三缺陷根治:后台会话回放缓冲(2000 条/4MB 有界 + agent:getReplayState + 事件总线)+ abort 双层自愈 + sendMessage 收尾兜底 + 中断卡片清扫
- 工具 abort 信号全覆盖:web_search/web_fetch/http_request/code_search/git 系列/delegate_task 全部接入引擎中断;web_search 时间预算收敛(720s→≤240s);移除伪造 ToolExecutionContext 与死代码
- 安全:本地 Pinned CONNECT 代理根治浏览器通道 DNS rebinding(校验期 IP pinning,可注入 resolver 表测);配置 URL 域名解析深校验(DeepCheckSoftFailure 软失败);SSE 空 error 帧防御修复;Ollama generate/embed AbortSignal.any 合并
- 缺陷清单:UTF-16 BOM 读取、tmp 同毫秒碰撞(nanoid 后缀)、code_search JS 回退参数对称(case_sensitive/前后文独立)、list_directory include_node_modules、崩溃自愈退避(60s 窗 ≥3 次停 reload)、MemoryViewer/Sidebar i18n 收口

P2 能力演进:
- 会话回收站:SCHEMA_VERSION 3 + 迁移 10(deleted_at,存在性守卫),软删除/恢复/彻底删除/30 天自动清理(启动+24h),searchMessages 聚合剔除,Sidebar 回收站面板
- 会话回放播放器:sessions:listRecordings/readRecording(白名单+目录边界+20MB 上限),SessionReplayPlayer 时间轴/步进/变速,Trace 面板入口
- electron-updater 自动更新:双轨(手动 feed 比对保留),生产环境启动静默检查 + update:status 广播 + app:updateInstall + LogsSettings UpdatePanel + builder publish 配置
- @ 文件提及:workspace.listFiles/readFileClip(边界/512KB/NUL 拒绝/MEMORY.md 保护),ChatInput Fuse 联想+键盘导航+附件管线注入
- MCP Resources/Prompts 发现:可选能力 try/catch 降级,mcp:listServerContents,MCPSettings 展开视图
- 文档对齐:内部 API 标准 HTML(Adapter 清单补 MiMo/已实现注记/STREAM_RESET/DONE.finishReason/ repetition_truncation 映射);README v0.8.0 亮点表

P3 测试基建:
- 新增 4 个测试文件:engine-stream-contract(6)、engine-stream-reliability(4:集成空闲超时/504 重试/思考中 abort/P4-2 强制收尾)、thinking-capability-gate(7)、pinned-proxy(9,含深校验 5)、session-trash(5,DB 域)、use-agent-stream hook 级(5)、agent.test 回放缓冲(2)
- 契约更新:orchestrator 被中断 SubAgent success=false(abort 优先级修复语义)、SSE 空 error 帧、UTF-16 正常读取、DeepSeek 未配置思考显式 disabled、迁移矩阵 v2→3
- 弱断言根治:registry WEBP 单向断言、hooks-contracts 自比恒真、memory 空 token 补强

全量验证:typecheck 0 错误 / lint 0 问题 / 系统 Node 2144 通过(301 DB 用例按 ABI 跳过)/ Electron ABI 2445/2445 全量通过 0 跳过
2026-09-05 20:06:26 +08:00

441 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* web_fetch — 网页抓取工具
*
* 三阶段回退策略:
* Phase 1: HTTP 抓取(UA 轮换 + 反爬请求头 + 指数退避重试 + 拦截检测)
* Phase 2: 内容过短自动升级(< 200 字符 → 浏览器渲染)
* Phase 3: 浏览器回退(共享 BrowserWindowManager + JS 渲染 + 内容提取)
*
* 浏览器回退使用与 web_browser 相同的 BrowserWindowManager 单例,
* 避免创建多个独立浏览器窗口,支持窗口复用。
*
* @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
*/
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
import type { MetonaToolDef } from '../../../harness/types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
import {
fetchCache,
buildAntiCrawlHeaders,
htmlToText,
isInterceptedPage,
readBodyWithLimit,
logTool,
// v0.6.4 P4-4: 内置 HTML→Markdown 转换(extract_mode='markdown'
htmlToMarkdown,
} from './network-utils';
import { getBrowserManager } from './browser';
// v0.6.4 P2-2 根治安全不对称:web_fetch 此前完全没有 SSRF 校验(仅协议检查)且
// requiresPermission:false —— LLM 可直接抓取 http://127.0.0.1:<port>、
// http://169.254.169.254/latest/meta-data 等内网/云元数据地址,浏览器回退通道
// 同样可达内网。现复用共享 ssrf-guard 模块(与 http_request 同源同行为)。
// v0.7.3 P2-1 根治: 抓取层升级为 ssrfPinnedFetch —— 校验通过的 IP 集合 pin 到
// 连接层(undici connect.lookup),关闭校验-连接之间的 DNS rebinding 窗口;
// 重定向改为逐跳手动跟随,每一跳都先校验后连接(原 redirect:'follow' 下
// 中间跳转在"终态复检"之前已真实发出,可触达内网)。
import { validateSSRF } from './ssrf-guard';
import { resolveRedirectTarget, ssrfPinnedFetch } from './ssrf-dispatcher';
// ===== 跳过重试的状态码 =====
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
/** v0.7.3 P2-1: 单次抓取允许的最大重定向跳数(每跳均经校验 + pinning) */
const MAX_REDIRECT_HOPS = 5;
// ===== WebFetchTool =====
export class WebFetchTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'web_fetch',
description:
'Fetch a web page and convert to plain text. Uses a three-phase fallback strategy: HTTP fetch with anti-crawl headers → SPA auto-upgrade → browser rendering. Handles Cloudflare interception, JavaScript-rendered pages, and large files (10MB limit).',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Target URL (http/https only)' },
// H-3/H-4 修复: 补齐规范要求的 max_chars 和 extract_mode 参数
// @see docs/Agent网络工具通用设计-v2.md — 第 3 章 web_fetch 抓取设计
max_chars: {
type: 'number',
description: 'Maximum characters to return (default 50000, truncated with notice)',
},
extract_mode: {
type: 'string',
enum: ['text', 'html', 'markdown'],
description:
'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed, "markdown"=structured Markdown (headings/links/code/lists)',
},
mobile_ua: { type: 'boolean', description: 'Use mobile User-Agent (default false)' },
retry: {
type: 'boolean',
description: 'Enable retry with exponential backoff (default true)',
},
},
required: ['url'],
},
category: MetonaToolCategory.NETWORK,
riskLevel: MetonaRiskLevel.LOW,
requiresPermission: false,
// v0.6.2: 120s → 240s — v0.6.1 浏览器回退串行化后,web_search 并发 3 个回退
// 排队最坏 ~127.5s(每个 30s load + 2.5s 渲染 + 10s eval),旧值 120s 会让
// 排队末位的抓取在队列等待中被工具超时杀掉(表现为抓取不稳定)。
timeoutMs: 240_000,
};
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
const url = args.url as string;
const mobileUA = (args.mobile_ua as boolean) ?? false;
const enableRetry = (args.retry as boolean) ?? true;
// H-3/H-4 修复: 读取 max_chars 和 extract_mode 参数
const maxChars = (args.max_chars as number) ?? 50_000;
const extractMode = ((args.extract_mode as string) ?? 'text') as 'text' | 'html' | 'markdown';
if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
}
// v0.8.0 P1-2: abort 立即生效 —— 引擎中断(超时/用户停止)时不再继续后续阶段
if (context.signal?.aborted) {
return { url, content: '', success: false, error: 'Fetch aborted' };
}
// v0.6.4 P2-2: SSRF 校验 —— 覆盖 Phase1 HTTP 与 Phase3 浏览器两条通道的入口。
// 协议白名单 / 私有段 IP / 云元数据地址一律拒绝。
try {
await validateSSRF(url);
} catch (ssrfErr) {
logTool('web_fetch', `SSRF blocked: ${(ssrfErr as Error).message}`);
return { url, content: '', success: false, error: (ssrfErr as Error).message };
}
// v0.6.4 P2-2: 缓存键携带 extract_mode —— 原实现 html/text 共用同一 URL 键,
// 先请求 text 再请求 html 会命中 text 缓存,把纯文本冒充"清理后的 HTML"返回
const cacheKey = `${extractMode}:${url}`;
const cached = fetchCache.get(cacheKey);
if (cached) {
logTool('web_fetch', `Cache hit: ${url} [${extractMode}]`);
return this.buildSuccess(url, cached, 'cache', maxChars);
}
logTool('web_fetch', `Fetching: ${url}`);
// ===== Phase 1: HTTP 抓取 =====
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry, context.signal);
// v0.8.0 P1-2: HTTP 阶段被 abort —— 直接终止,不进浏览器回退
if (context.signal?.aborted) {
return { url, content: '', success: false, error: 'Fetch aborted' };
}
if (phase1Result.success && !phase1Result.intercepted) {
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
// v0.6.4 P4-4: markdown 模式在 Phase1 的清理后 HTML 上做结构化转换;
// 浏览器回退通道产出纯文本,降级为 text 语义(回退产物不做二次包装)。
const phase1Content =
extractMode === 'html'
? phase1Result.html
: extractMode === 'markdown'
? htmlToMarkdown(phase1Result.html)
: phase1Result.text;
// 内容过短检测 → Phase 2 升级(仅对 text 模式生效,html 模式不升级)
if (extractMode === 'text' && phase1Content.length < 200) {
logTool(
'web_fetch',
`Phase 2: Content too short (${phase1Content.length} chars), upgrading to browser`,
);
const browserResult = await this.browserFetch(url, context.signal);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
}
// 写入缓存(缓存键已含模式:text/markdown 可缓存,html 不缓存以避免模式混淆)
if (extractMode !== 'html') {
fetchCache.set(cacheKey, phase1Content);
}
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
}
// ===== Phase 3: 浏览器回退 =====
// v0.6.4 P2-2: SSRF 阻断的请求禁止进入浏览器回退(否则等于借 Chromium 绕过校验)
if (phase1Result.blocked) {
return {
url,
content: '',
success: false,
error: phase1Result.reason,
};
}
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url, context.signal);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
// 全部失败
return {
url,
content: '',
success: false,
error: `All phases failed. HTTP: ${phase1Result.reason}. Browser fallback also failed.`,
};
}
// ===== Phase 1: HTTP 抓取 =====
private async httpFetch(
url: string,
mobileUA: boolean,
enableRetry: boolean,
signal?: AbortSignal,
): Promise<{
success: boolean;
html: string;
text: string;
intercepted: boolean;
reason: string;
/** v0.6.4 P2-2: true=被 SSRF 防护阻断 —— execute() 必须立即失败返回,禁止进入浏览器回退 */
blocked?: boolean;
}> {
const maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
// ===== v0.7.3 P2-1 根治: 手动逐跳重定向 + 每跳 SSRF 校验 + DNS pinning =====
// 原 redirect:'follow' 下 undici 在内核自动跟跳:跳转目标仅在"终态复检"
// 时被校验,中间跳转的请求已经真实发出(可触达内网/元数据地址)。
// 现改为逐跳手动跟随:每跳由 ssrfPinnedFetch 发出(校验通过 IP pin 到
// 连接层),Location 目标显式校验通过后才允许下一跳;私有地址/非法
// 协议目标按 blocked 语义立即终止(禁止重试与浏览器回退)。
let currentUrl = url;
let response: Response | null = null;
let redirectBlocked: string | null = null;
for (let hop = 0; hop <= MAX_REDIRECT_HOPS; hop++) {
// v0.8.0 P1-2: 每跳透传引擎级 abort signal —— 用户中断时底层 fetch
// 立即取消(含重定向链中间跳)
response = await ssrfPinnedFetch(
currentUrl,
{ headers, redirect: 'manual' },
20_000,
signal,
);
const next = resolveRedirectTarget(response, currentUrl);
if (next === null) break; // 非重定向(或无/非法 Location)—— 当前响应即终态
if (hop === MAX_REDIRECT_HOPS) {
return {
success: false,
html: '',
text: '',
intercepted: false,
reason: `Too many redirects (>${MAX_REDIRECT_HOPS})`,
};
}
// 下一跳目标显式校验(ssrfPinnedFetch 内部还会再次校验+pinning
// 这里提前拦截以保证 blocked 语义:不重试、不进浏览器回退)
try {
await validateSSRF(next);
} catch (ssrfErr) {
redirectBlocked = `Redirect target blocked by SSRF guard: ${(ssrfErr as Error).message}`;
break;
}
currentUrl = next;
response = null; // 丢弃中间跳转响应,下一跳重新抓取
}
if (redirectBlocked) {
return {
success: false,
html: '',
text: '',
intercepted: false,
blocked: true,
reason: redirectBlocked,
};
}
if (!response) {
// 防御性:循环正常结束必然携带终态响应
return { success: false, html: '', text: '', intercepted: false, reason: 'No response' };
}
// 跳过重试的状态码 → 直接进入浏览器回退
if (SKIP_RETRY_STATUS.has(response.status)) {
return {
success: false,
html: '',
text: '',
intercepted: true,
reason: `HTTP ${response.status}`,
};
}
if (!response.ok) {
// 5xx 可重试
if (response.status >= 500 && attempt < maxRetries - 1) {
await this.sleep(
backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6,
signal,
);
continue;
}
return {
success: false,
html: '',
text: '',
intercepted: false,
reason: `HTTP ${response.status} ${response.statusText}`,
};
}
// 读取正文(10MB 限制)
const html = await readBodyWithLimit(response, 10 * 1024 * 1024);
// 拦截检测
if (isInterceptedPage(html)) {
return {
success: false,
html: '',
text: '',
intercepted: true,
reason: 'Intercepted page detected',
};
}
// HTML → 纯文本
const text = htmlToText(html);
// H-3/H-4 修复: 同时保留原始 HTML,供 extract_mode='html' 使用
return { success: true, html, text, intercepted: false, reason: '' };
} catch (err) {
const errorMsg = (err as Error).message;
// v0.8.0 P1-2: abort 语义 —— 不重试、不睡眠,直接终止本阶段
if (signal?.aborted || (err as Error & { name?: string }).name === 'AbortError') {
return {
success: false,
html: '',
text: '',
intercepted: false,
reason: 'Fetch aborted',
};
}
if (attempt < maxRetries - 1) {
logTool('web_fetch', `Attempt ${attempt + 1} failed: ${errorMsg}, retrying...`);
await this.sleep(
backoffBase * Math.pow(2, attempt) + Math.random() * backoffBase * 0.6,
signal,
);
continue;
}
return { success: false, html: '', text: '', intercepted: false, reason: errorMsg };
}
}
return {
success: false,
html: '',
text: '',
intercepted: false,
reason: 'All retries exhausted',
};
}
// ===== Phase 2/3: 浏览器回退(使用共享 BrowserWindowManager 单例) =====
private async browserFetch(url: string, signal?: AbortSignal): Promise<string | null> {
// 查缓存(浏览器阶段产出的是纯文本,与 text 模式同键)
const cached = fetchCache.get(`text:${url}`);
if (cached) {
logTool('web_fetch', 'Browser cache hit');
return cached;
}
try {
// v0.8.0 P1-2: 引擎级 abort 透传 —— 中断时 BrowserWindowManager 停止页面
// 加载并终止抓取序列(此前浏览器回退完全不受 abort 控制)
if (signal?.aborted) return null;
// 崩溃修复: 走 manager.fetchPageText(内部串行化完整的 open→等待→evaluate 序列)。
// 原实现直接 open/evaluate 共享单例 —— web_search 并行抓取触发多个回退同时进入时,
// 后到者销毁前者的窗口(ERR_ABORTED ×3 = 应用崩溃 ×3,见 manager 注释)。
const text = await getBrowserManager().fetchPageText(url, signal);
if (text && text.trim().length >= 80) {
// 拦截检测(浏览器渲染后仍可能是验证码挑战页)
if (isInterceptedPage(text)) {
logTool('web_fetch', `Browser fetch detected intercepted page: ${url}`);
return null;
}
// 内容大小限制(与 HTTP 阶段一致,防止超大页面耗尽上下文)
const MAX_BROWSER_TEXT = 500_000; // 500K chars
const safeText =
text.length > MAX_BROWSER_TEXT
? text.slice(0, MAX_BROWSER_TEXT) + '\n\n[... content truncated ...]'
: text;
// 写缓存
fetchCache.set(`text:${url}`, safeText);
logTool('web_fetch', `Browser fetch success: ${safeText.length} chars`);
return safeText;
}
return null;
} catch (err) {
logTool('web_fetch', `Browser fetch failed: ${(err as Error).message}`);
return null;
}
// 注意:不关闭窗口 — manager 是单例,窗口由 web_browser 或 cleanupBrowser 管理
}
// ===== 辅助方法 =====
private buildSuccess(
url: string,
text: string,
method: string,
maxChars?: number,
extractMode?: 'text' | 'html' | 'markdown',
): unknown {
// H-3/H-4 修复: 应用 max_chars 截断,防止过长内容消耗过多 token
let content = text;
let truncated = false;
if (maxChars !== undefined && maxChars > 0 && text.length > maxChars) {
content = text.slice(0, maxChars) + `\n\n[... content truncated at ${maxChars} chars ...]`;
truncated = true;
}
return {
url,
content,
success: true,
method,
length: content.length,
original_length: text.length,
truncated,
extract_mode: extractMode ?? 'text',
};
}
/** v0.8.0 P1-2: abort 感知的 sleep —— 中断期间不再等待退避间隔 */
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) return resolve();
const cleanup = (): void => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
};
const onAbort = (): void => {
cleanup();
resolve();
};
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
}