SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
7a1ad33 verified
Raw
History Blame Contribute Delete
8.29 kB
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Part } from '@google/genai';
import { type ConcreteNode, NodeType } from './types.js';
import { createHash } from 'node:crypto';
import { debugLogger } from '../../utils/debugLogger.js';
import type { NodeIdService } from './nodeIdService.js';
import type { HistoryTurn } from '../../core/agentChatHistory.js';
import { isSnapshotState } from '../utils/snapshotGenerator.js';
import { deriveStableId } from '../../utils/cryptoUtils.js';
import { ensureStableToolIds } from '../../utils/sessionUtils.js';
// Global WeakMap to cache hashes for Part objects.
// This optimizes getStableId by avoiding redundant stringify/hash operations
// on the same object instances across multiple management passes.
const PART_HASH_CACHE = new WeakMap<object, string>();
function isTextPart(part: Part): part is Part & { text: string } {
return typeof part.text === 'string';
}
function isInlineDataPart(
part: Part,
): part is Part & { inlineData: { data: string } } {
return (
typeof part.inlineData === 'object' &&
part.inlineData !== null &&
typeof part.inlineData.data === 'string'
);
}
function isFileDataPart(
part: Part,
): part is Part & { fileData: { fileUri: string } } {
return (
typeof part.fileData === 'object' &&
part.fileData !== null &&
typeof part.fileData.fileUri === 'string'
);
}
function isFunctionCallPart(part: Part): part is Part & {
functionCall: { id?: string; name: string; args: Record<string, unknown> };
} {
return (
typeof part.functionCall === 'object' &&
part.functionCall !== null &&
typeof part.functionCall.name === 'string'
);
}
function isFunctionResponsePart(part: Part): part is Part & {
functionResponse: {
id?: string;
name: string;
response: Record<string, unknown>;
};
} {
return (
typeof part.functionResponse === 'object' &&
part.functionResponse !== null &&
typeof part.functionResponse.name === 'string'
);
}
function isExecutableCodePart(
part: Part,
): part is Part & { executableCode: { code: string; language: string } } {
return (
typeof part.executableCode === 'object' &&
part.executableCode !== null &&
typeof part.executableCode.code === 'string' &&
typeof part.executableCode.language === 'string'
);
}
function isCodeExecutionResultPart(
part: Part,
): part is Part & { codeExecutionResult: { outcome: string; output: string } } {
return (
typeof part.codeExecutionResult === 'object' &&
part.codeExecutionResult !== null &&
typeof part.codeExecutionResult.output === 'string' &&
typeof part.codeExecutionResult.outcome === 'string'
);
}
/**
* Generates a stable ID for an object reference using a NodeIdService.
* Falls back to content-based hashing for Part-like objects to ensure
* stability across object re-creations (e.g. during history mapping).
*/
export function getStableId(
obj: object,
idService: NodeIdService,
turnSalt: string = '',
partIdx: number = 0,
): string {
let id = idService.get(obj);
if (id) return id;
const cachedHash = PART_HASH_CACHE.get(obj);
if (cachedHash) {
id = `${cachedHash}_${turnSalt}_${partIdx}`;
idService.set(obj, id);
return id;
}
const part = obj as Part;
let contentHash: string | undefined;
if (isTextPart(part)) {
contentHash = createHash('sha256').update(part.text).digest('hex');
id = `text_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isInlineDataPart(part)) {
contentHash = createHash('sha256')
.update(part.inlineData.data)
.digest('hex');
id = `media_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isFileDataPart(part)) {
contentHash = createHash('sha256')
.update(part.fileData.fileUri)
.digest('hex');
id = `file_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isFunctionCallPart(part)) {
if (part.functionCall.id) {
id = `call_${part.functionCall.id}`;
} else {
contentHash = createHash('sha256')
.update(
`call:${part.functionCall.name}:${JSON.stringify(part.functionCall.args)}`,
)
.digest('hex');
id = `call_h_${contentHash}_${turnSalt}_${partIdx}`;
}
} else if (isFunctionResponsePart(part)) {
if (part.functionResponse.id) {
id = `resp_${part.functionResponse.id}`;
} else {
contentHash = createHash('sha256')
.update(
`resp:${part.functionResponse.name}:${JSON.stringify(part.functionResponse.response)}`,
)
.digest('hex');
id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`;
}
} else if (isExecutableCodePart(part)) {
contentHash = createHash('sha256')
.update(
`exec:${part.executableCode.language}:${part.executableCode.code}`,
)
.digest('hex');
id = `exec_${contentHash}_${turnSalt}_${partIdx}`;
} else if (isCodeExecutionResultPart(part)) {
contentHash = createHash('sha256')
.update(
`result:${part.codeExecutionResult.outcome}:${part.codeExecutionResult.output}`,
)
.digest('hex');
id = `result_${contentHash}_${turnSalt}_${partIdx}`;
}
if (contentHash) {
PART_HASH_CACHE.set(obj, contentHash);
}
if (!id) {
if (turnSalt && partIdx === -1) {
id = `turn_${turnSalt}`;
} else {
id = `${turnSalt}_f_${partIdx}`;
}
}
idService.set(obj, id);
return id;
}
/**
* Builds a 1:1 Mirror Graph from Chat History.
* Every Part in history is mapped to exactly one ConcreteNode.
*/
export class ContextGraphBuilder {
constructor(private readonly idService: NodeIdService) {}
processHistory(history: readonly HistoryTurn[]): ConcreteNode[] {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ensureStableToolIds(history as HistoryTurn[]);
const nodes: ConcreteNode[] = [];
for (let turnIdx = 0; turnIdx < history.length; turnIdx++) {
const turn = history[turnIdx];
const msg = turn.content;
if (!msg.parts) continue;
const hasEnvHeader = msg.parts?.some(
(p) => isTextPart(p) && p.text.trim().startsWith('<session_context>'),
);
const turnSalt =
hasEnvHeader && turnIdx === 0
? deriveStableId(['environment-context'])
: turn.id;
const turnId = turnSalt.startsWith('turn_')
? turnSalt
: `turn_${turnSalt}`;
if (msg.role === 'user') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
const part = msg.parts[partIdx];
// Skip legacy session context headers if they appear later in history (after Turn 0).
if (
isTextPart(part) &&
part.text.trim().startsWith('<session_context>') &&
turnIdx > 0
) {
debugLogger.log(
'[ContextGraphBuilder] Skipping legacy environment header turn from graph.',
);
continue;
}
const isSnapshot = isTextPart(part) && isSnapshotState(part.text);
const id = getStableId(part, this.idService, turnSalt, partIdx);
const node: ConcreteNode = {
id,
timestamp: Date.now(),
type: isFunctionResponsePart(part)
? NodeType.TOOL_EXECUTION
: isSnapshot
? NodeType.SNAPSHOT
: NodeType.USER_PROMPT,
role: 'user',
payload: part,
turnId,
};
nodes.push(node);
}
} else if (msg.role === 'model') {
for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) {
const part = msg.parts[partIdx];
const id = getStableId(part, this.idService, turnSalt, partIdx);
const node: ConcreteNode = {
id,
timestamp: Date.now(),
type: isFunctionCallPart(part)
? NodeType.TOOL_EXECUTION
: NodeType.AGENT_THOUGHT,
role: 'model',
payload: part,
turnId,
};
nodes.push(node);
}
}
}
debugLogger.log(
`[ContextGraphBuilder] Mirror Graph built with ${nodes.length} nodes.`,
);
return nodes;
}
}