| |
| |
| |
| |
| |
|
|
| import type { HookPlanner, HookEventContext } from './hookPlanner.js'; |
| import type { HookRunner } from './hookRunner.js'; |
| import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js'; |
| import { |
| HookEventName, |
| HookType, |
| type HookConfig, |
| type HookInput, |
| type BeforeToolInput, |
| type AfterToolInput, |
| type BeforeAgentInput, |
| type NotificationInput, |
| type AfterAgentInput, |
| type SessionStartInput, |
| type SessionEndInput, |
| type PreCompressInput, |
| type BeforeModelInput, |
| type AfterModelInput, |
| type BeforeToolSelectionInput, |
| type NotificationType, |
| type SessionStartSource, |
| type SessionEndReason, |
| type PreCompressTrigger, |
| type HookExecutionResult, |
| type McpToolContext, |
| } from './types.js'; |
| import { defaultHookTranslator } from './hookTranslator.js'; |
| import type { |
| GenerateContentParameters, |
| GenerateContentResponse, |
| } from '@google/genai'; |
| import { logHookCall } from '../telemetry/loggers.js'; |
| import { HookCallEvent } from '../telemetry/types.js'; |
| import { debugLogger } from '../utils/debugLogger.js'; |
| import { coreEvents } from '../utils/events.js'; |
| import type { AgentLoopContext } from '../config/agent-loop-context.js'; |
|
|
| |
| |
| |
| export class HookEventHandler { |
| private readonly context: AgentLoopContext; |
| private readonly hookPlanner: HookPlanner; |
| private readonly hookRunner: HookRunner; |
| private readonly hookAggregator: HookAggregator; |
|
|
| |
| |
| |
| |
| |
| private readonly reportedFailures = new WeakMap<object, Set<string>>(); |
|
|
| constructor( |
| context: AgentLoopContext, |
| hookPlanner: HookPlanner, |
| hookRunner: HookRunner, |
| hookAggregator: HookAggregator, |
| ) { |
| this.context = context; |
| this.hookPlanner = hookPlanner; |
| this.hookRunner = hookRunner; |
| this.hookAggregator = hookAggregator; |
| } |
|
|
| |
| |
| |
| |
| async fireBeforeToolEvent( |
| toolName: string, |
| toolInput: Record<string, unknown>, |
| mcpContext?: McpToolContext, |
| originalRequestName?: string, |
| ): Promise<AggregatedHookResult> { |
| const input: BeforeToolInput = { |
| ...this.createBaseInput(HookEventName.BeforeTool), |
| tool_name: toolName, |
| tool_input: toolInput, |
| ...(mcpContext && { mcp_context: mcpContext }), |
| ...(originalRequestName && { |
| original_request_name: originalRequestName, |
| }), |
| }; |
|
|
| const context: HookEventContext = { toolName }; |
| return this.executeHooks(HookEventName.BeforeTool, input, context); |
| } |
|
|
| |
| |
| |
| |
| async fireAfterToolEvent( |
| toolName: string, |
| toolInput: Record<string, unknown>, |
| toolResponse: Record<string, unknown>, |
| mcpContext?: McpToolContext, |
| originalRequestName?: string, |
| ): Promise<AggregatedHookResult> { |
| const input: AfterToolInput = { |
| ...this.createBaseInput(HookEventName.AfterTool), |
| tool_name: toolName, |
| tool_input: toolInput, |
| tool_response: toolResponse, |
| ...(mcpContext && { mcp_context: mcpContext }), |
| ...(originalRequestName && { |
| original_request_name: originalRequestName, |
| }), |
| }; |
|
|
| const context: HookEventContext = { toolName }; |
| return this.executeHooks(HookEventName.AfterTool, input, context); |
| } |
|
|
| |
| |
| |
| |
| async fireBeforeAgentEvent(prompt: string): Promise<AggregatedHookResult> { |
| const input: BeforeAgentInput = { |
| ...this.createBaseInput(HookEventName.BeforeAgent), |
| prompt, |
| }; |
|
|
| return this.executeHooks(HookEventName.BeforeAgent, input); |
| } |
|
|
| |
| |
| |
| async fireNotificationEvent( |
| type: NotificationType, |
| message: string, |
| details: Record<string, unknown>, |
| ): Promise<AggregatedHookResult> { |
| const input: NotificationInput = { |
| ...this.createBaseInput(HookEventName.Notification), |
| notification_type: type, |
| message, |
| details, |
| }; |
|
|
| return this.executeHooks(HookEventName.Notification, input); |
| } |
|
|
| |
| |
| |
| |
| async fireAfterAgentEvent( |
| prompt: string, |
| promptResponse: string, |
| stopHookActive: boolean = false, |
| ): Promise<AggregatedHookResult> { |
| const input: AfterAgentInput = { |
| ...this.createBaseInput(HookEventName.AfterAgent), |
| prompt, |
| prompt_response: promptResponse, |
| stop_hook_active: stopHookActive, |
| }; |
|
|
| return this.executeHooks(HookEventName.AfterAgent, input); |
| } |
|
|
| |
| |
| |
| async fireSessionStartEvent( |
| source: SessionStartSource, |
| ): Promise<AggregatedHookResult> { |
| const input: SessionStartInput = { |
| ...this.createBaseInput(HookEventName.SessionStart), |
| source, |
| }; |
|
|
| const context: HookEventContext = { trigger: source }; |
| return this.executeHooks(HookEventName.SessionStart, input, context); |
| } |
|
|
| |
| |
| |
| async fireSessionEndEvent( |
| reason: SessionEndReason, |
| ): Promise<AggregatedHookResult> { |
| const input: SessionEndInput = { |
| ...this.createBaseInput(HookEventName.SessionEnd), |
| reason, |
| }; |
|
|
| const context: HookEventContext = { trigger: reason }; |
| return this.executeHooks(HookEventName.SessionEnd, input, context); |
| } |
|
|
| |
| |
| |
| async firePreCompressEvent( |
| trigger: PreCompressTrigger, |
| ): Promise<AggregatedHookResult> { |
| const input: PreCompressInput = { |
| ...this.createBaseInput(HookEventName.PreCompress), |
| trigger, |
| }; |
|
|
| const context: HookEventContext = { trigger }; |
| return this.executeHooks(HookEventName.PreCompress, input, context); |
| } |
|
|
| |
| |
| |
| |
| async fireBeforeModelEvent( |
| llmRequest: GenerateContentParameters, |
| ): Promise<AggregatedHookResult> { |
| const input: BeforeModelInput = { |
| ...this.createBaseInput(HookEventName.BeforeModel), |
| llm_request: defaultHookTranslator.toHookLLMRequest(llmRequest), |
| }; |
|
|
| return this.executeHooks( |
| HookEventName.BeforeModel, |
| input, |
| undefined, |
| llmRequest, |
| ); |
| } |
|
|
| |
| |
| |
| |
| async fireAfterModelEvent( |
| llmRequest: GenerateContentParameters, |
| llmResponse: GenerateContentResponse, |
| ): Promise<AggregatedHookResult> { |
| const input: AfterModelInput = { |
| ...this.createBaseInput(HookEventName.AfterModel), |
| llm_request: defaultHookTranslator.toHookLLMRequest(llmRequest), |
| llm_response: defaultHookTranslator.toHookLLMResponse(llmResponse), |
| }; |
|
|
| return this.executeHooks( |
| HookEventName.AfterModel, |
| input, |
| undefined, |
| llmRequest, |
| ); |
| } |
|
|
| |
| |
| |
| |
| async fireBeforeToolSelectionEvent( |
| llmRequest: GenerateContentParameters, |
| ): Promise<AggregatedHookResult> { |
| const input: BeforeToolSelectionInput = { |
| ...this.createBaseInput(HookEventName.BeforeToolSelection), |
| llm_request: defaultHookTranslator.toHookLLMRequest(llmRequest), |
| }; |
|
|
| return this.executeHooks( |
| HookEventName.BeforeToolSelection, |
| input, |
| undefined, |
| llmRequest, |
| ); |
| } |
|
|
| |
| |
| |
| |
| private async executeHooks( |
| eventName: HookEventName, |
| input: HookInput, |
| context?: HookEventContext, |
| requestContext?: object, |
| ): Promise<AggregatedHookResult> { |
| try { |
| |
| const plan = this.hookPlanner.createExecutionPlan(eventName, context); |
|
|
| if (!plan || plan.hookConfigs.length === 0) { |
| return { |
| success: true, |
| allOutputs: [], |
| errors: [], |
| totalDuration: 0, |
| }; |
| } |
|
|
| const onHookStart = (config: HookConfig, index: number) => { |
| coreEvents.emitHookStart({ |
| hookName: this.getHookName(config), |
| eventName, |
| source: config.source, |
| hookIndex: index + 1, |
| totalHooks: plan.hookConfigs.length, |
| }); |
| }; |
|
|
| const onHookEnd = (config: HookConfig, result: HookExecutionResult) => { |
| coreEvents.emitHookEnd({ |
| hookName: this.getHookName(config), |
| eventName, |
| success: result.success, |
| }); |
| }; |
|
|
| |
| const results = plan.sequential |
| ? await this.hookRunner.executeHooksSequential( |
| plan.hookConfigs, |
| eventName, |
| input, |
| onHookStart, |
| onHookEnd, |
| ) |
| : await this.hookRunner.executeHooksParallel( |
| plan.hookConfigs, |
| eventName, |
| input, |
| onHookStart, |
| onHookEnd, |
| ); |
|
|
| |
| const aggregated = this.hookAggregator.aggregateResults( |
| results, |
| eventName, |
| ); |
|
|
| |
| this.processCommonHookOutputFields(aggregated); |
|
|
| |
| this.logHookExecution( |
| eventName, |
| input, |
| results, |
| aggregated, |
| requestContext, |
| ); |
|
|
| return aggregated; |
| } catch (error) { |
| debugLogger.error(`Hook event bus error for ${eventName}: ${error}`); |
|
|
| return { |
| success: false, |
| allOutputs: [], |
| errors: [error instanceof Error ? error : new Error(String(error))], |
| totalDuration: 0, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| private createBaseInput(eventName: HookEventName): HookInput { |
| |
| const transcriptPath = |
| this.context.geminiClient |
| ?.getChatRecordingService() |
| ?.getConversationFilePath() ?? ''; |
|
|
| return { |
| session_id: this.context.config.getSessionId(), |
| transcript_path: transcriptPath, |
| cwd: this.context.config.getWorkingDir(), |
| hook_event_name: eventName, |
| timestamp: new Date().toISOString(), |
| }; |
| } |
|
|
| |
| |
| |
| private logHookExecution( |
| eventName: HookEventName, |
| input: HookInput, |
| results: HookExecutionResult[], |
| aggregated: AggregatedHookResult, |
| requestContext?: object, |
| ): void { |
| const failedHooks = results.filter((r) => !r.success); |
| const successCount = results.length - failedHooks.length; |
| const errorCount = failedHooks.length; |
|
|
| if (errorCount > 0) { |
| const failedNames = failedHooks |
| .map((r) => this.getHookNameFromResult(r)) |
| .join(', '); |
|
|
| let shouldEmit = true; |
| if (requestContext) { |
| let reportedSet = this.reportedFailures.get(requestContext); |
| if (!reportedSet) { |
| reportedSet = new Set<string>(); |
| this.reportedFailures.set(requestContext, reportedSet); |
| } |
|
|
| const failureKey = `${eventName}:${failedNames}`; |
| if (reportedSet.has(failureKey)) { |
| shouldEmit = false; |
| } else { |
| reportedSet.add(failureKey); |
| } |
| } |
|
|
| debugLogger.warn( |
| `Hook execution for ${eventName}: ${successCount} succeeded, ${errorCount} failed (${failedNames}), ` + |
| `total duration: ${aggregated.totalDuration}ms`, |
| ); |
|
|
| if (shouldEmit) { |
| coreEvents.emitFeedback( |
| 'warning', |
| `Hook(s) [${failedNames}] failed for event ${eventName}. Press F12 to see the debug drawer for more details.\n`, |
| ); |
| } |
| } else { |
| debugLogger.debug( |
| `Hook execution for ${eventName}: ${successCount} hooks executed successfully, ` + |
| `total duration: ${aggregated.totalDuration}ms`, |
| ); |
| } |
|
|
| |
| for (const result of results) { |
| |
| const hookName = this.getHookNameFromResult(result); |
| const hookType = this.getHookTypeFromResult(result); |
|
|
| const hookCallEvent = new HookCallEvent( |
| eventName, |
| hookType, |
| hookName, |
| { ...input }, |
| result.duration, |
| result.success, |
| result.output ? { ...result.output } : undefined, |
| result.exitCode, |
| result.stdout, |
| result.stderr, |
| result.error?.message, |
| ); |
|
|
| logHookCall(this.context.config, hookCallEvent); |
|
|
| |
| |
| if (result.output?.systemMessage) { |
| coreEvents.emitHookSystemMessage({ |
| hookName, |
| eventName, |
| message: result.output.systemMessage, |
| }); |
| } |
| } |
|
|
| |
| for (const error of aggregated.errors) { |
| debugLogger.warn(`Hook execution error: ${error.message}`); |
| } |
| } |
|
|
| |
| |
| |
| private processCommonHookOutputFields( |
| aggregated: AggregatedHookResult, |
| ): void { |
| if (!aggregated.finalOutput) { |
| return; |
| } |
|
|
| |
| const systemMessage = aggregated.finalOutput.systemMessage; |
| if (systemMessage && !aggregated.finalOutput.suppressOutput) { |
| debugLogger.warn(`Hook system message: ${systemMessage}`); |
| } |
|
|
| |
|
|
| |
| if (aggregated.finalOutput.shouldStopExecution()) { |
| const stopReason = aggregated.finalOutput.getEffectiveReason(); |
| debugLogger.log(`Hook requested to stop execution: ${stopReason}`); |
|
|
| |
| |
| |
| } |
|
|
| |
| } |
|
|
| |
| |
| |
| private getHookName(config: HookConfig): string { |
| if (config.type === HookType.Command) { |
| return config.name || config.command || 'unknown-command'; |
| } |
| return config.name || 'unknown-hook'; |
| } |
|
|
| |
| |
| |
| private getHookNameFromResult(result: HookExecutionResult): string { |
| return this.getHookName(result.hookConfig); |
| } |
|
|
| |
| |
| |
| private getHookTypeFromResult(result: HookExecutionResult): HookType { |
| return result.hookConfig.type; |
| } |
| } |
|
|