@illuma-ai/agents 1.1.3 → 1.1.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.
Files changed (35) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +6 -2
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/constants.cjs +10 -0
  4. package/dist/cjs/common/constants.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +86 -12
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/main.cjs +4 -0
  8. package/dist/cjs/main.cjs.map +1 -1
  9. package/dist/cjs/types/graph.cjs.map +1 -1
  10. package/dist/cjs/utils/fileManifest.cjs +49 -0
  11. package/dist/cjs/utils/fileManifest.cjs.map +1 -0
  12. package/dist/esm/agents/AgentContext.mjs +6 -2
  13. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  14. package/dist/esm/common/constants.mjs +10 -1
  15. package/dist/esm/common/constants.mjs.map +1 -1
  16. package/dist/esm/graphs/Graph.mjs +87 -13
  17. package/dist/esm/graphs/Graph.mjs.map +1 -1
  18. package/dist/esm/main.mjs +2 -1
  19. package/dist/esm/main.mjs.map +1 -1
  20. package/dist/esm/types/graph.mjs.map +1 -1
  21. package/dist/esm/utils/fileManifest.mjs +46 -0
  22. package/dist/esm/utils/fileManifest.mjs.map +1 -0
  23. package/dist/types/agents/AgentContext.d.ts +4 -1
  24. package/dist/types/common/constants.d.ts +9 -0
  25. package/dist/types/types/graph.d.ts +35 -0
  26. package/dist/types/utils/fileManifest.d.ts +17 -0
  27. package/dist/types/utils/index.d.ts +1 -0
  28. package/package.json +1 -1
  29. package/src/agents/AgentContext.ts +7 -0
  30. package/src/common/constants.ts +10 -0
  31. package/src/graphs/Graph.ts +92 -13
  32. package/src/graphs/gapFeatures.test.ts +246 -8
  33. package/src/types/graph.ts +36 -0
  34. package/src/utils/fileManifest.ts +49 -0
  35. package/src/utils/index.ts +1 -0
@@ -636,11 +636,16 @@ describe('Proactive Summarization — Context Pressure', () => {
636
636
  // ===========================================================================
637
637
 
638
638
  import { applyCalibration as _applyCalibration } from '@/utils/pruneCalibration';
639
+ import { COMPACTION_RECENT_ROUNDS } from '@/common/constants';
640
+ import { buildFileManifestBlock, FILE_MANIFEST_PREFIX } from '@/utils/fileManifest';
641
+ import type { FileManifestEntry } from '@/types/graph';
639
642
 
640
643
  describe('Context Compaction — Windowed View (no message deletion)', () => {
641
644
  /**
642
645
  * Simulates the compaction logic from Graph.ts without the full Graph instance.
643
- * This tests the windowed-view algorithm directly.
646
+ * Mirrors the two modes:
647
+ * A) No summary → fill budget with as many recent messages as fit
648
+ * B) Summary exists → keep last COMPACTION_RECENT_ROUNDS rounds only
644
649
  */
645
650
  function buildWindowedView(opts: {
646
651
  messages: BaseMessage[];
@@ -648,8 +653,9 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
648
653
  maxTokens: number;
649
654
  summary?: string;
650
655
  tokenCounter: TokenCounter;
656
+ fileManifest?: FileManifestEntry[];
651
657
  }) {
652
- const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter } = opts;
658
+ const { messages, indexTokenCountMap, maxTokens, summary, tokenCounter, fileManifest } = opts;
653
659
 
654
660
  const systemMsg = messages[0]?.getType() === 'system' ? messages[0] : null;
655
661
  const systemTokens = systemMsg != null ? (indexTokenCountMap[0] ?? 0) : 0;
@@ -663,11 +669,28 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
663
669
  let usedTokens = 0;
664
670
  let windowStart = messages.length;
665
671
 
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;
672
+ if (!summary) {
673
+ // Mode A: No summary — fill budget
674
+ for (let i = messages.length - 1; i >= contentStart; i--) {
675
+ const msgTokens = indexTokenCountMap[i] ?? 0;
676
+ if (usedTokens + msgTokens > recentBudget) break;
677
+ usedTokens += msgTokens;
678
+ windowStart = i;
679
+ }
680
+ } else {
681
+ // Mode B: Summary exists — keep last N rounds
682
+ let roundsSeen = 0;
683
+ for (let i = messages.length - 1; i >= contentStart; i--) {
684
+ const msgType = messages[i]?.getType();
685
+ const msgTokens = indexTokenCountMap[i] ?? 0;
686
+ if (usedTokens + msgTokens > recentBudget) break;
687
+ usedTokens += msgTokens;
688
+ windowStart = i;
689
+ if (msgType === 'human') {
690
+ roundsSeen++;
691
+ if (roundsSeen >= COMPACTION_RECENT_ROUNDS) break;
692
+ }
693
+ }
671
694
  }
672
695
 
673
696
  // Don't split tool-call / tool-result pairs
@@ -685,9 +708,20 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
685
708
  const view: BaseMessage[] = [];
686
709
  if (systemMsg) view.push(systemMsg);
687
710
  if (summaryMsg) view.push(summaryMsg);
711
+
712
+ // Inject file manifest when files exist and messages are being compacted
713
+ let fileManifestMsg: SystemMessage | null = null;
714
+ if (fileManifest && fileManifest.length > 0 && compactedMessages.length > 0) {
715
+ const manifestBlock = buildFileManifestBlock(fileManifest);
716
+ if (manifestBlock) {
717
+ fileManifestMsg = new SystemMessage(manifestBlock);
718
+ view.push(fileManifestMsg);
719
+ }
720
+ }
721
+
688
722
  view.push(...recentMessages);
689
723
 
690
- return { view, compactedMessages, recentMessages, usedTokens };
724
+ return { view, compactedMessages, recentMessages, usedTokens, fileManifestMsg };
691
725
  }
692
726
 
693
727
  it('builds a windowed view without deleting any messages', () => {
@@ -836,6 +870,73 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
836
870
  expect(recentWithSummary.length).toBeLessThan(recentWithout.length);
837
871
  });
838
872
 
873
+ it('with summary, limits window to last 2 rounds (not budget-filling)', () => {
874
+ // 20 messages = 10 rounds. With summary, should only keep last 2 rounds (4 msgs).
875
+ const messages: BaseMessage[] = [
876
+ new SystemMessage('System prompt'),
877
+ ];
878
+ for (let i = 0; i < 20; i++) {
879
+ messages.push(
880
+ i % 2 === 0
881
+ ? new HumanMessage(`User question ${i / 2}`)
882
+ : new AIMessage(`AI answer ${(i - 1) / 2}`)
883
+ );
884
+ }
885
+ const indexTokenCountMap: Record<string, number | undefined> = {};
886
+ for (let i = 0; i < messages.length; i++) {
887
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
888
+ }
889
+
890
+ const { view, recentMessages, compactedMessages } = buildWindowedView({
891
+ messages,
892
+ indexTokenCountMap,
893
+ maxTokens: 100_000, // huge budget — would fit everything
894
+ summary: 'Summary of earlier conversation',
895
+ tokenCounter: simpleTokenCounter,
896
+ });
897
+
898
+ // Despite huge budget, only last 2 rounds kept (4 content msgs: H+A+H+A)
899
+ // Plus possible trailing messages in the last round
900
+ expect(recentMessages.length).toBeLessThanOrEqual(5); // 2 rounds + maybe 1 trailing
901
+ expect(recentMessages.length).toBeGreaterThanOrEqual(4); // at least 2 full rounds
902
+
903
+ // Most messages are compacted behind the summary
904
+ expect(compactedMessages.length).toBeGreaterThan(10);
905
+
906
+ // View = system + summary + recent window
907
+ expect(view[0].getType()).toBe('system');
908
+ expect(view[1].content).toContain('[Conversation Summary]');
909
+ });
910
+
911
+ it('without summary, fills budget (no round limit)', () => {
912
+ const messages = buildConversation(20, 100); // small messages
913
+ const indexTokenCountMap: Record<string, number | undefined> = {};
914
+ for (let i = 0; i < messages.length; i++) {
915
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
916
+ }
917
+
918
+ const { recentMessages: withoutSummary } = buildWindowedView({
919
+ messages,
920
+ indexTokenCountMap,
921
+ maxTokens: 100_000, // huge budget
922
+ tokenCounter: simpleTokenCounter,
923
+ // no summary → mode A
924
+ });
925
+
926
+ const { recentMessages: withSummary } = buildWindowedView({
927
+ messages,
928
+ indexTokenCountMap,
929
+ maxTokens: 100_000,
930
+ summary: 'Summary exists',
931
+ tokenCounter: simpleTokenCounter,
932
+ });
933
+
934
+ // Without summary: all messages included (budget-filling mode)
935
+ expect(withoutSummary.length).toBe(20); // all content messages
936
+ // With summary: only last 2 rounds
937
+ expect(withSummary.length).toBeLessThan(withoutSummary.length);
938
+ });
939
+
839
940
  it('original messages array is never mutated', () => {
840
941
  const messages = buildConversation(15, 400);
841
942
  const originalLength = messages.length;
@@ -862,4 +963,141 @@ describe('Context Compaction — Windowed View (no message deletion)', () => {
862
963
  expect(messages[0].content).toBe(originalFirstContent);
863
964
  expect(messages[messages.length - 1].content).toBe(originalLastContent);
864
965
  });
966
+
967
+ // ── File Manifest in Windowed View ─────────────────────────────────────
968
+
969
+ it('injects file manifest block when files exist and messages are compacted', () => {
970
+ const messages = buildConversation(20, 400);
971
+ const indexTokenCountMap: Record<string, number | undefined> = {};
972
+ for (let i = 0; i < messages.length; i++) {
973
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
974
+ }
975
+
976
+ const manifest: FileManifestEntry[] = [
977
+ { fileId: 'f1', filename: 'report.pdf', contentId: 'abc123' },
978
+ { fileId: 'f2', filename: 'data.csv', contentId: 'def456', source: 'local' },
979
+ ];
980
+
981
+ const { view, compactedMessages, fileManifestMsg } = buildWindowedView({
982
+ messages,
983
+ indexTokenCountMap,
984
+ maxTokens: 600,
985
+ summary: 'Summary of earlier turns',
986
+ tokenCounter: simpleTokenCounter,
987
+ fileManifest: manifest,
988
+ });
989
+
990
+ // File manifest is injected
991
+ expect(fileManifestMsg).not.toBeNull();
992
+ expect(compactedMessages.length).toBeGreaterThan(0);
993
+
994
+ // Manifest message contains file names and content IDs
995
+ const manifestContent = fileManifestMsg!.content as string;
996
+ expect(manifestContent).toContain(FILE_MANIFEST_PREFIX);
997
+ expect(manifestContent).toContain('report.pdf');
998
+ expect(manifestContent).toContain('abc123');
999
+ expect(manifestContent).toContain('data.csv');
1000
+ expect(manifestContent).toContain('def456');
1001
+
1002
+ // View order: [system] + [summary] + [file manifest] + [recent messages]
1003
+ expect(view[0].getType()).toBe('system');
1004
+ expect((view[1].content as string)).toContain('[Conversation Summary]');
1005
+ expect((view[2].content as string)).toContain(FILE_MANIFEST_PREFIX);
1006
+ // Recent messages follow
1007
+ expect(view.length).toBeGreaterThan(3);
1008
+ });
1009
+
1010
+ it('does NOT inject file manifest when no messages are compacted', () => {
1011
+ const messages = buildConversation(4, 100); // small conversation
1012
+ const indexTokenCountMap: Record<string, number | undefined> = {};
1013
+ for (let i = 0; i < messages.length; i++) {
1014
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
1015
+ }
1016
+
1017
+ const manifest: FileManifestEntry[] = [
1018
+ { fileId: 'f1', filename: 'file.txt', contentId: 'aaa' },
1019
+ ];
1020
+
1021
+ const { compactedMessages, fileManifestMsg } = buildWindowedView({
1022
+ messages,
1023
+ indexTokenCountMap,
1024
+ maxTokens: 100_000, // everything fits
1025
+ tokenCounter: simpleTokenCounter,
1026
+ fileManifest: manifest,
1027
+ });
1028
+
1029
+ // No compaction happened, so no manifest injected
1030
+ expect(compactedMessages.length).toBe(0);
1031
+ expect(fileManifestMsg).toBeNull();
1032
+ });
1033
+
1034
+ it('does NOT inject file manifest when manifest is empty', () => {
1035
+ const messages = buildConversation(20, 400);
1036
+ const indexTokenCountMap: Record<string, number | undefined> = {};
1037
+ for (let i = 0; i < messages.length; i++) {
1038
+ indexTokenCountMap[i] = simpleTokenCounter(messages[i]);
1039
+ }
1040
+
1041
+ const { fileManifestMsg } = buildWindowedView({
1042
+ messages,
1043
+ indexTokenCountMap,
1044
+ maxTokens: 600,
1045
+ summary: 'Summary',
1046
+ tokenCounter: simpleTokenCounter,
1047
+ fileManifest: [],
1048
+ });
1049
+
1050
+ expect(fileManifestMsg).toBeNull();
1051
+ });
1052
+ });
1053
+
1054
+ // ===========================================================================
1055
+ // File Manifest Utility — Unit Tests
1056
+ // ===========================================================================
1057
+
1058
+ describe('buildFileManifestBlock', () => {
1059
+ it('returns empty string for undefined manifest', () => {
1060
+ expect(buildFileManifestBlock(undefined)).toBe('');
1061
+ });
1062
+
1063
+ it('returns empty string for empty manifest', () => {
1064
+ expect(buildFileManifestBlock([])).toBe('');
1065
+ });
1066
+
1067
+ it('builds block with file names and content IDs', () => {
1068
+ const manifest: FileManifestEntry[] = [
1069
+ { fileId: 'f1', filename: 'report.pdf', contentId: 'abc123' },
1070
+ { fileId: 'f2', filename: 'notes.md', contentId: 'def456' },
1071
+ ];
1072
+
1073
+ const block = buildFileManifestBlock(manifest);
1074
+
1075
+ expect(block).toContain(FILE_MANIFEST_PREFIX);
1076
+ expect(block).toContain('report.pdf');
1077
+ expect(block).toContain('(content_id: abc123)');
1078
+ expect(block).toContain('notes.md');
1079
+ expect(block).toContain('(content_id: def456)');
1080
+ expect(block).toContain('file_search or content_tool read');
1081
+ });
1082
+
1083
+ it('includes source when provided', () => {
1084
+ const manifest: FileManifestEntry[] = [
1085
+ { fileId: 'f1', filename: 'doc.pdf', source: 'sharepoint' },
1086
+ ];
1087
+
1088
+ const block = buildFileManifestBlock(manifest);
1089
+ expect(block).toContain('[sharepoint]');
1090
+ });
1091
+
1092
+ it('handles entries without contentId or source', () => {
1093
+ const manifest: FileManifestEntry[] = [
1094
+ { fileId: 'f1', filename: 'image.png' },
1095
+ ];
1096
+
1097
+ const block = buildFileManifestBlock(manifest);
1098
+ expect(block).toContain('- image.png');
1099
+ // No per-file content_id or source brackets in the file line
1100
+ expect(block).not.toContain('(content_id:');
1101
+ expect(block).not.toContain('[local]');
1102
+ });
865
1103
  });
@@ -555,6 +555,26 @@ export interface PruneCalibrationState {
555
555
  iterations: number;
556
556
  }
557
557
 
558
+ /**
559
+ * Lightweight file metadata entry for conversation-level file awareness.
560
+ * Contains only IDs and names — NOT full content — so the agent always knows
561
+ * what files exist in the conversation even after compaction pushes old messages
562
+ * behind the summary window. The agent can retrieve full content on-demand
563
+ * via file_search (RAG) or content_tool read (by contentId).
564
+ */
565
+ export interface FileManifestEntry {
566
+ /** Unique file identifier (e.g., MongoDB ObjectId or UUID) */
567
+ fileId: string;
568
+ /** Original filename (e.g., "quarterly-report.pdf") */
569
+ filename: string;
570
+ /** Content identifier for on-demand retrieval via content_tool read */
571
+ contentId?: string;
572
+ /** File source (e.g., "local", "sharepoint", "onedrive") */
573
+ source?: string;
574
+ /** Index of the message that introduced this file (0-based in the original message array) */
575
+ messageIndex?: number;
576
+ }
577
+
558
578
  export interface AgentInputs {
559
579
  agentId: string;
560
580
  /** Human-readable name for the agent (used in handoff context). Defaults to agentId if not provided. */
@@ -632,4 +652,20 @@ export interface AgentInputs {
632
652
  * @see SummarizationConfig
633
653
  */
634
654
  summarizationConfig?: SummarizationConfig;
655
+ /**
656
+ * Lightweight file manifest for the conversation.
657
+ * Contains file IDs, names, and metadata — NOT full content.
658
+ *
659
+ * Used by the compaction engine to inject a [Conversation Files] block
660
+ * into the windowed view, ensuring the LLM always knows what files exist
661
+ * even when old messages (with full file content) are behind the summary.
662
+ *
663
+ * The agent can retrieve full content on-demand via:
664
+ * - file_search (RAG semantic search over embedded files)
665
+ * - content_tool read (by contentId for exact file retrieval)
666
+ *
667
+ * Built by the orchestrator (e.g., Ranger) from message_file_map
668
+ * and metadata.context_files across all conversation messages.
669
+ */
670
+ fileManifest?: FileManifestEntry[];
635
671
  }
@@ -0,0 +1,49 @@
1
+ // src/utils/fileManifest.ts
2
+ //
3
+ // Utility for building a lightweight [Conversation Files] context block
4
+ // from a file manifest. Injected into the compaction windowed view so the
5
+ // LLM retains awareness of ALL conversation files, even when old messages
6
+ // (with full file content) are behind the summary.
7
+
8
+ import type { FileManifestEntry } from '@/types/graph';
9
+
10
+ /**
11
+ * Prefix marker for the file manifest block.
12
+ * Used to detect and deduplicate manifest messages across turns.
13
+ */
14
+ export const FILE_MANIFEST_PREFIX = '[Conversation Files]';
15
+
16
+ /**
17
+ * Builds a compact text block listing all files in the conversation.
18
+ * Each entry costs ~10-15 tokens, so 10 files ≈ 100-150 tokens total.
19
+ *
20
+ * The block includes retrieval hints so the LLM knows how to fetch
21
+ * full content on demand (via file_search or content_tool).
22
+ *
23
+ * @param manifest - Array of file metadata entries
24
+ * @returns Formatted text block, or empty string if manifest is empty/undefined
25
+ */
26
+ export function buildFileManifestBlock(manifest: FileManifestEntry[] | undefined): string {
27
+ if (!manifest || manifest.length === 0) {
28
+ return '';
29
+ }
30
+
31
+ const lines = manifest.map((entry) => {
32
+ const parts: string[] = [`- ${entry.filename}`];
33
+ if (entry.contentId) {
34
+ parts.push(`(content_id: ${entry.contentId})`);
35
+ }
36
+ if (entry.source) {
37
+ parts.push(`[${entry.source}]`);
38
+ }
39
+ return parts.join(' ');
40
+ });
41
+
42
+ return [
43
+ FILE_MANIFEST_PREFIX,
44
+ 'The following files have been shared in this conversation.',
45
+ 'Use file_search or content_tool read (with content_id) to retrieve full content when needed.',
46
+ '',
47
+ ...lines,
48
+ ].join('\n');
49
+ }
@@ -11,3 +11,4 @@ export * from './toolCallContinuation';
11
11
  export * from './contextPressure';
12
12
  export * from './toolDiscoveryCache';
13
13
  export * from './pruneCalibration';
14
+ export * from './fileManifest';