ds4-context-engine 0.3.0-alpha.3 → 0.3.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,14 @@ import {
14
14
  highestClassification,
15
15
  type PrivacyClassification,
16
16
  } from "ds4-context-core/privacy/privacy-policy";
17
- import { prepareCompactionSource } from "./compaction-adapter.ts";
17
+ import {
18
+ prepareCompactionSource,
19
+ sliceCompactionSource,
20
+ type PreparedCompactionSource,
21
+ type PreparedCompactionSourceSlice,
22
+ } from "./compaction-adapter.ts";
23
+ import { buildCompactionAtomicGroups } from "ds4-context-core/compaction/segmentation";
24
+ import { estimateMessageTokens } from "ds4-context-core/core/token-estimator";
18
25
  import { adaptiveRecentTailLimit } from "ds4-context-core/planner/context-planner";
19
26
  import type { Logger } from "ds4-context-core/shared/logging";
20
27
  import {
@@ -42,6 +49,10 @@ import {
42
49
  type SummaryValidationStatus,
43
50
  } from "ds4-context-core/compaction/summary-contract";
44
51
 
52
+ const MAX_SEGMENT_REQUESTS = 32;
53
+ const MAX_AGGREGATE_REQUESTS = 64;
54
+ const MAX_AGGREGATE_LEVELS = 16;
55
+
45
56
  export type CompactionPhase =
46
57
  | "idle"
47
58
  | "requested"
@@ -66,6 +77,10 @@ export interface CompactionDiagnostics {
66
77
  requestedAt?: number;
67
78
  completedAt?: number;
68
79
  lastError?: string;
80
+ inputBudgetTokens?: number;
81
+ sourcePromptTokens?: number;
82
+ segmentCount?: number;
83
+ aggregateCalls?: number;
69
84
  contextTokens?: number;
70
85
  softLimitTokens?: number;
71
86
  proactiveThresholdTokens?: number;
@@ -108,6 +123,26 @@ export interface SessionCompactFailedLike {
108
123
  fromExtension: boolean;
109
124
  }
110
125
 
126
+ interface SegmentGenerationPlan {
127
+ source: PreparedCompactionSourceSlice;
128
+ prompt: string;
129
+ validationSource: string;
130
+ readFiles: string[];
131
+ modifiedFiles: string[];
132
+ classification: PrivacyClassification;
133
+ promptTokens: number;
134
+ }
135
+
136
+ interface AggregateGenerationPlan {
137
+ children: EmbeddedSummaryNode[];
138
+ prompt: string;
139
+ validationSource: string;
140
+ readFiles: string[];
141
+ modifiedFiles: string[];
142
+ classification: PrivacyClassification;
143
+ promptTokens: number;
144
+ }
145
+
111
146
  interface CompactionCoordinatorDependencies {
112
147
  config: Ds4ContextConfig;
113
148
  database?: ContextDatabase;
@@ -176,7 +211,8 @@ export class CompactionCoordinator {
176
211
  ctx: ExtensionContext,
177
212
  ): Promise<{ compaction?: CompactionResult } | undefined> {
178
213
  const config = this.dependencies.config;
179
- if (!config.compaction.enabled || !config.enabled || !ctx.model || config.context.maxSummaryTokens <= 0) {
214
+ const model = ctx.model;
215
+ if (!config.compaction.enabled || !config.enabled || !model || config.context.maxSummaryTokens <= 0) {
180
216
  return undefined;
181
217
  }
182
218
 
@@ -193,7 +229,24 @@ export class CompactionCoordinator {
193
229
  try {
194
230
  this.dependencies.syncSessionIndex(ctx);
195
231
  const source = prepareCompactionSource(event);
196
- this.state.sourceEntries = source.sourceEntryIds.length;
232
+ const inputBudgetTokens = this.inputBudgetTokens(model);
233
+ if (inputBudgetTokens <= 0) throw new Error("Active model has no safe compaction input budget");
234
+ const wholePlan = this.buildSegmentPlan(source, event, model.provider);
235
+ this.state = {
236
+ ...this.state,
237
+ sourceEntries: source.sourceEntryIds.length,
238
+ inputBudgetTokens,
239
+ sourcePromptTokens: wholePlan.promptTokens,
240
+ };
241
+ const segmentPlans = this.partitionSegmentPlans(
242
+ source,
243
+ wholePlan,
244
+ event,
245
+ model.provider,
246
+ inputBudgetTokens,
247
+ );
248
+ this.state.segmentCount = segmentPlans.length;
249
+
197
250
  const usedIds = new Set(this.graphRecords.keys());
198
251
  if (source.previousNode) usedIds.add(source.previousNode.id);
199
252
  const nextId = (): string => {
@@ -208,129 +261,70 @@ export class CompactionCoordinator {
208
261
  reason: trigger,
209
262
  isSplitTurn: event.preparation.isSplitTurn,
210
263
  messageCount: source.messages.length,
211
- provider: ctx.model.provider,
212
- model: ctx.model.id,
213
- };
214
- const classifyContent = (text: string): { value: string; classification: PrivacyClassification } => {
215
- const classified = this.dependencies.classifyContent?.(text, ctx.model!.provider);
216
- if (classified) return classified;
217
- return {
218
- value: this.dependencies.sanitizeContent?.(text, ctx.model!.provider) ?? text,
219
- classification: "normal",
220
- };
221
- };
222
- const sourcePrivacy = classifyContent(source.conversationText);
223
- const validationPrivacy = classifyContent(source.sourceText);
224
- const instructionPrivacy = event.customInstructions
225
- ? classifyContent(event.customInstructions)
226
- : undefined;
227
- const readFilePrivacy = source.segmentReadFiles.map(classifyContent);
228
- const modifiedFilePrivacy = source.segmentModifiedFiles.map(classifyContent);
229
- const segmentClassification = [
230
- sourcePrivacy.classification,
231
- validationPrivacy.classification,
232
- ...(instructionPrivacy ? [instructionPrivacy.classification] : []),
233
- ...readFilePrivacy.map((item) => item.classification),
234
- ...modifiedFilePrivacy.map((item) => item.classification),
235
- ].reduce(highestClassification, "normal" as PrivacyClassification);
236
- const segmentGenerated = await this.generateSummary({
237
- stage: "segment",
238
- prompt: buildSummaryPrompt({
239
- conversationText: sourcePrivacy.value,
240
- ...(instructionPrivacy ? { customInstructions: instructionPrivacy.value } : {}),
241
- readFiles: readFilePrivacy.map((item) => item.value),
242
- modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
243
- isSplitTurn: event.preparation.isSplitTurn,
244
- }),
245
- validationSource: validationPrivacy.value,
246
- readFiles: readFilePrivacy.map((item) => item.value),
247
- modifiedFiles: modifiedFilePrivacy.map((item) => item.value),
248
- event,
249
- ctx,
250
- });
251
- const segmentId = nextId();
252
- const segmentNode: EmbeddedSummaryNode = {
253
- id: segmentId,
254
- kind: "segment",
255
- content: classifiedSummary(segmentGenerated.content, segmentClassification),
256
- sourceHash: source.sourceHash,
257
- sourceEntryIds: [...source.sourceEntryIds],
258
- childSummaryIds: [],
259
- graphLevel: 0,
260
- createdAt: this.dependencies.now(),
261
- validationStatus: segmentGenerated.validation.status,
262
- validationIssueCodes: unique(segmentGenerated.validation.issues.map((issue) => issue.code)),
263
- provider: ctx.model.provider,
264
- model: ctx.model.id,
264
+ provider: model.provider,
265
+ model: model.id,
265
266
  };
266
267
 
267
- const embeddedNodes: EmbeddedSummaryNode[] = [];
268
- let previousNode = source.previousNode;
269
- if (!previousNode && source.previousSummary) {
270
- previousNode = importUntrackedPreviousSummary({
268
+ const usages: GeneratedSummary["usage"][] = [];
269
+ const segmentNodes: EmbeddedSummaryNode[] = [];
270
+ for (const plan of segmentPlans) {
271
+ const generated = await this.generateSummary({
272
+ stage: "segment",
273
+ prompt: plan.prompt,
274
+ validationSource: plan.validationSource,
275
+ readFiles: plan.readFiles,
276
+ modifiedFiles: plan.modifiedFiles,
277
+ event,
278
+ ctx,
279
+ });
280
+ usages.push(generated.usage);
281
+ segmentNodes.push({
271
282
  id: nextId(),
272
- content: source.previousSummary,
283
+ kind: "segment",
284
+ content: classifiedSummary(generated.content, plan.classification),
285
+ sourceHash: plan.source.sourceHash,
286
+ sourceEntryIds: [...plan.source.sourceEntryIds],
287
+ childSummaryIds: [],
288
+ graphLevel: 0,
273
289
  createdAt: this.dependencies.now(),
274
- provider: ctx.model.provider,
275
- model: ctx.model.id,
290
+ validationStatus: generated.validation.status,
291
+ validationIssueCodes: unique(generated.validation.issues.map((issue) => issue.code)),
292
+ provider: model.provider,
293
+ model: model.id,
276
294
  });
277
- embeddedNodes.push(previousNode);
278
295
  }
296
+ const segmentId = segmentNodes[0]?.id;
297
+ if (!segmentId) throw new Error("Compaction fan-out produced no segment summary node");
279
298
 
280
- const usages = [segmentGenerated.usage];
281
- let activeNode = segmentNode;
282
- if (previousNode) {
283
- const aggregateChildren = [previousNode, segmentNode];
284
- const classifiedChildren = aggregateChildren.map((child) => ({
285
- child,
286
- privacy: classifyContent(child.content),
287
- }));
288
- const promptChildren = classifiedChildren.map(({ child, privacy }) => ({
289
- ...child,
290
- content: privacy.value,
291
- }));
292
- const aggregateReadFiles = source.readFiles.map(classifyContent);
293
- const aggregateModifiedFiles = source.modifiedFiles.map(classifyContent);
294
- const aggregateInstruction = event.customInstructions
295
- ? classifyContent(event.customInstructions)
296
- : undefined;
297
- const aggregateClassification = [
298
- ...classifiedChildren.map((item) => item.privacy.classification),
299
- ...(aggregateInstruction ? [aggregateInstruction.classification] : []),
300
- ...aggregateReadFiles.map((item) => item.classification),
301
- ...aggregateModifiedFiles.map((item) => item.classification),
302
- ].reduce(highestClassification, "normal" as PrivacyClassification);
303
- const aggregateGenerated = await this.generateSummary({
304
- stage: "aggregate",
305
- prompt: buildAggregateSummaryPrompt({
306
- children: promptChildren,
307
- ...(aggregateInstruction ? { customInstructions: aggregateInstruction.value } : {}),
308
- readFiles: aggregateReadFiles.map((item) => item.value),
309
- modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
310
- }),
311
- validationSource: promptChildren.map((child) => child.content).join("\n\n"),
312
- readFiles: aggregateReadFiles.map((item) => item.value),
313
- modifiedFiles: aggregateModifiedFiles.map((item) => item.value),
314
- event,
315
- ctx,
316
- });
317
- usages.push(aggregateGenerated.usage);
318
- activeNode = {
299
+ const createdNodes: EmbeddedSummaryNode[] = [];
300
+ let previousNode = source.previousNode;
301
+ if (!previousNode && source.previousSummary) {
302
+ previousNode = importUntrackedPreviousSummary({
319
303
  id: nextId(),
320
- kind: "aggregate",
321
- content: classifiedSummary(aggregateGenerated.content, aggregateClassification),
322
- sourceHash: computeAggregateSourceHash(aggregateChildren),
323
- sourceEntryIds: unique(aggregateChildren.flatMap((child) => child.sourceEntryIds)),
324
- childSummaryIds: aggregateChildren.map((child) => child.id),
325
- graphLevel: Math.max(...aggregateChildren.map((child) => child.graphLevel)) + 1,
304
+ content: source.previousSummary,
326
305
  createdAt: this.dependencies.now(),
327
- validationStatus: aggregateGenerated.validation.status,
328
- validationIssueCodes: unique(aggregateGenerated.validation.issues.map((issue) => issue.code)),
329
- provider: ctx.model.provider,
330
- model: ctx.model.id,
331
- };
332
- embeddedNodes.push(segmentNode);
306
+ provider: model.provider,
307
+ model: model.id,
308
+ });
309
+ createdNodes.push(previousNode);
333
310
  }
311
+ createdNodes.push(...segmentNodes);
312
+ const roots = [...(previousNode ? [previousNode] : []), ...segmentNodes];
313
+ const aggregated = await this.aggregateSummaryNodes({
314
+ roots,
315
+ event,
316
+ ctx,
317
+ provider: model.provider,
318
+ model: model.id,
319
+ readFiles: source.readFiles,
320
+ modifiedFiles: source.modifiedFiles,
321
+ inputBudgetTokens,
322
+ nextId,
323
+ createdNodes,
324
+ usages,
325
+ });
326
+ const activeNode = aggregated.activeNode;
327
+ const embeddedNodes = createdNodes.filter((node) => node.id !== activeNode.id);
334
328
 
335
329
  const records = embeddedNodes.map((node) => createSummaryRecord({
336
330
  sessionId: this.dependencies.sessionId,
@@ -359,14 +353,22 @@ export class CompactionCoordinator {
359
353
  firstKeptEntryId: event.preparation.firstKeptEntryId,
360
354
  tokensBefore: event.preparation.tokensBefore,
361
355
  requestedAt,
356
+ inputBudgetTokens,
357
+ sourcePromptTokens: wholePlan.promptTokens,
358
+ segmentCount: segmentPlans.length,
359
+ aggregateCalls: aggregated.aggregateCalls,
362
360
  };
363
- this.dependencies.logger.info("compaction.summary_graph_prepared", {
361
+ this.dependencies.logger.debug("compaction.summary_graph_prepared", {
364
362
  activeSummaryId: activeNode.id,
365
363
  segmentSummaryId: segmentId,
366
364
  graphLevel: activeNode.graphLevel,
367
365
  createdNodes: records.length,
368
366
  sourceEntries: activeNode.sourceEntryIds.length,
369
367
  validationStatus: activeNode.validationStatus,
368
+ inputBudgetTokens,
369
+ sourcePromptTokens: wholePlan.promptTokens,
370
+ segmentCount: segmentPlans.length,
371
+ aggregateCalls: aggregated.aggregateCalls,
370
372
  trigger,
371
373
  });
372
374
  const activeRecord = records.at(-1);
@@ -420,13 +422,19 @@ export class CompactionCoordinator {
420
422
  : undefined;
421
423
  if (!details || !effectiveEntry) {
422
424
  this.failPendingNodes();
423
- const customError = this.state.lastError;
425
+ const attempt = this.state;
426
+ const customError = attempt.lastError;
424
427
  this.state = {
425
428
  phase: "pi-default",
426
429
  trigger: event.reason,
427
430
  firstKeptEntryId: event.compactionEntry.firstKeptEntryId,
428
431
  tokensBefore: event.compactionEntry.tokensBefore,
429
432
  completedAt: this.dependencies.now(),
433
+ ...(attempt.sourceEntries !== undefined ? { sourceEntries: attempt.sourceEntries } : {}),
434
+ ...(attempt.inputBudgetTokens !== undefined ? { inputBudgetTokens: attempt.inputBudgetTokens } : {}),
435
+ ...(attempt.sourcePromptTokens !== undefined ? { sourcePromptTokens: attempt.sourcePromptTokens } : {}),
436
+ ...(attempt.segmentCount !== undefined ? { segmentCount: attempt.segmentCount } : {}),
437
+ ...(attempt.aggregateCalls !== undefined ? { aggregateCalls: attempt.aggregateCalls } : {}),
430
438
  ...(customError ? { lastError: `Custom fallback: ${customError}` } : {}),
431
439
  };
432
440
  return;
@@ -459,8 +467,16 @@ export class CompactionCoordinator {
459
467
  tokensBefore: effectiveEntry.tokensBefore,
460
468
  requestedAt: metadata.generatedAt,
461
469
  completedAt: this.dependencies.now(),
470
+ ...(this.state.inputBudgetTokens !== undefined
471
+ ? { inputBudgetTokens: this.state.inputBudgetTokens }
472
+ : {}),
473
+ ...(this.state.sourcePromptTokens !== undefined
474
+ ? { sourcePromptTokens: this.state.sourcePromptTokens }
475
+ : {}),
476
+ ...(this.state.segmentCount !== undefined ? { segmentCount: this.state.segmentCount } : {}),
477
+ ...(this.state.aggregateCalls !== undefined ? { aggregateCalls: this.state.aggregateCalls } : {}),
462
478
  };
463
- this.dependencies.logger.info("compaction.summary_graph_committed", {
479
+ this.dependencies.logger.debug("compaction.summary_graph_committed", {
464
480
  activeSummaryId: metadata.summaryId,
465
481
  segmentSummaryId: metadata.segmentSummaryId,
466
482
  graphLevel: metadata.graphLevel,
@@ -640,6 +656,286 @@ export class CompactionCoordinator {
640
656
  };
641
657
  }
642
658
 
659
+ private inputBudgetTokens(model: ModelDescriptor): number {
660
+ const config = this.dependencies.config;
661
+ const resolved = this.dependencies.resolveModelBudget?.(model)
662
+ ?? {
663
+ budget: calculateContextBudget(createModelProfile(model), config.context),
664
+ recentTailTokens: adaptiveRecentTailLimit(
665
+ model.contextWindow,
666
+ config.context.recentTailTokens,
667
+ ),
668
+ };
669
+ return Math.floor(resolved.budget.activeInputBudget);
670
+ }
671
+
672
+ private classify(text: string, provider: string): {
673
+ value: string;
674
+ classification: PrivacyClassification;
675
+ } {
676
+ const classified = this.dependencies.classifyContent?.(text, provider);
677
+ if (classified) return classified;
678
+ return {
679
+ value: this.dependencies.sanitizeContent?.(text, provider) ?? text,
680
+ classification: "normal",
681
+ };
682
+ }
683
+
684
+ private promptTokens(prompt: string): number {
685
+ return estimateMessageTokens({
686
+ role: "user",
687
+ content: [{ type: "text", text: prompt }],
688
+ });
689
+ }
690
+
691
+ private buildSegmentPlan(
692
+ source: PreparedCompactionSourceSlice,
693
+ event: SessionBeforeCompactEvent,
694
+ provider: string,
695
+ ): SegmentGenerationPlan {
696
+ const sourcePrivacy = this.classify(source.conversationText, provider);
697
+ const validationPrivacy = this.classify(source.sourceText, provider);
698
+ const instructionPrivacy = event.customInstructions
699
+ ? this.classify(event.customInstructions, provider)
700
+ : undefined;
701
+ const readFilePrivacy = source.segmentReadFiles.map((file) => this.classify(file, provider));
702
+ const modifiedFilePrivacy = source.segmentModifiedFiles.map((file) => this.classify(file, provider));
703
+ const classification = [
704
+ sourcePrivacy.classification,
705
+ validationPrivacy.classification,
706
+ ...(instructionPrivacy ? [instructionPrivacy.classification] : []),
707
+ ...readFilePrivacy.map((item) => item.classification),
708
+ ...modifiedFilePrivacy.map((item) => item.classification),
709
+ ].reduce(highestClassification, "normal" as PrivacyClassification);
710
+ const readFiles = readFilePrivacy.map((item) => item.value);
711
+ const modifiedFiles = modifiedFilePrivacy.map((item) => item.value);
712
+ const prompt = buildSummaryPrompt({
713
+ conversationText: sourcePrivacy.value,
714
+ ...(instructionPrivacy ? { customInstructions: instructionPrivacy.value } : {}),
715
+ readFiles,
716
+ modifiedFiles,
717
+ isSplitTurn: source.isSplitTurn,
718
+ });
719
+ return {
720
+ source,
721
+ prompt,
722
+ validationSource: validationPrivacy.value,
723
+ readFiles,
724
+ modifiedFiles,
725
+ classification,
726
+ promptTokens: this.promptTokens(prompt),
727
+ };
728
+ }
729
+
730
+ private partitionSegmentPlans(
731
+ source: PreparedCompactionSource,
732
+ wholePlan: SegmentGenerationPlan,
733
+ event: SessionBeforeCompactEvent,
734
+ provider: string,
735
+ inputBudgetTokens: number,
736
+ ): SegmentGenerationPlan[] {
737
+ if (wholePlan.promptTokens <= inputBudgetTokens) return [wholePlan];
738
+
739
+ const groups = buildCompactionAtomicGroups(source.messages);
740
+ const plans: SegmentGenerationPlan[] = [];
741
+ let currentIndices: number[] = [];
742
+ let currentPlan: SegmentGenerationPlan | undefined;
743
+
744
+ for (const group of groups) {
745
+ const candidateIndices = [...currentIndices, ...group.messageIndices];
746
+ const candidatePlan = this.buildSegmentPlan(
747
+ sliceCompactionSource(source, candidateIndices),
748
+ event,
749
+ provider,
750
+ );
751
+ if (candidatePlan.promptTokens <= inputBudgetTokens) {
752
+ currentIndices = candidateIndices;
753
+ currentPlan = candidatePlan;
754
+ continue;
755
+ }
756
+
757
+ if (currentPlan) {
758
+ plans.push(currentPlan);
759
+ if (plans.length >= MAX_SEGMENT_REQUESTS) {
760
+ throw new Error(`Compaction fan-out exceeded the bounded segment request limit (${MAX_SEGMENT_REQUESTS})`);
761
+ }
762
+ }
763
+ const atomicPlan = this.buildSegmentPlan(
764
+ sliceCompactionSource(source, group.messageIndices),
765
+ event,
766
+ provider,
767
+ );
768
+ if (atomicPlan.promptTokens > inputBudgetTokens) {
769
+ throw new Error(
770
+ `Compaction source contains an indivisible atomic group above the model input budget (promptTokens=${atomicPlan.promptTokens}; inputBudgetTokens=${inputBudgetTokens})`,
771
+ );
772
+ }
773
+ currentIndices = [...group.messageIndices];
774
+ currentPlan = atomicPlan;
775
+ }
776
+
777
+ if (currentPlan) plans.push(currentPlan);
778
+ if (plans.length === 0) throw new Error("Compaction fan-out produced no source segments");
779
+ if (plans.length > MAX_SEGMENT_REQUESTS) {
780
+ throw new Error(`Compaction fan-out exceeded the bounded segment request limit (${MAX_SEGMENT_REQUESTS})`);
781
+ }
782
+ return plans;
783
+ }
784
+
785
+ private buildAggregatePlan(
786
+ children: readonly EmbeddedSummaryNode[],
787
+ event: SessionBeforeCompactEvent,
788
+ provider: string,
789
+ readFiles: readonly string[],
790
+ modifiedFiles: readonly string[],
791
+ ): AggregateGenerationPlan {
792
+ const classifiedChildren = children.map((child) => ({
793
+ child,
794
+ privacy: this.classify(child.content, provider),
795
+ }));
796
+ const promptChildren = classifiedChildren.map(({ child, privacy }) => ({
797
+ ...child,
798
+ content: privacy.value,
799
+ }));
800
+ const aggregateReadFiles = readFiles.map((file) => this.classify(file, provider));
801
+ const aggregateModifiedFiles = modifiedFiles.map((file) => this.classify(file, provider));
802
+ const aggregateInstruction = event.customInstructions
803
+ ? this.classify(event.customInstructions, provider)
804
+ : undefined;
805
+ const classification = [
806
+ ...classifiedChildren.map((item) => item.privacy.classification),
807
+ ...(aggregateInstruction ? [aggregateInstruction.classification] : []),
808
+ ...aggregateReadFiles.map((item) => item.classification),
809
+ ...aggregateModifiedFiles.map((item) => item.classification),
810
+ ].reduce(highestClassification, "normal" as PrivacyClassification);
811
+ const sanitizedReadFiles = aggregateReadFiles.map((item) => item.value);
812
+ const sanitizedModifiedFiles = aggregateModifiedFiles.map((item) => item.value);
813
+ const prompt = buildAggregateSummaryPrompt({
814
+ children: promptChildren,
815
+ ...(aggregateInstruction ? { customInstructions: aggregateInstruction.value } : {}),
816
+ readFiles: sanitizedReadFiles,
817
+ modifiedFiles: sanitizedModifiedFiles,
818
+ });
819
+ return {
820
+ children: [...children],
821
+ prompt,
822
+ validationSource: promptChildren.map((child) => child.content).join("\n\n"),
823
+ readFiles: sanitizedReadFiles,
824
+ modifiedFiles: sanitizedModifiedFiles,
825
+ classification,
826
+ promptTokens: this.promptTokens(prompt),
827
+ };
828
+ }
829
+
830
+ private async aggregateSummaryNodes(input: {
831
+ roots: EmbeddedSummaryNode[];
832
+ event: SessionBeforeCompactEvent;
833
+ ctx: ExtensionContext;
834
+ provider: string;
835
+ model: string;
836
+ readFiles: readonly string[];
837
+ modifiedFiles: readonly string[];
838
+ inputBudgetTokens: number;
839
+ nextId: () => string;
840
+ createdNodes: EmbeddedSummaryNode[];
841
+ usages: GeneratedSummary["usage"][];
842
+ }): Promise<{ activeNode: EmbeddedSummaryNode; aggregateCalls: number }> {
843
+ let layer = [...input.roots];
844
+ let aggregateCalls = 0;
845
+ let graphPasses = 0;
846
+
847
+ while (layer.length > 1) {
848
+ graphPasses++;
849
+ if (graphPasses > MAX_AGGREGATE_LEVELS) {
850
+ throw new Error(`Compaction aggregation exceeded the bounded graph depth (${MAX_AGGREGATE_LEVELS})`);
851
+ }
852
+ const batches: EmbeddedSummaryNode[][] = [];
853
+ let current: EmbeddedSummaryNode[] = [];
854
+ for (const node of layer) {
855
+ if (current.length === 0) {
856
+ current = [node];
857
+ continue;
858
+ }
859
+ const candidate = [...current, node];
860
+ const candidatePlan = this.buildAggregatePlan(
861
+ candidate,
862
+ input.event,
863
+ input.provider,
864
+ input.readFiles,
865
+ input.modifiedFiles,
866
+ );
867
+ if (candidatePlan.promptTokens <= input.inputBudgetTokens) {
868
+ current = candidate;
869
+ continue;
870
+ }
871
+ if (current.length === 1) {
872
+ throw new Error(
873
+ `Compaction child summaries cannot be aggregated within the model input budget (inputBudgetTokens=${input.inputBudgetTokens})`,
874
+ );
875
+ }
876
+ batches.push(current);
877
+ current = [node];
878
+ }
879
+ if (current.length > 0) batches.push(current);
880
+
881
+ const nextLayer: EmbeddedSummaryNode[] = [];
882
+ for (const batch of batches) {
883
+ if (batch.length === 1) {
884
+ nextLayer.push(batch[0] as EmbeddedSummaryNode);
885
+ continue;
886
+ }
887
+ aggregateCalls++;
888
+ if (aggregateCalls > MAX_AGGREGATE_REQUESTS) {
889
+ throw new Error(`Compaction fan-in exceeded the bounded aggregate request limit (${MAX_AGGREGATE_REQUESTS})`);
890
+ }
891
+ const plan = this.buildAggregatePlan(
892
+ batch,
893
+ input.event,
894
+ input.provider,
895
+ input.readFiles,
896
+ input.modifiedFiles,
897
+ );
898
+ if (plan.promptTokens > input.inputBudgetTokens) {
899
+ throw new Error("Compaction aggregate prompt exceeded its preflight input budget");
900
+ }
901
+ const generated = await this.generateSummary({
902
+ stage: "aggregate",
903
+ prompt: plan.prompt,
904
+ validationSource: plan.validationSource,
905
+ readFiles: plan.readFiles,
906
+ modifiedFiles: plan.modifiedFiles,
907
+ event: input.event,
908
+ ctx: input.ctx,
909
+ });
910
+ input.usages.push(generated.usage);
911
+ const aggregateNode: EmbeddedSummaryNode = {
912
+ id: input.nextId(),
913
+ kind: "aggregate",
914
+ content: classifiedSummary(generated.content, plan.classification),
915
+ sourceHash: computeAggregateSourceHash(batch),
916
+ sourceEntryIds: unique(batch.flatMap((child) => child.sourceEntryIds)),
917
+ childSummaryIds: batch.map((child) => child.id),
918
+ graphLevel: Math.max(...batch.map((child) => child.graphLevel)) + 1,
919
+ createdAt: this.dependencies.now(),
920
+ validationStatus: generated.validation.status,
921
+ validationIssueCodes: unique(generated.validation.issues.map((issue) => issue.code)),
922
+ provider: input.provider,
923
+ model: input.model,
924
+ };
925
+ input.createdNodes.push(aggregateNode);
926
+ nextLayer.push(aggregateNode);
927
+ }
928
+ if (nextLayer.length >= layer.length) {
929
+ throw new Error("Compaction aggregation could not reduce the summary graph within the input budget");
930
+ }
931
+ layer = nextLayer;
932
+ }
933
+
934
+ const activeNode = layer[0];
935
+ if (!activeNode) throw new Error("Compaction aggregation produced no active summary node");
936
+ return { activeNode, aggregateCalls };
937
+ }
938
+
643
939
  private generateSummary(input: {
644
940
  stage: "segment" | "aggregate";
645
941
  prompt: string;