@probelabs/probe 0.6.0-rc205 → 0.6.0-rc207

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@probelabs/probe",
3
- "version": "0.6.0-rc205",
3
+ "version": "0.6.0-rc207",
4
4
  "description": "Node.js wrapper for the probe code search tool",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -70,6 +70,7 @@ import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
70
70
  import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
71
71
  import { handleContextLimitError } from './contextCompactor.js';
72
72
  import { formatErrorForAI, ParameterError } from '../utils/error-types.js';
73
+ import { truncateIfNeeded, getMaxOutputTokens } from './outputTruncator.js';
73
74
  import {
74
75
  TaskManager,
75
76
  createTaskTool,
@@ -90,6 +91,45 @@ const MAX_TOOL_ITERATIONS = (() => {
90
91
  })();
91
92
  const MAX_HISTORY_MESSAGES = 100;
92
93
 
94
+ /**
95
+ * Extract tool name from wrapped_tool:toolName format
96
+ * @param {string} wrappedToolError - Error string in format 'wrapped_tool:toolName'
97
+ * @returns {string} The extracted tool name or 'unknown' if format is invalid
98
+ */
99
+ function extractWrappedToolName(wrappedToolError) {
100
+ if (!wrappedToolError || typeof wrappedToolError !== 'string') {
101
+ return 'unknown';
102
+ }
103
+ const colonIndex = wrappedToolError.indexOf(':');
104
+ return colonIndex !== -1 ? wrappedToolError.slice(colonIndex + 1) : 'unknown';
105
+ }
106
+
107
+ /**
108
+ * Check if an error indicates a wrapped tool format error
109
+ * @param {string|null} error - Error from detectUnrecognizedToolCall
110
+ * @returns {boolean} True if it's a wrapped tool error
111
+ */
112
+ function isWrappedToolError(error) {
113
+ return error && typeof error === 'string' && error.startsWith('wrapped_tool:');
114
+ }
115
+
116
+ /**
117
+ * Create error message for wrapped tool format issues
118
+ * @param {string} wrappedToolName - The tool name that was incorrectly wrapped
119
+ * @returns {string} User-friendly error message with correct format instructions
120
+ */
121
+ function createWrappedToolErrorMessage(wrappedToolName) {
122
+ return `Your response contained an incorrectly formatted tool call (${wrappedToolName} wrapped in XML tags). This cannot be used.
123
+
124
+ Please use the CORRECT format:
125
+
126
+ <${wrappedToolName}>
127
+ Your content here
128
+ </${wrappedToolName}>
129
+
130
+ Do NOT wrap in other tags like <api_call>, <tool_name>, <function>, etc.`;
131
+ }
132
+
93
133
  // Supported image file extensions (imported from shared config)
94
134
 
95
135
  // Maximum image file size (20MB) to prevent OOM attacks
@@ -145,6 +185,7 @@ export class ProbeAgent {
145
185
  * @param {boolean} [options.fallback.stopOnSuccess=true] - Stop on first success
146
186
  * @param {number} [options.fallback.maxTotalAttempts=10] - Maximum total attempts across all providers
147
187
  * @param {string} [options.completionPrompt] - Custom prompt to run after attempt_completion for validation/review (runs before mermaid/JSON validation)
188
+ * @param {number} [options.maxOutputTokens] - Maximum tokens for tool output before truncation (default: 20000, can also be set via PROBE_MAX_OUTPUT_TOKENS env var)
148
189
  */
149
190
  constructor(options = {}) {
150
191
  // Basic configuration
@@ -237,6 +278,9 @@ export class ProbeAgent {
237
278
  // Initialize token counter
238
279
  this.tokenCounter = new TokenCounter();
239
280
 
281
+ // Maximum output tokens for tool results (truncate if exceeded)
282
+ this.maxOutputTokens = getMaxOutputTokens(options.maxOutputTokens);
283
+
240
284
  if (this.debug) {
241
285
  console.log(`[DEBUG] Generated session ID for agent: ${this.sessionId}`);
242
286
  console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
@@ -2537,6 +2581,11 @@ Follow these instructions carefully:
2537
2581
  }
2538
2582
  }
2539
2583
 
2584
+ // Circuit breaker for repeated format errors
2585
+ let lastFormatErrorType = null;
2586
+ let sameFormatErrorCount = 0;
2587
+ const MAX_REPEATED_FORMAT_ERRORS = 3;
2588
+
2540
2589
  // Tool iteration loop (only for non-CLI engines like Vercel/Anthropic/OpenAI)
2541
2590
  while (currentIteration < maxIterations && !completionAttempted) {
2542
2591
  currentIteration++;
@@ -2830,7 +2879,28 @@ Follow these instructions carefully:
2830
2879
  );
2831
2880
 
2832
2881
  if (lastAssistantMessage) {
2833
- finalResult = lastAssistantMessage.content;
2882
+ const prevContent = lastAssistantMessage.content;
2883
+
2884
+ // Check for patterns indicating a failed/wrapped tool call attempt
2885
+ // Use detectUnrecognizedToolCall for consistent detection logic
2886
+ const wrappedToolError = detectUnrecognizedToolCall(prevContent, validTools);
2887
+
2888
+ if (isWrappedToolError(wrappedToolError)) {
2889
+ // Previous response was a broken tool call attempt - don't reuse it
2890
+ const wrappedToolName = extractWrappedToolName(wrappedToolError);
2891
+ if (this.debug) {
2892
+ console.log(`[DEBUG] Previous response contains wrapped tool '${wrappedToolName}' - rejecting for __PREVIOUS_RESPONSE__`);
2893
+ }
2894
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
2895
+ currentMessages.push({
2896
+ role: 'user',
2897
+ content: createWrappedToolErrorMessage(wrappedToolName)
2898
+ });
2899
+ completionAttempted = false;
2900
+ continue; // Don't use broken response, continue the loop
2901
+ }
2902
+
2903
+ finalResult = prevContent;
2834
2904
  if (this.debug) console.log(`[DEBUG] Using previous response as completion: ${finalResult.substring(0, 100)}...`);
2835
2905
  } else {
2836
2906
  finalResult = 'Error: No previous response found to use as completion.';
@@ -2882,7 +2952,24 @@ Follow these instructions carefully:
2882
2952
  // Execute MCP tool through the bridge
2883
2953
  const executionResult = await this.mcpBridge.mcpTools[toolName].execute(params);
2884
2954
 
2885
- const toolResultContent = typeof executionResult === 'string' ? executionResult : JSON.stringify(executionResult, null, 2);
2955
+ let toolResultContent = typeof executionResult === 'string' ? executionResult : JSON.stringify(executionResult, null, 2);
2956
+
2957
+ // Truncate if output exceeds token limit
2958
+ try {
2959
+ const truncateResult = await truncateIfNeeded(toolResultContent, this.tokenCounter, this.sessionId, this.maxOutputTokens);
2960
+ if (truncateResult.truncated) {
2961
+ toolResultContent = truncateResult.content;
2962
+ if (this.debug) {
2963
+ console.log(`[DEBUG] Tool output truncated: ${truncateResult.originalTokens} tokens -> saved to ${truncateResult.tempFilePath || 'N/A'}`);
2964
+ if (truncateResult.error) {
2965
+ console.log(`[DEBUG] Truncation file error: ${truncateResult.error}`);
2966
+ }
2967
+ }
2968
+ }
2969
+ } catch (truncateError) {
2970
+ // If truncation fails entirely, log and continue with original content
2971
+ console.error(`[WARN] Tool output truncation failed: ${truncateError.message}`);
2972
+ }
2886
2973
 
2887
2974
  // Log MCP tool result in debug mode
2888
2975
  if (this.debug) {
@@ -3059,10 +3146,28 @@ Follow these instructions carefully:
3059
3146
 
3060
3147
  // Add assistant response and tool result to conversation
3061
3148
  currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3062
-
3063
- const toolResultContent = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult, null, 2);
3149
+
3150
+ let toolResultContent = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult, null, 2);
3151
+
3152
+ // Truncate if output exceeds token limit
3153
+ try {
3154
+ const truncateResult = await truncateIfNeeded(toolResultContent, this.tokenCounter, this.sessionId, this.maxOutputTokens);
3155
+ if (truncateResult.truncated) {
3156
+ toolResultContent = truncateResult.content;
3157
+ if (this.debug) {
3158
+ console.log(`[DEBUG] Tool output truncated: ${truncateResult.originalTokens} tokens -> saved to ${truncateResult.tempFilePath || 'N/A'}`);
3159
+ if (truncateResult.error) {
3160
+ console.log(`[DEBUG] Truncation file error: ${truncateResult.error}`);
3161
+ }
3162
+ }
3163
+ }
3164
+ } catch (truncateError) {
3165
+ // If truncation fails entirely, log and continue with original content
3166
+ console.error(`[WARN] Tool output truncation failed: ${truncateError.message}`);
3167
+ }
3168
+
3064
3169
  const toolResultMessage = `<tool_result>\n${toolResultContent}\n</tool_result>`;
3065
-
3170
+
3066
3171
  currentMessages.push({
3067
3172
  role: 'user',
3068
3173
  content: toolResultMessage
@@ -3125,7 +3230,32 @@ Follow these instructions carefully:
3125
3230
  const unrecognizedTool = detectUnrecognizedToolCall(assistantResponseContent, validTools);
3126
3231
 
3127
3232
  let reminderContent;
3128
- if (unrecognizedTool) {
3233
+ if (isWrappedToolError(unrecognizedTool)) {
3234
+ // AI wrapped a valid tool name in arbitrary XML tags - provide clear format error
3235
+ const wrappedToolName = extractWrappedToolName(unrecognizedTool);
3236
+ if (this.debug) {
3237
+ console.log(`[DEBUG] Detected wrapped tool '${wrappedToolName}' in assistant response - wrong XML format.`);
3238
+ }
3239
+ const toolError = new ParameterError(
3240
+ `Tool '${wrappedToolName}' found but in WRONG FORMAT - do not wrap tools in other XML tags.`,
3241
+ {
3242
+ suggestion: `Use the tool tag DIRECTLY without any wrapper:
3243
+
3244
+ CORRECT FORMAT:
3245
+ <${wrappedToolName}>
3246
+ <param>value</param>
3247
+ </${wrappedToolName}>
3248
+
3249
+ WRONG (what you did - do not wrap in other tags):
3250
+ <api_call><tool_name>${wrappedToolName}</tool_name>...</api_call>
3251
+ <function>${wrappedToolName}</function>
3252
+ <call name="${wrappedToolName}">...</call>
3253
+
3254
+ Remove ALL wrapper tags and use <${wrappedToolName}> directly as the outermost tag.`
3255
+ }
3256
+ );
3257
+ reminderContent = `<tool_result>\n${formatErrorForAI(toolError)}\n</tool_result>`;
3258
+ } else if (unrecognizedTool) {
3129
3259
  // AI tried to use a tool that's not available - provide clear error
3130
3260
  if (this.debug) {
3131
3261
  console.log(`[DEBUG] Detected unrecognized tool '${unrecognizedTool}' in assistant response.`);
@@ -3135,6 +3265,33 @@ Follow these instructions carefully:
3135
3265
  });
3136
3266
  reminderContent = `<tool_result>\n${formatErrorForAI(toolError)}\n</tool_result>`;
3137
3267
  } else {
3268
+ // No tool call detected at all - check if this is the last iteration
3269
+ // On the last iteration, if the AI gave a substantive response without using
3270
+ // attempt_completion, accept it as the final answer rather than losing the content
3271
+ if (currentIteration >= maxIterations) {
3272
+ // Clean up the response - remove thinking tags
3273
+ let cleanedResponse = assistantResponseContent;
3274
+ // Remove <thinking>...</thinking> blocks
3275
+ cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '').trim();
3276
+ // Also remove unclosed thinking tags
3277
+ cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*$/gi, '').trim();
3278
+
3279
+ // Only use if there's substantial content (not just a failed tool call attempt)
3280
+ const hasSubstantialContent = cleanedResponse.length > 50 &&
3281
+ !cleanedResponse.includes('<api_call>') &&
3282
+ !cleanedResponse.includes('<tool_name>') &&
3283
+ !cleanedResponse.includes('<function>');
3284
+
3285
+ if (hasSubstantialContent) {
3286
+ if (this.debug) {
3287
+ console.log(`[DEBUG] Max iterations reached - accepting AI response as final answer (${cleanedResponse.length} chars)`);
3288
+ }
3289
+ finalResult = cleanedResponse;
3290
+ completionAttempted = true;
3291
+ break;
3292
+ }
3293
+ }
3294
+
3138
3295
  // Standard reminder - no tool call detected at all
3139
3296
  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.
3140
3297
 
@@ -3166,6 +3323,31 @@ Note: <attempt_complete></attempt_complete> reuses your PREVIOUS assistant messa
3166
3323
  console.log(`[DEBUG] No tool call detected in assistant response. Prompting for tool use.`);
3167
3324
  }
3168
3325
  }
3326
+
3327
+ // Circuit breaker: track repeated format errors and break early
3328
+ // For wrapped_tool errors, track them as a category (any wrapped_tool counts)
3329
+ // For other errors, track the exact error type
3330
+ if (unrecognizedTool) {
3331
+ const isWrapped = isWrappedToolError(unrecognizedTool);
3332
+ const errorCategory = isWrapped ? 'wrapped_tool' : unrecognizedTool;
3333
+
3334
+ if (errorCategory === lastFormatErrorType) {
3335
+ sameFormatErrorCount++;
3336
+ if (sameFormatErrorCount >= MAX_REPEATED_FORMAT_ERRORS) {
3337
+ const errorDesc = isWrapped ? 'wrapped tool format' : unrecognizedTool;
3338
+ console.error(`[ERROR] Format error category '${errorCategory}' repeated ${sameFormatErrorCount} times. Breaking loop early to prevent infinite iteration.`);
3339
+ finalResult = `Error: Unable to complete request. The AI model repeatedly used incorrect tool call format (${errorDesc}). Please try rephrasing your question or using a different model.`;
3340
+ break;
3341
+ }
3342
+ } else {
3343
+ lastFormatErrorType = errorCategory;
3344
+ sameFormatErrorCount = 1;
3345
+ }
3346
+ } else {
3347
+ // Reset counter if it's a different kind of "no tool call" situation
3348
+ lastFormatErrorType = null;
3349
+ sameFormatErrorCount = 0;
3350
+ }
3169
3351
  }
3170
3352
 
3171
3353
  // Keep message history manageable
@@ -0,0 +1,108 @@
1
+ import { writeFile, mkdir } from 'fs/promises';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { randomUUID } from 'crypto';
5
+
6
+ const DEFAULT_MAX_OUTPUT_TOKENS = 20000;
7
+ const CHARS_PER_TOKEN = 4; // Conservative approximation
8
+
9
+ /**
10
+ * Validate and normalize a token limit value.
11
+ * Returns the default if the value is invalid (NaN, negative, zero).
12
+ * @param {any} value - The value to validate
13
+ * @returns {number} A valid positive token limit
14
+ */
15
+ function validateTokenLimit(value) {
16
+ const num = Number(value);
17
+ if (isNaN(num) || num <= 0) {
18
+ return DEFAULT_MAX_OUTPUT_TOKENS;
19
+ }
20
+ return num;
21
+ }
22
+
23
+ /**
24
+ * Get the maximum output tokens limit based on priority:
25
+ * 1. Constructor value (if provided and valid)
26
+ * 2. Environment variable PROBE_MAX_OUTPUT_TOKENS (if valid)
27
+ * 3. Default (20000)
28
+ * @param {number|undefined} constructorValue - Value passed to ProbeAgent constructor
29
+ * @returns {number} The maximum output tokens limit (always a valid positive number)
30
+ */
31
+ export function getMaxOutputTokens(constructorValue) {
32
+ if (constructorValue !== undefined && constructorValue !== null) {
33
+ const validated = validateTokenLimit(constructorValue);
34
+ // Only use constructor value if it was valid; otherwise fall through to env/default
35
+ if (validated !== DEFAULT_MAX_OUTPUT_TOKENS || Number(constructorValue) === DEFAULT_MAX_OUTPUT_TOKENS) {
36
+ return validated;
37
+ }
38
+ }
39
+ if (process.env.PROBE_MAX_OUTPUT_TOKENS) {
40
+ return validateTokenLimit(process.env.PROBE_MAX_OUTPUT_TOKENS);
41
+ }
42
+ return DEFAULT_MAX_OUTPUT_TOKENS;
43
+ }
44
+
45
+ /**
46
+ * Truncate tool output if it exceeds the token limit.
47
+ * When truncated, saves full output to a temp file and returns a message with the file path.
48
+ * If file system operations fail, returns truncated content without file reference.
49
+ *
50
+ * @param {string} content - The tool output content to potentially truncate
51
+ * @param {Object} tokenCounter - TokenCounter instance with countTokens method
52
+ * @param {string} sessionId - Session ID for naming temp files
53
+ * @param {number} maxTokens - Maximum tokens allowed (defaults to 20000)
54
+ * @returns {Promise<{truncated: boolean, content: string, tempFilePath?: string, originalTokens?: number, error?: string}>}
55
+ */
56
+ export async function truncateIfNeeded(content, tokenCounter, sessionId, maxTokens) {
57
+ const limit = validateTokenLimit(maxTokens);
58
+ const tokenCount = tokenCounter.countTokens(content);
59
+
60
+ if (tokenCount <= limit) {
61
+ return { truncated: false, content };
62
+ }
63
+
64
+ // Truncate to approximately maxTokens worth of characters
65
+ const maxChars = limit * CHARS_PER_TOKEN;
66
+ const truncatedContent = content.substring(0, maxChars);
67
+
68
+ // Try to write full output to temp file
69
+ let tempFilePath = null;
70
+ let fileError = null;
71
+
72
+ try {
73
+ const tempDir = join(tmpdir(), 'probe-output');
74
+ await mkdir(tempDir, { recursive: true });
75
+ tempFilePath = join(tempDir, `tool-output-${sessionId || 'unknown'}-${randomUUID()}.txt`);
76
+ await writeFile(tempFilePath, content, 'utf8');
77
+ } catch (err) {
78
+ fileError = err.message || 'Unknown file system error';
79
+ tempFilePath = null;
80
+ }
81
+
82
+ let message;
83
+ if (tempFilePath) {
84
+ message = `Output exceeded maximum size (${tokenCount} tokens, limit: ${limit}).
85
+ Full output saved to: ${tempFilePath}
86
+
87
+ --- Truncated Output (first ${limit} tokens approx) ---
88
+ ${truncatedContent}
89
+ ...
90
+ --- End of Truncated Output ---`;
91
+ } else {
92
+ message = `Output exceeded maximum size (${tokenCount} tokens, limit: ${limit}).
93
+ Warning: Could not save full output to file (${fileError}).
94
+
95
+ --- Truncated Output (first ${limit} tokens approx) ---
96
+ ${truncatedContent}
97
+ ...
98
+ --- End of Truncated Output ---`;
99
+ }
100
+
101
+ return {
102
+ truncated: true,
103
+ content: message,
104
+ tempFilePath: tempFilePath || undefined,
105
+ originalTokens: tokenCount,
106
+ error: fileError || undefined
107
+ };
108
+ }
@@ -617,6 +617,37 @@ export function detectUnrecognizedToolCall(xmlString, validTools) {
617
617
  }
618
618
  }
619
619
 
620
+ // Check if any valid tool name appears inside specific wrapper patterns
621
+ // This catches cases where AI wraps tools in arbitrary tags like:
622
+ // <api_call><tool_name>attempt_completion</tool_name>...</api_call>
623
+ // <function>search</function>
624
+ // <call name="extract">...</call>
625
+ // Only match specific wrapper patterns to avoid false positives with normal text
626
+ const allToolNames = [...new Set([...knownToolNames, ...validTools])];
627
+ for (const toolName of allToolNames) {
628
+ // Escape regex metacharacters in tool name to prevent regex errors
629
+ const escapedToolName = toolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
630
+
631
+ // Match specific wrapper patterns that indicate a tool call attempt:
632
+ // 1. <tool_name>toolName</tool_name> - common Claude API-style wrapper
633
+ // 2. <function>toolName</function> - function call style
634
+ // 3. <name>toolName</name> - generic name wrapper
635
+ // 4. <call><name>toolName - partial wrapper patterns
636
+ const wrapperPatterns = [
637
+ new RegExp(`<tool_name>\\s*${escapedToolName}\\s*</tool_name>`, 'i'),
638
+ new RegExp(`<function>\\s*${escapedToolName}\\s*</function>`, 'i'),
639
+ new RegExp(`<name>\\s*${escapedToolName}\\s*</name>`, 'i'),
640
+ // Also check for tool name immediately after api_call or call opening tag
641
+ new RegExp(`<(?:api_call|call)[^>]*>[\\s\\S]*?<tool_name>\\s*${escapedToolName}`, 'i')
642
+ ];
643
+
644
+ for (const pattern of wrapperPatterns) {
645
+ if (pattern.test(xmlString)) {
646
+ return `wrapped_tool:${toolName}`;
647
+ }
648
+ }
649
+ }
650
+
620
651
  return null;
621
652
  }
622
653