This commit is contained in:
+81
-9
@@ -5,6 +5,7 @@ import { logger } from '../../logger.js';
|
||||
import type { SearchFilterConfig } from '../../config.js';
|
||||
import { checkSSRF, ssrfSafeFetch } from './shared/ssrf.js';
|
||||
import { htmlToText } from './shared/html.js';
|
||||
import { extractReadableMarkdown, truncateForAgent } from './shared/readability.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
SNIFF_HEAD_BYTES,
|
||||
type BinaryVerdict,
|
||||
} from './binary-detect.js';
|
||||
import { saveSourceFile, appendSourceIndex } from './source-library.js';
|
||||
|
||||
const BINARY_CONTENT_TYPE_PREFIXES = [
|
||||
'application/pdf',
|
||||
@@ -29,6 +31,14 @@ const BINARY_CONTENT_TYPE_PREFIXES = [
|
||||
// WebFetch text body は最大 5MB で打ち切る(巨大 HTML による context 膨張防止)
|
||||
const MAX_WEBFETCH_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
// agent のコンテキストに返すクリーン Markdown の上限文字数。
|
||||
// 全文は source/ に保存されるので、ここは context 節約のための切り詰め。
|
||||
const MAX_AGENT_RETURN_CHARS = 10000;
|
||||
|
||||
// source/ に保存するクリーン Markdown の上限バイト数(病的に巨大なページ対策)。
|
||||
// 5MB body cap が入力を制限済みだが、Markdown 化後にも保険をかける。
|
||||
const MAX_SOURCE_MARKDOWN_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// --- ツール定義 ---
|
||||
|
||||
const WEBSEARCH_DEF: ToolDef = {
|
||||
@@ -67,13 +77,13 @@ const DOWNLOADFILE_DEF: ToolDef = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'DownloadFile',
|
||||
description: 'URL からファイル(PDF・画像・バイナリ等)をダウンロードし、workspace の input/ または output/ に保存する。WebFetch では取得できない PDF や画像ファイルに使う。詳細・命名規約は ReadToolDoc({ name: "DownloadFile" })。',
|
||||
description: 'URL からファイル(PDF・画像・バイナリ等)をダウンロードし、workspace の input/・output/・source/(調査資料をスペースのソースライブラリに蓄積)に保存する。詳細・命名規約は ReadToolDoc({ name: "DownloadFile" })。',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', description: 'ダウンロードする URL' },
|
||||
filename: { type: 'string', description: '保存するファイル名(省略時は URL から推定)' },
|
||||
section: { type: 'string', description: '保存先: "input" または "output" (デフォルト: "input")' },
|
||||
section: { type: 'string', description: '保存先: "input" / "output" / "source"(調査資料の蓄積用)(デフォルト: "input")' },
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
@@ -810,8 +820,8 @@ async function executeWebFetch(
|
||||
ctx: ToolContext,
|
||||
): Promise<ToolResult> {
|
||||
const rawUrl = input['url'] as string;
|
||||
// selector パラメータは受け取るが、cheerio なしでは CSS セレクト不可のため無視
|
||||
// (仕様上 selector は省略可能で、対応できない旨をログに残す)
|
||||
// selector 指定時は、linkedom + querySelector でそのサブツリーだけをクリーン抽出する。
|
||||
// 要素が見つからなければ全ページの readability 抽出にフォールバックする。
|
||||
const selector = input['selector'] as string | undefined;
|
||||
|
||||
const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 30;
|
||||
@@ -854,7 +864,7 @@ async function executeWebFetch(
|
||||
}
|
||||
|
||||
if (selector) {
|
||||
logger.debug(`[WebFetch] selector "${selector}" is specified but CSS selection is not supported without cheerio — fetching full page`);
|
||||
logger.debug(`[WebFetch] selector "${selector}" specified — will clean-extract that subtree (falls back to full page if not found)`);
|
||||
}
|
||||
|
||||
logger.debug(`[WebFetch] fetching ${rawUrl}`);
|
||||
@@ -932,9 +942,59 @@ async function executeWebFetch(
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const text =
|
||||
htmlToText(sniffed.text) +
|
||||
(sniffed.truncated ? '\n\n[truncated: body exceeded 5MB]' : '');
|
||||
// Readability ベースのクリーン抽出を試みる。記事本文だけを構造保持で
|
||||
// Markdown 化する。失敗(非記事・SPA・空)したら従来の素朴な htmlToText に
|
||||
// フォールバックして WebFetch を絶対に hard-fail させない。
|
||||
const bodyTruncatedNote = sniffed.truncated ? '\n\n[truncated: body exceeded 5MB]' : '';
|
||||
let extracted = extractReadableMarkdown(sniffed.text, {
|
||||
baseUrl: rawUrl,
|
||||
...(selector ? { selector } : {}),
|
||||
});
|
||||
// selector が見つからなかった場合は、ページ全体の readability 抽出を試す。
|
||||
if (!extracted && selector) {
|
||||
extracted = extractReadableMarkdown(sniffed.text, { baseUrl: rawUrl });
|
||||
}
|
||||
|
||||
let fullClean: string;
|
||||
let extractionTitle: string | undefined;
|
||||
if (extracted) {
|
||||
fullClean = extracted.markdown;
|
||||
extractionTitle = extracted.title;
|
||||
} else {
|
||||
// フォールバック: 旧 htmlToText(内部で 10k 切り詰め済み)
|
||||
logger.debug(`[WebFetch] readability extraction empty, falling back to naive htmlToText for ${rawUrl}`);
|
||||
fullClean = htmlToText(sniffed.text);
|
||||
}
|
||||
fullClean = (fullClean + bodyTruncatedNote).trim();
|
||||
|
||||
// source 保存用は全文だが、病的に巨大なページに備えて上限でガードする。
|
||||
// 5MB body cap が入力を既に制限しているが、Markdown 化後にも保険をかける。
|
||||
let sourceBody = fullClean;
|
||||
if (Buffer.byteLength(sourceBody, 'utf-8') > MAX_SOURCE_MARKDOWN_BYTES) {
|
||||
const sliced = sourceBody.slice(0, MAX_SOURCE_MARKDOWN_BYTES);
|
||||
sourceBody = sliced + '\n\n... (truncated: extracted markdown exceeded source size cap)';
|
||||
}
|
||||
|
||||
// スペース文脈 (ctx.spaceId != null) なら全文クリーン Markdown を source/ に蓄積する。
|
||||
// ephemeral / 非スペースタスク(spaceId undefined)は従来どおり保存しない。
|
||||
// 既存の logs/raw への保存挙動は別経路(raw-save.ts)なので変更しない。
|
||||
let savedSourceFile: string | null = null;
|
||||
if (ctx.spaceId != null && sourceBody.trim().length > 0) {
|
||||
try {
|
||||
savedSourceFile = saveSourceFile(ctx.workspacePath, {
|
||||
url: rawUrl,
|
||||
...(extractionTitle ? { title: extractionTitle } : {}),
|
||||
body: sourceBody,
|
||||
tool: 'WebFetch',
|
||||
ext: '.md',
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn(`[WebFetch] failed to persist source: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// agent のコンテキストに返すのは切り詰めた版。全文は source/ に保存済み。
|
||||
const text = truncateForAgent(fullClean, MAX_AGENT_RETURN_CHARS, savedSourceFile);
|
||||
|
||||
// vlmEnabled 時はファーストビューのスクショを並行取得して画像を添付する。
|
||||
// 失敗時は警告ログのみで WebFetch 自体は成功扱いとする。
|
||||
@@ -982,7 +1042,7 @@ async function executeDownloadFile(
|
||||
): Promise<ToolResult> {
|
||||
const rawUrl = input['url'] as string;
|
||||
const rawSection = typeof input['section'] === 'string' ? input['section'] : 'input';
|
||||
const section = rawSection === 'output' ? 'output' : 'input';
|
||||
const section = rawSection === 'output' || rawSection === 'source' ? rawSection : 'input';
|
||||
const timeoutSec = ctx.toolsConfig?.webfetchTimeout ?? 60;
|
||||
const allowedHosts = ctx.toolsConfig?.webfetchAllowedHosts ?? [];
|
||||
|
||||
@@ -1062,6 +1122,18 @@ async function executeDownloadFile(
|
||||
bytes: buf.length, outcome: 'success',
|
||||
});
|
||||
|
||||
// section='source' のとき出典メタを source/index.jsonl に追記する。
|
||||
// (ファイルはすでに source/ 配下に保存済みなので、ここでは index 行だけ追記)
|
||||
if (section === 'source') {
|
||||
appendSourceIndex(ctx.workspacePath, {
|
||||
file: filename,
|
||||
url: rawUrl,
|
||||
fetched_at: new Date().toISOString(),
|
||||
tool: 'DownloadFile',
|
||||
bytes: buf.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
output: `Downloaded ${buf.length} bytes from ${rawUrl} -> ${section}/${filename} (content-type: ${contentType || 'unknown'})`,
|
||||
isError: false,
|
||||
|
||||
Reference in New Issue
Block a user