@probelabs/probe-chat 0.6.0-rc56

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/probeChat.js ADDED
@@ -0,0 +1,1677 @@
1
+ import 'dotenv/config';
2
+ import { createAnthropic } from '@ai-sdk/anthropic';
3
+ import { createOpenAI } from '@ai-sdk/openai';
4
+ import { createGoogleGenerativeAI } from '@ai-sdk/google';
5
+ import { streamText } from 'ai'; // Removed 'tool' import as it's not used directly here
6
+ import { randomUUID } from 'crypto';
7
+ import { TokenCounter } from './tokenCounter.js';
8
+ import { TokenUsageDisplay } from './tokenUsageDisplay.js';
9
+ import { writeFileSync, existsSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { TelemetryConfig } from './telemetry.js';
12
+ import { trace } from '@opentelemetry/api';
13
+ import { appTracer } from './appTracer.js';
14
+ // Import the tools that emit events and the listFilesByLevel utility
15
+ import { listFilesByLevel } from '@probelabs/probe';
16
+ // Import schemas and parser from common (assuming tools.js)
17
+ import {
18
+ searchSchema, querySchema, extractSchema, attemptCompletionSchema,
19
+ searchToolDefinition, queryToolDefinition, extractToolDefinition, attemptCompletionToolDefinition, implementToolDefinition,
20
+ listFilesToolDefinition, searchFilesToolDefinition,
21
+ parseXmlToolCallWithThinking
22
+ } from './tools.js'; // Assuming common.js is moved to tools/
23
+ // Import tool *instances* for execution
24
+ import { searchToolInstance, queryToolInstance, extractToolInstance, implementToolInstance, listFilesToolInstance, searchFilesToolInstance } from './probeTool.js'; // Added new tool instances
25
+
26
+ // Maximum number of messages to keep in history
27
+ const MAX_HISTORY_MESSAGES = 100;
28
+ // Maximum iterations for the tool loop - configurable via MAX_TOOL_ITERATIONS env var
29
+ const MAX_TOOL_ITERATIONS = parseInt(process.env.MAX_TOOL_ITERATIONS || '30', 10);
30
+
31
+ // Parse and validate allowed folders from environment variable
32
+ const allowedFolders = process.env.ALLOWED_FOLDERS
33
+ ? process.env.ALLOWED_FOLDERS.split(',').map(folder => folder.trim()).filter(Boolean)
34
+ : [];
35
+
36
+ // Validate folders exist on startup - will be handled by index.js in non-interactive mode
37
+ // This is kept for backward compatibility with direct ProbeChat usage
38
+ const validateFolders = () => {
39
+ if (allowedFolders.length > 0) {
40
+ for (const folder of allowedFolders) {
41
+ const exists = existsSync(folder);
42
+ // Only log if not in non-interactive mode or if in debug mode
43
+ if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
44
+ console.log(`- ${folder} ${exists ? '✓' : '✗ (not found)'}`);
45
+ if (!exists) {
46
+ console.warn(`Warning: Folder "${folder}" does not exist or is not accessible`);
47
+ }
48
+ }
49
+ }
50
+ } else {
51
+ // Only log if not in non-interactive mode or if in debug mode
52
+ if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
53
+ console.warn('No folders configured via ALLOWED_FOLDERS. Tools might default to current directory or require explicit paths.');
54
+ }
55
+ }
56
+ };
57
+
58
+ // Only validate folders on startup if not in non-interactive mode
59
+ if (typeof process !== 'undefined' && !process.env.PROBE_CHAT_SKIP_FOLDER_VALIDATION) {
60
+ validateFolders();
61
+ }
62
+
63
+
64
+ /**
65
+ * Extract image URLs from message text
66
+ * @param {string} message - The message text to analyze
67
+ * @param {boolean} debug - Whether to log debug information
68
+ * @returns {Array} Array of { url: string, cleanedMessage: string }
69
+ */
70
+ function extractImageUrls(message, debug = false) {
71
+ // This function should be called within the session context, so it will inherit the trace ID
72
+ const tracer = trace.getTracer('probe-chat', '1.0.0');
73
+ return tracer.startActiveSpan('content.image.extract', (span) => {
74
+ try {
75
+ // Pattern to match image URLs and base64 data:
76
+ // 1. GitHub private-user-images URLs (always images, regardless of extension)
77
+ // 2. GitHub user-attachments/assets URLs (always images, regardless of extension)
78
+ // 3. URLs with common image extensions (PNG, JPG, JPEG, WebP, GIF)
79
+ // 4. Base64 data URLs (data:image/...)
80
+ // Updated to stop at quotes, spaces, or common HTML/XML delimiters
81
+ const imageUrlPattern = /(?:data:image\/[a-zA-Z]*;base64,[A-Za-z0-9+/=]+|https?:\/\/(?:(?:private-user-images\.githubusercontent\.com|github\.com\/user-attachments\/assets)\/[^\s"'<>]+|[^\s"'<>]+\.(?:png|jpg|jpeg|webp|gif)(?:\?[^\s"'<>]*)?))/gi;
82
+
83
+ span.setAttributes({
84
+ 'message.length': message.length,
85
+ 'debug.enabled': debug
86
+ });
87
+
88
+ if (debug) {
89
+ console.log(`[DEBUG] Scanning message for image URLs. Message length: ${message.length}`);
90
+ console.log(`[DEBUG] Image URL pattern: ${imageUrlPattern.toString()}`);
91
+ }
92
+
93
+ const urls = [];
94
+ let match;
95
+
96
+ while ((match = imageUrlPattern.exec(message)) !== null) {
97
+ urls.push(match[0]);
98
+ if (debug) {
99
+ console.log(`[DEBUG] Found image URL: ${match[0]}`);
100
+ }
101
+ }
102
+
103
+ // Remove image URLs from message text
104
+ const cleanedMessage = message.replace(imageUrlPattern, '').trim();
105
+
106
+ span.setAttributes({
107
+ 'images.found': urls.length,
108
+ 'message.cleaned_length': cleanedMessage.length
109
+ });
110
+
111
+ if (debug) {
112
+ console.log(`[DEBUG] Total image URLs found: ${urls.length}`);
113
+ if (urls.length > 0) {
114
+ console.log(`[DEBUG] Original message length: ${message.length}, cleaned message length: ${cleanedMessage.length}`);
115
+ }
116
+ }
117
+
118
+ const result = {
119
+ imageUrls: urls,
120
+ cleanedMessage: cleanedMessage
121
+ };
122
+
123
+ span.setStatus({ code: 1 }); // SUCCESS
124
+ return result;
125
+ } catch (error) {
126
+ span.recordException(error);
127
+ span.setStatus({ code: 2, message: error.message }); // ERROR
128
+ throw error;
129
+ } finally {
130
+ span.end();
131
+ }
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Validate image URLs by checking if they're accessible, handling redirects
137
+ * @param {string[]} imageUrls - Array of image URLs to validate
138
+ * @param {boolean} debug - Whether to log debug messages
139
+ * @returns {Promise<string[]>} Array of valid final image URLs (after redirects)
140
+ */
141
+ async function validateImageUrls(imageUrls, debug = false) {
142
+ const validUrls = [];
143
+
144
+ for (const url of imageUrls) {
145
+ try {
146
+ // Check if it's a base64 data URL
147
+ if (url.startsWith('data:image/')) {
148
+ // Validate base64 data URL format
149
+ const dataUrlMatch = url.match(/^data:image\/([a-zA-Z]*);base64,([A-Za-z0-9+/=]+)$/);
150
+ if (dataUrlMatch) {
151
+ const [, imageType, base64Data] = dataUrlMatch;
152
+
153
+ // Basic validation of base64 data
154
+ if (base64Data.length > 0 && imageType) {
155
+ // Estimate file size from base64 (rough approximation: base64 is ~1.33x original size)
156
+ const estimatedSize = (base64Data.length * 3) / 4;
157
+
158
+ // Check size limit (10MB)
159
+ if (estimatedSize <= 10 * 1024 * 1024) {
160
+ validUrls.push(url);
161
+ if (debug) {
162
+ console.log(`[DEBUG] Valid base64 image: ${imageType} (~${(estimatedSize / 1024).toFixed(1)}KB)`);
163
+ }
164
+ } else {
165
+ if (debug) {
166
+ console.log(`[DEBUG] Base64 image too large: ~${(estimatedSize / 1024 / 1024).toFixed(1)}MB (max 10MB)`);
167
+ }
168
+ }
169
+ } else {
170
+ if (debug) {
171
+ console.log(`[DEBUG] Invalid base64 data URL format: ${url.substring(0, 50)}...`);
172
+ }
173
+ }
174
+ } else {
175
+ if (debug) {
176
+ console.log(`[DEBUG] Invalid data URL format: ${url.substring(0, 50)}...`);
177
+ }
178
+ }
179
+ } else {
180
+ // Handle regular HTTP/HTTPS URLs
181
+ // Always use GET request with Range header to validate and get content type
182
+ // This works better than HEAD for GitHub URLs and other services
183
+ const response = await fetch(url, {
184
+ method: 'GET',
185
+ headers: {
186
+ 'Range': 'bytes=0-1023' // Only fetch first 1KB to check content type and minimize data transfer
187
+ },
188
+ timeout: 10000, // TIMEOUTS.HTTP_REQUEST - 10 second timeout for GitHub URLs which can be slower
189
+ redirect: 'follow'
190
+ });
191
+
192
+ if (response.ok || response.status === 206) { // 206 = Partial Content (from Range header)
193
+ // Check if the response has image content type
194
+ const contentType = response.headers.get('content-type');
195
+ if (contentType && contentType.startsWith('image/')) {
196
+ // Use the final URL after following redirects
197
+ const finalUrl = response.url;
198
+ validUrls.push(finalUrl);
199
+ if (debug) {
200
+ if (finalUrl !== url) {
201
+ console.log(`[DEBUG] Valid image URL after redirect: ${url} -> ${finalUrl} (${contentType})`);
202
+ } else {
203
+ console.log(`[DEBUG] Valid image URL: ${finalUrl} (${contentType})`);
204
+ }
205
+ }
206
+ } else {
207
+ if (debug) {
208
+ console.log(`[DEBUG] URL not an image: ${url} (${contentType || 'unknown type'})`);
209
+ }
210
+ }
211
+ } else {
212
+ if (debug) {
213
+ console.log(`[DEBUG] URL not accessible: ${url} (status: ${response.status})`);
214
+ }
215
+ }
216
+ }
217
+ } catch (error) {
218
+ if (debug) {
219
+ console.log(`[DEBUG] Error validating image URL ${url}: ${error.message}`);
220
+ }
221
+ }
222
+ }
223
+
224
+ return validUrls;
225
+ }
226
+
227
+ /**
228
+ * ProbeChat class to handle chat interactions with AI models
229
+ */
230
+ export class ProbeChat {
231
+ /**
232
+ * Create a new ProbeChat instance
233
+ * @param {Object} options - Configuration options
234
+ * @param {string} [options.sessionId] - Optional session ID
235
+ * @param {boolean} [options.isNonInteractive=false] - Suppress internal logs if true
236
+ * @param {Function} [options.toolCallCallback] - Callback function for tool calls (sessionId, toolCallData) - *Note: Callback may need adjustment for XML flow*
237
+ * @param {string} [options.customPrompt] - Custom prompt to replace the default system message
238
+ * @param {string} [options.promptType] - Predefined prompt type (architect, code-review, support)
239
+ * @param {boolean} [options.allowEdit=false] - Allow the use of the 'implement' tool
240
+ */
241
+ constructor(options = {}) {
242
+ // Suppress internal logs if in non-interactive mode
243
+ this.isNonInteractive = !!options.isNonInteractive;
244
+ // Flag to track if a request has been cancelled
245
+ this.cancelled = false;
246
+
247
+ // AbortController for cancelling fetch requests
248
+ this.abortController = null;
249
+ // Make allowedFolders accessible as a property of the class
250
+ this.allowedFolders = allowedFolders;
251
+
252
+ // Store custom prompt or prompt type if provided
253
+ this.customPrompt = options.customPrompt || process.env.CUSTOM_PROMPT || null;
254
+ this.promptType = options.promptType || process.env.PROMPT_TYPE || null;
255
+
256
+ // Store allowEdit flag - enable if allow_edit is set or if allow_suggestions is set via environment
257
+ // Note: ALLOW_SUGGESTIONS also enables allowEdit because the implement tool is needed to generate
258
+ // code changes that reviewdog can then convert into PR review suggestions
259
+ this.allowEdit = !!options.allowEdit || process.env.ALLOW_EDIT === '1' || process.env.ALLOW_SUGGESTIONS === '1';
260
+
261
+ // Store client-provided API credentials if available
262
+ this.clientApiProvider = options.apiProvider;
263
+ this.clientApiKey = options.apiKey;
264
+ this.clientApiUrl = options.apiUrl;
265
+
266
+ // Initialize token counter and display
267
+ this.tokenCounter = new TokenCounter();
268
+ this.tokenDisplay = new TokenUsageDisplay({
269
+ maxTokens: 8192 // Will be updated based on model
270
+ });
271
+
272
+ // Use provided session ID or generate a unique one
273
+ this.sessionId = options.sessionId || randomUUID();
274
+
275
+ // Get debug mode
276
+ this.debug = process.env.DEBUG_CHAT === '1';
277
+
278
+ if (this.debug) {
279
+ console.log(`[DEBUG] Generated session ID for chat: ${this.sessionId}`);
280
+ console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
281
+ console.log(`[DEBUG] Allow Edit (implement tool): ${this.allowEdit}`);
282
+ }
283
+
284
+ // Store tool instances for execution
285
+ // These are the actual functions/objects that perform the actions
286
+ this.toolImplementations = {
287
+ search: searchToolInstance,
288
+ query: queryToolInstance,
289
+ extract: extractToolInstance,
290
+ listFiles: listFilesToolInstance,
291
+ searchFiles: searchFilesToolInstance,
292
+ // attempt_completion is handled specially in the loop, no direct implementation needed here
293
+ };
294
+
295
+ // Conditionally add the implement tool if allowed
296
+ if (this.allowEdit) {
297
+ this.toolImplementations.implement = implementToolInstance;
298
+ }
299
+
300
+ // Initialize the chat model
301
+ this.initializeModel();
302
+
303
+ // Initialize telemetry
304
+ this.initializeTelemetry();
305
+
306
+ // Initialize chat history
307
+ this.history = [];
308
+
309
+ // Initialize display history - tracks what users actually see
310
+ this.displayHistory = [];
311
+
312
+ // Store persistent storage instance if provided
313
+ this.storage = options.storage || null;
314
+ }
315
+
316
+ /**
317
+ * Initialize the AI model based on available API keys and forced provider setting
318
+ */
319
+ initializeModel() {
320
+ // Get API keys from environment variables or client-provided values
321
+ const anthropicApiKey = this.clientApiKey && this.clientApiProvider === 'anthropic' ?
322
+ this.clientApiKey : process.env.ANTHROPIC_API_KEY;
323
+ const openaiApiKey = this.clientApiKey && this.clientApiProvider === 'openai' ?
324
+ this.clientApiKey : process.env.OPENAI_API_KEY;
325
+ const googleApiKey = this.clientApiKey && this.clientApiProvider === 'google' ?
326
+ this.clientApiKey : process.env.GOOGLE_API_KEY;
327
+
328
+ // Get custom API URLs if provided (client URL takes precedence over environment variables)
329
+ const llmBaseUrl = process.env.LLM_BASE_URL; // Generic base URL for all providers
330
+
331
+ // For each provider, use client URL if available and matches the provider
332
+ const anthropicApiUrl = (this.clientApiUrl && this.clientApiProvider === 'anthropic') ?
333
+ this.clientApiUrl : (process.env.ANTHROPIC_API_URL || llmBaseUrl);
334
+
335
+ const openaiApiUrl = (this.clientApiUrl && this.clientApiProvider === 'openai') ?
336
+ this.clientApiUrl : (process.env.OPENAI_API_URL || llmBaseUrl);
337
+
338
+ const googleApiUrl = (this.clientApiUrl && this.clientApiProvider === 'google') ?
339
+ this.clientApiUrl : (process.env.GOOGLE_API_URL || llmBaseUrl);
340
+
341
+ // Get model override if provided
342
+ const modelName = process.env.MODEL_NAME;
343
+
344
+ // Check if client has specified a provider that should be forced
345
+ const clientForceProvider = this.clientApiProvider && this.clientApiKey ? this.clientApiProvider : null;
346
+
347
+ // Use client-forced provider or environment variable
348
+ const forceProvider = clientForceProvider || (process.env.FORCE_PROVIDER ? process.env.FORCE_PROVIDER.toLowerCase() : null);
349
+
350
+ if (this.debug) {
351
+ console.log(`[DEBUG] Available API keys: Anthropic=${!!anthropicApiKey}, OpenAI=${!!openaiApiKey}, Google=${!!googleApiKey}`);
352
+ console.log(`[DEBUG] Force provider: ${forceProvider || '(not set)'}`);
353
+ if (llmBaseUrl) console.log(`[DEBUG] Generic LLM Base URL: ${llmBaseUrl}`);
354
+ if (process.env.ANTHROPIC_API_URL) console.log(`[DEBUG] Custom Anthropic URL: ${anthropicApiUrl}`);
355
+ if (process.env.OPENAI_API_URL) console.log(`[DEBUG] Custom OpenAI URL: ${openaiApiUrl}`);
356
+ if (process.env.GOOGLE_API_URL) console.log(`[DEBUG] Custom Google URL: ${googleApiUrl}`);
357
+ if (modelName) console.log(`[DEBUG] Model override: ${modelName}`);
358
+ }
359
+
360
+ // Check if a specific provider is forced
361
+
362
+ if (forceProvider) {
363
+ if (!this.isNonInteractive || this.debug) {
364
+ console.log(`Provider forced to: ${forceProvider}`);
365
+ }
366
+
367
+ if (forceProvider === 'anthropic' && anthropicApiKey) {
368
+ this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
369
+ return;
370
+ } else if (forceProvider === 'openai' && openaiApiKey) {
371
+ this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
372
+ return;
373
+ } else if (forceProvider === 'google' && googleApiKey) {
374
+ this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
375
+ return;
376
+ }
377
+
378
+ console.warn(`WARNING: Forced provider "${forceProvider}" selected but required API key is missing or invalid! Falling back to auto-detection.`);
379
+ }
380
+
381
+ // If no provider is forced or forced provider failed, use the first available API key
382
+ if (anthropicApiKey) {
383
+ this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
384
+ } else if (openaiApiKey) {
385
+ this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
386
+ } else if (googleApiKey) {
387
+ this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
388
+ } else {
389
+ console.error('FATAL: No API key provided. Please set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY environment variable.');
390
+ this.noApiKeysMode = true; // Use flag for potential UI handling
391
+ this.model = 'none';
392
+ this.apiType = 'none';
393
+ console.log('ProbeChat cannot function without an API key.');
394
+ // Consider throwing an error here in a real application to prevent execution
395
+ // throw new Error('No API key configured for AI provider.');
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Initialize Anthropic model
401
+ * @param {string} apiKey - Anthropic API key
402
+ * @param {string} [apiUrl] - Optional Anthropic API URL override
403
+ * @param {string} [modelName] - Optional model name override
404
+ */
405
+ initializeAnthropicModel(apiKey, apiUrl, modelName) {
406
+ this.provider = createAnthropic({
407
+ apiKey: apiKey,
408
+ ...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
409
+ });
410
+ this.model = modelName || 'claude-3-7-sonnet-20250219';
411
+ this.apiType = 'anthropic';
412
+ if (!this.isNonInteractive || this.debug) {
413
+ const urlSource = process.env.ANTHROPIC_API_URL ? 'ANTHROPIC_API_URL' :
414
+ (process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
415
+ console.log(`Using Anthropic API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Initialize OpenAI model
421
+ * @param {string} apiKey - OpenAI API key
422
+ * @param {string} [apiUrl] - Optional OpenAI API URL override
423
+ * @param {string} [modelName] - Optional model name override
424
+ */
425
+ initializeOpenAIModel(apiKey, apiUrl, modelName) {
426
+ this.provider = createOpenAI({
427
+ compatibility: 'strict',
428
+ apiKey: apiKey,
429
+ ...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
430
+ });
431
+ this.model = modelName || 'gpt-4o';
432
+ this.apiType = 'openai';
433
+ if (!this.isNonInteractive || this.debug) {
434
+ const urlSource = process.env.OPENAI_API_URL ? 'OPENAI_API_URL' :
435
+ (process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
436
+ console.log(`Using OpenAI API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Initialize Google model
442
+ * @param {string} apiKey - Google API key
443
+ * @param {string} [apiUrl] - Optional Google API URL override
444
+ * @param {string} [modelName] - Optional model name override
445
+ */
446
+ initializeGoogleModel(apiKey, apiUrl, modelName) {
447
+ this.provider = createGoogleGenerativeAI({
448
+ apiKey: apiKey,
449
+ ...(apiUrl && { baseURL: apiUrl }), // Conditionally add baseURL
450
+ });
451
+ this.model = modelName || 'gemini-2.0-flash';
452
+ this.apiType = 'google';
453
+ if (!this.isNonInteractive || this.debug) {
454
+ const urlSource = process.env.GOOGLE_API_URL ? 'GOOGLE_API_URL' :
455
+ (process.env.LLM_BASE_URL ? 'LLM_BASE_URL' : 'default');
456
+ console.log(`Using Google API with model: ${this.model}${apiUrl ? ` (URL: ${apiUrl}, from: ${urlSource})` : ''}`);
457
+ }
458
+ // Note: Google's tool support might differ. Ensure XML approach works reliably.
459
+ }
460
+
461
+ /**
462
+ * Initialize telemetry configuration
463
+ */
464
+ initializeTelemetry() {
465
+ try {
466
+ // Check if telemetry is enabled via environment variables
467
+ const fileEnabled = process.env.OTEL_ENABLE_FILE === 'true';
468
+ const remoteEnabled = process.env.OTEL_ENABLE_REMOTE === 'true';
469
+ const consoleEnabled = process.env.OTEL_ENABLE_CONSOLE === 'true';
470
+
471
+ if (fileEnabled || remoteEnabled || consoleEnabled) {
472
+ this.telemetryConfig = new TelemetryConfig({
473
+ enableFile: fileEnabled,
474
+ enableRemote: remoteEnabled,
475
+ enableConsole: consoleEnabled,
476
+ filePath: process.env.OTEL_FILE_PATH || './traces.jsonl',
477
+ remoteEndpoint: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT || 'http://localhost:4318/v1/traces'
478
+ });
479
+
480
+ this.telemetryConfig.initialize();
481
+
482
+ if (this.debug) {
483
+ console.log('[DEBUG] Telemetry initialized successfully');
484
+ }
485
+ } else {
486
+ if (this.debug) {
487
+ console.log('[DEBUG] Telemetry disabled - no exporters configured');
488
+ }
489
+ }
490
+ } catch (error) {
491
+ console.error('Failed to initialize telemetry:', error.message);
492
+ this.telemetryConfig = null;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Get the system message with instructions for the AI (XML Tool Format)
498
+ * @returns {Promise<string>} - The system message
499
+ */
500
+ async getSystemMessage() {
501
+ // --- Dynamically build Tool Definitions ---
502
+ let toolDefinitions = `
503
+ ${searchToolDefinition}
504
+ ${queryToolDefinition}
505
+ ${extractToolDefinition}
506
+ ${listFilesToolDefinition}
507
+ ${searchFilesToolDefinition}
508
+ ${attemptCompletionToolDefinition}
509
+ `;
510
+ if (this.allowEdit) {
511
+ toolDefinitions += `${implementToolDefinition}\n`;
512
+ }
513
+
514
+ // --- Dynamically build Tool Guidelines ---
515
+ let xmlToolGuidelines = `
516
+ # Tool Use Formatting
517
+
518
+ Tool use MUST be formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags. You MUST use exactly ONE tool call per message until you are ready to complete the task.
519
+
520
+ Structure:
521
+ <tool_name>
522
+ <parameter1_name>value1</parameter1_name>
523
+ <parameter2_name>value2</parameter2_name>
524
+ ...
525
+ </tool_name>
526
+
527
+ Example:
528
+ <search>
529
+ <query>error handling</query>
530
+ <path>src/search</path>
531
+ </search>
532
+
533
+ # Thinking Process
534
+
535
+ Before using a tool, analyze the situation within <thinking></thinking> tags. This helps you organize your thoughts and make better decisions. Your thinking process should include:
536
+
537
+ 1. Analyze what information you already have and what information you need to proceed with the task.
538
+ 2. Determine which of the available tools would be most effective for gathering this information or accomplishing the current step.
539
+ 3. Check if all required parameters for the tool are available or can be inferred from the context.
540
+ 4. If all parameters are available, proceed with the tool use.
541
+ 5. If parameters are missing, explain what's missing and why it's needed.
542
+
543
+ Example:
544
+ <thinking>
545
+ I need to find code related to error handling in the search module. The most appropriate tool for this is the search tool, which requires a query parameter and a path parameter. I have both the query ("error handling") and the path ("src/search"), so I can proceed with the search.
546
+ </thinking>
547
+
548
+ # Tool Use Guidelines
549
+
550
+ 1. Think step-by-step about how to achieve the user's goal.
551
+ 2. Use <thinking></thinking> tags to analyze the situation and determine the appropriate tool.
552
+ 3. Choose **one** tool that helps achieve the current step.
553
+ 4. Format the tool call using the specified XML format. Ensure all required parameters are included.
554
+ 5. **You MUST respond with exactly one tool call in the specified XML format in each turn.**
555
+ 6. Wait for the tool execution result, which will be provided in the next message (within a <tool_result> block).
556
+ 7. Analyze the tool result and decide the next step. If more tool calls are needed, repeat steps 2-6.
557
+ 8. If the task is fully complete and all previous steps were successful, use the \`<attempt_completion>\` tool to provide the final answer. This is the ONLY way to finish the task.
558
+ 9. If you cannot proceed (e.g., missing information, invalid request), explain the issue clearly before using \`<attempt_completion>\` with an appropriate message in the \`<result>\` tag.
559
+ 10. Do not be lazy and dig to the topic as deep as possible, until you see full picture.
560
+
561
+ Available Tools:
562
+ - search: Search code using keyword queries.
563
+ - query: Search code using structural AST patterns.
564
+ - extract: Extract specific code blocks or lines from files.
565
+ - listFiles: List files and directories in a specified location.
566
+ - searchFiles: Find files matching a glob pattern with recursive search capability.
567
+ ${this.allowEdit ? '- implement: Implement a feature or fix a bug using aider.\n' : ''}
568
+ - attempt_completion: Finalize the task and provide the result to the user.
569
+ `;
570
+ // Common instructions that will be added to all prompts
571
+ const commonInstructions = `<instructions>
572
+ Follow these instructions carefully:
573
+ 1. Analyze the user's request.
574
+ 2. Use <thinking></thinking> tags to analyze the situation and determine the appropriate tool for each step.
575
+ 3. Use the available tools step-by-step to fulfill the request.
576
+ 4. You should always prefer the \`search\` tool for code-related questions. Read full files only if really necessary.
577
+ 4. Ensure to get really deep and understand the full picture before answering. Ensure to check dependencies where required.
578
+ 5. You MUST respond with exactly ONE tool call per message, using the specified XML format, until the task is complete.
579
+ 6. Wait for the tool execution result (provided in the next user message in a <tool_result> block) before proceeding to the next step.
580
+ 7. Once the task is fully completed, and you have confirmed the success of all steps, use the '<attempt_completion>' tool to provide the final result. This is the ONLY way to signal completion.
581
+ 8. Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results. Avoid reading files in full, only when absolutely necessary.
582
+ 9. Show mermaid diagrams to illustrate complex code structures or workflows. In diagrams, content inside ["..."] always should be in quotes.</instructions>
583
+ `;
584
+
585
+ // Define predefined prompts (without the common instructions)
586
+ const predefinedPrompts = {
587
+ 'code-explorer': `You are ProbeChat Code Explorer, a specialized AI assistant focused on helping developers, product managers, and QAs understand and navigate codebases. Your primary function is to answer questions based on code, explain how systems work, and provide insights into code functionality using the provided code analysis tools.
588
+
589
+ When exploring code:
590
+ - Provide clear, concise explanations based on user request
591
+ - Find and highlight the most relevant code snippets, if required
592
+ - Trace function calls and data flow through the system
593
+ - Use diagrams to illustrate code structure and relationships when helpful
594
+ - Try to understand the user's intent and provide relevant information
595
+ - Understand high level picture
596
+ - Balance detail with clarity in your explanations`,
597
+
598
+ 'architect': `You are ProbeChat Architect, a specialized AI assistant focused on software architecture and design. Your primary function is to help users understand, analyze, and design software systems using the provided code analysis tools. You excel at identifying architectural patterns, suggesting improvements, and creating high-level design documentation. You provide detailed and accurate responses to user queries about system architecture, component relationships, and code organization.
599
+
600
+ When analyzing code:
601
+ - Focus on high-level design patterns and system organization
602
+ - Identify architectural patterns and component relationships
603
+ - Evaluate system structure and suggest architectural improvements
604
+ - Create diagrams to illustrate system architecture and workflows
605
+ - Consider scalability, maintainability, and extensibility in your analysis`,
606
+
607
+ 'code-review': `You are ProbeChat Code Reviewer, a specialized AI assistant focused on code quality and best practices. Your primary function is to help users identify issues, suggest improvements, and ensure code follows best practices using the provided code analysis tools. You excel at spotting bugs, performance issues, security vulnerabilities, and style inconsistencies. You provide detailed and constructive feedback on code quality.
608
+
609
+ When reviewing code:
610
+ - Look for bugs, edge cases, and potential issues
611
+ - Identify performance bottlenecks and optimization opportunities
612
+ - Check for security vulnerabilities and best practices
613
+ - Evaluate code style and consistency
614
+ - Is the backward compatibility can be broken?
615
+ - Organize feedback by severity (critical, major, minor) and type (bug, performance, security, style)
616
+ - Provide specific, actionable suggestions with code examples where appropriate
617
+
618
+ ## Failure Detection
619
+
620
+ If you detect critical issues that should prevent the code from being merged, include <fail> in your response:
621
+ - Security vulnerabilities that could be exploited
622
+ - Breaking changes without proper documentation or migration path
623
+ - Critical bugs that would cause system failures
624
+ - Severe violations of project standards that must be addressed
625
+
626
+ The <fail> tag will cause the GitHub check to fail, drawing immediate attention to these critical issues.`,
627
+
628
+ 'engineer': `You are senior engineer focused on software architecture and design.
629
+ Before jumping on the task you first, in details analyse user request, and try to provide elegant and concise solution.
630
+ If solution is clear, you can jump to implementation right away, if not, you can ask user a clarification question, by calling attempt_completion tool, with required details.
631
+ You are allowed to use search tool with allow_tests argument, in order to find the tests.
632
+
633
+ Before jumping to implementation:
634
+ - Focus on high-level design patterns and system organization
635
+ - Identify architectural patterns and component relationships
636
+ - Evaluate system structure and suggest architectural improvements
637
+ - Focus on backward compatibility.
638
+ - Respond with diagrams to illustrate system architecture and workflows, if required.
639
+ - Consider scalability, maintainability, and extensibility in your analysis
640
+
641
+ During the implementation:
642
+ - Avoid implementing special cases
643
+ - Do not forget to add the tests`,
644
+
645
+ 'support': `You are ProbeChat Support, a specialized AI assistant focused on helping developers troubleshoot issues and solve problems. Your primary function is to help users diagnose errors, understand unexpected behaviors, and find solutions using the provided code analysis tools. You excel at debugging, explaining complex concepts, and providing step-by-step guidance. You provide detailed and patient support to help users overcome technical challenges.
646
+
647
+ When troubleshooting:
648
+ - Focus on finding root causes, not just symptoms
649
+ - Explain concepts clearly with appropriate context
650
+ - Provide step-by-step guidance to solve problems
651
+ - Suggest diagnostic steps to verify solutions
652
+ - Consider edge cases and potential complications
653
+ - Be empathetic and patient in your explanations`
654
+ };
655
+
656
+ let systemMessage = '';
657
+
658
+ // Use custom prompt if provided
659
+ if (this.customPrompt) {
660
+ // For custom prompts, use the entire content as is
661
+ systemMessage = "<role>" + this.customPrompt + "</role>";
662
+ if (this.debug) {
663
+ console.log(`[DEBUG] Using custom prompt`);
664
+ }
665
+ }
666
+ // Use predefined prompt if specified
667
+ else if (this.promptType && predefinedPrompts[this.promptType]) {
668
+ systemMessage = "<role>" + predefinedPrompts[this.promptType] + "</role>";
669
+ if (this.debug) {
670
+ console.log(`[DEBUG] Using predefined prompt: ${this.promptType}`);
671
+ }
672
+ // Add common instructions to predefined prompts
673
+ systemMessage += commonInstructions;
674
+ } else {
675
+ // Use the default prompt (code explorer) if no prompt type is specified
676
+ systemMessage = "<role>" + predefinedPrompts['code-explorer'] + "</role>";
677
+ if (this.debug) {
678
+ console.log(`[DEBUG] Using default prompt: code explorer`);
679
+ }
680
+ // Add common instructions to the default prompt
681
+ systemMessage += commonInstructions;
682
+ }
683
+ // Add XML Tool Guidelines
684
+ systemMessage += `\n${xmlToolGuidelines}\n`;
685
+
686
+ // Add Tool Definitions
687
+ systemMessage += `\n# Tools Available\n${toolDefinitions}\n`;
688
+
689
+ // Add special emphasis for image handling
690
+ systemMessage += `\n# CRITICAL: XML Tool Format Required\n\nEven when processing images or visual content, you MUST respond using the XML tool format. Do not provide direct answers about images - instead use the appropriate tool (usually <attempt_completion>) with your analysis inside the <result> tag.\n\nExample when analyzing an image:\n<attempt_completion>\n<result>\nI can see this is a promotional image from Tyk showing... [your analysis here]\n</result>\n</attempt_completion>\n`;
691
+
692
+
693
+ const searchDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
694
+ if (this.debug) {
695
+ console.log(`[DEBUG] Generating file list for base directory: ${searchDirectory}...`);
696
+ }
697
+
698
+ // Add folder information
699
+ if (this.allowedFolders.length > 0) {
700
+ const folderList = this.allowedFolders.map(f => `"${f}"`).join(', ');
701
+ systemMessage += `\n\nYou are configured to primarily operate within these folders: ${folderList}. When using tools like 'search' or 'query', the 'path' parameter should generally refer to these folders or subpaths within them. The root for relative paths is considered the project base.`;
702
+ } else {
703
+ systemMessage += `\n\nCurrent path: ${searchDirectory}. When using tools, specify paths like '.' for the current directory, 'src/utils', etc., within the 'path' parameter. Dependencies are located in /dep folder: "/dep/go/github.com/user/repo", "/dep/js/<package>", "/dep/rust/crate_name".`;
704
+ }
705
+
706
+ // Add Rules/Capabilities section
707
+ systemMessage += `\n\n# Capabilities & Rules\n- Search given folder using keywords (\`search\`) or structural patterns (\`query\`).\n- Extract specific code blocks or full files using (\`extract\`).\n- File paths are relative to the project base unless using dependency syntax.\n- Always wait for tool results (\`<tool_result>...\`) before proceeding.\n- Use \`attempt_completion\` ONLY when the entire task is finished.\n- Be direct and technical. Use exactly ONE tool call per response in the specified XML format. Prefer using search tool.\n`;
708
+
709
+ if (this.debug) {
710
+ console.log(`[DEBUG] Base system message length (pre-file list): ${systemMessage.length}`);
711
+ }
712
+
713
+ // Add file list information if available
714
+ try {
715
+ let files = await listFilesByLevel({
716
+ directory: searchDirectory, // Use the determined search directory
717
+ maxFiles: 100, // Keep it reasonable
718
+ respectGitignore: true
719
+ });
720
+
721
+ // Exclude debug file(s) and common large directories
722
+ files = files.filter((file) => {
723
+ const lower = file.toLowerCase();
724
+ return !lower.includes('probe-debug.txt') && !lower.includes('node_modules') && !lower.includes('/.git/');
725
+ });
726
+
727
+ if (files.length > 0) {
728
+ const fileListHeader = `\n\n# Project Files (Sample of up to ${files.length} files in ${searchDirectory}):\n`;
729
+ const fileListContent = files.map(file => `- ${file}`).join('\n');
730
+ systemMessage += fileListHeader + fileListContent;
731
+ if (this.debug) {
732
+ console.log(`[DEBUG] Added ${files.length} files to system message. Total length: ${systemMessage.length}`);
733
+ }
734
+ } else {
735
+ if (this.debug) {
736
+ console.log(`[DEBUG] No files found or listed for the project directory: ${searchDirectory}.`);
737
+ }
738
+ systemMessage += `\n\n# Project Files\nNo files listed for the primary directory (${searchDirectory}). You may need to use tools like 'search' or 'query' with broad paths initially if the user's request requires file exploration.`;
739
+ }
740
+ } catch (error) {
741
+ console.warn(`Warning: Could not generate file list for directory "${searchDirectory}": ${error.message}`);
742
+ systemMessage += `\n\n# Project Files\nCould not retrieve file listing. Proceed based on user instructions and tool capabilities.`;
743
+ }
744
+
745
+ if (this.debug) {
746
+ console.log(`[DEBUG] Final system message length: ${systemMessage.length}`);
747
+ // Log first/last parts for verification
748
+ const debugFilePath = join(process.cwd(), 'probe-debug-system-prompt.txt');
749
+ try {
750
+ writeFileSync(debugFilePath, systemMessage);
751
+ console.log(`[DEBUG] Full system prompt saved to ${debugFilePath}`);
752
+ } catch (e) {
753
+ console.error(`[DEBUG] Failed to write full system prompt: ${e.message}`);
754
+ console.log(`[DEBUG] System message START:\n${systemMessage.substring(0, 300)}...`);
755
+ console.log(`[DEBUG] System message END:\n...${systemMessage.substring(systemMessage.length - 300)}`);
756
+ }
757
+ }
758
+
759
+ return systemMessage;
760
+ }
761
+
762
+ /**
763
+ * Abort the current chat request
764
+ */
765
+ abort() {
766
+ if (!this.isNonInteractive || this.debug) {
767
+ console.log(`Aborting chat for session: ${this.sessionId}`);
768
+ }
769
+ this.cancelled = true;
770
+
771
+ // Abort any fetch requests
772
+ if (this.abortController) {
773
+ try {
774
+ this.abortController.abort('User cancelled request'); // Pass reason
775
+ } catch (error) {
776
+ // Ignore errors if already aborted or controller is in an unexpected state
777
+ if (error.name !== 'AbortError') {
778
+ console.error('Error aborting fetch request:', error);
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ /**
785
+ * Process a user message and get a response
786
+ * @param {string} message - The user message
787
+ * @param {string} [sessionId] - Optional session ID to use for this chat (overrides the default)
788
+ * @param {Object} [apiCredentials] - Optional API credentials for this call
789
+ * @param {string[]} [images] - Optional array of base64 image URLs
790
+ * @returns {Promise<string>} - The AI response
791
+ */
792
+ async chat(message, sessionId, apiCredentials = null, images = []) {
793
+ // Use our custom app tracer for granular tracing
794
+ const effectiveSessionId = sessionId || this.sessionId;
795
+
796
+ // Start the chat session span first, then execute the entire chat flow within the session context
797
+ const chatSessionSpan = appTracer.startChatSession(effectiveSessionId, message, this.apiType, this.model);
798
+
799
+ // Execute the entire chat flow within the session context
800
+ return await appTracer.withSessionContext(effectiveSessionId, async () => {
801
+
802
+ try {
803
+
804
+ // Update client credentials if provided in this call
805
+ if (apiCredentials) {
806
+ this.clientApiProvider = apiCredentials.apiProvider || this.clientApiProvider;
807
+ this.clientApiKey = apiCredentials.apiKey || this.clientApiKey;
808
+ this.clientApiUrl = apiCredentials.apiUrl || this.clientApiUrl;
809
+
810
+ // Re-initialize the model with the new credentials
811
+ if (apiCredentials.apiKey && apiCredentials.apiProvider) {
812
+ this.initializeModel();
813
+ }
814
+ }
815
+
816
+ // Handle no API keys mode gracefully
817
+ if (this.noApiKeysMode) {
818
+ console.error("Cannot process chat: No API keys configured.");
819
+ appTracer.endChatSession(effectiveSessionId, false, 0);
820
+ // Return structured response even for API key errors
821
+ return {
822
+ response: "Error: ProbeChat is not configured with an AI provider API key. Please set the appropriate environment variable (e.g., ANTHROPIC_API_KEY, OPENAI_API_KEY) or provide an API key in the browser.",
823
+ tokenUsage: { contextWindow: 0, current: {}, total: {} }
824
+ };
825
+ }
826
+
827
+ // Reset cancelled flag for the new request
828
+ this.cancelled = false;
829
+
830
+ // Create a new AbortController for this specific request
831
+ // This ensures previous cancellations don't affect new requests
832
+ this.abortController = new AbortController();
833
+
834
+ // If a session ID is provided and it's different from the current one, update it
835
+ if (sessionId && sessionId !== this.sessionId) {
836
+ if (this.debug) {
837
+ console.log(`[DEBUG] Switching session ID from ${this.sessionId} to ${sessionId}`);
838
+ }
839
+ // Update the session ID for this instance
840
+ this.sessionId = sessionId;
841
+ // NOTE: History is NOT cleared automatically when session ID changes this way.
842
+ // Call clearHistory() explicitly if a new session should start fresh.
843
+ }
844
+
845
+ // Process the message using the potentially updated session ID
846
+ const result = await this._processChat(message, effectiveSessionId, images);
847
+
848
+ appTracer.endChatSession(effectiveSessionId, true, result.tokenUsage?.total?.total || 0);
849
+
850
+ // CRITICAL FIX: Ensure all spans are properly exported before returning
851
+ if (this.telemetryConfig) {
852
+ try {
853
+ // First, ensure the session span is ended within its context
854
+ await appTracer.withSessionContext(effectiveSessionId, async () => {
855
+ // Small delay to ensure all child spans are ended
856
+ await new Promise(resolve => setTimeout(resolve, 50));
857
+ });
858
+
859
+ // Give BatchSpanProcessor time to process the ended spans
860
+ // BatchSpanProcessor has a scheduledDelayMillis of 500ms (reduced from default 5000ms)
861
+ await new Promise(resolve => setTimeout(resolve, 600));
862
+
863
+ // Force flush all pending spans
864
+ await this.telemetryConfig.forceFlush();
865
+
866
+ // Additional delay to ensure file writes complete
867
+ await new Promise(resolve => setTimeout(resolve, 100));
868
+ } catch (flushError) {
869
+ if (this.debug) console.log('[DEBUG] Telemetry flush warning:', flushError.message);
870
+ }
871
+ }
872
+
873
+ return result;
874
+ } catch (error) {
875
+ appTracer.endChatSession(effectiveSessionId, false, 0);
876
+
877
+ // CRITICAL FIX: Ensure all spans are properly exported even on error
878
+ if (this.telemetryConfig) {
879
+ try {
880
+ // First, ensure the session span is ended within its context
881
+ await appTracer.withSessionContext(effectiveSessionId, async () => {
882
+ // Small delay to ensure all child spans are ended
883
+ await new Promise(resolve => setTimeout(resolve, 50));
884
+ });
885
+
886
+ // Give BatchSpanProcessor time to process the ended spans
887
+ // BatchSpanProcessor has a scheduledDelayMillis of 500ms (reduced from default 5000ms)
888
+ await new Promise(resolve => setTimeout(resolve, 600));
889
+
890
+ // Force flush all pending spans
891
+ await this.telemetryConfig.forceFlush();
892
+
893
+ // Additional delay to ensure file writes complete
894
+ await new Promise(resolve => setTimeout(resolve, 100));
895
+ } catch (flushError) {
896
+ if (this.debug) console.log('[DEBUG] Telemetry flush warning:', flushError.message);
897
+ }
898
+ }
899
+
900
+ throw error;
901
+ }
902
+ }); // End withSessionContext
903
+ }
904
+
905
+ /**
906
+ * Internal method to process a chat message using the XML tool loop
907
+ * @param {string} message - The user message
908
+ * @param {string} sessionId - The session ID for tracing
909
+ * @param {string[]} images - Array of base64 image URLs
910
+ * @returns {Promise<string>} - The final AI response after loop completion
911
+ * @private
912
+ */
913
+ async _processChat(message, sessionId, images = []) {
914
+ let currentIteration = 0;
915
+ let completionAttempted = false;
916
+ let finalResult = `Error: Max tool iterations (${MAX_TOOL_ITERATIONS}) reached without completion. You can increase this limit using the MAX_TOOL_ITERATIONS environment variable or --max-iterations flag.`; // Default error
917
+
918
+ this.abortController = new AbortController();
919
+ const debugFilePath = join(process.cwd(), 'probe-debug.txt');
920
+
921
+ try {
922
+ if (this.debug) {
923
+ console.log(`[DEBUG] ===== Starting XML Tool Chat Loop (Session: ${this.sessionId}) =====`);
924
+ console.log(`[DEBUG] Received user message: ${message}`);
925
+ console.log(`[DEBUG] Initial history length: ${this.history.length}`);
926
+ }
927
+
928
+ this.tokenCounter.startNewTurn();
929
+ this.tokenCounter.addRequestTokens(this.tokenCounter.countTokens(message));
930
+
931
+ if (this.history.length > MAX_HISTORY_MESSAGES) {
932
+ const removedCount = this.history.length - MAX_HISTORY_MESSAGES;
933
+ this.history = this.history.slice(removedCount);
934
+ if (this.debug) console.log(`[DEBUG] Trimmed history to ${this.history.length} messages (removed ${removedCount}).`);
935
+ }
936
+
937
+ const isFirstMessage = this.history.length === 0;
938
+
939
+ // Start user message processing trace
940
+ const messageId = `msg_${Date.now()}`;
941
+ appTracer.startUserMessageProcessing(sessionId, messageId, message);
942
+
943
+ // Extract image URLs from the message within the processing context
944
+ const { imageUrls, cleanedMessage } = appTracer.withUserProcessingContext(sessionId, () =>
945
+ extractImageUrls(message, this.debug)
946
+ );
947
+
948
+ // Start image processing trace if images are found
949
+ if (imageUrls.length > 0) {
950
+ appTracer.startImageProcessing(sessionId, messageId, imageUrls, cleanedMessage.length);
951
+ if (this.debug) console.log(`[DEBUG] Found ${imageUrls.length} image URLs in message`);
952
+ }
953
+
954
+ // Log image detection only in interactive mode or debug mode
955
+ if (imageUrls.length > 0) {
956
+ if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
957
+ console.log(`Detected ${imageUrls.length} image URL(s) in message.`);
958
+ }
959
+ if (this.debug) {
960
+ console.log(`[DEBUG] Extracted image URLs:`, imageUrls);
961
+ }
962
+ }
963
+
964
+ // Validate image URLs and filter out broken ones
965
+ let validImageUrls = [];
966
+ let validationResults = null;
967
+
968
+ if (imageUrls.length > 0) {
969
+ const validationStartTime = Date.now();
970
+ validImageUrls = await validateImageUrls(imageUrls, this.debug);
971
+ const validationEndTime = Date.now();
972
+
973
+ // Record validation results in trace
974
+ validationResults = {
975
+ totalUrls: imageUrls.length,
976
+ validUrls: validImageUrls.length,
977
+ invalidUrls: imageUrls.length - validImageUrls.length,
978
+ redirectedUrls: 0, // TODO: capture from validateImageUrls if needed
979
+ timeoutUrls: 0, // TODO: capture from validateImageUrls if needed
980
+ networkErrors: 0, // TODO: capture from validateImageUrls if needed
981
+ durationMs: validationEndTime - validationStartTime
982
+ };
983
+
984
+ appTracer.recordImageValidation(sessionId, validationResults);
985
+ appTracer.endImageProcessing(sessionId, validImageUrls.length > 0, validImageUrls.length);
986
+ } else {
987
+ validImageUrls = await validateImageUrls(imageUrls, this.debug);
988
+ }
989
+
990
+ // Start the agent loop trace within user processing context
991
+ appTracer.withUserProcessingContext(sessionId, () => {
992
+ appTracer.startAgentLoop(sessionId, MAX_TOOL_ITERATIONS);
993
+ });
994
+
995
+ // Log validation results only in interactive mode or debug mode
996
+ if (imageUrls.length > 0) {
997
+ const invalidCount = imageUrls.length - validImageUrls.length;
998
+ if (process.env.PROBE_NON_INTERACTIVE !== '1' || process.env.DEBUG_CHAT === '1') {
999
+ if (validImageUrls.length > 0) {
1000
+ console.log(`Image validation: ${validImageUrls.length} valid, ${invalidCount} invalid/inaccessible.`);
1001
+ } else {
1002
+ console.log(`Image validation: All ${imageUrls.length} image URLs failed validation.`);
1003
+ }
1004
+ }
1005
+
1006
+ if (this.debug && validImageUrls.length > 0) {
1007
+ console.log(`[DEBUG] Valid image URLs:`, validImageUrls);
1008
+ }
1009
+ }
1010
+
1011
+ const wrappedMessage = isFirstMessage ? `<task>\n${cleanedMessage}\n</task>` : cleanedMessage;
1012
+
1013
+ // Combine extracted URL images with uploaded base64 images
1014
+ const allImages = [...validImageUrls, ...images];
1015
+
1016
+ // Create the user message with potential image attachments
1017
+ const userMessage = { role: 'user', content: wrappedMessage };
1018
+
1019
+ // Store user message in display history (always visible to users)
1020
+ const displayUserMessage = {
1021
+ role: 'user',
1022
+ content: message, // Store original unwrapped message
1023
+ visible: true,
1024
+ displayType: 'user',
1025
+ timestamp: new Date().toISOString()
1026
+ };
1027
+
1028
+ // Add image attachments if any images are present
1029
+ if (allImages.length > 0) {
1030
+ userMessage.content = [
1031
+ { type: 'text', text: wrappedMessage },
1032
+ ...allImages.map(imageUrl => ({
1033
+ type: 'image',
1034
+ image: imageUrl
1035
+ }))
1036
+ ];
1037
+
1038
+ // Add images to display message as well
1039
+ displayUserMessage.images = allImages;
1040
+
1041
+ if (this.debug) {
1042
+ console.log(`[DEBUG] Created message with ${allImages.length} images (${validImageUrls.length} from URLs, ${images.length} uploaded)`);
1043
+ }
1044
+ }
1045
+
1046
+ // Add user message to display history
1047
+ if (!this.displayHistory) {
1048
+ this.displayHistory = [];
1049
+ }
1050
+ this.displayHistory.push(displayUserMessage);
1051
+
1052
+ // Save user message to persistent storage
1053
+ if (this.storage) {
1054
+ this.storage.saveMessage(this.sessionId, {
1055
+ role: 'user',
1056
+ content: message, // Original message
1057
+ timestamp: Date.now(),
1058
+ displayType: 'user',
1059
+ visible: 1,
1060
+ images: allImages,
1061
+ metadata: {}
1062
+ }).catch(err => {
1063
+ console.error('Failed to save user message to persistent storage:', err);
1064
+ });
1065
+ }
1066
+
1067
+ let currentMessages = [
1068
+ ...this.history,
1069
+ userMessage
1070
+ ];
1071
+
1072
+ const promptGenerationStart = Date.now();
1073
+ const systemPrompt = await this.getSystemMessage();
1074
+ const promptGenerationEnd = Date.now();
1075
+
1076
+ if (this.debug) {
1077
+ const systemTokens = this.tokenCounter.countTokens(systemPrompt);
1078
+ this.tokenCounter.addRequestTokens(systemTokens);
1079
+ console.log(`[DEBUG] System prompt estimated tokens: ${systemTokens}`);
1080
+
1081
+ // Record system prompt generation metrics
1082
+ appTracer.recordSystemPromptGeneration(sessionId, {
1083
+ baseLength: 11747, // Approximate base system message length
1084
+ finalLength: systemPrompt.length,
1085
+ filesAdded: this.history.length > 0 ? 35 : 36, // Approximate from logs
1086
+ generationDurationMs: promptGenerationEnd - promptGenerationStart,
1087
+ promptType: this.promptType || 'default',
1088
+ estimatedTokens: systemTokens
1089
+ });
1090
+ }
1091
+
1092
+ while (currentIteration < MAX_TOOL_ITERATIONS && !completionAttempted) {
1093
+ currentIteration++;
1094
+ if (this.cancelled) throw new Error('Request was cancelled by the user');
1095
+
1096
+ // Start iteration trace within agent loop context
1097
+ appTracer.withAgentLoopContext(sessionId, () => {
1098
+ appTracer.startAgentIteration(sessionId, currentIteration, currentMessages.length, this.tokenCounter.contextSize || 0);
1099
+ });
1100
+
1101
+ if (this.debug) {
1102
+ console.log(`\n[DEBUG] --- Tool Loop Iteration ${currentIteration}/${MAX_TOOL_ITERATIONS} ---`);
1103
+ console.log(`[DEBUG] Current messages count for AI call: ${currentMessages.length}`);
1104
+ currentMessages.slice(-3).forEach((msg, idx) => {
1105
+ const contentPreview = (typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)).substring(0, 80).replace(/\n/g, ' ');
1106
+ console.log(`[DEBUG] Msg[${currentMessages.length - 3 + idx}]: ${msg.role}: ${contentPreview}...`);
1107
+ });
1108
+ }
1109
+
1110
+ this.tokenCounter.calculateContextSize(currentMessages);
1111
+ if (this.debug) console.log(`[DEBUG] Estimated context tokens BEFORE LLM call (Iter ${currentIteration}): ${this.tokenCounter.contextSize}`);
1112
+
1113
+ let maxResponseTokens = 4000;
1114
+ if (this.model.includes('claude-3-opus') || this.model.startsWith('gpt-4-')) {
1115
+ maxResponseTokens = 4096;
1116
+ } else if (this.model.includes('claude-3-5-sonnet') || this.model.startsWith('gpt-4o')) {
1117
+ maxResponseTokens = 8000;
1118
+ } else if (this.model.includes('gemini-2.5')) {
1119
+ maxResponseTokens = 60000;
1120
+ } else if (this.model.startsWith('gemini')) {
1121
+ maxResponseTokens = 8000;
1122
+ }
1123
+ this.tokenDisplay = new TokenUsageDisplay({ maxTokens: maxResponseTokens });
1124
+
1125
+ const userMsgIndices = currentMessages.reduce(
1126
+ (acc, msg, index) => (msg.role === 'user' ? [...acc, index] : acc),
1127
+ []
1128
+ );
1129
+ const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1;
1130
+ const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1;
1131
+
1132
+ let transformedMessages = currentMessages;
1133
+ if (this.apiType === 'anthropic') {
1134
+ transformedMessages = currentMessages.map((message, index) => {
1135
+ if (message.role === 'user' && (index === lastUserMsgIndex || index === secondLastUserMsgIndex)) {
1136
+ return {
1137
+ ...message,
1138
+ content: typeof message.content === 'string'
1139
+ ? [{ type: "text", text: message.content, providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } } }]
1140
+ : message.content.map((content, contentIndex) => {
1141
+ // Only apply cache_control to the text part, not images
1142
+ if (content.type === 'text' && contentIndex === 0) {
1143
+ return {
1144
+ ...content,
1145
+ providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
1146
+ };
1147
+ }
1148
+ return content;
1149
+ })
1150
+ };
1151
+ }
1152
+ return message;
1153
+ });
1154
+ }
1155
+
1156
+ let streamError;
1157
+
1158
+ const generateOptions = {
1159
+ model: this.provider(this.model),
1160
+ messages: transformedMessages,
1161
+ system: systemPrompt,
1162
+ temperature: 0.3,
1163
+ maxTokens: maxResponseTokens,
1164
+ signal: this.abortController.signal,
1165
+ onError({ error }) {
1166
+ streamError = error;
1167
+ console.error(error); // your error logging logic here
1168
+ },
1169
+ providerOptions: {
1170
+ openai: {
1171
+ streamOptions: {
1172
+ include_usage: true
1173
+ }
1174
+ }
1175
+ },
1176
+ experimental_telemetry: {
1177
+ isEnabled: false, // Disable built-in telemetry in favor of our custom tracing
1178
+ functionId: this.sessionId,
1179
+ metadata: {
1180
+ sessionId: this.sessionId,
1181
+ iteration: currentIteration,
1182
+ model: this.model,
1183
+ apiType: this.apiType,
1184
+ allowEdit: this.allowEdit,
1185
+ promptType: this.promptType || 'default'
1186
+ }
1187
+ }
1188
+ };
1189
+
1190
+ // Start AI generation request trace within iteration context
1191
+ const aiRequestSpan = appTracer.withIterationContext(sessionId, currentIteration, () => {
1192
+ return appTracer.startAiGenerationRequest(sessionId, currentIteration, this.model, this.apiType, {
1193
+ temperature: 0.3,
1194
+ maxTokens: maxResponseTokens,
1195
+ maxRetries: 2
1196
+ });
1197
+ });
1198
+
1199
+ // **Streaming Response Handling**
1200
+ let assistantResponseContent = '';
1201
+ let startTime = Date.now();
1202
+ let firstChunkTime = null;
1203
+ try {
1204
+ if (this.debug) console.log(`[DEBUG] Calling streamText with model ${this.model}...`);
1205
+
1206
+ if (streamError) {
1207
+ throw streamError
1208
+ }
1209
+
1210
+ const { textStream } = streamText(generateOptions);
1211
+ for await (const chunk of textStream) {
1212
+ if (this.cancelled) throw new Error('Request was cancelled by the user');
1213
+ if (firstChunkTime === null) {
1214
+ firstChunkTime = Date.now();
1215
+ }
1216
+ assistantResponseContent += chunk;
1217
+ }
1218
+
1219
+ if (this.debug) {
1220
+ console.log(`[DEBUG] Streamed AI response (Iter ${currentIteration}). Length: ${assistantResponseContent.length}`);
1221
+ }
1222
+ if (assistantResponseContent.length == 0) {
1223
+ console.warn(`[WARN] Empty response from AI model (Iter ${currentIteration}).`);
1224
+ throw new Error('Empty response from AI model');
1225
+ }
1226
+
1227
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
1228
+
1229
+ const responseTokenCount = this.tokenCounter.countTokens(assistantResponseContent);
1230
+ if (this.debug) console.log(`[DEBUG] Estimated response tokens (Iter ${currentIteration}): ${responseTokenCount}`);
1231
+ this.tokenCounter.addResponseTokens(responseTokenCount);
1232
+ this.tokenCounter.calculateContextSize(currentMessages);
1233
+ if (this.debug) console.log(`[DEBUG] Context size AFTER LLM response (Iter ${currentIteration}): ${this.tokenCounter.contextSize}`);
1234
+
1235
+ // Record AI response in trace
1236
+ const endTime = Date.now();
1237
+ appTracer.recordAiResponse(sessionId, currentIteration, {
1238
+ response: assistantResponseContent, // Include actual response content
1239
+ responseLength: assistantResponseContent.length,
1240
+ completionTokens: responseTokenCount,
1241
+ promptTokens: this.tokenCounter.contextSize || 0,
1242
+ finishReason: 'stop',
1243
+ timeToFirstChunk: firstChunkTime ? (firstChunkTime - startTime) : 0,
1244
+ timeToFinish: endTime - startTime
1245
+ });
1246
+
1247
+ appTracer.endAiRequest(sessionId, currentIteration, true);
1248
+
1249
+ } catch (error) {
1250
+ // Classify and record the AI model error
1251
+ let errorCategory = 'unknown';
1252
+ if (this.cancelled || error.name === 'AbortError' || (error.message && error.message.includes('cancelled'))) {
1253
+ errorCategory = 'cancellation';
1254
+ } else if (error.message?.includes('timeout')) {
1255
+ errorCategory = 'timeout';
1256
+ } else if (error.message?.includes('rate limit') || error.message?.includes('quota')) {
1257
+ errorCategory = 'api_limit';
1258
+ } else if (error.message?.includes('network') || error.message?.includes('fetch')) {
1259
+ errorCategory = 'network';
1260
+ } else if (error.status >= 400 && error.status < 500) {
1261
+ errorCategory = 'client_error';
1262
+ } else if (error.status >= 500) {
1263
+ errorCategory = 'server_error';
1264
+ }
1265
+
1266
+ appTracer.recordAiModelError(sessionId, currentIteration, {
1267
+ category: errorCategory,
1268
+ message: error.message,
1269
+ model: this.model,
1270
+ provider: this.apiType,
1271
+ statusCode: error.status || 0,
1272
+ retryAttempt: 0
1273
+ });
1274
+
1275
+ appTracer.endAiRequest(sessionId, currentIteration, false);
1276
+
1277
+ if (this.cancelled || error.name === 'AbortError' || (error.message && error.message.includes('cancelled'))) {
1278
+ console.log(`Chat request cancelled during LLM call (Iter ${currentIteration})`);
1279
+ this.cancelled = true;
1280
+ appTracer.recordSessionCancellation(sessionId, 'ai_request_cancelled', {
1281
+ currentIteration,
1282
+ activeTool: 'ai_generation'
1283
+ });
1284
+ throw new Error('Request was cancelled by the user');
1285
+ }
1286
+ console.error(`Error during streamText (Iter ${currentIteration}):`, error);
1287
+ finalResult = `Error: Failed to get response from AI model during iteration ${currentIteration}. ${error.message}`;
1288
+ throw new Error(finalResult);
1289
+ }
1290
+
1291
+ const parsedTool = parseXmlToolCallWithThinking(assistantResponseContent);
1292
+ if (parsedTool) {
1293
+ const { toolName, params } = parsedTool;
1294
+ if (this.debug) console.log(`[DEBUG] Parsed tool call: ${toolName} with params:`, params);
1295
+
1296
+ // Record tool call parsing in trace
1297
+ appTracer.recordToolCallParsed(sessionId, currentIteration, toolName, params);
1298
+
1299
+ if (toolName === 'attempt_completion') {
1300
+ completionAttempted = true;
1301
+ const validation = attemptCompletionSchema.safeParse(params);
1302
+ if (!validation.success) {
1303
+ finalResult = `Error: AI attempted completion with invalid parameters: ${JSON.stringify(validation.error.issues)}`;
1304
+ console.warn(`[WARN] Invalid attempt_completion parameters:`, validation.error.issues);
1305
+ appTracer.recordCompletionAttempt(sessionId, false);
1306
+ } else {
1307
+ finalResult = validation.data.result;
1308
+
1309
+ // Store final assistant response in display history
1310
+ const displayAssistantMessage = {
1311
+ role: 'assistant',
1312
+ content: finalResult,
1313
+ visible: true,
1314
+ displayType: 'final',
1315
+ timestamp: new Date().toISOString()
1316
+ };
1317
+ this.displayHistory.push(displayAssistantMessage);
1318
+
1319
+ // Save final response to persistent storage
1320
+ if (this.storage) {
1321
+ this.storage.saveMessage(this.sessionId, {
1322
+ role: 'assistant',
1323
+ content: finalResult,
1324
+ timestamp: Date.now(),
1325
+ displayType: 'final',
1326
+ visible: 1,
1327
+ images: [],
1328
+ metadata: {}
1329
+ }).catch(err => {
1330
+ console.error('Failed to save final response to persistent storage:', err);
1331
+ });
1332
+ }
1333
+
1334
+ appTracer.recordCompletionAttempt(sessionId, true, finalResult);
1335
+ if (this.debug) {
1336
+ console.log(`[DEBUG] Completion attempted successfully. Final Result captured.`);
1337
+
1338
+ try {
1339
+ const systemPrompt = await this.getSystemMessage();
1340
+ let debugContent = `system: ${systemPrompt}\n\n`;
1341
+ for (const msg of currentMessages) {
1342
+ if (msg.role === 'user' || msg.role === 'assistant') {
1343
+ debugContent += `${msg.role}: ${msg.content}\n\n`;
1344
+ }
1345
+ }
1346
+ debugContent += `assistant (final result): ${finalResult}\n\n`;
1347
+ writeFileSync(debugFilePath, debugContent, { flag: 'w' });
1348
+ if (this.debug) console.log(`[DEBUG] Wrote complete chat history to ${debugFilePath}`);
1349
+ } catch (error) {
1350
+ console.error(`Error writing chat history to debug file: ${error.message}`);
1351
+ }
1352
+ }
1353
+ }
1354
+ break;
1355
+
1356
+ } else if (this.toolImplementations[toolName]) {
1357
+ const toolInstance = this.toolImplementations[toolName];
1358
+ let toolResultContent = '';
1359
+
1360
+ // Start tool execution trace within iteration context
1361
+ appTracer.withIterationContext(sessionId, currentIteration, () => {
1362
+ appTracer.startToolExecution(sessionId, currentIteration, toolName, params);
1363
+ });
1364
+
1365
+ try {
1366
+ const enhancedParams = { ...params, sessionId: this.sessionId };
1367
+ if (this.debug) console.log(`[DEBUG] Executing tool '${toolName}' with params:`, enhancedParams);
1368
+ const executionResult = await toolInstance.execute(enhancedParams);
1369
+ toolResultContent = typeof executionResult === 'string' ? executionResult : JSON.stringify(executionResult, null, 2);
1370
+ if (this.debug) {
1371
+ const preview = toolResultContent.substring(0, 200).replace(/\n/g, ' ') + (toolResultContent.length > 200 ? '...' : '');
1372
+ console.log(`[DEBUG] Tool '${toolName}' executed successfully. Result preview: ${preview}`);
1373
+ }
1374
+
1375
+ // End tool execution trace with success
1376
+ appTracer.endToolExecution(sessionId, currentIteration, true, toolResultContent.length, null, toolResultContent);
1377
+ } catch (error) {
1378
+ console.error(`Error executing tool ${toolName}:`, error);
1379
+ toolResultContent = `Error executing tool ${toolName}: ${error.message}`;
1380
+ if (this.debug) console.log(`[DEBUG] Tool '${toolName}' execution FAILED.`);
1381
+
1382
+ // Classify and record tool execution error
1383
+ let errorCategory = 'execution';
1384
+ if (error.message?.includes('validation')) {
1385
+ errorCategory = 'validation';
1386
+ } else if (error.message?.includes('permission') || error.message?.includes('access')) {
1387
+ errorCategory = 'filesystem';
1388
+ } else if (error.message?.includes('network') || error.message?.includes('fetch')) {
1389
+ errorCategory = 'network';
1390
+ } else if (error.message?.includes('timeout')) {
1391
+ errorCategory = 'timeout';
1392
+ }
1393
+
1394
+ appTracer.recordToolError(sessionId, currentIteration, toolName, {
1395
+ category: errorCategory,
1396
+ message: error.message,
1397
+ exitCode: error.code || 0,
1398
+ signal: error.signal || '',
1399
+ params: enhancedParams
1400
+ });
1401
+
1402
+ // End tool execution trace with failure
1403
+ appTracer.endToolExecution(sessionId, currentIteration, false, 0, error.message, toolResultContent);
1404
+ }
1405
+
1406
+ const toolResultMessage = `<tool_result>\n${toolResultContent}\n</tool_result>`;
1407
+ currentMessages.push({ role: 'user', content: toolResultMessage });
1408
+ this.tokenCounter.calculateContextSize(currentMessages);
1409
+ if (this.debug) console.log(`[DEBUG] Context size after adding tool result for '${toolName}': ${this.tokenCounter.contextSize}`);
1410
+
1411
+ } else {
1412
+ if (this.debug) console.log(`[DEBUG] Assistant used invalid tool name: ${toolName}`);
1413
+ const errorContent = `<tool_result>\nError: Invalid tool name specified: '${toolName}'. Please use one of: search, query, extract, attempt_completion.\n</tool_result>`;
1414
+ currentMessages.push({ role: 'user', content: errorContent });
1415
+ this.tokenCounter.calculateContextSize(currentMessages);
1416
+ }
1417
+
1418
+ } else {
1419
+ if (this.debug) console.log(`[DEBUG] Assistant response did not contain a valid XML tool call.`);
1420
+ const forceToolContent = `Your response did not contain a valid tool call in the required XML format. You MUST respond with exactly one tool call (e.g., <search>...</search> or <attempt_completion>...</attempt_completion>) based on the previous steps and the user's goal. Analyze the situation and choose the appropriate next tool.`;
1421
+ currentMessages.push({ role: 'user', content: forceToolContent });
1422
+ this.tokenCounter.calculateContextSize(currentMessages);
1423
+ }
1424
+
1425
+ if (currentMessages.length > MAX_HISTORY_MESSAGES + 3) {
1426
+ const messagesBefore = currentMessages.length;
1427
+ const removeCount = currentMessages.length - MAX_HISTORY_MESSAGES;
1428
+ currentMessages = currentMessages.slice(removeCount);
1429
+
1430
+ // Record in-loop history management
1431
+ appTracer.recordHistoryOperation(sessionId, 'trim', {
1432
+ messagesBefore,
1433
+ messagesAfter: currentMessages.length,
1434
+ messagesRemoved: removeCount,
1435
+ reason: 'loop_memory_limit'
1436
+ });
1437
+
1438
+ if (this.debug) console.log(`[DEBUG] Trimmed 'currentMessages' within loop to ${currentMessages.length} (removed ${removeCount}).`);
1439
+ this.tokenCounter.calculateContextSize(currentMessages);
1440
+ }
1441
+
1442
+ // End iteration trace
1443
+ appTracer.endIteration(sessionId, currentIteration, true, completionAttempted ? 'completion_attempted' : 'tool_executed');
1444
+ }
1445
+
1446
+ if (currentIteration >= MAX_TOOL_ITERATIONS && !completionAttempted) {
1447
+ console.warn(`[WARN] Max tool iterations (${MAX_TOOL_ITERATIONS}) reached for session ${this.sessionId}. Returning current error state.`);
1448
+ }
1449
+
1450
+ // End agent loop trace
1451
+ appTracer.endAgentLoop(sessionId, currentIteration, completionAttempted, completionAttempted ? 'completion' : 'max_iterations');
1452
+
1453
+ this.history = currentMessages.map(msg => ({ ...msg }));
1454
+ if (this.history.length > MAX_HISTORY_MESSAGES) {
1455
+ const messagesBefore = this.history.length;
1456
+ const finalRemoveCount = this.history.length - MAX_HISTORY_MESSAGES;
1457
+ this.history = this.history.slice(finalRemoveCount);
1458
+
1459
+ // Record history management operation
1460
+ appTracer.recordHistoryOperation(sessionId, 'trim', {
1461
+ messagesBefore,
1462
+ messagesAfter: this.history.length,
1463
+ messagesRemoved: finalRemoveCount,
1464
+ reason: 'max_length'
1465
+ });
1466
+
1467
+ if (this.debug) console.log(`[DEBUG] Final history trim applied. Length: ${this.history.length} (removed ${finalRemoveCount})`);
1468
+ }
1469
+
1470
+ this.tokenCounter.updateHistory(this.history);
1471
+
1472
+ // Record token metrics
1473
+ const tokenUsage = this.tokenCounter.getTokenUsage();
1474
+ appTracer.recordTokenMetrics(sessionId, {
1475
+ contextWindow: tokenUsage.contextWindow || 0,
1476
+ currentTotal: tokenUsage.current?.total || 0,
1477
+ requestTokens: tokenUsage.current?.request || 0,
1478
+ responseTokens: tokenUsage.current?.response || 0,
1479
+ cacheRead: tokenUsage.current?.cacheRead || 0,
1480
+ cacheWrite: tokenUsage.current?.cacheWrite || 0
1481
+ });
1482
+
1483
+ // End user message processing trace
1484
+ appTracer.endUserMessageProcessing(sessionId, completionAttempted);
1485
+
1486
+ if (this.debug) {
1487
+ console.log(`[DEBUG] Updated tokenCounter history with ${this.history.length} messages`);
1488
+ console.log(`[DEBUG] Context size after history update: ${this.tokenCounter.contextSize}`);
1489
+ console.log(`[DEBUG] ===== Ending XML Tool Chat Loop =====`);
1490
+ console.log(`[DEBUG] Loop finished after ${currentIteration} iterations.`);
1491
+ console.log(`[DEBUG] Completion attempted: ${completionAttempted}`);
1492
+ console.log(`[DEBUG] Final history length: ${this.history.length}`);
1493
+ const resultPreview = (typeof finalResult === 'string' ? finalResult : JSON.stringify(finalResult)).substring(0, 200).replace(/\n/g, ' ');
1494
+ console.log(`[DEBUG] Returning final result: "${resultPreview}..."`);
1495
+ }
1496
+
1497
+ this.tokenCounter.calculateContextSize(this.history);
1498
+ const updatedTokenUsage = this.tokenCounter.getTokenUsage();
1499
+ if (this.debug) {
1500
+ console.log(`[DEBUG] Final context window size: ${updatedTokenUsage.contextWindow}`);
1501
+ console.log(`[DEBUG] Cache metrics - Read: ${updatedTokenUsage.current.cacheRead}, Write: ${updatedTokenUsage.current.cacheWrite}`);
1502
+ }
1503
+
1504
+ return {
1505
+ response: finalResult,
1506
+ tokenUsage: updatedTokenUsage
1507
+ };
1508
+
1509
+ } catch (error) {
1510
+ // Check if this is a critical API error that should cause process exit
1511
+ const isCriticalApiError = this._isCriticalApiError(error);
1512
+
1513
+ // Record the top-level processing error
1514
+ if (this.cancelled || (error.message && error.message.includes('cancelled'))) {
1515
+ appTracer.recordSessionCancellation(sessionId, 'processing_cancelled', {
1516
+ currentIteration,
1517
+ errorMessage: error.message
1518
+ });
1519
+ } else {
1520
+ // Record as a general processing error
1521
+ appTracer.recordAiModelError(sessionId, currentIteration || 0, {
1522
+ category: isCriticalApiError ? 'critical_api_error' : 'processing_error',
1523
+ message: error.message,
1524
+ model: this.model,
1525
+ provider: this.apiType,
1526
+ statusCode: error.statusCode || 0,
1527
+ retryAttempt: 0
1528
+ });
1529
+ }
1530
+
1531
+ // End chat session before cleanup to ensure span is properly captured
1532
+ appTracer.endChatSession(sessionId, false, 0);
1533
+
1534
+ // Clean up any remaining spans for this session (but session span is already ended)
1535
+ appTracer.cleanup(sessionId);
1536
+
1537
+ console.error('Error in chat processing loop:', error);
1538
+ if (this.debug) console.error('Error in chat processing loop:', error);
1539
+
1540
+ this.tokenCounter.updateHistory(this.history);
1541
+ if (this.debug) console.log(`[DEBUG] Error case - Updated tokenCounter history with ${this.history.length} messages`);
1542
+
1543
+ this.tokenCounter.calculateContextSize(this.history);
1544
+ const updatedTokenUsage = this.tokenCounter.getTokenUsage();
1545
+ if (this.debug) {
1546
+ console.log(`[DEBUG] Error case - Final context window size: ${updatedTokenUsage.contextWindow}`);
1547
+ console.log(`[DEBUG] Error case - Cache metrics - Read: ${updatedTokenUsage.current.cacheRead}, Write: ${updatedTokenUsage.current.cacheWrite}`);
1548
+ }
1549
+
1550
+ if (this.cancelled || (error.message && error.message.includes('cancelled'))) {
1551
+ return { response: "Request cancelled.", tokenUsage: updatedTokenUsage };
1552
+ }
1553
+
1554
+ // Re-throw critical API errors so the process exits with code 1
1555
+ if (isCriticalApiError) {
1556
+ throw error;
1557
+ }
1558
+
1559
+ return {
1560
+ response: `Error during chat processing: ${error.message || 'An unexpected error occurred.'}`,
1561
+ tokenUsage: updatedTokenUsage
1562
+ };
1563
+ } finally {
1564
+ this.abortController = null;
1565
+ }
1566
+ }
1567
+
1568
+ /**
1569
+ * Check if an error is a critical API error that should cause process exit
1570
+ * @param {Error} error - The error to check
1571
+ * @returns {boolean} - True if this is a critical API error
1572
+ * @private
1573
+ */
1574
+ _isCriticalApiError(error) {
1575
+ // Check for AI SDK API call errors
1576
+ if (error[Symbol.for('vercel.ai.error.AI_APICallError')]) {
1577
+ const statusCode = error.statusCode;
1578
+ const errorMessage = error.message?.toLowerCase() || '';
1579
+
1580
+ // Critical HTTP status codes that indicate configuration issues
1581
+ if (statusCode === 401 || statusCode === 403) {
1582
+ return true; // Unauthorized/Forbidden - bad API key
1583
+ }
1584
+ if (statusCode === 404) {
1585
+ return true; // Not Found - wrong URL or model not found
1586
+ }
1587
+ if (statusCode >= 500 && statusCode < 600) {
1588
+ return false; // Server errors - could be temporary, don't exit
1589
+ }
1590
+ }
1591
+
1592
+ // Check for specific error messages that indicate configuration issues
1593
+ const errorMessage = error.message?.toLowerCase() || '';
1594
+ if (errorMessage.includes('not found')) {
1595
+ return true; // API endpoint not found
1596
+ }
1597
+ if (errorMessage.includes('unauthorized') || errorMessage.includes('invalid api key')) {
1598
+ return true; // Authentication issues
1599
+ }
1600
+ if (errorMessage.includes('forbidden') || errorMessage.includes('access denied')) {
1601
+ return true; // Permission issues
1602
+ }
1603
+ if (errorMessage.includes('empty response from ai model')) {
1604
+ return true; // Indicates API connection/configuration issue
1605
+ }
1606
+
1607
+ return false; // Other errors are not critical
1608
+ }
1609
+
1610
+ /**
1611
+ * Get the current token usage summary
1612
+ * @returns {Object} - Raw token usage data for UI display
1613
+ */
1614
+ getTokenUsage() {
1615
+ // Get raw token usage from the counter
1616
+ const usage = this.tokenCounter.getTokenUsage();
1617
+
1618
+ // Return the raw usage data directly
1619
+ // This allows the web interface to format it as needed
1620
+ return usage;
1621
+ }
1622
+
1623
+ /**
1624
+ * Clear the entire history and reset session/token usage
1625
+ * @returns {string} - The new session ID
1626
+ */
1627
+ clearHistory() {
1628
+ const oldHistoryLength = this.history.length;
1629
+ const oldSessionId = this.sessionId;
1630
+
1631
+ this.history = [];
1632
+ this.sessionId = randomUUID(); // Generate a new session ID
1633
+
1634
+ // Clear the tokenCounter - this resets all counters and the internal history
1635
+ this.tokenCounter.clear();
1636
+
1637
+ // Double-check that the tokenCounter's history is empty
1638
+ if (this.tokenCounter.history && this.tokenCounter.history.length > 0) {
1639
+ this.tokenCounter.history = [];
1640
+ if (this.debug) {
1641
+ console.log(`[DEBUG] Explicitly cleared tokenCounter history after clear() call`);
1642
+ }
1643
+ }
1644
+
1645
+ this.cancelled = false; // Reset cancellation flag
1646
+ if (this.abortController) {
1647
+ // Ensure any lingering abort signal is cleared (though should be handled by `chat`)
1648
+ try { this.abortController.abort('History cleared'); } catch (e) { /* ignore */ }
1649
+ this.abortController = null;
1650
+ }
1651
+
1652
+
1653
+ if (this.debug) {
1654
+ console.log(`[DEBUG] ===== CLEARING CHAT HISTORY & STATE =====`);
1655
+ console.log(`[DEBUG] Cleared ${oldHistoryLength} messages from history`);
1656
+ console.log(`[DEBUG] Old session ID: ${oldSessionId}`);
1657
+ console.log(`[DEBUG] New session ID: ${this.sessionId}`);
1658
+ console.log(`[DEBUG] Token counter reset.`);
1659
+ console.log(`[DEBUG] Cancellation flag reset.`);
1660
+ }
1661
+
1662
+ // Tool implementations are instance properties, they persist. Session ID is passed during execution.
1663
+
1664
+ return this.sessionId; // Return the newly generated session ID
1665
+ }
1666
+
1667
+ /**
1668
+ * Get the session ID for this chat instance
1669
+ * @returns {string} - The session ID
1670
+ */
1671
+ getSessionId() {
1672
+ return this.sessionId;
1673
+ }
1674
+ }
1675
+
1676
+ // Export the extractImageUrls function for testing
1677
+ export { extractImageUrls };