diff --git a/packages/core/src/context/ir/fromIr.ts b/packages/core/src/context/ir/fromIr.ts new file mode 100644 index 0000000000..f745b00cfa --- /dev/null +++ b/packages/core/src/context/ir/fromIr.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content, Part } from '@google/genai'; +import type { Episode, EpisodeStep, UserPrompt, AgentYield } from './types.js'; + +export function fromIr(episodes: Episode[]): Content[] { + const history: Content[] = []; + + for (const ep of episodes) { + if (ep.trigger.type === 'USER_PROMPT') { + const triggerContent = serializeTrigger(ep.trigger); + if (triggerContent) history.push(triggerContent); + } + + const stepContents = serializeSteps(ep.steps); + history.push(...stepContents); + + if (ep.yield) { + history.push(serializeYield(ep.yield)); + } + } + + return history; +} + +function serializeTrigger(trigger: UserPrompt): Content | null { + const parts: Part[] = []; + for (const sp of trigger.semanticParts) { + if (sp.presentation) { + parts.push({ text: sp.presentation.text }); + } else if (sp.type === 'text') { + parts.push({ text: sp.text }); + } else if (sp.type === 'inline_data') { + parts.push({ + inlineData: { mimeType: sp.mimeType, data: sp.data }, + }); + } else if (sp.type === 'file_data') { + parts.push({ + fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri }, + }); + } else if (sp.type === 'raw_part') { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-unsafe-type-assertion + parts.push(sp.part as unknown as Part); + } + } + return parts.length > 0 ? { role: 'user', parts } : null; +} + +function serializeSteps(steps: EpisodeStep[]): Content[] { + const history: Content[] = []; + let pendingModelParts: Part[] = []; + let pendingUserParts: Part[] = []; + + const flushPending = () => { + if (pendingModelParts.length > 0) { + history.push({ role: 'model', parts: [...pendingModelParts] }); + pendingModelParts = []; + } + if (pendingUserParts.length > 0) { + history.push({ role: 'user', parts: [...pendingUserParts] }); + pendingUserParts = []; + } + }; + + for (const step of steps) { + if (step.type === 'AGENT_THOUGHT') { + if (pendingUserParts.length > 0) flushPending(); + pendingModelParts.push({ + text: step.presentation?.text ?? step.text, + }); + } else if (step.type === 'TOOL_EXECUTION') { + pendingModelParts.push({ + functionCall: { + name: step.toolName, + args: step.intent as unknown as Record, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + id: step.id, + }, + }); + const observation = step.presentation + ? step.presentation.observation + : step.observation; + pendingUserParts.push({ + functionResponse: { + name: step.toolName, + response: observation as unknown as Record, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + id: step.id, + }, + }); + } + } + flushPending(); + + return history; +} + +function serializeYield(yieldNode: AgentYield): Content { + return { + role: 'model', + parts: [{ text: yieldNode.presentation?.text ?? yieldNode.text }], + }; +} diff --git a/packages/core/src/context/ir/mapper.ts b/packages/core/src/context/ir/mapper.ts index 9c18473c99..5a57bdf046 100644 --- a/packages/core/src/context/ir/mapper.ts +++ b/packages/core/src/context/ir/mapper.ts @@ -4,304 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content, Part } from '@google/genai'; -import { randomUUID } from 'node:crypto'; -import type { - Episode, - IrMetadata, - SemanticPart, - ToolExecution, - AgentThought, - AgentYield, - UserPrompt, -} from './types.js'; -import { estimateContextTokenCountSync as estimateTokenCountSync } from '../utils/contextTokenCalculator.js'; - -// WeakMap to provide stable, deterministic identity across parses for the exact same Content/Part references -const nodeIdentityMap = new WeakMap(); - -function getStableId(obj: object): string { - let id = nodeIdentityMap.get(obj); - if (!id) { - id = randomUUID(); - nodeIdentityMap.set(obj, id); - } - return id; -} +import type { Content } from '@google/genai'; +import type { Episode } from './types.js'; +import { toIr, setMapperConfig } from './toIr.js'; +import { fromIr } from './fromIr.js'; export class IrMapper { static setConfig(cfg: { charsPerToken?: number }) { - this.config = cfg; + setMapperConfig(cfg); } - private static config: { charsPerToken?: number } | undefined; /** * Translates a flat Gemini Content[] array into our rich Episodic Intermediate Representation. * Groups adjacent function calls and responses into unified ToolExecution nodes. */ static toIr(history: readonly Content[]): Episode[] { - const episodes: Episode[] = []; - let currentEpisode: Partial | null = null; - const pendingCallParts: Map = new Map(); - - const createMetadata = (parts: Part[]): IrMetadata => { - const tokens = estimateTokenCountSync(parts, 0, IrMapper.config); - return { - originalTokens: tokens, - currentTokens: tokens, - transformations: [], - }; - }; - - const finalizeEpisode = () => { - if (currentEpisode && currentEpisode.trigger) { - episodes.push(currentEpisode as unknown as Episode); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - } - 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) { - if (!currentEpisode) { - currentEpisode = { - id: getStableId(msg), - timestamp: Date.now(), - trigger: { - id: getStableId(msg.parts[0] || msg), - type: 'SYSTEM_EVENT', - name: 'history_resume', - payload: {}, - metadata: createMetadata([]), - }, - steps: [], - }; - } - - for (const part of msg.parts) { - if (part.functionResponse) { - const callId = part.functionResponse.id || ''; - const matchingCall = pendingCallParts.get(callId); - - const intentTokens = matchingCall - ? estimateTokenCountSync([matchingCall]) - : 0; - const obsTokens = estimateTokenCountSync([part]); - - const step: ToolExecution = { - id: getStableId(part), - type: 'TOOL_EXECUTION', - toolName: part.functionResponse.name || 'unknown', - intent: - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (matchingCall?.functionCall?.args as unknown as Record< - string, - unknown - >) || {}, - observation: - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (part.functionResponse.response as unknown as Record< - string, - unknown - >) || {}, - tokens: { - intent: intentTokens, - observation: obsTokens, - }, - metadata: { - originalTokens: intentTokens + obsTokens, - currentTokens: intentTokens + obsTokens, - transformations: [], - }, - }; - currentEpisode.steps!.push(step); - if (callId) pendingCallParts.delete(callId); - } - } - } - - if (hasUserParts) { - finalizeEpisode(); - - const semanticParts: SemanticPart[] = []; - for (const p of msg.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 trigger: UserPrompt = { - id: getStableId(msg.parts[0] || msg), - type: 'USER_PROMPT', - semanticParts, - metadata: createMetadata( - msg.parts.filter((p) => !p.functionResponse), - ), - }; - - currentEpisode = { - id: getStableId(msg), - timestamp: Date.now(), - trigger, - steps: [], - }; - } - } else if (msg.role === 'model') { - if (!currentEpisode) { - currentEpisode = { - id: getStableId(msg), - timestamp: Date.now(), - trigger: { - id: getStableId(msg.parts[0] || msg), - type: 'SYSTEM_EVENT', - name: 'model_init', - payload: {}, - metadata: createMetadata([]), - }, - steps: [], - }; - } - - for (const part of msg.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), - type: 'AGENT_THOUGHT', - text: part.text, - metadata: createMetadata([part]), - }; - currentEpisode.steps!.push(thought); - } - } - } - } - - if (currentEpisode) { - if (currentEpisode.steps && currentEpisode.steps.length > 0) { - const lastStep = currentEpisode.steps[currentEpisode.steps.length - 1]; - if (lastStep.type === 'AGENT_THOUGHT') { - const yieldNode: AgentYield = { - id: lastStep.id, - type: 'AGENT_YIELD', - text: lastStep.text, - metadata: lastStep.metadata, - }; - currentEpisode.steps.pop(); - currentEpisode.yield = yieldNode; - } - } - finalizeEpisode(); - } - - return episodes; + return toIr(history); } /** * Re-serializes the Episodic IR back into a flat Gemini Content[] array. */ static fromIr(episodes: Episode[]): Content[] { - const history: Content[] = []; - - for (const ep of episodes) { - // 1. Serialize Trigger - if (ep.trigger.type === 'USER_PROMPT') { - const parts: Part[] = []; - for (const sp of ep.trigger.semanticParts) { - if (sp.presentation) { - parts.push({ text: sp.presentation.text }); - } else if (sp.type === 'text') { - parts.push({ text: sp.text }); - } else if (sp.type === 'inline_data') { - parts.push({ - inlineData: { mimeType: sp.mimeType, data: sp.data }, - }); - } else if (sp.type === 'file_data') { - parts.push({ - fileData: { mimeType: sp.mimeType, fileUri: sp.fileUri }, - }); - } else if (sp.type === 'raw_part') { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-unsafe-type-assertion - parts.push(sp.part as unknown as Part); - } - } - if (parts.length > 0) history.push({ role: 'user', parts }); - } - - // 2. Serialize Steps - let pendingModelParts: Part[] = []; - let pendingUserParts: Part[] = []; - - const flushPending = () => { - if (pendingModelParts.length > 0) { - history.push({ role: 'model', parts: [...pendingModelParts] }); - pendingModelParts = []; - } - if (pendingUserParts.length > 0) { - history.push({ role: 'user', parts: [...pendingUserParts] }); - pendingUserParts = []; - } - }; - - for (const step of ep.steps) { - if (step.type === 'AGENT_THOUGHT') { - if (pendingUserParts.length > 0) flushPending(); - pendingModelParts.push({ - text: step.presentation?.text ?? step.text, - }); - } else if (step.type === 'TOOL_EXECUTION') { - pendingModelParts.push({ - functionCall: { - name: step.toolName, - args: step.intent as unknown as Record, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - id: step.id, - }, - }); - const observation = step.presentation - ? step.presentation.observation - : step.observation; - pendingUserParts.push({ - functionResponse: { - name: step.toolName, - response: observation as unknown as Record, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - id: step.id, - }, - }); - } - } - flushPending(); - - // 3. Serialize Yield - if (ep.yield) { - history.push({ - role: 'model', - parts: [{ text: ep.yield.presentation?.text ?? ep.yield.text }], - }); - } - } - - return history; + return fromIr(episodes); } } diff --git a/packages/core/src/context/ir/toIr.ts b/packages/core/src/context/ir/toIr.ts new file mode 100644 index 0000000000..a7f4b28ce1 --- /dev/null +++ b/packages/core/src/context/ir/toIr.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content, Part } from '@google/genai'; +import { randomUUID } from 'node:crypto'; +import type { + Episode, + IrMetadata, + SemanticPart, + ToolExecution, + AgentThought, + AgentYield, + UserPrompt, + SystemEvent, +} from './types.js'; +import { estimateContextTokenCountSync } from '../utils/contextTokenCalculator.js'; + +// WeakMap to provide stable, deterministic identity across parses for the exact same Content/Part references +const nodeIdentityMap = new WeakMap(); + +export function getStableId(obj: object): string { + let id = nodeIdentityMap.get(obj); + if (!id) { + id = randomUUID(); + nodeIdentityMap.set(obj, id); + } + return id; +} + +export let charsPerTokenConfig: { charsPerToken?: number } | undefined; + +export function setMapperConfig(cfg: { charsPerToken?: number }) { + charsPerTokenConfig = cfg; +} + +export function createMetadata(parts: Part[]): IrMetadata { + const tokens = estimateContextTokenCountSync(parts, 0, charsPerTokenConfig); + return { + originalTokens: tokens, + currentTokens: tokens, + transformations: [], + }; +} + +export function toIr(history: readonly Content[]): Episode[] { + const episodes: Episode[] = []; + let currentEpisode: Partial | null = null; + const pendingCallParts: Map = new Map(); + + const finalizeEpisode = () => { + if (currentEpisode && currentEpisode.trigger) { + episodes.push(currentEpisode as unknown as Episode); // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + } + 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); + } + + if (hasUserParts) { + finalizeEpisode(); + currentEpisode = parseUserParts(msg); + } + } else if (msg.role === 'model') { + currentEpisode = parseModelParts(msg, currentEpisode, pendingCallParts); + } + } + + if (currentEpisode) { + finalizeYield(currentEpisode); + finalizeEpisode(); + } + + return episodes; +} + +function parseToolResponses( + msg: Content, + currentEpisode: Partial | null, + pendingCallParts: Map, +): Partial { + if (!currentEpisode) { + currentEpisode = { + id: getStableId(msg), + timestamp: Date.now(), + trigger: { + id: getStableId(msg.parts![0] || msg), + type: 'SYSTEM_EVENT', + name: 'history_resume', + payload: {}, + metadata: createMetadata([]), + } as SystemEvent, + steps: [], + }; + } + + for (const part of msg.parts!) { + if (part.functionResponse) { + const callId = part.functionResponse.id || ''; + const matchingCall = pendingCallParts.get(callId); + + const intentTokens = matchingCall + ? estimateContextTokenCountSync([matchingCall]) + : 0; + const obsTokens = estimateContextTokenCountSync([part]); + + const step: ToolExecution = { + id: getStableId(part), + type: 'TOOL_EXECUTION', + toolName: part.functionResponse.name || 'unknown', + intent: + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (matchingCall?.functionCall?.args as unknown as Record< + string, + unknown + >) || {}, + observation: + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (part.functionResponse.response as unknown as Record< + string, + unknown + >) || {}, + tokens: { + intent: intentTokens, + observation: obsTokens, + }, + metadata: { + originalTokens: intentTokens + obsTokens, + currentTokens: intentTokens + obsTokens, + transformations: [], + }, + }; + currentEpisode.steps!.push(step); + if (callId) pendingCallParts.delete(callId); + } + } + return currentEpisode; +} + +function parseUserParts(msg: Content): Partial { + const semanticParts: SemanticPart[] = []; + for (const p of msg.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 trigger: UserPrompt = { + id: getStableId(msg.parts![0] || msg), + type: 'USER_PROMPT', + semanticParts, + metadata: createMetadata( + msg.parts!.filter((p) => !p.functionResponse), + ), + }; + + return { + id: getStableId(msg), + timestamp: Date.now(), + trigger, + steps: [], + }; +} + +function parseModelParts( + msg: Content, + currentEpisode: Partial | null, + pendingCallParts: Map, +): Partial { + if (!currentEpisode) { + currentEpisode = { + id: getStableId(msg), + timestamp: Date.now(), + trigger: { + id: getStableId(msg.parts![0] || msg), + type: 'SYSTEM_EVENT', + name: 'model_init', + payload: {}, + metadata: createMetadata([]), + } as SystemEvent, + steps: [], + }; + } + + for (const part of msg.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), + type: 'AGENT_THOUGHT', + text: part.text, + metadata: createMetadata([part]), + }; + currentEpisode.steps!.push(thought); + } + } + return currentEpisode; +} + +function finalizeYield(currentEpisode: Partial) { + if (currentEpisode.steps && currentEpisode.steps.length > 0) { + const lastStep = currentEpisode.steps[currentEpisode.steps.length - 1]; + if (lastStep.type === 'AGENT_THOUGHT') { + const yieldNode: AgentYield = { + id: lastStep.id, + type: 'AGENT_YIELD', + text: lastStep.text, + metadata: lastStep.metadata, + }; + currentEpisode.steps.pop(); + currentEpisode.yield = yieldNode; + } + } +}