@illuma-ai/agents 1.1.1 → 1.1.3
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.
- package/dist/cjs/common/constants.cjs +12 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +156 -82
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/main.cjs +1 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/esm/common/constants.mjs +12 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +158 -84
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/types/common/constants.d.ts +11 -0
- package/package.json +1 -1
- package/src/common/constants.ts +12 -0
- package/src/graphs/Graph.ts +203 -102
- package/src/graphs/gapFeatures.test.ts +345 -0
|
@@ -518,3 +518,348 @@ describe('All Features Combined — Full Pipeline', () => {
|
|
|
518
518
|
expect(callback).toHaveBeenCalled();
|
|
519
519
|
});
|
|
520
520
|
});
|
|
521
|
+
|
|
522
|
+
// ===========================================================================
|
|
523
|
+
// Proactive Summarization — Context Pressure
|
|
524
|
+
// ===========================================================================
|
|
525
|
+
|
|
526
|
+
import { getContextUtilization } from '@/messages/prune';
|
|
527
|
+
import { PROACTIVE_SUMMARY_THRESHOLD } from '@/common/constants';
|
|
528
|
+
|
|
529
|
+
describe('Proactive Summarization — Context Pressure', () => {
|
|
530
|
+
it('triggers proactive summary at 80% utilization BEFORE pruning', () => {
|
|
531
|
+
// Simulate context at 82% utilization
|
|
532
|
+
const maxContextTokens = 200_000;
|
|
533
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
534
|
+
// Build messages that fill ~82% of context
|
|
535
|
+
const msgsNeeded = 40;
|
|
536
|
+
const tokensPerMsg = Math.floor((maxContextTokens * 0.82) / msgsNeeded);
|
|
537
|
+
for (let i = 0; i < msgsNeeded; i++) {
|
|
538
|
+
indexTokenCountMap[String(i)] = tokensPerMsg;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
|
|
542
|
+
const threshold = PROACTIVE_SUMMARY_THRESHOLD * 100; // 80
|
|
543
|
+
|
|
544
|
+
expect(utilization).toBeGreaterThanOrEqual(threshold);
|
|
545
|
+
// At 82%, proactive summary should fire
|
|
546
|
+
// But pruning should NOT have happened yet (context < 90% safety factor)
|
|
547
|
+
const effectiveBudget = Math.floor(maxContextTokens * 0.9); // CONTEXT_SAFETY_FACTOR
|
|
548
|
+
const totalTokens = Object.values(indexTokenCountMap).reduce((s, v) => (s ?? 0) + (v ?? 0), 0) as number;
|
|
549
|
+
expect(totalTokens).toBeLessThan(effectiveBudget);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('does NOT trigger proactive summary below 80%', () => {
|
|
553
|
+
const maxContextTokens = 200_000;
|
|
554
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
555
|
+
// Fill to 50% utilization
|
|
556
|
+
const msgsNeeded = 20;
|
|
557
|
+
const tokensPerMsg = Math.floor((maxContextTokens * 0.5) / msgsNeeded);
|
|
558
|
+
for (let i = 0; i < msgsNeeded; i++) {
|
|
559
|
+
indexTokenCountMap[String(i)] = tokensPerMsg;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
|
|
563
|
+
expect(utilization).toBeLessThan(PROACTIVE_SUMMARY_THRESHOLD * 100);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('selects only older messages for proactive summarization (keeps recent turns)', () => {
|
|
567
|
+
const messages: BaseMessage[] = [
|
|
568
|
+
new SystemMessage('System prompt'),
|
|
569
|
+
...Array.from({ length: 20 }, (_, i) =>
|
|
570
|
+
i % 2 === 0
|
|
571
|
+
? new HumanMessage(`User message ${i}`)
|
|
572
|
+
: new AIMessage(`AI response ${i}`)
|
|
573
|
+
),
|
|
574
|
+
];
|
|
575
|
+
|
|
576
|
+
// Simulate the selection logic from Graph.ts proactive summarization
|
|
577
|
+
const recentTurnCount = Math.max(4, Math.floor(messages.length * 0.3));
|
|
578
|
+
const oldMessages = messages.slice(
|
|
579
|
+
1, // skip system message
|
|
580
|
+
Math.max(1, messages.length - recentTurnCount)
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// Recent 30% (~6 messages) preserved, older messages selected for summary
|
|
584
|
+
expect(oldMessages.length).toBeLessThan(messages.length);
|
|
585
|
+
expect(oldMessages.length).toBeGreaterThan(0);
|
|
586
|
+
// System message not included
|
|
587
|
+
expect(oldMessages[0].getType()).not.toBe('system');
|
|
588
|
+
// Last messages of conversation not included (recent turns preserved)
|
|
589
|
+
const lastOldIndex = messages.indexOf(oldMessages[oldMessages.length - 1]);
|
|
590
|
+
expect(lastOldIndex).toBeLessThan(messages.length - recentTurnCount);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it('never blocks — proactive summary is always fire-and-forget', async () => {
|
|
594
|
+
let resolveCallback: ((v: string) => void) | undefined;
|
|
595
|
+
const slowCallback = jest.fn(
|
|
596
|
+
() =>
|
|
597
|
+
new Promise<string>((resolve) => {
|
|
598
|
+
resolveCallback = resolve;
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
// Simulate proactive summary fire-and-forget
|
|
603
|
+
const summaryPromise = slowCallback().then((updated) => {
|
|
604
|
+
return updated;
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Main flow continues immediately — callback hasn't resolved yet
|
|
608
|
+
expect(slowCallback).toHaveBeenCalledTimes(1);
|
|
609
|
+
|
|
610
|
+
// Later, callback resolves (simulating Nova Micro responding)
|
|
611
|
+
resolveCallback!('Proactive summary result');
|
|
612
|
+
const result = await summaryPromise;
|
|
613
|
+
expect(result).toBe('Proactive summary result');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
it('at 100%+ utilization, uses existing summary without throwing', () => {
|
|
617
|
+
const maxContextTokens = 200_000;
|
|
618
|
+
const cachedSummary = 'Previously generated summary of the conversation';
|
|
619
|
+
|
|
620
|
+
// Context is at 105% (over budget)
|
|
621
|
+
const indexTokenCountMap: Record<string, number | undefined> = {
|
|
622
|
+
'0': 210_000, // system + everything
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const utilization = getContextUtilization(indexTokenCountMap, 0, maxContextTokens);
|
|
626
|
+
expect(utilization).toBeGreaterThan(100);
|
|
627
|
+
|
|
628
|
+
// Even at 100%+, we use the existing cached summary — no error thrown
|
|
629
|
+
expect(cachedSummary).toBeTruthy();
|
|
630
|
+
// Compaction builds a windowed view — no messages deleted, no throwing
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ===========================================================================
|
|
635
|
+
// Context Compaction (Copilot-style: never delete messages)
|
|
636
|
+
// ===========================================================================
|
|
637
|
+
|
|
638
|
+
import { applyCalibration as _applyCalibration } from '@/utils/pruneCalibration';
|
|
639
|
+
|
|
640
|
+
describe('Context Compaction — Windowed View (no message deletion)', () => {
|
|
641
|
+
/**
|
|
642
|
+
* Simulates the compaction logic from Graph.ts without the full Graph instance.
|
|
643
|
+
* This tests the windowed-view algorithm directly.
|
|
644
|
+
*/
|
|
645
|
+
function buildWindowedView(opts: {
|
|
646
|
+
messages: BaseMessage[];
|
|
647
|
+
indexTokenCountMap: Record<string, number | undefined>;
|
|
648
|
+
maxTokens: number;
|
|
649
|
+
summary?: string;
|
|
650
|
+
tokenCounter: TokenCounter;
|
|
651
|
+
}) {
|
|
652
|
+
const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter } = opts;
|
|
653
|
+
|
|
654
|
+
const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
|
|
655
|
+
const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
|
|
656
|
+
const summaryMsg = summary
|
|
657
|
+
? new SystemMessage(`[Conversation Summary]\n${summary}`)
|
|
658
|
+
: null;
|
|
659
|
+
const summaryTokens = summaryMsg != null ? tokenCounter(summaryMsg) : 0;
|
|
660
|
+
|
|
661
|
+
const recentBudget = maxTokens - systemTokens - summaryTokens - 3;
|
|
662
|
+
const contentStart = systemMsg != null ? 1 : 0;
|
|
663
|
+
let usedTokens = 0;
|
|
664
|
+
let windowStart = messages.length;
|
|
665
|
+
|
|
666
|
+
for (let i = messages.length - 1; i >= contentStart; i--) {
|
|
667
|
+
const msgTokens = indexTokenCountMap[i] ?? 0;
|
|
668
|
+
if (usedTokens + msgTokens > recentBudget) break;
|
|
669
|
+
usedTokens += msgTokens;
|
|
670
|
+
windowStart = i;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Don't split tool-call / tool-result pairs
|
|
674
|
+
while (
|
|
675
|
+
windowStart > contentStart &&
|
|
676
|
+
messages[windowStart]?.getType() === 'tool'
|
|
677
|
+
) {
|
|
678
|
+
windowStart--;
|
|
679
|
+
usedTokens += indexTokenCountMap[windowStart] ?? 0;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const recentMessages = messages.slice(windowStart);
|
|
683
|
+
const compactedMessages = messages.slice(contentStart, windowStart);
|
|
684
|
+
|
|
685
|
+
const view: BaseMessage[] = [];
|
|
686
|
+
if (systemMsg) view.push(systemMsg);
|
|
687
|
+
if (summaryMsg) view.push(summaryMsg);
|
|
688
|
+
view.push(...recentMessages);
|
|
689
|
+
|
|
690
|
+
return { view, compactedMessages, recentMessages, usedTokens };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
it('builds a windowed view without deleting any messages', () => {
|
|
694
|
+
const messages = buildConversation(20, 400); // system + 20 content msgs
|
|
695
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
696
|
+
for (let i = 0; i < messages.length; i++) {
|
|
697
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const { view, compactedMessages, recentMessages } = buildWindowedView({
|
|
701
|
+
messages,
|
|
702
|
+
indexTokenCountMap,
|
|
703
|
+
maxTokens: 500, // small budget forces windowing
|
|
704
|
+
tokenCounter: simpleTokenCounter,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
// View is smaller than original
|
|
708
|
+
expect(view.length).toBeLessThan(messages.length);
|
|
709
|
+
// But original messages array is untouched
|
|
710
|
+
expect(messages.length).toBe(21); // system + 20
|
|
711
|
+
// Compacted + recent = all non-system messages
|
|
712
|
+
expect(compactedMessages.length + recentMessages.length).toBe(20);
|
|
713
|
+
// View starts with system message
|
|
714
|
+
expect(view[0].getType()).toBe('system');
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
it('injects summary message covering compacted (windowed-out) messages', () => {
|
|
718
|
+
const messages = buildConversation(20, 400);
|
|
719
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
720
|
+
for (let i = 0; i < messages.length; i++) {
|
|
721
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const summary = 'Summary of earlier conversation turns';
|
|
725
|
+
const { view, compactedMessages } = buildWindowedView({
|
|
726
|
+
messages,
|
|
727
|
+
indexTokenCountMap,
|
|
728
|
+
maxTokens: 600,
|
|
729
|
+
summary,
|
|
730
|
+
tokenCounter: simpleTokenCounter,
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// Summary is injected after system message
|
|
734
|
+
expect(view[1].content).toContain('[Conversation Summary]');
|
|
735
|
+
expect(view[1].content).toContain(summary);
|
|
736
|
+
// There should be compacted messages behind the summary
|
|
737
|
+
expect(compactedMessages.length).toBeGreaterThan(0);
|
|
738
|
+
// Original array is unchanged
|
|
739
|
+
expect(messages.length).toBe(21);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it('includes all messages when budget is large enough (no compaction)', () => {
|
|
743
|
+
const messages = buildConversation(5, 100); // small conversation
|
|
744
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
745
|
+
for (let i = 0; i < messages.length; i++) {
|
|
746
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const { view, compactedMessages } = buildWindowedView({
|
|
750
|
+
messages,
|
|
751
|
+
indexTokenCountMap,
|
|
752
|
+
maxTokens: 100_000, // huge budget
|
|
753
|
+
tokenCounter: simpleTokenCounter,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// All messages fit — no compaction
|
|
757
|
+
expect(view.length).toBe(messages.length);
|
|
758
|
+
expect(compactedMessages.length).toBe(0);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('does not split tool-call / tool-result pairs at window boundary', () => {
|
|
762
|
+
const messages: BaseMessage[] = [
|
|
763
|
+
new SystemMessage('System'),
|
|
764
|
+
new HumanMessage('old question'),
|
|
765
|
+
new AIMessage('old answer'),
|
|
766
|
+
new HumanMessage('question about tool'),
|
|
767
|
+
new AIMessageChunk({
|
|
768
|
+
content: 'Let me search',
|
|
769
|
+
tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }],
|
|
770
|
+
}),
|
|
771
|
+
new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search' }),
|
|
772
|
+
new AIMessage('Based on the search results...'),
|
|
773
|
+
new HumanMessage('latest question'),
|
|
774
|
+
new AIMessage('latest answer'),
|
|
775
|
+
];
|
|
776
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
777
|
+
for (let i = 0; i < messages.length; i++) {
|
|
778
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Budget that would naturally cut between the AI tool-call and ToolMessage
|
|
782
|
+
// Force the window to start at the ToolMessage by making budget tight
|
|
783
|
+
const toolMsgIdx = 5; // ToolMessage index
|
|
784
|
+
let budgetUpToTool = 3; // priming tokens
|
|
785
|
+
for (let i = toolMsgIdx; i < messages.length; i++) {
|
|
786
|
+
budgetUpToTool += indexTokenCountMap[i] ?? 0;
|
|
787
|
+
}
|
|
788
|
+
// Budget includes ToolMessage but NOT the AI tool-call before it
|
|
789
|
+
// The algorithm should walk back to include the AI message too
|
|
790
|
+
const tightBudget = budgetUpToTool + (indexTokenCountMap[0] ?? 0) + 5;
|
|
791
|
+
|
|
792
|
+
const { view } = buildWindowedView({
|
|
793
|
+
messages,
|
|
794
|
+
indexTokenCountMap,
|
|
795
|
+
maxTokens: tightBudget,
|
|
796
|
+
tokenCounter: simpleTokenCounter,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
// Verify no ToolMessage appears without its preceding AI message
|
|
800
|
+
for (let i = 0; i < view.length; i++) {
|
|
801
|
+
if (view[i].getType() === 'tool' && i > 0) {
|
|
802
|
+
// The message before a ToolMessage should be an AI message (the tool caller)
|
|
803
|
+
// or another ToolMessage (multi-tool scenario), or system
|
|
804
|
+
const prevType = view[i - 1].getType();
|
|
805
|
+
expect(['ai', 'tool', 'system']).toContain(prevType);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('with summary, recent messages use remaining budget after summary tokens', () => {
|
|
811
|
+
const messages = buildConversation(20, 400);
|
|
812
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
813
|
+
for (let i = 0; i < messages.length; i++) {
|
|
814
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Large summary eats into the budget
|
|
818
|
+
const largeSummary = 'S'.repeat(1000); // ~250 tokens
|
|
819
|
+
const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({
|
|
820
|
+
messages,
|
|
821
|
+
indexTokenCountMap,
|
|
822
|
+
maxTokens: 800,
|
|
823
|
+
summary: largeSummary,
|
|
824
|
+
tokenCounter: simpleTokenCounter,
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Without summary — more recent messages fit
|
|
828
|
+
const { recentMessages: recentWithout } = buildWindowedView({
|
|
829
|
+
messages,
|
|
830
|
+
indexTokenCountMap,
|
|
831
|
+
maxTokens: 800,
|
|
832
|
+
tokenCounter: simpleTokenCounter,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// Summary takes budget, so fewer recent messages fit
|
|
836
|
+
expect(recentWithSummary.length).toBeLessThan(recentWithout.length);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('original messages array is never mutated', () => {
|
|
840
|
+
const messages = buildConversation(15, 400);
|
|
841
|
+
const originalLength = messages.length;
|
|
842
|
+
const originalFirstContent = messages[0].content;
|
|
843
|
+
const originalLastContent = messages[messages.length - 1].content;
|
|
844
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
845
|
+
for (let i = 0; i < messages.length; i++) {
|
|
846
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// Run compaction multiple times
|
|
850
|
+
for (let i = 0; i < 5; i++) {
|
|
851
|
+
buildWindowedView({
|
|
852
|
+
messages,
|
|
853
|
+
indexTokenCountMap,
|
|
854
|
+
maxTokens: 300,
|
|
855
|
+
summary: `Summary iteration ${i}`,
|
|
856
|
+
tokenCounter: simpleTokenCounter,
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Original array unchanged after 5 compaction runs
|
|
861
|
+
expect(messages.length).toBe(originalLength);
|
|
862
|
+
expect(messages[0].content).toBe(originalFirstContent);
|
|
863
|
+
expect(messages[messages.length - 1].content).toBe(originalLastContent);
|
|
864
|
+
});
|
|
865
|
+
});
|