diff --git a/.env.example b/.env.example index 9d74438d6..b5724e518 100644 --- a/.env.example +++ b/.env.example @@ -121,6 +121,9 @@ BYTEPLUS_VIDEO_MODEL=seedance-1-5-pro-251215 # Firecrawl web-browser integration — server-only; never VITE_ # Can also come from shell export (e.g. ~/.claude/secrets/search-apis.env) FIRECRAWL_API_KEY= +# Basic web_search / web_browser: empty uses Firecrawl when configured, otherwise +# free anonymous Parallel Search MCP. Override with parallel, firecrawl or disabled. +WEB_SEARCH_PROVIDER= # Social publishing (publish_to_social) via Upload-Post — server-only; never VITE_ # API key and profile from https://app.upload-post.com (connect your accounts to the profile) diff --git a/README.md b/README.md index 19395b48f..04df443e7 100644 --- a/README.md +++ b/README.md @@ -591,6 +591,26 @@ Third-party components and assets remain subject to their respective licenses. --- +## Web research in the built-in agent + +`web_search` and basic `web_browser` page reading work without a search account +or API key through [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp). +Search uses Fast mode and returns source URLs, titles and relevant excerpts. +Page reading returns markdown excerpts, rather than full-page HTML or screenshots. +Queries and requested URLs are sent to the selected provider. Anonymous access +has service quotas; provider errors are returned without switching providers. + +An existing `FIRECRAWL_API_KEY` in Settings or the environment keeps Firecrawl as +the default. Set `WEB_SEARCH_PROVIDER` in `.env.local` (then restart) to `parallel` +or `firecrawl` to choose explicitly, or `disabled` to disable basic search and +page reading. A tool call can select `provider: "parallel"` or `"firecrawl"` unless +the server setting is `disabled`. Firecrawl remains available for search locale +and time filters, screenshots, browser actions, structured extraction, full-page +formats, mapping, crawling and batch scraping; those features require its key. + +Related search and page-reading calls reuse an opaque conversation ID. Clearing +the chat starts a new ID; the project ID and chat text are never used as that ID. + ## Contributing 1. Create a branch from `main`. diff --git a/README_ZH.md b/README_ZH.md index 196c923b9..45ad3ca9c 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -580,6 +580,22 @@ OpenChatCut 采用 [GNU Affero General Public License v3.0 或更高版本](LICE --- +## 内置 Agent 的网页检索 + +`web_search` 和基础 `web_browser` 阅读可通过 +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) +直接使用,无需搜索服务账号或 API Key。搜索使用 Fast 模式,返回来源 URL、标题和相关摘录; +网页阅读返回 Markdown 摘录,不提供完整 HTML 或截图。查询和目标 URL 会发送给选定的服务商。 +匿名服务有配额限制,服务商报错时不会自动切换到其他服务商。 + +已在设置或环境中配置的 `FIRECRAWL_API_KEY` 会保留 Firecrawl 默认行为。 +可在 `.env.local` 中设置 `WEB_SEARCH_PROVIDER` 为 `parallel`、`firecrawl` 或 `disabled` +(修改后重启);`disabled` 会关闭基础搜索与网页阅读。 +工具调用也可通过 `provider: "parallel"` 或 `"firecrawl"` 显式选择,但不能绕过 `disabled`。 +搜索地区/语言/时间过滤、截图、浏览器动作、结构化提取、完整页面格式、站点映射、爬取和批量抓取 +仍需 Firecrawl Key。相关搜索与网页阅读共用不透明的会话 ID;清空聊天后生成新 ID, +不会使用工程 ID 或聊天文本作为会话 ID。 + ## 贡献 1. 从 `main` 创建分支。 diff --git a/assets/agent/openchatcut-tool-schemas.json b/assets/agent/openchatcut-tool-schemas.json index 57682d3d8..ccad37c01 100644 --- a/assets/agent/openchatcut-tool-schemas.json +++ b/assets/agent/openchatcut-tool-schemas.json @@ -3732,10 +3732,18 @@ }, { "name": "web_browser", - "description": "Scrape a single web page via Firecrawl (web_browser → Firecrawl /scrape). formats: markdown (default), html, rawHtml, images, links, branding, summary, screenshot, videos. branding = native brand kit (colors/fonts/logo); summary = native page summary. screenshot is auto-saved to media pool as screenshotAssetId when possible. For many known URLs use web_batch_scrape; site discovery use web_map; multi-page crawl use web_crawl; search use web_search.", + "description": "Read a web page: Parallel excerpts without a key, or Firecrawl when its key is configured. Parallel supports markdown excerpts and an optional query; advanced formats/actions require provider=firecrawl. formats: markdown (default), html, rawHtml, images, links, branding, summary, screenshot, videos. branding = native brand kit (colors/fonts/logo); summary = native page summary. screenshot is auto-saved to media pool as screenshotAssetId when possible. For many known URLs use web_batch_scrape; site discovery use web_map; multi-page crawl use web_crawl; search use web_search.", "input_schema": { "type": "object", "properties": { + "provider": { + "type": "string", + "enum": [ + "parallel", + "firecrawl" + ], + "description": "Override the server default. Firecrawl requires a configured key." + }, "url": { "type": "string", "description": "Page URL (http/https)." @@ -3780,7 +3788,7 @@ }, "query": { "type": "string", - "description": "NL structured-extraction prompt." + "description": "Focus for Parallel excerpts; structured-extraction prompt for Firecrawl." }, "schema": { "description": "JSON schema for structured extraction." @@ -3802,10 +3810,18 @@ }, { "name": "web_search", - "description": "Search the web via Firecrawl /search (official API). Returns titles, URLs, descriptions; by default also scrapes markdown for each hit (scrapeMarkdown=true). Use site: / filetype: operators in query when helpful. Prefer this to inventing URLs. Then web_browser a specific URL for deep scrape/screenshot.", + "description": "Search the web. Returns titles, URLs and source excerpts through free anonymous Parallel Search MCP. A configured Firecrawl key preserves Firecrawl search and optional scraped markdown. Use provider to override; country/lang/tbs filters require Firecrawl. Use site: / filetype: operators in query when helpful. Prefer this to inventing URLs. Then web_browser a specific URL for deep scrape/screenshot.", "input_schema": { "type": "object", "properties": { + "provider": { + "type": "string", + "enum": [ + "parallel", + "firecrawl" + ], + "description": "Override the server default. Firecrawl requires a configured key." + }, "query": { "type": "string", "description": "Search query (supports operators like site:example.com)." @@ -3828,7 +3844,7 @@ }, "scrapeMarkdown": { "type": "boolean", - "description": "If true (default), include markdown for each result via scrapeOptions." + "description": "Firecrawl only: include scraped markdown for each result (default true). Parallel returns excerpts." } }, "required": [ diff --git a/server/keystore-names.ts b/server/keystore-names.ts index 6e39550c0..fcad8b1b4 100644 --- a/server/keystore-names.ts +++ b/server/keystore-names.ts @@ -121,6 +121,7 @@ export const KEY_NAMES = [ "E2B_API_KEY", "E2B_TEMPLATE", "FIRECRAWL_API_KEY", + "WEB_SEARCH_PROVIDER", "UPLOAD_POST_API_KEY", "UPLOAD_POST_PROFILE", "UPLOAD_POST_BASE_URL", @@ -193,6 +194,7 @@ export type KeyName = (typeof KEY_NAMES)[number]; // not credentials). Deliberately a separate explicit list rather than derived from // KEY_NAMES: adding a key to the whitelist must never accidentally make it non-secret. export const NON_SECRET_NAMES: ReadonlySet = new Set([ + "WEB_SEARCH_PROVIDER", "AGENT_IMPORT_ROOTS", "PROXY_URL", "LLM_PROVIDER", diff --git a/server/keystore.verify.ts b/server/keystore.verify.ts index 1ade49f6d..3c9c1a398 100644 --- a/server/keystore.verify.ts +++ b/server/keystore.verify.ts @@ -169,6 +169,7 @@ const MODEL_ROUTING_NAMES = [ 'AGENT_IMPORT_ROOTS', // Agent local-path import whitelist (comma-separated absolute dirs) 'OPENCHATCUT_SKILLS_DIR', // User skill files directory (''=~/.openchatcut/skills) 'PROXY_URL', // Outbound network proxy (''=use HTTPS_PROXY/HTTP_PROXY env) + 'WEB_SEARCH_PROVIDER', // Basic web research routing ('' | parallel | firecrawl | disabled) ] as const; for (const name of MODEL_ROUTING_NAMES) { assert.ok((KEY_NAMES as readonly string[]).includes(name), `${name} is whitelisted (settable via POST /api/keys)`); @@ -191,8 +192,9 @@ assert.deepStrictEqual( ); // seed one SECRET + one non-secret on top of the state above (seeds accumulate in-process) -seedKeystore({ ...isolatedSeed, LLM_API_KEY: 'sec-x', MINIMAX_TTS_MODEL: 'speech-2.8-hd' } as Record); +seedKeystore({ ...isolatedSeed, LLM_API_KEY: 'sec-x', MINIMAX_TTS_MODEL: 'speech-2.8-hd', WEB_SEARCH_PROVIDER: 'disabled' } as Record); const st2 = keyStatus(); +assert.equal(st2.models['WEB_SEARCH_PROVIDER'], 'disabled', 'web routing/disable choice is echoed as non-secret config'); assert.equal(st2.models['MINIMAX_TTS_MODEL'], 'speech-2.8-hd', 'non-secret model value echoed in models'); assert.equal(st2.models['KLING_VIDEO_MODEL'], '', 'unset non-secret name echoes empty string'); assert.ok(!('LLM_API_KEY' in st2.models), 'SECRET key has no field in models at all'); diff --git a/server/plugins/firecrawl-shared.ts b/server/plugins/firecrawl-shared.ts index 747536faa..e207506d7 100644 --- a/server/plugins/firecrawl-shared.ts +++ b/server/plugins/firecrawl-shared.ts @@ -37,6 +37,8 @@ export const MAX_BODY = 2 * 1024 * 1024; export interface FirecrawlPluginOptions { apiKey: string; + /** Empty selects configured Firecrawl, otherwise anonymous Parallel. */ + searchProvider?: string; } export function readJsonBody(req: IncomingMessage): Promise { diff --git a/server/plugins/firecrawl.ts b/server/plugins/firecrawl.ts index 8638b0918..bc7c2a428 100644 --- a/server/plugins/firecrawl.ts +++ b/server/plugins/firecrawl.ts @@ -5,6 +5,7 @@ // already import from this module are re-exported below. import type { Plugin } from 'vite'; +import { parallelWebRequest } from './parallel-search.ts'; import type { FirecrawlPluginOptions } from './firecrawl-shared.ts'; import { notConfigured, readJsonBody, sendJson } from './firecrawl-shared.ts'; import { @@ -23,6 +24,31 @@ export function firecrawlPlugin(options: FirecrawlPluginOptions): Plugin { configureServer(server) { const log = (m: string) => server.config.logger.info(m); const key = options.apiKey; + const providerFor = (body: Record): string => { + if (options.searchProvider === 'disabled') throw new Error('Web search and page reading are disabled.'); + const provider = body.provider ?? options.searchProvider; + if (provider && provider !== 'parallel' && provider !== 'firecrawl') { + throw new Error('WEB_SEARCH_PROVIDER must be parallel, firecrawl or disabled.'); + } + return String(provider || (options.apiKey ? 'firecrawl' : 'parallel')); + }; + const parallel = async ( + res: import('node:http').ServerResponse, + operation: 'search' | 'fetch', + body: Record, + ) => { + const abort = new AbortController(); + const cancel = () => { if (!res.writableEnded) abort.abort(); }; + res.on('close', cancel); + const timeoutMs = operation === 'fetch' && typeof body.timeout === 'number' && Number.isFinite(body.timeout) + ? Math.max(1, Math.min(60_000, Math.round(body.timeout))) : 45_000; + const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(timeoutMs)]); + try { + sendJson(res, 200, await parallelWebRequest(operation, body, signal)); + } finally { + res.off('close', cancel); + } + }; server.middlewares.use('/api/web-browser', async (req, res) => { if (req.method !== 'POST') { @@ -30,9 +56,10 @@ export function firecrawlPlugin(options: FirecrawlPluginOptions): Plugin { return; } try { - if (notConfigured(res, key)) return; const body = (await readJsonBody(req)) as Record; - await handleScrape(key, body, res, log); + if (providerFor(body) === 'parallel') return await parallel(res, 'fetch', body); + if (notConfigured(res, options.apiKey)) return; + await handleScrape(options.apiKey, body, res, log); } catch (error) { const message = error instanceof Error ? error.message : String(error); server.config.logger.error(`[web-browser] ${message}`); @@ -46,9 +73,10 @@ export function firecrawlPlugin(options: FirecrawlPluginOptions): Plugin { return; } try { - if (notConfigured(res, key)) return; const body = (await readJsonBody(req)) as Record; - await handleSearch(key, body, res); + if (providerFor(body) === 'parallel') return await parallel(res, 'search', body); + if (notConfigured(res, options.apiKey)) return; + await handleSearch(options.apiKey, body, res); } catch (error) { const message = error instanceof Error ? error.message : String(error); server.config.logger.error(`[firecrawl/search] ${message}`); diff --git a/server/plugins/index.ts b/server/plugins/index.ts index 873c0e0f3..a82f932bb 100644 --- a/server/plugins/index.ts +++ b/server/plugins/index.ts @@ -354,6 +354,7 @@ export function serverPlugins(options: { projectStoreHttp?: boolean } = {}): Plu }, }), firecrawlPlugin({ + get searchProvider() { return getKey("WEB_SEARCH_PROVIDER"); }, get apiKey() { return getKey("FIRECRAWL_API_KEY"); }, diff --git a/server/plugins/parallel-search.ts b/server/plugins/parallel-search.ts new file mode 100644 index 000000000..548c3de7f --- /dev/null +++ b/server/plugins/parallel-search.ts @@ -0,0 +1,82 @@ +// Anonymous Search MCP adapter. Provider selection stays in the web middleware; +// an explicit provider failure is returned to the caller without a fallback. +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { fetchWithProxy, isHttpUrl, truncate } from './firecrawl-shared.ts'; + +const ENDPOINT = 'https://search.parallel.ai/mcp'; +const USER_AGENT = 'OpenChatCut (+https://github.com/0xsline/OpenChatCut)'; + +type Body = Record; + +export async function parallelWebRequest( + operation: 'search' | 'fetch', + body: Body, + signal: AbortSignal, +): Promise { + const query = typeof body.query === 'string' ? body.query.trim() : ''; + const url = typeof body.url === 'string' ? body.url.trim() : ''; + if (operation === 'search' && !query) throw new Error('query is required'); + if (operation === 'fetch' && !isHttpUrl(url)) throw new Error('url must be a valid http(s) URI'); + // Parallel returns relevant excerpts. Firecrawl keeps locale/time filters, + // screenshots, actions, structured extraction and complete-page formats. + const unsupported = operation === 'search' + ? ['country', 'lang', 'tbs'] + : ['country', 'waitFor', 'actions', 'execJs', 'schema', 'fullPage']; + if (unsupported.some((key) => body[key] != null && body[key] !== false && body[key] !== '') + || (operation === 'fetch' && (body.onlyMainContent === false + || (Array.isArray(body.formats) && body.formats.some((f) => f !== 'markdown'))))) { + throw new Error('These options require provider="firecrawl" and FIRECRAWL_API_KEY. Parallel supports search and page excerpts.'); + } + const args: Body = operation === 'search' + ? { objective: query, search_queries: [query] } + : { urls: [url], ...(query ? { objective: query.slice(0, 200) } : {}) }; + if (typeof body.sessionId === 'string' && /^[a-f0-9-]{36}$/.test(body.sessionId)) { + args.session_id = body.sessionId; + } + const client = new Client({ name: 'openchatcut', version: '0.2.17' }); + const transport = new StreamableHTTPClientTransport(new URL(ENDPOINT), { + fetch: (input, init) => { + const headers = new Headers(init?.headers); + headers.set('User-Agent', USER_AGENT); + return fetchWithProxy(input, { ...init, headers, + signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal }); + }, + }); + try { + await client.connect(transport, { signal, timeout: 60_000 }); + await client.listTools({}, { signal, timeout: 60_000 }); + const result = CallToolResultSchema.parse(await client.callTool({ + name: operation === 'search' ? 'web_search' : 'web_fetch', arguments: args, + }, CallToolResultSchema, { signal, timeout: 60_000 })); + const text = result.content + .filter((item) => item.type === 'text') + .map((item) => item.text).join('\n'); + if (result.isError) throw new Error(text || 'Parallel Search MCP request failed'); + const data = (result.structuredContent ?? JSON.parse(text)) as Body; + if (!Array.isArray(data.results)) throw new Error('Invalid Parallel Search MCP results'); + const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) + ? Math.max(1, Math.min(20, Math.round(body.limit))) : 5; + const results = data.results.slice(0, operation === 'search' ? limit : 1).map((value) => { + if (!value || typeof value !== 'object') throw new Error('Invalid Parallel Search MCP result'); + const item = value as Body; + if (typeof item.url !== 'string' || !isHttpUrl(item.url) || !Array.isArray(item.excerpts) + || item.excerpts.some((excerpt) => typeof excerpt !== 'string')) { + throw new Error('Invalid Parallel Search MCP result'); + } + const excerpts = (item.excerpts as string[]).map((excerpt) => truncate(excerpt, 12_000)); + return { url: item.url, title: item.title, description: excerpts.join('\n\n'), excerpts }; + }); + if (operation === 'search') { + return { configured: true, ok: true, provider: 'parallel', query, count: results.length, + results, warnings: data.warnings }; + } + if (!results.length) throw new Error(JSON.stringify(data.errors ?? 'Parallel could not read the page')); + return { configured: true, ok: true, provider: 'parallel', url: results[0]!.url, + metadata: { title: results[0]!.title }, markdown: results[0]!.description, + excerpts: results[0]!.excerpts, warnings: data.warnings }; + } finally { + await client.close(); + } +} diff --git a/src/agent/capabilities.ts b/src/agent/capabilities.ts index 2d4a660a0..d4203c8cc 100644 --- a/src/agent/capabilities.ts +++ b/src/agent/capabilities.ts @@ -172,7 +172,7 @@ const CAP_ROWS: { key: CapabilityKey; label: string; tool: string; fallback: str { key: 'stock', label: 'Stock-media search', tool: 'search_stock_media', fallback: 'use push_asset to import a known public URL directly' }, { key: 'transcription', label: 'Transcription/talking-head editing', tool: 'transcribe_track', fallback: 'word-level deletion, filler cleanup, and automatic captions are unavailable' }, { key: 'sandbox', label: 'Sandbox execution (ffmpeg/node/python)', tool: 'run_code', fallback: 'skip run_code steps; probe_media does not need the sandbox and stays available' }, - { key: 'web', label: 'Web extraction', tool: 'web_browser', fallback: 'ask the user to paste the page content' }, + { key: 'web', label: 'Advanced web extraction', tool: 'web_map/web_crawl/web_batch_scrape and Firecrawl page formats', fallback: 'use basic web_search/web_browser excerpts, or ask the user to paste the page content' }, ]; /** System-prompt section listing which key-gated tools are on/off (local editing — @@ -196,7 +196,14 @@ export function capabilitiesPrompt( + (off.length ? off.map((s) => ` - ${s}`).join('\n') : ' (none)') + '\nWhen an unavailable capability is needed, follow its fallback above or tell the user that the capability is not configured' + ' (guide them to Settings → the matching capability page to add a provider).' + + (liveModels?.WEB_SEARCH_PROVIDER === 'disabled' + ? '\nBasic web_search and web_browser page reading are disabled by the server. Do not call them.' + : liveModels?.WEB_SEARCH_PROVIDER === 'firecrawl' + ? '\nBasic web_search and web_browser explicitly use Firecrawl and require FIRECRAWL_API_KEY. Do not switch providers after an error.' + : liveModels?.WEB_SEARCH_PROVIDER === 'parallel' + ? '\nBasic web_search and web_browser page excerpts explicitly use anonymous Parallel Search MCP without a search key. Advanced page formats still require Firecrawl.' + : '\nBasic web_search and web_browser page excerpts use Firecrawl when its key is configured, otherwise anonymous Parallel Search MCP without a search key. Advanced page formats still require Firecrawl. Respect the server WEB_SEARCH_PROVIDER setting and any disabled response.') + '\nProvider choice: skill files only document per-provider usage details; the actual provider is decided by THIS list' + ' and its routing suffix (user default → single provider → ask once in manual mode → pick freely in auto mode).' - + ' Never use a provider that is not in the list above.'; + + ' Never use a provider that is not in the list above; basic web search/page providers follow the server setting described above.'; } diff --git a/src/agent/capabilities.verify.ts b/src/agent/capabilities.verify.ts index 0ba994938..0a9077c72 100644 --- a/src/agent/capabilities.verify.ts +++ b/src/agent/capabilities.verify.ts @@ -105,3 +105,13 @@ applyLiveKeyStatus({}); assert.ok(!capabilitiesPrompt({ ...ALL_OFF, video: true }).includes('OFox(model=ofox)'), 'unconfigured OFox stays hidden'); console.log('capabilities.verify: ok'); + +applyLiveModels({ WEB_SEARCH_PROVIDER: 'disabled' }); +assert.match(capabilitiesPrompt(ALL_OFF), /page reading are disabled by the server/); +applyLiveModels({}); +assert.match(capabilitiesPrompt(ALL_OFF), /without a search key/); +applyLiveModels({ WEB_SEARCH_PROVIDER: 'firecrawl' }); +assert.match(capabilitiesPrompt(ALL_OFF), /explicitly use Firecrawl and require FIRECRAWL_API_KEY/); +applyLiveModels({ WEB_SEARCH_PROVIDER: 'parallel' }); +assert.match(capabilitiesPrompt(ALL_OFF), /explicitly use anonymous Parallel/); +applyLiveModels({}); diff --git a/src/agent/codex/runtime.ts b/src/agent/codex/runtime.ts index 13ce5617d..d57cd16bf 100644 --- a/src/agent/codex/runtime.ts +++ b/src/agent/codex/runtime.ts @@ -218,7 +218,8 @@ export async function executeOpenChatCutTool( state.before = snapshotTimeline(execution.ctx.getState()); state.started = true; const result = await (execution.executeTool ?? executeEditorTool)( - schema.name, state.invocationArgs, execution.ctx, execution.toolCatalog, execution.harness, + schema.name, state.invocationArgs, execution.ctx, execution.toolCatalog, + { ...execution.harness, signal: execution.signal ?? execution.harness?.signal }, ); throwIfToolAborted(execution.signal, state); return await settleToolResult(schema, state.invocationArgs, result, execution, state); diff --git a/src/agent/harness-context.ts b/src/agent/harness-context.ts index f5964f961..9d76775ce 100644 --- a/src/agent/harness-context.ts +++ b/src/agent/harness-context.ts @@ -5,6 +5,7 @@ const MAX_LOAD_SKILL_RESULT_CHARS = 64_000; /** Ephemeral request context for low-level tools; never persisted in ProjectDoc. */ export interface HarnessToolExecutionContext { + readonly signal?: AbortSignal; readonly loadSkillResultBudgetChars?: number; } diff --git a/src/agent/tools/schemas/web-tools.ts b/src/agent/tools/schemas/web-tools.ts index 6ed6cb16d..2e2aea839 100644 --- a/src/agent/tools/schemas/web-tools.ts +++ b/src/agent/tools/schemas/web-tools.ts @@ -9,7 +9,8 @@ export const WEB_TOOL_SCHEMAS: AgentToolSchema[] = [ { name: 'web_browser', description: [ - 'Scrape a single web page via Firecrawl (web_browser → Firecrawl /scrape).', + 'Read a web page: Parallel excerpts without a key, or Firecrawl when its key is configured.', + 'Parallel supports markdown excerpts and an optional query; advanced formats/actions require provider=firecrawl.', 'formats: markdown (default), html, rawHtml, images, links, branding, summary, screenshot, videos.', 'branding = native brand kit (colors/fonts/logo); summary = native page summary.', 'screenshot is auto-saved to media pool as screenshotAssetId when possible.', @@ -18,6 +19,7 @@ export const WEB_TOOL_SCHEMAS: AgentToolSchema[] = [ input_schema: { type: 'object', properties: { + provider: { type: 'string', enum: ['parallel', 'firecrawl'], description: 'Override the server default. Firecrawl requires a configured key.' }, url: { type: 'string', description: 'Page URL (http/https).' }, formats: { type: 'array', @@ -29,7 +31,7 @@ export const WEB_TOOL_SCHEMAS: AgentToolSchema[] = [ waitFor: { type: 'number', description: 'ms wait before extract (0–10000).' }, timeout: { type: 'number', description: 'Timeout ms (max 60000).' }, country: { type: 'string', description: "Geo country code e.g. 'US'." }, - query: { type: 'string', description: 'NL structured-extraction prompt.' }, + query: { type: 'string', description: 'Focus for Parallel excerpts; structured-extraction prompt for Firecrawl.' }, schema: { description: 'JSON schema for structured extraction.' }, actions: { type: 'array', @@ -51,14 +53,16 @@ export const WEB_TOOL_SCHEMAS: AgentToolSchema[] = [ { name: 'web_search', description: [ - 'Search the web via Firecrawl /search (official API). Returns titles, URLs, descriptions;', - 'by default also scrapes markdown for each hit (scrapeMarkdown=true).', + 'Search the web. Returns titles, URLs and source excerpts through free anonymous Parallel Search MCP.', + 'A configured Firecrawl key preserves Firecrawl search and optional scraped markdown.', + 'Use provider to override; country/lang/tbs filters require Firecrawl.', 'Use site: / filetype: operators in query when helpful. Prefer this to inventing URLs.', 'Then web_browser a specific URL for deep scrape/screenshot.', ].join(' '), input_schema: { type: 'object', properties: { + provider: { type: 'string', enum: ['parallel', 'firecrawl'], description: 'Override the server default. Firecrawl requires a configured key.' }, query: { type: 'string', description: 'Search query (supports operators like site:example.com).' }, limit: { type: 'number', description: 'Max results 1–20 (default 5).' }, country: { type: 'string', description: "ISO country e.g. 'US' (default US on Firecrawl)." }, @@ -69,7 +73,7 @@ export const WEB_TOOL_SCHEMAS: AgentToolSchema[] = [ }, scrapeMarkdown: { type: 'boolean', - description: 'If true (default), include markdown for each result via scrapeOptions.', + description: 'Firecrawl only: include scraped markdown for each result (default true). Parallel returns excerpts.', }, }, required: ['query'], diff --git a/src/agent/tools/web-search-session.ts b/src/agent/tools/web-search-session.ts new file mode 100644 index 000000000..e6edd509b --- /dev/null +++ b/src/agent/tools/web-search-session.ts @@ -0,0 +1,29 @@ +import { currentAgentSessionGeneration } from '../../persist/agentSessionGeneration'; + +// The persisted chat generation changes when clearAgentSessionContext succeeds. +// Only the opaque UUID leaves this machine, never the project id or user text. +const pending = new Map>(); +const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; + +export async function webSearchSessionId(projectId: string | undefined): Promise { + // Standalone callers without a chat owner omit MCP's optional session argument. + if (!projectId) return undefined; + const generation = await currentAgentSessionGeneration(projectId); + const key = `cc.webSearchSession.${projectId}.${generation}`; + const existing = pending.get(key); + if (existing) return existing; + const create = async () => { + try { + const saved = localStorage.getItem(key); + if (saved && UUID.test(saved)) return saved; + } catch { /* Storage is optional; the in-memory conversation cache still works. */ } + const id = crypto.randomUUID(); + try { localStorage.setItem(key, id); } catch { /* Private browsing / storage full. */ } + return id; + }; + const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined; + const result = locks ? locks.request(key, create) : create(); + pending.set(key, result); + result.catch(() => { pending.delete(key); }); + return result; +} diff --git a/src/agent/tools/web-tools.ts b/src/agent/tools/web-tools.ts index ef7e5d07f..2e249a7e6 100644 --- a/src/agent/tools/web-tools.ts +++ b/src/agent/tools/web-tools.ts @@ -1,8 +1,12 @@ export { WEB_TOOL_SCHEMAS, WEB_TOOL_NAMES } from './schemas/web-tools'; import type { AgentContext } from '../context'; +import type { HarnessToolExecutionContext } from '../harness-context'; +import { webSearchSessionId } from './web-search-session'; import type { MediaAsset } from '../../editor/types'; -// Firecrawl tools for the editor agent: +// Web tools: Parallel search/page excerpts when unconfigured; saved Firecrawl keys +// retain Firecrawl behavior. Advanced scraping remains Firecrawl-only. +// Routes for the editor agent: // - web_browser → POST /api/web-browser (Firecrawl scrape) // - web_search → POST /api/firecrawl/search (official /v1/search) // - web_map → POST /api/firecrawl/map (official /v1/map) @@ -27,18 +31,19 @@ function uid(prefix: string): string { return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; } -async function postApi(path: string, body: Record): Promise> { +async function postApi(path: string, body: Record, signal?: AbortSignal): Promise> { let res: Response; try { res = await fetch(path, { method: 'POST', + signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } catch (e) { return { error: `request failed: ${e instanceof Error ? e.message : String(e)}`, - hint: 'Is the Vite dev server running with FIRECRAWL_API_KEY?', + hint: 'Check the local server, network connection and web provider settings.', }; } return (await res.json().catch(() => ({}))) as Record; @@ -56,7 +61,7 @@ function configError(data: Record): unknown | null { if (data.ok === false || data.error) { return { ok: false, - error: data.error ?? 'Firecrawl request failed', + error: data.error ?? 'Web provider request failed', status: data.status, crawlId: data.crawlId, }; @@ -69,16 +74,17 @@ export async function execWebTool( name: string, args: Args, ctx: AgentContext, + harness?: HarnessToolExecutionContext, ): Promise { - if (name === 'web_browser') return execScrape(args, ctx); - if (name === 'web_search') return execSearch(args); + if (name === 'web_browser') return execScrape(args, ctx, harness?.signal); + if (name === 'web_search') return execSearch(args, ctx, harness?.signal); if (name === 'web_map') return execMap(args); if (name === 'web_crawl') return execCrawl(args); if (name === 'web_batch_scrape') return execBatchScrape(args); return { error: `unknown tool ${name}` }; } -async function execScrape(args: Args, ctx: AgentContext): Promise { +async function execScrape(args: Args, ctx: AgentContext, signal?: AbortSignal): Promise { const url = String(args.url ?? '').trim(); if (!url || !isHttpUrl(url)) return { error: 'url must be a valid http(s) URI' }; @@ -98,7 +104,9 @@ async function execScrape(args: Args, ctx: AgentContext): Promise { if (Array.isArray(args.actions)) body.actions = args.actions.slice(0, 10); if (typeof args.execJs === 'string' && args.execJs.trim()) body.execJs = args.execJs; - const data = await postApi('/api/web-browser', body); + if (typeof args.provider === 'string') body.provider = args.provider; + body.sessionId = await webSearchSessionId(ctx.getProjectId?.()); + const data = await postApi('/api/web-browser', body, signal); const err = configError(data); if (err) return err; if (data.ok === false) { @@ -109,6 +117,9 @@ async function execScrape(args: Args, ctx: AgentContext): Promise { ok: true, url: data.url ?? url, metadata: data.metadata ?? null, + provider: data.provider, + excerpts: data.excerpts, + warnings: data.warnings, }; for (const key of [ 'markdown', 'html', 'rawHtml', 'links', 'images', 'videos', @@ -136,7 +147,7 @@ async function execScrape(args: Args, ctx: AgentContext): Promise { return out; } -async function execSearch(args: Args): Promise { +async function execSearch(args: Args, ctx: AgentContext, signal?: AbortSignal): Promise { const query = String(args.query ?? '').trim(); if (!query) return { error: 'query is required' }; const body: Record = { @@ -148,7 +159,9 @@ async function execSearch(args: Args): Promise { if (typeof args.lang === 'string') body.lang = args.lang; if (typeof args.tbs === 'string') body.tbs = args.tbs; - const data = await postApi('/api/firecrawl/search', body); + if (typeof args.provider === 'string') body.provider = args.provider; + body.sessionId = await webSearchSessionId(ctx.getProjectId?.()); + const data = await postApi('/api/firecrawl/search', body, signal); const err = configError(data); if (err) return err; if (data.ok === false) return { ok: false, error: data.error, status: data.status }; @@ -158,6 +171,8 @@ async function execSearch(args: Args): Promise { count: data.count, results: data.results, creditsUsed: data.creditsUsed, + provider: data.provider, + warnings: data.warnings, }; } diff --git a/src/agent/tools/web-tools.verify.ts b/src/agent/tools/web-tools.verify.ts index 33b8ee5cb..89f78f886 100644 --- a/src/agent/tools/web-tools.verify.ts +++ b/src/agent/tools/web-tools.verify.ts @@ -16,3 +16,201 @@ assert.deepEqual((byName.web_crawl!.input_schema as { required?: string[] }).req assert.deepEqual((byName.web_batch_scrape!.input_schema as { required?: string[] }).required, ['urls']); console.log('web-tools.check: OK', names.join(', ')); + +// Exercise the real validated dispatcher, local HTTP route and MCP SDK. The +// model itself is scripted here; every owned request and consumed result is real. +const { createServer } = await import('node:http'); +const { firecrawlPlugin } = await import('../../../server/plugins/firecrawl'); +const { executeOpenChatCutTool } = await import('../codex/runtime'); +const { DEFAULT_AGENT_SETTINGS } = await import('../settings/agentSettings'); +const { INITIAL } = await import('../../editor/initial'); +const { docFromTimeline } = await import('../../persist/projectStore'); +const { clearAgentSessionContext } = await import('../../persist/agentRuntimeStore'); +type Context = import('../context').AgentContext; +type Middleware = (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => Promise; +const routes = new Map(); +let apiKey = ''; +let provider = ''; +const plugin = firecrawlPlugin({ get apiKey() { return apiKey; }, get searchProvider() { return provider; } }); +const configure = plugin.configureServer as (server: unknown) => void; +configure({ config: { logger: { info() {}, error() {} } }, middlewares: { + use(path: string, handler: Middleware) { routes.set(path, handler); }, +} }); +const server = createServer((req, res) => { + const handler = routes.get(req.url!); + if (handler) void handler(req, res); + else { res.statusCode = 404; res.end(); } +}); +server.listen(0, '127.0.0.1'); +await new Promise((resolve) => server.once('listening', resolve)); +const address = server.address() as import('node:net').AddressInfo; +const origin = `http://127.0.0.1:${address.port}`; +const nativeFetch = globalThis.fetch; +const timeout = AbortSignal.timeout; +const memory = new Map(); +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { memory.set(key, value); }, +} }); +const outgoing: { url: string; headers: Headers; message: Record }[] = []; +let responseMode: 'ok' | 'quota' | 'error' | 'invalid' | 'wait' = 'ok'; +let firecrawlRequests = 0; +let observedAbort = false; +let callerSignal: AbortSignal | undefined; +globalThis.fetch = async (input, init) => { + const url = String(input); + if (url.startsWith('/api/')) { + assert.equal(init?.signal, callerSignal, 'forward caller cancellation without adding deadlines to legacy Firecrawl calls'); + return nativeFetch(origin + url, init); + } + if (url.startsWith('https://api.firecrawl.dev/')) { + firecrawlRequests += 1; + assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer saved-firecrawl-key'); + return Response.json({ data: [{ url: 'https://example.com/firecrawl', title: 'Configured Firecrawl', markdown: 'Saved provider content' }] }); + } + assert.equal(url, 'https://search.parallel.ai/mcp'); + const headers = new Headers(init?.headers); + assert.equal(headers.get('User-Agent'), 'OpenChatCut (+https://github.com/0xsline/OpenChatCut)'); + assert.equal(headers.get('Authorization'), null, 'no ambient credentials enter anonymous MCP'); + if (init?.method === 'GET') return new Response('', { status: 405 }); + const message = JSON.parse(String(init?.body ?? '{}')) as Record; + outgoing.push({ url, headers, message }); + if (message.id === undefined) return new Response(null, { status: 202 }); + let result: unknown; + if (message.method === 'initialize') { + result = { protocolVersion: '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: 'parallel-test', version: '1' } }; + } else if (message.method === 'tools/list') { + result = { tools: ['web_search', 'web_fetch'].map((name) => ({ name, inputSchema: { type: 'object' } })) }; + } else { + assert.equal(message.method, 'tools/call'); + if (responseMode === 'quota') return new Response('free anonymous quota exceeded', { status: 429, headers: { 'Retry-After': '60' } }); + if (responseMode === 'wait') { + return new Promise((_resolve, reject) => { + const cancel = () => { observedAbort = true; reject(init?.signal?.reason); }; + if (init?.signal?.aborted) cancel(); + else init?.signal?.addEventListener('abort', cancel, { once: true }); + }); + } + const data = { results: [{ url: 'https://example.com/source', title: 'Source title', excerpts: ['A useful source excerpt.'] }], errors: [] }; + result = responseMode === 'error' ? { isError: true, content: [{ type: 'text', text: 'service unavailable' }] } + : responseMode === 'invalid' ? { content: [{ type: 'text', text: 'broken JSON' }] } + : { content: [{ type: 'text', text: JSON.stringify(data) }], structuredContent: data }; + } + return Response.json({ jsonrpc: '2.0', id: message.id, result }); +}; +const context = (projectId: string): Context => ({ + commands: {} as Context['commands'], getState: () => INITIAL, + getDoc: () => docFromTimeline(INITIAL), getCreativeMode: () => null, + templates: [], audio: [], getProjectId: () => projectId, +}); +const a = context('web-research-a'); +const b = context('web-research-b'); +const calls = () => outgoing.filter((entry) => entry.message.method === 'tools/call') + .map((entry) => entry.message.params as { name: string; arguments: Record }); +async function invoke(name: string, args: Record, ctx = a, signal?: AbortSignal) { + const schema = WEB_TOOL_SCHEMAS.find((candidate) => candidate.name === name)!; + const events: import('../runtime').AgentEvent[] = []; + callerSignal = signal; + const execution = await executeOpenChatCutTool(schema, args, { + ctx, signal, settings: DEFAULT_AGENT_SETTINGS, onEvent: (event) => events.push(event), + }); + assert.ok(events.some((event) => event.type === 'tool'), 'result reaches the agent tool event consumer'); + return execution; +} +try { + const first = await invoke('web_search', { query: 'video script research', limit: 1 }); + assert.equal(first.success, true); + assert.match(JSON.stringify(first.result), /Source title.*useful source excerpt/); + assert.equal((first.result as { provider: string }).provider, 'parallel'); + const firstId = calls().at(-1)!.arguments.session_id; + assert.match(String(firstId), /^[a-f0-9-]{36}$/); + assert.equal(calls().at(-1)!.arguments.objective, 'video script research'); + assert.deepEqual(calls().at(-1)!.arguments.search_queries, ['video script research']); + const page = await invoke('web_browser', { url: 'https://example.com/source', query: 'video facts' }); + assert.equal(page.success, true); + assert.match(JSON.stringify(page.result), /useful source excerpt/); + assert.equal(calls().at(-1)!.name, 'web_fetch'); + assert.deepEqual(calls().at(-1)!.arguments.urls, ['https://example.com/source']); + assert.equal(calls().at(-1)!.arguments.session_id, firstId); + await invoke('web_search', { query: 'follow up' }); + assert.equal(calls().at(-1)!.arguments.session_id, firstId); + const beforeConcurrent = calls().length; + await Promise.all([ + invoke('web_search', { query: 'conversation A' }, a), + invoke('web_search', { query: 'conversation B' }, b), + invoke('web_browser', { url: 'https://example.com/source' }, b), + ]); + const concurrent = calls().slice(beforeConcurrent); + assert.equal(concurrent.find((call) => call.arguments.objective === 'conversation A')!.arguments.session_id, firstId); + const secondId = concurrent.find((call) => call.arguments.objective === 'conversation B')!.arguments.session_id; + assert.notEqual(secondId, firstId); + assert.equal(concurrent.find((call) => call.name === 'web_fetch')!.arguments.session_id, secondId); + responseMode = 'quota'; + assert.equal((await invoke('web_search', { query: 'retry boundary' })).success, false); + responseMode = 'ok'; + await invoke('web_search', { query: 'retry boundary' }); + assert.equal(calls().at(-1)!.arguments.session_id, firstId, 'quota and retry keep the conversation'); + await clearAgentSessionContext('web-research-a'); + await invoke('web_search', { query: 'new chat' }); + assert.notEqual(calls().at(-1)!.arguments.session_id, firstId, 'real chat clear rotates identity'); + const afterResetId = calls().at(-1)!.arguments.session_id; + const callsBeforeSettings = calls().length; + apiKey = 'saved-firecrawl-key'; + assert.equal((await invoke('web_search', { query: 'configured search' })).success, true); + assert.equal(firecrawlRequests, 1); + assert.equal(calls().length, callsBeforeSettings, 'saved Firecrawl key preserves provider choice'); + await invoke('web_search', { query: 'explicit parallel', provider: 'parallel' }); + assert.equal(calls().at(-1)!.arguments.session_id, afterResetId); + provider = 'parallel'; + await invoke('web_search', { query: 'saved Parallel overrides key' }); + assert.equal(calls().at(-1)!.arguments.objective, 'saved Parallel overrides key'); + provider = ''; + apiKey = ''; + assert.equal((await invoke('web_search', { query: 'explicit missing Firecrawl', provider: 'firecrawl' })).success, false); + assert.equal(calls().length, callsBeforeSettings + 2, 'no fallback after explicit provider failure'); + provider = 'firecrawl'; + assert.equal((await invoke('web_search', { query: 'saved choice without key' })).success, false); + provider = 'disabled'; + assert.equal((await invoke('web_search', { query: 'disabled', provider: 'parallel' })).success, false); + provider = 'invalid-provider'; + assert.equal((await invoke('web_search', { query: 'invalid setting' })).success, false); + provider = ''; + const beforeRejected = outgoing.length; + assert.equal((await invoke('web_browser', { url: 'file:///etc/passwd' })).success, false); + assert.equal((await invoke('web_browser', { url: 'https://example.com', formats: ['screenshot'] })).success, false); + assert.equal((await invoke('web_search', { query: 'filtered', country: 'US' })).success, false); + const denied = await executeOpenChatCutTool(byName.web_search!, { query: 'unavailable tool' }, { + ctx: a, settings: DEFAULT_AGENT_SETTINGS, activeToolCatalog: [], onEvent() {}, + }); + assert.equal(denied.success, false); + assert.equal(outgoing.length, beforeRejected, 'validation, unsupported options and permissions cause no outbound traffic'); + for (const mode of ['error', 'invalid'] as const) { + responseMode = mode; + assert.equal((await invoke('web_search', { query: mode })).success, false); + } + responseMode = 'wait'; + const abort = new AbortController(); + const pendingCall = invoke('web_search', { query: 'cancelled' }, a, abort.signal); + const cancelTimer = setTimeout(() => abort.abort(), 50); + assert.equal((await pendingCall).success, false); + clearTimeout(cancelTimer); + // Disconnection aborts the middleware's owned MCP request. + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(observedAbort, true); + observedAbort = false; + assert.equal((await invoke('web_browser', { url: 'https://example.com/source', timeout: 20 })).success, false, + 'page reading honors its requested deadline'); + assert.equal(observedAbort, true, 'the backend deadline aborts the owned MCP request'); + // Accelerate the configured 45-second deadline without changing the path. + AbortSignal.timeout = (ms) => timeout.call(AbortSignal, ms === 45_000 ? 20 : ms); + assert.equal((await invoke('web_search', { query: 'timed out' })).success, false); + assert.equal(firecrawlRequests, 1, 'errors, cancellation and timeouts never switch providers'); + assert.ok(outgoing.some((entry) => entry.message.method === 'initialize')); + assert.ok(outgoing.some((entry) => entry.message.method === 'tools/list')); + console.log('web-tools: validated dispatch -> HTTP -> MCP -> source results, provider choices, chat lifecycle, permissions and cancellation passed'); +} finally { + globalThis.fetch = nativeFetch; + AbortSignal.timeout = timeout; + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}