2025-11-24 14:31:48 -08:00
|
|
|
/**
|
|
|
|
|
* @license
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { Config } from '../config/config.js';
|
|
|
|
|
import { HookRegistry } from './hookRegistry.js';
|
|
|
|
|
import { HookRunner } from './hookRunner.js';
|
|
|
|
|
import { HookAggregator } from './hookAggregator.js';
|
|
|
|
|
import { HookPlanner } from './hookPlanner.js';
|
|
|
|
|
import { HookEventHandler } from './hookEventHandler.js';
|
|
|
|
|
import type { HookRegistryEntry } from './hookRegistry.js';
|
|
|
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
2026-01-08 00:41:49 +05:30
|
|
|
import type {
|
|
|
|
|
SessionStartSource,
|
|
|
|
|
SessionEndReason,
|
|
|
|
|
PreCompressTrigger,
|
2026-01-13 22:20:54 +05:30
|
|
|
DefaultHookOutput,
|
2026-01-20 23:16:54 +05:30
|
|
|
BeforeModelHookOutput,
|
|
|
|
|
AfterModelHookOutput,
|
|
|
|
|
BeforeToolSelectionHookOutput,
|
2026-01-21 22:43:03 +05:30
|
|
|
McpToolContext,
|
2026-01-08 00:41:49 +05:30
|
|
|
} from './types.js';
|
2026-01-24 18:52:08 +05:30
|
|
|
import { NotificationType } from './types.js';
|
2026-01-08 00:41:49 +05:30
|
|
|
import type { AggregatedHookResult } from './hookAggregator.js';
|
2026-01-20 23:16:54 +05:30
|
|
|
import type {
|
|
|
|
|
GenerateContentParameters,
|
|
|
|
|
GenerateContentResponse,
|
2026-01-21 10:04:50 +05:30
|
|
|
GenerateContentConfig,
|
|
|
|
|
ContentListUnion,
|
|
|
|
|
ToolConfig,
|
|
|
|
|
ToolListUnion,
|
2026-01-20 23:16:54 +05:30
|
|
|
} from '@google/genai';
|
2026-01-24 18:52:08 +05:30
|
|
|
import type { ToolCallConfirmationDetails } from '../tools/tools.js';
|
2026-01-21 10:04:50 +05:30
|
|
|
|
2025-11-24 14:31:48 -08:00
|
|
|
/**
|
|
|
|
|
* Main hook system that coordinates all hook-related functionality
|
|
|
|
|
*/
|
2026-01-21 10:04:50 +05:30
|
|
|
|
|
|
|
|
export interface BeforeModelHookResult {
|
|
|
|
|
/** Whether the model call was blocked */
|
|
|
|
|
blocked: boolean;
|
|
|
|
|
/** Whether the execution should be stopped entirely */
|
|
|
|
|
stopped?: boolean;
|
|
|
|
|
/** Reason for blocking (if blocked) */
|
|
|
|
|
reason?: string;
|
|
|
|
|
/** Synthetic response to return instead of calling the model (if blocked) */
|
|
|
|
|
syntheticResponse?: GenerateContentResponse;
|
|
|
|
|
/** Modified config (if not blocked) */
|
|
|
|
|
modifiedConfig?: GenerateContentConfig;
|
|
|
|
|
/** Modified contents (if not blocked) */
|
|
|
|
|
modifiedContents?: ContentListUnion;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Result from firing the BeforeToolSelection hook.
|
|
|
|
|
*/
|
|
|
|
|
export interface BeforeToolSelectionHookResult {
|
|
|
|
|
/** Modified tool config */
|
|
|
|
|
toolConfig?: ToolConfig;
|
|
|
|
|
/** Modified tools */
|
|
|
|
|
tools?: ToolListUnion;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Result from firing the AfterModel hook.
|
|
|
|
|
* Contains either a modified response or indicates to use the original chunk.
|
|
|
|
|
*/
|
|
|
|
|
export interface AfterModelHookResult {
|
|
|
|
|
/** The response to yield (either modified or original) */
|
|
|
|
|
response: GenerateContentResponse;
|
|
|
|
|
/** Whether the execution should be stopped entirely */
|
|
|
|
|
stopped?: boolean;
|
|
|
|
|
/** Whether the model call was blocked */
|
|
|
|
|
blocked?: boolean;
|
|
|
|
|
/** Reason for blocking or stopping */
|
|
|
|
|
reason?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 18:52:08 +05:30
|
|
|
/**
|
|
|
|
|
* Converts ToolCallConfirmationDetails to a serializable format for hooks.
|
|
|
|
|
* Excludes function properties (onConfirm, ideConfirmation) that can't be serialized.
|
|
|
|
|
*/
|
|
|
|
|
function toSerializableDetails(
|
|
|
|
|
details: ToolCallConfirmationDetails,
|
|
|
|
|
): Record<string, unknown> {
|
|
|
|
|
const base: Record<string, unknown> = {
|
|
|
|
|
type: details.type,
|
|
|
|
|
title: details.title,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (details.type) {
|
|
|
|
|
case 'edit':
|
|
|
|
|
return {
|
|
|
|
|
...base,
|
|
|
|
|
fileName: details.fileName,
|
|
|
|
|
filePath: details.filePath,
|
|
|
|
|
fileDiff: details.fileDiff,
|
|
|
|
|
originalContent: details.originalContent,
|
|
|
|
|
newContent: details.newContent,
|
|
|
|
|
isModifying: details.isModifying,
|
|
|
|
|
};
|
|
|
|
|
case 'exec':
|
|
|
|
|
return {
|
|
|
|
|
...base,
|
|
|
|
|
command: details.command,
|
|
|
|
|
rootCommand: details.rootCommand,
|
|
|
|
|
};
|
|
|
|
|
case 'mcp':
|
|
|
|
|
return {
|
|
|
|
|
...base,
|
|
|
|
|
serverName: details.serverName,
|
|
|
|
|
toolName: details.toolName,
|
|
|
|
|
toolDisplayName: details.toolDisplayName,
|
|
|
|
|
};
|
|
|
|
|
case 'info':
|
|
|
|
|
return {
|
|
|
|
|
...base,
|
|
|
|
|
prompt: details.prompt,
|
|
|
|
|
urls: details.urls,
|
|
|
|
|
};
|
|
|
|
|
default:
|
|
|
|
|
return base;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gets the message to display in the notification hook for tool confirmation.
|
|
|
|
|
*/
|
|
|
|
|
function getNotificationMessage(
|
|
|
|
|
confirmationDetails: ToolCallConfirmationDetails,
|
|
|
|
|
): string {
|
|
|
|
|
switch (confirmationDetails.type) {
|
|
|
|
|
case 'edit':
|
|
|
|
|
return `Tool ${confirmationDetails.title} requires editing`;
|
|
|
|
|
case 'exec':
|
|
|
|
|
return `Tool ${confirmationDetails.title} requires execution`;
|
|
|
|
|
case 'mcp':
|
|
|
|
|
return `Tool ${confirmationDetails.title} requires MCP`;
|
|
|
|
|
case 'info':
|
|
|
|
|
return `Tool ${confirmationDetails.title} requires information`;
|
|
|
|
|
default:
|
|
|
|
|
return `Tool requires confirmation`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-24 14:31:48 -08:00
|
|
|
export class HookSystem {
|
|
|
|
|
private readonly hookRegistry: HookRegistry;
|
|
|
|
|
private readonly hookRunner: HookRunner;
|
|
|
|
|
private readonly hookAggregator: HookAggregator;
|
|
|
|
|
private readonly hookPlanner: HookPlanner;
|
|
|
|
|
private readonly hookEventHandler: HookEventHandler;
|
|
|
|
|
|
|
|
|
|
constructor(config: Config) {
|
|
|
|
|
// Initialize components
|
|
|
|
|
this.hookRegistry = new HookRegistry(config);
|
2025-12-22 11:46:38 -05:00
|
|
|
this.hookRunner = new HookRunner(config);
|
2025-11-24 14:31:48 -08:00
|
|
|
this.hookAggregator = new HookAggregator();
|
|
|
|
|
this.hookPlanner = new HookPlanner(this.hookRegistry);
|
|
|
|
|
this.hookEventHandler = new HookEventHandler(
|
|
|
|
|
config,
|
|
|
|
|
this.hookPlanner,
|
|
|
|
|
this.hookRunner,
|
|
|
|
|
this.hookAggregator,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize the hook system
|
|
|
|
|
*/
|
|
|
|
|
async initialize(): Promise<void> {
|
|
|
|
|
await this.hookRegistry.initialize();
|
|
|
|
|
debugLogger.debug('Hook system initialized successfully');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get the hook event bus for firing events
|
|
|
|
|
*/
|
|
|
|
|
getEventHandler(): HookEventHandler {
|
|
|
|
|
return this.hookEventHandler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get hook registry for management operations
|
|
|
|
|
*/
|
|
|
|
|
getRegistry(): HookRegistry {
|
|
|
|
|
return this.hookRegistry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enable or disable a hook
|
|
|
|
|
*/
|
|
|
|
|
setHookEnabled(hookName: string, enabled: boolean): void {
|
|
|
|
|
this.hookRegistry.setHookEnabled(hookName, enabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all registered hooks for display/management
|
|
|
|
|
*/
|
|
|
|
|
getAllHooks(): HookRegistryEntry[] {
|
|
|
|
|
return this.hookRegistry.getAllHooks();
|
|
|
|
|
}
|
2026-01-08 00:41:49 +05:30
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fire hook events directly
|
|
|
|
|
*/
|
|
|
|
|
async fireSessionStartEvent(
|
|
|
|
|
source: SessionStartSource,
|
2026-01-20 08:55:43 +05:30
|
|
|
): Promise<DefaultHookOutput | undefined> {
|
|
|
|
|
const result = await this.hookEventHandler.fireSessionStartEvent(source);
|
|
|
|
|
return result.finalOutput;
|
2026-01-08 00:41:49 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fireSessionEndEvent(
|
|
|
|
|
reason: SessionEndReason,
|
|
|
|
|
): Promise<AggregatedHookResult | undefined> {
|
|
|
|
|
return this.hookEventHandler.fireSessionEndEvent(reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async firePreCompressEvent(
|
|
|
|
|
trigger: PreCompressTrigger,
|
|
|
|
|
): Promise<AggregatedHookResult | undefined> {
|
|
|
|
|
return this.hookEventHandler.firePreCompressEvent(trigger);
|
|
|
|
|
}
|
2026-01-12 23:08:45 +05:30
|
|
|
|
|
|
|
|
async fireBeforeAgentEvent(
|
|
|
|
|
prompt: string,
|
2026-01-13 22:20:54 +05:30
|
|
|
): Promise<DefaultHookOutput | undefined> {
|
|
|
|
|
const result = await this.hookEventHandler.fireBeforeAgentEvent(prompt);
|
|
|
|
|
return result.finalOutput;
|
2026-01-12 23:08:45 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fireAfterAgentEvent(
|
|
|
|
|
prompt: string,
|
|
|
|
|
response: string,
|
|
|
|
|
stopHookActive: boolean = false,
|
2026-01-13 22:20:54 +05:30
|
|
|
): Promise<DefaultHookOutput | undefined> {
|
|
|
|
|
const result = await this.hookEventHandler.fireAfterAgentEvent(
|
2026-01-12 23:08:45 +05:30
|
|
|
prompt,
|
|
|
|
|
response,
|
|
|
|
|
stopHookActive,
|
|
|
|
|
);
|
2026-01-13 22:20:54 +05:30
|
|
|
return result.finalOutput;
|
2026-01-12 23:08:45 +05:30
|
|
|
}
|
2026-01-20 23:16:54 +05:30
|
|
|
|
|
|
|
|
async fireBeforeModelEvent(
|
|
|
|
|
llmRequest: GenerateContentParameters,
|
|
|
|
|
): Promise<BeforeModelHookResult> {
|
|
|
|
|
try {
|
|
|
|
|
const result =
|
|
|
|
|
await this.hookEventHandler.fireBeforeModelEvent(llmRequest);
|
|
|
|
|
const hookOutput = result.finalOutput;
|
|
|
|
|
|
|
|
|
|
if (hookOutput?.shouldStopExecution()) {
|
|
|
|
|
return {
|
|
|
|
|
blocked: true,
|
|
|
|
|
stopped: true,
|
|
|
|
|
reason: hookOutput.getEffectiveReason(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blockingError = hookOutput?.getBlockingError();
|
|
|
|
|
if (blockingError?.blocked) {
|
|
|
|
|
const beforeModelOutput = hookOutput as BeforeModelHookOutput;
|
|
|
|
|
const syntheticResponse = beforeModelOutput.getSyntheticResponse();
|
|
|
|
|
return {
|
|
|
|
|
blocked: true,
|
|
|
|
|
reason:
|
|
|
|
|
hookOutput?.getEffectiveReason() || 'Model call blocked by hook',
|
|
|
|
|
syntheticResponse,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hookOutput) {
|
|
|
|
|
const beforeModelOutput = hookOutput as BeforeModelHookOutput;
|
|
|
|
|
const modifiedRequest =
|
|
|
|
|
beforeModelOutput.applyLLMRequestModifications(llmRequest);
|
|
|
|
|
return {
|
|
|
|
|
blocked: false,
|
|
|
|
|
modifiedConfig: modifiedRequest?.config,
|
|
|
|
|
modifiedContents: modifiedRequest?.contents,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { blocked: false };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.debug(`BeforeModelHookEvent failed:`, error);
|
|
|
|
|
return { blocked: false };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fireAfterModelEvent(
|
|
|
|
|
originalRequest: GenerateContentParameters,
|
|
|
|
|
chunk: GenerateContentResponse,
|
|
|
|
|
): Promise<AfterModelHookResult> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.hookEventHandler.fireAfterModelEvent(
|
|
|
|
|
originalRequest,
|
|
|
|
|
chunk,
|
|
|
|
|
);
|
|
|
|
|
const hookOutput = result.finalOutput;
|
|
|
|
|
|
|
|
|
|
if (hookOutput?.shouldStopExecution()) {
|
|
|
|
|
return {
|
|
|
|
|
response: chunk,
|
|
|
|
|
stopped: true,
|
|
|
|
|
reason: hookOutput.getEffectiveReason(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const blockingError = hookOutput?.getBlockingError();
|
|
|
|
|
if (blockingError?.blocked) {
|
|
|
|
|
return {
|
|
|
|
|
response: chunk,
|
|
|
|
|
blocked: true,
|
|
|
|
|
reason: hookOutput?.getEffectiveReason(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hookOutput) {
|
|
|
|
|
const afterModelOutput = hookOutput as AfterModelHookOutput;
|
|
|
|
|
const modifiedResponse = afterModelOutput.getModifiedResponse();
|
|
|
|
|
if (modifiedResponse) {
|
|
|
|
|
return { response: modifiedResponse };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { response: chunk };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.debug(`AfterModelHookEvent failed:`, error);
|
|
|
|
|
return { response: chunk };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fireBeforeToolSelectionEvent(
|
|
|
|
|
llmRequest: GenerateContentParameters,
|
|
|
|
|
): Promise<BeforeToolSelectionHookResult> {
|
|
|
|
|
try {
|
|
|
|
|
const result =
|
|
|
|
|
await this.hookEventHandler.fireBeforeToolSelectionEvent(llmRequest);
|
|
|
|
|
const hookOutput = result.finalOutput;
|
|
|
|
|
|
|
|
|
|
if (hookOutput) {
|
|
|
|
|
const toolSelectionOutput = hookOutput as BeforeToolSelectionHookOutput;
|
|
|
|
|
const modifiedConfig = toolSelectionOutput.applyToolConfigModifications(
|
|
|
|
|
{
|
|
|
|
|
toolConfig: llmRequest.config?.toolConfig,
|
|
|
|
|
tools: llmRequest.config?.tools,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
return {
|
|
|
|
|
toolConfig: modifiedConfig.toolConfig,
|
|
|
|
|
tools: modifiedConfig.tools,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return {};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.debug(`BeforeToolSelectionEvent failed:`, error);
|
|
|
|
|
return {};
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-21 22:43:03 +05:30
|
|
|
|
|
|
|
|
async fireBeforeToolEvent(
|
|
|
|
|
toolName: string,
|
|
|
|
|
toolInput: Record<string, unknown>,
|
|
|
|
|
mcpContext?: McpToolContext,
|
|
|
|
|
): Promise<DefaultHookOutput | undefined> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.hookEventHandler.fireBeforeToolEvent(
|
|
|
|
|
toolName,
|
|
|
|
|
toolInput,
|
|
|
|
|
mcpContext,
|
|
|
|
|
);
|
|
|
|
|
return result.finalOutput;
|
|
|
|
|
} catch (error) {
|
2026-01-24 18:52:08 +05:30
|
|
|
debugLogger.debug(`BeforeToolEvent failed for ${toolName}:`, error);
|
2026-01-21 22:43:03 +05:30
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fireAfterToolEvent(
|
|
|
|
|
toolName: string,
|
|
|
|
|
toolInput: Record<string, unknown>,
|
|
|
|
|
toolResponse: {
|
|
|
|
|
llmContent: unknown;
|
|
|
|
|
returnDisplay: unknown;
|
|
|
|
|
error: unknown;
|
|
|
|
|
},
|
|
|
|
|
mcpContext?: McpToolContext,
|
|
|
|
|
): Promise<DefaultHookOutput | undefined> {
|
|
|
|
|
try {
|
|
|
|
|
const result = await this.hookEventHandler.fireAfterToolEvent(
|
|
|
|
|
toolName,
|
|
|
|
|
toolInput,
|
|
|
|
|
toolResponse as Record<string, unknown>,
|
|
|
|
|
mcpContext,
|
|
|
|
|
);
|
|
|
|
|
return result.finalOutput;
|
|
|
|
|
} catch (error) {
|
2026-01-24 18:52:08 +05:30
|
|
|
debugLogger.debug(`AfterToolEvent failed for ${toolName}:`, error);
|
2026-01-21 22:43:03 +05:30
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-24 18:52:08 +05:30
|
|
|
|
|
|
|
|
async fireToolNotificationEvent(
|
|
|
|
|
confirmationDetails: ToolCallConfirmationDetails,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const message = getNotificationMessage(confirmationDetails);
|
|
|
|
|
const serializedDetails = toSerializableDetails(confirmationDetails);
|
|
|
|
|
|
|
|
|
|
await this.hookEventHandler.fireNotificationEvent(
|
|
|
|
|
NotificationType.ToolPermission,
|
|
|
|
|
message,
|
|
|
|
|
serializedDetails,
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.debug(
|
|
|
|
|
`NotificationEvent failed for ${confirmationDetails.title}:`,
|
|
|
|
|
error,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-24 14:31:48 -08:00
|
|
|
}
|