feat(tools): extend cancellation to browser, web, and process tools

This commit is contained in:
William Valentin
2026-02-15 22:12:03 -08:00
parent 7877a1bcc9
commit b4006e91ff
10 changed files with 228 additions and 32 deletions
@@ -16,7 +16,7 @@ Scope: Production-risk-first audit of bugs, code improvements, and feature oppor
- ✅ F-008 addressed: WhatsApp Chromium launch is now sandboxed by default; no-sandbox mode is behind explicit `whatsapp.no_sandbox: true` opt-in. - ✅ F-008 addressed: WhatsApp Chromium launch is now sandboxed by default; no-sandbox mode is behind explicit `whatsapp.no_sandbox: true` opt-in.
- ✅ F-014 addressed: `ModelRouter.setOnTierChange` now preserves existing listeners instead of replacing them, removing destructive listener-setter behavior. - ✅ F-014 addressed: `ModelRouter.setOnTierChange` now preserves existing listeners instead of replacing them, removing destructive listener-setter behavior.
- ✅ F-002 addressed: `config.patch` now supports durable persistence via atomic write + backup when daemon has a concrete config path, and response includes `persisted`/`persistError` so UI can distinguish runtime-only vs disk-persisted updates. - ✅ F-002 addressed: `config.patch` now supports durable persistence via atomic write + backup when daemon has a concrete config path, and response includes `persisted`/`persistError` so UI can distinguish runtime-only vs disk-persisted updates.
- ◑ F-003 partially addressed: tool execution now has an `AbortSignal` contract and executor triggers abort on timeout; host `shell.exec` and sandbox docker exec now respond to cancellation. Additional high-risk tools still need explicit cancellation coverage for full closure. - ◑ F-003 partially addressed: tool execution now has an `AbortSignal` contract and executor triggers abort on timeout; host `shell.exec`, sandbox docker exec, `process.start`, browser tools (`browser.navigate/click/type/content/eval/screenshot`), and web tools (`web.fetch`, `web.search`) now respond to cancellation. Remaining closure work is timeout-side-effect regression coverage.
- ✅ F-015 addressed: retry defaults no longer classify timeout-style failures as non-retryable, improving resilience for transient timeout conditions. - ✅ F-015 addressed: retry defaults no longer classify timeout-style failures as non-retryable, improving resilience for transient timeout conditions.
## Executive Summary ## Executive Summary
@@ -24,7 +24,7 @@ Scope: Production-risk-first audit of bugs, code improvements, and feature oppor
Current health snapshot: Current health snapshot:
- `pnpm typecheck`: passing - `pnpm typecheck`: passing
- `pnpm build`: passing - `pnpm build`: passing
- `pnpm test:run`: passing (`137/137` files, `1735/1735` tests) - `pnpm test:run`: passing (`140/140` files, `1765/1765` tests)
- `pnpm lint`: failing (`148 errors`, `530 warnings`) - `pnpm lint`: failing (`148 errors`, `530 warnings`)
Top conclusions: Top conclusions:
@@ -113,6 +113,10 @@ Non-goals:
- Timeout + cancellation conformance tests for cancellable tools. - Timeout + cancellation conformance tests for cancellable tools.
- Regression tests verifying no post-timeout side effects. - Regression tests verifying no post-timeout side effects.
Remediation update (2026-02-16):
- Abort propagation now covers executor -> context signal -> `process.start`, browser tools, and web fetch/search tools.
- Added aborted-signal regression tests for these tool paths.
### F-004 Medium: Lint quality gate is broken and concentrated in key runtime files ### F-004 Medium: Lint quality gate is broken and concentrated in key runtime files
- Severity: Medium - Severity: Medium
+11 -3
View File
@@ -2552,19 +2552,27 @@
"status": "in_progress", "status": "in_progress",
"date": "2026-02-16", "date": "2026-02-16",
"updated": "2026-02-16", "updated": "2026-02-16",
"summary": "Started timeout-cancellation hardening: added ToolExecutionContext AbortSignal contract, executor abort-on-timeout propagation, and cancellation-aware shell execution for host and sandbox docker exec paths. Remaining work: migrate additional high-risk tools and add post-timeout side-effect regression coverage.", "summary": "Expanded timeout-cancellation hardening: executor AbortSignal propagation now reaches process.start, browser tools (navigate/screenshot/click/type/content/eval), and web tools (fetch/search), with pre-abort regression tests. Remaining work: add explicit post-timeout side-effect regression coverage.",
"files_modified": [ "files_modified": [
"src/tools/types.ts", "src/tools/types.ts",
"src/tools/executor.ts", "src/tools/executor.ts",
"src/tools/executor.test.ts", "src/tools/executor.test.ts",
"src/tools/builtin/shell.ts", "src/tools/builtin/shell.ts",
"src/tools/builtin/browser/tools.ts",
"src/tools/builtin/browser/tools.test.ts",
"src/tools/builtin/web-fetch.ts",
"src/tools/builtin/web-fetch.test.ts",
"src/tools/builtin/web-search.ts",
"src/tools/builtin/web-search.test.ts",
"src/tools/builtin/process/start.ts",
"src/tools/builtin/process/manager.test.ts",
"src/sandbox/docker.ts", "src/sandbox/docker.ts",
"src/sandbox/tools.ts", "src/sandbox/tools.ts",
"docs/api/TOOLS.md", "docs/api/TOOLS.md",
"docs/architecture/CONTRIBUTOR_MAP.md", "docs/architecture/CONTRIBUTOR_MAP.md",
"docs/plans/analysis/2026-02-16-codebase-audit-report.md" "docs/plans/analysis/2026-02-16-codebase-audit-report.md"
], ],
"test_status": "pnpm test:run src/tools/executor.test.ts src/tools/builtin/shell.test.ts + pnpm typecheck passing" "test_status": "pnpm test:run src/tools/builtin/browser/tools.test.ts src/tools/builtin/web-fetch.test.ts src/tools/builtin/web-search.test.ts src/tools/builtin/process/manager.test.ts + pnpm typecheck passing"
}, },
"audit-followup-retry-timeout-defaults": { "audit-followup-retry-timeout-defaults": {
"status": "completed", "status": "completed",
@@ -2580,7 +2588,7 @@
} }
}, },
"overall_progress": { "overall_progress": {
"total_test_count": 1703, "total_test_count": 1765,
"all_tests_passing": true, "all_tests_passing": true,
"p0_completion": "3/3 (100%)", "p0_completion": "3/3 (100%)",
"p1_completion": "4/4 (100%)", "p1_completion": "4/4 (100%)",
+12
View File
@@ -154,4 +154,16 @@ describe('Browser tools', () => {
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Element not found'); expect(result.error).toContain('Element not found');
}); });
it('returns aborted error when signal is already aborted', async () => {
const tool = tools.find(t => t.name === 'browser.navigate')!;
const controller = new AbortController();
controller.abort();
const result = await tool.execute({ url: 'https://example.com' }, { signal: controller.signal });
expect(result.success).toBe(false);
expect(result.error).toContain('aborted');
expect(mockManager.getPage).not.toHaveBeenCalled();
expect(mockGoto).not.toHaveBeenCalled();
});
}); });
+109 -16
View File
@@ -1,6 +1,60 @@
import type { Tool, ToolResult } from '../../types.js'; import type { Tool, ToolExecutionContext, ToolResult } from '../../types.js';
import type { BrowserManager } from './manager.js'; import type { BrowserManager } from './manager.js';
function abortError(): string {
return 'Operation aborted';
}
function isAbortError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return error.name === 'AbortError' || error.message.toLowerCase().includes('aborted');
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error(abortError());
}
}
async function withAbort<T>(
operation: Promise<T>,
signal?: AbortSignal,
onAbort?: () => void,
): Promise<T> {
if (!signal) {
return operation;
}
if (signal.aborted) {
onAbort?.();
throw new Error(abortError());
}
return new Promise<T>((resolve, reject) => {
const cleanup = () => {
signal.removeEventListener('abort', handleAbort);
};
const handleAbort = () => {
onAbort?.();
cleanup();
reject(new Error(abortError()));
};
signal.addEventListener('abort', handleAbort, { once: true });
operation
.then((value) => {
cleanup();
resolve(value);
})
.catch((error) => {
cleanup();
reject(error);
});
});
}
/** Create all browser tools bound to a BrowserManager instance. */ /** Create all browser tools bound to a BrowserManager instance. */
export function createBrowserTools(manager: BrowserManager): Tool[] { export function createBrowserTools(manager: BrowserManager): Tool[] {
return [ return [
@@ -28,12 +82,23 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
}, },
required: ['url'], required: ['url'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { url: string; waitUntil?: string }; const args = rawArgs as { url: string; waitUntil?: string };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
throwIfAborted(context?.signal);
const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2'; const waitUntil = (args.waitUntil ?? 'domcontentloaded') as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
await page.goto(args.url, { waitUntil }); await withAbort(
page.goto(args.url, { waitUntil }),
context?.signal,
() => {
void page.evaluate(() => {
(globalThis as { stop?: () => void }).stop?.();
}).catch(() => undefined);
},
);
throwIfAborted(context?.signal);
const title = await page.title(); const title = await page.title();
const currentUrl = page.url(); const currentUrl = page.url();
return { return {
@@ -41,6 +106,9 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
output: `Navigated to: ${currentUrl}\nTitle: ${title}`, output: `Navigated to: ${currentUrl}\nTitle: ${title}`,
}; };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
@@ -62,10 +130,12 @@ function createBrowserScreenshotTool(manager: BrowserManager): Tool {
selector: { type: 'string', description: 'CSS selector to screenshot a specific element' }, selector: { type: 'string', description: 'CSS selector to screenshot a specific element' },
}, },
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { fullPage?: boolean; selector?: string }; const args = rawArgs as { fullPage?: boolean; selector?: string };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
throwIfAborted(context?.signal);
let screenshotData: string; let screenshotData: string;
if (args.selector) { if (args.selector) {
@@ -86,6 +156,9 @@ function createBrowserScreenshotTool(manager: BrowserManager): Tool {
output: `Screenshot captured (base64 PNG, ${screenshotData.length} chars):\n${screenshotData.slice(0, 200)}...`, output: `Screenshot captured (base64 PNG, ${screenshotData.length} chars):\n${screenshotData.slice(0, 200)}...`,
}; };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
@@ -107,13 +180,18 @@ function createBrowserClickTool(manager: BrowserManager): Tool {
}, },
required: ['selector'], required: ['selector'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { selector: string }; const args = rawArgs as { selector: string };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
await page.click(args.selector); throwIfAborted(context?.signal);
await withAbort(page.click(args.selector), context?.signal);
return { success: true, output: `Clicked element: ${args.selector}` }; return { success: true, output: `Clicked element: ${args.selector}` };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
@@ -137,17 +215,22 @@ function createBrowserTypeTool(manager: BrowserManager): Tool {
}, },
required: ['selector', 'text'], required: ['selector', 'text'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { selector: string; text: string; clear?: boolean }; const args = rawArgs as { selector: string; text: string; clear?: boolean };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
throwIfAborted(context?.signal);
if (args.clear) { if (args.clear) {
await page.click(args.selector, { count: 3 }); // Select all await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all
await page.keyboard.press('Backspace'); await withAbort(page.keyboard.press('Backspace'), context?.signal);
} }
await page.type(args.selector, args.text); await withAbort(page.type(args.selector, args.text), context?.signal);
return { success: true, output: `Typed "${args.text}" into ${args.selector}` }; return { success: true, output: `Typed "${args.text}" into ${args.selector}` };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
@@ -169,19 +252,21 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
maxLength: { type: 'number', description: 'Maximum characters to return (default: 10000)' }, maxLength: { type: 'number', description: 'Maximum characters to return (default: 10000)' },
}, },
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { selector?: string; maxLength?: number }; const args = rawArgs as { selector?: string; maxLength?: number };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
throwIfAborted(context?.signal);
const selector = args.selector ?? 'body'; const selector = args.selector ?? 'body';
const maxLength = args.maxLength ?? 10000; const maxLength = args.maxLength ?? 10000;
// The $eval callback executes in the browser context where DOM types exist, // The $eval callback executes in the browser context where DOM types exist,
// but TS checks against Node.js lib (no DOM). Use `any` to bridge the gap. // but TS checks against Node.js lib (no DOM). Use `any` to bridge the gap.
const text = await page.$eval(selector, (el) => { const text = await withAbort(page.$eval(selector, (el) => {
const htmlEl = el as unknown as { innerText?: string; textContent?: string | null }; const htmlEl = el as unknown as { innerText?: string; textContent?: string | null };
return htmlEl.innerText || htmlEl.textContent || ''; return htmlEl.innerText || htmlEl.textContent || '';
}); }), context?.signal);
const truncated = text.length > maxLength const truncated = text.length > maxLength
? text.slice(0, maxLength) + `\n... (truncated, ${text.length} total chars)` ? text.slice(0, maxLength) + `\n... (truncated, ${text.length} total chars)`
@@ -195,6 +280,9 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
output: `URL: ${url}\nTitle: ${title}\n\n${truncated}`, output: `URL: ${url}\nTitle: ${title}\n\n${truncated}`,
}; };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
@@ -216,18 +304,23 @@ function createBrowserEvalTool(manager: BrowserManager): Tool {
}, },
required: ['expression'], required: ['expression'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as { expression: string }; const args = rawArgs as { expression: string };
try { try {
throwIfAborted(context?.signal);
const page = await manager.getPage(); const page = await manager.getPage();
throwIfAborted(context?.signal);
// Use evaluate with a function that evaluates the expression string // Use evaluate with a function that evaluates the expression string
const result = await page.evaluate((expr: string) => { const result = await withAbort(page.evaluate((expr: string) => {
// eslint-disable-next-line no-eval // eslint-disable-next-line no-eval
return eval(expr); return eval(expr);
}, args.expression); }, args.expression), context?.signal);
const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2); const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return { success: true, output: output ?? 'undefined' }; return { success: true, output: output ?? 'undefined' };
} catch (error) { } catch (error) {
if (isAbortError(error)) {
return { success: false, output: '', error: abortError() };
}
return { return {
success: false, success: false,
output: '', output: '',
+13
View File
@@ -221,6 +221,19 @@ describe('Process tools', () => {
await new Promise(resolve => setTimeout(resolve, 300)); await new Promise(resolve => setTimeout(resolve, 300));
}); });
it('process.start tool fails when aborted before start', async () => {
const { createProcessStartTool } = await import('./start.js');
manager = new ProcessManager();
const tool = createProcessStartTool(manager);
const controller = new AbortController();
controller.abort();
const result = await tool.execute({ command: 'sleep 60' }, { signal: controller.signal });
expect(result.success).toBe(false);
expect(result.error).toContain('aborted');
expect(manager.list().length).toBe(0);
});
it('process.status tool returns status info', async () => { it('process.status tool returns status info', async () => {
const { createProcessStatusTool } = await import('./status.js'); const { createProcessStatusTool } = await import('./status.js');
manager = new ProcessManager(); manager = new ProcessManager();
+9 -2
View File
@@ -1,4 +1,4 @@
import type { Tool, ToolResult } from '../../types.js'; import type { Tool, ToolExecutionContext, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js'; import type { ProcessManager } from './manager.js';
interface ProcessStartArgs { interface ProcessStartArgs {
@@ -18,8 +18,15 @@ export function createProcessStartTool(manager: ProcessManager): Tool {
}, },
required: ['command'], required: ['command'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as ProcessStartArgs; const args = rawArgs as ProcessStartArgs;
if (context?.signal?.aborted) {
return {
success: false,
output: '',
error: 'Operation aborted',
};
}
try { try {
const proc = manager.start(args.command, args.cwd); const proc = manager.start(args.command, args.cwd);
return { return {
+14
View File
@@ -160,6 +160,20 @@ describe('web.fetch', () => {
expect(result.error).toContain('network error'); expect(result.error).toContain('network error');
}); });
it('returns aborted error when signal is already aborted', async () => {
const controller = new AbortController();
controller.abort();
const result = await webFetchTool.execute(
{ url: 'https://example.com' },
{ signal: controller.signal },
);
expect(result.success).toBe(false);
expect(result.error).toContain('aborted');
expect(mockFetch).not.toHaveBeenCalled();
});
// ---- Caching ---- // ---- Caching ----
it('caches responses and reuses them on the second call', async () => { it('caches responses and reuses them on the second call', async () => {
+15 -3
View File
@@ -1,7 +1,7 @@
import { parseHTML } from 'linkedom'; import { parseHTML } from 'linkedom';
import { Readability } from '@mozilla/readability'; import { Readability } from '@mozilla/readability';
import TurndownService from 'turndown'; import TurndownService from 'turndown';
import type { Tool, ToolResult } from '../types.js'; import type { Tool, ToolExecutionContext, ToolResult } from '../types.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -181,11 +181,15 @@ export const webFetchTool: Tool = {
required: ['url'], required: ['url'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as WebFetchArgs; const args = rawArgs as WebFetchArgs;
const format: OutputFormat = args.format ?? 'markdown'; const format: OutputFormat = args.format ?? 'markdown';
const timeout = args.timeout ?? 15_000; const timeout = args.timeout ?? 15_000;
if (context?.signal?.aborted) {
return { success: false, output: '', error: 'Operation aborted' };
}
// ----- Check cache (lazy eviction) ----- // // ----- Check cache (lazy eviction) ----- //
evictExpired(); evictExpired();
const key = cacheKey(args.url, format); const key = cacheKey(args.url, format);
@@ -196,8 +200,13 @@ export const webFetchTool: Tool = {
// ----- Fetch ----- // // ----- Fetch ----- //
try { try {
const timeoutSignal = AbortSignal.timeout(timeout);
const signal = context?.signal
? AbortSignal.any([context.signal, timeoutSignal])
: timeoutSignal;
const response = await fetch(args.url, { const response = await fetch(args.url, {
signal: AbortSignal.timeout(timeout), signal,
headers: { headers: {
'User-Agent': 'Flynn/0.1 (personal AI assistant)', 'User-Agent': 'Flynn/0.1 (personal AI assistant)',
Accept: 'text/html, application/json, text/plain, */*', Accept: 'text/html, application/json, text/plain, */*',
@@ -224,6 +233,9 @@ export const webFetchTool: Tool = {
return { success: true, output }; return { success: true, output };
} catch (error) { } catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
return { success: false, output: '', error: 'Operation aborted' };
}
return { return {
success: false, success: false,
output: '', output: '',
+15
View File
@@ -255,4 +255,19 @@ describe('web.search', () => {
expect(result.output).toContain('2. **Result 2**'); expect(result.output).toContain('2. **Result 2**');
}); });
}); });
it('returns aborted error when signal is already aborted', async () => {
const tool = createWebSearchTool(braveConfig);
const controller = new AbortController();
controller.abort();
const result = await tool.execute(
{ query: 'test query' },
{ signal: controller.signal },
);
expect(result.success).toBe(false);
expect(result.error).toContain('aborted');
expect(mockFetch).not.toHaveBeenCalled();
});
}); });
+24 -6
View File
@@ -1,4 +1,4 @@
import type { Tool, ToolResult } from '../types.js'; import type { Tool, ToolExecutionContext, ToolResult } from '../types.js';
/** Configuration for the web search tool. */ /** Configuration for the web search tool. */
export interface WebSearchConfig { export interface WebSearchConfig {
@@ -49,16 +49,22 @@ async function searchBrave(
query: string, query: string,
count: number, count: number,
apiKey: string, apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> { ): Promise<SearchResult[]> {
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: query,
count: String(count), count: String(count),
}); });
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const fetchSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const response = await fetch( const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`, `https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
{ {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), signal: fetchSignal,
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'X-Subscription-Token': apiKey, 'X-Subscription-Token': apiKey,
@@ -90,6 +96,7 @@ async function searchSearxng(
query: string, query: string,
count: number, count: number,
endpoint: string, endpoint: string,
signal?: AbortSignal,
): Promise<SearchResult[]> { ): Promise<SearchResult[]> {
// Strip trailing slash from endpoint // Strip trailing slash from endpoint
const baseUrl = endpoint.replace(/\/+$/, ''); const baseUrl = endpoint.replace(/\/+$/, '');
@@ -99,8 +106,13 @@ async function searchSearxng(
categories: 'general', categories: 'general',
}); });
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const fetchSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const response = await fetch(`${baseUrl}/search?${params.toString()}`, { const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), signal: fetchSignal,
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
}, },
@@ -147,8 +159,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
}, },
required: ['query'], required: ['query'],
}, },
execute: async (rawArgs: unknown): Promise<ToolResult> => { execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as WebSearchArgs; const args = rawArgs as WebSearchArgs;
if (context?.signal?.aborted) {
return { success: false, output: '', error: 'Operation aborted' };
}
// Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default // Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default
const count = Math.min(args.count ?? defaultCount, MAX_RESULTS); const count = Math.min(args.count ?? defaultCount, MAX_RESULTS);
@@ -163,7 +178,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
error: 'Brave Search API key not configured', error: 'Brave Search API key not configured',
}; };
} }
results = await searchBrave(args.query, count, config.apiKey); results = await searchBrave(args.query, count, config.apiKey, context?.signal);
} else { } else {
// SearXNG provider // SearXNG provider
if (!config.endpoint) { if (!config.endpoint) {
@@ -173,7 +188,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
error: 'SearXNG endpoint not configured', error: 'SearXNG endpoint not configured',
}; };
} }
results = await searchSearxng(args.query, count, config.endpoint); results = await searchSearxng(args.query, count, config.endpoint, context?.signal);
} }
if (results.length === 0) { if (results.length === 0) {
@@ -185,6 +200,9 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
return { success: true, output: formatResults(results) }; return { success: true, output: formatResults(results) };
} catch (error) { } catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
return { success: false, output: '', error: 'Operation aborted' };
}
return { return {
success: false, success: false,
output: '', output: '',