Skip to content

Commit 0b78de9

Browse files
fix(core): handle AbortError when ESC cancels tool execution (#20863)
1 parent a220874 commit 0b78de9

3 files changed

Lines changed: 96 additions & 6 deletions

File tree

packages/core/src/scheduler/tool-executor.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,87 @@ describe('ToolExecutor', () => {
211211
});
212212
});
213213

214+
it('should return cancelled result when executeToolWithHooks rejects with AbortError', async () => {
215+
const mockTool = new MockTool({
216+
name: 'webSearchTool',
217+
description: 'Mock web search',
218+
});
219+
const invocation = mockTool.build({});
220+
221+
const abortErr = new Error('The user aborted a request.');
222+
abortErr.name = 'AbortError';
223+
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockRejectedValue(
224+
abortErr,
225+
);
226+
227+
const scheduledCall: ScheduledToolCall = {
228+
status: CoreToolCallStatus.Scheduled,
229+
request: {
230+
callId: 'call-abort',
231+
name: 'webSearchTool',
232+
args: {},
233+
isClientInitiated: false,
234+
prompt_id: 'prompt-abort',
235+
},
236+
tool: mockTool,
237+
invocation: invocation as unknown as AnyToolInvocation,
238+
startTime: Date.now(),
239+
};
240+
241+
const result = await executor.execute({
242+
call: scheduledCall,
243+
signal: new AbortController().signal,
244+
onUpdateToolCall: vi.fn(),
245+
});
246+
247+
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
248+
if (result.status === CoreToolCallStatus.Cancelled) {
249+
const response = result.response.responseParts[0]?.functionResponse
250+
?.response as Record<string, unknown>;
251+
expect(response['error']).toContain('Operation cancelled.');
252+
}
253+
});
254+
255+
it('should return cancelled result when executeToolWithHooks rejects with "Operation cancelled by user" message', async () => {
256+
const mockTool = new MockTool({
257+
name: 'someTool',
258+
description: 'Mock',
259+
});
260+
const invocation = mockTool.build({});
261+
262+
const cancelErr = new Error('Operation cancelled by user');
263+
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockRejectedValue(
264+
cancelErr,
265+
);
266+
267+
const scheduledCall: ScheduledToolCall = {
268+
status: CoreToolCallStatus.Scheduled,
269+
request: {
270+
callId: 'call-cancel-msg',
271+
name: 'someTool',
272+
args: {},
273+
isClientInitiated: false,
274+
prompt_id: 'prompt-cancel-msg',
275+
},
276+
tool: mockTool,
277+
invocation: invocation as unknown as AnyToolInvocation,
278+
startTime: Date.now(),
279+
};
280+
281+
const result = await executor.execute({
282+
call: scheduledCall,
283+
signal: new AbortController().signal,
284+
onUpdateToolCall: vi.fn(),
285+
});
286+
287+
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
288+
if (result.status === CoreToolCallStatus.Cancelled) {
289+
const response = result.response.responseParts[0]?.functionResponse
290+
?.response as Record<string, unknown>;
291+
expect(response['error']).toContain('User cancelled tool execution.');
292+
}
293+
});
294+
214295
it('should return cancelled result when signal is aborted', async () => {
215296
const mockTool = new MockTool({
216297
name: 'slowTool',

packages/core/src/scheduler/tool-executor.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type AgentLoopContext,
1717
type ToolLiveOutput,
1818
} from '../index.js';
19+
import { isAbortError } from '../utils/errors.js';
1920
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
2021
import { ShellToolInvocation } from '../tools/shell.js';
2122
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
@@ -159,15 +160,17 @@ export class ToolExecutor {
159160
}
160161
} catch (executionError: unknown) {
161162
spanMetadata.error = executionError;
162-
const isAbortError =
163-
executionError instanceof Error &&
164-
(executionError.name === 'AbortError' ||
163+
const abortedByError =
164+
isAbortError(executionError) ||
165+
(executionError instanceof Error &&
165166
executionError.message.includes('Operation cancelled by user'));
166167

167-
if (signal.aborted || isAbortError) {
168+
if (signal.aborted || abortedByError) {
168169
completedToolCall = await this.createCancelledResult(
169170
call,
170-
'User cancelled tool execution.',
171+
isAbortError(executionError)
172+
? 'Operation cancelled.'
173+
: 'User cancelled tool execution.',
171174
);
172175
} else {
173176
const error =

packages/core/src/tools/web-search.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from './tools.js';
1717
import { ToolErrorType } from './tool-error.js';
1818

19-
import { getErrorMessage } from '../utils/errors.js';
19+
import { getErrorMessage, isAbortError } from '../utils/errors.js';
2020
import { type Config } from '../config/config.js';
2121
import { getResponseText } from '../utils/partUtils.js';
2222
import { debugLogger } from '../utils/debugLogger.js';
@@ -175,6 +175,12 @@ class WebSearchToolInvocation extends BaseToolInvocation<
175175
sources,
176176
};
177177
} catch (error: unknown) {
178+
if (isAbortError(error)) {
179+
return {
180+
llmContent: 'Web search was cancelled.',
181+
returnDisplay: 'Search cancelled.',
182+
};
183+
}
178184
const errorMessage = `Error during web search for query "${
179185
this.params.query
180186
}": ${getErrorMessage(error)}`;

0 commit comments

Comments
 (0)