| |
| |
| |
| |
| |
|
|
| import type { Content } from '@google/genai'; |
| import type { |
| AgentChatHistory, |
| HistoryTurn, |
| } from '../core/agentChatHistory.js'; |
| import type { ConcreteNode } from './graph/types.js'; |
| import type { ContextEventBus } from './eventBus.js'; |
| import type { ContextTracer } from './tracer.js'; |
| import type { ContextEnvironment } from './pipeline/environment.js'; |
| import type { ContextProfile } from './config/profiles.js'; |
| import type { PipelineOrchestrator } from './pipeline/orchestrator.js'; |
| import { render } from './graph/render.js'; |
| import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js'; |
| import { debugLogger } from '../utils/debugLogger.js'; |
| import { deriveStableId } from '../utils/cryptoUtils.js'; |
| import { hardenHistory } from '../utils/historyHardening.js'; |
| import { checkContextInvariants } from './utils/invariantChecker.js'; |
| import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js'; |
|
|
| export class ContextManager { |
| |
| private buffer: ContextWorkingBufferImpl = |
| ContextWorkingBufferImpl.initialize([]); |
|
|
| private readonly eventBus: ContextEventBus; |
| private readonly orchestrator: PipelineOrchestrator; |
|
|
| |
| private readonly evaluatedNodeIds = new Set<string>(); |
|
|
| |
| private lastTriggeredDeficit = 0; |
| private lastTriggeredNormalizeDeficit = 0; |
|
|
| |
| private lastRenderCache?: { |
| nodesHash: string; |
| result: { |
| history: HistoryTurn[]; |
| apiHistory: Content[]; |
| pendingApiHistory: Content[]; |
| didApplyManagement: boolean; |
| baseUnits: number; |
| processedNodes: readonly ConcreteNode[]; |
| }; |
| }; |
|
|
| private hasPerformedHotStart = false; |
|
|
| constructor( |
| private readonly sidecar: ContextProfile, |
| private readonly env: ContextEnvironment, |
| private readonly tracer: ContextTracer, |
| orchestrator: PipelineOrchestrator, |
| private readonly chatHistory: AgentChatHistory, |
| private readonly advancedTokenCalculator: AdvancedTokenCalculator, |
| private readonly headerProvider?: () => Promise<Content | undefined>, |
| ) { |
| this.eventBus = env.eventBus; |
| this.orchestrator = orchestrator; |
|
|
| |
| |
| this.orchestrator.setNodeProvider(() => this.buffer.nodes); |
|
|
| this.eventBus.onProcessorResult((event) => { |
| |
| const bufferIds = new Set(this.buffer.nodes.map((n) => n.id)); |
| if (!event.targets.every((t) => bufferIds.has(t.id))) { |
| debugLogger.warn( |
| `[ContextManager] Dropping processor result from ${event.processorId}: targets no longer in buffer.`, |
| ); |
| return; |
| } |
|
|
| this.buffer = this.buffer.applyProcessorResult( |
| event.processorId, |
| event.targets, |
| event.returnedNodes, |
| ); |
| }); |
| } |
|
|
| async renderHistory( |
| pendingRequest?: HistoryTurn, |
| activeTaskIds: Set<string> = new Set(), |
| abortSignal?: AbortSignal, |
| ): Promise<{ |
| history: HistoryTurn[]; |
| apiHistory: Content[]; |
| pendingApiHistory: Content[]; |
| didApplyManagement: boolean; |
| baseUnits: number; |
| processedNodes: readonly ConcreteNode[]; |
| }> { |
| this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context'); |
|
|
| |
| const currentHistory = this.chatHistory.get(); |
| const pristineNodes = this.env.graphMapper.sync(currentHistory); |
|
|
| this.buffer = this.buffer.syncPristineHistory(pristineNodes); |
|
|
| |
| const newPrimalNodes = new Set<string>(); |
| for (const node of pristineNodes) { |
| if (!this.evaluatedNodeIds.has(node.id)) { |
| newPrimalNodes.add(node.id); |
| this.evaluatedNodeIds.add(node.id); |
| } |
| } |
|
|
| |
| let previewNodes: readonly ConcreteNode[] = []; |
| if (pendingRequest) { |
| const syncedNodes = this.env.graphMapper.sync([pendingRequest]); |
| const previewNodeIds = new Set(syncedNodes.map((n) => n.id)); |
|
|
| const previewBuffer = ContextWorkingBufferImpl.initialize(syncedNodes); |
|
|
| const processedPreviewBuffer = await this.orchestrator.executeTriggerSync( |
| 'new_message', |
| previewBuffer, |
| previewNodeIds, |
| ); |
|
|
| previewNodes = processedPreviewBuffer.nodes; |
| } |
|
|
| |
| const hotStartPromise = (async () => { |
| if (!this.hasPerformedHotStart) { |
| this.hasPerformedHotStart = true; |
| if (this.buffer.nodes.length > 0) { |
| const nodesForHotStart = [...this.buffer.nodes, ...previewNodes]; |
| await this.performHotStartCalibration(nodesForHotStart, abortSignal); |
| } |
| } |
| })(); |
|
|
| |
| await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]); |
|
|
| let nodes = this.buffer.nodes; |
| const previewNodeIds = new Set<string>(); |
|
|
| if (previewNodes.length > 0) { |
| nodes = [...nodes, ...previewNodes]; |
| for (const node of previewNodes) { |
| previewNodeIds.add(node.id); |
| } |
| } |
|
|
| |
| await this.evaluateTriggers(nodes, newPrimalNodes, activeTaskIds); |
|
|
| |
| nodes = [...this.buffer.nodes, ...previewNodes]; |
|
|
| |
| const header = this.headerProvider |
| ? await this.headerProvider() |
| : undefined; |
|
|
| const nodesHash = deriveStableId([ |
| ...nodes.map((n) => n.id), |
| header ? JSON.stringify(header.parts) : 'no-header', |
| ]); |
|
|
| if (this.lastRenderCache?.nodesHash === nodesHash) { |
| this.tracer.logEvent('ContextManager', 'Render Cache Hit', { nodesHash }); |
| return this.lastRenderCache.result; |
| } |
|
|
| const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds); |
|
|
| const renderResult = await render( |
| nodes, |
| this.orchestrator, |
| this.sidecar, |
| this.tracer, |
| this.env, |
| this.advancedTokenCalculator, |
| { |
| protectionReasons, |
| header, |
| lateBindPrompt: !!pendingRequest, |
| }, |
| ); |
|
|
| const { |
| history: renderedHistory, |
| pendingHistory, |
| didApplyManagement, |
| baseUnits, |
| processedNodes, |
| } = renderResult; |
|
|
| if (didApplyManagement) { |
| |
| |
| const masterIdsInResult = new Set(this.buffer.nodes.map((n) => n.id)); |
| const processedMasterNodes = processedNodes.filter( |
| (n) => !previewNodeIds.has(n.id) || masterIdsInResult.has(n.id), |
| ); |
|
|
| this.buffer = this.buffer.applyProcessorResult( |
| 'sync_backstop', |
| this.buffer.nodes, |
| processedMasterNodes, |
| ); |
| } |
|
|
| |
| checkContextInvariants(this.buffer.nodes, 'RenderHistory'); |
|
|
| const fullHistoryToHarden = [...renderedHistory, ...pendingHistory]; |
|
|
| const hardenedFullHistory = hardenHistory(fullHistoryToHarden, { |
| sentinels: this.sidecar.sentinels, |
| }); |
|
|
| const envContextId = deriveStableId(['environment-context']); |
| const pendingIds = new Set(pendingHistory.map((t) => t.id)); |
| const resultHistory: HistoryTurn[] = []; |
| const resultPending: HistoryTurn[] = []; |
|
|
| let foundPending = false; |
| for (const turn of hardenedFullHistory) { |
| const isPendingId = |
| pendingIds.has(turn.id) || |
| (turn.id.startsWith('turn_') && pendingIds.has(turn.id.substring(5))); |
|
|
| const isStartupContext = |
| (turn.id === envContextId || turn.id === `turn_${envContextId}`) && |
| turn !== hardenedFullHistory[hardenedFullHistory.length - 1]; |
|
|
| if (!foundPending && isPendingId && !isStartupContext) { |
| foundPending = true; |
| } |
|
|
| if (foundPending) { |
| resultPending.push(turn); |
| } else { |
| resultHistory.push(turn); |
| } |
| } |
|
|
| const result = { |
| history: renderedHistory, |
| apiHistory: resultHistory.map((h) => h.content), |
| pendingApiHistory: resultPending.map((h) => h.content), |
| didApplyManagement, |
| baseUnits, |
| processedNodes, |
| }; |
|
|
| if (header) { |
| result.apiHistory.unshift(header); |
| } |
|
|
| this.lastRenderCache = { nodesHash, result }; |
|
|
| this.tracer.logEvent('ContextManager', 'Rendering Complete', { |
| historySize: renderedHistory.length, |
| pendingSize: pendingHistory.length, |
| didApplyManagement, |
| }); |
|
|
| return result; |
| } |
|
|
| async waitForPipelines(): Promise<void> { |
| await this.orchestrator.waitForPipelines(); |
| } |
|
|
| shutdown() { |
| this.orchestrator.shutdown(); |
| } |
|
|
| getNodes(): readonly ConcreteNode[] { |
| return this.buffer.nodes; |
| } |
|
|
| getEnvironment(): ContextEnvironment { |
| return this.env; |
| } |
|
|
| getPristineGraph(): readonly ConcreteNode[] { |
| const pristineSet = new Map<string, ConcreteNode>(); |
| for (const node of this.buffer.nodes) { |
| const roots = this.buffer.getPristineNodes(node.id); |
| for (const root of roots) { |
| pristineSet.set(root.id, root); |
| } |
| } |
| return Array.from(pristineSet.values()).sort( |
| (a, b) => a.timestamp - b.timestamp, |
| ); |
| } |
|
|
| private async evaluateTriggers( |
| nodes: readonly ConcreteNode[], |
| newPrimalNodes: ReadonlySet<string>, |
| activeTaskIds: Set<string>, |
| ) { |
| if (newPrimalNodes.size > 0) { |
| this.buffer = await this.orchestrator.executeTriggerSync( |
| 'nodes_added', |
| this.buffer, |
| newPrimalNodes, |
| ); |
| } |
|
|
| |
| const bufferIds = new Set(this.buffer.nodes.map((n) => n.id)); |
| const previewNodes = nodes.filter((n) => !bufferIds.has(n.id)); |
| const currentNodes = [...this.buffer.nodes, ...previewNodes]; |
|
|
| const currentTokens = |
| this.env.tokenCalculator.calculateConcreteListTokens(currentNodes); |
|
|
| if (currentTokens > this.sidecar.config.budget.retainedTokens) { |
| const agedOutRetainedNodes = new Set<string>(); |
| const agedOutNormalizedNodes = new Set<string>(); |
|
|
| const protectionMap = this.getProtectedNodeIds( |
| currentNodes, |
| activeTaskIds, |
| ); |
| const protectedIds = new Set(protectionMap.keys()); |
|
|
| |
| const envTurnId = `turn_${deriveStableId(['environment-context'])}`; |
| const turn0Nodes = currentNodes.filter((n) => n.turnId === envTurnId); |
| for (const n of turn0Nodes) { |
| protectedIds.add(n.id); |
| } |
|
|
| let rollingTokens = 0; |
| for (let i = currentNodes.length - 1; i >= 0; i--) { |
| const node = currentNodes[i]; |
| const priorTokens = rollingTokens; |
| rollingTokens += this.env.tokenCalculator.calculateConcreteListTokens([ |
| node, |
| ]); |
|
|
| if (priorTokens > this.sidecar.config.budget.retainedTokens) { |
| if (!protectedIds.has(node.id)) { |
| const hasNormalizedTier = |
| this.sidecar.config.budget.normalizedTokens !== undefined; |
| if ( |
| !hasNormalizedTier || |
| priorTokens <= this.sidecar.config.budget.normalizedTokens! |
| ) { |
| agedOutRetainedNodes.add(node.id); |
| } |
| if ( |
| hasNormalizedTier && |
| priorTokens > this.sidecar.config.budget.normalizedTokens! |
| ) { |
| agedOutNormalizedNodes.add(node.id); |
| } |
| } |
| } |
| } |
|
|
| if (agedOutRetainedNodes.size > 0) { |
| const targetDeficit = |
| currentTokens - this.sidecar.config.budget.retainedTokens; |
| const threshold = |
| this.sidecar.config.budget.coalescingThresholdTokens || 0; |
|
|
| if (targetDeficit < this.lastTriggeredDeficit) { |
| this.lastTriggeredDeficit = targetDeficit; |
| } |
|
|
| if (targetDeficit > this.lastTriggeredDeficit + threshold) { |
| this.lastTriggeredDeficit = targetDeficit; |
|
|
| this.eventBus.emitConsolidationNeeded({ |
| nodes: this.buffer.nodes, |
| targetDeficit, |
| targetNodeIds: agedOutRetainedNodes, |
| }); |
|
|
| this.env.tokenCalculator.garbageCollectCache( |
| new Set(this.buffer.nodes.map((n) => n.id)), |
| ); |
|
|
| this.buffer = await this.orchestrator.executeTriggerSync( |
| 'nodes_aged_out', |
| this.buffer, |
| agedOutRetainedNodes, |
| protectedIds, |
| ); |
| } |
| } else { |
| this.lastTriggeredDeficit = 0; |
| } |
|
|
| if (agedOutNormalizedNodes.size > 0) { |
| const targetDeficit = |
| currentTokens - this.sidecar.config.budget.normalizedTokens!; |
| const threshold = |
| this.sidecar.config.budget.coalescingThresholdTokens || 0; |
|
|
| if (targetDeficit < this.lastTriggeredNormalizeDeficit) { |
| this.lastTriggeredNormalizeDeficit = targetDeficit; |
| } |
|
|
| if (targetDeficit > this.lastTriggeredNormalizeDeficit + threshold) { |
| this.lastTriggeredNormalizeDeficit = targetDeficit; |
|
|
| this.eventBus.emitNormalizeNeeded({ |
| nodes: this.buffer.nodes, |
| targetDeficit, |
| targetNodeIds: agedOutNormalizedNodes, |
| }); |
|
|
| this.buffer = await this.orchestrator.executeTriggerSync( |
| 'normalized_exceeded', |
| this.buffer, |
| agedOutNormalizedNodes, |
| protectedIds, |
| ); |
| } |
| } else { |
| this.lastTriggeredNormalizeDeficit = 0; |
| } |
| } |
| } |
|
|
| private getProtectedNodeIds( |
| nodes: readonly ConcreteNode[], |
| extraProtectedIds: Set<string> = new Set(), |
| ): Map<string, string> { |
| const protectionMap = new Map<string, string>(); |
| if (nodes.length === 0) return protectionMap; |
|
|
| const lastNode = nodes[nodes.length - 1]; |
| const lastTurnId = lastNode.turnId; |
|
|
| |
| const envContextId = deriveStableId(['environment-context']); |
| const envContextTurnId = `turn_${envContextId}`; |
|
|
| for (const node of nodes) { |
| if (node.turnId === envContextTurnId || node.turnId === envContextId) { |
| protectionMap.set(node.id, 'environment_context'); |
| } |
| if (node.turnId === lastTurnId) { |
| protectionMap.set(node.id, 'recent_turn'); |
| } |
| } |
|
|
| for (const id of extraProtectedIds) { |
| protectionMap.set(id, 'external_active_task'); |
| } |
|
|
| return protectionMap; |
| } |
|
|
| private async performHotStartCalibration( |
| nodes: readonly ConcreteNode[], |
| abortSignal?: AbortSignal, |
| ) { |
| const history = this.env.graphMapper.fromGraph(nodes); |
| const contents = history.map((h) => h.content); |
|
|
| try { |
| const { totalTokens } = await this.env.llmClient.countTokens({ |
| modelConfigKey: { model: 'context-calibrator' }, |
| contents, |
| abortSignal, |
| }); |
|
|
| if (totalTokens !== undefined) { |
| this.env.eventBus.emitTokenGroundTruth({ |
| actualTokens: totalTokens, |
| promptBaseUnits: this.advancedTokenCalculator.getRawBaseUnits(nodes), |
| }); |
| } |
| } catch (e) { |
| debugLogger.warn('[ContextManager] Hot start calibration failed', e); |
| } |
| } |
| } |
|
|