@probelabs/probe 0.6.0-rc159 → 0.6.0-rc162

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.
Files changed (43) hide show
  1. package/README.md +169 -1
  2. package/build/agent/FallbackManager.d.ts +176 -0
  3. package/build/agent/FallbackManager.js +545 -0
  4. package/build/agent/ProbeAgent.d.ts +7 -1
  5. package/build/agent/ProbeAgent.js +545 -89
  6. package/build/agent/RetryManager.d.ts +157 -0
  7. package/build/agent/RetryManager.js +334 -0
  8. package/build/agent/acp/tools.js +6 -2
  9. package/build/agent/contextCompactor.js +271 -0
  10. package/build/agent/index.js +2165 -250
  11. package/build/agent/probeTool.js +20 -2
  12. package/build/agent/schemaUtils.js +7 -0
  13. package/build/agent/tools.js +16 -0
  14. package/build/agent/xmlParsingUtils.js +24 -4
  15. package/build/index.js +13 -0
  16. package/build/tools/common.js +21 -4
  17. package/build/tools/edit.js +409 -0
  18. package/build/tools/index.js +11 -0
  19. package/cjs/agent/ProbeAgent.cjs +2620 -606
  20. package/cjs/index.cjs +2582 -563
  21. package/package.json +2 -2
  22. package/src/agent/FallbackManager.d.ts +176 -0
  23. package/src/agent/FallbackManager.js +545 -0
  24. package/src/agent/ProbeAgent.d.ts +7 -1
  25. package/src/agent/ProbeAgent.js +545 -89
  26. package/src/agent/RetryManager.d.ts +157 -0
  27. package/src/agent/RetryManager.js +334 -0
  28. package/src/agent/acp/tools.js +6 -2
  29. package/src/agent/contextCompactor.js +271 -0
  30. package/src/agent/index.js +30 -1
  31. package/src/agent/probeTool.js +20 -2
  32. package/src/agent/schemaUtils.js +7 -0
  33. package/src/agent/tools.js +16 -0
  34. package/src/agent/xmlParsingUtils.js +24 -4
  35. package/src/index.js +13 -0
  36. package/src/tools/common.js +21 -4
  37. package/src/tools/edit.js +409 -0
  38. package/src/tools/index.js +11 -0
  39. package/bin/binaries/probe-v0.6.0-rc159-aarch64-apple-darwin.tar.gz +0 -0
  40. package/bin/binaries/probe-v0.6.0-rc159-aarch64-unknown-linux-musl.tar.gz +0 -0
  41. package/bin/binaries/probe-v0.6.0-rc159-x86_64-apple-darwin.tar.gz +0 -0
  42. package/bin/binaries/probe-v0.6.0-rc159-x86_64-pc-windows-msvc.zip +0 -0
  43. package/bin/binaries/probe-v0.6.0-rc159-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -24,10 +24,13 @@ import {
24
24
  queryToolDefinition,
25
25
  extractToolDefinition,
26
26
  delegateToolDefinition,
27
+ bashToolDefinition,
27
28
  listFilesToolDefinition,
28
29
  searchFilesToolDefinition,
29
30
  attemptCompletionToolDefinition,
30
31
  implementToolDefinition,
32
+ editToolDefinition,
33
+ createToolDefinition,
31
34
  attemptCompletionSchema,
32
35
  parseXmlToolCallWithThinking
33
36
  } from './tools.js';
@@ -55,9 +58,19 @@ import {
55
58
  parseHybridXmlToolCall,
56
59
  loadMCPConfigurationFromPath
57
60
  } from './mcp/index.js';
61
+ import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
62
+ import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
63
+ import { handleContextLimitError } from './contextCompactor.js';
58
64
 
59
65
  // Maximum tool iterations to prevent infinite loops - configurable via MAX_TOOL_ITERATIONS env var
60
- const MAX_TOOL_ITERATIONS = parseInt(process.env.MAX_TOOL_ITERATIONS || '30', 10);
66
+ const MAX_TOOL_ITERATIONS = (() => {
67
+ const val = parseInt(process.env.MAX_TOOL_ITERATIONS || '30', 10);
68
+ if (isNaN(val) || val < 1 || val > 200) {
69
+ console.warn('[ProbeAgent] MAX_TOOL_ITERATIONS must be between 1 and 200, using default: 30');
70
+ return 30;
71
+ }
72
+ return val;
73
+ })();
61
74
  const MAX_HISTORY_MESSAGES = 100;
62
75
 
63
76
  // Supported image file extensions (imported from shared config)
@@ -92,6 +105,20 @@ export class ProbeAgent {
92
105
  * @param {Array} [options.mcpServers] - Deprecated, use mcpConfig instead
93
106
  * @param {Object} [options.storageAdapter] - Custom storage adapter for history management
94
107
  * @param {Object} [options.hooks] - Hook callbacks for events (e.g., {'tool:start': callback})
108
+ * @param {Array<string>|null} [options.allowedTools] - List of allowed tool names. Use ['*'] for all tools (default), [] or null for no tools (raw AI mode), or specific tool names like ['search', 'query', 'extract']. Supports exclusion with '!' prefix (e.g., ['*', '!bash'])
109
+ * @param {boolean} [options.disableTools=false] - Convenience flag to disable all tools (equivalent to allowedTools: []). Takes precedence over allowedTools if set.
110
+ * @param {Object} [options.retry] - Retry configuration
111
+ * @param {number} [options.retry.maxRetries=3] - Maximum retry attempts per provider
112
+ * @param {number} [options.retry.initialDelay=1000] - Initial delay in ms
113
+ * @param {number} [options.retry.maxDelay=30000] - Maximum delay in ms
114
+ * @param {number} [options.retry.backoffFactor=2] - Exponential backoff multiplier
115
+ * @param {Array<string>} [options.retry.retryableErrors] - List of retryable error patterns
116
+ * @param {Object} [options.fallback] - Fallback configuration
117
+ * @param {string} [options.fallback.strategy] - Fallback strategy: 'same-model', 'same-provider', 'any', 'custom'
118
+ * @param {Array<string>} [options.fallback.models] - List of models for same-provider fallback
119
+ * @param {Array<Object>} [options.fallback.providers] - List of provider configurations for custom fallback
120
+ * @param {boolean} [options.fallback.stopOnSuccess=true] - Stop on first success
121
+ * @param {number} [options.fallback.maxTotalAttempts=10] - Maximum total attempts across all providers
95
122
  */
96
123
  constructor(options = {}) {
97
124
  // Basic configuration
@@ -104,11 +131,24 @@ export class ProbeAgent {
104
131
  this.cancelled = false;
105
132
  this.tracer = options.tracer || null;
106
133
  this.outline = !!options.outline;
107
- this.maxResponseTokens = options.maxResponseTokens || parseInt(process.env.MAX_RESPONSE_TOKENS || '0', 10) || null;
134
+ this.maxResponseTokens = options.maxResponseTokens || (() => {
135
+ const val = parseInt(process.env.MAX_RESPONSE_TOKENS || '0', 10);
136
+ if (isNaN(val) || val < 0 || val > 200000) {
137
+ return null;
138
+ }
139
+ return val || null;
140
+ })();
108
141
  this.maxIterations = options.maxIterations || null;
109
142
  this.disableMermaidValidation = !!options.disableMermaidValidation;
110
143
  this.disableJsonValidation = !!options.disableJsonValidation;
111
144
 
145
+ // Tool filtering configuration
146
+ // Parse allowedTools option: ['*'] = all tools, [] or null = no tools, ['tool1', 'tool2'] = specific tools
147
+ // Supports exclusion with '!' prefix: ['*', '!bash'] = all tools except bash
148
+ // disableTools is a convenience flag that overrides allowedTools to []
149
+ const effectiveAllowedTools = options.disableTools ? [] : options.allowedTools;
150
+ this.allowedTools = this._parseAllowedTools(effectiveAllowedTools);
151
+
112
152
  // Storage adapter (defaults to in-memory)
113
153
  this.storageAdapter = options.storageAdapter || new InMemoryStorageAdapter();
114
154
 
@@ -171,6 +211,14 @@ export class ProbeAgent {
171
211
  this.mcpBridge = null;
172
212
  this._mcpInitialized = false; // Track if MCP initialization has been attempted
173
213
 
214
+ // Retry configuration
215
+ this.retryConfig = options.retry || {};
216
+ this.retryManager = null; // Will be initialized lazily when needed
217
+
218
+ // Fallback configuration
219
+ this.fallbackConfig = options.fallback || null;
220
+ this.fallbackManager = null; // Will be initialized in initializeModel
221
+
174
222
  // Initialize the AI model
175
223
  this.initializeModel();
176
224
 
@@ -178,6 +226,74 @@ export class ProbeAgent {
178
226
  // Constructor must remain synchronous for backward compatibility
179
227
  }
180
228
 
229
+ /**
230
+ * Parse allowedTools configuration
231
+ * @param {Array<string>|null|undefined} allowedTools - Tool filtering configuration
232
+ * @returns {Object} Parsed configuration with isEnabled method
233
+ * @private
234
+ */
235
+ _parseAllowedTools(allowedTools) {
236
+ // Helper to check if tool matches a pattern (supports * wildcard)
237
+ const matchesPattern = (toolName, pattern) => {
238
+ if (!pattern.includes('*')) {
239
+ return toolName === pattern;
240
+ }
241
+ const regexPattern = pattern.replace(/\*/g, '.*');
242
+ return new RegExp(`^${regexPattern}$`).test(toolName);
243
+ };
244
+
245
+ // Default: all tools allowed
246
+ if (!allowedTools || (Array.isArray(allowedTools) && allowedTools.includes('*'))) {
247
+ const exclusions = Array.isArray(allowedTools)
248
+ ? allowedTools.filter(t => t.startsWith('!')).map(t => t.slice(1))
249
+ : [];
250
+
251
+ return {
252
+ mode: 'all',
253
+ exclusions,
254
+ isEnabled: (toolName) => !exclusions.some(pattern => matchesPattern(toolName, pattern))
255
+ };
256
+ }
257
+
258
+ // Empty array or null: no tools (raw AI mode)
259
+ if (Array.isArray(allowedTools) && allowedTools.length === 0) {
260
+ return {
261
+ mode: 'none',
262
+ isEnabled: () => false
263
+ };
264
+ }
265
+
266
+ // Specific tools allowed (with wildcard support)
267
+ const allowedPatterns = allowedTools.filter(t => !t.startsWith('!'));
268
+ return {
269
+ mode: 'whitelist',
270
+ allowed: allowedPatterns,
271
+ isEnabled: (toolName) => allowedPatterns.some(pattern => matchesPattern(toolName, pattern))
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Check if an MCP tool is allowed based on allowedTools configuration
277
+ * Uses mcp__ prefix convention (like Claude Code)
278
+ * @param {string} toolName - The MCP tool name (without mcp__ prefix)
279
+ * @returns {boolean} - Whether the tool is allowed
280
+ * @private
281
+ */
282
+ _isMcpToolAllowed(toolName) {
283
+ const mcpToolName = `mcp__${toolName}`;
284
+ return this.allowedTools.isEnabled(mcpToolName) || this.allowedTools.isEnabled(toolName);
285
+ }
286
+
287
+ /**
288
+ * Filter MCP tools based on allowedTools configuration
289
+ * @param {string[]} mcpToolNames - Array of MCP tool names
290
+ * @returns {string[]} - Filtered array of allowed MCP tool names
291
+ * @private
292
+ */
293
+ _filterMcpTools(mcpToolNames) {
294
+ return mcpToolNames.filter(toolName => this._isMcpToolAllowed(toolName));
295
+ }
296
+
181
297
  /**
182
298
  * Initialize the agent asynchronously (must be called after constructor)
183
299
  * This method initializes MCP and merges MCP tools into the tool list, and loads history from storage
@@ -210,10 +326,15 @@ export class ProbeAgent {
210
326
  await this.initializeMCP();
211
327
 
212
328
  // Merge MCP tools into toolImplementations for unified access
329
+ // Apply allowedTools filtering using mcp__ prefix (like Claude Code)
213
330
  if (this.mcpBridge) {
214
331
  const mcpTools = this.mcpBridge.mcpTools || {};
215
332
  for (const [toolName, toolImpl] of Object.entries(mcpTools)) {
216
- this.toolImplementations[toolName] = toolImpl;
333
+ if (this._isMcpToolAllowed(toolName)) {
334
+ this.toolImplementations[toolName] = toolImpl;
335
+ } else if (this.debug) {
336
+ console.error(`[DEBUG] MCP tool '${toolName}' filtered out by allowedTools`);
337
+ }
217
338
  }
218
339
  }
219
340
 
@@ -284,6 +405,16 @@ export class ProbeAgent {
284
405
  this.toolImplementations.bash = wrappedTools.bashToolInstance;
285
406
  }
286
407
 
408
+ // Add edit and create tools if enabled
409
+ if (this.allowEdit) {
410
+ if (wrappedTools.editToolInstance) {
411
+ this.toolImplementations.edit = wrappedTools.editToolInstance;
412
+ }
413
+ if (wrappedTools.createToolInstance) {
414
+ this.toolImplementations.create = wrappedTools.createToolInstance;
415
+ }
416
+ }
417
+
287
418
  // Store wrapped tools for ACP system
288
419
  this.wrappedTools = wrappedTools;
289
420
 
@@ -352,15 +483,19 @@ export class ProbeAgent {
352
483
  if (forceProvider) {
353
484
  if (forceProvider === 'anthropic' && anthropicApiKey) {
354
485
  this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
486
+ this.initializeFallbackManager(forceProvider, modelName);
355
487
  return;
356
488
  } else if (forceProvider === 'openai' && openaiApiKey) {
357
489
  this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
490
+ this.initializeFallbackManager(forceProvider, modelName);
358
491
  return;
359
492
  } else if (forceProvider === 'google' && googleApiKey) {
360
493
  this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
494
+ this.initializeFallbackManager(forceProvider, modelName);
361
495
  return;
362
496
  } else if (forceProvider === 'bedrock' && ((awsAccessKeyId && awsSecretAccessKey && awsRegion) || awsApiKey)) {
363
497
  this.initializeBedrockModel(awsAccessKeyId, awsSecretAccessKey, awsRegion, awsSessionToken, awsApiKey, awsBedrockBaseUrl, modelName);
498
+ this.initializeFallbackManager(forceProvider, modelName);
364
499
  return;
365
500
  }
366
501
  console.warn(`WARNING: Forced provider "${forceProvider}" selected but required API key is missing or invalid! Falling back to auto-detection.`);
@@ -369,17 +504,146 @@ export class ProbeAgent {
369
504
  // If no provider is forced or forced provider failed, use the first available API key
370
505
  if (anthropicApiKey) {
371
506
  this.initializeAnthropicModel(anthropicApiKey, anthropicApiUrl, modelName);
507
+ this.initializeFallbackManager('anthropic', modelName);
372
508
  } else if (openaiApiKey) {
373
509
  this.initializeOpenAIModel(openaiApiKey, openaiApiUrl, modelName);
510
+ this.initializeFallbackManager('openai', modelName);
374
511
  } else if (googleApiKey) {
375
512
  this.initializeGoogleModel(googleApiKey, googleApiUrl, modelName);
513
+ this.initializeFallbackManager('google', modelName);
376
514
  } else if ((awsAccessKeyId && awsSecretAccessKey && awsRegion) || awsApiKey) {
377
515
  this.initializeBedrockModel(awsAccessKeyId, awsSecretAccessKey, awsRegion, awsSessionToken, awsApiKey, awsBedrockBaseUrl, modelName);
516
+ this.initializeFallbackManager('bedrock', modelName);
378
517
  } else {
379
518
  throw new Error('No API key provided. Please set ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN), OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY (or GOOGLE_API_KEY), AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION), or AWS_BEDROCK_API_KEY environment variables.');
380
519
  }
381
520
  }
382
521
 
522
+ /**
523
+ * Initialize fallback manager based on configuration
524
+ * @param {string} primaryProvider - The primary provider being used
525
+ * @param {string} primaryModel - The primary model being used
526
+ * @private
527
+ */
528
+ initializeFallbackManager(primaryProvider, primaryModel) {
529
+ // Skip fallback initialization if explicitly disabled or in test mode
530
+ if (this.fallbackConfig === false || process.env.DISABLE_FALLBACK === '1') {
531
+ return;
532
+ }
533
+
534
+ // If fallback config is provided explicitly, use it
535
+ if (this.fallbackConfig && this.fallbackConfig.providers) {
536
+ try {
537
+ this.fallbackManager = new FallbackManager({
538
+ ...this.fallbackConfig,
539
+ debug: this.debug
540
+ });
541
+
542
+ if (this.debug) {
543
+ console.log(`[DEBUG] Fallback manager initialized with ${this.fallbackManager.providers.length} providers`);
544
+ }
545
+ } catch (error) {
546
+ console.error('[WARNING] Failed to initialize fallback manager:', error.message);
547
+ }
548
+ return;
549
+ }
550
+
551
+ // Try to load from environment variables
552
+ const envFallbackManager = createFallbackManagerFromEnv(this.debug);
553
+ if (envFallbackManager) {
554
+ this.fallbackManager = envFallbackManager;
555
+ if (this.debug) {
556
+ console.log(`[DEBUG] Fallback manager initialized from environment variables`);
557
+ }
558
+ return;
559
+ }
560
+
561
+ // Auto-build fallback from available providers if enabled
562
+ if (process.env.AUTO_FALLBACK === '1' || this.fallbackConfig?.auto) {
563
+ const providers = buildFallbackProvidersFromEnv({
564
+ primaryProvider,
565
+ primaryModel
566
+ });
567
+
568
+ if (providers.length > 1) {
569
+ try {
570
+ this.fallbackManager = new FallbackManager({
571
+ strategy: 'custom',
572
+ providers,
573
+ debug: this.debug
574
+ });
575
+
576
+ if (this.debug) {
577
+ console.log(`[DEBUG] Auto-fallback enabled with ${providers.length} providers`);
578
+ }
579
+ } catch (error) {
580
+ console.error('[WARNING] Failed to initialize auto-fallback:', error.message);
581
+ }
582
+ }
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Execute streamText with retry and fallback support
588
+ * @param {Object} options - streamText options
589
+ * @returns {Promise<Object>} - streamText result
590
+ * @private
591
+ */
592
+ async streamTextWithRetryAndFallback(options) {
593
+ // Initialize retry manager if not already created
594
+ if (!this.retryManager) {
595
+ this.retryManager = new RetryManager({
596
+ maxRetries: this.retryConfig.maxRetries ?? 3,
597
+ initialDelay: this.retryConfig.initialDelay ?? 1000,
598
+ maxDelay: this.retryConfig.maxDelay ?? 30000,
599
+ backoffFactor: this.retryConfig.backoffFactor ?? 2,
600
+ retryableErrors: this.retryConfig.retryableErrors,
601
+ debug: this.debug
602
+ });
603
+ }
604
+
605
+ // If no fallback manager, just use retry with current provider
606
+ if (!this.fallbackManager) {
607
+ return await this.retryManager.executeWithRetry(
608
+ () => streamText(options),
609
+ {
610
+ provider: this.apiType,
611
+ model: this.model
612
+ }
613
+ );
614
+ }
615
+
616
+ // Use fallback manager with retry for each provider
617
+ return await this.fallbackManager.executeWithFallback(
618
+ async (provider, model, config) => {
619
+ // Create options with the fallback provider
620
+ const fallbackOptions = {
621
+ ...options,
622
+ model: provider(model)
623
+ };
624
+
625
+ // Create a retry manager for this specific provider
626
+ const providerRetryManager = new RetryManager({
627
+ maxRetries: config.maxRetries ?? this.retryConfig.maxRetries ?? 3,
628
+ initialDelay: this.retryConfig.initialDelay ?? 1000,
629
+ maxDelay: this.retryConfig.maxDelay ?? 30000,
630
+ backoffFactor: this.retryConfig.backoffFactor ?? 2,
631
+ retryableErrors: this.retryConfig.retryableErrors,
632
+ debug: this.debug
633
+ });
634
+
635
+ // Execute with retry for this provider
636
+ return await providerRetryManager.executeWithRetry(
637
+ () => streamText(fallbackOptions),
638
+ {
639
+ provider: config.provider,
640
+ model: model
641
+ }
642
+ );
643
+ }
644
+ );
645
+ }
646
+
383
647
  /**
384
648
  * Initialize Anthropic model
385
649
  */
@@ -866,10 +1130,15 @@ export class ProbeAgent {
866
1130
  await this.initializeMCP();
867
1131
 
868
1132
  // Merge MCP tools into toolImplementations for unified access
1133
+ // Apply allowedTools filtering using mcp__ prefix (like Claude Code)
869
1134
  if (this.mcpBridge) {
870
1135
  const mcpTools = this.mcpBridge.mcpTools || {};
871
1136
  for (const [toolName, toolImpl] of Object.entries(mcpTools)) {
872
- this.toolImplementations[toolName] = toolImpl;
1137
+ if (this._isMcpToolAllowed(toolName)) {
1138
+ this.toolImplementations[toolName] = toolImpl;
1139
+ } else if (this.debug) {
1140
+ console.error(`[DEBUG] MCP tool '${toolName}' filtered out by allowedTools`);
1141
+ }
873
1142
  }
874
1143
  }
875
1144
  } catch (error) {
@@ -880,19 +1149,53 @@ export class ProbeAgent {
880
1149
  }
881
1150
  }
882
1151
 
883
- // Build tool definitions
884
- let toolDefinitions = `
885
- ${searchToolDefinition}
886
- ${queryToolDefinition}
887
- ${extractToolDefinition}
888
- ${listFilesToolDefinition}
889
- ${searchFilesToolDefinition}
890
- ${attemptCompletionToolDefinition}
891
- `;
892
- if (this.allowEdit) {
1152
+ // Build tool definitions based on allowedTools configuration
1153
+ let toolDefinitions = '';
1154
+
1155
+ // Helper to check if a tool is allowed
1156
+ const isToolAllowed = (toolName) => this.allowedTools.isEnabled(toolName);
1157
+
1158
+ // Core tools (filtered by allowedTools)
1159
+ if (isToolAllowed('search')) {
1160
+ toolDefinitions += `${searchToolDefinition}\n`;
1161
+ }
1162
+ if (isToolAllowed('query')) {
1163
+ toolDefinitions += `${queryToolDefinition}\n`;
1164
+ }
1165
+ if (isToolAllowed('extract')) {
1166
+ toolDefinitions += `${extractToolDefinition}\n`;
1167
+ }
1168
+ if (isToolAllowed('listFiles')) {
1169
+ toolDefinitions += `${listFilesToolDefinition}\n`;
1170
+ }
1171
+ if (isToolAllowed('searchFiles')) {
1172
+ toolDefinitions += `${searchFilesToolDefinition}\n`;
1173
+ }
1174
+
1175
+ // Edit tools (require both allowEdit flag AND allowedTools permission)
1176
+ if (this.allowEdit && isToolAllowed('implement')) {
893
1177
  toolDefinitions += `${implementToolDefinition}\n`;
894
1178
  }
895
- if (this.enableDelegate) {
1179
+ if (this.allowEdit && isToolAllowed('edit')) {
1180
+ toolDefinitions += `${editToolDefinition}\n`;
1181
+ }
1182
+ if (this.allowEdit && isToolAllowed('create')) {
1183
+ toolDefinitions += `${createToolDefinition}\n`;
1184
+ }
1185
+
1186
+ // Bash tool (require both enableBash flag AND allowedTools permission)
1187
+ if (this.enableBash && isToolAllowed('bash')) {
1188
+ toolDefinitions += `${bashToolDefinition}\n`;
1189
+ }
1190
+
1191
+ // Always include attempt_completion (unless explicitly disabled in raw AI mode)
1192
+ if (isToolAllowed('attempt_completion')) {
1193
+ toolDefinitions += `${attemptCompletionToolDefinition}\n`;
1194
+ }
1195
+
1196
+ // Delegate tool (require both enableDelegate flag AND allowedTools permission)
1197
+ // Place after attempt_completion as it's an optional tool
1198
+ if (this.enableDelegate && isToolAllowed('delegate')) {
896
1199
  toolDefinitions += `${delegateToolDefinition}\n`;
897
1200
  }
898
1201
 
@@ -958,7 +1261,7 @@ Available Tools:
958
1261
  - extract: Extract specific code blocks or lines from files.
959
1262
  - listFiles: List files and directories in a specified location.
960
1263
  - searchFiles: Find files matching a glob pattern with recursive search capability.
961
- ${this.allowEdit ? '- implement: Implement a feature or fix a bug using aider.\n' : ''}${this.enableDelegate ? '- delegate: Delegate big distinct tasks to specialized probe subagents.\n' : ''}
1264
+ ${this.allowEdit ? '- implement: Implement a feature or fix a bug using aider.\n- edit: Edit files using exact string replacement.\n- create: Create new files with specified content.\n' : ''}${this.enableDelegate ? '- delegate: Delegate big distinct tasks to specialized probe subagents.\n' : ''}${this.enableBash ? '- bash: Execute bash commands for system operations.\n' : ''}
962
1265
  - attempt_completion: Finalize the task and provide the result to the user.
963
1266
  - attempt_complete: Quick completion using previous response (shorthand).
964
1267
  `;
@@ -974,7 +1277,10 @@ Follow these instructions carefully:
974
1277
  6. You MUST respond with exactly ONE tool call per message, using the specified XML format, until the task is complete.
975
1278
  7. Wait for the tool execution result (provided in the next user message in a <tool_result> block) before proceeding to the next step.
976
1279
  8. Once the task is fully completed, use the '<attempt_completion>' tool to provide the final result. This is the ONLY way to signal completion.
977
- 9. Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results.
1280
+ 9. Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results.${this.allowEdit ? `
1281
+ 10. When modifying files, choose the appropriate tool:
1282
+ - Use 'edit' for precise changes to existing files (requires exact string match)
1283
+ - Use 'create' for new files or complete file rewrites` : ''}
978
1284
  </instructions>
979
1285
  `;
980
1286
 
@@ -1068,11 +1374,17 @@ When troubleshooting:
1068
1374
  // Add Tool Definitions
1069
1375
  systemMessage += `\n# Tools Available\n${toolDefinitions}\n`;
1070
1376
 
1071
- // Add MCP tools if available
1377
+ // Add MCP tools if available (filtered by allowedTools)
1072
1378
  if (this.mcpBridge && this.mcpBridge.getToolNames().length > 0) {
1073
- systemMessage += `\n## MCP Tools (JSON parameters in <params> tag)\n`;
1074
- systemMessage += this.mcpBridge.getXmlToolDefinitions();
1075
- systemMessage += `\n\nFor MCP tools, use JSON format within the params tag, e.g.:\n<mcp_tool>\n<params>\n{"key": "value"}\n</params>\n</mcp_tool>\n`;
1379
+ const allMcpTools = this.mcpBridge.getToolNames();
1380
+ const allowedMcpTools = this._filterMcpTools(allMcpTools);
1381
+
1382
+ if (allowedMcpTools.length > 0) {
1383
+ systemMessage += `\n## MCP Tools (JSON parameters in <params> tag)\n`;
1384
+ // Get only allowed MCP tool definitions
1385
+ systemMessage += this.mcpBridge.getXmlToolDefinitions(allowedMcpTools);
1386
+ systemMessage += `\n\nFor MCP tools, use JSON format within the params tag, e.g.:\n<mcp_tool>\n<params>\n{"key": "value"}\n</params>\n</mcp_tool>\n`;
1387
+ }
1076
1388
  }
1077
1389
 
1078
1390
  // Add folder information
@@ -1258,57 +1570,120 @@ When troubleshooting:
1258
1570
 
1259
1571
  // Make AI request
1260
1572
  let assistantResponseContent = '';
1261
- try {
1262
- // Wrap AI request with tracing if available
1263
- const executeAIRequest = async () => {
1264
- // Prepare messages with potential image content
1265
- const messagesForAI = this.prepareMessagesWithImages(currentMessages);
1266
-
1267
- const result = await streamText({
1268
- model: this.provider(this.model),
1269
- messages: messagesForAI,
1270
- maxTokens: maxResponseTokens,
1271
- temperature: 0.3,
1272
- });
1573
+ let compactionAttempted = false;
1574
+
1575
+ // Retry loop for context compaction - separate from streamTextWithRetryAndFallback
1576
+ // which handles transient errors (rate limits, network issues, etc.)
1577
+ while (true) {
1578
+ try {
1579
+ // Wrap AI request with tracing if available
1580
+ const executeAIRequest = async () => {
1581
+ // Prepare messages with potential image content
1582
+ const messagesForAI = this.prepareMessagesWithImages(currentMessages);
1583
+
1584
+ const result = await this.streamTextWithRetryAndFallback({
1585
+ model: this.provider(this.model),
1586
+ messages: messagesForAI,
1587
+ maxTokens: maxResponseTokens,
1588
+ temperature: 0.3,
1589
+ });
1273
1590
 
1274
- // Get the promise reference BEFORE consuming stream (doesn't lock it)
1275
- const usagePromise = result.usage;
1591
+ // Get the promise reference BEFORE consuming stream (doesn't lock it)
1592
+ const usagePromise = result.usage;
1593
+
1594
+ // Collect the streamed response - stream all content for now
1595
+ for await (const delta of result.textStream) {
1596
+ assistantResponseContent += delta;
1597
+ // For now, stream everything - we'll handle segmentation after tools execute
1598
+ if (options.onStream) {
1599
+ options.onStream(delta);
1600
+ }
1601
+ }
1276
1602
 
1277
- // Collect the streamed response - stream all content for now
1278
- for await (const delta of result.textStream) {
1279
- assistantResponseContent += delta;
1280
- // For now, stream everything - we'll handle segmentation after tools execute
1281
- if (options.onStream) {
1282
- options.onStream(delta);
1603
+ // Record token usage - await the promise AFTER stream is consumed
1604
+ const usage = await usagePromise;
1605
+ if (usage) {
1606
+ this.tokenCounter.recordUsage(usage, result.experimental_providerMetadata);
1283
1607
  }
1608
+
1609
+ return result;
1610
+ };
1611
+
1612
+ if (this.tracer) {
1613
+ await this.tracer.withSpan('ai.request', executeAIRequest, {
1614
+ 'ai.model': this.model,
1615
+ 'ai.provider': this.clientApiProvider || 'auto',
1616
+ 'iteration': currentIteration,
1617
+ 'max_tokens': maxResponseTokens,
1618
+ 'temperature': 0.3,
1619
+ 'message_count': currentMessages.length
1620
+ });
1621
+ } else {
1622
+ await executeAIRequest();
1284
1623
  }
1285
1624
 
1286
- // Record token usage - await the promise AFTER stream is consumed
1287
- const usage = await usagePromise;
1288
- if (usage) {
1289
- this.tokenCounter.recordUsage(usage, result.experimental_providerMetadata);
1625
+ // Success - break out of compaction retry loop
1626
+ break;
1627
+
1628
+ } catch (error) {
1629
+ // Check if this is a context limit error (only try compaction once per iteration)
1630
+ if (!compactionAttempted && handleContextLimitError) {
1631
+ const compactionResult = handleContextLimitError(error, currentMessages, {
1632
+ keepLastSegment: true,
1633
+ minSegmentsToKeep: 1
1634
+ });
1635
+
1636
+ if (compactionResult) {
1637
+ // Context limit error detected - compact and retry once
1638
+ const { messages: compactedMessages, stats } = compactionResult;
1639
+
1640
+ // Check if compaction actually reduced message count
1641
+ if (stats.removed === 0) {
1642
+ // No messages removed - compaction won't help, fail immediately
1643
+ console.error(`[ERROR] Context window exceeded but no messages can be compacted.`);
1644
+ console.error(`[ERROR] The conversation history is already minimal (${stats.originalCount} messages).`);
1645
+ finalResult = `Error: Context window limit exceeded and conversation cannot be compacted further. Consider starting a new session or reducing system message size.`;
1646
+ throw new Error(finalResult);
1647
+ }
1648
+
1649
+ compactionAttempted = true;
1650
+
1651
+ console.log(`[INFO] Context window limit exceeded. Compacting conversation...`);
1652
+ console.log(`[INFO] Removed ${stats.removed} messages (${stats.reductionPercent}% reduction)`);
1653
+ console.log(`[INFO] Estimated token savings: ${stats.tokensSaved} tokens`);
1654
+
1655
+ if (this.debug) {
1656
+ console.log(`[DEBUG] Compaction stats:`, stats);
1657
+ console.log(`[DEBUG] Original message count: ${stats.originalCount}`);
1658
+ console.log(`[DEBUG] Compacted message count: ${stats.compactedCount}`);
1659
+ }
1660
+
1661
+ // Replace currentMessages with compacted version (creates new array reference)
1662
+ // This ensures we don't mutate the original history array
1663
+ currentMessages = [...compactedMessages];
1664
+
1665
+ // Log compaction event if tracer is available
1666
+ if (this.tracer) {
1667
+ this.tracer.addEvent('context.compacted', {
1668
+ 'iteration': currentIteration,
1669
+ 'original_count': stats.originalCount,
1670
+ 'compacted_count': stats.compactedCount,
1671
+ 'reduction_percent': stats.reductionPercent,
1672
+ 'tokens_saved': stats.tokensSaved
1673
+ });
1674
+ }
1675
+
1676
+ // Continue to retry with compacted messages
1677
+ continue;
1678
+ }
1290
1679
  }
1291
1680
 
1292
- return result;
1293
- };
1294
-
1295
- if (this.tracer) {
1296
- await this.tracer.withSpan('ai.request', executeAIRequest, {
1297
- 'ai.model': this.model,
1298
- 'ai.provider': this.clientApiProvider || 'auto',
1299
- 'iteration': currentIteration,
1300
- 'max_tokens': maxResponseTokens,
1301
- 'temperature': 0.3,
1302
- 'message_count': currentMessages.length
1303
- });
1304
- } else {
1305
- await executeAIRequest();
1681
+ // Not a context limit error, compaction already attempted, or compaction not available
1682
+ // IMPORTANT: This break prevents infinite loop if compacted messages still exceed limit
1683
+ console.error(`Error during streamText (Iter ${currentIteration}):`, error);
1684
+ finalResult = `Error: Failed to get response from AI model during iteration ${currentIteration}. ${error.message}`;
1685
+ throw new Error(finalResult);
1306
1686
  }
1307
-
1308
- } catch (error) {
1309
- console.error(`Error during streamText (Iter ${currentIteration}):`, error);
1310
- finalResult = `Error: Failed to get response from AI model during iteration ${currentIteration}. ${error.message}`;
1311
- throw new Error(finalResult);
1312
1687
  }
1313
1688
 
1314
1689
  // Log preview of assistant response for debugging loops
@@ -1327,7 +1702,10 @@ When troubleshooting:
1327
1702
  'search', 'query', 'extract', 'listFiles', 'searchFiles', 'attempt_completion'
1328
1703
  ];
1329
1704
  if (this.allowEdit) {
1330
- validTools.push('implement');
1705
+ validTools.push('implement', 'edit', 'create');
1706
+ }
1707
+ if (this.enableBash) {
1708
+ validTools.push('bash');
1331
1709
  }
1332
1710
  if (this.enableDelegate) {
1333
1711
  validTools.push('delegate');
@@ -1745,15 +2123,13 @@ NOT: {"type": "object", "properties": {"name": {"type": "string"}}}
1745
2123
  Convert your previous response content into actual JSON data that follows this schema structure.`;
1746
2124
 
1747
2125
  // Call answer recursively with _schemaFormatted flag to prevent infinite loop
1748
- finalResult = await this.answer(schemaPrompt, [], {
1749
- ...options,
1750
- _schemaFormatted: true
2126
+ finalResult = await this.answer(schemaPrompt, [], {
2127
+ ...options,
2128
+ _schemaFormatted: true
1751
2129
  });
1752
-
1753
- // Step 2: Clean the response (remove code blocks)
1754
- finalResult = cleanSchemaResponse(finalResult);
1755
-
1756
- // Step 3: Validate and fix Mermaid diagrams if present
2130
+
2131
+ // Step 2: Validate and fix Mermaid diagrams if present (BEFORE cleaning schema)
2132
+ // This ensures mermaid validation sees the full response before JSON extraction strips content
1757
2133
  if (!this.disableMermaidValidation) {
1758
2134
  try {
1759
2135
  if (this.debug) {
@@ -1815,19 +2191,16 @@ Convert your previous response content into actual JSON data that follows this s
1815
2191
  } else if (this.debug) {
1816
2192
  console.log(`[DEBUG] Mermaid validation: Skipped due to disableMermaidValidation option`);
1817
2193
  }
1818
-
2194
+
2195
+ // Step 3: Clean the response (remove code blocks, extract JSON)
2196
+ // This happens AFTER mermaid validation to preserve full content for validation
2197
+ finalResult = cleanSchemaResponse(finalResult);
2198
+
1819
2199
  // Step 4: Validate and potentially correct JSON responses
1820
2200
  if (isJsonSchema(options.schema)) {
1821
2201
  if (this.debug) {
1822
2202
  console.log(`[DEBUG] JSON validation: Starting validation process for schema response`);
1823
- console.log(`[DEBUG] JSON validation: Response length: ${finalResult.length} chars`);
1824
- }
1825
-
1826
- // Clean the response first to extract JSON from markdown/code blocks
1827
- finalResult = cleanSchemaResponse(finalResult);
1828
-
1829
- if (this.debug) {
1830
- console.log(`[DEBUG] JSON validation: After cleaning, length: ${finalResult.length} chars`);
2203
+ console.log(`[DEBUG] JSON validation: Cleaned response length: ${finalResult.length} chars`);
1831
2204
  }
1832
2205
 
1833
2206
  // Record JSON validation start in telemetry
@@ -1939,17 +2312,16 @@ Convert your previous response content into actual JSON data that follows this s
1939
2312
  } else if (reachedMaxIterations && options.schema && this.debug) {
1940
2313
  console.log('[DEBUG] Skipping schema formatting due to max iterations reached without completion');
1941
2314
  } else if (completionAttempted && options.schema && !options._schemaFormatted && !options._skipValidation) {
1942
- // For attempt_completion results with schema, still clean markdown if needed
2315
+ // For attempt_completion results with schema, validate mermaid diagrams BEFORE cleaning schema
2316
+ // This ensures mermaid validation sees the full response before JSON extraction strips content
1943
2317
  // Skip this validation if we're in a recursive correction call (_skipValidation flag)
1944
2318
  try {
1945
- finalResult = cleanSchemaResponse(finalResult);
1946
-
1947
- // Validate and fix Mermaid diagrams if present
2319
+ // Validate and fix Mermaid diagrams if present (BEFORE schema cleaning)
1948
2320
  if (!this.disableMermaidValidation) {
1949
2321
  if (this.debug) {
1950
- console.log(`[DEBUG] Mermaid validation: Validating attempt_completion result...`);
2322
+ console.log(`[DEBUG] Mermaid validation: Validating attempt_completion result BEFORE schema cleaning...`);
1951
2323
  }
1952
-
2324
+
1953
2325
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
1954
2326
  debug: this.debug,
1955
2327
  path: this.allowedFolders[0],
@@ -1957,7 +2329,7 @@ Convert your previous response content into actual JSON data that follows this s
1957
2329
  model: this.model,
1958
2330
  tracer: this.tracer
1959
2331
  });
1960
-
2332
+
1961
2333
  if (mermaidValidation.wasFixed) {
1962
2334
  finalResult = mermaidValidation.fixedResponse;
1963
2335
  if (this.debug) {
@@ -1972,6 +2344,9 @@ Convert your previous response content into actual JSON data that follows this s
1972
2344
  } else if (this.debug) {
1973
2345
  console.log(`[DEBUG] Mermaid validation: Skipped for attempt_completion result due to disableMermaidValidation option`);
1974
2346
  }
2347
+
2348
+ // Now clean the schema response (may extract JSON and discard other content)
2349
+ finalResult = cleanSchemaResponse(finalResult);
1975
2350
 
1976
2351
  // Validate and potentially correct JSON for attempt_completion results
1977
2352
  if (isJsonSchema(options.schema)) {
@@ -2185,6 +2560,75 @@ Convert your previous response content into actual JSON data that follows this s
2185
2560
  }
2186
2561
  }
2187
2562
 
2563
+ /**
2564
+ * Manually compact conversation history
2565
+ * Removes intermediate monologues from older segments while preserving
2566
+ * user messages, final answers, and the most recent segment
2567
+ *
2568
+ * @param {Object} options - Compaction options
2569
+ * @param {boolean} [options.keepLastSegment=true] - Keep the most recent segment intact
2570
+ * @param {number} [options.minSegmentsToKeep=1] - Number of recent segments to preserve fully
2571
+ * @returns {Object} Compaction statistics
2572
+ */
2573
+ async compactHistory(options = {}) {
2574
+ const { compactMessages, calculateCompactionStats } = await import('./contextCompactor.js');
2575
+
2576
+ if (this.history.length === 0) {
2577
+ if (this.debug) {
2578
+ console.log(`[DEBUG] No history to compact for session ${this.sessionId}`);
2579
+ }
2580
+ return {
2581
+ originalCount: 0,
2582
+ compactedCount: 0,
2583
+ removed: 0,
2584
+ reductionPercent: 0,
2585
+ originalTokens: 0,
2586
+ compactedTokens: 0,
2587
+ tokensSaved: 0
2588
+ };
2589
+ }
2590
+
2591
+ // Perform compaction
2592
+ const compactedMessages = compactMessages(this.history, options);
2593
+ const stats = calculateCompactionStats(this.history, compactedMessages);
2594
+
2595
+ // Update history
2596
+ this.history = compactedMessages;
2597
+
2598
+ // Save to storage (clear old history first, then save compacted messages)
2599
+ try {
2600
+ // Clear existing history to avoid duplicates
2601
+ await this.storageAdapter.clearHistory(this.sessionId);
2602
+
2603
+ // Save compacted messages
2604
+ // Note: Using sequential saves as storage adapter interface doesn't support batch operations
2605
+ // For large histories, consider implementing a batch save method in your custom adapter
2606
+ for (const message of compactedMessages) {
2607
+ await this.storageAdapter.saveMessage(this.sessionId, message);
2608
+ }
2609
+ } catch (error) {
2610
+ console.error(`[ERROR] Failed to save compacted messages to storage:`, error);
2611
+ }
2612
+
2613
+ // Log results
2614
+ console.log(`[INFO] Manually compacted conversation history`);
2615
+ console.log(`[INFO] Removed ${stats.removed} messages (${stats.reductionPercent}% reduction)`);
2616
+ console.log(`[INFO] Estimated token savings: ${stats.tokensSaved} tokens`);
2617
+
2618
+ if (this.debug) {
2619
+ console.log(`[DEBUG] Compaction stats:`, stats);
2620
+ }
2621
+
2622
+ // Emit hook
2623
+ await this.hooks.emit(HOOK_TYPES.STORAGE_SAVE, {
2624
+ sessionId: this.sessionId,
2625
+ compacted: true,
2626
+ stats
2627
+ });
2628
+
2629
+ return stats;
2630
+ }
2631
+
2188
2632
  /**
2189
2633
  * Clone this agent's session to create a new agent with shared conversation history
2190
2634
  * @param {Object} options - Clone options
@@ -2215,6 +2659,17 @@ Convert your previous response content into actual JSON data that follows this s
2215
2659
  }
2216
2660
 
2217
2661
  // Create new agent with same configuration
2662
+ // Reconstruct the original allowedTools array from the parsed configuration
2663
+ let allowedToolsArray = null;
2664
+ if (this.allowedTools.mode === 'whitelist') {
2665
+ allowedToolsArray = [...this.allowedTools.allowed];
2666
+ } else if (this.allowedTools.mode === 'none') {
2667
+ allowedToolsArray = [];
2668
+ } else if (this.allowedTools.mode === 'all' && this.allowedTools.exclusions.length > 0) {
2669
+ allowedToolsArray = ['*', ...this.allowedTools.exclusions.map(t => '!' + t)];
2670
+ }
2671
+ // If mode is 'all' with no exclusions, leave as null (default)
2672
+
2218
2673
  const clonedAgent = new ProbeAgent({
2219
2674
  // Copy current agent's config
2220
2675
  customPrompt: this.customPrompt,
@@ -2231,6 +2686,7 @@ Convert your previous response content into actual JSON data that follows this s
2231
2686
  maxIterations: this.maxIterations,
2232
2687
  disableMermaidValidation: this.disableMermaidValidation,
2233
2688
  disableJsonValidation: this.disableJsonValidation,
2689
+ allowedTools: allowedToolsArray,
2234
2690
  enableMcp: !!this.mcpBridge,
2235
2691
  mcpConfig: this.mcpConfig,
2236
2692
  enableBash: this.enableBash,