@probelabs/probe 0.6.0-rc226 → 0.6.0-rc227

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.
@@ -3,6 +3,26 @@ import { EventEmitter } from 'events';
3
3
  import type { RetryOptions } from './RetryManager';
4
4
  import type { FallbackOptions } from './FallbackManager';
5
5
 
6
+ // ============================================================================
7
+ // Timeout Configuration Constants
8
+ // ============================================================================
9
+
10
+ /**
11
+ * Default activity timeout for engine streams (3 minutes / 180000ms).
12
+ * This is the time allowed between stream chunks before considering the stream stalled.
13
+ */
14
+ export const ENGINE_ACTIVITY_TIMEOUT_DEFAULT: number;
15
+
16
+ /**
17
+ * Minimum allowed activity timeout (5 seconds / 5000ms).
18
+ */
19
+ export const ENGINE_ACTIVITY_TIMEOUT_MIN: number;
20
+
21
+ /**
22
+ * Maximum allowed activity timeout (10 minutes / 600000ms).
23
+ */
24
+ export const ENGINE_ACTIVITY_TIMEOUT_MAX: number;
25
+
6
26
  /**
7
27
  * Configuration options for creating a ProbeAgent instance
8
28
  */
@@ -80,6 +100,10 @@ export interface ProbeAgentOptions {
80
100
  completionPrompt?: string;
81
101
  /** Enable task management system for tracking multi-step progress */
82
102
  enableTasks?: boolean;
103
+ /** Timeout in ms for AI requests (default: 120000 or REQUEST_TIMEOUT env var). Used to abort hung requests. */
104
+ requestTimeout?: number;
105
+ /** Maximum timeout in ms for the entire operation including all retries and fallbacks (default: 300000 or MAX_OPERATION_TIMEOUT env var). This is the absolute maximum time for streamTextWithRetryAndFallback. */
106
+ maxOperationTimeout?: number;
83
107
  }
84
108
 
85
109
  /**
@@ -4,6 +4,29 @@
4
4
  import dotenv from 'dotenv';
5
5
  dotenv.config();
6
6
 
7
+ // ============================================================================
8
+ // Timeout Configuration Constants
9
+ // ============================================================================
10
+
11
+ /**
12
+ * Default activity timeout for engine streams (3 minutes).
13
+ * This is the time allowed between stream chunks before considering the stream stalled.
14
+ * Conservative default to handle extended thinking models that may not stream during thinking.
15
+ */
16
+ export const ENGINE_ACTIVITY_TIMEOUT_DEFAULT = 180000;
17
+
18
+ /**
19
+ * Minimum allowed activity timeout (5 seconds).
20
+ * Prevents unreasonably short timeouts that could cause premature failures.
21
+ */
22
+ export const ENGINE_ACTIVITY_TIMEOUT_MIN = 5000;
23
+
24
+ /**
25
+ * Maximum allowed activity timeout (10 minutes).
26
+ * Prevents excessively long waits for stalled streams.
27
+ */
28
+ export const ENGINE_ACTIVITY_TIMEOUT_MAX = 600000;
29
+
7
30
  import { createAnthropic } from '@ai-sdk/anthropic';
8
31
  import { createOpenAI } from '@ai-sdk/openai';
9
32
  import { createGoogleGenerativeAI } from '@ai-sdk/google';
@@ -189,6 +212,8 @@ export class ProbeAgent {
189
212
  * @param {number} [options.fallback.maxTotalAttempts=10] - Maximum total attempts across all providers
190
213
  * @param {string} [options.completionPrompt] - Custom prompt to run after attempt_completion for validation/review (runs before mermaid/JSON validation)
191
214
  * @param {number} [options.maxOutputTokens] - Maximum tokens for tool output before truncation (default: 20000, can also be set via PROBE_MAX_OUTPUT_TOKENS env var)
215
+ * @param {number} [options.requestTimeout] - Timeout in ms for AI requests (default: 120000 or REQUEST_TIMEOUT env var). Used to abort hung requests.
216
+ * @param {number} [options.maxOperationTimeout] - Maximum timeout in ms for the entire operation including all retries and fallbacks (default: 300000 or MAX_OPERATION_TIMEOUT env var). This is the absolute maximum time for streamTextWithRetryAndFallback.
192
217
  */
193
218
  constructor(options = {}) {
194
219
  // Basic configuration
@@ -330,6 +355,41 @@ export class ProbeAgent {
330
355
  // Each ProbeAgent instance has its own limits, not shared globally
331
356
  this.delegationManager = new DelegationManager();
332
357
 
358
+ // Request timeout configuration (default 2 minutes)
359
+ // Validates env var to prevent NaN or unreasonable values
360
+ this.requestTimeout = options.requestTimeout ?? (() => {
361
+ if (process.env.REQUEST_TIMEOUT) {
362
+ const parsed = parseInt(process.env.REQUEST_TIMEOUT, 10);
363
+ // Validate: must be positive number between 1s and 1 hour
364
+ if (isNaN(parsed) || parsed < 1000 || parsed > 3600000) {
365
+ return 120000; // Default 2 minutes
366
+ }
367
+ return parsed;
368
+ }
369
+ return 120000;
370
+ })();
371
+ if (this.debug) {
372
+ console.log(`[DEBUG] Request timeout: ${this.requestTimeout}ms`);
373
+ }
374
+
375
+ // Maximum operation timeout for entire streamTextWithRetryAndFallback operation (default 5 minutes)
376
+ // This is the absolute maximum time including all retries and fallbacks
377
+ // Validates env var to prevent NaN or unreasonable values
378
+ this.maxOperationTimeout = options.maxOperationTimeout ?? (() => {
379
+ if (process.env.MAX_OPERATION_TIMEOUT) {
380
+ const parsed = parseInt(process.env.MAX_OPERATION_TIMEOUT, 10);
381
+ // Validate: must be positive number between 1s and 2 hours
382
+ if (isNaN(parsed) || parsed < 1000 || parsed > 7200000) {
383
+ return 300000; // Default 5 minutes
384
+ }
385
+ return parsed;
386
+ }
387
+ return 300000;
388
+ })();
389
+ if (this.debug) {
390
+ console.log(`[DEBUG] Max operation timeout: ${this.maxOperationTimeout}ms`);
391
+ }
392
+
333
393
  // Retry configuration
334
394
  this.retryConfig = options.retry || {};
335
395
  this.retryManager = null; // Will be initialized lazily when needed
@@ -1105,120 +1165,128 @@ export class ProbeAgent {
1105
1165
  }
1106
1166
 
1107
1167
  /**
1108
- * Execute streamText with retry and fallback support
1109
- * @param {Object} options - streamText options
1110
- * @returns {Promise<Object>} - streamText result
1168
+ * Create a streamText-compatible result from an engine stream with timeout handling
1169
+ * @param {AsyncGenerator} engineStream - The engine's query result
1170
+ * @param {AbortSignal} abortSignal - Signal for aborting the operation
1171
+ * @param {number} requestTimeout - Per-request timeout in ms
1172
+ * @param {Object} timeoutState - Object with timeoutId property (mutable for cleanup)
1173
+ * @returns {Object} - streamText-compatible result with textStream
1111
1174
  * @private
1112
1175
  */
1113
- async streamTextWithRetryAndFallback(options) {
1114
- // Check if we should use Claude Code engine
1115
- if (this.clientApiProvider === 'claude-code' || process.env.USE_CLAUDE_CODE === 'true') {
1176
+ _createEngineTextStreamResult(engineStream, abortSignal, requestTimeout, timeoutState) {
1177
+ // Activity timeout for engine stream - validates env var against defined bounds
1178
+ const activityTimeout = (() => {
1179
+ const parsed = parseInt(process.env.ENGINE_ACTIVITY_TIMEOUT, 10);
1180
+ return isNaN(parsed) || parsed < ENGINE_ACTIVITY_TIMEOUT_MIN || parsed > ENGINE_ACTIVITY_TIMEOUT_MAX
1181
+ ? ENGINE_ACTIVITY_TIMEOUT_DEFAULT
1182
+ : parsed;
1183
+ })();
1184
+ const startTime = Date.now();
1185
+
1186
+ // Create a text stream that extracts text from engine messages with timeout
1187
+ // The generator clears the operation timeout when done to handle the case
1188
+ // where the stream is returned immediately but consumed later
1189
+ async function* createTextStream() {
1190
+ let lastActivity = Date.now();
1191
+
1116
1192
  try {
1117
- const engine = await this.getEngine();
1118
- if (engine && engine.query) {
1119
- // Convert Vercel AI SDK format to engine format
1120
- // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1121
- // Look for user messages that are NOT the warning message
1122
- const userMessages = options.messages.filter(m =>
1123
- m.role === 'user' &&
1124
- !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1125
- );
1126
- const lastUserMessage = userMessages[userMessages.length - 1];
1127
- const prompt = lastUserMessage ? lastUserMessage.content : '';
1128
-
1129
- // Pass system message and other options
1130
- const engineOptions = {
1131
- maxTokens: options.maxTokens,
1132
- temperature: options.temperature,
1133
- messages: options.messages,
1134
- systemPrompt: options.messages.find(m => m.role === 'system')?.content
1135
- };
1136
-
1137
- // Get the engine's query result (async generator)
1138
- const engineStream = engine.query(prompt, engineOptions);
1139
-
1140
- // Create a text stream that extracts text from engine messages
1141
- async function* createTextStream() {
1142
- for await (const message of engineStream) {
1143
- if (message.type === 'text' && message.content) {
1144
- yield message.content;
1145
- } else if (typeof message === 'string') {
1146
- // If engine returns plain strings, pass them through
1147
- yield message;
1148
- }
1149
- // Ignore other message types for the text stream
1150
- }
1193
+ for await (const message of engineStream) {
1194
+ // Check for abort signal
1195
+ if (abortSignal.aborted) {
1196
+ const abortError = new Error('Operation aborted');
1197
+ abortError.name = 'AbortError';
1198
+ throw abortError;
1151
1199
  }
1152
1200
 
1153
- // Wrap the engine result to match streamText interface
1154
- return {
1155
- textStream: createTextStream(),
1156
- usage: Promise.resolve({}), // Engine should handle its own usage tracking
1157
- // Add other streamText-compatible properties as needed
1158
- };
1159
- }
1160
- } catch (error) {
1161
- if (this.debug) {
1162
- console.log(`[DEBUG] Failed to use Claude Code engine, falling back to Vercel:`, error.message);
1163
- }
1164
- // Fall through to use Vercel engine as fallback
1165
- }
1166
- }
1201
+ const now = Date.now();
1167
1202
 
1168
- // Check if we should use Codex engine
1169
- if (this.clientApiProvider === 'codex' || process.env.USE_CODEX === 'true') {
1170
- try {
1171
- const engine = await this.getEngine();
1172
- if (engine && engine.query) {
1173
- // Convert Vercel AI SDK format to engine format
1174
- // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1175
- // Look for user messages that are NOT the warning message
1176
- const userMessages = options.messages.filter(m =>
1177
- m.role === 'user' &&
1178
- !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1179
- );
1180
- const lastUserMessage = userMessages[userMessages.length - 1];
1181
- const prompt = lastUserMessage ? lastUserMessage.content : '';
1182
-
1183
- // Pass system message and other options
1184
- const engineOptions = {
1185
- maxTokens: options.maxTokens,
1186
- temperature: options.temperature,
1187
- messages: options.messages,
1188
- systemPrompt: options.messages.find(m => m.role === 'system')?.content
1189
- };
1190
-
1191
- // Get the engine's query result (async generator)
1192
- const engineStream = engine.query(prompt, engineOptions);
1193
-
1194
- // Create a text stream that extracts text from engine messages
1195
- async function* createTextStream() {
1196
- for await (const message of engineStream) {
1197
- if (message.type === 'text' && message.content) {
1198
- yield message.content;
1199
- } else if (typeof message === 'string') {
1200
- // If engine returns plain strings, pass them through
1201
- yield message;
1202
- }
1203
- // Ignore other message types for the text stream
1204
- }
1203
+ // Check for activity timeout (no data received for too long)
1204
+ if (now - lastActivity > activityTimeout) {
1205
+ throw new Error(`Engine stream timeout - no activity for ${activityTimeout}ms`);
1205
1206
  }
1206
1207
 
1207
- // Wrap the engine result to match streamText interface
1208
- return {
1209
- textStream: createTextStream(),
1210
- usage: Promise.resolve({}), // Engine should handle its own usage tracking
1211
- // Add other streamText-compatible properties as needed
1212
- };
1208
+ // Check for overall request timeout
1209
+ if (requestTimeout > 0 && now - startTime > requestTimeout) {
1210
+ throw new Error(`Engine stream timeout - request exceeded ${requestTimeout}ms`);
1211
+ }
1212
+
1213
+ lastActivity = now;
1214
+
1215
+ if (message.type === 'text' && message.content) {
1216
+ yield message.content;
1217
+ } else if (typeof message === 'string') {
1218
+ // If engine returns plain strings, pass them through
1219
+ yield message;
1220
+ }
1221
+ // Ignore other message types for the text stream
1213
1222
  }
1214
- } catch (error) {
1215
- if (this.debug) {
1216
- console.log(`[DEBUG] Failed to use Codex engine, falling back to Vercel:`, error.message);
1223
+ } finally {
1224
+ // Clear operation timeout when stream completes (success or error)
1225
+ // This is done here because for engine paths, the stream is returned
1226
+ // immediately but consumed later by the caller
1227
+ if (timeoutState.timeoutId) {
1228
+ clearTimeout(timeoutState.timeoutId);
1229
+ timeoutState.timeoutId = null;
1217
1230
  }
1218
- // Fall through to use Vercel engine as fallback
1219
1231
  }
1220
1232
  }
1221
1233
 
1234
+ // Wrap the engine result to match streamText interface
1235
+ // Note: maxOperationTimeout cleanup is handled by the generator's finally block
1236
+ // since the stream is consumed after this function returns.
1237
+ return {
1238
+ textStream: createTextStream(),
1239
+ usage: Promise.resolve({}), // Engine should handle its own usage tracking
1240
+ // Add other streamText-compatible properties as needed
1241
+ };
1242
+ }
1243
+
1244
+ /**
1245
+ * Try to use an engine (claude-code or codex) for streaming
1246
+ * @param {Object} options - streamText options
1247
+ * @param {AbortController} controller - Abort controller for the operation
1248
+ * @param {Object} timeoutState - Mutable timeout state for cleanup
1249
+ * @returns {Promise<Object|null>} - Stream result or null if engine unavailable
1250
+ * @private
1251
+ */
1252
+ async _tryEngineStreamPath(options, controller, timeoutState) {
1253
+ const engine = await this.getEngine();
1254
+ if (!engine || !engine.query) {
1255
+ return null;
1256
+ }
1257
+
1258
+ // Extract the ORIGINAL user message as the main prompt (skip any warning messages)
1259
+ const userMessages = options.messages.filter(m =>
1260
+ m.role === 'user' &&
1261
+ !m.content.includes('WARNING: You have reached the maximum tool iterations limit')
1262
+ );
1263
+ const lastUserMessage = userMessages[userMessages.length - 1];
1264
+ const prompt = lastUserMessage ? lastUserMessage.content : '';
1265
+
1266
+ // Pass system message and other options including abort signal
1267
+ const engineOptions = {
1268
+ maxTokens: options.maxTokens,
1269
+ temperature: options.temperature,
1270
+ messages: options.messages,
1271
+ systemPrompt: options.messages.find(m => m.role === 'system')?.content,
1272
+ abortSignal: controller.signal
1273
+ };
1274
+
1275
+ // Get the engine's query result and wrap with timeout handling
1276
+ const engineStream = engine.query(prompt, engineOptions);
1277
+ return this._createEngineTextStreamResult(
1278
+ engineStream, controller.signal, this.requestTimeout, timeoutState
1279
+ );
1280
+ }
1281
+
1282
+ /**
1283
+ * Execute streamText with Vercel AI SDK using retry/fallback logic
1284
+ * @param {Object} options - streamText options
1285
+ * @param {AbortController} controller - Abort controller for the operation
1286
+ * @returns {Promise<Object>} - Stream result
1287
+ * @private
1288
+ */
1289
+ async _executeWithVercelProvider(options, controller) {
1222
1290
  // Initialize retry manager if not already created
1223
1291
  if (!this.retryManager) {
1224
1292
  this.retryManager = new RetryManager({
@@ -1234,10 +1302,11 @@ export class ProbeAgent {
1234
1302
  // If no fallback manager, just use retry with current provider
1235
1303
  if (!this.fallbackManager) {
1236
1304
  return await this.retryManager.executeWithRetry(
1237
- () => streamText(options),
1305
+ () => streamText({ ...options, abortSignal: controller.signal }),
1238
1306
  {
1239
1307
  provider: this.apiType,
1240
- model: this.model
1308
+ model: this.model,
1309
+ signal: controller.signal
1241
1310
  }
1242
1311
  );
1243
1312
  }
@@ -1245,13 +1314,12 @@ export class ProbeAgent {
1245
1314
  // Use fallback manager with retry for each provider
1246
1315
  return await this.fallbackManager.executeWithFallback(
1247
1316
  async (provider, model, config) => {
1248
- // Create options with the fallback provider
1249
1317
  const fallbackOptions = {
1250
1318
  ...options,
1251
- model: provider(model)
1319
+ model: provider(model),
1320
+ abortSignal: controller.signal
1252
1321
  };
1253
1322
 
1254
- // Create a retry manager for this specific provider
1255
1323
  const providerRetryManager = new RetryManager({
1256
1324
  maxRetries: config.maxRetries ?? this.retryConfig.maxRetries ?? 3,
1257
1325
  initialDelay: this.retryConfig.initialDelay ?? 1000,
@@ -1261,18 +1329,70 @@ export class ProbeAgent {
1261
1329
  debug: this.debug
1262
1330
  });
1263
1331
 
1264
- // Execute with retry for this provider
1265
1332
  return await providerRetryManager.executeWithRetry(
1266
1333
  () => streamText(fallbackOptions),
1267
1334
  {
1268
1335
  provider: config.provider,
1269
- model: model
1336
+ model: model,
1337
+ signal: controller.signal
1270
1338
  }
1271
1339
  );
1272
1340
  }
1273
1341
  );
1274
1342
  }
1275
1343
 
1344
+ /**
1345
+ * Execute streamText with retry and fallback support
1346
+ * @param {Object} options - streamText options
1347
+ * @returns {Promise<Object>} - streamText result
1348
+ * @private
1349
+ */
1350
+ async streamTextWithRetryAndFallback(options) {
1351
+ // Create AbortController for overall operation timeout
1352
+ const controller = new AbortController();
1353
+ const timeoutState = { timeoutId: null };
1354
+
1355
+ // Set up overall operation timeout (default 5 minutes)
1356
+ if (this.maxOperationTimeout && this.maxOperationTimeout > 0) {
1357
+ timeoutState.timeoutId = setTimeout(() => {
1358
+ controller.abort();
1359
+ if (this.debug) {
1360
+ console.log(`[DEBUG] Operation timed out after ${this.maxOperationTimeout}ms (max operation timeout)`);
1361
+ }
1362
+ }, this.maxOperationTimeout);
1363
+ }
1364
+
1365
+ try {
1366
+ // Try engine paths (claude-code or codex)
1367
+ const useClaudeCode = this.clientApiProvider === 'claude-code' || process.env.USE_CLAUDE_CODE === 'true';
1368
+ const useCodex = this.clientApiProvider === 'codex' || process.env.USE_CODEX === 'true';
1369
+
1370
+ if (useClaudeCode || useCodex) {
1371
+ try {
1372
+ const result = await this._tryEngineStreamPath(options, controller, timeoutState);
1373
+ if (result) {
1374
+ return result;
1375
+ }
1376
+ } catch (error) {
1377
+ if (this.debug) {
1378
+ const engineType = useClaudeCode ? 'Claude Code' : 'Codex';
1379
+ console.log(`[DEBUG] Failed to use ${engineType} engine, falling back to Vercel:`, error.message);
1380
+ }
1381
+ // Fall through to Vercel provider
1382
+ }
1383
+ }
1384
+
1385
+ // Use Vercel AI SDK with retry/fallback
1386
+ return await this._executeWithVercelProvider(options, controller);
1387
+ } finally {
1388
+ // Clean up timeout (for non-engine paths; engine paths clean up in the generator)
1389
+ if (timeoutState.timeoutId) {
1390
+ clearTimeout(timeoutState.timeoutId);
1391
+ timeoutState.timeoutId = null;
1392
+ }
1393
+ }
1394
+ }
1395
+
1276
1396
  /**
1277
1397
  * Initialize Anthropic model
1278
1398
  */
@@ -15,7 +15,7 @@ import { Session } from '../shared/Session.js';
15
15
  * Enhanced Claude Code Engine
16
16
  */
17
17
  export async function createEnhancedClaudeCLIEngine(options = {}) {
18
- const { agent, systemPrompt, customPrompt, debug, sessionId, allowedTools } = options;
18
+ const { agent, systemPrompt, customPrompt, debug, sessionId, allowedTools, timeout = 120000 } = options;
19
19
 
20
20
  // Create or reuse session
21
21
  const session = new Session(
@@ -154,6 +154,37 @@ export async function createEnhancedClaudeCLIEngine(options = {}) {
154
154
  stdio: ['ignore', 'pipe', 'pipe'] // Ignore stdin since echo handles it
155
155
  });
156
156
 
157
+ // Subprocess timeout handling
158
+ let killed = false;
159
+ let timeoutHandle;
160
+ let sigkillHandle;
161
+
162
+ if (timeout > 0) {
163
+ timeoutHandle = setTimeout(() => {
164
+ if (!killed) {
165
+ killed = true;
166
+ processEnded = true;
167
+ proc.kill('SIGTERM');
168
+
169
+ if (debug) {
170
+ console.log(`[DEBUG] Process timed out after ${timeout}ms, sending SIGTERM`);
171
+ }
172
+
173
+ // Force kill after 5 seconds if still running
174
+ sigkillHandle = setTimeout(() => {
175
+ if (proc.exitCode === null) {
176
+ proc.kill('SIGKILL');
177
+ if (debug) {
178
+ console.log('[DEBUG] Process did not exit, sending SIGKILL');
179
+ }
180
+ }
181
+ }, 5000);
182
+
183
+ emitter.emit('error', new Error(`Claude CLI process timed out after ${timeout}ms`));
184
+ }
185
+ }, timeout);
186
+ }
187
+
157
188
  // Handle stdout
158
189
  proc.stdout.on('data', (data) => {
159
190
  buffer += data.toString();
@@ -179,11 +210,25 @@ export async function createEnhancedClaudeCLIEngine(options = {}) {
179
210
 
180
211
  // Handle process end
181
212
  proc.on('close', (code) => {
213
+ // Clear the timeouts to prevent memory leaks
214
+ if (timeoutHandle) {
215
+ clearTimeout(timeoutHandle);
216
+ }
217
+ if (sigkillHandle) {
218
+ clearTimeout(sigkillHandle);
219
+ }
220
+
182
221
  processEnded = true;
183
222
  if (code !== 0 && debug) {
184
223
  console.log(`[DEBUG] Process exited with code ${code}`);
185
224
  }
186
225
 
226
+ // If killed by timeout, the error was already emitted
227
+ if (killed) {
228
+ emitter.emit('end');
229
+ return;
230
+ }
231
+
187
232
  // Process any remaining buffer
188
233
  if (buffer.trim()) {
189
234
  processJsonBuffer(buffer, emitter, session, debug, toolCollector);
@@ -205,6 +250,14 @@ export async function createEnhancedClaudeCLIEngine(options = {}) {
205
250
  });
206
251
 
207
252
  proc.on('error', (error) => {
253
+ // Clear the timeouts to prevent memory leaks
254
+ if (timeoutHandle) {
255
+ clearTimeout(timeoutHandle);
256
+ }
257
+ if (sigkillHandle) {
258
+ clearTimeout(sigkillHandle);
259
+ }
260
+ processEnded = true;
208
261
  emitter.emit('error', error);
209
262
  });
210
263
 
@@ -259,8 +312,24 @@ export async function createEnhancedClaudeCLIEngine(options = {}) {
259
312
  content: `\nšŸ”§ Using ${msg.name}: ${JSON.stringify(msg.input)}\n`
260
313
  };
261
314
 
262
- // Execute tool
263
- const result = await executeProbleTool(agent, msg.name, msg.input);
315
+ // Execute tool with timeout to prevent indefinite blocking
316
+ const toolTimeout = 30000; // 30 seconds
317
+ let toolTimeoutId;
318
+ const timeoutPromise = new Promise((_, reject) => {
319
+ toolTimeoutId = setTimeout(() => reject(new Error(`Tool ${msg.name} timed out after ${toolTimeout}ms`)), toolTimeout);
320
+ });
321
+ let result;
322
+ try {
323
+ result = await Promise.race([
324
+ executeProbleTool(agent, msg.name, msg.input),
325
+ timeoutPromise
326
+ ]);
327
+ } catch (error) {
328
+ result = `Tool error: ${error.message}`;
329
+ } finally {
330
+ // Always clear timeout to prevent memory leaks
331
+ clearTimeout(toolTimeoutId);
332
+ }
264
333
  yield { type: 'text', content: `${result}\n` };
265
334
  } else if (msg.type === 'toolBatch') {
266
335
  // Pass through the tool batch for ProbeAgent to emit