Add a request queue to the tool scheduler (#5845)

This commit is contained in:
Jacob MacDonald 2025-08-08 14:50:35 -07:00 committed by GitHub
parent 9ac62565a0
commit 69322e12e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 385 additions and 132 deletions

View File

@ -63,7 +63,7 @@ export type TrackedToolCall =
| TrackedCancelledToolCall; | TrackedCancelledToolCall;
export function useReactToolScheduler( export function useReactToolScheduler(
onComplete: (tools: CompletedToolCall[]) => void, onComplete: (tools: CompletedToolCall[]) => Promise<void>,
config: Config, config: Config,
setPendingHistoryItem: React.Dispatch< setPendingHistoryItem: React.Dispatch<
React.SetStateAction<HistoryItemWithoutId | null> React.SetStateAction<HistoryItemWithoutId | null>
@ -106,8 +106,8 @@ export function useReactToolScheduler(
); );
const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback( const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback(
(completedToolCalls) => { async (completedToolCalls) => {
onComplete(completedToolCalls); await onComplete(completedToolCalls);
}, },
[onComplete], [onComplete],
); );
@ -157,7 +157,7 @@ export function useReactToolScheduler(
request: ToolCallRequestInfo | ToolCallRequestInfo[], request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal, signal: AbortSignal,
) => { ) => {
scheduler.schedule(request, signal); void scheduler.schedule(request, signal);
}, },
[scheduler], [scheduler],
); );

View File

@ -592,3 +592,195 @@ describe('CoreToolScheduler YOLO mode', () => {
} }
}); });
}); });
describe('CoreToolScheduler request queueing', () => {
it('should queue a request if another is running', async () => {
let resolveFirstCall: (result: ToolResult) => void;
const firstCallPromise = new Promise<ToolResult>((resolve) => {
resolveFirstCall = resolve;
});
const mockTool = new MockTool();
mockTool.executeFn.mockImplementation(() => firstCallPromise);
const declarativeTool = mockTool;
const toolRegistry = {
getTool: () => declarativeTool,
getToolByName: () => declarativeTool,
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {} as any,
registerTool: () => {},
getToolByDisplayName: () => declarativeTool,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
};
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.YOLO, // Use YOLO to avoid confirmation prompts
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: Promise.resolve(toolRegistry as any),
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
const request1 = {
callId: '1',
name: 'mockTool',
args: { a: 1 },
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const request2 = {
callId: '2',
name: 'mockTool',
args: { b: 2 },
isClientInitiated: false,
prompt_id: 'prompt-2',
};
// Schedule the first call, which will pause execution.
scheduler.schedule([request1], abortController.signal);
// Wait for the first call to be in the 'executing' state.
await vi.waitFor(() => {
const calls = onToolCallsUpdate.mock.calls.at(-1)?.[0] as ToolCall[];
expect(calls?.[0]?.status).toBe('executing');
});
// Schedule the second call while the first is "running".
const schedulePromise2 = scheduler.schedule(
[request2],
abortController.signal,
);
// Ensure the second tool call hasn't been executed yet.
expect(mockTool.executeFn).toHaveBeenCalledTimes(1);
expect(mockTool.executeFn).toHaveBeenCalledWith({ a: 1 });
// Complete the first tool call.
resolveFirstCall!({
llmContent: 'First call complete',
returnDisplay: 'First call complete',
});
// Wait for the second schedule promise to resolve.
await schedulePromise2;
// Wait for the second call to be in the 'executing' state.
await vi.waitFor(() => {
const calls = onToolCallsUpdate.mock.calls.at(-1)?.[0] as ToolCall[];
expect(calls?.[0]?.status).toBe('executing');
});
// Now the second tool call should have been executed.
expect(mockTool.executeFn).toHaveBeenCalledTimes(2);
expect(mockTool.executeFn).toHaveBeenCalledWith({ b: 2 });
// Let the second call finish.
const secondCallResult = {
llmContent: 'Second call complete',
returnDisplay: 'Second call complete',
};
// Since the mock is shared, we need to resolve the current promise.
// In a real scenario, a new promise would be created for the second call.
resolveFirstCall!(secondCallResult);
// Wait for the second completion.
await vi.waitFor(() => {
expect(onAllToolCallsComplete).toHaveBeenCalledTimes(2);
});
// Verify the completion callbacks were called correctly.
expect(onAllToolCallsComplete.mock.calls[0][0][0].status).toBe('success');
expect(onAllToolCallsComplete.mock.calls[1][0][0].status).toBe('success');
});
it('should handle two synchronous calls to schedule', async () => {
const mockTool = new MockTool();
const declarativeTool = mockTool;
const toolRegistry = {
getTool: () => declarativeTool,
getToolByName: () => declarativeTool,
getFunctionDeclarations: () => [],
tools: new Map(),
discovery: {} as any,
registerTool: () => {},
getToolByDisplayName: () => declarativeTool,
getTools: () => [],
discoverTools: async () => {},
getAllTools: () => [],
getToolsByServer: () => [],
};
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const mockConfig = {
getSessionId: () => 'test-session-id',
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getApprovalMode: () => ApprovalMode.YOLO,
} as unknown as Config;
const scheduler = new CoreToolScheduler({
config: mockConfig,
toolRegistry: Promise.resolve(toolRegistry as any),
onAllToolCallsComplete,
onToolCallsUpdate,
getPreferredEditor: () => 'vscode',
onEditorClose: vi.fn(),
});
const abortController = new AbortController();
const request1 = {
callId: '1',
name: 'mockTool',
args: { a: 1 },
isClientInitiated: false,
prompt_id: 'prompt-1',
};
const request2 = {
callId: '2',
name: 'mockTool',
args: { b: 2 },
isClientInitiated: false,
prompt_id: 'prompt-2',
};
// Schedule two calls synchronously.
const schedulePromise1 = scheduler.schedule(
[request1],
abortController.signal,
);
const schedulePromise2 = scheduler.schedule(
[request2],
abortController.signal,
);
// Wait for both promises to resolve.
await Promise.all([schedulePromise1, schedulePromise2]);
// Ensure the tool was called twice with the correct arguments.
expect(mockTool.executeFn).toHaveBeenCalledTimes(2);
expect(mockTool.executeFn).toHaveBeenCalledWith({ a: 1 });
expect(mockTool.executeFn).toHaveBeenCalledWith({ b: 2 });
// Ensure completion callbacks were called twice.
expect(onAllToolCallsComplete).toHaveBeenCalledTimes(2);
});
});

View File

@ -125,7 +125,7 @@ export type OutputUpdateHandler = (
export type AllToolCallsCompleteHandler = ( export type AllToolCallsCompleteHandler = (
completedToolCalls: CompletedToolCall[], completedToolCalls: CompletedToolCall[],
) => void; ) => Promise<void>;
export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void; export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
@ -244,6 +244,14 @@ export class CoreToolScheduler {
private getPreferredEditor: () => EditorType | undefined; private getPreferredEditor: () => EditorType | undefined;
private config: Config; private config: Config;
private onEditorClose: () => void; private onEditorClose: () => void;
private isFinalizingToolCalls = false;
private isScheduling = false;
private requestQueue: Array<{
request: ToolCallRequestInfo | ToolCallRequestInfo[];
signal: AbortSignal;
resolve: () => void;
reject: (reason?: Error) => void;
}> = [];
constructor(options: CoreToolSchedulerOptions) { constructor(options: CoreToolSchedulerOptions) {
this.config = options.config; this.config = options.config;
@ -455,9 +463,12 @@ export class CoreToolScheduler {
} }
private isRunning(): boolean { private isRunning(): boolean {
return this.toolCalls.some( return (
this.isFinalizingToolCalls ||
this.toolCalls.some(
(call) => (call) =>
call.status === 'executing' || call.status === 'awaiting_approval', call.status === 'executing' || call.status === 'awaiting_approval',
)
); );
} }
@ -475,10 +486,48 @@ export class CoreToolScheduler {
} }
} }
async schedule( schedule(
request: ToolCallRequestInfo | ToolCallRequestInfo[], request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal, signal: AbortSignal,
): Promise<void> { ): Promise<void> {
if (this.isRunning() || this.isScheduling) {
return new Promise((resolve, reject) => {
const abortHandler = () => {
// Find and remove the request from the queue
const index = this.requestQueue.findIndex(
(item) => item.request === request,
);
if (index > -1) {
this.requestQueue.splice(index, 1);
reject(new Error('Tool call cancelled while in queue.'));
}
};
signal.addEventListener('abort', abortHandler, { once: true });
this.requestQueue.push({
request,
signal,
resolve: () => {
signal.removeEventListener('abort', abortHandler);
resolve();
},
reject: (reason?: Error) => {
signal.removeEventListener('abort', abortHandler);
reject(reason);
},
});
});
}
return this._schedule(request, signal);
}
private async _schedule(
request: ToolCallRequestInfo | ToolCallRequestInfo[],
signal: AbortSignal,
): Promise<void> {
this.isScheduling = true;
try {
if (this.isRunning()) { if (this.isRunning()) {
throw new Error( throw new Error(
'Cannot schedule new tool calls while other tool calls are actively running (executing or awaiting approval).', 'Cannot schedule new tool calls while other tool calls are actively running (executing or awaiting approval).',
@ -618,7 +667,10 @@ export class CoreToolScheduler {
} }
} }
this.attemptExecutionOfScheduledCalls(signal); this.attemptExecutionOfScheduledCalls(signal);
this.checkAndNotifyCompletion(); void this.checkAndNotifyCompletion();
} finally {
this.isScheduling = false;
}
} }
async handleConfirmationResponse( async handleConfirmationResponse(
@ -822,7 +874,7 @@ export class CoreToolScheduler {
} }
} }
private checkAndNotifyCompletion(): void { private async checkAndNotifyCompletion(): Promise<void> {
const allCallsAreTerminal = this.toolCalls.every( const allCallsAreTerminal = this.toolCalls.every(
(call) => (call) =>
call.status === 'success' || call.status === 'success' ||
@ -839,9 +891,18 @@ export class CoreToolScheduler {
} }
if (this.onAllToolCallsComplete) { if (this.onAllToolCallsComplete) {
this.onAllToolCallsComplete(completedCalls); this.isFinalizingToolCalls = true;
await this.onAllToolCallsComplete(completedCalls);
this.isFinalizingToolCalls = false;
} }
this.notifyToolCallsUpdate(); this.notifyToolCallsUpdate();
// After completion, process the next item in the queue.
if (this.requestQueue.length > 0) {
const next = this.requestQueue.shift()!;
this._schedule(next.request, next.signal)
.then(next.resolve)
.catch(next.reject);
}
} }
} }