ds4-context-engine 0.3.0-alpha.4 → 0.3.0-beta.1

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