feat(tools): extend cancellation to browser, web, and process tools
This commit is contained in:
@@ -154,4 +154,16 @@ describe('Browser tools', () => {
|
||||
expect(result.success).toBe(false);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
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. */
|
||||
export function createBrowserTools(manager: BrowserManager): Tool[] {
|
||||
return [
|
||||
@@ -28,12 +82,23 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
||||
},
|
||||
required: ['url'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as { url: string; waitUntil?: string };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
const page = await manager.getPage();
|
||||
throwIfAborted(context?.signal);
|
||||
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 currentUrl = page.url();
|
||||
return {
|
||||
@@ -41,6 +106,9 @@ function createBrowserNavigateTool(manager: BrowserManager): Tool {
|
||||
output: `Navigated to: ${currentUrl}\nTitle: ${title}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
@@ -62,10 +130,12 @@ function createBrowserScreenshotTool(manager: BrowserManager): Tool {
|
||||
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 };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
const page = await manager.getPage();
|
||||
throwIfAborted(context?.signal);
|
||||
|
||||
let screenshotData: string;
|
||||
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)}...`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
@@ -107,13 +180,18 @@ function createBrowserClickTool(manager: BrowserManager): Tool {
|
||||
},
|
||||
required: ['selector'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as { selector: string };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
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}` };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
@@ -137,17 +215,22 @@ function createBrowserTypeTool(manager: BrowserManager): Tool {
|
||||
},
|
||||
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 };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
const page = await manager.getPage();
|
||||
throwIfAborted(context?.signal);
|
||||
if (args.clear) {
|
||||
await page.click(args.selector, { count: 3 }); // Select all
|
||||
await page.keyboard.press('Backspace');
|
||||
await withAbort(page.click(args.selector, { count: 3 }), context?.signal); // Select all
|
||||
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}` };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
@@ -169,19 +252,21 @@ function createBrowserContentTool(manager: BrowserManager): Tool {
|
||||
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 };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
const page = await manager.getPage();
|
||||
throwIfAborted(context?.signal);
|
||||
const selector = args.selector ?? 'body';
|
||||
const maxLength = args.maxLength ?? 10000;
|
||||
|
||||
// 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.
|
||||
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 };
|
||||
return htmlEl.innerText || htmlEl.textContent || '';
|
||||
});
|
||||
}), context?.signal);
|
||||
|
||||
const truncated = text.length > maxLength
|
||||
? 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}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
@@ -216,18 +304,23 @@ function createBrowserEvalTool(manager: BrowserManager): Tool {
|
||||
},
|
||||
required: ['expression'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as { expression: string };
|
||||
try {
|
||||
throwIfAborted(context?.signal);
|
||||
const page = await manager.getPage();
|
||||
throwIfAborted(context?.signal);
|
||||
// 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
|
||||
return eval(expr);
|
||||
}, args.expression);
|
||||
}, args.expression), context?.signal);
|
||||
const output = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
return { success: true, output: output ?? 'undefined' };
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return { success: false, output: '', error: abortError() };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
|
||||
@@ -221,6 +221,19 @@ describe('Process tools', () => {
|
||||
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 () => {
|
||||
const { createProcessStatusTool } = await import('./status.js');
|
||||
manager = new ProcessManager();
|
||||
|
||||
@@ -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';
|
||||
|
||||
interface ProcessStartArgs {
|
||||
@@ -18,8 +18,15 @@ export function createProcessStartTool(manager: ProcessManager): Tool {
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as ProcessStartArgs;
|
||||
if (context?.signal?.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: 'Operation aborted',
|
||||
};
|
||||
}
|
||||
try {
|
||||
const proc = manager.start(args.command, args.cwd);
|
||||
return {
|
||||
|
||||
@@ -160,6 +160,20 @@ describe('web.fetch', () => {
|
||||
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 ----
|
||||
|
||||
it('caches responses and reuses them on the second call', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { parseHTML } from 'linkedom';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import TurndownService from 'turndown';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import type { Tool, ToolExecutionContext, ToolResult } from '../types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -181,11 +181,15 @@ export const webFetchTool: Tool = {
|
||||
required: ['url'],
|
||||
},
|
||||
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as WebFetchArgs;
|
||||
const format: OutputFormat = args.format ?? 'markdown';
|
||||
const timeout = args.timeout ?? 15_000;
|
||||
|
||||
if (context?.signal?.aborted) {
|
||||
return { success: false, output: '', error: 'Operation aborted' };
|
||||
}
|
||||
|
||||
// ----- Check cache (lazy eviction) ----- //
|
||||
evictExpired();
|
||||
const key = cacheKey(args.url, format);
|
||||
@@ -196,8 +200,13 @@ export const webFetchTool: Tool = {
|
||||
|
||||
// ----- Fetch ----- //
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(timeout);
|
||||
const signal = context?.signal
|
||||
? AbortSignal.any([context.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
|
||||
const response = await fetch(args.url, {
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
signal,
|
||||
headers: {
|
||||
'User-Agent': 'Flynn/0.1 (personal AI assistant)',
|
||||
Accept: 'text/html, application/json, text/plain, */*',
|
||||
@@ -224,6 +233,9 @@ export const webFetchTool: Tool = {
|
||||
|
||||
return { success: true, output };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
|
||||
return { success: false, output: '', error: 'Operation aborted' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
|
||||
@@ -255,4 +255,19 @@ describe('web.search', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
export interface WebSearchConfig {
|
||||
@@ -49,16 +49,22 @@ async function searchBrave(
|
||||
query: string,
|
||||
count: number,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResult[]> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
count: String(count),
|
||||
});
|
||||
|
||||
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||
const fetchSignal = signal
|
||||
? AbortSignal.any([signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
|
||||
{
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
signal: fetchSignal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-Subscription-Token': apiKey,
|
||||
@@ -90,6 +96,7 @@ async function searchSearxng(
|
||||
query: string,
|
||||
count: number,
|
||||
endpoint: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResult[]> {
|
||||
// Strip trailing slash from endpoint
|
||||
const baseUrl = endpoint.replace(/\/+$/, '');
|
||||
@@ -99,8 +106,13 @@ async function searchSearxng(
|
||||
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()}`, {
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
signal: fetchSignal,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
@@ -147,8 +159,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
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
|
||||
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',
|
||||
};
|
||||
}
|
||||
results = await searchBrave(args.query, count, config.apiKey);
|
||||
results = await searchBrave(args.query, count, config.apiKey, context?.signal);
|
||||
} else {
|
||||
// SearXNG provider
|
||||
if (!config.endpoint) {
|
||||
@@ -173,7 +188,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
||||
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) {
|
||||
@@ -185,6 +200,9 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
||||
|
||||
return { success: true, output: formatResults(results) };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
|
||||
return { success: false, output: '', error: 'Operation aborted' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
|
||||
Reference in New Issue
Block a user