Skip to content

Commit 5bed970

Browse files
authored
feat(hooks): Hook LLM Request/Response Integration (#9110)
1 parent b2bdfcf commit 5bed970

File tree

3 files changed

+365
-17
lines changed

3 files changed

+365
-17
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { AuthType } from './contentGenerator.js';
2727
import { TerminalQuotaError } from '../utils/googleQuotaErrors.js';
2828
import { retryWithBackoff, type RetryOptions } from '../utils/retry.js';
2929
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
30+
import { HookSystem } from '../hooks/hookSystem.js';
31+
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
3032

3133
// Mock fs module to prevent actual file system operations during tests
3234
const mockFileSystem = new Map<string, string>();
@@ -154,12 +156,20 @@ describe('GeminiChat', () => {
154156
isPreviewModelFallbackMode: vi.fn().mockReturnValue(false),
155157
setPreviewModelFallbackMode: vi.fn(),
156158
isInteractive: vi.fn().mockReturnValue(false),
159+
getEnableHooks: vi.fn().mockReturnValue(false),
157160
} as unknown as Config;
158161

162+
// Use proper MessageBus mocking for Phase 3 preparation
163+
const mockMessageBus = createMockMessageBus();
164+
mockConfig.getMessageBus = vi.fn().mockReturnValue(mockMessageBus);
165+
159166
// Disable 429 simulation for tests
160167
setSimulate429(false);
161168
// Reset history for each test by creating a new instance
162169
chat = new GeminiChat(mockConfig);
170+
mockConfig.getHookSystem = vi
171+
.fn()
172+
.mockReturnValue(new HookSystem(mockConfig));
163173
});
164174

165175
afterEach(() => {

packages/core/src/core/geminiChat.ts

Lines changed: 120 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
Tool,
1515
PartListUnion,
1616
GenerateContentConfig,
17+
GenerateContentParameters,
1718
} from '@google/genai';
1819
import { ThinkingLevel } from '@google/genai';
1920
import { toParts } from '../code_assist/converter.js';
@@ -47,6 +48,11 @@ import { isFunctionResponse } from '../utils/messageInspectors.js';
4748
import { partListUnionToString } from './geminiRequest.js';
4849
import type { ModelConfigKey } from '../services/modelConfigService.js';
4950
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
51+
import {
52+
fireAfterModelHook,
53+
fireBeforeModelHook,
54+
fireBeforeToolSelectionHook,
55+
} from './geminiChatHookTriggers.js';
5056

5157
export enum StreamEventType {
5258
/** A regular content chunk from the API. */
@@ -287,17 +293,17 @@ export class GeminiChat {
287293
this.history.push(userContent);
288294
const requestContents = this.getHistory(true);
289295

290-
// eslint-disable-next-line @typescript-eslint/no-this-alias
291-
const self = this;
292-
return (async function* () {
296+
const streamWithRetries = async function* (
297+
this: GeminiChat,
298+
): AsyncGenerator<StreamEvent, void, void> {
293299
try {
294300
let lastError: unknown = new Error('Request failed after all retries.');
295301

296302
let maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
297303
// If we are in Preview Model Fallback Mode, we want to fail fast (1 attempt)
298304
// when probing the Preview Model.
299305
if (
300-
self.config.isPreviewModelFallbackMode() &&
306+
this.config.isPreviewModelFallbackMode() &&
301307
model === PREVIEW_GEMINI_MODEL
302308
) {
303309
maxAttempts = 1;
@@ -314,7 +320,7 @@ export class GeminiChat {
314320
generateContentConfig.temperature = 1;
315321
}
316322

317-
const stream = await self.makeApiCallAndProcessStream(
323+
const stream = await this.makeApiCallAndProcessStream(
318324
model,
319325
generateContentConfig,
320326
requestContents,
@@ -335,7 +341,7 @@ export class GeminiChat {
335341
// Check if we have more attempts left.
336342
if (attempt < maxAttempts - 1) {
337343
logContentRetry(
338-
self.config,
344+
this.config,
339345
new ContentRetryEvent(
340346
attempt,
341347
(error as InvalidStreamError).type,
@@ -363,7 +369,7 @@ export class GeminiChat {
363369
isGemini2Model(model)
364370
) {
365371
logContentRetryFailure(
366-
self.config,
372+
this.config,
367373
new ContentRetryFailureEvent(
368374
maxAttempts,
369375
(lastError as InvalidStreamError).type,
@@ -377,15 +383,17 @@ export class GeminiChat {
377383
// We only do this if we didn't bypass Preview Model (i.e. we actually used it).
378384
if (
379385
model === PREVIEW_GEMINI_MODEL &&
380-
!self.config.isPreviewModelBypassMode()
386+
!this.config.isPreviewModelBypassMode()
381387
) {
382-
self.config.setPreviewModelFallbackMode(false);
388+
this.config.setPreviewModelFallbackMode(false);
383389
}
384390
}
385391
} finally {
386392
streamDoneResolver!();
387393
}
388-
})();
394+
};
395+
396+
return streamWithRetries.call(this);
389397
}
390398

391399
private async makeApiCallAndProcessStream(
@@ -397,7 +405,13 @@ export class GeminiChat {
397405
let effectiveModel = model;
398406
const contentsForPreviewModel =
399407
this.ensureActiveLoopHasThoughtSignatures(requestContents);
400-
const apiCall = () => {
408+
409+
// Track final request parameters for AfterModel hooks
410+
let lastModelToUse = model;
411+
let lastConfig: GenerateContentConfig = generateContentConfig;
412+
let lastContentsToUse: Content[] = requestContents;
413+
414+
const apiCall = async () => {
401415
let modelToUse = getEffectiveModel(
402416
this.config.isInFallbackMode(),
403417
model,
@@ -439,14 +453,79 @@ export class GeminiChat {
439453
};
440454
delete config.thinkingConfig?.thinkingLevel;
441455
}
456+
let contentsToUse =
457+
modelToUse === PREVIEW_GEMINI_MODEL
458+
? contentsForPreviewModel
459+
: requestContents;
460+
461+
// Fire BeforeModel and BeforeToolSelection hooks if enabled
462+
const hooksEnabled = this.config.getEnableHooks();
463+
const messageBus = this.config.getMessageBus();
464+
if (hooksEnabled && messageBus) {
465+
// Fire BeforeModel hook
466+
const beforeModelResult = await fireBeforeModelHook(messageBus, {
467+
model: modelToUse,
468+
config,
469+
contents: contentsToUse,
470+
});
471+
472+
// Check if hook blocked the model call
473+
if (beforeModelResult.blocked) {
474+
// Return a synthetic response generator
475+
const syntheticResponse = beforeModelResult.syntheticResponse;
476+
if (syntheticResponse) {
477+
return (async function* () {
478+
yield syntheticResponse;
479+
})();
480+
}
481+
// If blocked without synthetic response, return empty generator
482+
return (async function* () {
483+
// Empty generator - no response
484+
})();
485+
}
486+
487+
// Apply modifications from BeforeModel hook
488+
if (beforeModelResult.modifiedConfig) {
489+
Object.assign(config, beforeModelResult.modifiedConfig);
490+
}
491+
if (
492+
beforeModelResult.modifiedContents &&
493+
Array.isArray(beforeModelResult.modifiedContents)
494+
) {
495+
contentsToUse = beforeModelResult.modifiedContents as Content[];
496+
}
497+
498+
// Fire BeforeToolSelection hook
499+
const toolSelectionResult = await fireBeforeToolSelectionHook(
500+
messageBus,
501+
{
502+
model: modelToUse,
503+
config,
504+
contents: contentsToUse,
505+
},
506+
);
507+
508+
// Apply tool configuration modifications
509+
if (toolSelectionResult.toolConfig) {
510+
config.toolConfig = toolSelectionResult.toolConfig;
511+
}
512+
if (
513+
toolSelectionResult.tools &&
514+
Array.isArray(toolSelectionResult.tools)
515+
) {
516+
config.tools = toolSelectionResult.tools as Tool[];
517+
}
518+
}
519+
520+
// Track final request parameters for AfterModel hooks
521+
lastModelToUse = modelToUse;
522+
lastConfig = config;
523+
lastContentsToUse = contentsToUse;
442524

443525
return this.config.getContentGenerator().generateContentStream(
444526
{
445527
model: modelToUse,
446-
contents:
447-
modelToUse === PREVIEW_GEMINI_MODEL
448-
? contentsForPreviewModel
449-
: requestContents,
528+
contents: contentsToUse,
450529
config,
451530
},
452531
prompt_id,
@@ -470,7 +549,18 @@ export class GeminiChat {
470549
: undefined,
471550
});
472551

473-
return this.processStreamResponse(effectiveModel, streamResponse);
552+
// Store the original request for AfterModel hooks
553+
const originalRequest: GenerateContentParameters = {
554+
model: lastModelToUse,
555+
config: lastConfig,
556+
contents: lastContentsToUse,
557+
};
558+
559+
return this.processStreamResponse(
560+
effectiveModel,
561+
streamResponse,
562+
originalRequest,
563+
);
474564
}
475565

476566
/**
@@ -624,6 +714,7 @@ export class GeminiChat {
624714
private async *processStreamResponse(
625715
model: string,
626716
streamResponse: AsyncGenerator<GenerateContentResponse>,
717+
originalRequest: GenerateContentParameters,
627718
): AsyncGenerator<GenerateContentResponse> {
628719
const modelResponseParts: Part[] = [];
629720

@@ -663,7 +754,19 @@ export class GeminiChat {
663754
}
664755
}
665756

666-
yield chunk; // Yield every chunk to the UI immediately.
757+
// Fire AfterModel hook through MessageBus (only if hooks are enabled)
758+
const hooksEnabled = this.config.getEnableHooks();
759+
const messageBus = this.config.getMessageBus();
760+
if (hooksEnabled && messageBus && originalRequest && chunk) {
761+
const hookResult = await fireAfterModelHook(
762+
messageBus,
763+
originalRequest,
764+
chunk,
765+
);
766+
yield hookResult.response;
767+
} else {
768+
yield chunk; // Yield every chunk to the UI immediately.
769+
}
667770
}
668771

669772
// String thoughts and consolidate text parts.

0 commit comments

Comments
 (0)