Files
metona-ai-desktop/electron/harness/tools/built-in/web-fetch.ts
T
thzxx a7090214b1
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m20s
CI / 产物编译验证 (push) Successful in 10m5s
fix: v0.6.2 修复工具调用不稳定与会话停止 — 纯 tool_calls 轮丢失 assistant 消息导致 API 400
【根因(main.log 实证)】
19:04 / 19:05 / 19:06 三次会话终止均为同一报错:
  DeepSeek 400 "Messages with role 'tool' must be a response to a preceding
  message with 'tool_calls'"

缺陷链:engine 主循环仅在 step.thought 存在(该轮有文本或思考内容)时才
将 assistant 消息加入请求历史。当模型发起纯工具调用(零文本零思考 —
DeepSeek 高频行为)时:
  - assistant(tool_calls) 消息不进 messages
  - 但 tool 结果消息照常 push
  → 下一轮请求出现孤立 tool 消息 → 协议 400(不可重试)→ 会话 ERROR 终止
"不稳定" = 模型每轮是否附带文本是概率性行为:带文本正常,纯调用必崩。
DB 持久化侧同源缺陷(if (!step.thought) continue)导致这些步骤的
assistant 与 tool 结果全部不落库 — 重启后工具上下文丢失,模型重复调用。

【修复】
- engine.ts: 有 toolCalls 的轮次必 push assistant(content=null,C-6 规范)
- agent.ts: 持久化条件同步修复(无 thought 但有 toolCalls 的步骤落库)
- 回归测试: 纯 tool_calls 轮后第二次请求中 tool 消息前必须是带
  tool_calls 的 assistant(请求契约断言,engine-toolchain.test.ts)

【纵深防御 — 孤立 tool 消息过滤】
- openai-format.ts(DeepSeek/Agnes/MiMo/OpenAI 四家共享): 构建请求时
  按 tool_call_id 配对过滤孤立 tool 消息(任何来源的历史污染不再 400 死锁)
- anthropic.adapter.ts: tool_use/tool_result 同策略配对过滤
- 单测 ×6: 正常配对保留 / 孤立丢弃 / id 不匹配丢弃 / 多轮配对 /
  includeImages 原位转换 / 非 vision 静默丢弃

【多模态索引对齐收敛】
4 家 adapter 的 images 处理循环原按未过滤的 nonSystemMsgs[i-1] 对齐索引,
孤立 tool 过滤引入后会错位 — 统一收进 buildOpenAICompatibleMessages
(includeImages 参数,基于 sanitized 序列原位转换),4 家 adapter 删除
各自的索引对齐循环(DeepSeek vision 判断 / OpenAI 推理模型拒绝保留在 adapter)。

【终止原因可见化】
MAX_ITERATIONS / TIMEOUT 终止此前无任何提示(用户感知"会话直接停止")—
前端 DONE 事件非 completed 终止原因显示为 system 消息。

【v0.6.1 回归缓解】
web_fetch timeoutMs 120s → 240s:浏览器回退串行化后并发 3 个排队最坏
~127.5s,旧值让排队末位抓取被工具超时杀掉(表现为抓取不稳定)。

【验证】
lint 0/0;typecheck 双工程 0 错误;test:electron 259/259(+7);
electron-vite build 成功
2026-08-22 19:34:16 +08:00

298 lines
10 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,
fetchWithTimeout,
htmlToText,
isInterceptedPage,
readBodyWithLimit,
logTool,
} from './network-utils';
import { getBrowserManager } from './browser';
// ===== 跳过重试的状态码 =====
const SKIP_RETRY_STATUS = new Set([403, 429, 502, 503]);
// ===== 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'],
description:
'Content extraction mode: "text"=plain text (default), "html"=cleaned HTML with scripts/styles removed',
},
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';
if (!url || !/^https?:\/\//i.test(url)) {
return { url, content: '', success: false, error: 'URL must start with http:// or https://' };
}
// 先查缓存(HTTP 和浏览器阶段共享同一缓存)
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', `Cache hit: ${url}`);
return this.buildSuccess(url, cached, 'cache', maxChars);
}
logTool('web_fetch', `Fetching: ${url}`);
// ===== Phase 1: HTTP 抓取 =====
const phase1Result = await this.httpFetch(url, mobileUA, enableRetry);
if (phase1Result.success && !phase1Result.intercepted) {
// 根据 extract_mode 选择返回内容:'html' 模式返回清理后的 HTML'text' 模式返回纯文本
const phase1Content = extractMode === 'html' ? 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);
if (browserResult) {
return this.buildSuccess(url, browserResult, 'browser', maxChars);
}
}
// 写入缓存(仅缓存 text 模式的内容,html 模式不缓存以避免模式混淆)
if (extractMode === 'text') {
fetchCache.set(url, phase1Content);
}
return this.buildSuccess(url, phase1Content, 'http', maxChars, extractMode);
}
// ===== Phase 3: 浏览器回退 =====
logTool('web_fetch', `Phase 3: Falling back to browser (${phase1Result.reason})`);
const browserResult = await this.browserFetch(url);
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,
): Promise<{
success: boolean;
html: string;
text: string;
intercepted: boolean;
reason: string;
}> {
const maxRetries = enableRetry ? 3 : 1;
const backoffBase = 2_000;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const headers = buildAntiCrawlHeaders(url, attempt, mobileUA);
const response = await fetchWithTimeout(url, { headers, redirect: 'follow' }, 20_000);
// 跳过重试的状态码 → 直接进入浏览器回退
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,
);
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;
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);
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): Promise<string | null> {
// 查缓存
const cached = fetchCache.get(url);
if (cached) {
logTool('web_fetch', 'Browser cache hit');
return cached;
}
try {
// 崩溃修复: 走 manager.fetchPageText(内部串行化完整的 open→等待→evaluate 序列)。
// 原实现直接 open/evaluate 共享单例 —— web_search 并行抓取触发多个回退同时进入时,
// 后到者销毁前者的窗口(ERR_ABORTED ×3 = 应用崩溃 ×3,见 manager 注释)。
const text = await getBrowserManager().fetchPageText(url);
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(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',
): 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',
};
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}