Skip to content

Commit 7bfda31

Browse files
committed
feat(core): union-find context compaction for AgentHistoryProvider
Add union-find clustering as an alternative compression strategy, enabled via COMPRESSION_STRATEGY experiment flag ('union-find'). Messages graduate from a hot buffer into a cold forest where semantically similar messages merge into equivalence classes. Cluster summaries replace raw messages while preserving provenance through parent pointers. New files: - contextWindow.ts: Forest (union-find with path compression) + ContextWindow (hot/cold partitioning with overlap window) - embeddingService.ts: TF-IDF embedder (no external model) - clusterSummarizer.ts: async LLM cluster summarization Integration: - chatCompressionService.ts: branches on experiment flag - config.ts: async getCompressionStrategy() with ensureExperimentsLoaded - agentHistoryProvider.ts: prompt injection sanitization - toolDistillationService.ts: AbortSignal propagation, 64K distillation cap 80 new tests across contextWindow, embeddingService, clusterSummarizer, agentHistoryProvider, and toolDistillationService. Experiment: 7 pre-registered trials, UF leads flat by 8-18pp on factual recall at high compression pressure (p=0.039 in trial 2). See github.com/kimjune01/union-find-compaction Resolves #22877
1 parent a6d43cb commit 7bfda31

12 files changed

Lines changed: 2208 additions & 16 deletions

packages/core/src/code_assist/experiments/flagNames.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const ExperimentFlags = {
2020
PRO_MODEL_NO_ACCESS: 45768879,
2121
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
2222
DEFAULT_REQUEST_TIMEOUT: 45773134,
23+
COMPRESSION_STRATEGY: 45768880,
2324
} as const;
2425

2526
export type ExperimentFlagName =

packages/core/src/config/config.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2350,9 +2350,8 @@ export class Config implements McpContext, AgentLoopContext {
23502350
if (this.experimentalJitContext && this.memoryContextManager) {
23512351
await this.memoryContextManager.refresh();
23522352
} else {
2353-
const { refreshServerHierarchicalMemory } = await import(
2354-
'../utils/memoryDiscovery.js'
2355-
);
2353+
const { refreshServerHierarchicalMemory } =
2354+
await import('../utils/memoryDiscovery.js');
23562355
await refreshServerHierarchicalMemory(this);
23572356
}
23582357
if (this._geminiClient?.isInitialized()) {
@@ -3025,6 +3024,17 @@ export class Config implements McpContext, AgentLoopContext {
30253024
return remoteThreshold;
30263025
}
30273026

3027+
async getCompressionStrategy(): Promise<string> {
3028+
await this.ensureExperimentsLoaded();
3029+
const remoteStrategy =
3030+
this.experiments?.flags[ExperimentFlags.COMPRESSION_STRATEGY]
3031+
?.stringValue;
3032+
if (remoteStrategy === 'union-find' || remoteStrategy === 'flat') {
3033+
return remoteStrategy;
3034+
}
3035+
return 'flat';
3036+
}
3037+
30283038
async getUserCaching(): Promise<boolean | undefined> {
30293039
await this.ensureExperimentsLoaded();
30303040

packages/core/src/context/agentHistoryProvider.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ import {
2020
normalizeFunctionResponse,
2121
} from './truncation.js';
2222

23+
function sanitizePromptInput(value: unknown): string {
24+
return (JSON.stringify(value) ?? '')
25+
.replace(/\\[rn]/g, ' ')
26+
.replace(/[\r\n\u2028\u2029]+/g, ' ')
27+
.replace(/```/g, "'''")
28+
.replace(/[<>]/g, (char) => (char === '<' ? '&lt;' : '&gt;'))
29+
.replace(/[\x00-\x1f\x7f]/g, ''); // eslint-disable-line no-control-regex
30+
}
31+
2332
export class AgentHistoryProvider {
2433
// TODO(joshualitt): just pass the BaseLlmClient instead of the whole Config.
2534
constructor(
@@ -379,10 +388,10 @@ Distill these into a high-density Markdown block that orientates the agent on th
379388
- **Brevity:** Maximum 15 lines. No conversational preamble.
380389
381390
${hasPreviousSummary ? 'PREVIOUS SUMMARY AND TRUNCATED HISTORY:' : 'TRUNCATED HISTORY:'}
382-
${JSON.stringify(messagesToTruncate)}
391+
${sanitizePromptInput(messagesToTruncate)}
383392
384393
ACTIVE BRIDGE (LOOKAHEAD):
385-
${JSON.stringify(bridge)}`;
394+
${sanitizePromptInput(bridge)}`;
386395

387396
const summaryResponse = await this.config
388397
.getBaseLlmClient()

packages/core/src/context/chatCompressionService.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ describe('ChatCompressionService', () => {
193193
getProjectTempDir: vi.fn().mockReturnValue(testTempDir),
194194
},
195195
getApprovedPlanPath: vi.fn().mockReturnValue('/path/to/plan.md'),
196+
getCompressionStrategy: vi.fn().mockReturnValue('flat'),
196197
} as unknown as Config;
197198

198199
vi.mocked(getInitialChatHistory).mockImplementation(
@@ -897,4 +898,144 @@ describe('ChatCompressionService', () => {
897898
);
898899
});
899900
});
901+
902+
describe('Compression strategy dispatch', () => {
903+
it('should route to flat compression when strategy is flat', async () => {
904+
vi.mocked(
905+
mockConfig as unknown as { getCompressionStrategy: () => string },
906+
).getCompressionStrategy = vi.fn().mockReturnValue('flat');
907+
908+
const history: Content[] = [
909+
{ role: 'user', parts: [{ text: 'msg1' }] },
910+
{ role: 'model', parts: [{ text: 'msg2' }] },
911+
{ role: 'user', parts: [{ text: 'msg3' }] },
912+
{ role: 'model', parts: [{ text: 'msg4' }] },
913+
];
914+
vi.mocked(mockChat.getHistory).mockReturnValue(history);
915+
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
916+
917+
const result = await service.compress(
918+
mockChat,
919+
mockPromptId,
920+
false,
921+
mockModel,
922+
mockConfig,
923+
false,
924+
);
925+
926+
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
927+
// Flat uses 2 LLM calls (generate + verify)
928+
expect(
929+
mockConfig.getBaseLlmClient().generateContent,
930+
).toHaveBeenCalledTimes(2);
931+
});
932+
933+
it('should route to union-find compression when strategy is union-find', async () => {
934+
vi.mocked(
935+
mockConfig as unknown as { getCompressionStrategy: () => string },
936+
).getCompressionStrategy = vi.fn().mockReturnValue('union-find');
937+
938+
// Mock for cluster summarization
939+
const mockLlmClient = {
940+
generateContent: vi.fn().mockResolvedValue({
941+
candidates: [
942+
{
943+
content: {
944+
parts: [{ text: 'Cluster summary' }],
945+
},
946+
},
947+
],
948+
} as unknown as GenerateContentResponse),
949+
};
950+
vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue(
951+
mockLlmClient as unknown as BaseLlmClient,
952+
);
953+
954+
// Need enough messages to trigger graduation (> UNION_FIND_HOT_SIZE = 30)
955+
const history: Content[] = [];
956+
for (let i = 0; i < 35; i++) {
957+
history.push({
958+
role: i % 2 === 0 ? 'user' : 'model',
959+
parts: [{ text: `message ${i}` }],
960+
});
961+
}
962+
vi.mocked(mockChat.getHistory).mockReturnValue(history);
963+
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
964+
965+
const result = await service.compress(
966+
mockChat,
967+
mockPromptId,
968+
false,
969+
mockModel,
970+
mockConfig,
971+
false,
972+
);
973+
974+
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
975+
expect(result.newHistory).not.toBeNull();
976+
});
977+
978+
it('should return valid ChatCompressionInfo for union-find path', async () => {
979+
vi.mocked(
980+
mockConfig as unknown as { getCompressionStrategy: () => string },
981+
).getCompressionStrategy = vi.fn().mockReturnValue('union-find');
982+
983+
const mockLlmClient = {
984+
generateContent: vi.fn().mockResolvedValue({
985+
candidates: [
986+
{
987+
content: {
988+
parts: [{ text: 'Summary' }],
989+
},
990+
},
991+
],
992+
} as unknown as GenerateContentResponse),
993+
};
994+
vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue(
995+
mockLlmClient as unknown as BaseLlmClient,
996+
);
997+
998+
const history: Content[] = [];
999+
for (let i = 0; i < 35; i++) {
1000+
history.push({
1001+
role: i % 2 === 0 ? 'user' : 'model',
1002+
parts: [{ text: `msg ${i}` }],
1003+
});
1004+
}
1005+
vi.mocked(mockChat.getHistory).mockReturnValue(history);
1006+
vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000);
1007+
1008+
const result = await service.compress(
1009+
mockChat,
1010+
mockPromptId,
1011+
false,
1012+
mockModel,
1013+
mockConfig,
1014+
false,
1015+
);
1016+
1017+
expect(result.info.originalTokenCount).toBe(600000);
1018+
expect(result.info.newTokenCount).toBeDefined();
1019+
expect(typeof result.info.newTokenCount).toBe('number');
1020+
});
1021+
1022+
it('should return NOOP for union-find with empty history', async () => {
1023+
vi.mocked(
1024+
mockConfig as unknown as { getCompressionStrategy: () => string },
1025+
).getCompressionStrategy = vi.fn().mockReturnValue('union-find');
1026+
vi.mocked(mockChat.getHistory).mockReturnValue([]);
1027+
1028+
const result = await service.compress(
1029+
mockChat,
1030+
mockPromptId,
1031+
false,
1032+
mockModel,
1033+
mockConfig,
1034+
false,
1035+
);
1036+
1037+
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
1038+
expect(result.newHistory).toBeNull();
1039+
});
1040+
});
9001041
});

0 commit comments

Comments
 (0)