Skip to content

Commit 1195159

Browse files
authored
feat(core): pause agent timeout budget while waiting for tool confirmation (#18415)
1 parent bc8ffa6 commit 1195159

7 files changed

Lines changed: 299 additions & 10 deletions

File tree

packages/core/src/agents/agent-scheduler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface AgentSchedulingOptions {
2727
signal: AbortSignal;
2828
/** Optional function to get the preferred editor for tool modifications. */
2929
getPreferredEditor?: () => EditorType | undefined;
30+
/** Optional function to be notified when the scheduler is waiting for user confirmation. */
31+
onWaitingForConfirmation?: (waiting: boolean) => void;
3032
}
3133

3234
/**
@@ -48,6 +50,7 @@ export async function scheduleAgentTools(
4850
toolRegistry,
4951
signal,
5052
getPreferredEditor,
53+
onWaitingForConfirmation,
5154
} = options;
5255

5356
// Create a proxy/override of the config to provide the agent-specific tool registry.
@@ -60,6 +63,7 @@ export async function scheduleAgentTools(
6063
getPreferredEditor: getPreferredEditor ?? (() => undefined),
6164
schedulerId,
6265
parentCallId,
66+
onWaitingForConfirmation,
6367
});
6468

6569
return scheduler.schedule(requests, signal);

packages/core/src/agents/local-executor.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import { getModelConfigAlias } from './registry.js';
5858
import { getVersion } from '../utils/version.js';
5959
import { getToolCallContext } from '../utils/toolCallContext.js';
6060
import { scheduleAgentTools } from './agent-scheduler.js';
61+
import { DeadlineTimer } from '../utils/deadlineTimer.js';
6162

6263
/** A callback function to report on agent activity. */
6364
export type ActivityCallback = (activity: SubagentActivityEvent) => void;
@@ -231,6 +232,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
231232
turnCounter: number,
232233
combinedSignal: AbortSignal,
233234
timeoutSignal: AbortSignal, // Pass the timeout controller's signal
235+
onWaitingForConfirmation?: (waiting: boolean) => void,
234236
): Promise<AgentTurnResult> {
235237
const promptId = `${this.agentId}#${turnCounter}`;
236238

@@ -265,7 +267,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
265267
}
266268

267269
const { nextMessage, submittedOutput, taskCompleted } =
268-
await this.processFunctionCalls(functionCalls, combinedSignal, promptId);
270+
await this.processFunctionCalls(
271+
functionCalls,
272+
combinedSignal,
273+
promptId,
274+
onWaitingForConfirmation,
275+
);
269276
if (taskCompleted) {
270277
const finalResult = submittedOutput ?? 'Task completed successfully.';
271278
return {
@@ -322,6 +329,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
322329
| AgentTerminateMode.MAX_TURNS
323330
| AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL,
324331
externalSignal: AbortSignal, // The original signal passed to run()
332+
onWaitingForConfirmation?: (waiting: boolean) => void,
325333
): Promise<string | null> {
326334
this.emitActivity('THOUGHT_CHUNK', {
327335
text: `Execution limit reached (${reason}). Attempting one final recovery turn with a grace period.`,
@@ -355,6 +363,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
355363
turnCounter, // This will be the "last" turn number
356364
combinedSignal,
357365
graceTimeoutController.signal, // Pass grace signal to identify a *grace* timeout
366+
onWaitingForConfirmation,
358367
);
359368

360369
if (
@@ -415,14 +424,22 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
415424
this.definition.runConfig.maxTimeMinutes ?? DEFAULT_MAX_TIME_MINUTES;
416425
const maxTurns = this.definition.runConfig.maxTurns ?? DEFAULT_MAX_TURNS;
417426

418-
const timeoutController = new AbortController();
419-
const timeoutId = setTimeout(
420-
() => timeoutController.abort(new Error('Agent timed out.')),
427+
const deadlineTimer = new DeadlineTimer(
421428
maxTimeMinutes * 60 * 1000,
429+
'Agent timed out.',
422430
);
423431

432+
// Track time spent waiting for user confirmation to credit it back to the agent.
433+
const onWaitingForConfirmation = (waiting: boolean) => {
434+
if (waiting) {
435+
deadlineTimer.pause();
436+
} else {
437+
deadlineTimer.resume();
438+
}
439+
};
440+
424441
// Combine the external signal with the internal timeout signal.
425-
const combinedSignal = AbortSignal.any([signal, timeoutController.signal]);
442+
const combinedSignal = AbortSignal.any([signal, deadlineTimer.signal]);
426443

427444
logAgentStart(
428445
this.runtimeContext,
@@ -458,7 +475,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
458475
// Check for timeout or external abort.
459476
if (combinedSignal.aborted) {
460477
// Determine which signal caused the abort.
461-
terminateReason = timeoutController.signal.aborted
478+
terminateReason = deadlineTimer.signal.aborted
462479
? AgentTerminateMode.TIMEOUT
463480
: AgentTerminateMode.ABORTED;
464481
break;
@@ -469,7 +486,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
469486
currentMessage,
470487
turnCounter++,
471488
combinedSignal,
472-
timeoutController.signal,
489+
deadlineTimer.signal,
490+
onWaitingForConfirmation,
473491
);
474492

475493
if (turnResult.status === 'stop') {
@@ -498,6 +516,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
498516
turnCounter, // Use current turnCounter for the recovery attempt
499517
terminateReason,
500518
signal, // Pass the external signal
519+
onWaitingForConfirmation,
501520
);
502521

503522
if (recoveryResult !== null) {
@@ -551,7 +570,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
551570
if (
552571
error instanceof Error &&
553572
error.name === 'AbortError' &&
554-
timeoutController.signal.aborted &&
573+
deadlineTimer.signal.aborted &&
555574
!signal.aborted // Ensure the external signal was not the cause
556575
) {
557576
terminateReason = AgentTerminateMode.TIMEOUT;
@@ -563,6 +582,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
563582
turnCounter, // Use current turnCounter
564583
AgentTerminateMode.TIMEOUT,
565584
signal,
585+
onWaitingForConfirmation,
566586
);
567587

568588
if (recoveryResult !== null) {
@@ -591,7 +611,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
591611
this.emitActivity('ERROR', { error: String(error) });
592612
throw error; // Re-throw other errors or external aborts.
593613
} finally {
594-
clearTimeout(timeoutId);
614+
deadlineTimer.abort();
595615
logAgentFinish(
596616
this.runtimeContext,
597617
new AgentFinishEvent(
@@ -779,6 +799,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
779799
functionCalls: FunctionCall[],
780800
signal: AbortSignal,
781801
promptId: string,
802+
onWaitingForConfirmation?: (waiting: boolean) => void,
782803
): Promise<{
783804
nextMessage: Content;
784805
submittedOutput: string | null;
@@ -979,6 +1000,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
9791000
parentCallId: this.parentCallId,
9801001
toolRegistry: this.toolRegistry,
9811002
signal,
1003+
onWaitingForConfirmation,
9821004
},
9831005
);
9841006

packages/core/src/scheduler/confirmation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ export async function resolveConfirmation(
109109
modifier: ToolModificationHandler;
110110
getPreferredEditor: () => EditorType | undefined;
111111
schedulerId: string;
112+
onWaitingForConfirmation?: (waiting: boolean) => void;
112113
},
113114
): Promise<ResolutionResult> {
114-
const { state } = deps;
115+
const { state, onWaitingForConfirmation } = deps;
115116
const callId = toolCall.request.callId;
116117
let outcome = ToolConfirmationOutcome.ModifyWithEditor;
117118
let lastDetails: SerializableConfirmationDetails | undefined;
@@ -147,12 +148,14 @@ export async function resolveConfirmation(
147148
correlationId,
148149
});
149150

151+
onWaitingForConfirmation?.(true);
150152
const response = await waitForConfirmation(
151153
deps.messageBus,
152154
correlationId,
153155
signal,
154156
ideConfirmation,
155157
);
158+
onWaitingForConfirmation?.(false);
156159
outcome = response.outcome;
157160

158161
if ('onConfirm' in details && typeof details.onConfirm === 'function') {

packages/core/src/scheduler/scheduler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export interface SchedulerOptions {
5151
getPreferredEditor: () => EditorType | undefined;
5252
schedulerId: string;
5353
parentCallId?: string;
54+
onWaitingForConfirmation?: (waiting: boolean) => void;
5455
}
5556

5657
const createErrorResponse = (
@@ -90,6 +91,7 @@ export class Scheduler {
9091
private readonly getPreferredEditor: () => EditorType | undefined;
9192
private readonly schedulerId: string;
9293
private readonly parentCallId?: string;
94+
private readonly onWaitingForConfirmation?: (waiting: boolean) => void;
9395

9496
private isProcessing = false;
9597
private isCancelling = false;
@@ -101,6 +103,7 @@ export class Scheduler {
101103
this.getPreferredEditor = options.getPreferredEditor;
102104
this.schedulerId = options.schedulerId;
103105
this.parentCallId = options.parentCallId;
106+
this.onWaitingForConfirmation = options.onWaitingForConfirmation;
104107
this.state = new SchedulerStateManager(
105108
this.messageBus,
106109
this.schedulerId,
@@ -437,6 +440,7 @@ export class Scheduler {
437440
modifier: this.modifier,
438441
getPreferredEditor: this.getPreferredEditor,
439442
schedulerId: this.schedulerId,
443+
onWaitingForConfirmation: this.onWaitingForConfirmation,
440444
});
441445
outcome = result.outcome;
442446
lastDetails = result.lastDetails;
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach } from 'vitest';
8+
import { Scheduler } from './scheduler.js';
9+
import { resolveConfirmation } from './confirmation.js';
10+
import { checkPolicy } from './policy.js';
11+
import { PolicyDecision } from '../policy/types.js';
12+
import { ToolConfirmationOutcome } from '../tools/tools.js';
13+
import { ToolRegistry } from '../tools/tool-registry.js';
14+
import { MockTool } from '../test-utils/mock-tool.js';
15+
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
16+
import { makeFakeConfig } from '../test-utils/config.js';
17+
import type { Config } from '../config/config.js';
18+
import type { ToolCallRequestInfo } from './types.js';
19+
import type { MessageBus } from '../confirmation-bus/message-bus.js';
20+
21+
vi.mock('./confirmation.js');
22+
vi.mock('./policy.js');
23+
24+
describe('Scheduler waiting callback', () => {
25+
let mockConfig: Config;
26+
let messageBus: MessageBus;
27+
let toolRegistry: ToolRegistry;
28+
let mockTool: MockTool;
29+
30+
beforeEach(() => {
31+
messageBus = createMockMessageBus();
32+
mockConfig = makeFakeConfig();
33+
34+
// Override methods to use our mocks
35+
vi.spyOn(mockConfig, 'getMessageBus').mockReturnValue(messageBus);
36+
37+
mockTool = new MockTool({ name: 'test_tool' });
38+
toolRegistry = new ToolRegistry(mockConfig, messageBus);
39+
vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue(toolRegistry);
40+
toolRegistry.registerTool(mockTool);
41+
42+
vi.mocked(checkPolicy).mockResolvedValue({
43+
decision: PolicyDecision.ASK_USER,
44+
rule: undefined,
45+
});
46+
});
47+
48+
it('should trigger onWaitingForConfirmation callback', async () => {
49+
const onWaitingForConfirmation = vi.fn();
50+
const scheduler = new Scheduler({
51+
config: mockConfig,
52+
messageBus,
53+
getPreferredEditor: () => undefined,
54+
schedulerId: 'test-scheduler',
55+
onWaitingForConfirmation,
56+
});
57+
58+
vi.mocked(resolveConfirmation).mockResolvedValue({
59+
outcome: ToolConfirmationOutcome.ProceedOnce,
60+
});
61+
62+
const req: ToolCallRequestInfo = {
63+
callId: 'call-1',
64+
name: 'test_tool',
65+
args: {},
66+
isClientInitiated: false,
67+
prompt_id: 'test-prompt',
68+
};
69+
70+
await scheduler.schedule(req, new AbortController().signal);
71+
72+
expect(resolveConfirmation).toHaveBeenCalledWith(
73+
expect.anything(),
74+
expect.anything(),
75+
expect.objectContaining({
76+
onWaitingForConfirmation,
77+
}),
78+
);
79+
});
80+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { DeadlineTimer } from './deadlineTimer.js';
9+
10+
describe('DeadlineTimer', () => {
11+
beforeEach(() => {
12+
vi.useFakeTimers();
13+
});
14+
15+
afterEach(() => {
16+
vi.restoreAllMocks();
17+
});
18+
19+
it('should abort when timeout is reached', () => {
20+
const timer = new DeadlineTimer(1000);
21+
const signal = timer.signal;
22+
expect(signal.aborted).toBe(false);
23+
24+
vi.advanceTimersByTime(1000);
25+
expect(signal.aborted).toBe(true);
26+
expect(signal.reason).toBeInstanceOf(Error);
27+
expect((signal.reason as Error).message).toBe('Timeout exceeded.');
28+
});
29+
30+
it('should allow extending the deadline', () => {
31+
const timer = new DeadlineTimer(1000);
32+
const signal = timer.signal;
33+
34+
vi.advanceTimersByTime(500);
35+
expect(signal.aborted).toBe(false);
36+
37+
timer.extend(1000); // New deadline is 1000 + 1000 = 2000 from start
38+
39+
vi.advanceTimersByTime(600); // 1100 total
40+
expect(signal.aborted).toBe(false);
41+
42+
vi.advanceTimersByTime(900); // 2000 total
43+
expect(signal.aborted).toBe(true);
44+
});
45+
46+
it('should allow pausing and resuming the timer', () => {
47+
const timer = new DeadlineTimer(1000);
48+
const signal = timer.signal;
49+
50+
vi.advanceTimersByTime(500);
51+
timer.pause();
52+
53+
vi.advanceTimersByTime(2000); // Wait a long time while paused
54+
expect(signal.aborted).toBe(false);
55+
56+
timer.resume();
57+
vi.advanceTimersByTime(400);
58+
expect(signal.aborted).toBe(false);
59+
60+
vi.advanceTimersByTime(200); // Total active time 500 + 400 + 200 = 1100
61+
expect(signal.aborted).toBe(true);
62+
});
63+
64+
it('should abort immediately when abort() is called', () => {
65+
const timer = new DeadlineTimer(1000);
66+
const signal = timer.signal;
67+
68+
timer.abort('cancelled');
69+
expect(signal.aborted).toBe(true);
70+
expect(signal.reason).toBe('cancelled');
71+
});
72+
73+
it('should not fire timeout if aborted manually', () => {
74+
const timer = new DeadlineTimer(1000);
75+
const signal = timer.signal;
76+
77+
timer.abort();
78+
vi.advanceTimersByTime(1000);
79+
// Already aborted, but shouldn't re-abort or throw
80+
expect(signal.aborted).toBe(true);
81+
});
82+
});

0 commit comments

Comments
 (0)