/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { FunctionCallingConfigMode } from '@google/genai'; import { DefaultHookOutput, BeforeToolHookOutput, BeforeModelHookOutput, BeforeToolSelectionHookOutput, AfterModelHookOutput, AfterAgentHookOutput, HookEventName, type HookOutput, type HookExecutionResult, type BeforeToolSelectionOutput, } from './types.js'; /** * Aggregated hook result */ export interface AggregatedHookResult { success: boolean; finalOutput?: DefaultHookOutput; allOutputs: HookOutput[]; errors: Error[]; totalDuration: number; } /** * Hook aggregator that merges results from multiple hooks using event-specific strategies */ export class HookAggregator { /** * Aggregate results from multiple hook executions */ aggregateResults( results: HookExecutionResult[], eventName: HookEventName, ): AggregatedHookResult { const allOutputs: HookOutput[] = []; const errors: Error[] = []; let totalDuration = 0; // Collect all outputs and errors for (const result of results) { totalDuration += result.duration; if (result.error) { errors.push(result.error); } if (result.output) { allOutputs.push(result.output); } } // Merge outputs using event-specific strategy const mergedOutput = this.mergeOutputs(allOutputs, eventName); const finalOutput = mergedOutput ? this.createSpecificHookOutput(mergedOutput, eventName) : undefined; return { success: errors.length === 0, finalOutput, allOutputs, errors, totalDuration, }; } /** * Merge hook outputs using event-specific strategies * * Note: We always use the merge logic even for single hooks to ensure * consistent default behaviors (e.g., default decision='allow' for OR logic) */ private mergeOutputs( outputs: HookOutput[], eventName: HookEventName, ): HookOutput | undefined { if (outputs.length === 0) { return undefined; } switch (eventName) { case HookEventName.BeforeTool: case HookEventName.AfterTool: case HookEventName.BeforeAgent: case HookEventName.AfterAgent: case HookEventName.SessionStart: return this.mergeWithOrDecision(outputs); case HookEventName.BeforeModel: case HookEventName.AfterModel: return this.mergeWithFieldReplacement(outputs); case HookEventName.BeforeToolSelection: return this.mergeToolSelectionOutputs( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion outputs as BeforeToolSelectionOutput[], ); default: // For other events, use simple merge return this.mergeSimple(outputs); } } /** * Merge outputs with OR decision logic and message concatenation */ private mergeWithOrDecision(outputs: HookOutput[]): HookOutput { const merged: HookOutput = { continue: true, suppressOutput: false, }; const messages: string[] = []; const reasons: string[] = []; const systemMessages: string[] = []; const additionalContexts: string[] = []; let hasBlockDecision = false; let hasAskDecision = false; let hasContinueFalse = false; for (const output of outputs) { // Handle continue flag if (output.continue === false) { hasContinueFalse = true; merged.continue = false; if (output.stopReason) { messages.push(output.stopReason); } } // Handle decision (OR logic for blocking) const tempOutput = new DefaultHookOutput(output); if (tempOutput.isBlockingDecision()) { hasBlockDecision = true; merged.decision = output.decision; } else if (tempOutput.isAskDecision()) { hasAskDecision = true; // Ask decision is only set if no blocking decision was found so far if (!hasBlockDecision) { merged.decision = output.decision; } } // Collect messages if (output.reason) { reasons.push(output.reason); } if (output.systemMessage) { systemMessages.push(output.systemMessage); } // Handle suppress output (any true wins) if (output.suppressOutput) { merged.suppressOutput = true; } // Handle clearContext (any true wins) - for AfterAgent hooks if (output.hookSpecificOutput?.['clearContext'] === true) { merged.hookSpecificOutput = { ...(merged.hookSpecificOutput || {}), clearContext: true, }; } // Merge hookSpecificOutput (excluding clearContext which is handled above) if (output.hookSpecificOutput) { const { clearContext: _clearContext, ...restSpecificOutput } = output.hookSpecificOutput; merged.hookSpecificOutput = { ...(merged.hookSpecificOutput || {}), ...restSpecificOutput, }; } // Collect additional context from hook-specific outputs this.extractAdditionalContext(output, additionalContexts); } // Set final decision if no blocking or ask decision was found if (!hasBlockDecision && !hasAskDecision && !hasContinueFalse) { merged.decision = 'allow'; } // Merge messages if (messages.length > 0) { merged.stopReason = messages.join('\n'); } if (reasons.length > 0) { merged.reason = reasons.join('\n'); } if (systemMessages.length > 0) { merged.systemMessage = systemMessages.join('\n'); } // Add merged additional context if (additionalContexts.length > 0) { merged.hookSpecificOutput = { ...(merged.hookSpecificOutput || {}), additionalContext: additionalContexts.join('\n'), }; } return merged; } /** * Merge outputs with later fields replacing earlier fields */ private mergeWithFieldReplacement(outputs: HookOutput[]): HookOutput { let merged: HookOutput = {}; for (const output of outputs) { // Later outputs override earlier ones merged = { ...merged, ...output, hookSpecificOutput: { ...merged.hookSpecificOutput, ...output.hookSpecificOutput, }, }; } return merged; } /** * Merge tool selection outputs with specific logic for tool config * * Tool Selection Strategy: * - The intent is to provide a UNION of tools from all hooks * - If any hook specifies NONE mode, no tools are available (most restrictive wins) * - If any hook specifies ANY mode (and no NONE), ANY mode is used * - Otherwise AUTO mode is used * - Function names are collected from all hooks and sorted for deterministic caching * * This means hooks can only add/enable tools, not filter them out individually. * If one hook restricts and another re-enables, the union takes the re-enabled tool. */ private mergeToolSelectionOutputs( outputs: BeforeToolSelectionOutput[], ): BeforeToolSelectionOutput { const merged: BeforeToolSelectionOutput = {}; const allFunctionNames = new Set(); let hasNoneMode = false; let hasAnyMode = false; for (const output of outputs) { const toolConfig = output.hookSpecificOutput?.toolConfig; if (!toolConfig) { continue; } // Check mode (using simplified HookToolConfig format) if (toolConfig.mode === 'NONE') { hasNoneMode = true; } else if (toolConfig.mode === 'ANY') { hasAnyMode = true; } // Collect function names (union of all hooks) if (toolConfig.allowedFunctionNames) { for (const name of toolConfig.allowedFunctionNames) { allFunctionNames.add(name); } } } // Determine final mode and function names let finalMode: FunctionCallingConfigMode; let finalFunctionNames: string[] = []; if (hasNoneMode) { // NONE mode wins - most restrictive finalMode = FunctionCallingConfigMode.NONE; finalFunctionNames = []; } else if (hasAnyMode) { // ANY mode if present (and no NONE) finalMode = FunctionCallingConfigMode.ANY; // Sort for deterministic output to ensure consistent caching finalFunctionNames = Array.from(allFunctionNames).sort(); } else { // Default to AUTO mode finalMode = FunctionCallingConfigMode.AUTO; // Sort for deterministic output to ensure consistent caching finalFunctionNames = Array.from(allFunctionNames).sort(); } merged.hookSpecificOutput = { hookEventName: 'BeforeToolSelection', toolConfig: { mode: finalMode, allowedFunctionNames: finalFunctionNames, }, }; return merged; } /** * Simple merge for events without special logic */ private mergeSimple(outputs: HookOutput[]): HookOutput { let merged: HookOutput = {}; for (const output of outputs) { merged = { ...merged, ...output }; } return merged; } /** * Create the appropriate specific hook output class based on event type */ private createSpecificHookOutput( output: HookOutput, eventName: HookEventName, ): DefaultHookOutput { switch (eventName) { case HookEventName.BeforeTool: return new BeforeToolHookOutput(output); case HookEventName.BeforeModel: return new BeforeModelHookOutput(output); case HookEventName.BeforeToolSelection: return new BeforeToolSelectionHookOutput(output); case HookEventName.AfterModel: return new AfterModelHookOutput(output); case HookEventName.AfterAgent: return new AfterAgentHookOutput(output); default: return new DefaultHookOutput(output); } } /** * Extract additional context from hook-specific outputs */ private extractAdditionalContext( output: HookOutput, contexts: string[], ): void { const specific = output.hookSpecificOutput; if (!specific) { return; } // Extract additionalContext from various hook types if ( 'additionalContext' in specific && // eslint-disable-next-line no-restricted-syntax typeof specific['additionalContext'] === 'string' ) { contexts.push(specific['additionalContext']); } } }