Skip to content

Commit 206fa08

Browse files
feat(hooks): Support explicit stop and block execution control in model hooks (google-gemini#15947)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
1 parent e55b929 commit 206fa08

File tree

7 files changed

+517
-65
lines changed

7 files changed

+517
-65
lines changed

packages/core/src/core/geminiChat.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,18 @@ import { createAvailabilityServiceMock } from '../availability/testUtils.js';
2828
import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
2929
import * as policyHelpers from '../availability/policyHelpers.js';
3030
import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js';
31+
import {
32+
fireBeforeModelHook,
33+
fireAfterModelHook,
34+
fireBeforeToolSelectionHook,
35+
} from './geminiChatHookTriggers.js';
36+
37+
// Mock hook triggers
38+
vi.mock('./geminiChatHookTriggers.js', () => ({
39+
fireBeforeModelHook: vi.fn(),
40+
fireAfterModelHook: vi.fn(),
41+
fireBeforeToolSelectionHook: vi.fn().mockResolvedValue({}),
42+
}));
3143

3244
// Mock fs module to prevent actual file system operations during tests
3345
const mockFileSystem = new Map<string, string>();
@@ -2269,4 +2281,151 @@ describe('GeminiChat', () => {
22692281
);
22702282
});
22712283
});
2284+
2285+
describe('Hook execution control', () => {
2286+
beforeEach(() => {
2287+
vi.mocked(mockConfig.getEnableHooks).mockReturnValue(true);
2288+
// Default to allowing execution
2289+
vi.mocked(fireBeforeModelHook).mockResolvedValue({ blocked: false });
2290+
vi.mocked(fireAfterModelHook).mockResolvedValue({
2291+
response: {} as GenerateContentResponse,
2292+
});
2293+
vi.mocked(fireBeforeToolSelectionHook).mockResolvedValue({});
2294+
});
2295+
2296+
it('should yield AGENT_EXECUTION_STOPPED when BeforeModel hook stops execution', async () => {
2297+
vi.mocked(fireBeforeModelHook).mockResolvedValue({
2298+
blocked: true,
2299+
stopped: true,
2300+
reason: 'stopped by hook',
2301+
});
2302+
2303+
const stream = await chat.sendMessageStream(
2304+
{ model: 'gemini-pro' },
2305+
'test',
2306+
'prompt-id',
2307+
new AbortController().signal,
2308+
);
2309+
2310+
const events: StreamEvent[] = [];
2311+
for await (const event of stream) {
2312+
events.push(event);
2313+
}
2314+
2315+
expect(events).toHaveLength(1);
2316+
expect(events[0]).toEqual({
2317+
type: StreamEventType.AGENT_EXECUTION_STOPPED,
2318+
reason: 'stopped by hook',
2319+
});
2320+
});
2321+
2322+
it('should yield AGENT_EXECUTION_BLOCKED and synthetic response when BeforeModel hook blocks execution', async () => {
2323+
const syntheticResponse = {
2324+
candidates: [{ content: { parts: [{ text: 'blocked' }] } }],
2325+
} as GenerateContentResponse;
2326+
2327+
vi.mocked(fireBeforeModelHook).mockResolvedValue({
2328+
blocked: true,
2329+
reason: 'blocked by hook',
2330+
syntheticResponse,
2331+
});
2332+
2333+
const stream = await chat.sendMessageStream(
2334+
{ model: 'gemini-pro' },
2335+
'test',
2336+
'prompt-id',
2337+
new AbortController().signal,
2338+
);
2339+
2340+
const events: StreamEvent[] = [];
2341+
for await (const event of stream) {
2342+
events.push(event);
2343+
}
2344+
2345+
expect(events).toHaveLength(2);
2346+
expect(events[0]).toEqual({
2347+
type: StreamEventType.AGENT_EXECUTION_BLOCKED,
2348+
reason: 'blocked by hook',
2349+
});
2350+
expect(events[1]).toEqual({
2351+
type: StreamEventType.CHUNK,
2352+
value: syntheticResponse,
2353+
});
2354+
});
2355+
2356+
it('should yield AGENT_EXECUTION_STOPPED when AfterModel hook stops execution', async () => {
2357+
// Mock content generator to return a stream
2358+
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
2359+
(async function* () {
2360+
yield {
2361+
candidates: [{ content: { parts: [{ text: 'response' }] } }],
2362+
} as unknown as GenerateContentResponse;
2363+
})(),
2364+
);
2365+
2366+
vi.mocked(fireAfterModelHook).mockResolvedValue({
2367+
response: {} as GenerateContentResponse,
2368+
stopped: true,
2369+
reason: 'stopped by after hook',
2370+
});
2371+
2372+
const stream = await chat.sendMessageStream(
2373+
{ model: 'gemini-pro' },
2374+
'test',
2375+
'prompt-id',
2376+
new AbortController().signal,
2377+
);
2378+
2379+
const events: StreamEvent[] = [];
2380+
for await (const event of stream) {
2381+
events.push(event);
2382+
}
2383+
2384+
expect(events).toContainEqual({
2385+
type: StreamEventType.AGENT_EXECUTION_STOPPED,
2386+
reason: 'stopped by after hook',
2387+
});
2388+
});
2389+
2390+
it('should yield AGENT_EXECUTION_BLOCKED and response when AfterModel hook blocks execution', async () => {
2391+
const response = {
2392+
candidates: [{ content: { parts: [{ text: 'response' }] } }],
2393+
} as unknown as GenerateContentResponse;
2394+
2395+
// Mock content generator to return a stream
2396+
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
2397+
(async function* () {
2398+
yield response;
2399+
})(),
2400+
);
2401+
2402+
vi.mocked(fireAfterModelHook).mockResolvedValue({
2403+
response,
2404+
blocked: true,
2405+
reason: 'blocked by after hook',
2406+
});
2407+
2408+
const stream = await chat.sendMessageStream(
2409+
{ model: 'gemini-pro' },
2410+
'test',
2411+
'prompt-id',
2412+
new AbortController().signal,
2413+
);
2414+
2415+
const events: StreamEvent[] = [];
2416+
for await (const event of stream) {
2417+
events.push(event);
2418+
}
2419+
2420+
expect(events).toContainEqual({
2421+
type: StreamEventType.AGENT_EXECUTION_BLOCKED,
2422+
reason: 'blocked by after hook',
2423+
});
2424+
// Should also contain the chunk (hook response)
2425+
expect(events).toContainEqual({
2426+
type: StreamEventType.CHUNK,
2427+
value: response,
2428+
});
2429+
});
2430+
});
22722431
});

packages/core/src/core/geminiChat.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,17 @@ export enum StreamEventType {
6161
/** A signal that a retry is about to happen. The UI should discard any partial
6262
* content from the attempt that just failed. */
6363
RETRY = 'retry',
64+
/** A signal that the agent execution has been stopped by a hook. */
65+
AGENT_EXECUTION_STOPPED = 'agent_execution_stopped',
66+
/** A signal that the agent execution has been blocked by a hook. */
67+
AGENT_EXECUTION_BLOCKED = 'agent_execution_blocked',
6468
}
6569

6670
export type StreamEvent =
6771
| { type: StreamEventType.CHUNK; value: GenerateContentResponse }
68-
| { type: StreamEventType.RETRY };
72+
| { type: StreamEventType.RETRY }
73+
| { type: StreamEventType.AGENT_EXECUTION_STOPPED; reason: string }
74+
| { type: StreamEventType.AGENT_EXECUTION_BLOCKED; reason: string };
6975

7076
/**
7177
* Options for retrying due to invalid content from the model.
@@ -197,6 +203,29 @@ export class InvalidStreamError extends Error {
197203
}
198204
}
199205

206+
/**
207+
* Custom error to signal that agent execution has been stopped.
208+
*/
209+
export class AgentExecutionStoppedError extends Error {
210+
constructor(public reason: string) {
211+
super(reason);
212+
this.name = 'AgentExecutionStoppedError';
213+
}
214+
}
215+
216+
/**
217+
* Custom error to signal that agent execution has been blocked.
218+
*/
219+
export class AgentExecutionBlockedError extends Error {
220+
constructor(
221+
public reason: string,
222+
public syntheticResponse?: GenerateContentResponse,
223+
) {
224+
super(reason);
225+
this.name = 'AgentExecutionBlockedError';
226+
}
227+
}
228+
200229
/**
201230
* Chat session that enables sending messages to the model with previous
202231
* conversation context.
@@ -325,6 +354,30 @@ export class GeminiChat {
325354
lastError = null;
326355
break;
327356
} catch (error) {
357+
if (error instanceof AgentExecutionStoppedError) {
358+
yield {
359+
type: StreamEventType.AGENT_EXECUTION_STOPPED,
360+
reason: error.reason,
361+
};
362+
lastError = null; // Clear error as this is an expected stop
363+
return; // Stop the generator
364+
}
365+
366+
if (error instanceof AgentExecutionBlockedError) {
367+
yield {
368+
type: StreamEventType.AGENT_EXECUTION_BLOCKED,
369+
reason: error.reason,
370+
};
371+
if (error.syntheticResponse) {
372+
yield {
373+
type: StreamEventType.CHUNK,
374+
value: error.syntheticResponse,
375+
};
376+
}
377+
lastError = null; // Clear error as this is an expected stop
378+
return; // Stop the generator
379+
}
380+
328381
if (isConnectionPhase) {
329382
throw error;
330383
}
@@ -457,19 +510,35 @@ export class GeminiChat {
457510
contents: contentsToUse,
458511
});
459512

513+
// Check if hook requested to stop execution
514+
if (beforeModelResult.stopped) {
515+
throw new AgentExecutionStoppedError(
516+
beforeModelResult.reason || 'Agent execution stopped by hook',
517+
);
518+
}
519+
460520
// Check if hook blocked the model call
461521
if (beforeModelResult.blocked) {
462522
// Return a synthetic response generator
463523
const syntheticResponse = beforeModelResult.syntheticResponse;
464524
if (syntheticResponse) {
465-
return (async function* () {
466-
yield syntheticResponse;
467-
})();
525+
// Ensure synthetic response has a finish reason to prevent InvalidStreamError
526+
if (
527+
syntheticResponse.candidates &&
528+
syntheticResponse.candidates.length > 0
529+
) {
530+
for (const candidate of syntheticResponse.candidates) {
531+
if (!candidate.finishReason) {
532+
candidate.finishReason = FinishReason.STOP;
533+
}
534+
}
535+
}
468536
}
469-
// If blocked without synthetic response, return empty generator
470-
return (async function* () {
471-
// Empty generator - no response
472-
})();
537+
538+
throw new AgentExecutionBlockedError(
539+
beforeModelResult.reason || 'Model call blocked by hook',
540+
syntheticResponse,
541+
);
473542
}
474543

475544
// Apply modifications from BeforeModel hook
@@ -748,6 +817,20 @@ export class GeminiChat {
748817
originalRequest,
749818
chunk,
750819
);
820+
821+
if (hookResult.stopped) {
822+
throw new AgentExecutionStoppedError(
823+
hookResult.reason || 'Agent execution stopped by hook',
824+
);
825+
}
826+
827+
if (hookResult.blocked) {
828+
throw new AgentExecutionBlockedError(
829+
hookResult.reason || 'Agent execution blocked by hook',
830+
hookResult.response,
831+
);
832+
}
833+
751834
yield hookResult.response;
752835
} else {
753836
yield chunk; // Yield every chunk to the UI immediately.

0 commit comments

Comments
 (0)