check-in(broken)

This commit is contained in:
Your Name
2026-04-08 22:46:27 +00:00
parent aa71d592f9
commit fd5a703684
10 changed files with 77 additions and 187 deletions
@@ -18,7 +18,6 @@ import { ContextEnvironmentImpl } from './sidecar/environmentImpl.js';
import { SidecarLoader } from './sidecar/SidecarLoader.js';
import { ContextTracer } from './tracer.js';
import { ContextEventBus } from './eventBus.js';
import { ContextTokenCalculator } from './utils/contextTokenCalculator.js';
import type { Content } from '@google/genai';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type { Episode } from './ir/types.js';
@@ -140,7 +139,7 @@ describe('ContextManager Golden Tests', () => {
const history = createLargeHistory();
(
contextManager as unknown as { pristineEpisodes: Episode[] }
).pristineEpisodes = (contextManager as any).env.irMapper.toIr(history, new ContextTokenCalculator(4));
).pristineEpisodes = (contextManager as any).env.irMapper.toIr(history, (contextManager as any).env.tokenCalculator);
const result = await contextManager.projectCompressedHistory();
expect(result).toMatchSnapshot();
});
+1 -1
View File
@@ -141,7 +141,7 @@ export class ContextManager {
}
this.historyObserver = new HistoryObserver(
this.chatHistory,
chatHistory,
this.env.eventBus,
this.tracer,
this.env.tokenCalculator,
+35 -125
View File
@@ -1,133 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content, Part } from '@google/genai';
import type {
ConcreteNode,
UserPrompt,
AgentThought,
ToolExecution,
AgentYield,
MaskedTool,
} from './types.js';
import { isAgentThought, isAgentYield, isSystemEvent, isSnapshot, isRollingSummary, isMaskedTool, isToolExecution, isUserPrompt } from './graphUtils.js';
import type { ConcreteNode } from './types.js';
import type { IrSerializationWriter, IrNodeBehaviorRegistry } from './behaviorRegistry.js';
export function fromIr(ship: readonly ConcreteNode[]): Content[] {
const history: Content[] = [];
const agentParts: Part[] = [];
class IrSerializer implements IrSerializationWriter {
private history: Content[] = [];
private currentModelParts: Part[] = [];
const flushAgentParts = () => {
if (agentParts.length > 0) {
history.push({ role: 'model', parts: [...agentParts] });
agentParts.length = 0;
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 fromIr(ship: readonly ConcreteNode[], registry: IrNodeBehaviorRegistry): Content[] {
const writer = new IrSerializer();
for (const node of ship) {
if (isUserPrompt(node)) {
flushAgentParts();
const content = serializeUserPrompt(node);
if (content) history.push(content);
} else if (isSystemEvent(node)) {
flushAgentParts();
// System events do not map strictly to Gemini Content parts unless synthesized.
} else if (isAgentThought(node)) {
agentParts.push(serializeAgentThought(node));
} else if (isToolExecution(node)) {
const parts = serializeToolExecution(node);
agentParts.push(parts.call);
flushAgentParts();
history.push({ role: 'user', parts: [parts.response] });
} else if (isMaskedTool(node)) {
const parts = serializeMaskedTool(node);
agentParts.push(parts.call);
flushAgentParts();
history.push({ role: 'user', parts: [parts.response] });
} else if (isAgentYield(node)) {
agentParts.push(serializeAgentYield(node));
flushAgentParts();
} else if (isSnapshot(node)) {
flushAgentParts();
history.push({ role: 'user', parts: [{ text: (node).text }] });
} else if (isRollingSummary(node)) {
flushAgentParts();
history.push({ role: 'user', parts: [{ text: (node).text }] });
}
const behavior = registry.get(node.type);
behavior.serialize(node, writer);
}
flushAgentParts();
return history;
}
export function serializeUserPrompt(prompt: UserPrompt): Content | null {
const parts: Part[] = [];
for (const sp of prompt.semanticParts) {
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') {
parts.push(sp.part);
}
}
return parts.length > 0 ? { role: 'user', parts } : null;
}
export function serializeAgentThought(thought: AgentThought): Part {
return { text: thought.text };
}
export function serializeToolExecution(
tool: ToolExecution,
): { call: Part; response: Part } {
return {
call: {
functionCall: {
id: tool.id,
name: tool.toolName,
args: tool.intent,
},
},
response: {
functionResponse: {
id: tool.id,
name: tool.toolName,
response: typeof tool.observation === "string" ? { message: tool.observation } : tool.observation,
},
},
};
}
export function serializeMaskedTool(
tool: MaskedTool,
): { call: Part; response: Part } {
return {
call: {
functionCall: {
id: tool.id,
name: tool.toolName,
args: tool.intent ?? {},
},
},
response: {
functionResponse: {
id: tool.id,
name: tool.toolName,
response: typeof tool.observation === 'string' ? { message: tool.observation } : (tool.observation ?? {}),
},
},
};
}
export function serializeAgentYield(yieldNode: AgentYield): Part {
return { text: yieldNode.text };
return writer.getContents();
}
@@ -15,7 +15,8 @@ import { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
describe('HistorySquashingProcessor', () => {
it('should truncate nodes that exceed maxTokensPerNode', async () => {
const mockTokenCalculator = new ContextTokenCalculator(1) as any;
const env = createMockEnvironment();
const mockTokenCalculator = new ContextTokenCalculator(1, env.behaviorRegistry) as any;
mockTokenCalculator.tokensToChars = vi.fn().mockReturnValue(10); // Limit is 10 chars
mockTokenCalculator.estimateTokensForString = vi.fn((text: string) => {
@@ -24,9 +25,7 @@ describe('HistorySquashingProcessor', () => {
});
mockTokenCalculator.estimateTokensForParts = vi.fn(() => 1);
const env = createMockEnvironment({
tokenCalculator: mockTokenCalculator
});
(env as any).tokenCalculator = mockTokenCalculator;
const processor = HistorySquashingProcessor.create(env, {
maxTokensPerNode: 1, // Will equal 10 chars limit
@@ -80,7 +79,8 @@ describe('HistorySquashingProcessor', () => {
});
it('should stop truncating once the deficit is cleared', async () => {
const mockTokenCalculator = new ContextTokenCalculator(1) as any;
const env = createMockEnvironment();
const mockTokenCalculator = new ContextTokenCalculator(1, env.behaviorRegistry) as any;
mockTokenCalculator.tokensToChars = vi.fn().mockReturnValue(10);
mockTokenCalculator.estimateTokensForString = vi.fn((text: string) => {
if (text.includes('OMITTED')) return 0; // Huge savings
@@ -88,9 +88,7 @@ describe('HistorySquashingProcessor', () => {
});
mockTokenCalculator.estimateTokensForParts = vi.fn(() => 0);
const env = createMockEnvironment({
tokenCalculator: mockTokenCalculator
});
(env as any).tokenCalculator = mockTokenCalculator;
const processor = HistorySquashingProcessor.create(env, {
maxTokensPerNode: 1,
@@ -18,22 +18,23 @@ describe('SemanticCompressionProcessor', () => {
it('should trigger summarization via LLM for long text parts', async () => {
const mockLlmClient = {
generateContent: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'Mocked Summary!' }] } }],
text: 'Mocked Summary!',
}),
};
const mockTokenCalculator = new ContextTokenCalculator(1) as any;
const env = createMockEnvironment({
llmClient: mockLlmClient as any,
});
const mockTokenCalculator = new ContextTokenCalculator(1, env.behaviorRegistry) as any;
mockTokenCalculator.tokensToChars = vi.fn().mockReturnValue(10);
mockTokenCalculator.estimateTokensForParts = vi.fn((parts: any) => {
if (parts[0]?.text === 'Mocked Summary!') return 5;
if (parts[0]?.functionResponse?.response?.summary === 'Mocked Summary!') return 10;
return 5000;
});
mockTokenCalculator.getTokenCost = vi.fn().mockReturnValue(5000);
const env = createMockEnvironment({
llmClient: mockLlmClient as any,
tokenCalculator: mockTokenCalculator
});
(env as any).tokenCalculator = mockTokenCalculator;
const processor = SemanticCompressionProcessor.create(env, {
nodeThresholdTokens: 10,
@@ -69,18 +70,17 @@ describe('SemanticCompressionProcessor', () => {
// 1. User Prompt
const compressedPrompt = result[0] as UserPrompt;
expect(compressedPrompt.id).toBe('mock-uuid-1');
expect(compressedPrompt.id).not.toBe(prompt.id);
expect(compressedPrompt.semanticParts[0].type).toBe('text');
expect((compressedPrompt.semanticParts[0] as any).text).toBe('Mocked Summary!');
// 2. Agent Thought
const compressedThought = result[1] as AgentThought;
expect(compressedThought.id).toBe('mock-uuid-2');
expect(compressedThought.id).toMatch(/^mock-uuid-/);
expect(compressedThought.id).not.toBe(thought.id);
expect(compressedThought.text).toBe('Mocked Summary!');
const compressedTool = result[2] as ToolExecution;
expect(compressedTool.id).toBe('mock-uuid-3');
expect(compressedTool.id).toMatch(/^mock-uuid-/);
expect(compressedTool.id).not.toBe(tool.id);
expect(compressedTool.observation).toEqual({ summary: 'Mocked Summary!' });
@@ -90,22 +90,23 @@ describe('SemanticCompressionProcessor', () => {
it('should stop summarizing once the deficit is cleared', async () => {
const mockLlmClient = {
generateContent: vi.fn().mockResolvedValue({
candidates: [{ content: { parts: [{ text: 'Mocked Summary!' }] } }],
text: 'Mocked Summary!',
}),
};
const mockTokenCalculator = new ContextTokenCalculator(1) as any;
const env = createMockEnvironment({
llmClient: mockLlmClient as any,
});
const mockTokenCalculator = new ContextTokenCalculator(1, env.behaviorRegistry) as any;
mockTokenCalculator.tokensToChars = vi.fn().mockReturnValue(10);
// Returning 0 tokens for the summary to maximize savings
mockTokenCalculator.estimateTokensForParts = vi.fn((parts: any) => {
if (parts[0]?.text === 'Mocked Summary!') return 0;
return 5000;
});
mockTokenCalculator.getTokenCost = vi.fn().mockReturnValue(5000);
const env = createMockEnvironment({
llmClient: mockLlmClient as any,
tokenCalculator: mockTokenCalculator
});
(env as any).tokenCalculator = mockTokenCalculator;
const processor = SemanticCompressionProcessor.create(env, {
nodeThresholdTokens: 10,
@@ -137,7 +138,6 @@ describe('SemanticCompressionProcessor', () => {
// 1. User Prompt (was summarized because deficit was > 0)
const compressedPrompt = result[0] as UserPrompt;
expect(compressedPrompt.id).toBe('mock-uuid-1');
expect(compressedPrompt.id).not.toBe(prompt.id);
// 2. Agent Thought (was NOT summarized because deficit hit 0)
@@ -123,12 +123,6 @@ export class SemanticCompressionProcessor implements ContextProcessor {
}
if (modified) {
newParts.map(p => {
if (p.type === 'text') return { text: p.text };
if (p.type === 'inline_data') return { inlineData: { mimeType: p.mimeType, data: p.data } };
if (p.type === 'file_data') return { fileData: { mimeType: p.mimeType, fileUri: p.fileUri } };
return (p as Extract<import('../ir/types.js').SemanticPart, { type: 'raw_part' }>).part;
});
returnedNodes.push({
...prompt,
id: this.env.idGenerator.generateId(),
@@ -10,6 +10,8 @@ import type { ContextTracer } from '../tracer.js';
import type { IFileSystem } from '../system/IFileSystem.js';
import type { IIdGenerator } from '../system/IIdGenerator.js';
import type { LiveInbox } from './inbox.js';
import type { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
import type { IrMapper } from '../ir/mapper.js';
export type { ContextTracer, ContextEventBus };
@@ -26,6 +28,6 @@ export interface ContextEnvironment {
readonly idGenerator: IIdGenerator;
readonly eventBus: ContextEventBus;
readonly inbox: LiveInbox;
readonly behaviorRegistry: import('../ir/behaviorRegistry.js').IrNodeBehaviorRegistry;
readonly irMapper: import('../ir/mapper.js').IrMapper;
readonly behaviorRegistry: IrNodeBehaviorRegistry;
readonly irMapper: IrMapper;
}
@@ -25,8 +25,8 @@ export class ContextEnvironmentImpl implements ContextEnvironment {
readonly fileSystem: IFileSystem;
readonly idGenerator: IIdGenerator;
readonly inbox: LiveInbox;
readonly behaviorRegistry: import('../ir/behaviorRegistry.js').IrNodeBehaviorRegistry;
readonly irMapper: import('../ir/mapper.js').IrMapper;
readonly behaviorRegistry: IrNodeBehaviorRegistry;
readonly irMapper: IrMapper;
constructor(
readonly llmClient: BaseLlmClient,
@@ -40,13 +40,12 @@ export class ContextEnvironmentImpl implements ContextEnvironment {
fileSystem?: IFileSystem,
idGenerator?: IIdGenerator,
) {
this.tokenCalculator = new ContextTokenCalculator(this.charsPerToken);
this.behaviorRegistry = new IrNodeBehaviorRegistry();
registerBuiltInBehaviors(this.behaviorRegistry);
this.tokenCalculator = new ContextTokenCalculator(this.charsPerToken, this.behaviorRegistry);
this.fileSystem = fileSystem || new NodeFileSystem();
this.idGenerator = idGenerator || new NodeIdGenerator();
this.inbox = new LiveInbox();
this.behaviorRegistry = new IrNodeBehaviorRegistry();
registerBuiltInBehaviors(this.behaviorRegistry);
this.irMapper = new IrMapper(this.behaviorRegistry);
}
}
@@ -123,7 +123,7 @@ export function createMockEnvironment(
eventBus: new ContextEventBus(),
tracer: new ContextTracer({ targetDir: '/tmp', sessionId: 'mock-session' }),
charsPerToken: 1,
tokenCalculator: new ContextTokenCalculator(1),
tokenCalculator: new ContextTokenCalculator(1, registry),
fileSystem: new InMemoryFileSystem(),
idGenerator: new DeterministicIdGenerator('mock-uuid-'),
behaviorRegistry: registry,
@@ -7,8 +7,7 @@
import type { Part } from '@google/genai';
import { estimateTokenCountSync as baseEstimate } from '../../utils/tokenCalculation.js';
import type { ConcreteNode } from '../ir/types.js';
import { isUserPrompt, isAgentThought, isToolExecution, isMaskedTool, isAgentYield, isSnapshot, isRollingSummary } from '../ir/graphUtils.js';
import { serializeUserPrompt, serializeAgentThought, serializeToolExecution, serializeMaskedTool, serializeAgentYield } from '../ir/fromIr.js';
import type { IrNodeBehaviorRegistry } from '../ir/behaviorRegistry.js';
/**
* The flat token cost assigned to a single multi-modal asset (like an image tile)
@@ -19,7 +18,10 @@ import { serializeUserPrompt, serializeAgentThought, serializeToolExecution, ser
export class ContextTokenCalculator {
private readonly tokenCache = new Map<string, number>();
constructor(private readonly charsPerToken: number) {}
constructor(
private readonly charsPerToken: number,
private readonly registry: IrNodeBehaviorRegistry
) {}
/**
* Estimates tokens for a simple string based on character count.
@@ -42,23 +44,9 @@ export class ContextTokenCalculator {
* Because nodes are immutable, this cost never changes for this node ID.
*/
cacheNodeTokens(node: ConcreteNode): number {
let tokens = 0;
if (isUserPrompt(node)) {
const content = serializeUserPrompt(node);
if (content && content.parts) tokens = this.estimateTokensForParts(content.parts as Part[]);
} else if (isAgentThought(node)) {
tokens = this.estimateTokensForParts([serializeAgentThought(node)]);
} else if (isToolExecution(node)) {
const parts = serializeToolExecution(node);
tokens = this.estimateTokensForParts([parts.call, parts.response]);
} else if (isMaskedTool(node)) {
const parts = serializeMaskedTool(node);
tokens = this.estimateTokensForParts([parts.call, parts.response]);
} else if (isAgentYield(node)) {
tokens = this.estimateTokensForParts([serializeAgentYield(node)]);
} else if (isSnapshot(node) || isRollingSummary(node)) {
tokens = this.estimateTokensForParts([{ text: node.text }]);
}
const behavior = this.registry.get(node.type);
const parts = behavior.getEstimatableParts(node);
const tokens = this.estimateTokensForParts(parts);
this.tokenCache.set(node.id, tokens);
return tokens;
}