From 5570b1c046d689b5ff857dcc62f4d129ae22f22a Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Sun, 8 Mar 2026 11:41:36 -0700 Subject: [PATCH] feat(core): enhance hook system with PreCompress replacement and Idle event PreCompress hooks can now return newHistory to replace built-in compression. Add Idle hook event that fires after configurable inactivity period. - PreCompress: accept history in hook input, return newHistory to skip built-in summarization (CompressionStatus.HOOK_REPLACED) - Idle: new HookEventName with fireIdleEvent, auto-activates when extensions register Idle hooks (default 300s, configurable via hooksConfig.idleTimeout) - Hook can return hookSpecificOutput.prompt to auto-submit a message --- packages/cli/src/config/settingsSchema.ts | 22 +++ packages/cli/src/ui/hooks/useGeminiStream.ts | 58 +++++++ packages/core/src/core/client.test.ts | 1 + packages/core/src/core/turn.ts | 3 + packages/core/src/hooks/hookEventHandler.ts | 15 ++ packages/core/src/hooks/hookSystem.ts | 9 +- packages/core/src/hooks/types.test.ts | 1 + packages/core/src/hooks/types.ts | 33 +++- .../services/chatCompressionService.test.ts | 149 +++++++++++++++++- .../src/services/chatCompressionService.ts | 62 +++++++- 10 files changed, 344 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index bd1f9d82a4..cacb7b14c7 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2021,6 +2021,16 @@ const SETTINGS_SCHEMA = { description: 'Show visual indicators when hooks are executing.', showInDialog: true, }, + idleTimeout: { + type: 'number', + label: 'Idle Timeout', + category: 'Advanced', + requiresRestart: false, + default: 0, + description: + 'Time in seconds before the Idle hook fires when there is no user input. Set to 0 to disable.', + showInDialog: true, + }, }, }, @@ -2165,6 +2175,18 @@ const SETTINGS_SCHEMA = { ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, + Idle: { + type: 'array', + label: 'Idle Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after a period of inactivity. Can trigger maintenance tasks like memory consolidation.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, }, additionalProperties: { type: 'array', diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d254902a94..bd882c6d69 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1874,6 +1874,64 @@ export const useGeminiStream = ( storage, ]); + // Idle hook timer: fires after idleTimeout seconds of no activity. + // If idleTimeout is explicitly set, use it. Otherwise, if any Idle hooks + // are registered (e.g. by an extension), use a default of 300 seconds. + const DEFAULT_IDLE_TIMEOUT = 300; + const idleTimerRef = useRef | null>(null); + const configuredIdleTimeout = settings.merged.hooksConfig?.idleTimeout ?? 0; + useEffect(() => { + // Clear any existing timer + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + + if (streamingState !== StreamingState.Idle || !config.getEnableHooks()) { + return; + } + + // Compute effective timeout: use configured value, or default if + // Idle hooks are registered (e.g. by an extension). + let idleTimeoutSeconds = configuredIdleTimeout; + if (idleTimeoutSeconds <= 0) { + const hookSystem = config.getHookSystem(); + const hasIdleHooks = hookSystem + ?.getAllHooks() + .some((h) => h.eventName === 'Idle' && h.enabled); + idleTimeoutSeconds = hasIdleHooks ? DEFAULT_IDLE_TIMEOUT : 0; + } + + if (idleTimeoutSeconds <= 0) { + return; + } + + const startTime = Date.now(); + idleTimerRef.current = setTimeout(async () => { + const hookSystem = config.getHookSystem(); + if (!hookSystem) return; + + const elapsed = Math.round((Date.now() - startTime) / 1000); + try { + const result = await hookSystem.fireIdleEvent(elapsed); + const prompt = result?.finalOutput?.hookSpecificOutput?.['prompt']; + if (typeof prompt === 'string' && prompt.trim()) { + // Auto-submit the prompt returned by the hook + void submitQuery(prompt); + } + } catch { + // Idle hook failures are non-fatal + } + }, idleTimeoutSeconds * 1000); + + return () => { + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + }; + }, [streamingState, configuredIdleTimeout, config, submitQuery]); + const lastOutputTime = Math.max( lastToolOutputTime, lastShellOutputTime, diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 58e9645b28..2b34214798 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -141,6 +141,7 @@ const mockHookSystem = { fireBeforeAgentEvent: vi.fn().mockResolvedValue(undefined), fireAfterAgentEvent: vi.fn().mockResolvedValue(undefined), firePreCompressEvent: vi.fn().mockResolvedValue(undefined), + fireIdleEvent: vi.fn().mockResolvedValue(undefined), }; /** diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 9c0e536c48..6b26699dc2 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -182,6 +182,9 @@ export enum CompressionStatus { /** The compression was skipped due to previous failure, but content was truncated to budget */ CONTENT_TRUNCATED, + + /** The compression was replaced by a PreCompress hook */ + HOOK_REPLACED, } export interface ChatCompressionInfo { diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index 7fa45e3271..a55d88b832 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -30,6 +30,7 @@ import { type PreCompressTrigger, type HookExecutionResult, type McpToolContext, + type IdleInput, } from './types.js'; import { defaultHookTranslator } from './hookTranslator.js'; import type { @@ -204,16 +205,30 @@ export class HookEventHandler { */ async firePreCompressEvent( trigger: PreCompressTrigger, + history: Array<{ role: string; parts: Array<{ text?: string }> }>, ): Promise { const input: PreCompressInput = { ...this.createBaseInput(HookEventName.PreCompress), trigger, + history, }; const context: HookEventContext = { trigger }; return this.executeHooks(HookEventName.PreCompress, input, context); } + /** + * Fire an Idle event + */ + async fireIdleEvent(idleSeconds: number): Promise { + const input: IdleInput = { + ...this.createBaseInput(HookEventName.Idle), + idle_seconds: idleSeconds, + }; + + return this.executeHooks(HookEventName.Idle, input); + } + /** * Fire a BeforeModel event * Called by handleHookExecutionRequest - executes hooks directly diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index f748665985..96f0c7522f 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -232,8 +232,15 @@ export class HookSystem { async firePreCompressEvent( trigger: PreCompressTrigger, + history: Array<{ role: string; parts: Array<{ text?: string }> }>, ): Promise { - return this.hookEventHandler.firePreCompressEvent(trigger); + return this.hookEventHandler.firePreCompressEvent(trigger, history); + } + + async fireIdleEvent( + idleSeconds: number, + ): Promise { + return this.hookEventHandler.fireIdleEvent(idleSeconds); } async fireBeforeAgentEvent( diff --git a/packages/core/src/hooks/types.test.ts b/packages/core/src/hooks/types.test.ts index ab809cbec7..a0c4d4d81c 100644 --- a/packages/core/src/hooks/types.test.ts +++ b/packages/core/src/hooks/types.test.ts @@ -57,6 +57,7 @@ describe('Hook Types', () => { 'BeforeModel', 'AfterModel', 'BeforeToolSelection', + 'Idle', ]; for (const event of expectedEvents) { diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 9c6217ffa4..c95c3c388e 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -43,12 +43,18 @@ export enum HookEventName { BeforeModel = 'BeforeModel', AfterModel = 'AfterModel', BeforeToolSelection = 'BeforeToolSelection', + Idle = 'Idle', } /** * Fields in the hooks configuration that are not hook event names */ -export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications']; +export const HOOKS_CONFIG_FIELDS = [ + 'enabled', + 'disabled', + 'notifications', + 'idleTimeout', +]; /** * Hook implementation types @@ -642,14 +648,37 @@ export enum PreCompressTrigger { */ export interface PreCompressInput extends HookInput { trigger: PreCompressTrigger; + history: Array<{ role: string; parts: Array<{ text?: string }> }>; } - /** * PreCompress hook output */ export interface PreCompressOutput { suppressOutput?: boolean; systemMessage?: string; + hookSpecificOutput?: { + hookEventName: 'PreCompress'; + newHistory?: Array<{ role: string; parts: Array<{ text?: string }> }>; + }; +} + +/** + * Idle hook input + */ +export interface IdleInput extends HookInput { + idle_seconds: number; +} + +/** + * Idle hook output + */ +export interface IdleOutput { + suppressOutput?: boolean; + systemMessage?: string; + hookSpecificOutput?: { + hookEventName: 'Idle'; + prompt?: string; + }; } /** diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 7ae9549a25..b51ec4a68c 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -183,7 +183,7 @@ describe('ChatCompressionService', () => { }), getEnableHooks: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: () => undefined, + getHookSystem: vi.fn().mockReturnValue(undefined), getNextCompressionTruncationId: vi.fn().mockReturnValue(1), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000), storage: { @@ -894,4 +894,151 @@ describe('ChatCompressionService', () => { ); }); }); + + describe('PreCompress hook replacement', () => { + it('should use hook-provided newHistory and skip built-in compression', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const hookReplacementHistory = [ + { + role: 'user', + parts: [{ text: 'Archive summary: topics discussed...' }], + }, + { + role: 'model', + parts: [{ text: 'Understood, continuing from archive.' }], + }, + ]; + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + finalOutput: { + hookSpecificOutput: { + hookEventName: 'PreCompress', + newHistory: hookReplacementHistory, + }, + }, + allOutputs: [], + errors: [], + totalDuration: 100, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + const result = await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.HOOK_REPLACED, + ); + expect(result.newHistory).not.toBeNull(); + expect(result.newHistory!.length).toBe(2); + expect(result.newHistory![0].parts![0].text).toBe( + 'Archive summary: topics discussed...', + ); + // Built-in LLM compression should NOT have been called + expect( + mockConfig.getBaseLlmClient().generateContent, + ).not.toHaveBeenCalled(); + }); + + it('should proceed with built-in compression when hook returns no newHistory', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + finalOutput: { + systemMessage: 'Compression starting...', + }, + allOutputs: [], + errors: [], + totalDuration: 50, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + const result = await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + // Should fall through to normal compression + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(mockConfig.getBaseLlmClient().generateContent).toHaveBeenCalled(); + }); + + it('should pass history to the hook', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'world' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + allOutputs: [], + errors: [], + totalDuration: 10, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + expect(mockHookSystem.firePreCompressEvent).toHaveBeenCalledWith( + 'manual', + [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'world' }] }, + ], + ); + }); + }); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 8dceb18f4b..1c876264b3 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -254,11 +254,6 @@ export class ChatCompressionService { }; } - // Fire PreCompress hook before compression - // This fires for both manual and auto compression attempts - const trigger = force ? PreCompressTrigger.Manual : PreCompressTrigger.Auto; - await config.getHookSystem()?.firePreCompressEvent(trigger); - const originalTokenCount = chat.getLastPromptTokenCount(); // Don't compress if not forced and we are under the limit. @@ -278,6 +273,63 @@ export class ChatCompressionService { } } + // Fire PreCompress hook — only when compression will actually proceed + const trigger = force ? PreCompressTrigger.Manual : PreCompressTrigger.Auto; + + // Serialize history for the hook: strip non-text parts to keep payload manageable + const curatedForHook = curatedHistory.map((c) => ({ + role: c.role ?? 'user', + parts: (c.parts ?? []) + .filter((p): p is { text: string } => typeof p.text === 'string') + .map((p) => ({ text: p.text })), + })); + + const hookResult = await config + .getHookSystem() + ?.firePreCompressEvent(trigger, curatedForHook); + + // If a hook provided replacement history, use it and skip built-in compression + const hookNewHistory = + hookResult?.finalOutput?.hookSpecificOutput?.['newHistory']; + if (Array.isArray(hookNewHistory) && hookNewHistory.length > 0) { + // Convert hook output back to Content[] + const replacementHistory: Content[] = hookNewHistory.map( + (entry: { role?: string; parts?: Array<{ text?: string }> }) => { + const role = + entry.role === 'model' || entry.role === 'user' + ? entry.role + : 'user'; + return { + role, + parts: (entry.parts ?? []).map((p: { text?: string }) => ({ + text: p.text ?? '', + })), + }; + }, + ); + + const newTokenCount = estimateTokenCountSync( + replacementHistory.flatMap((c) => c.parts || []), + ); + + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: newTokenCount, + }), + ); + + return { + newHistory: replacementHistory, + info: { + originalTokenCount, + newTokenCount, + compressionStatus: CompressionStatus.HOOK_REPLACED, + }, + }; + } + // Apply token-based truncation to the entire history before splitting. // This ensures that even the "to compress" portion is within safe limits for the summarization model. const truncatedHistory = await truncateHistoryToBudget(