robustness(core): static checks to validate history is immutable (#21228)

This commit is contained in:
Jacob Richman
2026-03-09 11:26:03 -07:00
committed by GitHub
parent e7b20c49ac
commit 4c9f9bb3e2
13 changed files with 34 additions and 31 deletions

View File

@@ -11,7 +11,9 @@ import type { Content } from '@google/genai';
/**
* Serializes chat history to a Markdown string.
*/
export function serializeHistoryToMarkdown(history: Content[]): string {
export function serializeHistoryToMarkdown(
history: readonly Content[],
): string {
return history
.map((item) => {
const text =
@@ -49,7 +51,7 @@ export function serializeHistoryToMarkdown(history: Content[]): string {
* Options for exporting chat history.
*/
export interface ExportHistoryOptions {
history: Content[];
history: readonly Content[];
filePath: string;
}

View File

@@ -31,7 +31,7 @@ export interface MessageActionReturn {
export interface LoadHistoryActionReturn<HistoryType = unknown> {
type: 'load_history';
history: HistoryType;
clientHistory: Content[]; // The history for the generative client
clientHistory: readonly Content[]; // The history for the generative client
}
/**

View File

@@ -255,7 +255,7 @@ export class GeminiClient {
return this.chat !== undefined;
}
getHistory(): Content[] {
getHistory(): readonly Content[] {
return this.getChat().getHistory();
}
@@ -263,7 +263,7 @@ export class GeminiClient {
this.getChat().stripThoughtsFromHistory();
}
setHistory(history: Content[]) {
setHistory(history: readonly Content[]) {
this.getChat().setHistory(history);
this.updateTelemetryTokenCount();
this.forceFullIdeContext = true;
@@ -1171,7 +1171,7 @@ export class GeminiClient {
/**
* Masks bulky tool outputs to save context window space.
*/
private async tryMaskToolOutputs(history: Content[]): Promise<void> {
private async tryMaskToolOutputs(history: readonly Content[]): Promise<void> {
if (!this.config.getToolOutputMaskingEnabled()) {
return;
}

View File

@@ -470,7 +470,7 @@ export class GeminiChat {
private async makeApiCallAndProcessStream(
modelConfigKey: ModelConfigKey,
requestContents: Content[],
requestContents: readonly Content[],
prompt_id: string,
abortSignal: AbortSignal,
role: LlmRole,
@@ -489,7 +489,7 @@ export class GeminiChat {
let currentGenerateContentConfig: GenerateContentConfig =
newAvailabilityConfig;
let lastConfig: GenerateContentConfig = currentGenerateContentConfig;
let lastContentsToUse: Content[] = requestContents;
let lastContentsToUse: Content[] = [...requestContents];
const getAvailabilityContext = createAvailabilityContextProvider(
this.config,
@@ -528,9 +528,9 @@ export class GeminiChat {
abortSignal,
};
let contentsToUse = supportsModernFeatures(modelToUse)
? contentsForPreviewModel
: requestContents;
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
? [...contentsForPreviewModel]
: [...requestContents];
const hookSystem = this.config.getHookSystem();
if (hookSystem) {
@@ -687,16 +687,10 @@ export class GeminiChat {
* @return History contents alternating between user and model for the entire
* chat session.
*/
getHistory(curated: boolean = false): Content[] {
getHistory(curated: boolean = false): readonly Content[] {
const history = curated
? extractCuratedHistory(this.history)
: this.history;
// Return a shallow copy of the array to prevent callers from mutating
// the internal history array (push/pop/splice). Content objects are
// shared references — callers MUST NOT mutate them in place.
// This replaces a prior structuredClone() which deep-copied the entire
// conversation on every call, causing O(n) memory pressure per turn
// that compounded into OOM crashes in long-running sessions.
return [...history];
}
@@ -714,8 +708,8 @@ export class GeminiChat {
this.history.push(content);
}
setHistory(history: Content[]): void {
this.history = history;
setHistory(history: readonly Content[]): void {
this.history = [...history];
this.lastPromptTokenCount = estimateTokenCountSync(
this.history.flatMap((c) => c.parts || []),
);
@@ -742,7 +736,9 @@ export class GeminiChat {
// To ensure our requests validate, the first function call in every model
// turn within the active loop must have a `thoughtSignature` property.
// If we do not do this, we will get back 400 errors from the API.
ensureActiveLoopHasThoughtSignatures(requestContents: Content[]): Content[] {
ensureActiveLoopHasThoughtSignatures(
requestContents: readonly Content[],
): readonly Content[] {
// First, find the start of the active loop by finding the last user turn
// with a text message, i.e. that is not a function response.
let activeLoopStartIndex = -1;

View File

@@ -27,7 +27,7 @@ export interface LogEntry {
}
export interface Checkpoint {
history: Content[];
history: readonly Content[];
authType?: AuthType;
}

View File

@@ -31,7 +31,7 @@ export interface RoutingDecision {
*/
export interface RoutingContext {
/** The full history of the conversation. */
history: Content[];
history: readonly Content[];
/** The immediate request parts to be processed. */
request: PartListUnion;
/** An abort signal to cancel an LLM call during routing. */

View File

@@ -74,7 +74,9 @@ export class ContextBuilder {
}
// Helper to convert Google GenAI Content[] to Safety Protocol ConversationTurn[]
private convertHistoryToTurns(history: Content[]): ConversationTurn[] {
private convertHistoryToTurns(
history: readonly Content[],
): ConversationTurn[] {
const turns: ConversationTurn[] = [];
let currentUserRequest: { text: string } | undefined;

View File

@@ -130,7 +130,7 @@ export function modelStringToModelConfigAlias(model: string): string {
* contain massive tool outputs (like large grep results or logs).
*/
async function truncateHistoryToBudget(
history: Content[],
history: readonly Content[],
config: Config,
): Promise<Content[]> {
let functionResponseTokenCounter = 0;

View File

@@ -664,7 +664,7 @@ export class ChatRecordingService {
* Updates the conversation history based on the provided API Content array.
* This is used to persist changes made to the history (like masking) back to disk.
*/
updateMessagesFromHistory(history: Content[]): void {
updateMessagesFromHistory(history: readonly Content[]): void {
if (!this.conversationFile) return;
try {

View File

@@ -43,7 +43,7 @@ const EXEMPT_TOOLS = new Set([
]);
export interface MaskingResult {
newHistory: Content[];
newHistory: readonly Content[];
maskedCount: number;
tokensSaved: number;
}
@@ -67,7 +67,10 @@ export interface MaskingResult {
* are preserved until they collectively reach the threshold.
*/
export class ToolOutputMaskingService {
async mask(history: Content[], config: Config): Promise<MaskingResult> {
async mask(
history: readonly Content[],
config: Config,
): Promise<MaskingResult> {
const maskingConfig = await config.getToolOutputMaskingConfig();
if (!maskingConfig.enabled || history.length === 0) {
return { newHistory: history, maskedCount: 0, tokensSaved: 0 };

View File

@@ -14,7 +14,7 @@ import type { ToolCallRequestInfo } from '../scheduler/types.js';
export interface ToolCallData<HistoryType = unknown, ArgsType = unknown> {
history?: HistoryType;
clientHistory?: Content[];
clientHistory?: readonly Content[];
commitHash?: string;
toolCall: {
name: string;

View File

@@ -226,7 +226,7 @@ export class GeminiCliSession {
break;
}
const transcript: Content[] = client.getHistory();
const transcript: readonly Content[] = client.getHistory();
const context: SessionContext = {
sessionId,
transcript,

View File

@@ -51,7 +51,7 @@ export interface AgentShell {
export interface SessionContext {
sessionId: string;
transcript: Content[];
transcript: readonly Content[];
cwd: string;
timestamp: string;
fs: AgentFilesystem;