mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 18:10:43 -07:00
feat(core): introduce decoupled ContextManager and Sidecar architecture (#24752)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
|
||||
export interface NodeSerializationWriter {
|
||||
appendContent(content: Content): void;
|
||||
appendModelPart(part: Part): void;
|
||||
appendUserPart(part: Part): void;
|
||||
flushModelParts(): void;
|
||||
}
|
||||
|
||||
export interface NodeBehavior<T extends ConcreteNode = ConcreteNode> {
|
||||
readonly type: T['type'];
|
||||
|
||||
/** Serializes the node into the Gemini Content structure. */
|
||||
serialize(node: T, writer: NodeSerializationWriter): void;
|
||||
|
||||
/**
|
||||
* Generates a structural representation of the node for the purpose
|
||||
* of estimating its token cost.
|
||||
*/
|
||||
getEstimatableParts(node: T): Part[];
|
||||
}
|
||||
|
||||
export class NodeBehaviorRegistry {
|
||||
private readonly behaviors = new Map<string, NodeBehavior<ConcreteNode>>();
|
||||
|
||||
register<T extends ConcreteNode>(behavior: NodeBehavior<T>) {
|
||||
this.behaviors.set(behavior.type, behavior);
|
||||
}
|
||||
|
||||
get(type: string): NodeBehavior<ConcreteNode> {
|
||||
const behavior = this.behaviors.get(type);
|
||||
if (!behavior) {
|
||||
throw new Error(`Unregistered Node type: ${type}`);
|
||||
}
|
||||
return behavior;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Part } from '@google/genai';
|
||||
import type { NodeBehavior, NodeBehaviorRegistry } from './behaviorRegistry.js';
|
||||
import type {
|
||||
UserPrompt,
|
||||
AgentThought,
|
||||
ToolExecution,
|
||||
MaskedTool,
|
||||
AgentYield,
|
||||
Snapshot,
|
||||
RollingSummary,
|
||||
SystemEvent,
|
||||
} from './types.js';
|
||||
|
||||
export const UserPromptBehavior: NodeBehavior<UserPrompt> = {
|
||||
type: 'USER_PROMPT',
|
||||
getEstimatableParts(prompt) {
|
||||
const parts: Part[] = [];
|
||||
for (const sp of prompt.semanticParts) {
|
||||
switch (sp.type) {
|
||||
case 'text':
|
||||
parts.push({ text: sp.text });
|
||||
break;
|
||||
case 'inline_data':
|
||||
parts.push({ inlineData: { mimeType: sp.mimeType, data: sp.data } });
|
||||
break;
|
||||
case 'file_data':
|
||||
parts.push({
|
||||
fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri },
|
||||
});
|
||||
break;
|
||||
case 'raw_part':
|
||||
parts.push(sp.part);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
},
|
||||
serialize(prompt, writer) {
|
||||
const parts = this.getEstimatableParts(prompt);
|
||||
if (parts.length > 0) {
|
||||
writer.flushModelParts();
|
||||
writer.appendContent({ role: 'user', parts });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const AgentThoughtBehavior: NodeBehavior<AgentThought> = {
|
||||
type: 'AGENT_THOUGHT',
|
||||
getEstimatableParts(thought) {
|
||||
return [{ text: thought.text }];
|
||||
},
|
||||
serialize(thought, writer) {
|
||||
writer.appendModelPart({ text: thought.text });
|
||||
},
|
||||
};
|
||||
|
||||
export const ToolExecutionBehavior: NodeBehavior<ToolExecution> = {
|
||||
type: 'TOOL_EXECUTION',
|
||||
getEstimatableParts(tool) {
|
||||
return [
|
||||
{ functionCall: { id: tool.id, name: tool.toolName, args: tool.intent } },
|
||||
{
|
||||
functionResponse: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
response:
|
||||
typeof tool.observation === 'string'
|
||||
? { message: tool.observation }
|
||||
: tool.observation,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
serialize(tool, writer) {
|
||||
const parts = this.getEstimatableParts(tool);
|
||||
writer.appendModelPart(parts[0]);
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart(parts[1]);
|
||||
},
|
||||
};
|
||||
|
||||
export const MaskedToolBehavior: NodeBehavior<MaskedTool> = {
|
||||
type: 'MASKED_TOOL',
|
||||
getEstimatableParts(tool) {
|
||||
return [
|
||||
{
|
||||
functionCall: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
args: tool.intent ?? {},
|
||||
},
|
||||
},
|
||||
{
|
||||
functionResponse: {
|
||||
id: tool.id,
|
||||
name: tool.toolName,
|
||||
response:
|
||||
typeof tool.observation === 'string'
|
||||
? { message: tool.observation }
|
||||
: (tool.observation ?? {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
serialize(tool, writer) {
|
||||
const parts = this.getEstimatableParts(tool);
|
||||
writer.appendModelPart(parts[0]);
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart(parts[1]);
|
||||
},
|
||||
};
|
||||
|
||||
export const AgentYieldBehavior: NodeBehavior<AgentYield> = {
|
||||
type: 'AGENT_YIELD',
|
||||
getEstimatableParts(yieldNode) {
|
||||
return [{ text: yieldNode.text }];
|
||||
},
|
||||
serialize(yieldNode, writer) {
|
||||
writer.appendModelPart({ text: yieldNode.text });
|
||||
writer.flushModelParts();
|
||||
},
|
||||
};
|
||||
|
||||
export const SystemEventBehavior: NodeBehavior<SystemEvent> = {
|
||||
type: 'SYSTEM_EVENT',
|
||||
getEstimatableParts() {
|
||||
return [];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
},
|
||||
};
|
||||
|
||||
export const SnapshotBehavior: NodeBehavior<Snapshot> = {
|
||||
type: 'SNAPSHOT',
|
||||
getEstimatableParts(node) {
|
||||
return [{ text: node.text }];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart({ text: node.text });
|
||||
},
|
||||
};
|
||||
|
||||
export const RollingSummaryBehavior: NodeBehavior<RollingSummary> = {
|
||||
type: 'ROLLING_SUMMARY',
|
||||
getEstimatableParts(node) {
|
||||
return [{ text: node.text }];
|
||||
},
|
||||
serialize(node, writer) {
|
||||
writer.flushModelParts();
|
||||
writer.appendUserPart({ text: node.text });
|
||||
},
|
||||
};
|
||||
|
||||
export function registerBuiltInBehaviors(registry: NodeBehaviorRegistry) {
|
||||
registry.register(UserPromptBehavior);
|
||||
registry.register(AgentThoughtBehavior);
|
||||
registry.register(ToolExecutionBehavior);
|
||||
registry.register(MaskedToolBehavior);
|
||||
registry.register(AgentYieldBehavior);
|
||||
registry.register(SystemEventBehavior);
|
||||
registry.register(SnapshotBehavior);
|
||||
registry.register(RollingSummaryBehavior);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import type {
|
||||
NodeSerializationWriter,
|
||||
NodeBehaviorRegistry,
|
||||
} from './behaviorRegistry.js';
|
||||
|
||||
class NodeSerializer implements NodeSerializationWriter {
|
||||
private history: Content[] = [];
|
||||
private currentModelParts: Part[] = [];
|
||||
|
||||
appendContent(content: Content) {
|
||||
this.flushModelParts();
|
||||
this.history.push(content);
|
||||
}
|
||||
|
||||
appendModelPart(part: Part) {
|
||||
this.currentModelParts.push(part);
|
||||
}
|
||||
|
||||
appendUserPart(part: Part) {
|
||||
this.flushModelParts();
|
||||
this.history.push({ role: 'user', parts: [part] });
|
||||
}
|
||||
|
||||
flushModelParts() {
|
||||
if (this.currentModelParts.length > 0) {
|
||||
this.history.push({ role: 'model', parts: [...this.currentModelParts] });
|
||||
this.currentModelParts = [];
|
||||
}
|
||||
}
|
||||
|
||||
getContents(): Content[] {
|
||||
this.flushModelParts();
|
||||
return this.history;
|
||||
}
|
||||
}
|
||||
|
||||
export function fromGraph(
|
||||
nodes: readonly ConcreteNode[],
|
||||
registry: NodeBehaviorRegistry,
|
||||
): Content[] {
|
||||
const writer = new NodeSerializer();
|
||||
for (const node of nodes) {
|
||||
const behavior = registry.get(node.type);
|
||||
behavior.serialize(node, writer);
|
||||
}
|
||||
return writer.getContents();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Episode, ConcreteNode } from './types.js';
|
||||
import { toGraph } from './toGraph.js';
|
||||
import { fromGraph } from './fromGraph.js';
|
||||
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import type { NodeBehaviorRegistry } from './behaviorRegistry.js';
|
||||
|
||||
export class ContextGraphMapper {
|
||||
private readonly nodeIdentityMap = new WeakMap<object, string>();
|
||||
|
||||
constructor(private readonly registry: NodeBehaviorRegistry) {}
|
||||
|
||||
toGraph(
|
||||
history: readonly Content[],
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
): Episode[] {
|
||||
return toGraph(history, tokenCalculator, this.nodeIdentityMap);
|
||||
}
|
||||
|
||||
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
|
||||
return fromGraph(nodes, this.registry);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ConcreteNode } from './types.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import type {
|
||||
ContextEnvironment,
|
||||
ContextTracer,
|
||||
} from '../pipeline/environment.js';
|
||||
import type { PipelineOrchestrator } from '../pipeline/orchestrator.js';
|
||||
import type { ContextProfile } from '../config/profiles.js';
|
||||
|
||||
/**
|
||||
* Orchestrates the final render: takes a working buffer view (The Nodes),
|
||||
* applies the Immediate Sanitization pipeline, and enforces token boundaries.
|
||||
*/
|
||||
export async function render(
|
||||
nodes: readonly ConcreteNode[],
|
||||
orchestrator: PipelineOrchestrator,
|
||||
sidecar: ContextProfile,
|
||||
tracer: ContextTracer,
|
||||
env: ContextEnvironment,
|
||||
protectedIds: Set<string>,
|
||||
): Promise<Content[]> {
|
||||
if (!sidecar.config.budget) {
|
||||
const contents = env.graphMapper.fromGraph(nodes);
|
||||
tracer.logEvent('Render', 'Render Context to LLM (No Budget)', {
|
||||
renderedContext: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
|
||||
const maxTokens = sidecar.config.budget.maxTokens;
|
||||
const currentTokens = env.tokenCalculator.calculateConcreteListTokens(nodes);
|
||||
|
||||
// V0: Always protect the first node (System Prompt) and the last turn
|
||||
if (nodes.length > 0) {
|
||||
protectedIds.add(nodes[0].id);
|
||||
if (nodes[0].logicalParentId) protectedIds.add(nodes[0].logicalParentId);
|
||||
|
||||
const lastNode = nodes[nodes.length - 1];
|
||||
protectedIds.add(lastNode.id);
|
||||
if (lastNode.logicalParentId) protectedIds.add(lastNode.logicalParentId);
|
||||
}
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
tracer.logEvent(
|
||||
'Render',
|
||||
`View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`,
|
||||
);
|
||||
const contents = env.graphMapper.fromGraph(nodes);
|
||||
tracer.logEvent('Render', 'Render Context for LLM', {
|
||||
renderedContext: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
|
||||
tracer.logEvent(
|
||||
'Render',
|
||||
`View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Context Manager Synchronous Barrier triggered: View at ${currentTokens} tokens (limit: ${maxTokens}).`,
|
||||
);
|
||||
|
||||
// Calculate exactly which nodes aged out of the retainedTokens budget to form our target delta
|
||||
const agedOutNodes = new Set<string>();
|
||||
let rollingTokens = 0;
|
||||
// Start from newest and count backwards
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
const node = nodes[i];
|
||||
const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([node]);
|
||||
rollingTokens += nodeTokens;
|
||||
if (rollingTokens > sidecar.config.budget.retainedTokens) {
|
||||
agedOutNodes.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
const processedNodes = await orchestrator.executeTriggerSync(
|
||||
'gc_backstop',
|
||||
nodes,
|
||||
agedOutNodes,
|
||||
protectedIds,
|
||||
);
|
||||
|
||||
const finalTokens =
|
||||
env.tokenCalculator.calculateConcreteListTokens(processedNodes);
|
||||
tracer.logEvent(
|
||||
'Render',
|
||||
`Finished rendering. Final token count: ${finalTokens}.`,
|
||||
);
|
||||
debugLogger.log(
|
||||
`Context Manager finished. Final actual token count: ${finalTokens}.`,
|
||||
);
|
||||
|
||||
// Apply skipList logic to abstract over summarized nodes
|
||||
const skipList = new Set<string>();
|
||||
for (const node of processedNodes) {
|
||||
if (node.abstractsIds) {
|
||||
for (const id of node.abstractsIds) skipList.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodes = processedNodes.filter((n) => !skipList.has(n.id));
|
||||
|
||||
const contents = env.graphMapper.fromGraph(visibleNodes);
|
||||
tracer.logEvent('Render', 'Render Sanitized Context for LLM', {
|
||||
renderedContextSanitized: contents,
|
||||
});
|
||||
return contents;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type {
|
||||
Episode,
|
||||
SemanticPart,
|
||||
ToolExecution,
|
||||
AgentThought,
|
||||
AgentYield,
|
||||
UserPrompt,
|
||||
} from './types.js';
|
||||
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isRecord } from '../../utils/markdownUtils.js';
|
||||
|
||||
// We remove the global nodeIdentityMap and instead rely on one passed from ContextGraphMapper
|
||||
export function getStableId(
|
||||
obj: object,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): string {
|
||||
let id = nodeIdentityMap.get(obj);
|
||||
if (!id) {
|
||||
id = randomUUID();
|
||||
nodeIdentityMap.set(obj, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function isCompleteEpisode(ep: Partial<Episode>): ep is Episode {
|
||||
return (
|
||||
typeof ep.id === 'string' &&
|
||||
Array.isArray(ep.concreteNodes) &&
|
||||
ep.concreteNodes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function toGraph(
|
||||
history: readonly Content[],
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Episode[] {
|
||||
const episodes: Episode[] = [];
|
||||
let currentEpisode: Partial<Episode> | null = null;
|
||||
const pendingCallParts: Map<string, Part> = new Map();
|
||||
|
||||
const finalizeEpisode = () => {
|
||||
if (currentEpisode && isCompleteEpisode(currentEpisode)) {
|
||||
episodes.push(currentEpisode);
|
||||
}
|
||||
currentEpisode = null;
|
||||
};
|
||||
|
||||
for (const msg of history) {
|
||||
if (!msg.parts) continue;
|
||||
|
||||
if (msg.role === 'user') {
|
||||
const hasToolResponses = msg.parts.some((p) => !!p.functionResponse);
|
||||
const hasUserParts = msg.parts.some(
|
||||
(p) => !!p.text || !!p.inlineData || !!p.fileData,
|
||||
);
|
||||
|
||||
if (hasToolResponses) {
|
||||
currentEpisode = parseToolResponses(
|
||||
msg,
|
||||
currentEpisode,
|
||||
pendingCallParts,
|
||||
tokenCalculator,
|
||||
nodeIdentityMap,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasUserParts) {
|
||||
finalizeEpisode();
|
||||
currentEpisode = parseUserParts(msg, nodeIdentityMap);
|
||||
}
|
||||
} else if (msg.role === 'model') {
|
||||
currentEpisode = parseModelParts(
|
||||
msg,
|
||||
currentEpisode,
|
||||
pendingCallParts,
|
||||
nodeIdentityMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEpisode) {
|
||||
finalizeYield(currentEpisode);
|
||||
finalizeEpisode();
|
||||
}
|
||||
|
||||
return episodes;
|
||||
}
|
||||
|
||||
function parseToolResponses(
|
||||
msg: Content,
|
||||
currentEpisode: Partial<Episode> | null,
|
||||
pendingCallParts: Map<string, Part>,
|
||||
tokenCalculator: ContextTokenCalculator,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
if (!currentEpisode) {
|
||||
currentEpisode = {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
|
||||
concreteNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parts = msg.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.functionResponse) {
|
||||
const callId = part.functionResponse.id || '';
|
||||
const matchingCall = pendingCallParts.get(callId);
|
||||
|
||||
const intentTokens = matchingCall
|
||||
? tokenCalculator.estimateTokensForParts([matchingCall])
|
||||
: 0;
|
||||
const obsTokens = tokenCalculator.estimateTokensForParts([part]);
|
||||
|
||||
const step: ToolExecution = {
|
||||
id: getStableId(part, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
type: 'TOOL_EXECUTION',
|
||||
toolName: part.functionResponse.name || 'unknown',
|
||||
intent: isRecord(matchingCall?.functionCall?.args)
|
||||
? matchingCall.functionCall.args
|
||||
: {},
|
||||
observation: isRecord(part.functionResponse.response)
|
||||
? part.functionResponse.response
|
||||
: {},
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: obsTokens,
|
||||
},
|
||||
};
|
||||
currentEpisode.concreteNodes = [
|
||||
...(currentEpisode.concreteNodes || []),
|
||||
step,
|
||||
];
|
||||
if (callId) pendingCallParts.delete(callId);
|
||||
}
|
||||
}
|
||||
return currentEpisode;
|
||||
}
|
||||
|
||||
function parseUserParts(
|
||||
msg: Content,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
const semanticParts: SemanticPart[] = [];
|
||||
const parts = msg.parts || [];
|
||||
for (const p of parts) {
|
||||
if (p.text !== undefined)
|
||||
semanticParts.push({ type: 'text', text: p.text });
|
||||
else if (p.inlineData)
|
||||
semanticParts.push({
|
||||
type: 'inline_data',
|
||||
mimeType: p.inlineData.mimeType || '',
|
||||
data: p.inlineData.data || '',
|
||||
});
|
||||
else if (p.fileData)
|
||||
semanticParts.push({
|
||||
type: 'file_data',
|
||||
mimeType: p.fileData.mimeType || '',
|
||||
fileUri: p.fileData.fileUri || '',
|
||||
});
|
||||
else if (!p.functionResponse)
|
||||
semanticParts.push({ type: 'raw_part', part: p }); // Preserve unknowns
|
||||
}
|
||||
|
||||
const baseObj = parts.length > 0 ? parts[0] : msg;
|
||||
const trigger: UserPrompt = {
|
||||
id: getStableId(baseObj, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
type: 'USER_PROMPT',
|
||||
semanticParts,
|
||||
};
|
||||
return {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
|
||||
concreteNodes: [trigger],
|
||||
};
|
||||
}
|
||||
|
||||
function parseModelParts(
|
||||
msg: Content,
|
||||
currentEpisode: Partial<Episode> | null,
|
||||
pendingCallParts: Map<string, Part>,
|
||||
nodeIdentityMap: WeakMap<object, string>,
|
||||
): Partial<Episode> {
|
||||
if (!currentEpisode) {
|
||||
currentEpisode = {
|
||||
id: getStableId(msg, nodeIdentityMap),
|
||||
|
||||
concreteNodes: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parts = msg.parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.functionCall) {
|
||||
const callId = part.functionCall.id || '';
|
||||
if (callId) pendingCallParts.set(callId, part);
|
||||
} else if (part.text) {
|
||||
const thought: AgentThought = {
|
||||
id: getStableId(part, nodeIdentityMap),
|
||||
timestamp: Date.now(),
|
||||
type: 'AGENT_THOUGHT',
|
||||
text: part.text,
|
||||
};
|
||||
|
||||
currentEpisode.concreteNodes = [
|
||||
...(currentEpisode.concreteNodes || []),
|
||||
thought,
|
||||
];
|
||||
}
|
||||
}
|
||||
return currentEpisode;
|
||||
}
|
||||
|
||||
function finalizeYield(currentEpisode: Partial<Episode>) {
|
||||
if (currentEpisode.concreteNodes && currentEpisode.concreteNodes.length > 0) {
|
||||
const yieldNode: AgentYield = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
type: 'AGENT_YIELD',
|
||||
text: 'Yield', // Synthesized yield since we don't have the original concrete node
|
||||
};
|
||||
const existingNodes = currentEpisode.concreteNodes || [];
|
||||
currentEpisode.concreteNodes = [...existingNodes, yieldNode];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
export type NodeType =
|
||||
// Organic Concrete Nodes
|
||||
| 'USER_PROMPT'
|
||||
| 'SYSTEM_EVENT'
|
||||
| 'AGENT_THOUGHT'
|
||||
| 'TOOL_EXECUTION'
|
||||
| 'AGENT_YIELD'
|
||||
|
||||
// Synthetic Concrete Nodes
|
||||
| 'SNAPSHOT'
|
||||
| 'ROLLING_SUMMARY'
|
||||
| 'MASKED_TOOL'
|
||||
|
||||
// Logical Nodes
|
||||
| 'TASK'
|
||||
| 'EPISODE';
|
||||
|
||||
/** Base interface for all nodes in the Episodic Context Graph */
|
||||
export interface Node {
|
||||
readonly id: string;
|
||||
readonly type: NodeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete Nodes: The atomic, renderable pieces of data.
|
||||
* These are the actual "planks" of the Nodes of Theseus.
|
||||
*/
|
||||
export interface BaseConcreteNode extends Node {
|
||||
readonly timestamp: number;
|
||||
/** The ID of the Logical Node (e.g., Episode) that structurally owns this node */
|
||||
readonly logicalParentId?: string;
|
||||
|
||||
/** If this node replaced a single node 1:1 (e.g., masking), this points to the original */
|
||||
readonly replacesId?: string;
|
||||
|
||||
/** If this node is a synthetic summary of N nodes, this points to the original IDs */
|
||||
readonly abstractsIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic Parts for User Prompts
|
||||
*/
|
||||
export interface SemanticTextPart {
|
||||
readonly type: 'text';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface SemanticInlineDataPart {
|
||||
readonly type: 'inline_data';
|
||||
readonly mimeType: string;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
export interface SemanticFileDataPart {
|
||||
readonly type: 'file_data';
|
||||
readonly mimeType: string;
|
||||
readonly fileUri: string;
|
||||
}
|
||||
|
||||
export interface SemanticRawPart {
|
||||
readonly type: 'raw_part';
|
||||
readonly part: Part;
|
||||
}
|
||||
|
||||
export type SemanticPart =
|
||||
| SemanticTextPart
|
||||
| SemanticInlineDataPart
|
||||
| SemanticFileDataPart
|
||||
| SemanticRawPart;
|
||||
|
||||
/**
|
||||
* Trigger Nodes
|
||||
* Events that wake the agent up and initiate an Episode.
|
||||
*/
|
||||
export interface UserPrompt extends BaseConcreteNode {
|
||||
readonly type: 'USER_PROMPT';
|
||||
readonly semanticParts: readonly SemanticPart[];
|
||||
}
|
||||
|
||||
export interface SystemEvent extends BaseConcreteNode {
|
||||
readonly type: 'SYSTEM_EVENT';
|
||||
readonly name: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type EpisodeTrigger = UserPrompt | SystemEvent;
|
||||
|
||||
/**
|
||||
* Step Nodes
|
||||
* The internal autonomous actions taken by the agent during its loop.
|
||||
*/
|
||||
export interface AgentThought extends BaseConcreteNode {
|
||||
readonly type: 'AGENT_THOUGHT';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface ToolExecution extends BaseConcreteNode {
|
||||
readonly type: 'TOOL_EXECUTION';
|
||||
readonly toolName: string;
|
||||
readonly intent: Record<string, unknown>;
|
||||
readonly observation: string | Record<string, unknown>;
|
||||
readonly tokens: {
|
||||
readonly intent: number;
|
||||
readonly observation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MaskedTool extends BaseConcreteNode {
|
||||
readonly type: 'MASKED_TOOL';
|
||||
readonly toolName: string;
|
||||
readonly intent?: Record<string, unknown>;
|
||||
readonly observation?: string | Record<string, unknown>;
|
||||
readonly tokens: {
|
||||
readonly intent: number;
|
||||
readonly observation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type EpisodeStep = AgentThought | ToolExecution | MaskedTool;
|
||||
|
||||
/**
|
||||
* Resolution Node
|
||||
* The final message where the agent yields control back to the user.
|
||||
*/
|
||||
export interface AgentYield extends BaseConcreteNode {
|
||||
readonly type: 'AGENT_YIELD';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic Leaf Interfaces
|
||||
* Processors that generate summaries emit explicit synthetic nodes.
|
||||
*/
|
||||
export interface Snapshot extends BaseConcreteNode {
|
||||
readonly type: 'SNAPSHOT';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export interface RollingSummary extends BaseConcreteNode {
|
||||
readonly type: 'ROLLING_SUMMARY';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
export type SyntheticLeaf = Snapshot | RollingSummary;
|
||||
|
||||
export type ConcreteNode =
|
||||
| UserPrompt
|
||||
| SystemEvent
|
||||
| AgentThought
|
||||
| ToolExecution
|
||||
| MaskedTool
|
||||
| AgentYield
|
||||
| Snapshot
|
||||
| RollingSummary;
|
||||
|
||||
/**
|
||||
* Logical Nodes
|
||||
* These define hierarchy and grouping. They do not directly render to Gemini.
|
||||
*/
|
||||
export interface Episode extends Node {
|
||||
readonly type: 'EPISODE';
|
||||
/** References to the Concrete Node IDs that conceptually belong to this Episode. */
|
||||
concreteNodes: readonly ConcreteNode[];
|
||||
}
|
||||
|
||||
export interface Task extends Node {
|
||||
readonly type: 'TASK';
|
||||
readonly goal: string;
|
||||
readonly status: 'active' | 'completed' | 'failed';
|
||||
/** References to the Episode IDs that belong to this task */
|
||||
readonly episodeIds: readonly string[];
|
||||
}
|
||||
|
||||
export type LogicalNode = Task | Episode;
|
||||
|
||||
export function isEpisode(node: Node): node is Episode {
|
||||
return node.type === 'EPISODE';
|
||||
}
|
||||
|
||||
export function isTask(node: Node): node is Task {
|
||||
return node.type === 'TASK';
|
||||
}
|
||||
|
||||
export function isAgentThought(node: Node): node is AgentThought {
|
||||
return node.type === 'AGENT_THOUGHT';
|
||||
}
|
||||
|
||||
export function isAgentYield(node: Node): node is AgentYield {
|
||||
return node.type === 'AGENT_YIELD';
|
||||
}
|
||||
|
||||
export function isToolExecution(node: Node): node is ToolExecution {
|
||||
return node.type === 'TOOL_EXECUTION';
|
||||
}
|
||||
|
||||
export function isMaskedTool(node: Node): node is MaskedTool {
|
||||
return node.type === 'MASKED_TOOL';
|
||||
}
|
||||
|
||||
export function isUserPrompt(node: Node): node is UserPrompt {
|
||||
return node.type === 'USER_PROMPT';
|
||||
}
|
||||
|
||||
export function isSystemEvent(node: Node): node is SystemEvent {
|
||||
return node.type === 'SYSTEM_EVENT';
|
||||
}
|
||||
|
||||
export function isSnapshot(node: Node): node is Snapshot {
|
||||
return node.type === 'SNAPSHOT';
|
||||
}
|
||||
|
||||
export function isRollingSummary(node: Node): node is RollingSummary {
|
||||
return node.type === 'ROLLING_SUMMARY';
|
||||
}
|
||||
Reference in New Issue
Block a user