mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
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:
@@ -2021,6 +2021,16 @@ const SETTINGS_SCHEMA = {
|
|||||||
description: 'Show visual indicators when hooks are executing.',
|
description: 'Show visual indicators when hooks are executing.',
|
||||||
showInDialog: true,
|
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',
|
ref: 'HookDefinitionArray',
|
||||||
mergeStrategy: MergeStrategy.CONCAT,
|
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: {
|
additionalProperties: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -1874,6 +1874,64 @@ export const useGeminiStream = (
|
|||||||
storage,
|
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(
|
const lastOutputTime = Math.max(
|
||||||
lastToolOutputTime,
|
lastToolOutputTime,
|
||||||
lastShellOutputTime,
|
lastShellOutputTime,
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ const mockHookSystem = {
|
|||||||
fireBeforeAgentEvent: vi.fn().mockResolvedValue(undefined),
|
fireBeforeAgentEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
fireAfterAgentEvent: vi.fn().mockResolvedValue(undefined),
|
fireAfterAgentEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
firePreCompressEvent: vi.fn().mockResolvedValue(undefined),
|
firePreCompressEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
|
fireIdleEvent: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ export enum CompressionStatus {
|
|||||||
|
|
||||||
/** The compression was skipped due to previous failure, but content was truncated to budget */
|
/** The compression was skipped due to previous failure, but content was truncated to budget */
|
||||||
CONTENT_TRUNCATED,
|
CONTENT_TRUNCATED,
|
||||||
|
|
||||||
|
/** The compression was replaced by a PreCompress hook */
|
||||||
|
HOOK_REPLACED,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatCompressionInfo {
|
export interface ChatCompressionInfo {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type PreCompressTrigger,
|
type PreCompressTrigger,
|
||||||
type HookExecutionResult,
|
type HookExecutionResult,
|
||||||
type McpToolContext,
|
type McpToolContext,
|
||||||
|
type IdleInput,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import { defaultHookTranslator } from './hookTranslator.js';
|
import { defaultHookTranslator } from './hookTranslator.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -204,16 +205,30 @@ export class HookEventHandler {
|
|||||||
*/
|
*/
|
||||||
async firePreCompressEvent(
|
async firePreCompressEvent(
|
||||||
trigger: PreCompressTrigger,
|
trigger: PreCompressTrigger,
|
||||||
|
history: Array<{ role: string; parts: Array<{ text?: string }> }>,
|
||||||
): Promise<AggregatedHookResult> {
|
): Promise<AggregatedHookResult> {
|
||||||
const input: PreCompressInput = {
|
const input: PreCompressInput = {
|
||||||
...this.createBaseInput(HookEventName.PreCompress),
|
...this.createBaseInput(HookEventName.PreCompress),
|
||||||
trigger,
|
trigger,
|
||||||
|
history,
|
||||||
};
|
};
|
||||||
|
|
||||||
const context: HookEventContext = { trigger };
|
const context: HookEventContext = { trigger };
|
||||||
return this.executeHooks(HookEventName.PreCompress, input, context);
|
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
|
* Fire a BeforeModel event
|
||||||
* Called by handleHookExecutionRequest - executes hooks directly
|
* Called by handleHookExecutionRequest - executes hooks directly
|
||||||
|
|||||||
@@ -232,8 +232,15 @@ export class HookSystem {
|
|||||||
|
|
||||||
async firePreCompressEvent(
|
async firePreCompressEvent(
|
||||||
trigger: PreCompressTrigger,
|
trigger: PreCompressTrigger,
|
||||||
|
history: Array<{ role: string; parts: Array<{ text?: string }> }>,
|
||||||
): Promise<AggregatedHookResult | undefined> {
|
): 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(
|
async fireBeforeAgentEvent(
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('Hook Types', () => {
|
|||||||
'BeforeModel',
|
'BeforeModel',
|
||||||
'AfterModel',
|
'AfterModel',
|
||||||
'BeforeToolSelection',
|
'BeforeToolSelection',
|
||||||
|
'Idle',
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const event of expectedEvents) {
|
for (const event of expectedEvents) {
|
||||||
|
|||||||
@@ -43,12 +43,18 @@ export enum HookEventName {
|
|||||||
BeforeModel = 'BeforeModel',
|
BeforeModel = 'BeforeModel',
|
||||||
AfterModel = 'AfterModel',
|
AfterModel = 'AfterModel',
|
||||||
BeforeToolSelection = 'BeforeToolSelection',
|
BeforeToolSelection = 'BeforeToolSelection',
|
||||||
|
Idle = 'Idle',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields in the hooks configuration that are not hook event names
|
* 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
|
* Hook implementation types
|
||||||
@@ -642,14 +648,37 @@ export enum PreCompressTrigger {
|
|||||||
*/
|
*/
|
||||||
export interface PreCompressInput extends HookInput {
|
export interface PreCompressInput extends HookInput {
|
||||||
trigger: PreCompressTrigger;
|
trigger: PreCompressTrigger;
|
||||||
|
history: Array<{ role: string; parts: Array<{ text?: string }> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PreCompress hook output
|
* PreCompress hook output
|
||||||
*/
|
*/
|
||||||
export interface PreCompressOutput {
|
export interface PreCompressOutput {
|
||||||
suppressOutput?: boolean;
|
suppressOutput?: boolean;
|
||||||
systemMessage?: string;
|
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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('ChatCompressionService', () => {
|
|||||||
}),
|
}),
|
||||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||||
getHookSystem: () => undefined,
|
getHookSystem: vi.fn().mockReturnValue(undefined),
|
||||||
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
|
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
|
||||||
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
|
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
|
||||||
storage: {
|
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' }] },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
const originalTokenCount = chat.getLastPromptTokenCount();
|
||||||
|
|
||||||
// Don't compress if not forced and we are under the limit.
|
// 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.
|
// 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.
|
// This ensures that even the "to compress" portion is within safe limits for the summarization model.
|
||||||
const truncatedHistory = await truncateHistoryToBudget(
|
const truncatedHistory = await truncateHistoryToBudget(
|
||||||
|
|||||||
Reference in New Issue
Block a user