@aj-archipelago/cortex 1.4.31 → 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.
@@ -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 {
@@ -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.31",
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": {
@@ -79,7 +79,13 @@ export default {
79
79
  entityId: ``,
80
80
  researchMode: false,
81
81
  userInfo: '',
82
- model: 'oai-gpt41'
82
+ model: 'oai-gpt41',
83
+ contextKey: ``,
84
+ clientSideTools: {
85
+ type: 'array',
86
+ items: { type: 'object' },
87
+ default: []
88
+ }
83
89
  },
84
90
  timeout: 600,
85
91
 
@@ -449,16 +455,48 @@ export default {
449
455
  let pathwayResolver = resolver;
450
456
 
451
457
  // Load input parameters and information into args
452
- const { entityId, voiceResponse, aiMemorySelfModify, chatId, researchMode } = { ...pathwayResolver.pathway.inputParameters, ...args };
458
+ let { entityId, voiceResponse, aiMemorySelfModify, chatId, researchMode, clientSideTools } = { ...pathwayResolver.pathway.inputParameters, ...args };
459
+
460
+ // Parse clientSideTools if it's a string (from GraphQL)
461
+ if (typeof clientSideTools === 'string') {
462
+ try {
463
+ clientSideTools = JSON.parse(clientSideTools);
464
+ } catch (e) {
465
+ logger.error(`Failed to parse clientSideTools: ${e.message}`);
466
+ clientSideTools = [];
467
+ }
468
+ }
453
469
 
454
470
  const entityConfig = loadEntityConfig(entityId);
455
- const { entityTools, entityToolsOpenAiFormat } = getToolsForEntity(entityConfig);
471
+ let { entityTools, entityToolsOpenAiFormat } = getToolsForEntity(entityConfig);
456
472
  const { name: entityName, instructions: entityInstructions } = entityConfig || {};
457
473
 
458
474
  // Determine useMemory: entityConfig.useMemory === false is a hard disable (entity can't use memory)
459
475
  // Otherwise args.useMemory can disable it, default true
460
476
  args.useMemory = entityConfig?.useMemory === false ? false : (args.useMemory ?? true);
461
477
 
478
+ // Add client-side tools from the caller
479
+ if (clientSideTools && Array.isArray(clientSideTools) && clientSideTools.length > 0) {
480
+ logger.info(`Adding ${clientSideTools.length} client-side tools from caller`);
481
+ clientSideTools.forEach(tool => {
482
+ const toolName = tool.function?.name?.toLowerCase();
483
+ if (toolName) {
484
+ // Mark as client-side tool and add to available tools
485
+ entityTools[toolName] = {
486
+ definition: {
487
+ ...tool,
488
+ clientSide: true, // Mark it as client-side
489
+ icon: tool.icon || '📱'
490
+ },
491
+ pathwayName: 'client_side_execution', // Placeholder pathway
492
+ clientSide: true
493
+ };
494
+ entityToolsOpenAiFormat.push(tool);
495
+ logger.info(`Registered client-side tool: ${toolName}`);
496
+ }
497
+ });
498
+ }
499
+
462
500
  // Initialize chat history if needed
463
501
  if (!args.chatHistory || args.chatHistory.length === 0) {
464
502
  args.chatHistory = [];
@@ -0,0 +1,241 @@
1
+ // clientToolCallbacks.js
2
+ // Storage and management for pending client-side tool callbacks
3
+
4
+ import logger from '../lib/logger.js';
5
+ import Redis from 'ioredis';
6
+ import { config } from '../config.js';
7
+
8
+ // Map to store pending client tool callbacks
9
+ // Key: toolCallbackId, Value: { resolve, reject, timeout, requestId }
10
+ const pendingCallbacks = new Map();
11
+
12
+ // Default timeout for client tool responses (5 minutes)
13
+ // Increased from 60s to 5min to accommodate longer operations like CreateApplet
14
+ const DEFAULT_TIMEOUT = 300000;
15
+
16
+ // Redis setup for cross-instance communication
17
+ const connectionString = config.get('storageConnectionString');
18
+ const clientToolCallbackChannel = 'clientToolCallbacks';
19
+
20
+ let subscriptionClient;
21
+ let publisherClient;
22
+
23
+ if (connectionString) {
24
+ logger.info(`Setting up Redis pub/sub for client tool callbacks on channel: ${clientToolCallbackChannel}`);
25
+
26
+ try {
27
+ subscriptionClient = new Redis(connectionString);
28
+ subscriptionClient.on('error', (error) => {
29
+ logger.error(`Redis subscriptionClient error (clientToolCallbacks): ${error}`);
30
+ });
31
+
32
+ subscriptionClient.on('connect', () => {
33
+ subscriptionClient.subscribe(clientToolCallbackChannel, (error) => {
34
+ if (error) {
35
+ logger.error(`Error subscribing to Redis channel ${clientToolCallbackChannel}: ${error}`);
36
+ } else {
37
+ logger.info(`Subscribed to client tool callback channel: ${clientToolCallbackChannel}`);
38
+ }
39
+ });
40
+ });
41
+
42
+ subscriptionClient.on('message', (channel, message) => {
43
+ if (channel === clientToolCallbackChannel) {
44
+ try {
45
+ const { toolCallbackId, result } = JSON.parse(message);
46
+ logger.debug(`Received client tool callback via Redis: ${toolCallbackId}`);
47
+
48
+ // Try to resolve it locally (will only work if this instance has the pending callback)
49
+ resolveClientToolCallbackLocal(toolCallbackId, result);
50
+ } catch (error) {
51
+ logger.error(`Error processing client tool callback from Redis: ${error}`);
52
+ }
53
+ }
54
+ });
55
+ } catch (error) {
56
+ logger.error(`Redis connection error (clientToolCallbacks): ${error}`);
57
+ }
58
+
59
+ try {
60
+ publisherClient = new Redis(connectionString);
61
+ publisherClient.on('error', (error) => {
62
+ logger.error(`Redis publisherClient error (clientToolCallbacks): ${error}`);
63
+ });
64
+ } catch (error) {
65
+ logger.error(`Redis connection error (clientToolCallbacks): ${error}`);
66
+ }
67
+ } else {
68
+ logger.info('No Redis connection configured. Client tool callbacks will only work on single instance.');
69
+ }
70
+
71
+ /**
72
+ * Register a pending client tool callback
73
+ * @param {string} toolCallbackId - Unique ID for this tool call
74
+ * @param {string} requestId - The request ID for logging/tracking
75
+ * @param {number} timeoutMs - Timeout in milliseconds
76
+ * @returns {Promise} Promise that resolves when client submits the result
77
+ */
78
+ export function waitForClientToolResult(toolCallbackId, requestId, timeoutMs = DEFAULT_TIMEOUT) {
79
+ return new Promise((resolve, reject) => {
80
+ // Set up timeout
81
+ const timeout = setTimeout(() => {
82
+ pendingCallbacks.delete(toolCallbackId);
83
+ logger.error(`Client tool callback timeout for ${toolCallbackId} (requestId: ${requestId})`);
84
+ reject(new Error(`Client tool execution timeout after ${timeoutMs}ms`));
85
+ }, timeoutMs);
86
+
87
+ // Store the callback
88
+ pendingCallbacks.set(toolCallbackId, {
89
+ resolve,
90
+ reject,
91
+ timeout,
92
+ requestId,
93
+ createdAt: Date.now()
94
+ });
95
+
96
+ logger.info(`Registered client tool callback: ${toolCallbackId} (requestId: ${requestId})`);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Resolve a pending client tool callback locally (internal use)
102
+ * @param {string} toolCallbackId - The tool callback ID
103
+ * @param {object} result - The result from the client
104
+ * @returns {boolean} True if callback was found and resolved
105
+ */
106
+ function resolveClientToolCallbackLocal(toolCallbackId, result) {
107
+ const callback = pendingCallbacks.get(toolCallbackId);
108
+
109
+ if (!callback) {
110
+ // This is normal in a multi-instance setup - the callback might be on another instance
111
+ logger.debug(`No pending callback found for toolCallbackId: ${toolCallbackId} (may be on another instance)`);
112
+ return false;
113
+ }
114
+
115
+ // Clear the timeout
116
+ clearTimeout(callback.timeout);
117
+
118
+ // Remove from pending
119
+ pendingCallbacks.delete(toolCallbackId);
120
+
121
+ logger.info(`Resolved client tool callback: ${toolCallbackId} (requestId: ${callback.requestId})`);
122
+
123
+ // Resolve the promise
124
+ callback.resolve(result);
125
+
126
+ return true;
127
+ }
128
+
129
+ /**
130
+ * Resolve a pending client tool callback with the result
131
+ * This function publishes to Redis so all instances can attempt to resolve
132
+ * @param {string} toolCallbackId - The tool callback ID
133
+ * @param {object} result - The result from the client
134
+ * @returns {Promise<boolean>} True if callback was published/resolved
135
+ */
136
+ export async function resolveClientToolCallback(toolCallbackId, result) {
137
+ if (publisherClient) {
138
+ // Publish to Redis so all instances can try to resolve
139
+ try {
140
+ const message = JSON.stringify({ toolCallbackId, result });
141
+ logger.debug(`Publishing client tool callback to Redis: ${toolCallbackId}`);
142
+ await publisherClient.publish(clientToolCallbackChannel, message);
143
+ return true;
144
+ } catch (error) {
145
+ logger.error(`Error publishing client tool callback to Redis: ${error}`);
146
+ // Fall back to local resolution
147
+ return resolveClientToolCallbackLocal(toolCallbackId, result);
148
+ }
149
+ } else {
150
+ // No Redis, resolve locally
151
+ return resolveClientToolCallbackLocal(toolCallbackId, result);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Reject a pending client tool callback locally (internal use)
157
+ * @param {string} toolCallbackId - The tool callback ID
158
+ * @param {Error} error - The error
159
+ * @returns {boolean} True if callback was found and rejected
160
+ */
161
+ function rejectClientToolCallbackLocal(toolCallbackId, error) {
162
+ const callback = pendingCallbacks.get(toolCallbackId);
163
+
164
+ if (!callback) {
165
+ logger.debug(`No pending callback found for toolCallbackId: ${toolCallbackId} (may be on another instance)`);
166
+ return false;
167
+ }
168
+
169
+ // Clear the timeout
170
+ clearTimeout(callback.timeout);
171
+
172
+ // Remove from pending
173
+ pendingCallbacks.delete(toolCallbackId);
174
+
175
+ logger.info(`Rejected client tool callback: ${toolCallbackId} (requestId: ${callback.requestId})`);
176
+
177
+ // Reject the promise
178
+ callback.reject(error);
179
+
180
+ return true;
181
+ }
182
+
183
+ /**
184
+ * Reject a pending client tool callback with an error
185
+ * This function publishes to Redis so all instances can attempt to reject
186
+ * @param {string} toolCallbackId - The tool callback ID
187
+ * @param {Error} error - The error
188
+ * @returns {Promise<boolean>} True if callback was published/rejected
189
+ */
190
+ export async function rejectClientToolCallback(toolCallbackId, error) {
191
+ if (publisherClient) {
192
+ // Publish to Redis so all instances can try to reject
193
+ try {
194
+ const message = JSON.stringify({
195
+ toolCallbackId,
196
+ result: { success: false, error: error.message || error.toString() }
197
+ });
198
+ logger.debug(`Publishing client tool callback rejection to Redis: ${toolCallbackId}`);
199
+ await publisherClient.publish(clientToolCallbackChannel, message);
200
+ return true;
201
+ } catch (publishError) {
202
+ logger.error(`Error publishing client tool callback rejection to Redis: ${publishError}`);
203
+ // Fall back to local rejection
204
+ return rejectClientToolCallbackLocal(toolCallbackId, error);
205
+ }
206
+ } else {
207
+ // No Redis, reject locally
208
+ return rejectClientToolCallbackLocal(toolCallbackId, error);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Get count of pending callbacks (for monitoring)
214
+ */
215
+ export function getPendingCallbackCount() {
216
+ return pendingCallbacks.size;
217
+ }
218
+
219
+ /**
220
+ * Clean up old callbacks (for maintenance)
221
+ */
222
+ export function cleanupOldCallbacks(maxAgeMs = 120000) {
223
+ const now = Date.now();
224
+ let cleaned = 0;
225
+
226
+ for (const [id, callback] of pendingCallbacks.entries()) {
227
+ if (now - callback.createdAt > maxAgeMs) {
228
+ clearTimeout(callback.timeout);
229
+ pendingCallbacks.delete(id);
230
+ callback.reject(new Error('Callback expired during cleanup'));
231
+ cleaned++;
232
+ }
233
+ }
234
+
235
+ if (cleaned > 0) {
236
+ logger.info(`Cleaned up ${cleaned} old client tool callbacks`);
237
+ }
238
+
239
+ return cleaned;
240
+ }
241
+
@@ -80,6 +80,7 @@ const resolveAndAddFileContent = async (pathways, pathwayArgs, requestId, config
80
80
 
81
81
  // Helper function to execute pathway with cortex pathway name or fallback to legacy
82
82
  const executePathwayWithFallback = async (pathway, pathwayArgs, contextValue, info, requestId, originalPrompt = null, config) => {
83
+ // Extract cortexPathwayName from originalPrompt (could be original object or transformed Prompt object)
83
84
  const cortexPathwayName = (originalPrompt && typeof originalPrompt === 'object' && originalPrompt.cortexPathwayName)
84
85
  ? originalPrompt.cortexPathwayName
85
86
  : null;
@@ -100,6 +101,12 @@ const executePathwayWithFallback = async (pathway, pathwayArgs, contextValue, in
100
101
  // Remove old aiStyle parameter (no longer used)
101
102
  delete cortexArgs.aiStyle;
102
103
 
104
+ // Extract researchMode from originalPrompt if not already in pathwayArgs
105
+ // originalPrompt could be the original object from JSON or a transformed Prompt object
106
+ if (originalPrompt && typeof originalPrompt === 'object' && originalPrompt.researchMode !== undefined) {
107
+ cortexArgs.researchMode = originalPrompt.researchMode;
108
+ }
109
+
103
110
  // Transform context parameters to agentContext array format (only if agentContext not already provided)
104
111
  if (!cortexArgs.agentContext && (cortexArgs.contextId || cortexArgs.contextKey || cortexArgs.altContextId || cortexArgs.altContextKey)) {
105
112
  const agentContext = [];
package/server/graphql.js CHANGED
@@ -20,7 +20,7 @@ import logger from '../lib/logger.js';
20
20
  import { buildModelEndpoints } from '../lib/requestExecutor.js';
21
21
  import { startTestServer } from '../tests/helpers/server.js';
22
22
  import { requestState } from './requestState.js';
23
- import { cancelRequestResolver } from './resolver.js';
23
+ import { cancelRequestResolver, submitClientToolResultResolver } from './resolver.js';
24
24
  import subscriptions from './subscriptions.js';
25
25
  import { getMessageTypeDefs } from './typeDef.js';
26
26
  import { buildRestEndpoints } from './rest.js';
@@ -79,6 +79,7 @@ const getTypedefs = (pathways, pathwayManager) => {
79
79
 
80
80
  type Mutation {
81
81
  cancelRequest(requestId: String!): Boolean
82
+ submitClientToolResult(requestId: String!, toolCallbackId: String!, result: String!, success: Boolean!): Boolean
82
83
  }
83
84
 
84
85
  ${getExecuteWorkspaceTypeDefs()}
@@ -140,6 +141,7 @@ const getResolvers = (config, pathways, pathwayManager) => {
140
141
  },
141
142
  Mutation: {
142
143
  'cancelRequest': cancelRequestResolver,
144
+ 'submitClientToolResult': submitClientToolResultResolver,
143
145
  ...mutationResolvers,
144
146
  ...pathwayManagerResolvers.Mutation
145
147
  },
@@ -44,8 +44,9 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
44
44
  try {
45
45
  // First try to parse as JSON if it's a string
46
46
  const part = typeof inputPart === 'string' ? JSON.parse(inputPart) : inputPart;
47
- const {type, text, image_url, gcs} = part;
48
- let fileUrl = gcs || image_url?.url;
47
+ const {type, text, image_url, gcs, url} = part;
48
+ // Check for URL in multiple places: gcs, image_url.url, or direct url property
49
+ let fileUrl = gcs || image_url?.url || url;
49
50
 
50
51
  if (typeof part === 'string') {
51
52
  return { text: inputPart };
@@ -72,9 +73,12 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
72
73
  if (!base64Data) {
73
74
  return null;
74
75
  }
76
+ // Extract MIME type from data URL if available
77
+ const mimeMatch = fileUrl.match(/data:([^;]+);base64,/);
78
+ const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg';
75
79
  return {
76
80
  inlineData: {
77
- mimeType: 'image/jpeg',
81
+ mimeType: mimeType,
78
82
  data: base64Data
79
83
  }
80
84
  };
@@ -85,6 +89,15 @@ class Gemini15VisionPlugin extends Gemini15ChatPlugin {
85
89
  fileUri: fileUrl
86
90
  }
87
91
  };
92
+ } else if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
93
+ // Gemini can read directly from HTTP/HTTPS URLs using fileData with fileUri
94
+ // No need to fetch and convert to base64
95
+ return {
96
+ fileData: {
97
+ mimeType: mime.lookup(fileUrl) || 'image/jpeg',
98
+ fileUri: fileUrl
99
+ }
100
+ };
88
101
  }
89
102
  return null;
90
103
  }
@@ -1,8 +1,9 @@
1
1
  import { fulfillWithTimeout } from '../lib/promiser.js';
2
2
  import { PathwayResolver } from './pathwayResolver.js';
3
3
  import CortexResponse from '../lib/cortexResponse.js';
4
- import { withRequestLoggingDisabled } from '../lib/logger.js';
4
+ import logger, { withRequestLoggingDisabled } from '../lib/logger.js';
5
5
  import { sanitizeBase64 } from '../lib/util.js';
6
+ import { resolveClientToolCallback } from './clientToolCallbacks.js';
6
7
 
7
8
  // This resolver uses standard parameters required by Apollo server:
8
9
  // (parent, args, contextValue, info)
@@ -99,6 +100,40 @@ const cancelRequestResolver = (parent, args, contextValue, _info) => {
99
100
  return true
100
101
  }
101
102
 
103
+ const submitClientToolResultResolver = async (parent, args, contextValue, _info) => {
104
+ const { requestId, toolCallbackId, result, success } = args;
105
+
106
+ logger.info(`Received client tool result submission: requestId=${requestId}, toolCallbackId=${toolCallbackId}, success=${success}`);
107
+
108
+ try {
109
+ // Parse the result if it's a string
110
+ let parsedResult = result;
111
+ try {
112
+ parsedResult = JSON.parse(result);
113
+ } catch (e) {
114
+ // If parsing fails, use the string as-is
115
+ }
116
+
117
+ // Resolve the waiting callback (now async, publishes to Redis if available)
118
+ const resolved = await resolveClientToolCallback(toolCallbackId, {
119
+ success,
120
+ data: parsedResult,
121
+ error: !success ? (parsedResult.error || 'Tool execution failed') : null
122
+ });
123
+
124
+ if (!resolved) {
125
+ logger.warn(`Failed to publish/resolve callback for toolCallbackId: ${toolCallbackId}`);
126
+ return false;
127
+ }
128
+
129
+ logger.info(`Successfully published/resolved client tool callback: ${toolCallbackId}`);
130
+ return true;
131
+ } catch (error) {
132
+ logger.error(`Error in submitClientToolResultResolver: ${error.message}`);
133
+ return false;
134
+ }
135
+ }
136
+
102
137
  export {
103
- resolver, rootResolver, cancelRequestResolver
138
+ resolver, rootResolver, cancelRequestResolver, submitClientToolResultResolver
104
139
  };
@@ -0,0 +1,161 @@
1
+ // Test for client tool callbacks with Redis pub/sub
2
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
3
+ import {
4
+ waitForClientToolResult,
5
+ resolveClientToolCallback,
6
+ getPendingCallbackCount
7
+ } from '../../server/clientToolCallbacks.js';
8
+
9
+ describe('Client Tool Callbacks - Multi-Instance Support', () => {
10
+ const mockRequestId = 'test-request-123';
11
+
12
+ afterAll(async () => {
13
+ // Give Redis time to clean up connections
14
+ await new Promise(resolve => setTimeout(resolve, 100));
15
+ });
16
+
17
+ it('should register and resolve a callback locally', async () => {
18
+ const toolCallbackId = 'test-callback-1';
19
+
20
+ // Start waiting for result
21
+ const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 5000);
22
+
23
+ // Verify callback is registered
24
+ expect(getPendingCallbackCount()).toBeGreaterThan(0);
25
+
26
+ // Simulate client submitting result
27
+ const testResult = {
28
+ success: true,
29
+ data: { message: 'Test completed' },
30
+ error: null
31
+ };
32
+
33
+ // Resolve the callback (this will publish to Redis if available, or resolve locally)
34
+ const resolved = await resolveClientToolCallback(toolCallbackId, testResult);
35
+ expect(resolved).toBe(true);
36
+
37
+ // Wait for the result
38
+ const result = await waitPromise;
39
+
40
+ expect(result).toEqual(testResult);
41
+ expect(result.success).toBe(true);
42
+ expect(result.data.message).toBe('Test completed');
43
+ });
44
+
45
+ it('should timeout if no result is received', async () => {
46
+ const toolCallbackId = 'test-callback-timeout';
47
+
48
+ // Start waiting with a short timeout
49
+ const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 100);
50
+
51
+ // Don't resolve - let it timeout
52
+ await expect(waitPromise).rejects.toThrow('Client tool execution timeout');
53
+ }, 10000);
54
+
55
+ it('should handle callback with error result', async () => {
56
+ const toolCallbackId = 'test-callback-error';
57
+
58
+ // Start waiting for result
59
+ const waitPromise = waitForClientToolResult(toolCallbackId, mockRequestId, 5000);
60
+
61
+ // Simulate client submitting error result
62
+ const errorResult = {
63
+ success: false,
64
+ data: null,
65
+ error: 'Tool execution failed'
66
+ };
67
+
68
+ // Resolve with error
69
+ await resolveClientToolCallback(toolCallbackId, errorResult);
70
+
71
+ // Wait for the result
72
+ const result = await waitPromise;
73
+
74
+ expect(result.success).toBe(false);
75
+ expect(result.error).toBe('Tool execution failed');
76
+ });
77
+
78
+ it('should handle multiple concurrent callbacks', async () => {
79
+ const callbacks = [];
80
+ const numCallbacks = 5;
81
+
82
+ // Register multiple callbacks
83
+ for (let i = 0; i < numCallbacks; i++) {
84
+ const callbackId = `concurrent-callback-${i}`;
85
+ const promise = waitForClientToolResult(callbackId, mockRequestId, 5000);
86
+ callbacks.push({ id: callbackId, promise });
87
+ }
88
+
89
+ // Resolve all callbacks
90
+ for (let i = 0; i < numCallbacks; i++) {
91
+ const result = {
92
+ success: true,
93
+ data: { index: i, message: `Result ${i}` },
94
+ error: null
95
+ };
96
+ await resolveClientToolCallback(callbacks[i].id, result);
97
+ }
98
+
99
+ // Wait for all results
100
+ const results = await Promise.all(callbacks.map(cb => cb.promise));
101
+
102
+ // Verify all results
103
+ expect(results).toHaveLength(numCallbacks);
104
+ results.forEach((result, index) => {
105
+ expect(result.success).toBe(true);
106
+ expect(result.data.index).toBe(index);
107
+ });
108
+ });
109
+
110
+ it('should return false when resolving non-existent callback', async () => {
111
+ const nonExistentId = 'non-existent-callback-id';
112
+
113
+ const result = {
114
+ success: true,
115
+ data: { message: 'Test' },
116
+ error: null
117
+ };
118
+
119
+ // This should publish to Redis (if available) or return false locally
120
+ // Either way, it should not throw an error
121
+ const resolved = await resolveClientToolCallback(nonExistentId, result);
122
+
123
+ // In Redis mode, this returns true (published)
124
+ // In local mode, this returns false (not found)
125
+ expect(typeof resolved).toBe('boolean');
126
+ });
127
+ });
128
+
129
+ describe('Client Tool Callbacks - Performance', () => {
130
+ it('should handle rapid callback resolution', async () => {
131
+ const start = Date.now();
132
+ const numCallbacks = 100;
133
+ const callbacks = [];
134
+
135
+ // Register many callbacks rapidly
136
+ for (let i = 0; i < numCallbacks; i++) {
137
+ const callbackId = `perf-callback-${i}`;
138
+ const promise = waitForClientToolResult(callbackId, 'perf-test', 5000);
139
+ callbacks.push({ id: callbackId, promise });
140
+ }
141
+
142
+ // Resolve all callbacks rapidly
143
+ for (let i = 0; i < numCallbacks; i++) {
144
+ await resolveClientToolCallback(callbacks[i].id, {
145
+ success: true,
146
+ data: { index: i },
147
+ error: null
148
+ });
149
+ }
150
+
151
+ // Wait for all
152
+ const results = await Promise.all(callbacks.map(cb => cb.promise));
153
+ const duration = Date.now() - start;
154
+
155
+ expect(results).toHaveLength(numCallbacks);
156
+ expect(duration).toBeLessThan(10000); // Should complete in under 10 seconds
157
+
158
+ console.log(`Performance test: ${numCallbacks} callbacks resolved in ${duration}ms (${(duration/numCallbacks).toFixed(2)}ms avg per callback)`);
159
+ }, 15000);
160
+ });
161
+
@@ -1086,7 +1086,7 @@ test('File collection: syncAndStripFilesFromChatHistory only strips collection f
1086
1086
  ];
1087
1087
 
1088
1088
  // Process chat history
1089
- const { chatHistory: processed } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
1089
+ const { chatHistory: processed, availableFiles } = await syncAndStripFilesFromChatHistory(chatHistory, createAgentContext(contextId));
1090
1090
 
1091
1091
  // Verify only collection file was stripped
1092
1092
  const content = processed[0].content;
@@ -432,9 +432,9 @@ test('Gemini 1.5 image URL type handling', t => {
432
432
  { type: 'image_url', image_url: { url: 'gs://my-bucket/image1.jpg' } },
433
433
  // Base64 URL - should be converted to inlineData
434
434
  { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/4AAQSkZJRg...' } },
435
- // Regular HTTP URL - should be dropped (return null)
435
+ // Regular HTTP URL - should be converted to fileData (Gemini supports HTTP URLs directly)
436
436
  { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } },
437
- // Azure blob URL - should be dropped (return null)
437
+ // Azure blob URL - should be converted to fileData (Gemini supports HTTP URLs directly)
438
438
  { type: 'image_url', image_url: { url: 'https://myaccount.blob.core.windows.net/container/image.jpg' } }
439
439
  ]}
440
440
  ];
@@ -442,20 +442,30 @@ test('Gemini 1.5 image URL type handling', t => {
442
442
  const { modifiedMessages } = gemini15.convertMessagesToGemini(messages);
443
443
 
444
444
  t.is(modifiedMessages.length, 1);
445
- t.is(modifiedMessages[0].parts.length, 3); // text + gcs + base64 (2 urls dropped)
446
-
445
+ t.is(modifiedMessages[0].parts.length, 5); // text + gcs + base64 + http + azure (all urls kept)
446
+
447
447
  // Check text part
448
448
  t.is(modifiedMessages[0].parts[0].text, 'Process these images:');
449
-
449
+
450
450
  // Check GCS URL handling
451
451
  t.true('fileData' in modifiedMessages[0].parts[1]);
452
452
  t.is(modifiedMessages[0].parts[1].fileData.fileUri, 'gs://my-bucket/image1.jpg');
453
453
  t.is(modifiedMessages[0].parts[1].fileData.mimeType, 'image/jpeg');
454
-
454
+
455
455
  // Check base64 URL handling
456
456
  t.true('inlineData' in modifiedMessages[0].parts[2]);
457
457
  t.is(modifiedMessages[0].parts[2].inlineData.mimeType, 'image/jpeg');
458
458
  t.is(modifiedMessages[0].parts[2].inlineData.data, '/9j/4AAQSkZJRg...');
459
+
460
+ // Check HTTP URL handling
461
+ t.true('fileData' in modifiedMessages[0].parts[3]);
462
+ t.is(modifiedMessages[0].parts[3].fileData.fileUri, 'https://example.com/image.jpg');
463
+ t.is(modifiedMessages[0].parts[3].fileData.mimeType, 'image/jpeg');
464
+
465
+ // Check Azure blob URL handling
466
+ t.true('fileData' in modifiedMessages[0].parts[4]);
467
+ t.is(modifiedMessages[0].parts[4].fileData.fileUri, 'https://myaccount.blob.core.windows.net/container/image.jpg');
468
+ t.is(modifiedMessages[0].parts[4].fileData.mimeType, 'image/jpeg');
459
469
  });
460
470
 
461
471
  // Test edge cases for image URLs in Gemini 1.5
@@ -1,15 +0,0 @@
1
- ### New Features
2
- - **Batch File Analysis**: The `AnalyzeFile` tool now supports an array of `files` for simultaneous analysis, in addition to the legacy single `file` parameter.
3
- - **Unified File Collection Loading**: Refactored internal file handling to use a single, unified function (`loadFileCollection`) that handles deduplication across multiple contexts (e.g., user and workspace) and consistent filtering logic.
4
-
5
- ### Improvements
6
- - **Enhanced File Filtering**:
7
- - Improved `inCollection` status logic to treat empty arrays consistently with `undefined` states.
8
- - Added support for loading all files from a context regardless of their specific chat collection status, facilitating better handling of system-level uploads.
9
- - **Search Tool Refinements**: Updated `SearchFileCollection` descriptions to clarify that it uses simple substring matching and does not support complex boolean operators (AND/OR).
10
- - **Tool Documentation**: Improved descriptions for `AddFileToCollection`, `ListFileCollection`, and `SearchFileCollection` to help agents better understand chat-specific vs. global file visibility.
11
- - **Reference Counting**: Improved metadata synchronization to better track file usage across different chat sessions, ensuring files uploaded via external tools (like Labeeb) are correctly integrated into the collection.
12
-
13
- ### Bug Fixes
14
- - **Media API Timeout**: Significantly increased the timeout for the media chunks API from 30 seconds to 10 minutes (600,000ms) to accommodate larger file processing and slow network conditions.
15
- - **Metadata Normalization**: Fixed inconsistencies in how \
@@ -1,5 +0,0 @@
1
- ### Core Improvements
2
- - **Unified File Collection Loading**: Refactored file loading logic into a single, robust `loadFileCollection` function. This supports compound contexts (user + workspace), deduplication of files across multiple sources, and more flexible filtering.
3
- - **Enhanced Media Support**: Significantly increased the timeout for the media chunks API from 30 seconds to 600 seconds to better handle large file processing.
4
- - **Support for Untagged Uploads**: Improved synchronization logic to identify and process files that exist in storage but lack explicit collection metadata (e.g., Labeeb uploads). These files are now correctly stripped from chat history and added to the relevant chat collection upon use.
5
- - **Improved Filtering Logic**: Refined how the system distinguishes between global files, chat-specific files, and files not yet in any collection. Empty collection arrays are now treated consistently as \