From b25915340325fbb72366fce3e9db82580136c3a4 Mon Sep 17 00:00:00 2001 From: Edilmo Palencia Date: Mon, 3 Nov 2025 18:47:23 -0800 Subject: [PATCH] feat(hooks): Hook Input/Output Contracts (#9080) --- packages/core/src/config/config.test.ts | 15 +- packages/core/src/config/config.ts | 45 +- packages/core/src/hooks/types.test.ts | 38 ++ packages/core/src/hooks/types.ts | 602 ++++++++++++++++++++++++ packages/core/src/index.ts | 3 + 5 files changed, 648 insertions(+), 55 deletions(-) create mode 100644 packages/core/src/hooks/types.test.ts create mode 100644 packages/core/src/hooks/types.ts diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 056569f428..1850ffc0e6 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -6,18 +6,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; -import type { - ConfigParameters, - SandboxConfig, - HookDefinition, -} from './config.js'; -import { - Config, - DEFAULT_FILE_FILTERING_OPTIONS, - HookType, - HookEventName, -} from './config.js'; +import type { ConfigParameters, SandboxConfig } from './config.js'; +import { Config, DEFAULT_FILE_FILTERING_OPTIONS } from './config.js'; import { ApprovalMode } from '../policy/types.js'; +import type { HookDefinition } from '../hooks/types.js'; +import { HookType, HookEventName } from '../hooks/types.js'; import * as path from 'node:path'; import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryTool.js'; import { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index dd7df46ec6..eb1d3418d9 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -32,6 +32,7 @@ import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; import { GeminiClient } from '../core/client.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; +import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; import type { TelemetryTarget } from '../telemetry/index.js'; @@ -207,50 +208,6 @@ export interface SandboxConfig { image: string; } -/** - * Event names for the hook system - */ -export enum HookEventName { - BeforeTool = 'BeforeTool', - AfterTool = 'AfterTool', - BeforeAgent = 'BeforeAgent', - Notification = 'Notification', - AfterAgent = 'AfterAgent', - SessionStart = 'SessionStart', - SessionEnd = 'SessionEnd', - PreCompress = 'PreCompress', - BeforeModel = 'BeforeModel', - AfterModel = 'AfterModel', - BeforeToolSelection = 'BeforeToolSelection', -} - -/** - * Hook configuration entry - */ -export interface CommandHookConfig { - type: HookType.Command; - command: string; - timeout?: number; -} - -export type HookConfig = CommandHookConfig; - -/** - * Hook definition with matcher - */ -export interface HookDefinition { - matcher?: string; - sequential?: boolean; - hooks: HookConfig[]; -} - -/** - * Hook implementation types - */ -export enum HookType { - Command = 'command', -} - export interface ConfigParameters { sessionId: string; embeddingModel?: string; diff --git a/packages/core/src/hooks/types.test.ts b/packages/core/src/hooks/types.test.ts new file mode 100644 index 0000000000..d40fe32041 --- /dev/null +++ b/packages/core/src/hooks/types.test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { HookEventName, HookType } from './types.js'; + +describe('Hook Types', () => { + describe('HookEventName', () => { + it('should contain all required event names', () => { + const expectedEvents = [ + 'BeforeTool', + 'AfterTool', + 'BeforeAgent', + 'Notification', + 'AfterAgent', + 'SessionStart', + 'SessionEnd', + 'PreCompress', + 'BeforeModel', + 'AfterModel', + 'BeforeToolSelection', + ]; + + for (const event of expectedEvents) { + expect(Object.values(HookEventName)).toContain(event); + } + }); + }); + + describe('HookType', () => { + it('should contain command type', () => { + expect(HookType.Command).toBe('command'); + }); + }); +}); diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts new file mode 100644 index 0000000000..5a30e2f8c8 --- /dev/null +++ b/packages/core/src/hooks/types.ts @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + GenerateContentResponse, + GenerateContentParameters, + ToolConfig as GenAIToolConfig, + ToolListUnion, +} from '@google/genai'; +import type { + LLMRequest, + LLMResponse, + HookToolConfig, +} from './hookTranslator.js'; +import { defaultHookTranslator } from './hookTranslator.js'; + +/** + * Event names for the hook system + */ +export enum HookEventName { + BeforeTool = 'BeforeTool', + AfterTool = 'AfterTool', + BeforeAgent = 'BeforeAgent', + Notification = 'Notification', + AfterAgent = 'AfterAgent', + SessionStart = 'SessionStart', + SessionEnd = 'SessionEnd', + PreCompress = 'PreCompress', + BeforeModel = 'BeforeModel', + AfterModel = 'AfterModel', + BeforeToolSelection = 'BeforeToolSelection', +} + +/** + * Hook configuration entry + */ +export interface CommandHookConfig { + type: HookType.Command; + command: string; + timeout?: number; +} + +export type HookConfig = CommandHookConfig; + +/** + * Hook definition with matcher + */ +export interface HookDefinition { + matcher?: string; + sequential?: boolean; + hooks: HookConfig[]; +} + +/** + * Hook implementation types + */ +export enum HookType { + Command = 'command', +} + +/** + * Decision types for hook outputs + */ +export type HookDecision = + | 'ask' + | 'block' + | 'deny' + | 'approve' + | 'allow' + | undefined; + +/** + * Base hook input - common fields for all events + */ +export interface HookInput { + session_id: string; + transcript_path: string; + cwd: string; + hook_event_name: string; + timestamp: string; +} + +/** + * Base hook output - common fields for all events + */ +export interface HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; +} + +/** + * Factory function to create the appropriate hook output class based on event name + * Returns DefaultHookOutput for all events since it contains all necessary methods + */ +export function createHookOutput( + eventName: string, + data: Partial, +): DefaultHookOutput { + switch (eventName) { + case 'BeforeModel': + return new BeforeModelHookOutput(data); + case 'AfterModel': + return new AfterModelHookOutput(data); + case 'BeforeToolSelection': + return new BeforeToolSelectionHookOutput(data); + default: + return new DefaultHookOutput(data); + } +} + +/** + * Default implementation of HookOutput with utility methods + */ +export class DefaultHookOutput implements HookOutput { + continue?: boolean; + stopReason?: string; + suppressOutput?: boolean; + systemMessage?: string; + decision?: HookDecision; + reason?: string; + hookSpecificOutput?: Record; + + constructor(data: Partial = {}) { + this.continue = data.continue; + this.stopReason = data.stopReason; + this.suppressOutput = data.suppressOutput; + this.systemMessage = data.systemMessage; + this.decision = data.decision; + this.reason = data.reason; + this.hookSpecificOutput = data.hookSpecificOutput; + } + + /** + * Check if this output represents a blocking decision + */ + isBlockingDecision(): boolean { + return this.decision === 'block' || this.decision === 'deny'; + } + + /** + * Check if this output requests to stop execution + */ + shouldStopExecution(): boolean { + return this.continue === false; + } + + /** + * Get the effective reason for blocking or stopping + */ + getEffectiveReason(): string { + return this.reason || this.stopReason || 'No reason provided'; + } + + /** + * Apply LLM request modifications (specific method for BeforeModel hooks) + */ + applyLLMRequestModifications( + target: GenerateContentParameters, + ): GenerateContentParameters { + // Base implementation - overridden by BeforeModelHookOutput + return target; + } + + /** + * Apply tool config modifications (specific method for BeforeToolSelection hooks) + */ + applyToolConfigModifications(target: { + toolConfig?: GenAIToolConfig; + tools?: ToolListUnion; + }): { + toolConfig?: GenAIToolConfig; + tools?: ToolListUnion; + } { + // Base implementation - overridden by BeforeToolSelectionHookOutput + return target; + } + + /** + * Get additional context for adding to responses + */ + getAdditionalContext(): string | undefined { + if ( + this.hookSpecificOutput && + 'additionalContext' in this.hookSpecificOutput + ) { + const context = this.hookSpecificOutput['additionalContext']; + return typeof context === 'string' ? context : undefined; + } + return undefined; + } + + /** + * Check if execution should be blocked and return error info + */ + getBlockingError(): { blocked: boolean; reason: string } { + if (this.isBlockingDecision()) { + return { + blocked: true, + reason: this.getEffectiveReason(), + }; + } + return { blocked: false, reason: '' }; + } +} + +/** + * Specific hook output class for BeforeTool events with compatibility support + */ +export class BeforeToolHookOutput extends DefaultHookOutput { + /** + * Get the effective blocking reason, considering compatibility fields + */ + override getEffectiveReason(): string { + // Check for compatibility fields first + if (this.hookSpecificOutput) { + if ('permissionDecisionReason' in this.hookSpecificOutput) { + const compatReason = + this.hookSpecificOutput['permissionDecisionReason']; + if (typeof compatReason === 'string') { + return compatReason; + } + } + } + + return super.getEffectiveReason(); + } + + /** + * Check if this output represents a blocking decision, considering compatibility fields + */ + override isBlockingDecision(): boolean { + // Check compatibility field first + if ( + this.hookSpecificOutput && + 'permissionDecision' in this.hookSpecificOutput + ) { + const compatDecision = this.hookSpecificOutput['permissionDecision']; + if (compatDecision === 'block' || compatDecision === 'deny') { + return true; + } + } + + return super.isBlockingDecision(); + } +} + +/** + * Specific hook output class for BeforeModel events + */ +export class BeforeModelHookOutput extends DefaultHookOutput { + /** + * Get synthetic LLM response if provided by hook + */ + getSyntheticResponse(): GenerateContentResponse | undefined { + if (this.hookSpecificOutput && 'llm_response' in this.hookSpecificOutput) { + const hookResponse = this.hookSpecificOutput[ + 'llm_response' + ] as LLMResponse; + if (hookResponse) { + // Convert hook format to SDK format + return defaultHookTranslator.fromHookLLMResponse(hookResponse); + } + } + return undefined; + } + + /** + * Apply modifications to LLM request + */ + override applyLLMRequestModifications( + target: GenerateContentParameters, + ): GenerateContentParameters { + if (this.hookSpecificOutput && 'llm_request' in this.hookSpecificOutput) { + const hookRequest = this.hookSpecificOutput[ + 'llm_request' + ] as Partial; + if (hookRequest) { + // Convert hook format to SDK format + const sdkRequest = defaultHookTranslator.fromHookLLMRequest( + hookRequest as LLMRequest, + target, + ); + return { + ...target, + ...sdkRequest, + }; + } + } + return target; + } +} + +/** + * Specific hook output class for BeforeToolSelection events + */ +export class BeforeToolSelectionHookOutput extends DefaultHookOutput { + /** + * Apply tool configuration modifications + */ + override applyToolConfigModifications(target: { + toolConfig?: GenAIToolConfig; + tools?: ToolListUnion; + }): { toolConfig?: GenAIToolConfig; tools?: ToolListUnion } { + if (this.hookSpecificOutput && 'toolConfig' in this.hookSpecificOutput) { + const hookToolConfig = this.hookSpecificOutput[ + 'toolConfig' + ] as HookToolConfig; + if (hookToolConfig) { + // Convert hook format to SDK format + const sdkToolConfig = + defaultHookTranslator.fromHookToolConfig(hookToolConfig); + return { + ...target, + tools: target.tools || [], + toolConfig: sdkToolConfig, + }; + } + } + return target; + } +} + +/** + * Specific hook output class for AfterModel events + */ +export class AfterModelHookOutput extends DefaultHookOutput { + /** + * Get modified LLM response if provided by hook + */ + getModifiedResponse(): GenerateContentResponse | undefined { + if (this.hookSpecificOutput && 'llm_response' in this.hookSpecificOutput) { + const hookResponse = this.hookSpecificOutput[ + 'llm_response' + ] as Partial; + if (hookResponse?.candidates?.[0]?.content) { + // Convert hook format to SDK format + return defaultHookTranslator.fromHookLLMResponse( + hookResponse as LLMResponse, + ); + } + } + + // If hook wants to stop execution, create a synthetic stop response + if (this.shouldStopExecution()) { + const stopResponse: LLMResponse = { + candidates: [ + { + content: { + role: 'model', + parts: [this.getEffectiveReason() || 'Execution stopped by hook'], + }, + finishReason: 'STOP', + }, + ], + }; + return defaultHookTranslator.fromHookLLMResponse(stopResponse); + } + + return undefined; + } +} + +/** + * BeforeTool hook input + */ +export interface BeforeToolInput extends HookInput { + tool_name: string; + tool_input: Record; +} + +/** + * BeforeTool hook output + */ +export interface BeforeToolOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'BeforeTool'; + permissionDecision?: HookDecision; + permissionDecisionReason?: string; + }; +} + +/** + * AfterTool hook input + */ +export interface AfterToolInput extends HookInput { + tool_name: string; + tool_input: Record; + tool_response: Record; +} + +/** + * AfterTool hook output + */ +export interface AfterToolOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'AfterTool'; + additionalContext?: string; + }; +} + +/** + * BeforeAgent hook input + */ +export interface BeforeAgentInput extends HookInput { + prompt: string; +} + +/** + * BeforeAgent hook output + */ +export interface BeforeAgentOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'BeforeAgent'; + additionalContext?: string; + }; +} + +/** + * Notification types + */ +export enum NotificationType { + ToolPermission = 'ToolPermission', +} + +/** + * Notification hook input + */ +export interface NotificationInput extends HookInput { + notification_type: NotificationType; + message: string; + details: Record; +} + +/** + * Notification hook output + */ +export interface NotificationOutput { + suppressOutput?: boolean; + systemMessage?: string; +} + +/** + * AfterAgent hook input + */ +export interface AfterAgentInput extends HookInput { + prompt: string; + prompt_response: string; + stop_hook_active: boolean; +} + +/** + * SessionStart source types + */ +export enum SessionStartSource { + Startup = 'startup', + Resume = 'resume', + Clear = 'clear', + Compress = 'compress', +} + +/** + * SessionStart hook input + */ +export interface SessionStartInput extends HookInput { + source: SessionStartSource; +} + +/** + * SessionStart hook output + */ +export interface SessionStartOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'SessionStart'; + additionalContext?: string; + }; +} + +/** + * SessionEnd reason types + */ +export enum SessionEndReason { + Exit = 'exit', + Clear = 'clear', + Logout = 'logout', + PromptInputExit = 'prompt_input_exit', + Other = 'other', +} + +/** + * SessionEnd hook input + */ +export interface SessionEndInput extends HookInput { + reason: SessionEndReason; +} + +/** + * PreCompress trigger types + */ +export enum PreCompressTrigger { + Manual = 'manual', + Auto = 'auto', +} + +/** + * PreCompress hook input + */ +export interface PreCompressInput extends HookInput { + trigger: PreCompressTrigger; +} + +/** + * PreCompress hook output + */ +export interface PreCompressOutput { + suppressOutput?: boolean; + systemMessage?: string; +} + +/** + * BeforeModel hook input - uses decoupled types + */ +export interface BeforeModelInput extends HookInput { + llm_request: LLMRequest; +} + +/** + * BeforeModel hook output + */ +export interface BeforeModelOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'BeforeModel'; + llm_request?: Partial; + llm_response?: LLMResponse; + }; +} + +/** + * AfterModel hook input - uses decoupled types + */ +export interface AfterModelInput extends HookInput { + llm_request: LLMRequest; + llm_response: LLMResponse; +} + +/** + * AfterModel hook output + */ +export interface AfterModelOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'AfterModel'; + llm_response?: Partial; + }; +} + +/** + * BeforeToolSelection hook input - uses decoupled types + */ +export interface BeforeToolSelectionInput extends HookInput { + llm_request: LLMRequest; +} + +/** + * BeforeToolSelection hook output + */ +export interface BeforeToolSelectionOutput extends HookOutput { + hookSpecificOutput?: { + hookEventName: 'BeforeToolSelection'; + toolConfig?: HookToolConfig; + }; +} + +/** + * Hook execution result + */ +export interface HookExecutionResult { + hookConfig: HookConfig; + eventName: HookEventName; + success: boolean; + output?: HookOutput; + stdout?: string; + stderr?: string; + exitCode?: number; + duration: number; + error?: Error; +} + +/** + * Hook execution plan for an event + */ +export interface HookExecutionPlan { + eventName: HookEventName; + hookConfigs: HookConfig[]; + sequential: boolean; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ec30c4c2ac..513fae847d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -138,3 +138,6 @@ export { Storage } from './config/storage.js'; // Export test utils export * from './test-utils/index.js'; + +// Export hook types +export * from './hooks/types.js';