mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-17 21:40:39 -07:00
formatting
This commit is contained in:
@@ -699,7 +699,7 @@ export interface ConfigParameters {
|
||||
experimentalJitContext?: boolean;
|
||||
autoDistillation?: boolean;
|
||||
experimentalMemoryManager?: boolean;
|
||||
experimentalContextSidecarConfig?: string;
|
||||
experimentalContextManagementConfig?: string;
|
||||
experimentalAgentHistoryTruncation?: boolean;
|
||||
experimentalAgentHistoryTruncationThreshold?: number;
|
||||
experimentalAgentHistoryRetainedMessages?: number;
|
||||
@@ -941,7 +941,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly experimentalMemoryManager: boolean;
|
||||
private readonly experimentalContextSidecarConfig?: string;
|
||||
private readonly experimentalContextManagementConfig?: string;
|
||||
private readonly memoryBoundaryMarkers: readonly string[];
|
||||
private readonly topicUpdateNarration: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
@@ -1153,8 +1153,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
|
||||
this.experimentalContextSidecarConfig =
|
||||
params.experimentalContextSidecarConfig;
|
||||
this.experimentalContextManagementConfig =
|
||||
params.experimentalContextManagementConfig;
|
||||
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
|
||||
this.contextManagement = {
|
||||
enabled: params.contextManagement?.enabled ?? false,
|
||||
@@ -2417,8 +2417,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.experimentalMemoryManager;
|
||||
}
|
||||
|
||||
getExperimentalContextSidecarConfig(): string | undefined {
|
||||
return this.experimentalContextSidecarConfig;
|
||||
getExperimentalContextManagementConfig(): string | undefined {
|
||||
return this.experimentalContextManagementConfig;
|
||||
}
|
||||
|
||||
getContextManagementConfig(): ContextManagementConfig {
|
||||
|
||||
@@ -18,11 +18,14 @@ describe('SidecarLoader (Fake FS)', () => {
|
||||
beforeEach(() => {
|
||||
fileSystem = new InMemoryFileSystem();
|
||||
registry = new SidecarRegistry();
|
||||
registry.registerProcessor({ id: 'NodeTruncation', schema: { type: 'object', properties: { maxTokens: { type: 'number' } } }});
|
||||
registry.registerProcessor({
|
||||
id: 'NodeTruncation',
|
||||
schema: { type: 'object', properties: { maxTokens: { type: 'number' } } },
|
||||
});
|
||||
});
|
||||
|
||||
const mockConfig = {
|
||||
getExperimentalContextSidecarConfig: () => '/path/to/sidecar.json',
|
||||
getExperimentalContextManagementConfig: () => '/path/to/sidecar.json',
|
||||
} as unknown as Config;
|
||||
|
||||
it('returns default profile if file does not exist', () => {
|
||||
@@ -49,9 +52,9 @@ describe('SidecarLoader (Fake FS)', () => {
|
||||
processorOptions: {
|
||||
myTruncation: {
|
||||
type: 'NodeTruncation',
|
||||
options: { maxTokens: 500 }
|
||||
}
|
||||
}
|
||||
options: { maxTokens: 500 },
|
||||
},
|
||||
},
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(validConfig));
|
||||
const result = SidecarLoader.fromConfig(mockConfig, registry, fileSystem);
|
||||
@@ -65,9 +68,9 @@ describe('SidecarLoader (Fake FS)', () => {
|
||||
processorOptions: {
|
||||
myTruncation: {
|
||||
type: 'NodeTruncation',
|
||||
options: { maxTokens: "this should be a number" }
|
||||
}
|
||||
}
|
||||
options: { maxTokens: 'this should be a number' },
|
||||
},
|
||||
},
|
||||
};
|
||||
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(invalidConfig));
|
||||
expect(() =>
|
||||
|
||||
@@ -45,7 +45,7 @@ export class SidecarLoader {
|
||||
getSidecarConfigSchema(registry),
|
||||
parsed,
|
||||
);
|
||||
|
||||
|
||||
if (validationError) {
|
||||
throw new Error(
|
||||
`Invalid sidecar configuration in ${sidecarPath}. Validation error: ${validationError}`,
|
||||
@@ -64,7 +64,9 @@ export class SidecarLoader {
|
||||
config: {
|
||||
...defaultSidecarProfile.config,
|
||||
...(validConfig.budget ? { budget: validConfig.budget } : {}),
|
||||
...(validConfig.processorOptions ? { processorOptions: validConfig.processorOptions } : {})
|
||||
...(validConfig.processorOptions
|
||||
? { processorOptions: validConfig.processorOptions }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -78,7 +80,7 @@ export class SidecarLoader {
|
||||
registry: SidecarRegistry,
|
||||
fileSystem: IFileSystem = new NodeFileSystem(),
|
||||
): ContextProfile {
|
||||
const sidecarPath = config.getExperimentalContextSidecarConfig();
|
||||
const sidecarPath = config.getExperimentalContextManagementConfig();
|
||||
|
||||
if (sidecarPath && fileSystem.existsSync(sidecarPath)) {
|
||||
const size = fileSystem.statSyncSize(sidecarPath);
|
||||
|
||||
@@ -17,8 +17,14 @@ import { createStateSnapshotAsyncProcessor } from '../processors/stateSnapshotAs
|
||||
|
||||
export interface ContextProfile {
|
||||
config: SidecarConfig;
|
||||
buildPipelines: (env: ContextEnvironment, config?: SidecarConfig) => PipelineDef[];
|
||||
buildAsyncPipelines: (env: ContextEnvironment, config?: SidecarConfig) => AsyncPipelineDef[];
|
||||
buildPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: SidecarConfig,
|
||||
) => PipelineDef[];
|
||||
buildAsyncPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: SidecarConfig,
|
||||
) => AsyncPipelineDef[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,13 +38,19 @@ export const defaultSidecarProfile: ContextProfile = {
|
||||
maxTokens: 150000,
|
||||
},
|
||||
},
|
||||
|
||||
buildPipelines: (env: ContextEnvironment, config?: SidecarConfig): PipelineDef[] => {
|
||||
|
||||
buildPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: SidecarConfig,
|
||||
): PipelineDef[] => {
|
||||
// Helper to merge default options with dynamically loaded processorOptions by ID
|
||||
const getOptions = <T>(id: string, defaultOptions: T): T => {
|
||||
if (config?.processorOptions && config.processorOptions[id]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultOptions, ...(config.processorOptions[id].options as T) };
|
||||
return {
|
||||
...defaultOptions,
|
||||
...(config.processorOptions[id].options as T),
|
||||
};
|
||||
}
|
||||
return defaultOptions;
|
||||
};
|
||||
@@ -48,7 +60,11 @@ export const defaultSidecarProfile: ContextProfile = {
|
||||
name: 'Immediate Sanitization',
|
||||
triggers: ['new_message'],
|
||||
processors: [
|
||||
createToolMaskingProcessor('ToolMasking', env, getOptions('ToolMasking', { stringLengthThresholdTokens: 8000 })),
|
||||
createToolMaskingProcessor(
|
||||
'ToolMasking',
|
||||
env,
|
||||
getOptions('ToolMasking', { stringLengthThresholdTokens: 8000 }),
|
||||
),
|
||||
createBlobDegradationProcessor('BlobDegradation', env), // No options
|
||||
],
|
||||
},
|
||||
@@ -56,25 +72,43 @@ export const defaultSidecarProfile: ContextProfile = {
|
||||
name: 'Normalization',
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createNodeTruncationProcessor('NodeTruncation', env, getOptions('NodeTruncation', { maxTokensPerNode: 3000 })),
|
||||
createNodeDistillationProcessor('NodeDistillation', env, getOptions('NodeDistillation', { nodeThresholdTokens: 5000 })),
|
||||
createNodeTruncationProcessor(
|
||||
'NodeTruncation',
|
||||
env,
|
||||
getOptions('NodeTruncation', { maxTokensPerNode: 3000 }),
|
||||
),
|
||||
createNodeDistillationProcessor(
|
||||
'NodeDistillation',
|
||||
env,
|
||||
getOptions('NodeDistillation', { nodeThresholdTokens: 5000 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Emergency Backstop',
|
||||
triggers: ['gc_backstop'],
|
||||
processors: [
|
||||
createStateSnapshotProcessor('StateSnapshotSync', env, getOptions('StateSnapshotSync', { target: 'max' })),
|
||||
createStateSnapshotProcessor(
|
||||
'StateSnapshotSync',
|
||||
env,
|
||||
getOptions('StateSnapshotSync', { target: 'max' }),
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
buildAsyncPipelines: (env: ContextEnvironment, config?: SidecarConfig): AsyncPipelineDef[] => {
|
||||
buildAsyncPipelines: (
|
||||
env: ContextEnvironment,
|
||||
config?: SidecarConfig,
|
||||
): AsyncPipelineDef[] => {
|
||||
const getOptions = <T>(id: string, defaultOptions: T): T => {
|
||||
if (config?.processorOptions && config.processorOptions[id]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return { ...defaultOptions, ...(config.processorOptions[id].options as T) };
|
||||
return {
|
||||
...defaultOptions,
|
||||
...(config.processorOptions[id].options as T),
|
||||
};
|
||||
}
|
||||
return defaultOptions;
|
||||
};
|
||||
@@ -84,9 +118,13 @@ export const defaultSidecarProfile: ContextProfile = {
|
||||
name: 'Async Background GC',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor('StateSnapshotAsync', env, getOptions('StateSnapshotAsync', { type: 'accumulate' }))
|
||||
]
|
||||
}
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsync',
|
||||
env,
|
||||
getOptions('StateSnapshotAsync', { type: 'accumulate' }),
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,8 +24,8 @@ export class SidecarRegistry {
|
||||
return this.processors.get(id)?.schema;
|
||||
}
|
||||
|
||||
getSchemaDefs(): Array<{ id: string; schema: object }> {
|
||||
const defs: Array<{ id: string; schema: object }> = [];
|
||||
getSchemaDefs(): ContextProcessorDef[] {
|
||||
const defs = [];
|
||||
for (const def of this.processors.values()) {
|
||||
if (def.schema) defs.push({ id: def.id, schema: def.schema });
|
||||
}
|
||||
|
||||
@@ -10,25 +10,35 @@ export function getSidecarConfigSchema(registry?: SidecarRegistry) {
|
||||
// If a registry is provided, we can deeply validate processor overrides.
|
||||
// We do this by generating a `oneOf` list that matches the `type` discriminator
|
||||
// to the specific processor `options` schema.
|
||||
const processorOptionSchemas = registry ? registry.getSchemaDefs().map(def => ({
|
||||
type: 'object',
|
||||
required: ['type', 'options'],
|
||||
properties: {
|
||||
type: { const: def.id },
|
||||
options: def.schema
|
||||
}
|
||||
})) : [];
|
||||
|
||||
const processorOptionsMapping = processorOptionSchemas.length > 0
|
||||
? { oneOf: processorOptionSchemas }
|
||||
: {
|
||||
const processorOptionSchemas = registry
|
||||
? registry.getSchemaDefs().map((def) => ({
|
||||
type: 'object',
|
||||
required: ['type', 'options'],
|
||||
properties: {
|
||||
type: { type: 'string', description: 'The registry type of the processor (e.g. NodeTruncation)' },
|
||||
options: { type: 'object', description: 'The hyperparameter overrides' }
|
||||
}
|
||||
};
|
||||
type: { const: def.id },
|
||||
options: def.schema,
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
const processorOptionsMapping =
|
||||
processorOptionSchemas.length > 0
|
||||
? { oneOf: processorOptionSchemas }
|
||||
: {
|
||||
type: 'object',
|
||||
required: ['type', 'options'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The registry type of the processor (e.g. NodeTruncation)',
|
||||
},
|
||||
options: {
|
||||
type: 'object',
|
||||
description: 'The hyperparameter overrides',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
@@ -55,9 +65,10 @@ export function getSidecarConfigSchema(registry?: SidecarRegistry) {
|
||||
},
|
||||
processorOptions: {
|
||||
type: 'object',
|
||||
description: 'Named hyperparameter configurations for ContextProcessors and AsyncProcessors.',
|
||||
additionalProperties: processorOptionsMapping
|
||||
}
|
||||
description:
|
||||
'Named hyperparameter configurations for ContextProcessors and AsyncProcessors.',
|
||||
additionalProperties: processorOptionsMapping,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export interface SidecarConfig {
|
||||
retainedTokens: number;
|
||||
maxTokens: number;
|
||||
};
|
||||
/**
|
||||
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
|
||||
/**
|
||||
* Dynamic hyperparameter overrides for individual ContextProcessors and AsyncProcessors.
|
||||
* Keys are named identifiers (e.g. "gentleTruncation").
|
||||
*/
|
||||
processorOptions?: Record<string, { type: string; options: unknown }>;
|
||||
|
||||
@@ -79,11 +79,11 @@ To extend the system, developers author two types of plugins:
|
||||
2. **Context AsyncProcessors:** Inspired by the **Actor Model**, these are
|
||||
event-triggered background jobs designed for isolated, long-running async
|
||||
computations (e.g., asking an LLM to distill 50 turns of history).
|
||||
3. **Inboxes:** Because the graph can only be mutated synchronously, AsyncProcessors
|
||||
cannot touch the graph directly (preventing race conditions). Instead, they
|
||||
drop their results via message-passing into point-in-time snapshots called
|
||||
_Inboxes_. Processors later read from these Inboxes during a synchronous
|
||||
pipeline run to safely apply the async processor's findings.
|
||||
3. **Inboxes:** Because the graph can only be mutated synchronously,
|
||||
AsyncProcessors cannot touch the graph directly (preventing race conditions).
|
||||
Instead, they drop their results via message-passing into point-in-time
|
||||
snapshots called _Inboxes_. Processors later read from these Inboxes during a
|
||||
synchronous pipeline run to safely apply the async processor's findings.
|
||||
|
||||
## 4. Proofs of Construction
|
||||
|
||||
@@ -227,8 +227,8 @@ class MemoryExtractionProcessor implements ContextProcessor {
|
||||
|
||||
By treating the Context Graph as an immutable ledger updated only via functional
|
||||
Pipelines, we have eliminated race conditions and untraceable graph corruption.
|
||||
By utilizing AsyncProcessors and Inboxes, we have safely bridged the gap between slow
|
||||
LLM analysis and fast, synchronous terminal UI updates.
|
||||
By utilizing AsyncProcessors and Inboxes, we have safely bridged the gap between
|
||||
slow LLM analysis and fast, synchronous terminal UI updates.
|
||||
|
||||
We recognize this is not the final form—future iterations may require strict
|
||||
simple priority to updates, or more advanced generational garbage collection.
|
||||
|
||||
@@ -104,7 +104,8 @@ export class ContextManager {
|
||||
if (agedOutNodes.size > 0) {
|
||||
this.eventBus.emitConsolidationNeeded({
|
||||
nodes: this.buffer.nodes,
|
||||
targetDeficit: currentTokens - this.sidecar.config.budget.retainedTokens,
|
||||
targetDeficit:
|
||||
currentTokens - this.sidecar.config.budget.retainedTokens,
|
||||
targetNodeIds: agedOutNodes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ProcessArgs {
|
||||
|
||||
/**
|
||||
* A ContextProcessor is a pure, closure-based object that returns a modified subset of nodes
|
||||
* (or the original targets if no changes are needed).
|
||||
* (or the original targets if no changes are needed).
|
||||
* The Orchestrator will use this to generate a new graph delta.
|
||||
*/
|
||||
export interface ContextProcessor {
|
||||
|
||||
@@ -45,7 +45,7 @@ function createModifyingProcessor(id: string): ContextProcessor {
|
||||
};
|
||||
}
|
||||
return newTargets;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,18 +56,21 @@ function createThrowingProcessor(id: string): ContextProcessor {
|
||||
name: 'Throwing',
|
||||
process: async (): Promise<readonly ConcreteNode[]> => {
|
||||
throw new Error('Processor failed intentionally');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A mock async processor that signals it ran
|
||||
function createMockAsyncProcessor(id: string, executeSpy: ReturnType<typeof vi.fn>): AsyncContextProcessor {
|
||||
function createMockAsyncProcessor(
|
||||
id: string,
|
||||
executeSpy: ReturnType<typeof vi.fn>,
|
||||
): AsyncContextProcessor {
|
||||
return {
|
||||
id,
|
||||
name: 'MockAsyncProcessor',
|
||||
process: async (args: ProcessArgs) => {
|
||||
executeSpy(args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +159,10 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
{
|
||||
name: 'FailingPipeline',
|
||||
triggers: ['new_message'],
|
||||
processors: [createThrowingProcessor('Thrower'), createModifyingProcessor('Mod')],
|
||||
processors: [
|
||||
createThrowingProcessor('Thrower'),
|
||||
createModifyingProcessor('Mod'),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -183,13 +189,21 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
describe('Asynchronous async pipeline Events', () => {
|
||||
it('routes emitChunkReceived to async pipelines with nodes_added trigger', async () => {
|
||||
const executeSpy = vi.fn();
|
||||
const asyncProcessor = createMockAsyncProcessor('MyAsyncProcessor', executeSpy);
|
||||
|
||||
setupOrchestrator([], [{
|
||||
name: 'TestAsync',
|
||||
triggers: ['nodes_added'],
|
||||
processors: [asyncProcessor]
|
||||
}]);
|
||||
const asyncProcessor = createMockAsyncProcessor(
|
||||
'MyAsyncProcessor',
|
||||
executeSpy,
|
||||
);
|
||||
|
||||
setupOrchestrator(
|
||||
[],
|
||||
[
|
||||
{
|
||||
name: 'TestAsync',
|
||||
triggers: ['nodes_added'],
|
||||
processors: [asyncProcessor],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const node1 = createDummyNode('ep1', 'USER_PROMPT', 10);
|
||||
const node2 = createDummyNode('ep1', 'AGENT_THOUGHT', 20);
|
||||
@@ -200,7 +214,7 @@ describe('PipelineOrchestrator (Component)', () => {
|
||||
});
|
||||
|
||||
// Yield event loop
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(executeSpy).toHaveBeenCalledTimes(1);
|
||||
const callArgs = executeSpy.mock.calls[0][0];
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { ConcreteNode } from '../ir/types.js';
|
||||
import type { AsyncPipelineDef, PipelineDef, PipelineTrigger } from '../config/types.js';
|
||||
import type {
|
||||
AsyncPipelineDef,
|
||||
PipelineDef,
|
||||
PipelineTrigger,
|
||||
} from '../config/types.js';
|
||||
import type {
|
||||
ContextEnvironment,
|
||||
ContextEventBus,
|
||||
@@ -43,7 +47,12 @@ export class PipelineOrchestrator {
|
||||
private setupTriggers() {
|
||||
const bindTriggers = (
|
||||
pipelines: PipelineDef[] | AsyncPipelineDef[],
|
||||
executeFn: (pipeline: PipelineDef | AsyncPipelineDef, nodes: readonly ConcreteNode[], targets: ReadonlySet<string>, protectedIds: ReadonlySet<string>) => void
|
||||
executeFn: (
|
||||
pipeline: PipelineDef | AsyncPipelineDef,
|
||||
nodes: readonly ConcreteNode[],
|
||||
targets: ReadonlySet<string>,
|
||||
protectedIds: ReadonlySet<string>,
|
||||
) => void,
|
||||
) => {
|
||||
for (const pipeline of pipelines) {
|
||||
for (const trigger of pipeline.triggers) {
|
||||
@@ -52,7 +61,10 @@ export class PipelineOrchestrator {
|
||||
// Background timers not fully implemented in V1 yet
|
||||
}, trigger.intervalMs);
|
||||
this.activeTimers.push(timer);
|
||||
} else if (trigger === 'retained_exceeded' || trigger === 'nodes_aged_out') {
|
||||
} else if (
|
||||
trigger === 'retained_exceeded' ||
|
||||
trigger === 'nodes_aged_out'
|
||||
) {
|
||||
this.eventBus.onConsolidationNeeded((event) => {
|
||||
executeFn(pipeline, event.nodes, event.targetNodeIds, new Set());
|
||||
});
|
||||
@@ -67,15 +79,29 @@ export class PipelineOrchestrator {
|
||||
|
||||
bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
void this.executePipelineAsync(pipeline as PipelineDef, nodes, new Set(targets), new Set(protectedIds));
|
||||
void this.executePipelineAsync(
|
||||
pipeline as PipelineDef,
|
||||
nodes,
|
||||
new Set(targets),
|
||||
new Set(protectedIds),
|
||||
);
|
||||
});
|
||||
|
||||
bindTriggers(this.asyncPipelines, (pipeline, nodes, targetIds) => {
|
||||
const inboxSnapshot = new InboxSnapshotImpl(this.env.inbox.getMessages() || []);
|
||||
const inboxSnapshot = new InboxSnapshotImpl(
|
||||
this.env.inbox.getMessages() || [],
|
||||
);
|
||||
const targets = nodes.filter((n) => targetIds.has(n.id));
|
||||
for (const processor of pipeline.processors) {
|
||||
processor.process({ targets, inbox: inboxSnapshot, buffer: ContextWorkingBufferImpl.initialize(nodes) })
|
||||
.catch((e: unknown) => debugLogger.error(`AsyncProcessor ${processor.name} failed:`, e));
|
||||
processor
|
||||
.process({
|
||||
targets,
|
||||
inbox: inboxSnapshot,
|
||||
buffer: ContextWorkingBufferImpl.initialize(nodes),
|
||||
})
|
||||
.catch((e: unknown) =>
|
||||
debugLogger.error(`AsyncProcessor ${processor.name} failed:`, e),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,10 @@ describe('BlobDegradationProcessor', () => {
|
||||
// So we make the blob data 200 chars.
|
||||
const fakeData = 'A'.repeat(200);
|
||||
|
||||
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
|
||||
const processor = createBlobDegradationProcessor(
|
||||
'BlobDegradationProcessor',
|
||||
env,
|
||||
);
|
||||
|
||||
const parts: SemanticPart[] = [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
@@ -61,7 +64,10 @@ describe('BlobDegradationProcessor', () => {
|
||||
it('should degrade all blobs unconditionally', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
|
||||
const processor = createBlobDegradationProcessor(
|
||||
'BlobDegradationProcessor',
|
||||
env,
|
||||
);
|
||||
|
||||
// Tokens for fileData = 258.
|
||||
// Degraded text = "[File Reference (video/mp4) degraded to text to preserve context window. Original URI: gs://test1]"
|
||||
@@ -91,7 +97,10 @@ describe('BlobDegradationProcessor', () => {
|
||||
it('should return exactly the targets array if targets are empty', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
|
||||
const processor = createBlobDegradationProcessor(
|
||||
'BlobDegradationProcessor',
|
||||
env,
|
||||
);
|
||||
const targets: ConcreteNode[] = [];
|
||||
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
|
||||
@@ -16,80 +16,77 @@ export function createBlobDegradationProcessor(
|
||||
id,
|
||||
name: 'BlobDegradationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
let directoryCreated = false;
|
||||
|
||||
let blobOutputsDir = env.fileSystem.join(
|
||||
env.projectTempDir,
|
||||
'degraded-blobs',
|
||||
);
|
||||
const sessionId = env.sessionId;
|
||||
if (sessionId) {
|
||||
blobOutputsDir = env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const ensureDir = async () => {
|
||||
if (!directoryCreated) {
|
||||
await env.fileSystem.mkdir(blobOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
};
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
let directoryCreated = false;
|
||||
|
||||
// Forward scan, looking for bloated non-text parts to degrade
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
let blobOutputsDir = env.fileSystem.join(
|
||||
env.projectTempDir,
|
||||
'degraded-blobs',
|
||||
);
|
||||
const sessionId = env.sessionId;
|
||||
if (sessionId) {
|
||||
blobOutputsDir = env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') continue;
|
||||
const ensureDir = async () => {
|
||||
if (!directoryCreated) {
|
||||
await env.fileSystem.mkdir(blobOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
}
|
||||
};
|
||||
|
||||
let newText = '';
|
||||
let tokensSaved = 0;
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
switch (part.type) {
|
||||
case 'inline_data': {
|
||||
await ensureDir();
|
||||
const ext = part.mimeType.split('/')[1] || 'bin';
|
||||
const fileName = `blob_${Date.now()}_${env.idGenerator.generateId()}.${ext}`;
|
||||
const filePath = env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
fileName,
|
||||
);
|
||||
// Forward scan, looking for bloated non-text parts to degrade
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
const buffer = Buffer.from(part.data, 'base64');
|
||||
await env.fileSystem.writeFile(filePath, buffer);
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') continue;
|
||||
|
||||
const mb = (buffer.byteLength / 1024 / 1024).toFixed(2);
|
||||
newText = `[Multi-Modal Blob (${part.mimeType}, ${mb}MB) degraded to text to preserve context window. Saved to: ${filePath}]`;
|
||||
let newText = '';
|
||||
let tokensSaved = 0;
|
||||
|
||||
const oldTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
switch (part.type) {
|
||||
case 'inline_data': {
|
||||
await ensureDir();
|
||||
const ext = part.mimeType.split('/')[1] || 'bin';
|
||||
const fileName = `blob_${Date.now()}_${env.idGenerator.generateId()}.${ext}`;
|
||||
const filePath = env.fileSystem.join(
|
||||
blobOutputsDir,
|
||||
fileName,
|
||||
);
|
||||
|
||||
const buffer = Buffer.from(part.data, 'base64');
|
||||
await env.fileSystem.writeFile(filePath, buffer);
|
||||
|
||||
const mb = (buffer.byteLength / 1024 / 1024).toFixed(2);
|
||||
newText = `[Multi-Modal Blob (${part.mimeType}, ${mb}MB) degraded to text to preserve context window. Saved to: ${filePath}]`;
|
||||
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
inlineData: { mimeType: part.mimeType, data: part.data },
|
||||
},
|
||||
]);
|
||||
const newTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'file_data': {
|
||||
newText = `[File Reference (${part.mimeType}) degraded to text to preserve context window. Original URI: ${part.fileUri}]`;
|
||||
const oldTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'file_data': {
|
||||
newText = `[File Reference (${part.mimeType}) degraded to text to preserve context window. Original URI: ${part.fileUri}]`;
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
fileData: {
|
||||
mimeType: part.mimeType,
|
||||
@@ -97,54 +94,53 @@ export function createBlobDegradationProcessor(
|
||||
},
|
||||
},
|
||||
]);
|
||||
const newTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'raw_part': {
|
||||
newText = `[Unknown Part degraded to text to preserve context window.]`;
|
||||
const oldTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([part.part]);
|
||||
const newTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
case 'raw_part': {
|
||||
newText = `[Unknown Part degraded to text to preserve context window.]`;
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
part.part,
|
||||
]);
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: newText },
|
||||
]);
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
tokensSaved = oldTokens - newTokens;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (newText && tokensSaved > 0) {
|
||||
newParts[j] = { type: 'text', text: newText };
|
||||
modified = true;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (newText && tokensSaved > 0) {
|
||||
newParts[j] = { type: 'text', text: newText };
|
||||
modified = true;
|
||||
if (modified) {
|
||||
const degradedNode: UserPrompt = {
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
};
|
||||
returnedNodes.push(degradedNode);
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const degradedNode: UserPrompt = {
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
};
|
||||
returnedNodes.push(degradedNode);
|
||||
} else {
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
return returnedNodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,35 +23,35 @@ export function createHistoryTruncationProcessor(
|
||||
id,
|
||||
name: 'HistoryTruncationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
// Calculate how many tokens we need to remove based on the configured knob
|
||||
let targetTokensToRemove = 0;
|
||||
const strategy = options.target ?? 'max';
|
||||
// Calculate how many tokens we need to remove based on the configured knob
|
||||
let targetTokensToRemove = 0;
|
||||
const strategy = options.target ?? 'max';
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? 0;
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
} else if (strategy === 'max') {
|
||||
// 'max' means we remove all targets without stopping early
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
let removedTokens = 0;
|
||||
const keptNodes: ConcreteNode[] = [];
|
||||
|
||||
// The targets are sequentially ordered from oldest to newest.
|
||||
// We want to delete the oldest targets first.
|
||||
for (const node of targets) {
|
||||
if (removedTokens >= targetTokensToRemove) {
|
||||
keptNodes.push(node);
|
||||
continue;
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? 0;
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
} else if (strategy === 'max') {
|
||||
// 'max' means we remove all targets without stopping early
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
removedTokens += env.tokenCalculator.getTokenCost(node);
|
||||
}
|
||||
let removedTokens = 0;
|
||||
const keptNodes: ConcreteNode[] = [];
|
||||
|
||||
return keptNodes;
|
||||
// The targets are sequentially ordered from oldest to newest.
|
||||
// We want to delete the oldest targets first.
|
||||
for (const node of targets) {
|
||||
if (removedTokens >= targetTokensToRemove) {
|
||||
keptNodes.push(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
removedTokens += env.tokenCalculator.getTokenCost(node);
|
||||
}
|
||||
|
||||
return keptNodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,9 +25,13 @@ describe('NodeDistillationProcessor', () => {
|
||||
llmClient: mockLlmClient,
|
||||
});
|
||||
|
||||
const processor = createNodeDistillationProcessor('NodeDistillationProcessor', env, {
|
||||
nodeThresholdTokens: 10,
|
||||
});
|
||||
const processor = createNodeDistillationProcessor(
|
||||
'NodeDistillationProcessor',
|
||||
env,
|
||||
{
|
||||
nodeThresholdTokens: 10,
|
||||
},
|
||||
);
|
||||
|
||||
const longText = 'A'.repeat(50); // 50 chars
|
||||
|
||||
@@ -94,9 +98,13 @@ describe('NodeDistillationProcessor', () => {
|
||||
llmClient: mockLlmClient,
|
||||
});
|
||||
|
||||
const processor = createNodeDistillationProcessor('NodeDistillationProcessor', env, {
|
||||
nodeThresholdTokens: 100, // Very high threshold
|
||||
});
|
||||
const processor = createNodeDistillationProcessor(
|
||||
'NodeDistillationProcessor',
|
||||
env,
|
||||
{
|
||||
nodeThresholdTokens: 100, // Very high threshold
|
||||
},
|
||||
);
|
||||
|
||||
const shortText = 'Short text'; // 10 chars
|
||||
|
||||
|
||||
@@ -59,103 +59,96 @@ export function createNodeDistillationProcessor(
|
||||
id,
|
||||
name: 'NodeDistillationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
const semanticConfig = options;
|
||||
const limitTokens = semanticConfig.nodeThresholdTokens;
|
||||
const thresholdChars = env.tokenCalculator.tokensToChars(limitTokens);
|
||||
const semanticConfig = options;
|
||||
const limitTokens = semanticConfig.nodeThresholdTokens;
|
||||
const thresholdChars = env.tokenCalculator.tokensToChars(limitTokens);
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
// Scan the target working buffer and unconditionally apply the configured hyperparameter threshold
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
// Scan the target working buffer and unconditionally apply the configured hyperparameter threshold
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type !== 'text') continue;
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type !== 'text') continue;
|
||||
|
||||
if (part.text.length > thresholdChars) {
|
||||
const summary = await generateSummary(
|
||||
part.text,
|
||||
'User Prompt',
|
||||
);
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts(
|
||||
[{ text: summary }],
|
||||
);
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForParts(
|
||||
[{ text: part.text }],
|
||||
);
|
||||
if (part.text.length > thresholdChars) {
|
||||
const summary = await generateSummary(part.text, 'User Prompt');
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: summary },
|
||||
]);
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: part.text },
|
||||
]);
|
||||
|
||||
if (newTokens < oldTokens) {
|
||||
newParts[j] = { type: 'text', text: summary };
|
||||
modified = true;
|
||||
if (newTokens < oldTokens) {
|
||||
newParts[j] = { type: 'text', text: summary };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_THOUGHT': {
|
||||
if (node.text.length > thresholdChars) {
|
||||
const summary = await generateSummary(
|
||||
node.text,
|
||||
'Agent Thought',
|
||||
);
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: summary },
|
||||
]);
|
||||
const oldTokens = env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
if (newTokens < oldTokens) {
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: summary,
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TOOL_EXECUTION': {
|
||||
const rawObs = node.observation;
|
||||
|
||||
let stringifiedObs = '';
|
||||
if (typeof rawObs === 'string') {
|
||||
stringifiedObs = rawObs;
|
||||
} else {
|
||||
try {
|
||||
stringifiedObs = JSON.stringify(rawObs);
|
||||
} catch {
|
||||
stringifiedObs = String(rawObs);
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (stringifiedObs.length > thresholdChars) {
|
||||
const summary = await generateSummary(
|
||||
stringifiedObs,
|
||||
node.toolName || 'unknown',
|
||||
);
|
||||
const newObsObject = { summary };
|
||||
case 'AGENT_THOUGHT': {
|
||||
if (node.text.length > thresholdChars) {
|
||||
const summary = await generateSummary(node.text, 'Agent Thought');
|
||||
const newTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{ text: summary },
|
||||
]);
|
||||
const oldTokens = env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
const newObsTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
if (newTokens < oldTokens) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: summary,
|
||||
replacesId: node.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TOOL_EXECUTION': {
|
||||
const rawObs = node.observation;
|
||||
|
||||
let stringifiedObs = '';
|
||||
if (typeof rawObs === 'string') {
|
||||
stringifiedObs = rawObs;
|
||||
} else {
|
||||
try {
|
||||
stringifiedObs = JSON.stringify(rawObs);
|
||||
} catch {
|
||||
stringifiedObs = String(rawObs);
|
||||
}
|
||||
}
|
||||
|
||||
if (stringifiedObs.length > thresholdChars) {
|
||||
const summary = await generateSummary(
|
||||
stringifiedObs,
|
||||
node.toolName || 'unknown',
|
||||
);
|
||||
const newObsObject = { summary };
|
||||
|
||||
const newObsTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
functionResponse: {
|
||||
name: node.toolName || 'unknown',
|
||||
@@ -165,36 +158,36 @@ export function createNodeDistillationProcessor(
|
||||
},
|
||||
]);
|
||||
|
||||
const oldObsTokens =
|
||||
node.tokens?.observation ??
|
||||
env.tokenCalculator.getTokenCost(node);
|
||||
const intentTokens = node.tokens?.intent ?? 0;
|
||||
const oldObsTokens =
|
||||
node.tokens?.observation ??
|
||||
env.tokenCalculator.getTokenCost(node);
|
||||
const intentTokens = node.tokens?.intent ?? 0;
|
||||
|
||||
if (newObsTokens < oldObsTokens) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
observation: newObsObject as Record<string, unknown>,
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
replacesId: node.id,
|
||||
});
|
||||
break;
|
||||
if (newObsTokens < oldObsTokens) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
observation: newObsObject as Record<string, unknown>,
|
||||
tokens: {
|
||||
intent: intentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
replacesId: node.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
return returnedNodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ describe('NodeTruncationProcessor', () => {
|
||||
// env.tokenCalculator uses charsPerToken=1 natively.
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = createNodeTruncationProcessor('NodeTruncationProcessor', env, {
|
||||
maxTokensPerNode: 10, // 10 chars limit
|
||||
});
|
||||
const processor = createNodeTruncationProcessor(
|
||||
'NodeTruncationProcessor',
|
||||
env,
|
||||
{
|
||||
maxTokensPerNode: 10, // 10 chars limit
|
||||
},
|
||||
);
|
||||
|
||||
const longText = 'A'.repeat(50); // 50 tokens
|
||||
|
||||
@@ -82,9 +86,13 @@ describe('NodeTruncationProcessor', () => {
|
||||
it('should ignore nodes that are below maxTokensPerNode', async () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
const processor = createNodeTruncationProcessor('NodeTruncationProcessor', env, {
|
||||
maxTokensPerNode: 100, // 100 chars limit
|
||||
});
|
||||
const processor = createNodeTruncationProcessor(
|
||||
'NodeTruncationProcessor',
|
||||
env,
|
||||
{
|
||||
maxTokensPerNode: 100, // 100 chars limit
|
||||
},
|
||||
);
|
||||
|
||||
const shortText = 'Short text'; // 10 chars
|
||||
|
||||
|
||||
@@ -37,8 +37,7 @@ export function createNodeTruncationProcessor(
|
||||
|
||||
if (newText !== text) {
|
||||
// Using accurate TokenCalculator instead of simple math
|
||||
const newTokens =
|
||||
env.tokenCalculator.estimateTokensForString(newText);
|
||||
const newTokens = env.tokenCalculator.estimateTokensForString(newText);
|
||||
const oldTokens = env.tokenCalculator.estimateTokensForString(text);
|
||||
const tokensSaved = oldTokens - newTokens;
|
||||
|
||||
@@ -53,82 +52,82 @@ export function createNodeTruncationProcessor(
|
||||
id,
|
||||
name: 'NodeTruncationProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
const { maxTokensPerNode } = options;
|
||||
const limitChars = env.tokenCalculator.tokensToChars(maxTokensPerNode);
|
||||
const { maxTokensPerNode } = options;
|
||||
const limitChars = env.tokenCalculator.tokensToChars(maxTokensPerNode);
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'USER_PROMPT': {
|
||||
let modified = false;
|
||||
const newParts = [...node.semanticParts];
|
||||
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') {
|
||||
const squashResult = tryApplySquash(part.text, limitChars);
|
||||
if (squashResult) {
|
||||
newParts[j] = { type: 'text', text: squashResult.text };
|
||||
modified = true;
|
||||
for (let j = 0; j < node.semanticParts.length; j++) {
|
||||
const part = node.semanticParts[j];
|
||||
if (part.type === 'text') {
|
||||
const squashResult = tryApplySquash(part.text, limitChars);
|
||||
if (squashResult) {
|
||||
newParts[j] = { type: 'text', text: squashResult.text };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
semanticParts: newParts,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
case 'AGENT_THOUGHT': {
|
||||
const squashResult = tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_YIELD': {
|
||||
const squashResult = tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_THOUGHT': {
|
||||
const squashResult = tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'AGENT_YIELD': {
|
||||
const squashResult = tryApplySquash(node.text, limitChars);
|
||||
if (squashResult) {
|
||||
returnedNodes.push({
|
||||
...node,
|
||||
id: env.idGenerator.generateId(),
|
||||
text: squashResult.text,
|
||||
replacesId: node.id,
|
||||
});
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
return returnedNodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
describe('RollingSummaryProcessor', () => {
|
||||
it('should initialize with correct default options', () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
const processor = createRollingSummaryProcessor(
|
||||
'RollingSummaryProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'incremental',
|
||||
},
|
||||
);
|
||||
expect(processor.id).toBe('RollingSummaryProcessor');
|
||||
});
|
||||
|
||||
@@ -26,10 +30,14 @@ describe('RollingSummaryProcessor', () => {
|
||||
|
||||
// We want to free exactly 100 tokens.
|
||||
// We will supply nodes that cost 50 tokens each.
|
||||
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
});
|
||||
const processor = createRollingSummaryProcessor(
|
||||
'RollingSummaryProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
},
|
||||
);
|
||||
|
||||
const text50 = 'A'.repeat(50);
|
||||
const targets = [
|
||||
@@ -59,10 +67,14 @@ describe('RollingSummaryProcessor', () => {
|
||||
const env = createMockEnvironment();
|
||||
|
||||
// We want to free 100 tokens, but our nodes will only cost 10 tokens each.
|
||||
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
});
|
||||
const processor = createRollingSummaryProcessor(
|
||||
'RollingSummaryProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'freeNTokens',
|
||||
freeTokensTarget: 100,
|
||||
},
|
||||
);
|
||||
|
||||
const text10 = 'A'.repeat(10);
|
||||
const targets = [
|
||||
|
||||
@@ -28,77 +28,79 @@ export function createRollingSummaryProcessor(
|
||||
id,
|
||||
name: 'RollingSummaryProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
if (targets.length === 0) return targets;
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const strategy = options.target ?? 'max';
|
||||
let targetTokensToRemove = 0;
|
||||
const strategy = options.target ?? 'max';
|
||||
let targetTokensToRemove = 0;
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
// A rolling summary should target a small chunk. For now, since state isn't passed,
|
||||
// we'll default to a fixed threshold, like 10000 tokens, to avoid eating the whole history.
|
||||
// Ideally, the orchestrator should pass `tokensToRemove` explicitly.
|
||||
targetTokensToRemove = 10000;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
// Scan oldest to newest to find the oldest block that exceeds the token requirement
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
continue;
|
||||
if (strategy === 'incremental') {
|
||||
// A rolling summary should target a small chunk. For now, since state isn't passed,
|
||||
// we'll default to a fixed threshold, like 10000 tokens, to avoid eating the whole history.
|
||||
// Ideally, the orchestrator should pass `tokensToRemove` explicitly.
|
||||
targetTokensToRemove = 10000;
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += env.tokenCalculator.getTokenCost(node);
|
||||
if (targetTokensToRemove <= 0) return targets;
|
||||
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context to summarize
|
||||
// Scan oldest to newest to find the oldest block that exceeds the token requirement
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Synthesize the rolling summary synchronously
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
const newId = env.idGenerator.generateId();
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
const summaryNode: RollingSummary = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'ROLLING_SUMMARY',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
abstractsIds: nodesToSummarize.map((n) => n.id),
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, summaryNode);
|
||||
} else {
|
||||
returnedNodes.unshift(summaryNode);
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('RollingSummaryProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context to summarize
|
||||
|
||||
try {
|
||||
// Synthesize the rolling summary synchronously
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
const newId = env.idGenerator.generateId();
|
||||
|
||||
const summaryNode: RollingSummary = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'ROLLING_SUMMARY',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
abstractsIds: nodesToSummarize.map((n) => n.id),
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, summaryNode);
|
||||
} else {
|
||||
returnedNodes.unshift(summaryNode);
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('RollingSummaryProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@ describe('StateSnapshotAsyncProcessor', () => {
|
||||
// Spy on the publish method
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
|
||||
const worker = createStateSnapshotAsyncProcessor('StateSnapshotAsyncProcessor', env, { type: 'point-in-time' });
|
||||
|
||||
const worker = createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{ type: 'point-in-time' },
|
||||
);
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
|
||||
@@ -47,8 +51,12 @@ describe('StateSnapshotAsyncProcessor', () => {
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
const drainSpy = vi.spyOn(env.inbox, 'drainConsumed');
|
||||
|
||||
const worker = createStateSnapshotAsyncProcessor('StateSnapshotAsyncProcessor', env, { type: 'accumulate' });
|
||||
|
||||
const worker = createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{ type: 'accumulate' },
|
||||
);
|
||||
|
||||
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
|
||||
const targets = [nodeC];
|
||||
|
||||
@@ -70,7 +78,9 @@ describe('StateSnapshotAsyncProcessor', () => {
|
||||
await worker.process(args);
|
||||
|
||||
// The old draft should be consumed
|
||||
expect((args.inbox as InboxSnapshotImpl).getConsumedIds().has('draft-1')).toBe(true);
|
||||
expect(
|
||||
(args.inbox as InboxSnapshotImpl).getConsumedIds().has('draft-1'),
|
||||
).toBe(true);
|
||||
expect(drainSpy).toHaveBeenCalledWith(expect.any(Set));
|
||||
|
||||
// The new publish should contain ALL consumed IDs (old + new)
|
||||
@@ -103,8 +113,12 @@ describe('StateSnapshotAsyncProcessor', () => {
|
||||
it('should ignore empty targets', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const publishSpy = vi.spyOn(env.inbox, 'publish');
|
||||
const worker = createStateSnapshotAsyncProcessor('StateSnapshotAsyncProcessor', env, { type: 'accumulate' });
|
||||
|
||||
const worker = createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{ type: 'accumulate' },
|
||||
);
|
||||
|
||||
await worker.process(createMockProcessArgs([], [], []));
|
||||
|
||||
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
|
||||
|
||||
@@ -27,72 +27,75 @@ export function createStateSnapshotAsyncProcessor(
|
||||
process: async ({ targets, inbox }: ProcessArgs): Promise<void> => {
|
||||
if (targets.length === 0) return;
|
||||
|
||||
try {
|
||||
let nodesToSummarize = [...targets];
|
||||
let previousConsumedIds: string[] = [];
|
||||
const processorType = options.type ?? 'point-in-time';
|
||||
try {
|
||||
let nodesToSummarize = [...targets];
|
||||
let previousConsumedIds: string[] = [];
|
||||
const processorType = options.type ?? 'point-in-time';
|
||||
|
||||
if (processorType === 'accumulate') {
|
||||
// Look for the most recent unconsumed accumulate snapshot in the inbox
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
const accumulateSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === 'accumulate',
|
||||
if (processorType === 'accumulate') {
|
||||
// Look for the most recent unconsumed accumulate snapshot in the inbox
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
const accumulateSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === 'accumulate',
|
||||
);
|
||||
|
||||
if (accumulateSnapshots.length > 0) {
|
||||
// Sort to find the most recent
|
||||
const latest = [...accumulateSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
)[0];
|
||||
|
||||
// Consume the old draft so the inbox doesn't fill up with stale drafts
|
||||
inbox.consume(latest.id);
|
||||
// And we must persist its consumption back to the live inbox immediately,
|
||||
// because we are effectively "taking" it from the shelf to modify.
|
||||
env.inbox.drainConsumed(new Set([latest.id]));
|
||||
|
||||
previousConsumedIds = latest.payload.consumedIds;
|
||||
|
||||
// Prepend a synthetic node representing the previous rolling state
|
||||
const previousStateNode: ConcreteNode = {
|
||||
id: env.idGenerator.generateId(),
|
||||
logicalParentId: '',
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: latest.timestamp,
|
||||
text: latest.payload.newText,
|
||||
};
|
||||
|
||||
nodesToSummarize = [previousStateNode, ...targets];
|
||||
}
|
||||
}
|
||||
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
|
||||
if (accumulateSnapshots.length > 0) {
|
||||
// Sort to find the most recent
|
||||
const latest = [...accumulateSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
)[0];
|
||||
const newConsumedIds = [
|
||||
...previousConsumedIds,
|
||||
...targets.map((t) => t.id),
|
||||
];
|
||||
|
||||
// Consume the old draft so the inbox doesn't fill up with stale drafts
|
||||
inbox.consume(latest.id);
|
||||
// And we must persist its consumption back to the live inbox immediately,
|
||||
// because we are effectively "taking" it from the shelf to modify.
|
||||
env.inbox.drainConsumed(new Set([latest.id]));
|
||||
|
||||
previousConsumedIds = latest.payload.consumedIds;
|
||||
|
||||
// Prepend a synthetic node representing the previous rolling state
|
||||
const previousStateNode: ConcreteNode = {
|
||||
id: env.idGenerator.generateId(),
|
||||
logicalParentId: '',
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: latest.timestamp,
|
||||
text: latest.payload.newText,
|
||||
};
|
||||
|
||||
nodesToSummarize = [previousStateNode, ...targets];
|
||||
}
|
||||
// In V2, async pipelines communicate their work to the inbox, and the processor picks it up.
|
||||
env.inbox.publish(
|
||||
'PROPOSED_SNAPSHOT',
|
||||
{
|
||||
newText: snapshotText,
|
||||
consumedIds: newConsumedIds,
|
||||
type: processorType,
|
||||
},
|
||||
env.idGenerator,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
'StateSnapshotAsyncProcessor failed to generate snapshot',
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
|
||||
const newConsumedIds = [
|
||||
...previousConsumedIds,
|
||||
...targets.map((t) => t.id),
|
||||
];
|
||||
|
||||
// In V2, async pipelines communicate their work to the inbox, and the processor picks it up.
|
||||
env.inbox.publish(
|
||||
'PROPOSED_SNAPSHOT',
|
||||
{
|
||||
newText: snapshotText,
|
||||
consumedIds: newConsumedIds,
|
||||
type: processorType,
|
||||
},
|
||||
env.idGenerator,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error('StateSnapshotAsyncProcessor failed to generate snapshot', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@ import type { InboxSnapshotImpl } from '../pipeline/inbox.js';
|
||||
describe('StateSnapshotProcessor', () => {
|
||||
it('should ignore if budget is satisfied', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
const processor = createStateSnapshotProcessor(
|
||||
'StateSnapshotProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'incremental',
|
||||
},
|
||||
);
|
||||
const targets = [createDummyNode('ep1', 'USER_PROMPT')];
|
||||
const result = await processor.process(createMockProcessArgs(targets));
|
||||
expect(result).toBe(targets); // Strict equality
|
||||
@@ -25,9 +29,13 @@ describe('StateSnapshotProcessor', () => {
|
||||
|
||||
it('should apply a valid snapshot from the Inbox (Fast Path)', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
const processor = createStateSnapshotProcessor(
|
||||
'StateSnapshotProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'incremental',
|
||||
},
|
||||
);
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
@@ -65,9 +73,13 @@ describe('StateSnapshotProcessor', () => {
|
||||
|
||||
it('should reject a snapshot if the nodes were modified/deleted (Cache Invalidated)', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
|
||||
target: 'incremental',
|
||||
});
|
||||
const processor = createStateSnapshotProcessor(
|
||||
'StateSnapshotProcessor',
|
||||
env,
|
||||
{
|
||||
target: 'incremental',
|
||||
},
|
||||
);
|
||||
// Make deficit 0 so we don't fall through to the sync backstop and fail the test that way
|
||||
|
||||
// node-A is MISSING (user deleted it)
|
||||
@@ -99,7 +111,11 @@ describe('StateSnapshotProcessor', () => {
|
||||
|
||||
it('should fall back to sync backstop if inbox is empty', async () => {
|
||||
const env = createMockEnvironment();
|
||||
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, { target: 'max' }); // Summarize all
|
||||
const processor = createStateSnapshotProcessor(
|
||||
'StateSnapshotProcessor',
|
||||
env,
|
||||
{ target: 'max' },
|
||||
); // Summarize all
|
||||
|
||||
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
|
||||
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
|
||||
|
||||
@@ -28,141 +28,140 @@ export function createStateSnapshotProcessor(
|
||||
return {
|
||||
id,
|
||||
name: 'StateSnapshotProcessor',
|
||||
process: async ({
|
||||
targets,
|
||||
inbox,
|
||||
}: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
process: async ({ targets, inbox }: ProcessArgs) => {
|
||||
if (targets.length === 0) {
|
||||
return targets;
|
||||
}
|
||||
|
||||
// Determine what mode we are looking for: 'incremental' -> 'point-in-time', 'max' -> 'accumulate'
|
||||
const strategy = options.target ?? 'max';
|
||||
const expectedType =
|
||||
strategy === 'incremental' ? 'point-in-time' : 'accumulate';
|
||||
// Determine what mode we are looking for: 'incremental' -> 'point-in-time', 'max' -> 'accumulate'
|
||||
const strategy = options.target ?? 'max';
|
||||
const expectedType =
|
||||
strategy === 'incremental' ? 'point-in-time' : 'accumulate';
|
||||
|
||||
// 1. Check Inbox for a completed Snapshot (The Fast Path)
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
// 1. Check Inbox for a completed Snapshot (The Fast Path)
|
||||
const proposedSnapshots = inbox.getMessages<{
|
||||
newText: string;
|
||||
consumedIds: string[];
|
||||
type: string;
|
||||
}>('PROPOSED_SNAPSHOT');
|
||||
|
||||
if (proposedSnapshots.length > 0) {
|
||||
// Filter for the snapshot type that matches our processor mode
|
||||
const matchingSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === expectedType,
|
||||
);
|
||||
if (proposedSnapshots.length > 0) {
|
||||
// Filter for the snapshot type that matches our processor mode
|
||||
const matchingSnapshots = proposedSnapshots.filter(
|
||||
(s) => s.payload.type === expectedType,
|
||||
);
|
||||
|
||||
// Sort by newest timestamp first (we want the most accumulated snapshot)
|
||||
const sorted = [...matchingSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
);
|
||||
// Sort by newest timestamp first (we want the most accumulated snapshot)
|
||||
const sorted = [...matchingSnapshots].sort(
|
||||
(a, b) => b.timestamp - a.timestamp,
|
||||
);
|
||||
|
||||
for (const proposed of sorted) {
|
||||
const { consumedIds, newText } = proposed.payload;
|
||||
for (const proposed of sorted) {
|
||||
const { consumedIds, newText } = proposed.payload;
|
||||
|
||||
// Verify all consumed IDs still exist sequentially in targets
|
||||
const targetIds = new Set(targets.map((t) => t.id));
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
// Verify all consumed IDs still exist sequentially in targets
|
||||
const targetIds = new Set(targets.map((t) => t.id));
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
|
||||
if (isValid) {
|
||||
// If valid, apply it!
|
||||
const newId = env.idGenerator.generateId();
|
||||
if (isValid) {
|
||||
// If valid, apply it!
|
||||
const newId = env.idGenerator.generateId();
|
||||
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: newText,
|
||||
abstractsIds: consumedIds,
|
||||
};
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: newText,
|
||||
abstractsIds: consumedIds,
|
||||
};
|
||||
|
||||
// Remove the consumed nodes and insert the snapshot at the earliest index
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
// Remove the consumed nodes and insert the snapshot at the earliest index
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
}
|
||||
|
||||
inbox.consume(proposed.id);
|
||||
return returnedNodes;
|
||||
}
|
||||
|
||||
inbox.consume(proposed.id);
|
||||
return returnedNodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The Synchronous Backstop (The Slow Path)
|
||||
let targetTokensToRemove = 0;
|
||||
// 2. The Synchronous Backstop (The Slow Path)
|
||||
let targetTokensToRemove = 0;
|
||||
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity; // incremental implies removing as much as possible if no state is passed
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
// Scan oldest to newest
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
// In a real system, system prompt is protected, but we double check
|
||||
continue;
|
||||
if (strategy === 'incremental') {
|
||||
targetTokensToRemove = Infinity; // incremental implies removing as much as possible if no state is passed
|
||||
} else if (strategy === 'freeNTokens') {
|
||||
targetTokensToRemove = options.freeTokensTarget ?? Infinity;
|
||||
} else if (strategy === 'max') {
|
||||
targetTokensToRemove = Infinity;
|
||||
}
|
||||
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += env.tokenCalculator.getTokenCost(node);
|
||||
let deficitAccumulator = 0;
|
||||
const nodesToSummarize: ConcreteNode[] = [];
|
||||
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
// Scan oldest to newest
|
||||
for (const node of targets) {
|
||||
if (node.id === targets[0].id && node.type === 'USER_PROMPT') {
|
||||
// Keep system prompt if it's the very first node
|
||||
// In a real system, system prompt is protected, but we double check
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context
|
||||
nodesToSummarize.push(node);
|
||||
deficitAccumulator += env.tokenCalculator.getTokenCost(node);
|
||||
|
||||
try {
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
const newId = env.idGenerator.generateId();
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
abstractsIds: nodesToSummarize.map((n) => n.id),
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter((t) => !consumedIds.includes(t.id));
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
if (deficitAccumulator >= targetTokensToRemove) break;
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('StateSnapshotProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
if (nodesToSummarize.length < 2) return targets; // Not enough context
|
||||
|
||||
try {
|
||||
const snapshotText = await generator.synthesizeSnapshot(
|
||||
nodesToSummarize,
|
||||
options.systemInstruction,
|
||||
);
|
||||
const newId = env.idGenerator.generateId();
|
||||
const snapshotNode: Snapshot = {
|
||||
id: newId,
|
||||
logicalParentId: newId,
|
||||
type: 'SNAPSHOT',
|
||||
timestamp: Date.now(),
|
||||
text: snapshotText,
|
||||
abstractsIds: nodesToSummarize.map((n) => n.id),
|
||||
};
|
||||
|
||||
const consumedIds = nodesToSummarize.map((n) => n.id);
|
||||
const returnedNodes = targets.filter(
|
||||
(t) => !consumedIds.includes(t.id),
|
||||
);
|
||||
const firstRemovedIdx = targets.findIndex((t) =>
|
||||
consumedIds.includes(t.id),
|
||||
);
|
||||
|
||||
if (firstRemovedIdx !== -1) {
|
||||
const idx = Math.max(0, firstRemovedIdx);
|
||||
returnedNodes.splice(idx, 0, snapshotNode);
|
||||
} else {
|
||||
returnedNodes.unshift(snapshotNode);
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
} catch (e) {
|
||||
debugLogger.error('StateSnapshotProcessor failed sync backstop', e);
|
||||
return targets;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,194 +68,194 @@ export function createToolMaskingProcessor(
|
||||
env: ContextEnvironment,
|
||||
options: ToolMaskingProcessorOptions,
|
||||
): ContextProcessor {
|
||||
const isAlreadyMasked = (text: string): boolean => text.includes('<tool_output_masked>');
|
||||
const isAlreadyMasked = (text: string): boolean =>
|
||||
text.includes('<tool_output_masked>');
|
||||
|
||||
return {
|
||||
id,
|
||||
name: 'ToolMaskingProcessor',
|
||||
process: async ({ targets }: ProcessArgs) => {
|
||||
const maskingConfig = options;
|
||||
if (!maskingConfig) return targets;
|
||||
if (targets.length === 0) return targets;
|
||||
const maskingConfig = options;
|
||||
if (!maskingConfig) return targets;
|
||||
if (targets.length === 0) return targets;
|
||||
|
||||
const limitChars = env.tokenCalculator.tokensToChars(
|
||||
maskingConfig.stringLengthThresholdTokens,
|
||||
);
|
||||
|
||||
let toolOutputsDir = env.fileSystem.join(
|
||||
env.projectTempDir,
|
||||
'tool-outputs',
|
||||
);
|
||||
const sessionId = env.sessionId;
|
||||
if (sessionId) {
|
||||
toolOutputsDir = env.fileSystem.join(
|
||||
toolOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
const limitChars = env.tokenCalculator.tokensToChars(
|
||||
maskingConfig.stringLengthThresholdTokens,
|
||||
);
|
||||
}
|
||||
|
||||
let directoryCreated = false;
|
||||
|
||||
const handleMasking = async (
|
||||
content: string,
|
||||
toolName: string,
|
||||
callId: string,
|
||||
nodeType: string,
|
||||
): Promise<string> => {
|
||||
if (!directoryCreated) {
|
||||
await env.fileSystem.mkdir(toolOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
let toolOutputsDir = env.fileSystem.join(
|
||||
env.projectTempDir,
|
||||
'tool-outputs',
|
||||
);
|
||||
const sessionId = env.sessionId;
|
||||
if (sessionId) {
|
||||
toolOutputsDir = env.fileSystem.join(
|
||||
toolOutputsDir,
|
||||
`session-${sanitizeFilenamePart(sessionId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${env.idGenerator.generateId()}.txt`;
|
||||
const filePath = env.fileSystem.join(toolOutputsDir, fileName);
|
||||
let directoryCreated = false;
|
||||
|
||||
await env.fileSystem.writeFile(filePath, content);
|
||||
const handleMasking = async (
|
||||
content: string,
|
||||
toolName: string,
|
||||
callId: string,
|
||||
nodeType: string,
|
||||
): Promise<string> => {
|
||||
if (!directoryCreated) {
|
||||
await env.fileSystem.mkdir(toolOutputsDir, { recursive: true });
|
||||
directoryCreated = true;
|
||||
}
|
||||
|
||||
const fileSizeMB = (
|
||||
Buffer.byteLength(content, 'utf8') /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2);
|
||||
const totalLines = content.split('\n').length;
|
||||
return `<tool_output_masked>\n[Tool ${nodeType} string (${fileSizeMB}MB, ${totalLines} lines) masked to preserve context window. Full string saved to: ${filePath}]\n</tool_output_masked>`;
|
||||
};
|
||||
const fileName = `${sanitizeFilenamePart(toolName).toLowerCase()}_${sanitizeFilenamePart(callId).toLowerCase()}_${nodeType}_${env.idGenerator.generateId()}.txt`;
|
||||
const filePath = env.fileSystem.join(toolOutputsDir, fileName);
|
||||
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
await env.fileSystem.writeFile(filePath, content);
|
||||
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'TOOL_EXECUTION': {
|
||||
const toolName = node.toolName;
|
||||
if (toolName && UNMASKABLE_TOOLS.has(toolName)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
const fileSizeMB = (
|
||||
Buffer.byteLength(content, 'utf8') /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2);
|
||||
const totalLines = content.split('\n').length;
|
||||
return `<tool_output_masked>\n[Tool ${nodeType} string (${fileSizeMB}MB, ${totalLines} lines) masked to preserve context window. Full string saved to: ${filePath}]\n</tool_output_masked>`;
|
||||
};
|
||||
|
||||
const callId = node.id || Date.now().toString();
|
||||
const returnedNodes: ConcreteNode[] = [];
|
||||
|
||||
const maskAsync = async (
|
||||
obj: MaskableValue,
|
||||
nodeType: string,
|
||||
): Promise<{ masked: MaskableValue; changed: boolean }> => {
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.length > limitChars && !isAlreadyMasked(obj)) {
|
||||
const newString = await handleMasking(
|
||||
obj,
|
||||
toolName || 'unknown',
|
||||
callId,
|
||||
nodeType,
|
||||
);
|
||||
return { masked: newString, changed: true };
|
||||
for (const node of targets) {
|
||||
switch (node.type) {
|
||||
case 'TOOL_EXECUTION': {
|
||||
const toolName = node.toolName;
|
||||
if (toolName && UNMASKABLE_TOOLS.has(toolName)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
|
||||
const callId = node.id || Date.now().toString();
|
||||
|
||||
const maskAsync = async (
|
||||
obj: MaskableValue,
|
||||
nodeType: string,
|
||||
): Promise<{ masked: MaskableValue; changed: boolean }> => {
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.length > limitChars && !isAlreadyMasked(obj)) {
|
||||
const newString = await handleMasking(
|
||||
obj,
|
||||
toolName || 'unknown',
|
||||
callId,
|
||||
nodeType,
|
||||
);
|
||||
return { masked: newString, changed: true };
|
||||
}
|
||||
return { masked: obj, changed: false };
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
let changed = false;
|
||||
const masked: MaskableValue[] = [];
|
||||
for (const item of obj) {
|
||||
const res = await maskAsync(item, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked.push(res.masked);
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
let changed = false;
|
||||
const masked: Record<string, MaskableValue> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const res = await maskAsync(value, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked[key] = res.masked;
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
return { masked: obj, changed: false };
|
||||
};
|
||||
|
||||
const rawIntent = node.intent;
|
||||
const rawObs = node.observation;
|
||||
|
||||
if (!isMaskableRecord(rawIntent) || !isMaskableValue(rawObs)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
let changed = false;
|
||||
const masked: MaskableValue[] = [];
|
||||
for (const item of obj) {
|
||||
const res = await maskAsync(item, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked.push(res.masked);
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
let changed = false;
|
||||
const masked: Record<string, MaskableValue> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const res = await maskAsync(value, nodeType);
|
||||
if (res.changed) changed = true;
|
||||
masked[key] = res.masked;
|
||||
}
|
||||
return { masked, changed };
|
||||
}
|
||||
return { masked: obj, changed: false };
|
||||
};
|
||||
|
||||
const rawIntent = node.intent;
|
||||
const rawObs = node.observation;
|
||||
const intentRes = await maskAsync(rawIntent, 'intent');
|
||||
const obsRes = await maskAsync(rawObs, 'observation');
|
||||
|
||||
if (!isMaskableRecord(rawIntent) || !isMaskableValue(rawObs)) {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
if (intentRes.changed || obsRes.changed) {
|
||||
const maskedIntent = isMaskableRecord(intentRes.masked)
|
||||
? (intentRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
// Handle observation explicitly as string vs object
|
||||
const maskedObs =
|
||||
typeof obsRes.masked === 'string'
|
||||
? ({ message: obsRes.masked } as Record<string, unknown>)
|
||||
: isMaskableRecord(obsRes.masked)
|
||||
? (obsRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const intentRes = await maskAsync(rawIntent, 'intent');
|
||||
const obsRes = await maskAsync(rawObs, 'observation');
|
||||
const newIntentTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
functionCall: {
|
||||
name: toolName || 'unknown',
|
||||
args: maskedIntent,
|
||||
id: callId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
if (intentRes.changed || obsRes.changed) {
|
||||
const maskedIntent = isMaskableRecord(intentRes.masked)
|
||||
? (intentRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
// Handle observation explicitly as string vs object
|
||||
const maskedObs =
|
||||
typeof obsRes.masked === 'string'
|
||||
? ({ message: obsRes.masked } as Record<string, unknown>)
|
||||
: isMaskableRecord(obsRes.masked)
|
||||
? (obsRes.masked as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const newIntentTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
{
|
||||
functionCall: {
|
||||
let obsPart: Record<string, unknown> = {};
|
||||
if (maskedObs) {
|
||||
obsPart = {
|
||||
functionResponse: {
|
||||
name: toolName || 'unknown',
|
||||
args: maskedIntent,
|
||||
response: maskedObs,
|
||||
id: callId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
}
|
||||
|
||||
let obsPart: Record<string, unknown> = {};
|
||||
if (maskedObs) {
|
||||
obsPart = {
|
||||
functionResponse: {
|
||||
name: toolName || 'unknown',
|
||||
response: maskedObs,
|
||||
id: callId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const newObsTokens =
|
||||
env.tokenCalculator.estimateTokensForParts([
|
||||
const newObsTokens = env.tokenCalculator.estimateTokensForParts([
|
||||
obsPart as Part,
|
||||
]);
|
||||
|
||||
const tokensSaved =
|
||||
env.tokenCalculator.getTokenCost(node) -
|
||||
(newIntentTokens + newObsTokens);
|
||||
const tokensSaved =
|
||||
env.tokenCalculator.getTokenCost(node) -
|
||||
(newIntentTokens + newObsTokens);
|
||||
|
||||
if (tokensSaved > 0) {
|
||||
const maskedNode: ToolExecution = {
|
||||
...node,
|
||||
id: env.idGenerator.generateId(), // Modified, so generate new ID
|
||||
intent: maskedIntent ?? node.intent,
|
||||
observation: maskedObs ?? node.observation,
|
||||
tokens: {
|
||||
intent: newIntentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
replacesId: node.id,
|
||||
};
|
||||
if (tokensSaved > 0) {
|
||||
const maskedNode: ToolExecution = {
|
||||
...node,
|
||||
id: env.idGenerator.generateId(), // Modified, so generate new ID
|
||||
intent: maskedIntent ?? node.intent,
|
||||
observation: maskedObs ?? node.observation,
|
||||
tokens: {
|
||||
intent: newIntentTokens,
|
||||
observation: newObsTokens,
|
||||
},
|
||||
replacesId: node.id,
|
||||
};
|
||||
|
||||
returnedNodes.push(maskedNode);
|
||||
returnedNodes.push(maskedNode);
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
}
|
||||
} else {
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
returnedNodes.push(node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return returnedNodes;
|
||||
}
|
||||
return returnedNodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,7 +113,10 @@ export class SimulationHarness {
|
||||
let currentView = this.contextManager.getNodes();
|
||||
const currentTokens =
|
||||
this.env.tokenCalculator.calculateConcreteListTokens(currentView);
|
||||
if (this.config.config.budget && currentTokens > this.config.config.budget.maxTokens) {
|
||||
if (
|
||||
this.config.config.budget &&
|
||||
currentTokens > this.config.config.budget.maxTokens
|
||||
) {
|
||||
debugLogger.log(
|
||||
`[Turn ${this.currentTurnIndex}] Sync panic triggered! ${currentTokens} > ${this.config.config.budget.maxTokens}`,
|
||||
);
|
||||
|
||||
@@ -46,7 +46,9 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createBlobDegradationProcessor('BlobDegradationProcessor', env),
|
||||
createToolMaskingProcessor('ToolMaskingProcessor', env, { stringLengthThresholdTokens: 50 }),
|
||||
createToolMaskingProcessor('ToolMaskingProcessor', env, {
|
||||
stringLengthThresholdTokens: 50,
|
||||
}),
|
||||
createStateSnapshotProcessor('StateSnapshotProcessor', env, {}),
|
||||
],
|
||||
},
|
||||
@@ -54,15 +56,27 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
name: 'Immediate Sanitization', // The magic string the projector is hardcoded to use
|
||||
triggers: ['retained_exceeded'],
|
||||
processors: [
|
||||
createHistoryTruncationProcessor('HistoryTruncationProcessor', env, {}),
|
||||
createHistoryTruncationProcessor(
|
||||
'HistoryTruncationProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildAsyncPipelines: (env) => [
|
||||
{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
buildAsyncPipelines: (env) => [{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [createStateSnapshotAsyncProcessor('StateSnapshotAsyncProcessor', env, {})]
|
||||
}],
|
||||
});
|
||||
|
||||
const mockLlmClient = createMockLlmClient([
|
||||
@@ -186,11 +200,19 @@ describe('System Lifecycle Golden Tests', () => {
|
||||
budget: { maxTokens: 200, retainedTokens: 100 },
|
||||
},
|
||||
buildPipelines: () => [],
|
||||
buildAsyncPipelines: (env) => [{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [createStateSnapshotAsyncProcessor('StateSnapshotAsyncProcessor', env, {})]
|
||||
}],
|
||||
buildAsyncPipelines: (env) => [
|
||||
{
|
||||
name: 'Async',
|
||||
triggers: ['nodes_aged_out'],
|
||||
processors: [
|
||||
createStateSnapshotAsyncProcessor(
|
||||
'StateSnapshotAsyncProcessor',
|
||||
env,
|
||||
{},
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const harness = await SimulationHarness.create(gcConfig, mockLlmClient);
|
||||
|
||||
@@ -96,7 +96,6 @@ export function createDummyToolNode(
|
||||
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
|
||||
export interface MockLlmClient extends BaseLlmClient {
|
||||
generateContent: Mock;
|
||||
}
|
||||
@@ -231,7 +230,7 @@ export function createMockContextConfig(
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getExperimentalContextSidecarConfig: vi.fn().mockReturnValue(undefined),
|
||||
getExperimentalContextManagementConfig: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
Reference in New Issue
Block a user