-
Notifications
You must be signed in to change notification settings - Fork 3k
fix(scheduler): add opt-in per-tool-call execution timeout #6136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea552e2
29d9aae
b07d567
f96313a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -838,6 +838,68 @@ describe('CoreToolScheduler', () => { | |||||||
| ); | ||||||||
| }); | ||||||||
|
|
||||||||
| it('aborts and fails a tool call that exceeds the execution timeout', async () => { | ||||||||
| const previousTimeout = process.env['QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS']; | ||||||||
| process.env['QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS'] = '30'; | ||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The 30ms real timer is fragile under heavy CI load. This file already uses
Suggested change
Then advance timers with — qwen3.7-max via Qwen Code /review |
||||||||
| try { | ||||||||
| let toolSawAbort = false; | ||||||||
| // A tool that never settles on its own — it resolves only once its | ||||||||
| // AbortSignal fires, proving the timeout actually cancels the tool | ||||||||
| // rather than merely abandoning it. | ||||||||
| const execute = vi.fn( | ||||||||
| (_params: unknown, signal?: AbortSignal) => | ||||||||
| new Promise<ToolResult>((resolve) => { | ||||||||
| signal?.addEventListener('abort', () => { | ||||||||
| toolSawAbort = true; | ||||||||
| resolve({ | ||||||||
| llmContent: 'aborted late', | ||||||||
| returnDisplay: 'aborted late', | ||||||||
| }); | ||||||||
| }); | ||||||||
| }), | ||||||||
| ); | ||||||||
| const toolsByName = new Map<string, MockTool>([ | ||||||||
| [ | ||||||||
| 'read_file', | ||||||||
| new MockTool({ name: 'read_file', canUpdateOutput: true, execute }), | ||||||||
| ], | ||||||||
| ]); | ||||||||
| const { scheduler, onAllToolCallsComplete } = | ||||||||
| createSchedulerForLegacyToolTests({ toolsByName }); | ||||||||
|
|
||||||||
| await scheduler.schedule( | ||||||||
| [ | ||||||||
| { | ||||||||
| callId: 'timeout-1', | ||||||||
| name: 'read_file', | ||||||||
| args: { file_path: 'a.ts' }, | ||||||||
| isClientInitiated: false, | ||||||||
| prompt_id: 'prompt-timeout', | ||||||||
| }, | ||||||||
| ], | ||||||||
| new AbortController().signal, | ||||||||
| ); | ||||||||
|
|
||||||||
| const completedCall = ( | ||||||||
| onAllToolCallsComplete.mock.calls[0][0] as ToolCall[] | ||||||||
| )[0]; | ||||||||
| expect(completedCall.status).toBe('error'); | ||||||||
| if (completedCall.status === 'error') { | ||||||||
| expect(completedCall.response.errorType).toBe( | ||||||||
| ToolErrorType.EXECUTION_TIMEOUT, | ||||||||
| ); | ||||||||
| expect(completedCall.response.error?.message).toContain('timed out'); | ||||||||
| } | ||||||||
| expect(toolSawAbort).toBe(true); | ||||||||
| } finally { | ||||||||
| if (previousTimeout === undefined) { | ||||||||
| delete process.env['QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS']; | ||||||||
| } else { | ||||||||
| process.env['QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS'] = previousTimeout; | ||||||||
| } | ||||||||
| } | ||||||||
| }); | ||||||||
|
|
||||||||
| it('executes only the first request for duplicate callIds in one batch', async () => { | ||||||||
| const execute = vi.fn().mockResolvedValue({ | ||||||||
| llmContent: 'first result', | ||||||||
|
|
||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -220,6 +220,22 @@ const TOOL_FAILURE_KIND_NON_INTERACTIVE_DENIED = 'non_interactive_denied'; | |||||||||||||||||||||||||||
| const TOOL_FAILURE_KIND_BACKGROUND_AGENT_DENIED = 'background_agent_denied'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const TOOL_SPAN_STATUS_PRE_HOOK_BLOCKED = 'Tool execution blocked by hook'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||
| * Builds the failure ToolResult surfaced when a tool call exceeds the | ||||||||||||||||||||||||||||
| * execution timeout. Reported as a normal tool error so the model can adapt | ||||||||||||||||||||||||||||
| * (narrow scope, retry, etc.) instead of the session hanging. | ||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| function createToolTimeoutResult(timeoutMs: number): ToolResult { | ||||||||||||||||||||||||||||
| const message = | ||||||||||||||||||||||||||||
| `Tool execution timed out after ${Math.round(timeoutMs / 1000)}s. ` + | ||||||||||||||||||||||||||||
| `The tool may be stuck or operating on too large a scope.`; | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Nice to have]
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||
| llmContent: message, | ||||||||||||||||||||||||||||
| returnDisplay: message, | ||||||||||||||||||||||||||||
| error: { message, type: ToolErrorType.EXECUTION_TIMEOUT }, | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| const TOOL_SPAN_STATUS_POST_HOOK_STOPPED = 'Tool execution stopped by hook'; | ||||||||||||||||||||||||||||
| const TOOL_SPAN_STATUS_PERMISSION_DENIED = 'Permission denied for tool'; | ||||||||||||||||||||||||||||
| const TOOL_SPAN_STATUS_PERMISSION_HOOK_DENIED = | ||||||||||||||||||||||||||||
|
|
@@ -3315,6 +3331,39 @@ export class CoreToolScheduler { | |||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| let promise: Promise<ToolResult>; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Per-tool-call execution timeout. Disabled by default (experimental): | ||||||||||||||||||||||||||||
| // set QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS to a positive number of | ||||||||||||||||||||||||||||
| // milliseconds to cap how long a single tool call may run. | ||||||||||||||||||||||||||||
| const toolExecutionTimeoutMs = parsePositiveIntegerEnv( | ||||||||||||||||||||||||||||
| process.env['QWEN_CODE_TOOL_EXECUTION_TIMEOUT_MS'], | ||||||||||||||||||||||||||||
| 0, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // When a timeout is active, run the tool under a derived AbortSignal so | ||||||||||||||||||||||||||||
| // that on timeout we actually cancel the in-flight work (cooperative | ||||||||||||||||||||||||||||
| // tools stop; the shell kills its subprocess) instead of abandoning it | ||||||||||||||||||||||||||||
| // to run on unobserved. A user abort on the parent signal is forwarded | ||||||||||||||||||||||||||||
| // to the derived signal. The forwarding listener is torn down once the | ||||||||||||||||||||||||||||
| // tool settles (see finally below) so it never accumulates on the | ||||||||||||||||||||||||||||
| // long-lived parent (turn) signal across tool calls. | ||||||||||||||||||||||||||||
| let execSignal = signal; | ||||||||||||||||||||||||||||
| let timeoutController: AbortController | undefined; | ||||||||||||||||||||||||||||
| let removeParentAbortForward: (() => void) | undefined; | ||||||||||||||||||||||||||||
| if (toolExecutionTimeoutMs > 0) { | ||||||||||||||||||||||||||||
| timeoutController = new AbortController(); | ||||||||||||||||||||||||||||
| execSignal = timeoutController.signal; | ||||||||||||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||||||||||||
| timeoutController.abort(signal.reason); | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] This manual
Suggested change
This also removes the need for the — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||
| const controller = timeoutController; | ||||||||||||||||||||||||||||
| const forwardAbort = () => controller.abort(signal.reason); | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Listener/timer leak on synchronous throw from Fix: hoist — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| signal.addEventListener('abort', forwardAbort, { once: true }); | ||||||||||||||||||||||||||||
| removeParentAbortForward = () => | ||||||||||||||||||||||||||||
| signal.removeEventListener('abort', forwardAbort); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (invocation instanceof ShellToolInvocation) { | ||||||||||||||||||||||||||||
| const setPidCallback = (pid: number) => { | ||||||||||||||||||||||||||||
| this.toolCalls = this.toolCalls.map((tc) => | ||||||||||||||||||||||||||||
|
|
@@ -3348,7 +3397,7 @@ export class CoreToolScheduler { | |||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||
| promise = invocation.execute( | ||||||||||||||||||||||||||||
| signal, | ||||||||||||||||||||||||||||
| execSignal, | ||||||||||||||||||||||||||||
| liveOutputCallback, | ||||||||||||||||||||||||||||
| shellExecutionConfig, | ||||||||||||||||||||||||||||
| setPidCallback, | ||||||||||||||||||||||||||||
|
|
@@ -3357,13 +3406,45 @@ export class CoreToolScheduler { | |||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||
| promise = invocation.execute( | ||||||||||||||||||||||||||||
| signal, | ||||||||||||||||||||||||||||
| execSignal, | ||||||||||||||||||||||||||||
| liveOutputCallback, | ||||||||||||||||||||||||||||
| shellExecutionConfig, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const toolResult: ToolResult = await promise; | ||||||||||||||||||||||||||||
| let toolResult: ToolResult; | ||||||||||||||||||||||||||||
| if (timeoutController) { | ||||||||||||||||||||||||||||
| let timeoutTimer: ReturnType<typeof setTimeout> | undefined; | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| toolResult = await new Promise<ToolResult>((resolve, reject) => { | ||||||||||||||||||||||||||||
| timeoutTimer = setTimeout(() => { | ||||||||||||||||||||||||||||
| debugLogger.warn( | ||||||||||||||||||||||||||||
| `Tool ${canonicalName} (${callId}) timed out after ` + | ||||||||||||||||||||||||||||
| `${toolExecutionTimeoutMs}ms — aborting`, | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| // Cancel the in-flight tool via the derived signal, then resolve | ||||||||||||||||||||||||||||
| // with a timeout error so the scheduler is unblocked even if the | ||||||||||||||||||||||||||||
| // tool ignores the abort. A later settle from `promise` is a | ||||||||||||||||||||||||||||
| // no-op once this wrapper Promise has already resolved. | ||||||||||||||||||||||||||||
| timeoutController?.abort( | ||||||||||||||||||||||||||||
| new Error( | ||||||||||||||||||||||||||||
| `Tool execution timed out after ${toolExecutionTimeoutMs}ms`, | ||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
| resolve(createToolTimeoutResult(toolExecutionTimeoutMs)); | ||||||||||||||||||||||||||||
| }, toolExecutionTimeoutMs); | ||||||||||||||||||||||||||||
| timeoutTimer.unref?.(); | ||||||||||||||||||||||||||||
| promise.then(resolve, reject); | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] After the timeout fires and resolves the wrapper,
Suggested change
— qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||
| if (timeoutTimer) clearTimeout(timeoutTimer); | ||||||||||||||||||||||||||||
| // Tear down the parent-abort forwarding listener now that the tool | ||||||||||||||||||||||||||||
| // has settled (no-op if a user abort already removed it via `once`). | ||||||||||||||||||||||||||||
| removeParentAbortForward?.(); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||
| toolResult = await promise; | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| // A tool that observes signal.aborted and resolves with a normal | ||||||||||||||||||||||||||||
| // ToolResult (no .error field) would otherwise close the execution | ||||||||||||||||||||||||||||
| // sub-span as success while the parent tool span ends as cancelled. | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Only one test covers the timeout feature. Consider adding tests for:
'cancelled', notEXECUTION_TIMEOUTEXECUTION_TIMEOUTThe "tool ignores abort" path is the exact scenario the feature is designed for (stuck tools).
— qwen3.7-max via Qwen Code /review