@aj-archipelago/cortex 1.4.30 → 1.4.32
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/lib/fileUtils.js +194 -187
- package/lib/pathwayManager.js +7 -0
- package/lib/pathwayTools.js +71 -0
- package/package.json +1 -1
- package/pathways/system/entity/files/sys_read_file_collection.js +3 -3
- package/pathways/system/entity/sys_entity_agent.js +41 -3
- package/pathways/system/entity/tools/sys_tool_analyzefile.js +48 -19
- package/pathways/system/entity/tools/sys_tool_editfile.js +4 -4
- package/pathways/system/entity/tools/sys_tool_file_collection.js +24 -17
- package/pathways/system/entity/tools/sys_tool_view_image.js +3 -3
- package/server/clientToolCallbacks.js +241 -0
- package/server/executeWorkspace.js +7 -0
- package/server/graphql.js +3 -1
- package/server/plugins/gemini15VisionPlugin.js +16 -3
- package/server/resolver.js +37 -2
- package/tests/integration/clientToolCallbacks.test.js +161 -0
- package/tests/integration/features/tools/fileCollection.test.js +696 -63
- package/tests/integration/features/tools/writefile.test.js +4 -4
- package/tests/integration/graphql/async/stream/file_operations_agent.test.js +839 -0
- package/tests/unit/core/fileCollection.test.js +1 -1
- package/tests/unit/plugins/multimodal_conversion.test.js +16 -6
package/lib/fileUtils.js
CHANGED
|
@@ -507,17 +507,24 @@ function extractFilesFromChatHistory(chatHistory) {
|
|
|
507
507
|
*/
|
|
508
508
|
function isFileInCollection(inCollection, chatId = null) {
|
|
509
509
|
// If not set, file is not in collection
|
|
510
|
+
// Treat empty array [] the same as undefined (not in collection)
|
|
511
|
+
// Note: false is not a valid value (normalizes to undefined), but handle it defensively
|
|
510
512
|
if (inCollection === undefined || inCollection === null || inCollection === false) {
|
|
511
513
|
return false;
|
|
512
514
|
}
|
|
513
515
|
|
|
516
|
+
// Empty array means not in collection (same as undefined)
|
|
517
|
+
if (Array.isArray(inCollection) && inCollection.length === 0) {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
|
|
514
521
|
// Backward compatibility: boolean true means global
|
|
515
522
|
if (inCollection === true) {
|
|
516
523
|
return true;
|
|
517
524
|
}
|
|
518
525
|
|
|
519
526
|
// Array format: check if it includes '*' (global) or the specific chatId
|
|
520
|
-
if (Array.isArray(inCollection)) {
|
|
527
|
+
if (Array.isArray(inCollection) && inCollection.length > 0) {
|
|
521
528
|
// If no chatId specified, only include global files
|
|
522
529
|
if (chatId === null) {
|
|
523
530
|
return inCollection.includes('*');
|
|
@@ -699,125 +706,180 @@ function parseRawFileData(allFiles, contextKey = null) {
|
|
|
699
706
|
}
|
|
700
707
|
|
|
701
708
|
/**
|
|
702
|
-
*
|
|
703
|
-
* @param {Array}
|
|
704
|
-
* @
|
|
705
|
-
* @returns {Array} Filtered and sorted file collection (includes inCollection for reference counting)
|
|
709
|
+
* Sort files by lastAccessed (most recent first)
|
|
710
|
+
* @param {Array} files - Array of file objects
|
|
711
|
+
* @returns {Array} Sorted array
|
|
706
712
|
*/
|
|
707
|
-
function
|
|
708
|
-
|
|
709
|
-
const filtered = rawFiles.filter(file => isFileInCollection(file.inCollection, chatId));
|
|
710
|
-
|
|
711
|
-
// Keep inCollection in output (needed for reference counting display)
|
|
712
|
-
// Sort by lastAccessed (most recent first)
|
|
713
|
-
filtered.sort((a, b) => {
|
|
713
|
+
function sortFilesByLastAccessed(files) {
|
|
714
|
+
return files.sort((a, b) => {
|
|
714
715
|
const aDate = new Date(a.lastAccessed || a.addedDate || 0);
|
|
715
716
|
const bDate = new Date(b.lastAccessed || b.addedDate || 0);
|
|
716
717
|
return bDate - aDate;
|
|
717
718
|
});
|
|
718
|
-
|
|
719
|
-
return filtered;
|
|
720
719
|
}
|
|
721
720
|
|
|
722
|
-
|
|
723
|
-
|
|
721
|
+
/**
|
|
722
|
+
* Load file collection from one or more contexts (unified function)
|
|
723
|
+
*
|
|
724
|
+
* @param {Array|Object|string} agentContext - Context(s) to load from:
|
|
725
|
+
* - Array of {contextId, contextKey, default} objects (compound context)
|
|
726
|
+
* - Single {contextId, contextKey, default} object
|
|
727
|
+
* - String contextId (contextKey will be null)
|
|
728
|
+
* @param {Object} options - Load options
|
|
729
|
+
* @param {Array<string>|string|null} options.chatIds - Chat IDs to filter by:
|
|
730
|
+
* - If null/undefined/empty array: return ALL files regardless of inCollection
|
|
731
|
+
* - If provided: filter to files where inCollection includes '*' or any of these chatIds
|
|
732
|
+
* @param {boolean} options.useCache - Whether to use cache (default: true)
|
|
733
|
+
* @returns {Promise<Array>} File collection (deduplicated across contexts, sorted by lastAccessed)
|
|
734
|
+
*/
|
|
735
|
+
async function loadFileCollection(agentContext, options = {}) {
|
|
736
|
+
// Normalize agentContext to array format
|
|
737
|
+
let contexts = [];
|
|
738
|
+
if (typeof agentContext === 'string') {
|
|
739
|
+
// Single contextId string
|
|
740
|
+
contexts = [{ contextId: agentContext, contextKey: null, default: true }];
|
|
741
|
+
} else if (Array.isArray(agentContext)) {
|
|
742
|
+
contexts = agentContext.filter(ctx => ctx && ctx.contextId);
|
|
743
|
+
} else if (agentContext && typeof agentContext === 'object' && agentContext.contextId) {
|
|
744
|
+
// Single context object
|
|
745
|
+
contexts = [agentContext];
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (contexts.length === 0) {
|
|
724
749
|
return [];
|
|
725
750
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
751
|
+
|
|
752
|
+
// Normalize options
|
|
753
|
+
const useCache = options.useCache !== false; // default true
|
|
754
|
+
let chatIds = options.chatIds;
|
|
755
|
+
|
|
756
|
+
// Normalize chatIds to array or null
|
|
757
|
+
if (chatIds === undefined || chatIds === null) {
|
|
758
|
+
chatIds = null; // No filtering
|
|
759
|
+
} else if (typeof chatIds === 'string') {
|
|
760
|
+
chatIds = chatIds.trim() ? [chatIds] : null;
|
|
761
|
+
} else if (Array.isArray(chatIds)) {
|
|
762
|
+
chatIds = chatIds.filter(id => id && typeof id === 'string' && id.trim());
|
|
763
|
+
if (chatIds.length === 0) chatIds = null;
|
|
764
|
+
} else {
|
|
765
|
+
chatIds = null;
|
|
736
766
|
}
|
|
737
|
-
|
|
738
|
-
// Load from context-scoped Redis hash map (FileStoreMap:ctx:<contextId>)
|
|
739
|
-
let rawFiles = [];
|
|
740
767
|
|
|
741
|
-
|
|
742
|
-
|
|
768
|
+
// Load files from all contexts
|
|
769
|
+
let allFiles = [];
|
|
770
|
+
|
|
771
|
+
for (const ctx of contexts) {
|
|
772
|
+
const contextId = ctx.contextId;
|
|
773
|
+
const contextKey = ctx.contextKey || null;
|
|
774
|
+
const cacheKey = getCollectionCacheKey(contextId, contextKey);
|
|
743
775
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
776
|
+
let rawFiles = [];
|
|
777
|
+
|
|
778
|
+
// Check cache first
|
|
779
|
+
if (useCache && fileCollectionCache.has(cacheKey)) {
|
|
780
|
+
const cached = fileCollectionCache.get(cacheKey);
|
|
781
|
+
if (Date.now() - cached.timestamp < CACHE_TTL) {
|
|
782
|
+
rawFiles = cached.rawFiles;
|
|
783
|
+
}
|
|
751
784
|
}
|
|
752
|
-
} catch (e) {
|
|
753
|
-
// Collection doesn't exist yet or error reading, start with empty array
|
|
754
|
-
rawFiles = [];
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// Update cache with raw file data (supports any filtering on retrieval)
|
|
758
|
-
if (useCache) {
|
|
759
|
-
fileCollectionCache.set(cacheKey, {
|
|
760
|
-
rawFiles: rawFiles,
|
|
761
|
-
timestamp: Date.now()
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Filter and format for return
|
|
766
|
-
return filterAndFormatFileCollection(rawFiles, chatId);
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/**
|
|
770
|
-
* Load ALL files from a context's file collection, bypassing inCollection filtering.
|
|
771
|
-
* Used when merging alt contexts where we want all files regardless of chat scope.
|
|
772
|
-
* @param {string} contextId - Context ID
|
|
773
|
-
* @param {string|null} contextKey - Optional encryption key
|
|
774
|
-
* @returns {Promise<Array>} All files in the collection
|
|
775
|
-
*/
|
|
776
|
-
async function loadFileCollectionAll(contextId, contextKey = null) {
|
|
777
|
-
if (!contextId) {
|
|
778
|
-
return [];
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
try {
|
|
782
|
-
const redisClient = await getRedisClient();
|
|
783
785
|
|
|
784
|
-
if
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
786
|
+
// Load from Redis if not cached
|
|
787
|
+
if (rawFiles.length === 0) {
|
|
788
|
+
try {
|
|
789
|
+
const redisClient = await getRedisClient();
|
|
790
|
+
if (redisClient) {
|
|
791
|
+
const contextMapKey = `FileStoreMap:ctx:${contextId}`;
|
|
792
|
+
const filesData = await redisClient.hgetall(contextMapKey);
|
|
793
|
+
rawFiles = parseRawFileData(filesData, contextKey);
|
|
794
|
+
|
|
795
|
+
// Update cache
|
|
796
|
+
if (useCache) {
|
|
797
|
+
fileCollectionCache.set(cacheKey, {
|
|
798
|
+
rawFiles: rawFiles,
|
|
799
|
+
timestamp: Date.now()
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
} catch (e) {
|
|
804
|
+
// Collection doesn't exist yet or error reading
|
|
805
|
+
rawFiles = [];
|
|
806
|
+
}
|
|
800
807
|
}
|
|
801
|
-
|
|
802
|
-
//
|
|
808
|
+
|
|
809
|
+
// Tag files with their source context
|
|
810
|
+
allFiles.push(...rawFiles.map(f => ({ ...f, _contextId: contextId })));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Deduplicate by hash/url/gcs (keep first occurrence - primary context wins)
|
|
814
|
+
const seenHashes = new Set();
|
|
815
|
+
const seenUrls = new Set();
|
|
816
|
+
const seenGcs = new Set();
|
|
817
|
+
const deduped = [];
|
|
818
|
+
|
|
819
|
+
for (const file of allFiles) {
|
|
820
|
+
const isDupe = (file.hash && seenHashes.has(file.hash)) ||
|
|
821
|
+
(file.url && seenUrls.has(file.url)) ||
|
|
822
|
+
(file.gcs && seenGcs.has(file.gcs));
|
|
823
|
+
if (!isDupe) {
|
|
824
|
+
if (file.hash) seenHashes.add(file.hash);
|
|
825
|
+
if (file.url) seenUrls.add(file.url);
|
|
826
|
+
if (file.gcs) seenGcs.add(file.gcs);
|
|
827
|
+
deduped.push(file);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Apply filtering
|
|
832
|
+
let filtered;
|
|
833
|
+
if (chatIds !== null) {
|
|
834
|
+
// Filter to files that are in collection for any of the provided chatIds
|
|
835
|
+
filtered = deduped.filter(file => {
|
|
836
|
+
// Check if file is accessible for any of the chatIds
|
|
837
|
+
for (const chatId of chatIds) {
|
|
838
|
+
if (isFileInCollection(file.inCollection, chatId)) {
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return false;
|
|
843
|
+
});
|
|
844
|
+
} else {
|
|
845
|
+
// If chatIds is null, return ALL files
|
|
846
|
+
// Valid inCollection is an array with strings (or boolean true for backward compat)
|
|
847
|
+
// Files with inCollection === undefined/null/[] are included (Labeeb uploads, not in collection - treat the same)
|
|
848
|
+
// Note: false is not a valid value (normalizes to undefined), so we shouldn't see it in data
|
|
849
|
+
filtered = deduped.filter(file => {
|
|
850
|
+
const ic = file.inCollection;
|
|
851
|
+
// Include undefined/null/empty array (Labeeb uploads, not in collection - treat the same)
|
|
852
|
+
if (ic === undefined || ic === null || (Array.isArray(ic) && ic.length === 0)) return true;
|
|
853
|
+
// Include boolean true (backward compat - global)
|
|
854
|
+
if (ic === true) return true;
|
|
855
|
+
// Include valid arrays (non-empty arrays with strings)
|
|
856
|
+
if (Array.isArray(ic) && ic.length > 0) return true;
|
|
857
|
+
// Exclude everything else (invalid formats - defensive programming)
|
|
858
|
+
return false;
|
|
859
|
+
});
|
|
803
860
|
}
|
|
804
861
|
|
|
805
|
-
return
|
|
862
|
+
return sortFilesByLastAccessed(filtered);
|
|
806
863
|
}
|
|
807
864
|
|
|
808
865
|
/**
|
|
809
866
|
* Normalize inCollection value to array format
|
|
810
867
|
* @param {boolean|Array<string>|undefined} inCollection - inCollection value to normalize
|
|
811
|
-
* @returns {Array<string>|undefined} Normalized array or undefined if false/null
|
|
868
|
+
* @returns {Array<string>|undefined} Normalized array, or undefined if false/null/undefined/empty array
|
|
812
869
|
*/
|
|
813
870
|
function normalizeInCollection(inCollection) {
|
|
814
|
-
// If explicitly false
|
|
815
|
-
if (inCollection === false
|
|
871
|
+
// If explicitly false, return undefined (not in collection, same as undefined)
|
|
872
|
+
if (inCollection === false) {
|
|
816
873
|
return undefined;
|
|
817
874
|
}
|
|
818
875
|
|
|
819
|
-
// If undefined, return undefined (
|
|
820
|
-
if (inCollection === undefined) {
|
|
876
|
+
// If null or undefined, return undefined (file not in collection)
|
|
877
|
+
if (inCollection === null || inCollection === undefined) {
|
|
878
|
+
return undefined;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// If empty array, return undefined (not in collection, same as undefined)
|
|
882
|
+
if (Array.isArray(inCollection) && inCollection.length === 0) {
|
|
821
883
|
return undefined;
|
|
822
884
|
}
|
|
823
885
|
|
|
@@ -947,6 +1009,7 @@ async function updateFileMetadata(contextId, hash, metadata, contextKey = null,
|
|
|
947
1009
|
const fileData = {
|
|
948
1010
|
...existingData, // Preserve all CFH data (url, gcs, hash, filename, etc.)
|
|
949
1011
|
// Handle inCollection: normalize if provided, otherwise preserve existing or default based on chatId
|
|
1012
|
+
// normalizeInCollection converts false/null/undefined/[] to undefined (no collection membership)
|
|
950
1013
|
inCollection: metadata.inCollection !== undefined
|
|
951
1014
|
? normalizeInCollection(metadata.inCollection)
|
|
952
1015
|
: (existingData.inCollection !== undefined
|
|
@@ -964,6 +1027,7 @@ async function updateFileMetadata(contextId, hash, metadata, contextKey = null,
|
|
|
964
1027
|
};
|
|
965
1028
|
|
|
966
1029
|
// Remove inCollection if it's undefined (file not in collection)
|
|
1030
|
+
// Empty arrays [] are normalized to undefined, so they get deleted too
|
|
967
1031
|
if (fileData.inCollection === undefined) {
|
|
968
1032
|
delete fileData.inCollection;
|
|
969
1033
|
}
|
|
@@ -1377,7 +1441,7 @@ async function getAvailableFilesFromCollection(contextId, contextKey = null) {
|
|
|
1377
1441
|
return 'No files available.';
|
|
1378
1442
|
}
|
|
1379
1443
|
|
|
1380
|
-
const collection = await loadFileCollection(contextId, contextKey, true);
|
|
1444
|
+
const collection = await loadFileCollection({ contextId, contextKey, default: true });
|
|
1381
1445
|
return formatFilesForTemplate(collection);
|
|
1382
1446
|
}
|
|
1383
1447
|
|
|
@@ -1440,81 +1504,6 @@ function getDefaultContext(agentContext) {
|
|
|
1440
1504
|
return agentContext.find(ctx => ctx.default === true) || agentContext[0] || null;
|
|
1441
1505
|
}
|
|
1442
1506
|
|
|
1443
|
-
/**
|
|
1444
|
-
* Load merged file collection from agentContext array
|
|
1445
|
-
* Merges all contexts in the array for read operations
|
|
1446
|
-
* @param {Array} agentContext - Array of context objects { contextId, contextKey, default }
|
|
1447
|
-
* @param {string|null} chatId - Optional chat ID to filter files by (if provided, only includes files with '*' or this chatId in inCollection)
|
|
1448
|
-
* @returns {Promise<Array>} Merged file collection
|
|
1449
|
-
*/
|
|
1450
|
-
async function loadMergedFileCollection(agentContext, chatId = null) {
|
|
1451
|
-
if (!agentContext || !Array.isArray(agentContext) || agentContext.length === 0) {
|
|
1452
|
-
return [];
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
// Load first context as primary
|
|
1456
|
-
// If chatId is provided, use loadFileCollection to filter by chatId
|
|
1457
|
-
// Otherwise, use loadFileCollectionAll to get all files (we'll filter by inCollection below)
|
|
1458
|
-
const primaryCtx = agentContext[0];
|
|
1459
|
-
const primaryCollection = chatId
|
|
1460
|
-
? await loadFileCollection(primaryCtx.contextId, primaryCtx.contextKey || null, true, chatId)
|
|
1461
|
-
: await loadFileCollectionAll(primaryCtx.contextId, primaryCtx.contextKey || null);
|
|
1462
|
-
|
|
1463
|
-
// Tag primary files with their source context
|
|
1464
|
-
let collection = primaryCollection.map(f => ({ ...f, _contextId: primaryCtx.contextId }));
|
|
1465
|
-
|
|
1466
|
-
// Load and merge additional contexts
|
|
1467
|
-
for (let i = 1; i < agentContext.length; i++) {
|
|
1468
|
-
const ctx = agentContext[i];
|
|
1469
|
-
if (!ctx.contextId) continue;
|
|
1470
|
-
|
|
1471
|
-
// Load alternate collection
|
|
1472
|
-
// If chatId is provided, use loadFileCollection to filter by chatId
|
|
1473
|
-
// Otherwise, use loadFileCollectionAll to get all files
|
|
1474
|
-
const altCollection = chatId
|
|
1475
|
-
? await loadFileCollection(ctx.contextId, ctx.contextKey || null, true, chatId)
|
|
1476
|
-
: await loadFileCollectionAll(ctx.contextId, ctx.contextKey || null);
|
|
1477
|
-
|
|
1478
|
-
// Build set of existing identifiers from current collection
|
|
1479
|
-
const existingHashes = new Set(collection.map(f => f.hash).filter(Boolean));
|
|
1480
|
-
const existingUrls = new Set(collection.map(f => f.url).filter(Boolean));
|
|
1481
|
-
const existingGcs = new Set(collection.map(f => f.gcs).filter(Boolean));
|
|
1482
|
-
|
|
1483
|
-
// Add files from alt collection that aren't already in collection, tagged with alt context
|
|
1484
|
-
for (const file of altCollection) {
|
|
1485
|
-
const isDupe = (file.hash && existingHashes.has(file.hash)) ||
|
|
1486
|
-
(file.url && existingUrls.has(file.url)) ||
|
|
1487
|
-
(file.gcs && existingGcs.has(file.gcs));
|
|
1488
|
-
if (!isDupe) {
|
|
1489
|
-
collection.push({ ...file, _contextId: ctx.contextId });
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
// When chatId is null (includeAllChats=true), filter to only include files with inCollection set
|
|
1495
|
-
// Agent tools should only see files that are actually in the collection (have inCollection set)
|
|
1496
|
-
if (chatId === null) {
|
|
1497
|
-
collection = collection.filter(file => {
|
|
1498
|
-
const inCollection = file.inCollection;
|
|
1499
|
-
|
|
1500
|
-
// Exclude files without inCollection set or with empty inCollection array/string
|
|
1501
|
-
if (inCollection === undefined || inCollection === null || inCollection === false || inCollection === '') {
|
|
1502
|
-
return false;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
// Exclude empty arrays (file not in any collection)
|
|
1506
|
-
if (Array.isArray(inCollection) && inCollection.length === 0) {
|
|
1507
|
-
return false;
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// Include files with inCollection set (truthy and non-empty)
|
|
1511
|
-
return true;
|
|
1512
|
-
});
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
return collection;
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
1507
|
/**
|
|
1519
1508
|
* Get available files from file collection (no syncing from chat history)
|
|
1520
1509
|
* @param {Array} chatHistory - Unused, kept for API compatibility
|
|
@@ -1525,7 +1514,8 @@ async function getAvailableFiles(chatHistory, agentContext) {
|
|
|
1525
1514
|
if (!agentContext || !Array.isArray(agentContext) || agentContext.length === 0) {
|
|
1526
1515
|
return 'No files available.';
|
|
1527
1516
|
}
|
|
1528
|
-
|
|
1517
|
+
// Load all files from all contexts (no chatIds = no filtering)
|
|
1518
|
+
const collection = await loadFileCollection(agentContext);
|
|
1529
1519
|
// Strip internal _contextId before formatting
|
|
1530
1520
|
const cleanCollection = collection.map(({ _contextId, ...file }) => file);
|
|
1531
1521
|
return formatFilesForTemplate(cleanCollection);
|
|
@@ -1551,16 +1541,32 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
|
|
|
1551
1541
|
return { chatHistory, availableFiles: 'No files available.' };
|
|
1552
1542
|
}
|
|
1553
1543
|
|
|
1554
|
-
// Load
|
|
1555
|
-
|
|
1544
|
+
// Load ALL files from all contexts (no chatIds = no filtering)
|
|
1545
|
+
// This finds files even if they don't have inCollection set (Labeeb uploads)
|
|
1546
|
+
const allFiles = await loadFileCollection(agentContext);
|
|
1547
|
+
|
|
1548
|
+
// For availableFiles output, filter to only files with inCollection set
|
|
1549
|
+
const collection = chatId
|
|
1550
|
+
? await loadFileCollection(agentContext, { chatIds: [chatId] })
|
|
1551
|
+
: allFiles.filter(file => {
|
|
1552
|
+
const inCollection = file.inCollection;
|
|
1553
|
+
if (inCollection === undefined || inCollection === null || inCollection === false || inCollection === '') {
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
if (Array.isArray(inCollection) && inCollection.length === 0) {
|
|
1557
|
+
return false;
|
|
1558
|
+
}
|
|
1559
|
+
return true;
|
|
1560
|
+
});
|
|
1556
1561
|
|
|
1557
1562
|
// Build lookup map from contextId to contextKey for updates
|
|
1558
1563
|
const contextKeyMap = new Map(agentContext.map(ctx => [ctx.contextId, ctx.contextKey || null]));
|
|
1559
1564
|
|
|
1560
|
-
// Build lookup maps for fast matching and context lookup (
|
|
1561
|
-
|
|
1562
|
-
const
|
|
1563
|
-
const
|
|
1565
|
+
// Build lookup maps for fast matching and context lookup (use ALL files, not just filtered)
|
|
1566
|
+
// This allows us to find files that exist in Redis but don't have inCollection set yet
|
|
1567
|
+
const collectionByHash = new Map(allFiles.filter(f => f.hash).map(f => [f.hash, f]));
|
|
1568
|
+
const collectionByUrl = new Map(allFiles.filter(f => f.url).map(f => [f.url, f]));
|
|
1569
|
+
const collectionByGcs = new Map(allFiles.filter(f => f.gcs).map(f => [f.gcs, f]));
|
|
1564
1570
|
|
|
1565
1571
|
// Helper to get file from collection (by hash, URL, or GCS) to find _contextId
|
|
1566
1572
|
const getFileFromCollection = (contentObj) => {
|
|
@@ -1600,11 +1606,14 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
|
|
|
1600
1606
|
const now = new Date().toISOString();
|
|
1601
1607
|
// Update lastAccessed and add chatId to inCollection (reference counting)
|
|
1602
1608
|
// If this file is being used in a new chat, add that chat to the list
|
|
1603
|
-
|
|
1609
|
+
// If file doesn't have inCollection set yet (e.g., uploaded by Labeeb), initialize it
|
|
1610
|
+
const updatedInCollection = file.inCollection
|
|
1611
|
+
? addChatIdToInCollection(file.inCollection, chatId)
|
|
1612
|
+
: getInCollectionValue(chatId);
|
|
1604
1613
|
updateFileMetadata(file._contextId, hash, {
|
|
1605
1614
|
lastAccessed: now,
|
|
1606
1615
|
inCollection: updatedInCollection
|
|
1607
|
-
}, fileContextKey).catch((err) => {
|
|
1616
|
+
}, fileContextKey, chatId).catch((err) => {
|
|
1608
1617
|
logger.warn(`Failed to update metadata for stripped file (hash=${hash}): ${err?.message || err}`);
|
|
1609
1618
|
});
|
|
1610
1619
|
};
|
|
@@ -1626,8 +1635,8 @@ async function syncAndStripFilesFromChatHistory(chatHistory, agentContext, chatI
|
|
|
1626
1635
|
const filename = extractFilenameFromFileContent(contentObj);
|
|
1627
1636
|
return { type: 'text', text: `[File: ${filename} - available via file tools]` };
|
|
1628
1637
|
}
|
|
1629
|
-
// Not in collection - leave as-is
|
|
1630
|
-
return
|
|
1638
|
+
// Not in collection - leave as-is (use parsed object if it was parsed from string)
|
|
1639
|
+
return contentObj;
|
|
1631
1640
|
}
|
|
1632
1641
|
return item;
|
|
1633
1642
|
});
|
|
@@ -1817,9 +1826,8 @@ export async function resolveFileParameter(fileParam, agentContext, options = {}
|
|
|
1817
1826
|
}
|
|
1818
1827
|
|
|
1819
1828
|
try {
|
|
1820
|
-
// Load
|
|
1821
|
-
|
|
1822
|
-
const collection = await loadMergedFileCollection(agentContext);
|
|
1829
|
+
// Load all files from all contexts (no chatIds = no filtering)
|
|
1830
|
+
const collection = await loadFileCollection(agentContext);
|
|
1823
1831
|
|
|
1824
1832
|
const foundFile = findFileInCollection(trimmed, collection);
|
|
1825
1833
|
|
|
@@ -1875,8 +1883,9 @@ async function generateFileMessageContent(fileParam, agentContext) {
|
|
|
1875
1883
|
return null;
|
|
1876
1884
|
}
|
|
1877
1885
|
|
|
1878
|
-
// Load
|
|
1879
|
-
|
|
1886
|
+
// Load all files from all contexts (no chatIds = no filtering)
|
|
1887
|
+
// Use useCache: false to ensure we get fresh data (important for recently added files)
|
|
1888
|
+
const collection = await loadFileCollection(agentContext, { useCache: false });
|
|
1880
1889
|
|
|
1881
1890
|
// Find the file using shared matching logic
|
|
1882
1891
|
const foundFile = findFileInCollection(fileParam, collection);
|
|
@@ -2588,7 +2597,6 @@ export {
|
|
|
2588
2597
|
extractFilesFromChatHistory,
|
|
2589
2598
|
getAvailableFilesFromCollection,
|
|
2590
2599
|
getDefaultContext,
|
|
2591
|
-
loadMergedFileCollection,
|
|
2592
2600
|
formatFilesForTemplate,
|
|
2593
2601
|
getAvailableFiles,
|
|
2594
2602
|
syncAndStripFilesFromChatHistory,
|
|
@@ -2598,7 +2606,6 @@ export {
|
|
|
2598
2606
|
injectFileIntoChatHistory,
|
|
2599
2607
|
addFileToCollection,
|
|
2600
2608
|
loadFileCollection,
|
|
2601
|
-
loadFileCollectionAll,
|
|
2602
2609
|
saveFileCollection,
|
|
2603
2610
|
updateFileMetadata,
|
|
2604
2611
|
getCollectionCacheKey,
|
package/lib/pathwayManager.js
CHANGED
|
@@ -349,6 +349,7 @@ class PathwayManager {
|
|
|
349
349
|
const promptName = typeof promptItem === 'string' ? defaultName : (promptItem.name || defaultName);
|
|
350
350
|
const promptFiles = typeof promptItem === 'string' ? [] : (promptItem.files || []);
|
|
351
351
|
const cortexPathwayName = typeof promptItem === 'string' ? null : (promptItem.cortexPathwayName || null);
|
|
352
|
+
const researchMode = typeof promptItem === 'string' ? undefined : (promptItem.researchMode !== undefined ? promptItem.researchMode : undefined);
|
|
352
353
|
|
|
353
354
|
const messages = [];
|
|
354
355
|
|
|
@@ -383,6 +384,11 @@ class PathwayManager {
|
|
|
383
384
|
prompt.cortexPathwayName = cortexPathwayName;
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
// Preserve researchMode if present
|
|
388
|
+
if (researchMode !== undefined) {
|
|
389
|
+
prompt.researchMode = researchMode;
|
|
390
|
+
}
|
|
391
|
+
|
|
386
392
|
return prompt;
|
|
387
393
|
}
|
|
388
394
|
|
|
@@ -460,6 +466,7 @@ class PathwayManager {
|
|
|
460
466
|
prompt: String!
|
|
461
467
|
files: [String!]
|
|
462
468
|
cortexPathwayName: String
|
|
469
|
+
researchMode: Boolean
|
|
463
470
|
}
|
|
464
471
|
|
|
465
472
|
input PathwayInput {
|
package/lib/pathwayTools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getSemanticChunks } from "../server/chunker.js";
|
|
|
6
6
|
import logger from '../lib/logger.js';
|
|
7
7
|
import { requestState } from '../server/requestState.js';
|
|
8
8
|
import { processPathwayParameters } from '../server/typeDef.js';
|
|
9
|
+
import { waitForClientToolResult } from '../server/clientToolCallbacks.js';
|
|
9
10
|
|
|
10
11
|
// callPathway - call a pathway from another pathway
|
|
11
12
|
const callPathway = async (pathwayName, inArgs, pathwayResolver) => {
|
|
@@ -91,6 +92,76 @@ const callTool = async (toolName, args, toolDefinitions, pathwayResolver) => {
|
|
|
91
92
|
logger.debug(`callTool: Starting execution of ${toolName} ${JSON.stringify(logArgs)}`);
|
|
92
93
|
|
|
93
94
|
try {
|
|
95
|
+
// Check if this is a client-side tool
|
|
96
|
+
if (toolDef.clientSide === true || toolDef.definition?.clientSide === true) {
|
|
97
|
+
logger.info(`Tool ${toolName} is a client-side tool - waiting for client execution`);
|
|
98
|
+
|
|
99
|
+
const toolCallbackId = `${toolName}_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
100
|
+
|
|
101
|
+
// Explicitly publish the marker to the stream so the client receives it
|
|
102
|
+
if (pathwayResolver) {
|
|
103
|
+
const requestId = pathwayResolver.rootRequestId || pathwayResolver.requestId;
|
|
104
|
+
|
|
105
|
+
const toolCallbackData = {
|
|
106
|
+
toolUsed: [toolName],
|
|
107
|
+
clientSideTool: true,
|
|
108
|
+
toolCallbackName: toolName,
|
|
109
|
+
toolCallbackId: toolCallbackId,
|
|
110
|
+
toolCallbackMessage: args.userMessage || `Executing ${toolName}...`,
|
|
111
|
+
chatId: args.chatId || "",
|
|
112
|
+
requestId: requestId, // Include requestId so client can submit tool results
|
|
113
|
+
toolArgs: args
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
logger.info(`Publishing client-side tool marker to requestId: ${requestId}, toolCallbackId: ${toolCallbackId}`);
|
|
118
|
+
await publishRequestProgress({
|
|
119
|
+
requestId,
|
|
120
|
+
progress: 0.5,
|
|
121
|
+
data: JSON.stringify(""),
|
|
122
|
+
info: JSON.stringify(toolCallbackData)
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.error(`Error publishing client-side tool marker: ${error.message}`);
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Wait for the client to execute the tool and send back the result
|
|
130
|
+
logger.info(`Waiting for client tool result: ${toolCallbackId}`);
|
|
131
|
+
try {
|
|
132
|
+
// Use 5 minute timeout to accommodate longer operations like CreateApplet
|
|
133
|
+
const clientResult = await waitForClientToolResult(toolCallbackId, requestId, 300000);
|
|
134
|
+
logger.info(`Received client tool result for ${toolCallbackId}: ${JSON.stringify(clientResult).substring(0, 200)}`);
|
|
135
|
+
|
|
136
|
+
// If the client reported an error, throw it
|
|
137
|
+
if (!clientResult.success) {
|
|
138
|
+
throw new Error(clientResult.error || 'Client tool execution failed');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Return the client's result
|
|
142
|
+
toolResult = typeof clientResult.data === 'string'
|
|
143
|
+
? clientResult.data
|
|
144
|
+
: JSON.stringify(clientResult.data);
|
|
145
|
+
|
|
146
|
+
// Update resolver with tool result
|
|
147
|
+
pathwayResolver.tool = JSON.stringify({
|
|
148
|
+
...toolCallbackData,
|
|
149
|
+
result: clientResult.data
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
result: toolResult,
|
|
154
|
+
images: []
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
logger.error(`Error waiting for client tool result: ${error.message}`);
|
|
158
|
+
throw new Error(`Client tool execution failed: ${error.message}`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
throw new Error('PathwayResolver is required for client-side tools');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
94
165
|
const pathwayName = toolDef.pathwayName;
|
|
95
166
|
// Merge hard-coded pathway parameters with runtime args
|
|
96
167
|
const mergedArgs = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aj-archipelago/cortex",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.32",
|
|
4
4
|
"description": "Cortex is a GraphQL API for AI. It provides a simple, extensible interface for using AI services from OpenAI, Azure and others.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// File collections are stored in Redis hash maps (FileStoreMap:ctx:<contextId>
|
|
4
4
|
// Returns file collection as JSON array string for backward compatibility with Labeeb
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { loadFileCollection } from '../../../../lib/fileUtils.js';
|
|
7
7
|
|
|
8
8
|
export default {
|
|
9
9
|
inputParameters: {
|
|
@@ -33,8 +33,8 @@ export default {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
try {
|
|
36
|
-
// Load file collection from Redis hash maps (
|
|
37
|
-
const collection = await
|
|
36
|
+
// Load file collection from Redis hash maps (from all agentContext contexts)
|
|
37
|
+
const collection = await loadFileCollection(agentContext);
|
|
38
38
|
|
|
39
39
|
// Return as JSON array string for backward compatibility with Labeeb
|
|
40
40
|
// Labeeb expects either: [] or { version: "...", files: [...] }
|