@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.
- package/lib/pathwayManager.js +7 -0
- package/lib/pathwayTools.js +71 -0
- package/package.json +1 -1
- package/pathways/system/entity/sys_entity_agent.js +41 -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 +1 -1
- package/tests/unit/plugins/multimodal_conversion.test.js +16 -6
- package/RELEASE_NOTES_20251231_103631.md +0 -15
- package/RELEASE_NOTES_20251231_110946.md +0 -5
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": {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/server/resolver.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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 \
|