@probelabs/probe 0.6.0-rc161 → 0.6.0-rc163

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.
@@ -48,6 +48,7 @@ import {
48
48
  isJsonSchema,
49
49
  validateJsonResponse,
50
50
  createJsonCorrectionPrompt,
51
+ generateSchemaInstructions,
51
52
  isJsonSchemaDefinition,
52
53
  createSchemaDefinitionCorrectionPrompt,
53
54
  validateAndFixMermaidResponse
@@ -60,6 +61,7 @@ import {
60
61
  } from './mcp/index.js';
61
62
  import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
62
63
  import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
64
+ import { handleContextLimitError } from './contextCompactor.js';
63
65
 
64
66
  // Maximum tool iterations to prevent infinite loops - configurable via MAX_TOOL_ITERATIONS env var
65
67
  const MAX_TOOL_ITERATIONS = (() => {
@@ -104,6 +106,8 @@ export class ProbeAgent {
104
106
  * @param {Array} [options.mcpServers] - Deprecated, use mcpConfig instead
105
107
  * @param {Object} [options.storageAdapter] - Custom storage adapter for history management
106
108
  * @param {Object} [options.hooks] - Hook callbacks for events (e.g., {'tool:start': callback})
109
+ * @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'])
110
+ * @param {boolean} [options.disableTools=false] - Convenience flag to disable all tools (equivalent to allowedTools: []). Takes precedence over allowedTools if set.
107
111
  * @param {Object} [options.retry] - Retry configuration
108
112
  * @param {number} [options.retry.maxRetries=3] - Maximum retry attempts per provider
109
113
  * @param {number} [options.retry.initialDelay=1000] - Initial delay in ms
@@ -139,6 +143,13 @@ export class ProbeAgent {
139
143
  this.disableMermaidValidation = !!options.disableMermaidValidation;
140
144
  this.disableJsonValidation = !!options.disableJsonValidation;
141
145
 
146
+ // Tool filtering configuration
147
+ // Parse allowedTools option: ['*'] = all tools, [] or null = no tools, ['tool1', 'tool2'] = specific tools
148
+ // Supports exclusion with '!' prefix: ['*', '!bash'] = all tools except bash
149
+ // disableTools is a convenience flag that overrides allowedTools to []
150
+ const effectiveAllowedTools = options.disableTools ? [] : options.allowedTools;
151
+ this.allowedTools = this._parseAllowedTools(effectiveAllowedTools);
152
+
142
153
  // Storage adapter (defaults to in-memory)
143
154
  this.storageAdapter = options.storageAdapter || new InMemoryStorageAdapter();
144
155
 
@@ -216,6 +227,74 @@ export class ProbeAgent {
216
227
  // Constructor must remain synchronous for backward compatibility
217
228
  }
218
229
 
230
+ /**
231
+ * Parse allowedTools configuration
232
+ * @param {Array<string>|null|undefined} allowedTools - Tool filtering configuration
233
+ * @returns {Object} Parsed configuration with isEnabled method
234
+ * @private
235
+ */
236
+ _parseAllowedTools(allowedTools) {
237
+ // Helper to check if tool matches a pattern (supports * wildcard)
238
+ const matchesPattern = (toolName, pattern) => {
239
+ if (!pattern.includes('*')) {
240
+ return toolName === pattern;
241
+ }
242
+ const regexPattern = pattern.replace(/\*/g, '.*');
243
+ return new RegExp(`^${regexPattern}$`).test(toolName);
244
+ };
245
+
246
+ // Default: all tools allowed
247
+ if (!allowedTools || (Array.isArray(allowedTools) && allowedTools.includes('*'))) {
248
+ const exclusions = Array.isArray(allowedTools)
249
+ ? allowedTools.filter(t => t.startsWith('!')).map(t => t.slice(1))
250
+ : [];
251
+
252
+ return {
253
+ mode: 'all',
254
+ exclusions,
255
+ isEnabled: (toolName) => !exclusions.some(pattern => matchesPattern(toolName, pattern))
256
+ };
257
+ }
258
+
259
+ // Empty array or null: no tools (raw AI mode)
260
+ if (Array.isArray(allowedTools) && allowedTools.length === 0) {
261
+ return {
262
+ mode: 'none',
263
+ isEnabled: () => false
264
+ };
265
+ }
266
+
267
+ // Specific tools allowed (with wildcard support)
268
+ const allowedPatterns = allowedTools.filter(t => !t.startsWith('!'));
269
+ return {
270
+ mode: 'whitelist',
271
+ allowed: allowedPatterns,
272
+ isEnabled: (toolName) => allowedPatterns.some(pattern => matchesPattern(toolName, pattern))
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Check if an MCP tool is allowed based on allowedTools configuration
278
+ * Uses mcp__ prefix convention (like Claude Code)
279
+ * @param {string} toolName - The MCP tool name (without mcp__ prefix)
280
+ * @returns {boolean} - Whether the tool is allowed
281
+ * @private
282
+ */
283
+ _isMcpToolAllowed(toolName) {
284
+ const mcpToolName = `mcp__${toolName}`;
285
+ return this.allowedTools.isEnabled(mcpToolName) || this.allowedTools.isEnabled(toolName);
286
+ }
287
+
288
+ /**
289
+ * Filter MCP tools based on allowedTools configuration
290
+ * @param {string[]} mcpToolNames - Array of MCP tool names
291
+ * @returns {string[]} - Filtered array of allowed MCP tool names
292
+ * @private
293
+ */
294
+ _filterMcpTools(mcpToolNames) {
295
+ return mcpToolNames.filter(toolName => this._isMcpToolAllowed(toolName));
296
+ }
297
+
219
298
  /**
220
299
  * Initialize the agent asynchronously (must be called after constructor)
221
300
  * This method initializes MCP and merges MCP tools into the tool list, and loads history from storage
@@ -248,10 +327,15 @@ export class ProbeAgent {
248
327
  await this.initializeMCP();
249
328
 
250
329
  // Merge MCP tools into toolImplementations for unified access
330
+ // Apply allowedTools filtering using mcp__ prefix (like Claude Code)
251
331
  if (this.mcpBridge) {
252
332
  const mcpTools = this.mcpBridge.mcpTools || {};
253
333
  for (const [toolName, toolImpl] of Object.entries(mcpTools)) {
254
- this.toolImplementations[toolName] = toolImpl;
334
+ if (this._isMcpToolAllowed(toolName)) {
335
+ this.toolImplementations[toolName] = toolImpl;
336
+ } else if (this.debug) {
337
+ console.error(`[DEBUG] MCP tool '${toolName}' filtered out by allowedTools`);
338
+ }
255
339
  }
256
340
  }
257
341
 
@@ -1047,10 +1131,15 @@ export class ProbeAgent {
1047
1131
  await this.initializeMCP();
1048
1132
 
1049
1133
  // Merge MCP tools into toolImplementations for unified access
1134
+ // Apply allowedTools filtering using mcp__ prefix (like Claude Code)
1050
1135
  if (this.mcpBridge) {
1051
1136
  const mcpTools = this.mcpBridge.mcpTools || {};
1052
1137
  for (const [toolName, toolImpl] of Object.entries(mcpTools)) {
1053
- this.toolImplementations[toolName] = toolImpl;
1138
+ if (this._isMcpToolAllowed(toolName)) {
1139
+ this.toolImplementations[toolName] = toolImpl;
1140
+ } else if (this.debug) {
1141
+ console.error(`[DEBUG] MCP tool '${toolName}' filtered out by allowedTools`);
1142
+ }
1054
1143
  }
1055
1144
  }
1056
1145
  } catch (error) {
@@ -1061,24 +1150,53 @@ export class ProbeAgent {
1061
1150
  }
1062
1151
  }
1063
1152
 
1064
- // Build tool definitions
1065
- let toolDefinitions = `
1066
- ${searchToolDefinition}
1067
- ${queryToolDefinition}
1068
- ${extractToolDefinition}
1069
- ${listFilesToolDefinition}
1070
- ${searchFilesToolDefinition}
1071
- ${attemptCompletionToolDefinition}
1072
- `;
1073
- if (this.allowEdit) {
1153
+ // Build tool definitions based on allowedTools configuration
1154
+ let toolDefinitions = '';
1155
+
1156
+ // Helper to check if a tool is allowed
1157
+ const isToolAllowed = (toolName) => this.allowedTools.isEnabled(toolName);
1158
+
1159
+ // Core tools (filtered by allowedTools)
1160
+ if (isToolAllowed('search')) {
1161
+ toolDefinitions += `${searchToolDefinition}\n`;
1162
+ }
1163
+ if (isToolAllowed('query')) {
1164
+ toolDefinitions += `${queryToolDefinition}\n`;
1165
+ }
1166
+ if (isToolAllowed('extract')) {
1167
+ toolDefinitions += `${extractToolDefinition}\n`;
1168
+ }
1169
+ if (isToolAllowed('listFiles')) {
1170
+ toolDefinitions += `${listFilesToolDefinition}\n`;
1171
+ }
1172
+ if (isToolAllowed('searchFiles')) {
1173
+ toolDefinitions += `${searchFilesToolDefinition}\n`;
1174
+ }
1175
+
1176
+ // Edit tools (require both allowEdit flag AND allowedTools permission)
1177
+ if (this.allowEdit && isToolAllowed('implement')) {
1074
1178
  toolDefinitions += `${implementToolDefinition}\n`;
1179
+ }
1180
+ if (this.allowEdit && isToolAllowed('edit')) {
1075
1181
  toolDefinitions += `${editToolDefinition}\n`;
1182
+ }
1183
+ if (this.allowEdit && isToolAllowed('create')) {
1076
1184
  toolDefinitions += `${createToolDefinition}\n`;
1077
1185
  }
1078
- if (this.enableBash) {
1186
+
1187
+ // Bash tool (require both enableBash flag AND allowedTools permission)
1188
+ if (this.enableBash && isToolAllowed('bash')) {
1079
1189
  toolDefinitions += `${bashToolDefinition}\n`;
1080
1190
  }
1081
- if (this.enableDelegate) {
1191
+
1192
+ // Always include attempt_completion (unless explicitly disabled in raw AI mode)
1193
+ if (isToolAllowed('attempt_completion')) {
1194
+ toolDefinitions += `${attemptCompletionToolDefinition}\n`;
1195
+ }
1196
+
1197
+ // Delegate tool (require both enableDelegate flag AND allowedTools permission)
1198
+ // Place after attempt_completion as it's an optional tool
1199
+ if (this.enableDelegate && isToolAllowed('delegate')) {
1082
1200
  toolDefinitions += `${delegateToolDefinition}\n`;
1083
1201
  }
1084
1202
 
@@ -1257,11 +1375,17 @@ When troubleshooting:
1257
1375
  // Add Tool Definitions
1258
1376
  systemMessage += `\n# Tools Available\n${toolDefinitions}\n`;
1259
1377
 
1260
- // Add MCP tools if available
1378
+ // Add MCP tools if available (filtered by allowedTools)
1261
1379
  if (this.mcpBridge && this.mcpBridge.getToolNames().length > 0) {
1262
- systemMessage += `\n## MCP Tools (JSON parameters in <params> tag)\n`;
1263
- systemMessage += this.mcpBridge.getXmlToolDefinitions();
1264
- 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`;
1380
+ const allMcpTools = this.mcpBridge.getToolNames();
1381
+ const allowedMcpTools = this._filterMcpTools(allMcpTools);
1382
+
1383
+ if (allowedMcpTools.length > 0) {
1384
+ systemMessage += `\n## MCP Tools (JSON parameters in <params> tag)\n`;
1385
+ // Get only allowed MCP tool definitions
1386
+ systemMessage += this.mcpBridge.getXmlToolDefinitions(allowedMcpTools);
1387
+ 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`;
1388
+ }
1265
1389
  }
1266
1390
 
1267
1391
  // Add folder information
@@ -1332,11 +1456,18 @@ When troubleshooting:
1332
1456
 
1333
1457
  // Create user message with optional image support
1334
1458
  let userMessage = { role: 'user', content: message.trim() };
1335
-
1459
+
1460
+ // If schema is provided, prepend JSON format requirement to user message
1461
+ if (options.schema && !options._schemaFormatted) {
1462
+ const schemaInstructions = generateSchemaInstructions(options.schema, { debug: this.debug });
1463
+ userMessage.content = message.trim() + schemaInstructions;
1464
+ }
1465
+
1336
1466
  // If images are provided, use multi-modal message format
1337
1467
  if (images && images.length > 0) {
1468
+ const textContent = userMessage.content;
1338
1469
  userMessage.content = [
1339
- { type: 'text', text: message.trim() },
1470
+ { type: 'text', text: textContent },
1340
1471
  ...images.map(image => ({
1341
1472
  type: 'image',
1342
1473
  image: image
@@ -1447,57 +1578,120 @@ When troubleshooting:
1447
1578
 
1448
1579
  // Make AI request
1449
1580
  let assistantResponseContent = '';
1450
- try {
1451
- // Wrap AI request with tracing if available
1452
- const executeAIRequest = async () => {
1453
- // Prepare messages with potential image content
1454
- const messagesForAI = this.prepareMessagesWithImages(currentMessages);
1455
-
1456
- const result = await this.streamTextWithRetryAndFallback({
1457
- model: this.provider(this.model),
1458
- messages: messagesForAI,
1459
- maxTokens: maxResponseTokens,
1460
- temperature: 0.3,
1461
- });
1581
+ let compactionAttempted = false;
1582
+
1583
+ // Retry loop for context compaction - separate from streamTextWithRetryAndFallback
1584
+ // which handles transient errors (rate limits, network issues, etc.)
1585
+ while (true) {
1586
+ try {
1587
+ // Wrap AI request with tracing if available
1588
+ const executeAIRequest = async () => {
1589
+ // Prepare messages with potential image content
1590
+ const messagesForAI = this.prepareMessagesWithImages(currentMessages);
1591
+
1592
+ const result = await this.streamTextWithRetryAndFallback({
1593
+ model: this.provider(this.model),
1594
+ messages: messagesForAI,
1595
+ maxTokens: maxResponseTokens,
1596
+ temperature: 0.3,
1597
+ });
1462
1598
 
1463
- // Get the promise reference BEFORE consuming stream (doesn't lock it)
1464
- const usagePromise = result.usage;
1599
+ // Get the promise reference BEFORE consuming stream (doesn't lock it)
1600
+ const usagePromise = result.usage;
1465
1601
 
1466
- // Collect the streamed response - stream all content for now
1467
- for await (const delta of result.textStream) {
1468
- assistantResponseContent += delta;
1469
- // For now, stream everything - we'll handle segmentation after tools execute
1470
- if (options.onStream) {
1471
- options.onStream(delta);
1602
+ // Collect the streamed response - stream all content for now
1603
+ for await (const delta of result.textStream) {
1604
+ assistantResponseContent += delta;
1605
+ // For now, stream everything - we'll handle segmentation after tools execute
1606
+ if (options.onStream) {
1607
+ options.onStream(delta);
1608
+ }
1472
1609
  }
1610
+
1611
+ // Record token usage - await the promise AFTER stream is consumed
1612
+ const usage = await usagePromise;
1613
+ if (usage) {
1614
+ this.tokenCounter.recordUsage(usage, result.experimental_providerMetadata);
1615
+ }
1616
+
1617
+ return result;
1618
+ };
1619
+
1620
+ if (this.tracer) {
1621
+ await this.tracer.withSpan('ai.request', executeAIRequest, {
1622
+ 'ai.model': this.model,
1623
+ 'ai.provider': this.clientApiProvider || 'auto',
1624
+ 'iteration': currentIteration,
1625
+ 'max_tokens': maxResponseTokens,
1626
+ 'temperature': 0.3,
1627
+ 'message_count': currentMessages.length
1628
+ });
1629
+ } else {
1630
+ await executeAIRequest();
1473
1631
  }
1474
1632
 
1475
- // Record token usage - await the promise AFTER stream is consumed
1476
- const usage = await usagePromise;
1477
- if (usage) {
1478
- this.tokenCounter.recordUsage(usage, result.experimental_providerMetadata);
1633
+ // Success - break out of compaction retry loop
1634
+ break;
1635
+
1636
+ } catch (error) {
1637
+ // Check if this is a context limit error (only try compaction once per iteration)
1638
+ if (!compactionAttempted && handleContextLimitError) {
1639
+ const compactionResult = handleContextLimitError(error, currentMessages, {
1640
+ keepLastSegment: true,
1641
+ minSegmentsToKeep: 1
1642
+ });
1643
+
1644
+ if (compactionResult) {
1645
+ // Context limit error detected - compact and retry once
1646
+ const { messages: compactedMessages, stats } = compactionResult;
1647
+
1648
+ // Check if compaction actually reduced message count
1649
+ if (stats.removed === 0) {
1650
+ // No messages removed - compaction won't help, fail immediately
1651
+ console.error(`[ERROR] Context window exceeded but no messages can be compacted.`);
1652
+ console.error(`[ERROR] The conversation history is already minimal (${stats.originalCount} messages).`);
1653
+ finalResult = `Error: Context window limit exceeded and conversation cannot be compacted further. Consider starting a new session or reducing system message size.`;
1654
+ throw new Error(finalResult);
1655
+ }
1656
+
1657
+ compactionAttempted = true;
1658
+
1659
+ console.log(`[INFO] Context window limit exceeded. Compacting conversation...`);
1660
+ console.log(`[INFO] Removed ${stats.removed} messages (${stats.reductionPercent}% reduction)`);
1661
+ console.log(`[INFO] Estimated token savings: ${stats.tokensSaved} tokens`);
1662
+
1663
+ if (this.debug) {
1664
+ console.log(`[DEBUG] Compaction stats:`, stats);
1665
+ console.log(`[DEBUG] Original message count: ${stats.originalCount}`);
1666
+ console.log(`[DEBUG] Compacted message count: ${stats.compactedCount}`);
1667
+ }
1668
+
1669
+ // Replace currentMessages with compacted version (creates new array reference)
1670
+ // This ensures we don't mutate the original history array
1671
+ currentMessages = [...compactedMessages];
1672
+
1673
+ // Log compaction event if tracer is available
1674
+ if (this.tracer) {
1675
+ this.tracer.addEvent('context.compacted', {
1676
+ 'iteration': currentIteration,
1677
+ 'original_count': stats.originalCount,
1678
+ 'compacted_count': stats.compactedCount,
1679
+ 'reduction_percent': stats.reductionPercent,
1680
+ 'tokens_saved': stats.tokensSaved
1681
+ });
1682
+ }
1683
+
1684
+ // Continue to retry with compacted messages
1685
+ continue;
1686
+ }
1479
1687
  }
1480
1688
 
1481
- return result;
1482
- };
1483
-
1484
- if (this.tracer) {
1485
- await this.tracer.withSpan('ai.request', executeAIRequest, {
1486
- 'ai.model': this.model,
1487
- 'ai.provider': this.clientApiProvider || 'auto',
1488
- 'iteration': currentIteration,
1489
- 'max_tokens': maxResponseTokens,
1490
- 'temperature': 0.3,
1491
- 'message_count': currentMessages.length
1492
- });
1493
- } else {
1494
- await executeAIRequest();
1689
+ // Not a context limit error, compaction already attempted, or compaction not available
1690
+ // IMPORTANT: This break prevents infinite loop if compacted messages still exceed limit
1691
+ console.error(`Error during streamText (Iter ${currentIteration}):`, error);
1692
+ finalResult = `Error: Failed to get response from AI model during iteration ${currentIteration}. ${error.message}`;
1693
+ throw new Error(finalResult);
1495
1694
  }
1496
-
1497
- } catch (error) {
1498
- console.error(`Error during streamText (Iter ${currentIteration}):`, error);
1499
- finalResult = `Error: Failed to get response from AI model during iteration ${currentIteration}. ${error.message}`;
1500
- throw new Error(finalResult);
1501
1695
  }
1502
1696
 
1503
1697
  // Log preview of assistant response for debugging loops
@@ -1818,7 +2012,7 @@ When troubleshooting:
1818
2012
  // Build appropriate reminder message based on whether schema is provided
1819
2013
  let reminderContent;
1820
2014
  if (options.schema) { // Apply for ANY schema, not just JSON schemas
1821
- // When schema is provided, AI must use attempt_completion to trigger schema formatting
2015
+ // When schema is provided, use the same instructions as initial message
1822
2016
  reminderContent = `Please use one of the available tools to help answer the question, or use attempt_completion if you have enough information to provide a final answer.
1823
2017
 
1824
2018
  Remember: Use proper XML format with BOTH opening and closing tags:
@@ -1826,15 +2020,7 @@ Remember: Use proper XML format with BOTH opening and closing tags:
1826
2020
  <tool_name>
1827
2021
  <parameter>value</parameter>
1828
2022
  </tool_name>
1829
-
1830
- IMPORTANT: A schema was provided for the final output format.
1831
-
1832
- You MUST use attempt_completion to provide your answer:
1833
- <attempt_completion>
1834
- [Your complete answer here - provide in natural language, it will be automatically formatted to match the schema]
1835
- </attempt_completion>
1836
-
1837
- Your response will be automatically formatted to JSON. You can provide your answer in natural language or as JSON - either will work.`;
2023
+ ` + generateSchemaInstructions(options.schema, { debug: this.debug }).trim();
1838
2024
  } else {
1839
2025
  // Standard reminder without schema
1840
2026
  reminderContent = `Please use one of the available tools to help answer the question, or use attempt_completion if you have enough information to provide a final answer.
@@ -1937,15 +2123,13 @@ NOT: {"type": "object", "properties": {"name": {"type": "string"}}}
1937
2123
  Convert your previous response content into actual JSON data that follows this schema structure.`;
1938
2124
 
1939
2125
  // Call answer recursively with _schemaFormatted flag to prevent infinite loop
1940
- finalResult = await this.answer(schemaPrompt, [], {
1941
- ...options,
1942
- _schemaFormatted: true
2126
+ finalResult = await this.answer(schemaPrompt, [], {
2127
+ ...options,
2128
+ _schemaFormatted: true
1943
2129
  });
1944
-
1945
- // Step 2: Clean the response (remove code blocks)
1946
- finalResult = cleanSchemaResponse(finalResult);
1947
-
1948
- // 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
1949
2133
  if (!this.disableMermaidValidation) {
1950
2134
  try {
1951
2135
  if (this.debug) {
@@ -2007,19 +2191,16 @@ Convert your previous response content into actual JSON data that follows this s
2007
2191
  } else if (this.debug) {
2008
2192
  console.log(`[DEBUG] Mermaid validation: Skipped due to disableMermaidValidation option`);
2009
2193
  }
2010
-
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
+
2011
2199
  // Step 4: Validate and potentially correct JSON responses
2012
2200
  if (isJsonSchema(options.schema)) {
2013
2201
  if (this.debug) {
2014
2202
  console.log(`[DEBUG] JSON validation: Starting validation process for schema response`);
2015
- console.log(`[DEBUG] JSON validation: Response length: ${finalResult.length} chars`);
2016
- }
2017
-
2018
- // Clean the response first to extract JSON from markdown/code blocks
2019
- finalResult = cleanSchemaResponse(finalResult);
2020
-
2021
- if (this.debug) {
2022
- console.log(`[DEBUG] JSON validation: After cleaning, length: ${finalResult.length} chars`);
2203
+ console.log(`[DEBUG] JSON validation: Cleaned response length: ${finalResult.length} chars`);
2023
2204
  }
2024
2205
 
2025
2206
  // Record JSON validation start in telemetry
@@ -2131,17 +2312,16 @@ Convert your previous response content into actual JSON data that follows this s
2131
2312
  } else if (reachedMaxIterations && options.schema && this.debug) {
2132
2313
  console.log('[DEBUG] Skipping schema formatting due to max iterations reached without completion');
2133
2314
  } else if (completionAttempted && options.schema && !options._schemaFormatted && !options._skipValidation) {
2134
- // 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
2135
2317
  // Skip this validation if we're in a recursive correction call (_skipValidation flag)
2136
2318
  try {
2137
- finalResult = cleanSchemaResponse(finalResult);
2138
-
2139
- // Validate and fix Mermaid diagrams if present
2319
+ // Validate and fix Mermaid diagrams if present (BEFORE schema cleaning)
2140
2320
  if (!this.disableMermaidValidation) {
2141
2321
  if (this.debug) {
2142
- console.log(`[DEBUG] Mermaid validation: Validating attempt_completion result...`);
2322
+ console.log(`[DEBUG] Mermaid validation: Validating attempt_completion result BEFORE schema cleaning...`);
2143
2323
  }
2144
-
2324
+
2145
2325
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
2146
2326
  debug: this.debug,
2147
2327
  path: this.allowedFolders[0],
@@ -2149,7 +2329,7 @@ Convert your previous response content into actual JSON data that follows this s
2149
2329
  model: this.model,
2150
2330
  tracer: this.tracer
2151
2331
  });
2152
-
2332
+
2153
2333
  if (mermaidValidation.wasFixed) {
2154
2334
  finalResult = mermaidValidation.fixedResponse;
2155
2335
  if (this.debug) {
@@ -2164,6 +2344,9 @@ Convert your previous response content into actual JSON data that follows this s
2164
2344
  } else if (this.debug) {
2165
2345
  console.log(`[DEBUG] Mermaid validation: Skipped for attempt_completion result due to disableMermaidValidation option`);
2166
2346
  }
2347
+
2348
+ // Now clean the schema response (may extract JSON and discard other content)
2349
+ finalResult = cleanSchemaResponse(finalResult);
2167
2350
 
2168
2351
  // Validate and potentially correct JSON for attempt_completion results
2169
2352
  if (isJsonSchema(options.schema)) {
@@ -2377,6 +2560,75 @@ Convert your previous response content into actual JSON data that follows this s
2377
2560
  }
2378
2561
  }
2379
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
+
2380
2632
  /**
2381
2633
  * Clone this agent's session to create a new agent with shared conversation history
2382
2634
  * @param {Object} options - Clone options
@@ -2407,6 +2659,17 @@ Convert your previous response content into actual JSON data that follows this s
2407
2659
  }
2408
2660
 
2409
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
+
2410
2673
  const clonedAgent = new ProbeAgent({
2411
2674
  // Copy current agent's config
2412
2675
  customPrompt: this.customPrompt,
@@ -2423,6 +2686,7 @@ Convert your previous response content into actual JSON data that follows this s
2423
2686
  maxIterations: this.maxIterations,
2424
2687
  disableMermaidValidation: this.disableMermaidValidation,
2425
2688
  disableJsonValidation: this.disableJsonValidation,
2689
+ allowedTools: allowedToolsArray,
2426
2690
  enableMcp: !!this.mcpBridge,
2427
2691
  mcpConfig: this.mcpConfig,
2428
2692
  enableBash: this.enableBash,