From 1c8fe92d0f03c6aaf1f6d0e3d279b244993e164c Mon Sep 17 00:00:00 2001 From: Edilmo Palencia Date: Tue, 11 Nov 2025 23:14:09 -0800 Subject: [PATCH] feat(hooks): Hook Result Aggregation (#9095) --- .../core/src/hooks/hookAggregator.test.ts | 475 ++++++++++++++++++ packages/core/src/hooks/hookAggregator.ts | 343 +++++++++++++ 2 files changed, 818 insertions(+) create mode 100644 packages/core/src/hooks/hookAggregator.test.ts create mode 100644 packages/core/src/hooks/hookAggregator.ts diff --git a/packages/core/src/hooks/hookAggregator.test.ts b/packages/core/src/hooks/hookAggregator.test.ts new file mode 100644 index 0000000000..ea675464f2 --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.test.ts @@ -0,0 +1,475 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { HookAggregator } from './hookAggregator.js'; +import type { + HookExecutionResult, + BeforeToolSelectionOutput, + BeforeModelOutput, + HookOutput, +} from './types.js'; +import { HookType, HookEventName } from './types.js'; + +// Helper function to create proper HookExecutionResult objects +function createHookExecutionResult( + output?: HookOutput, + success = true, + duration = 100, + error?: Error, +): HookExecutionResult { + return { + success, + output, + duration, + error, + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + }; +} + +describe('HookAggregator', () => { + let aggregator: HookAggregator; + + beforeEach(() => { + aggregator = new HookAggregator(); + }); + + describe('aggregateResults', () => { + it('should handle empty results', () => { + const results: HookExecutionResult[] = []; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeTool, + ); + + expect(aggregated.success).toBe(true); + expect(aggregated.allOutputs).toHaveLength(0); + expect(aggregated.errors).toHaveLength(0); + expect(aggregated.totalDuration).toBe(0); + expect(aggregated.finalOutput).toBeUndefined(); + }); + + it('should aggregate successful results', () => { + const results: HookExecutionResult[] = [ + createHookExecutionResult( + { decision: 'allow', reason: 'Hook 1 approved' }, + true, + 100, + ), + createHookExecutionResult( + { decision: 'allow', reason: 'Hook 2 approved' }, + true, + 150, + ), + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeTool, + ); + + expect(aggregated.success).toBe(true); + expect(aggregated.allOutputs).toHaveLength(2); + expect(aggregated.errors).toHaveLength(0); + expect(aggregated.totalDuration).toBe(250); + expect(aggregated.finalOutput?.decision).toBe('allow'); + expect(aggregated.finalOutput?.reason).toBe( + 'Hook 1 approved\nHook 2 approved', + ); + }); + + it('should handle errors in results', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: false, + error: new Error('Hook failed'), + duration: 50, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: true, + output: { decision: 'allow' }, + duration: 100, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeTool, + ); + + expect(aggregated.success).toBe(false); + expect(aggregated.allOutputs).toHaveLength(1); + expect(aggregated.errors).toHaveLength(1); + expect(aggregated.errors[0].message).toBe('Hook failed'); + expect(aggregated.totalDuration).toBe(150); + }); + + it('should handle blocking decisions with OR logic', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: true, + output: { decision: 'allow', reason: 'Hook 1 allowed' }, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: true, + output: { decision: 'block', reason: 'Hook 2 blocked' }, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeTool, + ); + + expect(aggregated.success).toBe(true); + expect(aggregated.finalOutput?.decision).toBe('block'); + expect(aggregated.finalOutput?.reason).toBe( + 'Hook 1 allowed\nHook 2 blocked', + ); + }); + + it('should handle continue=false with precedence', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: true, + output: { decision: 'allow', continue: true }, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeTool, + success: true, + output: { + decision: 'allow', + continue: false, + stopReason: 'Stop requested', + }, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeTool, + ); + + expect(aggregated.success).toBe(true); + expect(aggregated.finalOutput?.continue).toBe(false); + expect(aggregated.finalOutput?.stopReason).toBe('Stop requested'); + }); + }); + + describe('BeforeToolSelection merge strategy', () => { + it('should merge tool configurations with NONE mode precedence', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', + allowedFunctionNames: ['tool1', 'tool2'], + }, + }, + } as BeforeToolSelectionOutput, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'NONE', + allowedFunctionNames: [], + }, + }, + } as BeforeToolSelectionOutput, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeToolSelection, + ); + + expect(aggregated.success).toBe(true); + const output = aggregated.finalOutput as BeforeToolSelectionOutput; + const toolConfig = output.hookSpecificOutput?.toolConfig; + expect(toolConfig?.mode).toBe('NONE'); + expect(toolConfig?.allowedFunctionNames).toEqual([]); + }); + + it('should merge tool configurations with ANY mode', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'AUTO', + allowedFunctionNames: ['tool1'], + }, + }, + } as BeforeToolSelectionOutput, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'ANY', + allowedFunctionNames: ['tool2', 'tool3'], + }, + }, + } as BeforeToolSelectionOutput, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeToolSelection, + ); + + expect(aggregated.success).toBe(true); + const output = aggregated.finalOutput as BeforeToolSelectionOutput; + const toolConfig = output.hookSpecificOutput?.toolConfig; + expect(toolConfig?.mode).toBe('ANY'); + expect(toolConfig?.allowedFunctionNames).toEqual([ + 'tool1', + 'tool2', + 'tool3', + ]); + }); + + it('should merge tool configurations with AUTO mode when all are AUTO', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'AUTO', + allowedFunctionNames: ['tool1'], + }, + }, + } as BeforeToolSelectionOutput, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeToolSelection, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'BeforeToolSelection', + toolConfig: { + mode: 'AUTO', + allowedFunctionNames: ['tool2'], + }, + }, + } as BeforeToolSelectionOutput, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeToolSelection, + ); + + expect(aggregated.success).toBe(true); + const output = aggregated.finalOutput as BeforeToolSelectionOutput; + const toolConfig = output.hookSpecificOutput?.toolConfig; + expect(toolConfig?.mode).toBe('AUTO'); + expect(toolConfig?.allowedFunctionNames).toEqual(['tool1', 'tool2']); + }); + }); + + describe('BeforeModel/AfterModel merge strategy', () => { + it('should use field replacement strategy', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeModel, + success: true, + output: { + decision: 'allow', + hookSpecificOutput: { + hookEventName: 'BeforeModel', + llm_request: { model: 'model1', config: {}, contents: [] }, + }, + }, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.BeforeModel, + success: true, + output: { + decision: 'block', + hookSpecificOutput: { + hookEventName: 'BeforeModel', + llm_request: { model: 'model2', config: {}, contents: [] }, + }, + }, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.BeforeModel, + ); + + expect(aggregated.success).toBe(true); + expect(aggregated.finalOutput?.decision).toBe('block'); // Later value wins + const output = aggregated.finalOutput as BeforeModelOutput; + const llmRequest = output.hookSpecificOutput?.llm_request; + expect(llmRequest?.['model']).toBe('model2'); // Later value wins + }); + }); + + describe('extractAdditionalContext', () => { + it('should extract additional context from hook outputs', () => { + const results: HookExecutionResult[] = [ + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.AfterTool, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'AfterTool', + additionalContext: 'Context from hook 1', + }, + }, + duration: 100, + }, + { + hookConfig: { + type: HookType.Command, + command: 'test-command', + timeout: 30000, + }, + eventName: HookEventName.AfterTool, + success: true, + output: { + hookSpecificOutput: { + hookEventName: 'AfterTool', + additionalContext: 'Context from hook 2', + }, + }, + duration: 150, + }, + ]; + + const aggregated = aggregator.aggregateResults( + results, + HookEventName.AfterTool, + ); + + expect(aggregated.success).toBe(true); + expect( + aggregated.finalOutput?.hookSpecificOutput?.['additionalContext'], + ).toBe('Context from hook 1\nContext from hook 2'); + }); + }); +}); diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts new file mode 100644 index 0000000000..16176d7cfc --- /dev/null +++ b/packages/core/src/hooks/hookAggregator.ts @@ -0,0 +1,343 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FunctionCallingConfigMode } from '@google/genai'; +import type { + HookOutput, + HookExecutionResult, + BeforeToolSelectionOutput, +} from './types.js'; +import { + DefaultHookOutput, + BeforeToolHookOutput, + BeforeModelHookOutput, + BeforeToolSelectionHookOutput, + AfterModelHookOutput, +} from './types.js'; +import { HookEventName } 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( + 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 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; + } + + // 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; + } + + // Collect additional context from hook-specific outputs + this.extractAdditionalContext(output, additionalContexts); + } + + // Set final decision if no blocking decision was found + if (!hasBlockDecision && !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); + 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 && + typeof specific['additionalContext'] === 'string' + ) { + contexts.push(specific['additionalContext']); + } + } +}