diff --git a/packages/core/src/context/graph/toGraph.ts b/packages/core/src/context/graph/toGraph.ts index 51012fed04..454c867fb8 100644 --- a/packages/core/src/context/graph/toGraph.ts +++ b/packages/core/src/context/graph/toGraph.ts @@ -6,7 +6,7 @@ import type { Part } from '@google/genai'; import { type ConcreteNode, NodeType } from './types.js'; -import { randomUUID, createHash } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { debugLogger } from '../../utils/debugLogger.js'; import type { NodeIdService } from './nodeIdService.js'; import type { HistoryTurn } from '../../core/agentChatHistory.js'; @@ -157,7 +157,7 @@ export function getStableId( if (turnSalt && partIdx === -1) { id = `turn_${turnSalt}`; } else { - id = randomUUID(); + id = `${turnSalt}_f_${partIdx}`; } } diff --git a/packages/core/src/context/processors/blobDegradationProcessor.ts b/packages/core/src/context/processors/blobDegradationProcessor.ts index c1cae0d0a6..663d26aba7 100644 --- a/packages/core/src/context/processors/blobDegradationProcessor.ts +++ b/packages/core/src/context/processors/blobDegradationProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ProcessArgs, ContextProcessor } from '../pipeline.js'; import * as fs from 'node:fs/promises'; @@ -62,7 +62,8 @@ export function createBlobDegradationProcessor( if (payload.inlineData?.data && payload.inlineData?.mimeType) { await ensureDir(); const ext = payload.inlineData.mimeType.split('/')[1] || 'bin'; - const fileName = `blob_${Date.now()}_${randomUUID()}.${ext}`; + // Use a stable filename based on the node ID + const fileName = `blob_${deriveStableId([node.id])}.${ext}`; const filePath = path.join(blobOutputsDir, fileName); const buffer = Buffer.from(payload.inlineData.data, 'base64'); @@ -92,7 +93,7 @@ export function createBlobDegradationProcessor( if (newText && tokensSaved > 0) { returnedNodes.push({ ...node, - id: randomUUID(), + id: deriveStableId([node.id, 'degraded']), payload: { text: newText }, replacesId: node.id, turnId: node.turnId, diff --git a/packages/core/src/context/processors/nodeDistillationProcessor.ts b/packages/core/src/context/processors/nodeDistillationProcessor.ts index 5691ddf51b..a6a818ed56 100644 --- a/packages/core/src/context/processors/nodeDistillationProcessor.ts +++ b/packages/core/src/context/processors/nodeDistillationProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ContextProcessor, ProcessArgs } from '../pipeline.js'; import { type ConcreteNode, NodeType } from '../graph/types.js'; @@ -99,9 +99,10 @@ export function createNodeDistillationProcessor( if (newTokens < oldTokens) { const distilledPayload = updatePart(payload, { text: summary }); + const newId = deriveStableId([node.id, 'distilled']); returnedNodes.push({ ...node, - id: randomUUID(), + id: newId, payload: distilledPayload, replacesId: node.id, timestamp: node.timestamp, @@ -158,9 +159,10 @@ export function createNodeDistillationProcessor( functionResponse: newFR, }); + const newId = deriveStableId([node.id, 'distilled']); returnedNodes.push({ ...node, - id: randomUUID(), + id: newId, payload: distilledPayload, replacesId: node.id, timestamp: node.timestamp, diff --git a/packages/core/src/context/processors/nodeTruncationProcessor.ts b/packages/core/src/context/processors/nodeTruncationProcessor.ts index acb08e2022..75f74f60cf 100644 --- a/packages/core/src/context/processors/nodeTruncationProcessor.ts +++ b/packages/core/src/context/processors/nodeTruncationProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ContextProcessor, ProcessArgs } from '../pipeline.js'; import type { ContextEnvironment } from '../pipeline/environment.js'; @@ -79,9 +79,10 @@ export function createNodeTruncationProcessor( if (text) { const squashResult = tryApplySquash(text, limitChars); if (squashResult) { + const newId = deriveStableId([node.id, 'truncated']); returnedNodes.push({ ...node, - id: randomUUID(), + id: newId, payload: { ...payload, text: squashResult.text }, replacesId: node.id, turnId: node.turnId, diff --git a/packages/core/src/context/processors/rollingSummaryProcessor.ts b/packages/core/src/context/processors/rollingSummaryProcessor.ts index fc6f5cb60a..5de111e0f8 100644 --- a/packages/core/src/context/processors/rollingSummaryProcessor.ts +++ b/packages/core/src/context/processors/rollingSummaryProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ContextProcessor, @@ -112,7 +112,8 @@ export function createRollingSummaryProcessor( try { // Synthesize the rolling summary synchronously const snapshotText = await generateRollingSummary(nodesToSummarize); - const newId = randomUUID(); + const consumedIds = nodesToSummarize.map((n) => n.id); + const newId = deriveStableId(consumedIds); const summaryNode: RollingSummary = { id: newId, @@ -121,10 +122,9 @@ export function createRollingSummaryProcessor( timestamp: nodesToSummarize[nodesToSummarize.length - 1].timestamp, role: 'user', payload: { text: snapshotText }, - abstractsIds: nodesToSummarize.map((n) => n.id), + abstractsIds: consumedIds, }; - const consumedIds = nodesToSummarize.map((n) => n.id); const returnedNodes = targets.filter( (t) => !consumedIds.includes(t.id), ); diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.ts b/packages/core/src/context/processors/stateSnapshotProcessor.ts index 52ab8f5338..b57f4f7960 100644 --- a/packages/core/src/context/processors/stateSnapshotProcessor.ts +++ b/packages/core/src/context/processors/stateSnapshotProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { createHash } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ContextProcessor, @@ -50,13 +50,6 @@ export function createStateSnapshotProcessor( ): ContextProcessor { const generator = new SnapshotGenerator(env); - const generateStableId = (consumedIds: string[]) => { - return createHash('sha256') - .update(consumedIds.sort().join(',')) - .digest('hex') - .slice(0, 32); - }; - return { id, name: 'StateSnapshotProcessor', @@ -101,7 +94,7 @@ export function createStateSnapshotProcessor( `[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`, ); // If valid, apply it! - const newId = generateStableId(consumedIds); + const newId = deriveStableId(consumedIds); const snapshotNode: Snapshot = { id: newId, @@ -197,7 +190,7 @@ export function createStateSnapshotProcessor( if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) { consumedIds.push(baselineIdToConsume); } - const newId = generateStableId(consumedIds); + const newId = deriveStableId(consumedIds); const snapshotNode: Snapshot = { id: newId, diff --git a/packages/core/src/context/processors/toolMaskingProcessor.ts b/packages/core/src/context/processors/toolMaskingProcessor.ts index e62bb34e5d..43aba6e40b 100644 --- a/packages/core/src/context/processors/toolMaskingProcessor.ts +++ b/packages/core/src/context/processors/toolMaskingProcessor.ts @@ -3,7 +3,7 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; import type { JSONSchemaType } from 'ajv'; import type { ContextProcessor, ProcessArgs } from '../pipeline.js'; import * as fs from 'node:fs/promises'; @@ -120,7 +120,7 @@ export function createToolMaskingProcessor( directoryCreated = true; } - const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${randomUUID()}.txt`; + const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${deriveStableId([content])}.txt`; const filePath = path.join(toolOutputsDir, fileName); await fs.writeFile(filePath, content); @@ -214,9 +214,10 @@ export function createToolMaskingProcessor( functionCall: newFC, }); + const newId = deriveStableId([node.id, 'masked']); returnedNodes.push({ ...node, - id: randomUUID(), + id: newId, payload: maskedPart, replacesId: node.id, turnId: node.turnId, @@ -242,9 +243,10 @@ export function createToolMaskingProcessor( functionResponse: newFR, }); + const newId = deriveStableId([node.id, 'masked']); returnedNodes.push({ ...node, - id: randomUUID(), + id: newId, payload: maskedPart, replacesId: node.id, turnId: node.turnId, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index ce99fb37bc..ef7188dbfc 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -296,13 +296,7 @@ export class GeminiClient { } setHistory(history: readonly (Content | HistoryTurn)[]) { - const turns = history.map((item) => { - if ('id' in item && 'content' in item) { - return item as HistoryTurn; - } - return { id: randomUUID(), content: item as Content }; - }); - this.getChat().setHistory(turns); + this.getChat().setHistory(history); this.updateTelemetryTokenCount(); this.forceFullIdeContext = true; } @@ -651,7 +645,11 @@ export class GeminiClient { if (this.contextManager) { const rawPendingRequest = createUserContent(request); const pendingRequest = { - id: randomUUID(), + id: + this.getChatRecordingService()?.recordSyntheticMessage( + 'user', + rawPendingRequest.parts || [], + ) || randomUUID(), content: rawPendingRequest, }; const { @@ -678,11 +676,7 @@ export class GeminiClient { signal, ); if (newHistory.length !== this.getHistory().length) { - const turns = newHistory.map((c) => ({ - id: randomUUID(), - content: c, - })); - this.getChat().setHistory(turns); + this.getChat().setHistory(newHistory); } } } else { @@ -1242,11 +1236,7 @@ export class GeminiClient { if (newHistory) { // We truncated content to save space, but summarization is still "failed". // We update the chat context directly without resetting the failure flag. - const turns = newHistory.map((c) => ({ - id: randomUUID(), - content: c, - })); - this.getChat().setHistory(turns); + this.getChat().setHistory(newHistory); this.updateTelemetryTokenCount(); // We don't reset the chat session fully like in COMPRESSED because // this is a lighter-weight intervention. @@ -1265,11 +1255,7 @@ export class GeminiClient { this.config, ); if (result.maskedCount > 0) { - const turns = result.newHistory.map((c) => ({ - id: randomUUID(), - content: c, - })); - this.getChat().setHistory(turns); + this.getChat().setHistory(result.newHistory); } } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 5f6b8f6a69..bb90504f24 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -323,6 +323,9 @@ export class GeminiChat { kind: 'main' | 'subagent' = 'main', ) { await this.chatRecordingService.initialize(resumedSessionData, kind); + // Sync initial history with the recorder to ensure all turns (even bootstrapped ones) + // are durable and coordinated. + this.chatRecordingService.updateMessagesFromHistory(this.agentHistory.get()); } setSystemInstruction(sysInstr: string) { @@ -913,7 +916,11 @@ export class GeminiChat { if ('id' in content && 'content' in content) { this.agentHistory.push(content); } else { - this.agentHistory.push({ id: randomUUID(), content }); + const id = this.chatRecordingService.recordSyntheticMessage( + content.role === 'user' ? 'user' : 'gemini', + content.parts || [], + ); + this.agentHistory.push({ id, content }); } } @@ -921,11 +928,16 @@ export class GeminiChat { history: readonly (Content | HistoryTurn)[], options: { silent?: boolean } = {}, ): void { - const wrappedHistory: HistoryTurn[] = history.map((item) => - 'id' in item && 'content' in item - ? item - : { id: randomUUID(), content: item }, - ); + const wrappedHistory: HistoryTurn[] = history.map((item) => { + if ('id' in item && 'content' in item) { + return item; + } + const id = this.chatRecordingService.recordSyntheticMessage( + item.role === 'user' ? 'user' : 'gemini', + item.parts || [], + ); + return { id, content: item }; + }); this.agentHistory.set(wrappedHistory, options); this.lastPromptTokenCount = estimateTokenCountSync( this.agentHistory.flatMap((c) => c.content.parts || []), diff --git a/packages/core/src/utils/cryptoUtils.ts b/packages/core/src/utils/cryptoUtils.ts new file mode 100644 index 0000000000..6c9eff9cde --- /dev/null +++ b/packages/core/src/utils/cryptoUtils.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; + +/** + * Derives a stable, deterministic ID from a list of source IDs. + * Used for synthetic turns like summaries to ensure that re-summarizing the same + * content produces a consistent identity. + */ +export function deriveStableId(sourceIds: string[]): string { + const sortedIds = [...sourceIds].sort(); + return createHash('sha256') + .update(sortedIds.join('|')) + .digest('hex') + .slice(0, 32); +} diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index b635cbcb8f..bb577705d3 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -7,7 +7,7 @@ import { type Part } from '@google/genai'; import { debugLogger } from './debugLogger.js'; import { type HistoryTurn } from '../core/agentChatHistory.js'; -import { randomUUID } from 'node:crypto'; +import { deriveStableId } from './cryptoUtils.js'; export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator'; @@ -153,7 +153,7 @@ function pairToolsAndEnforceSignatures( targetUserTurn = nextTurn; } else { targetUserTurn = { - id: randomUUID(), + id: deriveStableId([turn.id, 'sentinel_resp']), content: { role: 'user', parts: [] }, }; work.splice(i + 1, 0, targetUserTurn); @@ -278,7 +278,7 @@ function enforceRoleConstraints( '[HistoryHardener] Final history starts with model role. Prepending sentinel user turn.', ); result.unshift({ - id: randomUUID(), + id: deriveStableId([result[0].id, 'sentinel_start']), content: { role: 'user', parts: [{ text: sentinels.continuation }], @@ -292,7 +292,7 @@ function enforceRoleConstraints( '[HistoryHardener] Final history ends with model role. Appending sentinel user turn.', ); result.push({ - id: randomUUID(), + id: deriveStableId([result[result.length - 1].id, 'sentinel_end']), content: { role: 'user', parts: [{ text: 'Please continue.' }],