feat(tools): propagate timeout abort signals to tool execution
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { execFile } from 'child_process';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import type { Tool, ToolResult, ToolExecutionContext } from '../types.js';
|
||||
|
||||
interface ShellExecArgs {
|
||||
command: string;
|
||||
@@ -19,16 +19,19 @@ export const shellExecTool: Tool = {
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
|
||||
const args = rawArgs as ShellExecArgs;
|
||||
const timeout = args.timeout ?? 30_000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile('bash', ['-c', args.command], {
|
||||
const child = execFile('bash', ['-c', args.command], {
|
||||
cwd: args.cwd,
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024,
|
||||
}, (error, stdout, stderr) => {
|
||||
if (context?.signal) {
|
||||
context.signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (error) {
|
||||
if (error.killed || error.signal === 'SIGTERM') {
|
||||
resolve({ success: false, output: stdout, error: `Command timed out after ${timeout}ms` });
|
||||
@@ -43,6 +46,17 @@ export const shellExecTool: Tool = {
|
||||
}
|
||||
resolve({ success: true, output: stdout + (stderr ? `\nstderr: ${stderr}` : '') });
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
child.kill('SIGTERM');
|
||||
};
|
||||
if (context?.signal) {
|
||||
if (context.signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
context.signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,6 +42,23 @@ const fileWriteLikeTool: Tool = {
|
||||
execute: async () => ({ success: true, output: 'ok' }),
|
||||
};
|
||||
|
||||
const cancellableTool: Tool = {
|
||||
name: 'test.cancellable',
|
||||
description: 'Long-running cancellable tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
execute: async (_args, context) => {
|
||||
return await new Promise((resolve) => {
|
||||
const onAbort = () => resolve({ success: false, output: '', error: 'aborted' });
|
||||
if (context?.signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
context?.signal?.addEventListener('abort', onAbort, { once: true });
|
||||
setTimeout(() => resolve({ success: true, output: 'done' }), 5000);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
describe('ToolExecutor', () => {
|
||||
it('executes a tool and returns result', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
@@ -86,6 +103,17 @@ describe('ToolExecutor', () => {
|
||||
expect(result.error).toContain('timed out');
|
||||
});
|
||||
|
||||
it('aborts cancellable tool work on timeout', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(cancellableTool);
|
||||
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
|
||||
const executor = new ToolExecutor(registry, hooks, { defaultTimeoutMs: 50 });
|
||||
|
||||
const result = await executor.execute('test.cancellable', {});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('timed out');
|
||||
});
|
||||
|
||||
it('truncates large output', async () => {
|
||||
const registry = new ToolRegistry();
|
||||
registry.register(bigOutputTool);
|
||||
|
||||
@@ -225,6 +225,7 @@ export class ToolExecutor {
|
||||
});
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
@@ -232,17 +233,20 @@ export class ToolExecutor {
|
||||
const sandboxSessionId = context?.sessionId ?? `${context?.channel ?? 'unknown'}:${context?.sender ?? 'unknown'}`;
|
||||
const sandbox = await this.sandboxManager.getOrCreate(sandboxSessionId);
|
||||
if (toolName === 'shell.exec') {
|
||||
return createSandboxedShellTool(sandbox).execute(args);
|
||||
return createSandboxedShellTool(sandbox).execute(args, { signal: abortController.signal });
|
||||
}
|
||||
if (toolName === 'process.start') {
|
||||
return createSandboxedProcessStartTool(sandbox).execute(args);
|
||||
return createSandboxedProcessStartTool(sandbox).execute(args, { signal: abortController.signal });
|
||||
}
|
||||
}
|
||||
return tool.execute(args);
|
||||
return tool.execute(args, { signal: abortController.signal });
|
||||
})(),
|
||||
new Promise<ToolResult>((_, reject) => {
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`)),
|
||||
() => {
|
||||
abortController.abort();
|
||||
reject(new Error(`Tool '${toolName}' timed out after ${this.defaultTimeoutMs}ms`));
|
||||
},
|
||||
this.defaultTimeoutMs,
|
||||
);
|
||||
}),
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ export interface Tool {
|
||||
inputSchema: ToolInputSchema;
|
||||
/** Secret scopes required to execute this tool (optional). */
|
||||
requiredSecretScopes?: string[];
|
||||
execute(args: unknown): Promise<ToolResult>;
|
||||
execute(args: unknown, context?: ToolExecutionContext): Promise<ToolResult>;
|
||||
}
|
||||
|
||||
export interface ToolExecutionContext {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
|
||||
Reference in New Issue
Block a user