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
This commit is contained in:
Sandy Tao
2026-03-08 11:41:36 -07:00
parent d485e08606
commit 5570b1c046
10 changed files with 344 additions and 9 deletions

View File

@@ -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',

View File

@@ -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<ReturnType<typeof setTimeout> | 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,

View File

@@ -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),
};
/**

View File

@@ -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 {

View File

@@ -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<AggregatedHookResult> {
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<AggregatedHookResult> {
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

View File

@@ -232,8 +232,15 @@ export class HookSystem {
async firePreCompressEvent(
trigger: PreCompressTrigger,
history: Array<{ role: string; parts: Array<{ text?: string }> }>,
): Promise<AggregatedHookResult | undefined> {
return this.hookEventHandler.firePreCompressEvent(trigger);
return this.hookEventHandler.firePreCompressEvent(trigger, history);
}
async fireIdleEvent(
idleSeconds: number,
): Promise<AggregatedHookResult | undefined> {
return this.hookEventHandler.fireIdleEvent(idleSeconds);
}
async fireBeforeAgentEvent(

View File

@@ -57,6 +57,7 @@ describe('Hook Types', () => {
'BeforeModel',
'AfterModel',
'BeforeToolSelection',
'Idle',
];
for (const event of expectedEvents) {

View File

@@ -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;
};
}
/**

View File

@@ -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<Config['getHookSystem']>,
);
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<Config['getHookSystem']>,
);
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<Config['getHookSystem']>,
);
await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(mockHookSystem.firePreCompressEvent).toHaveBeenCalledWith(
'manual',
[
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'world' }] },
],
);
});
});
});

View File

@@ -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(