@illuma-ai/agents 1.1.2 → 1.1.4
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 +10 -0
- package/dist/cjs/common/constants.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +163 -79
- 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 +10 -1
- package/dist/esm/common/constants.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +164 -80
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -1
- package/dist/types/common/constants.d.ts +9 -0
- package/package.json +1 -1
- package/src/common/constants.ts +10 -0
- package/src/graphs/Graph.ts +194 -102
- package/src/graphs/gapFeatures.test.ts +321 -2
|
@@ -627,7 +627,326 @@ describe('Proactive Summarization — Context Pressure', () => {
|
|
|
627
627
|
|
|
628
628
|
// Even at 100%+, we use the existing cached summary — no error thrown
|
|
629
629
|
expect(cachedSummary).toBeTruthy();
|
|
630
|
-
//
|
|
631
|
-
|
|
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
|
+
import { COMPACTION_RECENT_ROUNDS } from '@/common/constants';
|
|
640
|
+
|
|
641
|
+
describe('Context Compaction — Windowed View (no message deletion)', () => {
|
|
642
|
+
/**
|
|
643
|
+
* Simulates the compaction logic from Graph.ts without the full Graph instance.
|
|
644
|
+
* Mirrors the two modes:
|
|
645
|
+
* A) No summary → fill budget with as many recent messages as fit
|
|
646
|
+
* B) Summary exists → keep last COMPACTION_RECENT_ROUNDS rounds only
|
|
647
|
+
*/
|
|
648
|
+
function buildWindowedView(opts: {
|
|
649
|
+
messages: BaseMessage[];
|
|
650
|
+
indexTokenCountMap: Record<string, number | undefined>;
|
|
651
|
+
maxTokens: number;
|
|
652
|
+
summary?: string;
|
|
653
|
+
tokenCounter: TokenCounter;
|
|
654
|
+
}) {
|
|
655
|
+
const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter } = opts;
|
|
656
|
+
|
|
657
|
+
const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
|
|
658
|
+
const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
|
|
659
|
+
const summaryMsg = summary
|
|
660
|
+
? new SystemMessage(`[Conversation Summary]\n${summary}`)
|
|
661
|
+
: null;
|
|
662
|
+
const summaryTokens = summaryMsg != null ? tokenCounter(summaryMsg) : 0;
|
|
663
|
+
|
|
664
|
+
const recentBudget = maxTokens - systemTokens - summaryTokens - 3;
|
|
665
|
+
const contentStart = systemMsg != null ? 1 : 0;
|
|
666
|
+
let usedTokens = 0;
|
|
667
|
+
let windowStart = messages.length;
|
|
668
|
+
|
|
669
|
+
if (!summary) {
|
|
670
|
+
// Mode A: No summary — fill budget
|
|
671
|
+
for (let i = messages.length - 1; i >= contentStart; i--) {
|
|
672
|
+
const msgTokens = indexTokenCountMap[i] ?? 0;
|
|
673
|
+
if (usedTokens + msgTokens > recentBudget) break;
|
|
674
|
+
usedTokens += msgTokens;
|
|
675
|
+
windowStart = i;
|
|
676
|
+
}
|
|
677
|
+
} else {
|
|
678
|
+
// Mode B: Summary exists — keep last N rounds
|
|
679
|
+
let roundsSeen = 0;
|
|
680
|
+
for (let i = messages.length - 1; i >= contentStart; i--) {
|
|
681
|
+
const msgType = messages[i]?.getType();
|
|
682
|
+
const msgTokens = indexTokenCountMap[i] ?? 0;
|
|
683
|
+
if (usedTokens + msgTokens > recentBudget) break;
|
|
684
|
+
usedTokens += msgTokens;
|
|
685
|
+
windowStart = i;
|
|
686
|
+
if (msgType === 'human') {
|
|
687
|
+
roundsSeen++;
|
|
688
|
+
if (roundsSeen >= COMPACTION_RECENT_ROUNDS) break;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Don't split tool-call / tool-result pairs
|
|
694
|
+
while (
|
|
695
|
+
windowStart > contentStart &&
|
|
696
|
+
messages[windowStart]?.getType() === 'tool'
|
|
697
|
+
) {
|
|
698
|
+
windowStart--;
|
|
699
|
+
usedTokens += indexTokenCountMap[windowStart] ?? 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const recentMessages = messages.slice(windowStart);
|
|
703
|
+
const compactedMessages = messages.slice(contentStart, windowStart);
|
|
704
|
+
|
|
705
|
+
const view: BaseMessage[] = [];
|
|
706
|
+
if (systemMsg) view.push(systemMsg);
|
|
707
|
+
if (summaryMsg) view.push(summaryMsg);
|
|
708
|
+
view.push(...recentMessages);
|
|
709
|
+
|
|
710
|
+
return { view, compactedMessages, recentMessages, usedTokens };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
it('builds a windowed view without deleting any messages', () => {
|
|
714
|
+
const messages = buildConversation(20, 400); // system + 20 content msgs
|
|
715
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
716
|
+
for (let i = 0; i < messages.length; i++) {
|
|
717
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const { view, compactedMessages, recentMessages } = buildWindowedView({
|
|
721
|
+
messages,
|
|
722
|
+
indexTokenCountMap,
|
|
723
|
+
maxTokens: 500, // small budget forces windowing
|
|
724
|
+
tokenCounter: simpleTokenCounter,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// View is smaller than original
|
|
728
|
+
expect(view.length).toBeLessThan(messages.length);
|
|
729
|
+
// But original messages array is untouched
|
|
730
|
+
expect(messages.length).toBe(21); // system + 20
|
|
731
|
+
// Compacted + recent = all non-system messages
|
|
732
|
+
expect(compactedMessages.length + recentMessages.length).toBe(20);
|
|
733
|
+
// View starts with system message
|
|
734
|
+
expect(view[0].getType()).toBe('system');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('injects summary message covering compacted (windowed-out) messages', () => {
|
|
738
|
+
const messages = buildConversation(20, 400);
|
|
739
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
740
|
+
for (let i = 0; i < messages.length; i++) {
|
|
741
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const summary = 'Summary of earlier conversation turns';
|
|
745
|
+
const { view, compactedMessages } = buildWindowedView({
|
|
746
|
+
messages,
|
|
747
|
+
indexTokenCountMap,
|
|
748
|
+
maxTokens: 600,
|
|
749
|
+
summary,
|
|
750
|
+
tokenCounter: simpleTokenCounter,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Summary is injected after system message
|
|
754
|
+
expect(view[1].content).toContain('[Conversation Summary]');
|
|
755
|
+
expect(view[1].content).toContain(summary);
|
|
756
|
+
// There should be compacted messages behind the summary
|
|
757
|
+
expect(compactedMessages.length).toBeGreaterThan(0);
|
|
758
|
+
// Original array is unchanged
|
|
759
|
+
expect(messages.length).toBe(21);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('includes all messages when budget is large enough (no compaction)', () => {
|
|
763
|
+
const messages = buildConversation(5, 100); // small conversation
|
|
764
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
765
|
+
for (let i = 0; i < messages.length; i++) {
|
|
766
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const { view, compactedMessages } = buildWindowedView({
|
|
770
|
+
messages,
|
|
771
|
+
indexTokenCountMap,
|
|
772
|
+
maxTokens: 100_000, // huge budget
|
|
773
|
+
tokenCounter: simpleTokenCounter,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// All messages fit — no compaction
|
|
777
|
+
expect(view.length).toBe(messages.length);
|
|
778
|
+
expect(compactedMessages.length).toBe(0);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('does not split tool-call / tool-result pairs at window boundary', () => {
|
|
782
|
+
const messages: BaseMessage[] = [
|
|
783
|
+
new SystemMessage('System'),
|
|
784
|
+
new HumanMessage('old question'),
|
|
785
|
+
new AIMessage('old answer'),
|
|
786
|
+
new HumanMessage('question about tool'),
|
|
787
|
+
new AIMessageChunk({
|
|
788
|
+
content: 'Let me search',
|
|
789
|
+
tool_calls: [{ id: 'tc_1', name: 'web_search', args: {} }],
|
|
790
|
+
}),
|
|
791
|
+
new ToolMessage({ content: 'Search results', tool_call_id: 'tc_1', name: 'web_search' }),
|
|
792
|
+
new AIMessage('Based on the search results...'),
|
|
793
|
+
new HumanMessage('latest question'),
|
|
794
|
+
new AIMessage('latest answer'),
|
|
795
|
+
];
|
|
796
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
797
|
+
for (let i = 0; i < messages.length; i++) {
|
|
798
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Budget that would naturally cut between the AI tool-call and ToolMessage
|
|
802
|
+
// Force the window to start at the ToolMessage by making budget tight
|
|
803
|
+
const toolMsgIdx = 5; // ToolMessage index
|
|
804
|
+
let budgetUpToTool = 3; // priming tokens
|
|
805
|
+
for (let i = toolMsgIdx; i < messages.length; i++) {
|
|
806
|
+
budgetUpToTool += indexTokenCountMap[i] ?? 0;
|
|
807
|
+
}
|
|
808
|
+
// Budget includes ToolMessage but NOT the AI tool-call before it
|
|
809
|
+
// The algorithm should walk back to include the AI message too
|
|
810
|
+
const tightBudget = budgetUpToTool + (indexTokenCountMap[0] ?? 0) + 5;
|
|
811
|
+
|
|
812
|
+
const { view } = buildWindowedView({
|
|
813
|
+
messages,
|
|
814
|
+
indexTokenCountMap,
|
|
815
|
+
maxTokens: tightBudget,
|
|
816
|
+
tokenCounter: simpleTokenCounter,
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Verify no ToolMessage appears without its preceding AI message
|
|
820
|
+
for (let i = 0; i < view.length; i++) {
|
|
821
|
+
if (view[i].getType() === 'tool' && i > 0) {
|
|
822
|
+
// The message before a ToolMessage should be an AI message (the tool caller)
|
|
823
|
+
// or another ToolMessage (multi-tool scenario), or system
|
|
824
|
+
const prevType = view[i - 1].getType();
|
|
825
|
+
expect(['ai', 'tool', 'system']).toContain(prevType);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('with summary, recent messages use remaining budget after summary tokens', () => {
|
|
831
|
+
const messages = buildConversation(20, 400);
|
|
832
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
833
|
+
for (let i = 0; i < messages.length; i++) {
|
|
834
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Large summary eats into the budget
|
|
838
|
+
const largeSummary = 'S'.repeat(1000); // ~250 tokens
|
|
839
|
+
const { view: viewWithSummary, recentMessages: recentWithSummary } = buildWindowedView({
|
|
840
|
+
messages,
|
|
841
|
+
indexTokenCountMap,
|
|
842
|
+
maxTokens: 800,
|
|
843
|
+
summary: largeSummary,
|
|
844
|
+
tokenCounter: simpleTokenCounter,
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// Without summary — more recent messages fit
|
|
848
|
+
const { recentMessages: recentWithout } = buildWindowedView({
|
|
849
|
+
messages,
|
|
850
|
+
indexTokenCountMap,
|
|
851
|
+
maxTokens: 800,
|
|
852
|
+
tokenCounter: simpleTokenCounter,
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// Summary takes budget, so fewer recent messages fit
|
|
856
|
+
expect(recentWithSummary.length).toBeLessThan(recentWithout.length);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('with summary, limits window to last 2 rounds (not budget-filling)', () => {
|
|
860
|
+
// 20 messages = 10 rounds. With summary, should only keep last 2 rounds (4 msgs).
|
|
861
|
+
const messages: BaseMessage[] = [
|
|
862
|
+
new SystemMessage('System prompt'),
|
|
863
|
+
];
|
|
864
|
+
for (let i = 0; i < 20; i++) {
|
|
865
|
+
messages.push(
|
|
866
|
+
i % 2 === 0
|
|
867
|
+
? new HumanMessage(`User question ${i / 2}`)
|
|
868
|
+
: new AIMessage(`AI answer ${(i - 1) / 2}`)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
872
|
+
for (let i = 0; i < messages.length; i++) {
|
|
873
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const { view, recentMessages, compactedMessages } = buildWindowedView({
|
|
877
|
+
messages,
|
|
878
|
+
indexTokenCountMap,
|
|
879
|
+
maxTokens: 100_000, // huge budget — would fit everything
|
|
880
|
+
summary: 'Summary of earlier conversation',
|
|
881
|
+
tokenCounter: simpleTokenCounter,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Despite huge budget, only last 2 rounds kept (4 content msgs: H+A+H+A)
|
|
885
|
+
// Plus possible trailing messages in the last round
|
|
886
|
+
expect(recentMessages.length).toBeLessThanOrEqual(5); // 2 rounds + maybe 1 trailing
|
|
887
|
+
expect(recentMessages.length).toBeGreaterThanOrEqual(4); // at least 2 full rounds
|
|
888
|
+
|
|
889
|
+
// Most messages are compacted behind the summary
|
|
890
|
+
expect(compactedMessages.length).toBeGreaterThan(10);
|
|
891
|
+
|
|
892
|
+
// View = system + summary + recent window
|
|
893
|
+
expect(view[0].getType()).toBe('system');
|
|
894
|
+
expect(view[1].content).toContain('[Conversation Summary]');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('without summary, fills budget (no round limit)', () => {
|
|
898
|
+
const messages = buildConversation(20, 100); // small messages
|
|
899
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
900
|
+
for (let i = 0; i < messages.length; i++) {
|
|
901
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const { recentMessages: withoutSummary } = buildWindowedView({
|
|
905
|
+
messages,
|
|
906
|
+
indexTokenCountMap,
|
|
907
|
+
maxTokens: 100_000, // huge budget
|
|
908
|
+
tokenCounter: simpleTokenCounter,
|
|
909
|
+
// no summary → mode A
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const { recentMessages: withSummary } = buildWindowedView({
|
|
913
|
+
messages,
|
|
914
|
+
indexTokenCountMap,
|
|
915
|
+
maxTokens: 100_000,
|
|
916
|
+
summary: 'Summary exists',
|
|
917
|
+
tokenCounter: simpleTokenCounter,
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
// Without summary: all messages included (budget-filling mode)
|
|
921
|
+
expect(withoutSummary.length).toBe(20); // all content messages
|
|
922
|
+
// With summary: only last 2 rounds
|
|
923
|
+
expect(withSummary.length).toBeLessThan(withoutSummary.length);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
it('original messages array is never mutated', () => {
|
|
927
|
+
const messages = buildConversation(15, 400);
|
|
928
|
+
const originalLength = messages.length;
|
|
929
|
+
const originalFirstContent = messages[0].content;
|
|
930
|
+
const originalLastContent = messages[messages.length - 1].content;
|
|
931
|
+
const indexTokenCountMap: Record<string, number | undefined> = {};
|
|
932
|
+
for (let i = 0; i < messages.length; i++) {
|
|
933
|
+
indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Run compaction multiple times
|
|
937
|
+
for (let i = 0; i < 5; i++) {
|
|
938
|
+
buildWindowedView({
|
|
939
|
+
messages,
|
|
940
|
+
indexTokenCountMap,
|
|
941
|
+
maxTokens: 300,
|
|
942
|
+
summary: `Summary iteration ${i}`,
|
|
943
|
+
tokenCounter: simpleTokenCounter,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Original array unchanged after 5 compaction runs
|
|
948
|
+
expect(messages.length).toBe(originalLength);
|
|
949
|
+
expect(messages[0].content).toBe(originalFirstContent);
|
|
950
|
+
expect(messages[messages.length - 1].content).toBe(originalLastContent);
|
|
632
951
|
});
|
|
633
952
|
});
|