Download packages/core/src/context/agentHistoryProvider.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 13.8 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/context/agentHistoryProvider.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/core/src/context/agentHistoryProvider.ts
-
curl -L -o agentHistoryProvider.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/core/src/context/agentHistoryProvider.ts
13.8 kB
| /** | |
| * @license | |
| * Copyright 2026 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import type { Content, Part } from '@google/genai'; | |
| import { getResponseText } from '../utils/partUtils.js'; | |
| import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; | |
| import { LlmRole } from '../telemetry/llmRole.js'; | |
| import { debugLogger } from '../utils/debugLogger.js'; | |
| import type { AgentHistoryProviderConfig } from './types.js'; | |
| import type { Config } from '../config/config.js'; | |
| import { | |
| MIN_TARGET_TOKENS, | |
| MIN_CHARS_FOR_TRUNCATION, | |
| TEXT_TRUNCATION_PREFIX, | |
| estimateCharsFromTokens, | |
| truncateProportionally, | |
| normalizeFunctionResponse, | |
| } from './truncation.js'; | |
| export class AgentHistoryProvider { | |
| // TODO(joshualitt): just pass the BaseLlmClient instead of the whole Config. | |
| constructor( | |
| private readonly providerConfig: AgentHistoryProviderConfig, | |
| private readonly config: Config, | |
| ) {} | |
| /** | |
| * Evaluates the chat history and performs truncation and summarization if necessary. | |
| * Returns a new array of Content if truncation occurred, otherwise returns the original array. | |
| */ | |
| async manageHistory( | |
| history: readonly Content[], | |
| abortSignal?: AbortSignal, | |
| ): Promise<readonly Content[]> { | |
| if (history.length === 0) { | |
| return history; | |
| } | |
| // Step 1: Normalize newest messages. | |
| const normalizedHistory = this.enforceMessageSizeLimits(history); | |
| const totalTokens = estimateTokenCountSync( | |
| normalizedHistory.flatMap((c) => c.parts || []), | |
| ); | |
| // Step 2: Check if truncation is needed based on the token threshold (High Watermark) | |
| if (totalTokens <= this.providerConfig.maxTokens) { | |
| return normalizedHistory; | |
| } | |
| // Step 3: Split into keep/truncate boundaries | |
| const { messagesToKeep, messagesToTruncate } = | |
| this.splitHistoryForTruncation(normalizedHistory); | |
| if (messagesToTruncate.length === 0) { | |
| return messagesToKeep; | |
| } | |
| debugLogger.log( | |
| `AgentHistoryProvider: Truncating ${messagesToTruncate.length} messages, retaining ${messagesToKeep.length} messages.`, | |
| ); | |
| const summaryText = await this.getSummaryText( | |
| messagesToTruncate, | |
| messagesToKeep, | |
| abortSignal, | |
| ); | |
| return this.mergeSummaryWithHistory(summaryText, messagesToKeep); | |
| } | |
| /** | |
| * Enforces message size limits on the most recent message and the message | |
| * that just exited the grace zone. | |
| * - Recent messages have a high MAXIMUM limit. | |
| * - Older messages (already processed) are restricted to the NORMAL limit | |
| * once they exit the grace period. | |
| */ | |
| private enforceMessageSizeLimits( | |
| history: readonly Content[], | |
| ): readonly Content[] { | |
| if (history.length === 0) return history; | |
| let hasChanges = false; | |
| let accumulatedTokens = 0; | |
| // Scan backwards to find the index where the token budget is exhausted | |
| let graceStartIndex = 0; | |
| for (let i = history.length - 1; i >= 0; i--) { | |
| const msgTokens = estimateTokenCountSync(history[i].parts || []); | |
| accumulatedTokens += msgTokens; | |
| if (accumulatedTokens > this.providerConfig.retainedTokens) { | |
| graceStartIndex = i + 1; | |
| break; | |
| } | |
| } | |
| const newHistory = history.map((msg, i) => { | |
| const targetTokens = | |
| i < graceStartIndex | |
| ? this.providerConfig.normalMessageTokens | |
| : this.providerConfig.maximumMessageTokens; | |
| const normalizedMsg = this.normalizeMessage(msg, targetTokens); | |
| if (normalizedMsg !== msg) { | |
| hasChanges = true; | |
| } | |
| return normalizedMsg; | |
| }); | |
| return hasChanges ? newHistory : history; | |
| } | |
| /** | |
| * Normalizes a message by proportionally masking its text or function response | |
| * if its total token count exceeds the target token limit. | |
| */ | |
| private normalizeMessage(msg: Content, targetTokens: number): Content { | |
| const currentTokens = estimateTokenCountSync(msg.parts || []); | |
| if (currentTokens <= targetTokens) { | |
| return msg; | |
| } | |
| // Calculate the compression ratio to apply to all large parts | |
| const ratio = targetTokens / currentTokens; | |
| // Proportional compression of the parts to fit the targetTokens budget | |
| // while maintaining API structure (never dropping a part completely). | |
| const newParts: Part[] = []; | |
| for (const part of msg.parts || []) { | |
| if (part.text) { | |
| const partTokens = estimateTokenCountSync([part]); | |
| const targetPartTokens = Math.max( | |
| MIN_TARGET_TOKENS, | |
| Math.floor(partTokens * ratio), | |
| ); | |
| const targetChars = estimateCharsFromTokens( | |
| part.text, | |
| targetPartTokens, | |
| ); | |
| if ( | |
| part.text.length > targetChars && | |
| targetChars > MIN_CHARS_FOR_TRUNCATION | |
| ) { | |
| const newText = truncateProportionally( | |
| part.text, | |
| targetChars, | |
| TEXT_TRUNCATION_PREFIX, | |
| this.providerConfig.normalizationHeadRatio, | |
| ); | |
| newParts.push({ text: newText }); | |
| } else { | |
| newParts.push(part); | |
| } | |
| } else if (part.functionResponse) { | |
| newParts.push( | |
| normalizeFunctionResponse( | |
| part, | |
| ratio, | |
| this.providerConfig.normalizationHeadRatio, | |
| ), | |
| ); | |
| } else { | |
| newParts.push(part); | |
| } | |
| } | |
| return { ...msg, parts: newParts }; | |
| } | |
| /** | |
| * Determines the boundary for splitting history based on the token budget, | |
| * keeping recent messages under a specific target token threshold, | |
| * while ensuring structural integrity (e.g. keeping functionCall/functionResponse pairs). | |
| */ | |
| private splitHistoryForTruncation(history: readonly Content[]): { | |
| messagesToKeep: readonly Content[]; | |
| messagesToTruncate: readonly Content[]; | |
| } { | |
| let accumulatedTokens = 0; | |
| let truncationBoundary = 0; // The index of the first message to keep | |
| // Scan backwards to calculate the boundary based on token budget | |
| for (let i = history.length - 1; i >= 0; i--) { | |
| const msg = history[i]; | |
| const msgTokens = estimateTokenCountSync(msg.parts || []); | |
| // Token Budget | |
| if (accumulatedTokens + msgTokens > this.providerConfig.retainedTokens) { | |
| // Exceeded budget, stop retaining messages here. | |
| truncationBoundary = i + 1; | |
| break; | |
| } | |
| accumulatedTokens += msgTokens; | |
| } | |
| // Ensure structural integrity of the boundary | |
| truncationBoundary = this.adjustBoundaryForIntegrity( | |
| history, | |
| truncationBoundary, | |
| ); | |
| const messagesToKeep = history.slice(truncationBoundary); | |
| const messagesToTruncate = history.slice(0, truncationBoundary); | |
| return { | |
| messagesToKeep, | |
| messagesToTruncate, | |
| }; | |
| } | |
| /** | |
| * Adjusts the truncation boundary backwards to prevent breaking functionCall/functionResponse pairs. | |
| */ | |
| private adjustBoundaryForIntegrity( | |
| history: readonly Content[], | |
| boundary: number, | |
| ): number { | |
| let currentBoundary = boundary; | |
| // Ensure we don't start at index 0 or out of bounds. | |
| if (currentBoundary <= 0 || currentBoundary >= history.length) { | |
| return currentBoundary; | |
| } | |
| while ( | |
| currentBoundary > 0 && | |
| currentBoundary < history.length && | |
| history[currentBoundary].role === 'user' && | |
| history[currentBoundary].parts?.some((p) => p.functionResponse) && | |
| history[currentBoundary - 1].role === 'model' && | |
| history[currentBoundary - 1].parts?.some((p) => p.functionCall) | |
| ) { | |
| currentBoundary--; // Include the functionCall in the retained history | |
| } | |
| return currentBoundary; | |
| } | |
| private getFallbackSummaryText( | |
| messagesToTruncate: readonly Content[], | |
| ): string { | |
| const userMessages = messagesToTruncate.filter((m) => m.role === 'user'); | |
| const modelMessages = messagesToTruncate.filter((m) => m.role === 'model'); | |
| const lastUserText = userMessages | |
| .slice(-1)[0] | |
| ?.parts?.map((p) => p.text || '') | |
| .join('') | |
| .trim(); | |
| const actionPath = modelMessages | |
| .flatMap( | |
| (m) => | |
| m.parts | |
| ?.filter((p) => p.functionCall) | |
| .map((p) => p.functionCall!.name) || [], | |
| ) | |
| .join(' → '); | |
| const summaryParts = [ | |
| '### [System Note: Conversation History Truncated]', | |
| 'Prior context was offloaded to maintain performance. Key highlights from the truncated history:', | |
| ]; | |
| if (lastUserText) { | |
| summaryParts.push( | |
| `- **Previous User Intent (Truncated):** "${lastUserText}"`, | |
| ); | |
| } | |
| if (actionPath) { | |
| summaryParts.push(`- **Action Path:** ${actionPath}`); | |
| } | |
| summaryParts.push( | |
| '- **Notice:** For deeper context, review persistent memory or task-specific logs.', | |
| ); | |
| return summaryParts.join('\n'); | |
| } | |
| private async getSummaryText( | |
| messagesToTruncate: readonly Content[], | |
| messagesToKeep: readonly Content[], | |
| abortSignal?: AbortSignal, | |
| ): Promise<string> { | |
| if (messagesToTruncate.length === 0) return ''; | |
| try { | |
| // Use the first few messages of the Grace Zone as a "contextual bridge" | |
| // to give the summarizer lookahead into the current state. | |
| const bridge = messagesToKeep.slice(0, 5); | |
| return await this.generateIntentSummary( | |
| messagesToTruncate, | |
| bridge, | |
| abortSignal, | |
| ); | |
| } catch (error) { | |
| debugLogger.log('AgentHistoryProvider: Summarization failed.', error); | |
| return this.getFallbackSummaryText(messagesToTruncate); | |
| } | |
| } | |
| private mergeSummaryWithHistory( | |
| summaryText: string, | |
| messagesToKeep: readonly Content[], | |
| ): readonly Content[] { | |
| if (!summaryText) return messagesToKeep; | |
| if (messagesToKeep.length === 0) { | |
| return [{ role: 'user', parts: [{ text: summaryText }] }]; | |
| } | |
| // To ensure strict user/model alternating roles required by the Gemini API, | |
| // we merge the summary into the first retained message if it's from the 'user'. | |
| const firstRetainedMessage = messagesToKeep[0]; | |
| if (firstRetainedMessage.role === 'user') { | |
| const mergedParts = [ | |
| { text: summaryText }, | |
| ...(firstRetainedMessage.parts || []), | |
| ]; | |
| const mergedMessage: Content = { | |
| role: 'user', | |
| parts: mergedParts, | |
| }; | |
| return [mergedMessage, ...messagesToKeep.slice(1)]; | |
| } else { | |
| const summaryMessage: Content = { | |
| role: 'user', | |
| parts: [{ text: summaryText }], | |
| }; | |
| return [summaryMessage, ...messagesToKeep]; | |
| } | |
| } | |
| private async generateIntentSummary( | |
| messagesToTruncate: readonly Content[], | |
| bridge: readonly Content[], | |
| abortSignal?: AbortSignal, | |
| ): Promise<string> { | |
| // 1. Identify and extract any existing summary from the truncated head | |
| const firstMsg = messagesToTruncate[0]; | |
| const firstPartText = firstMsg?.parts?.[0]?.text || ''; | |
| const hasPreviousSummary = firstPartText.includes('<intent_summary>'); | |
| // 2. Extract "The Action Path" (necklace of function names) | |
| const actionPath = messagesToTruncate | |
| .filter((m) => m.role === 'model') | |
| .flatMap( | |
| (m) => | |
| m.parts | |
| ?.filter((p) => p.functionCall) | |
| .map((p) => p.functionCall!.name) || [], | |
| ) | |
| .join(' → '); | |
| const prompt = `### State Update: Agent Continuity | |
| The conversation history has been truncated. You are generating a highly factual state summary to preserve the agent's exact working context. | |
| You have these signals to synthesize: | |
| ${hasPreviousSummary ? '1. **Previous Summary:** The existing state before this truncation.\n' : ''}2. **The Action Path:** A chronological list of tools called: [${actionPath}] | |
| 3. **Truncated History:** The specific actions, tool inputs, and tool outputs being offloaded. | |
| 4. **Active Bridge:** The first few turns of the "Grace Zone" (what follows immediately after this summary), showing the current tactical moment. | |
| ### Your Goal: | |
| Distill these into a high-density Markdown block that orientates the agent on the CONCRETE STATE of the workspace: | |
| - **Primary Goal:** The ultimate objective requested by the user. | |
| - **Verified Facts:** What has been definitively completed or proven (e.g., "File X was created", "Bug Y was reproduced"). | |
| - **Working Set:** The exact file paths currently being analyzed or modified. | |
| - **Active Blockers:** Exact error messages or failing test names currently preventing progress. | |
| ### Constraints: | |
| - **Format:** Wrap the entire response in <intent_summary> tags. | |
| - **Factuality:** Base all points strictly on the provided history. Do not invent rationale or assume success without proof. Use exact names and quotes. | |
| - **Brevity:** Maximum 15 lines. No conversational preamble. | |
| ${hasPreviousSummary ? 'PREVIOUS SUMMARY AND TRUNCATED HISTORY:' : 'TRUNCATED HISTORY:'} | |
| ${JSON.stringify(messagesToTruncate)} | |
| ACTIVE BRIDGE (LOOKAHEAD): | |
| ${JSON.stringify(bridge)}`; | |
| const summaryResponse = await this.config | |
| .getBaseLlmClient() | |
| .generateContent({ | |
| modelConfigKey: { model: 'agent-history-provider-summarizer' }, | |
| contents: [ | |
| { | |
| role: 'user', | |
| parts: [{ text: prompt }], | |
| }, | |
| ], | |
| promptId: 'agent-history-provider', | |
| abortSignal: abortSignal ?? new AbortController().signal, | |
| role: LlmRole.UTILITY_COMPRESSOR, | |
| }); | |
| let summary = getResponseText(summaryResponse) ?? ''; | |
| // Clean up if the model included extra tags or markdown | |
| summary = summary | |
| .replace(/```markdown/g, '') | |
| .replace(/```/g, '') | |
| .trim(); | |
| if (!summary.includes('<intent_summary>')) { | |
| summary = `<intent_summary>\n${summary}\n</intent_summary>`; | |
| } | |
| return summary; | |
| } | |
| } | |