@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.
@@ -9059,6 +9059,22 @@ function detectUnrecognizedToolCall(xmlString, validTools) {
9059
9059
  return toolName;
9060
9060
  }
9061
9061
  }
9062
+ const allToolNames = [.../* @__PURE__ */ new Set([...knownToolNames, ...validTools])];
9063
+ for (const toolName of allToolNames) {
9064
+ const escapedToolName = toolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9065
+ const wrapperPatterns = [
9066
+ new RegExp(`<tool_name>\\s*${escapedToolName}\\s*</tool_name>`, "i"),
9067
+ new RegExp(`<function>\\s*${escapedToolName}\\s*</function>`, "i"),
9068
+ new RegExp(`<name>\\s*${escapedToolName}\\s*</name>`, "i"),
9069
+ // Also check for tool name immediately after api_call or call opening tag
9070
+ new RegExp(`<(?:api_call|call)[^>]*>[\\s\\S]*?<tool_name>\\s*${escapedToolName}`, "i")
9071
+ ];
9072
+ for (const pattern of wrapperPatterns) {
9073
+ if (pattern.test(xmlString)) {
9074
+ return `wrapped_tool:${toolName}`;
9075
+ }
9076
+ }
9077
+ }
9062
9078
  return null;
9063
9079
  }
9064
9080
  function parseTargets(targets) {
@@ -9718,10 +9734,10 @@ var init_vercel = __esm({
9718
9734
  let extractOptions = { cwd: effectiveCwd };
9719
9735
  if (input_content) {
9720
9736
  const { writeFileSync: writeFileSync2, unlinkSync } = await import("fs");
9721
- const { join: join4 } = await import("path");
9722
- const { tmpdir } = await import("os");
9723
- const { randomUUID: randomUUID8 } = await import("crypto");
9724
- tempFilePath = join4(tmpdir(), `probe-extract-${randomUUID8()}.txt`);
9737
+ const { join: join5 } = await import("path");
9738
+ const { tmpdir: tmpdir2 } = await import("os");
9739
+ const { randomUUID: randomUUID9 } = await import("crypto");
9740
+ tempFilePath = join5(tmpdir2(), `probe-extract-${randomUUID9()}.txt`);
9725
9741
  writeFileSync2(tempFilePath, input_content);
9726
9742
  if (debug) {
9727
9743
  console.error(`Created temporary file for input content: ${tempFilePath}`);
@@ -67117,10 +67133,88 @@ var init_contextCompactor = __esm({
67117
67133
  }
67118
67134
  });
67119
67135
 
67136
+ // src/agent/outputTruncator.js
67137
+ import { writeFile, mkdir } from "fs/promises";
67138
+ import { tmpdir } from "os";
67139
+ import { join as join4 } from "path";
67140
+ import { randomUUID as randomUUID4 } from "crypto";
67141
+ function validateTokenLimit(value) {
67142
+ const num = Number(value);
67143
+ if (isNaN(num) || num <= 0) {
67144
+ return DEFAULT_MAX_OUTPUT_TOKENS;
67145
+ }
67146
+ return num;
67147
+ }
67148
+ function getMaxOutputTokens(constructorValue) {
67149
+ if (constructorValue !== void 0 && constructorValue !== null) {
67150
+ const validated = validateTokenLimit(constructorValue);
67151
+ if (validated !== DEFAULT_MAX_OUTPUT_TOKENS || Number(constructorValue) === DEFAULT_MAX_OUTPUT_TOKENS) {
67152
+ return validated;
67153
+ }
67154
+ }
67155
+ if (process.env.PROBE_MAX_OUTPUT_TOKENS) {
67156
+ return validateTokenLimit(process.env.PROBE_MAX_OUTPUT_TOKENS);
67157
+ }
67158
+ return DEFAULT_MAX_OUTPUT_TOKENS;
67159
+ }
67160
+ async function truncateIfNeeded(content, tokenCounter, sessionId, maxTokens) {
67161
+ const limit = validateTokenLimit(maxTokens);
67162
+ const tokenCount = tokenCounter.countTokens(content);
67163
+ if (tokenCount <= limit) {
67164
+ return { truncated: false, content };
67165
+ }
67166
+ const maxChars = limit * CHARS_PER_TOKEN;
67167
+ const truncatedContent = content.substring(0, maxChars);
67168
+ let tempFilePath = null;
67169
+ let fileError = null;
67170
+ try {
67171
+ const tempDir = join4(tmpdir(), "probe-output");
67172
+ await mkdir(tempDir, { recursive: true });
67173
+ tempFilePath = join4(tempDir, `tool-output-${sessionId || "unknown"}-${randomUUID4()}.txt`);
67174
+ await writeFile(tempFilePath, content, "utf8");
67175
+ } catch (err) {
67176
+ fileError = err.message || "Unknown file system error";
67177
+ tempFilePath = null;
67178
+ }
67179
+ let message;
67180
+ if (tempFilePath) {
67181
+ message = `Output exceeded maximum size (${tokenCount} tokens, limit: ${limit}).
67182
+ Full output saved to: ${tempFilePath}
67183
+
67184
+ --- Truncated Output (first ${limit} tokens approx) ---
67185
+ ${truncatedContent}
67186
+ ...
67187
+ --- End of Truncated Output ---`;
67188
+ } else {
67189
+ message = `Output exceeded maximum size (${tokenCount} tokens, limit: ${limit}).
67190
+ Warning: Could not save full output to file (${fileError}).
67191
+
67192
+ --- Truncated Output (first ${limit} tokens approx) ---
67193
+ ${truncatedContent}
67194
+ ...
67195
+ --- End of Truncated Output ---`;
67196
+ }
67197
+ return {
67198
+ truncated: true,
67199
+ content: message,
67200
+ tempFilePath: tempFilePath || void 0,
67201
+ originalTokens: tokenCount,
67202
+ error: fileError || void 0
67203
+ };
67204
+ }
67205
+ var DEFAULT_MAX_OUTPUT_TOKENS, CHARS_PER_TOKEN;
67206
+ var init_outputTruncator = __esm({
67207
+ "src/agent/outputTruncator.js"() {
67208
+ "use strict";
67209
+ DEFAULT_MAX_OUTPUT_TOKENS = 2e4;
67210
+ CHARS_PER_TOKEN = 4;
67211
+ }
67212
+ });
67213
+
67120
67214
  // src/agent/mcp/built-in-server.js
67121
67215
  import { createServer } from "http";
67122
67216
  import { EventEmitter as EventEmitter3 } from "events";
67123
- import { randomUUID as randomUUID4 } from "crypto";
67217
+ import { randomUUID as randomUUID5 } from "crypto";
67124
67218
  import { Server as MCPServer } from "@modelcontextprotocol/sdk/server/index.js";
67125
67219
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
67126
67220
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -67372,7 +67466,7 @@ var init_built_in_server = __esm({
67372
67466
  }
67373
67467
  const eventStore = new InMemoryEventStore();
67374
67468
  transport = new StreamableHTTPServerTransport({
67375
- sessionIdGenerator: () => randomUUID4(),
67469
+ sessionIdGenerator: () => randomUUID5(),
67376
67470
  eventStore,
67377
67471
  // Enable resumability
67378
67472
  onsessioninitialized: (newSessionId) => {
@@ -68593,11 +68687,32 @@ import { createOpenAI as createOpenAI2 } from "@ai-sdk/openai";
68593
68687
  import { createGoogleGenerativeAI as createGoogleGenerativeAI2 } from "@ai-sdk/google";
68594
68688
  import { createAmazonBedrock as createAmazonBedrock2 } from "@ai-sdk/amazon-bedrock";
68595
68689
  import { streamText as streamText2 } from "ai";
68596
- import { randomUUID as randomUUID5 } from "crypto";
68690
+ import { randomUUID as randomUUID6 } from "crypto";
68597
68691
  import { EventEmitter as EventEmitter5 } from "events";
68598
68692
  import { existsSync as existsSync6 } from "fs";
68599
68693
  import { readFile as readFile3, stat, readdir as readdir3 } from "fs/promises";
68600
68694
  import { resolve as resolve6, isAbsolute as isAbsolute5, dirname as dirname5, basename, normalize as normalize2, sep as sep5 } from "path";
68695
+ function extractWrappedToolName(wrappedToolError) {
68696
+ if (!wrappedToolError || typeof wrappedToolError !== "string") {
68697
+ return "unknown";
68698
+ }
68699
+ const colonIndex = wrappedToolError.indexOf(":");
68700
+ return colonIndex !== -1 ? wrappedToolError.slice(colonIndex + 1) : "unknown";
68701
+ }
68702
+ function isWrappedToolError(error) {
68703
+ return error && typeof error === "string" && error.startsWith("wrapped_tool:");
68704
+ }
68705
+ function createWrappedToolErrorMessage(wrappedToolName) {
68706
+ return `Your response contained an incorrectly formatted tool call (${wrappedToolName} wrapped in XML tags). This cannot be used.
68707
+
68708
+ Please use the CORRECT format:
68709
+
68710
+ <${wrappedToolName}>
68711
+ Your content here
68712
+ </${wrappedToolName}>
68713
+
68714
+ Do NOT wrap in other tags like <api_call>, <tool_name>, <function>, etc.`;
68715
+ }
68601
68716
  var MAX_TOOL_ITERATIONS, MAX_HISTORY_MESSAGES, MAX_IMAGE_FILE_SIZE, ProbeAgent;
68602
68717
  var init_ProbeAgent = __esm({
68603
68718
  "src/agent/ProbeAgent.js"() {
@@ -68622,6 +68737,7 @@ var init_ProbeAgent = __esm({
68622
68737
  init_FallbackManager();
68623
68738
  init_contextCompactor();
68624
68739
  init_error_types();
68740
+ init_outputTruncator();
68625
68741
  init_tasks();
68626
68742
  dotenv2.config();
68627
68743
  MAX_TOOL_ITERATIONS = (() => {
@@ -68681,9 +68797,10 @@ var init_ProbeAgent = __esm({
68681
68797
  * @param {boolean} [options.fallback.stopOnSuccess=true] - Stop on first success
68682
68798
  * @param {number} [options.fallback.maxTotalAttempts=10] - Maximum total attempts across all providers
68683
68799
  * @param {string} [options.completionPrompt] - Custom prompt to run after attempt_completion for validation/review (runs before mermaid/JSON validation)
68800
+ * @param {number} [options.maxOutputTokens] - Maximum tokens for tool output before truncation (default: 20000, can also be set via PROBE_MAX_OUTPUT_TOKENS env var)
68684
68801
  */
68685
68802
  constructor(options = {}) {
68686
- this.sessionId = options.sessionId || randomUUID5();
68803
+ this.sessionId = options.sessionId || randomUUID6();
68687
68804
  this.customPrompt = options.systemPrompt || options.customPrompt || null;
68688
68805
  this.promptType = options.promptType || "code-explorer";
68689
68806
  this.allowEdit = !!options.allowEdit;
@@ -68742,6 +68859,7 @@ var init_ProbeAgent = __esm({
68742
68859
  this.clientApiKey = null;
68743
68860
  this.clientApiUrl = null;
68744
68861
  this.tokenCounter = new TokenCounter();
68862
+ this.maxOutputTokens = getMaxOutputTokens(options.maxOutputTokens);
68745
68863
  if (this.debug) {
68746
68864
  console.log(`[DEBUG] Generated session ID for agent: ${this.sessionId}`);
68747
68865
  console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
@@ -70594,6 +70712,9 @@ You are working with a repository located at: ${searchDirectory}
70594
70712
  console.log(`[DEBUG] Schema provided, using extended iteration limit: ${maxIterations} (base: ${baseMaxIterations})`);
70595
70713
  }
70596
70714
  }
70715
+ let lastFormatErrorType = null;
70716
+ let sameFormatErrorCount = 0;
70717
+ const MAX_REPEATED_FORMAT_ERRORS = 3;
70597
70718
  while (currentIteration < maxIterations && !completionAttempted) {
70598
70719
  currentIteration++;
70599
70720
  if (this.cancelled) throw new Error("Request was cancelled by the user");
@@ -70792,7 +70913,22 @@ You are working with a repository located at: ${searchDirectory}
70792
70913
  (msg) => msg.role === "assistant" && msg.content && !(this.mcpBridge ? parseHybridXmlToolCall(msg.content, validTools, this.mcpBridge) : parseXmlToolCallWithThinking(msg.content, validTools))
70793
70914
  );
70794
70915
  if (lastAssistantMessage) {
70795
- finalResult = lastAssistantMessage.content;
70916
+ const prevContent = lastAssistantMessage.content;
70917
+ const wrappedToolError = detectUnrecognizedToolCall(prevContent, validTools);
70918
+ if (isWrappedToolError(wrappedToolError)) {
70919
+ const wrappedToolName = extractWrappedToolName(wrappedToolError);
70920
+ if (this.debug) {
70921
+ console.log(`[DEBUG] Previous response contains wrapped tool '${wrappedToolName}' - rejecting for __PREVIOUS_RESPONSE__`);
70922
+ }
70923
+ currentMessages.push({ role: "assistant", content: assistantResponseContent });
70924
+ currentMessages.push({
70925
+ role: "user",
70926
+ content: createWrappedToolErrorMessage(wrappedToolName)
70927
+ });
70928
+ completionAttempted = false;
70929
+ continue;
70930
+ }
70931
+ finalResult = prevContent;
70796
70932
  if (this.debug) console.log(`[DEBUG] Using previous response as completion: ${finalResult.substring(0, 100)}...`);
70797
70933
  } else {
70798
70934
  finalResult = "Error: No previous response found to use as completion.";
@@ -70833,7 +70969,21 @@ You are working with a repository located at: ${searchDirectory}
70833
70969
  `);
70834
70970
  }
70835
70971
  const executionResult = await this.mcpBridge.mcpTools[toolName].execute(params);
70836
- const toolResultContent = typeof executionResult === "string" ? executionResult : JSON.stringify(executionResult, null, 2);
70972
+ let toolResultContent = typeof executionResult === "string" ? executionResult : JSON.stringify(executionResult, null, 2);
70973
+ try {
70974
+ const truncateResult = await truncateIfNeeded(toolResultContent, this.tokenCounter, this.sessionId, this.maxOutputTokens);
70975
+ if (truncateResult.truncated) {
70976
+ toolResultContent = truncateResult.content;
70977
+ if (this.debug) {
70978
+ console.log(`[DEBUG] Tool output truncated: ${truncateResult.originalTokens} tokens -> saved to ${truncateResult.tempFilePath || "N/A"}`);
70979
+ if (truncateResult.error) {
70980
+ console.log(`[DEBUG] Truncation file error: ${truncateResult.error}`);
70981
+ }
70982
+ }
70983
+ }
70984
+ } catch (truncateError) {
70985
+ console.error(`[WARN] Tool output truncation failed: ${truncateError.message}`);
70986
+ }
70837
70987
  if (this.debug) {
70838
70988
  const preview = toolResultContent.length > 500 ? toolResultContent.substring(0, 500) + "..." : toolResultContent;
70839
70989
  console.error(`[DEBUG] ========================================`);
@@ -70982,7 +71132,21 @@ ${errorXml}
70982
71132
  throw toolError;
70983
71133
  }
70984
71134
  currentMessages.push({ role: "assistant", content: assistantResponseContent });
70985
- const toolResultContent = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult, null, 2);
71135
+ let toolResultContent = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult, null, 2);
71136
+ try {
71137
+ const truncateResult = await truncateIfNeeded(toolResultContent, this.tokenCounter, this.sessionId, this.maxOutputTokens);
71138
+ if (truncateResult.truncated) {
71139
+ toolResultContent = truncateResult.content;
71140
+ if (this.debug) {
71141
+ console.log(`[DEBUG] Tool output truncated: ${truncateResult.originalTokens} tokens -> saved to ${truncateResult.tempFilePath || "N/A"}`);
71142
+ if (truncateResult.error) {
71143
+ console.log(`[DEBUG] Truncation file error: ${truncateResult.error}`);
71144
+ }
71145
+ }
71146
+ }
71147
+ } catch (truncateError) {
71148
+ console.error(`[WARN] Tool output truncation failed: ${truncateError.message}`);
71149
+ }
70986
71150
  const toolResultMessage = `<tool_result>
70987
71151
  ${toolResultContent}
70988
71152
  </tool_result>`;
@@ -71035,7 +71199,33 @@ ${errorXml}
71035
71199
  currentMessages.push({ role: "assistant", content: assistantResponseContent });
71036
71200
  const unrecognizedTool = detectUnrecognizedToolCall(assistantResponseContent, validTools);
71037
71201
  let reminderContent;
71038
- if (unrecognizedTool) {
71202
+ if (isWrappedToolError(unrecognizedTool)) {
71203
+ const wrappedToolName = extractWrappedToolName(unrecognizedTool);
71204
+ if (this.debug) {
71205
+ console.log(`[DEBUG] Detected wrapped tool '${wrappedToolName}' in assistant response - wrong XML format.`);
71206
+ }
71207
+ const toolError = new ParameterError(
71208
+ `Tool '${wrappedToolName}' found but in WRONG FORMAT - do not wrap tools in other XML tags.`,
71209
+ {
71210
+ suggestion: `Use the tool tag DIRECTLY without any wrapper:
71211
+
71212
+ CORRECT FORMAT:
71213
+ <${wrappedToolName}>
71214
+ <param>value</param>
71215
+ </${wrappedToolName}>
71216
+
71217
+ WRONG (what you did - do not wrap in other tags):
71218
+ <api_call><tool_name>${wrappedToolName}</tool_name>...</api_call>
71219
+ <function>${wrappedToolName}</function>
71220
+ <call name="${wrappedToolName}">...</call>
71221
+
71222
+ Remove ALL wrapper tags and use <${wrappedToolName}> directly as the outermost tag.`
71223
+ }
71224
+ );
71225
+ reminderContent = `<tool_result>
71226
+ ${formatErrorForAI(toolError)}
71227
+ </tool_result>`;
71228
+ } else if (unrecognizedTool) {
71039
71229
  if (this.debug) {
71040
71230
  console.log(`[DEBUG] Detected unrecognized tool '${unrecognizedTool}' in assistant response.`);
71041
71231
  }
@@ -71046,6 +71236,20 @@ ${errorXml}
71046
71236
  ${formatErrorForAI(toolError)}
71047
71237
  </tool_result>`;
71048
71238
  } else {
71239
+ if (currentIteration >= maxIterations) {
71240
+ let cleanedResponse = assistantResponseContent;
71241
+ cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*?<\/thinking>/gi, "").trim();
71242
+ cleanedResponse = cleanedResponse.replace(/<thinking>[\s\S]*$/gi, "").trim();
71243
+ const hasSubstantialContent = cleanedResponse.length > 50 && !cleanedResponse.includes("<api_call>") && !cleanedResponse.includes("<tool_name>") && !cleanedResponse.includes("<function>");
71244
+ if (hasSubstantialContent) {
71245
+ if (this.debug) {
71246
+ console.log(`[DEBUG] Max iterations reached - accepting AI response as final answer (${cleanedResponse.length} chars)`);
71247
+ }
71248
+ finalResult = cleanedResponse;
71249
+ completionAttempted = true;
71250
+ break;
71251
+ }
71252
+ }
71049
71253
  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.
71050
71254
 
71051
71255
  Remember: Use proper XML format with BOTH opening and closing tags:
@@ -71075,6 +71279,25 @@ Note: <attempt_complete></attempt_complete> reuses your PREVIOUS assistant messa
71075
71279
  console.log(`[DEBUG] No tool call detected in assistant response. Prompting for tool use.`);
71076
71280
  }
71077
71281
  }
71282
+ if (unrecognizedTool) {
71283
+ const isWrapped = isWrappedToolError(unrecognizedTool);
71284
+ const errorCategory = isWrapped ? "wrapped_tool" : unrecognizedTool;
71285
+ if (errorCategory === lastFormatErrorType) {
71286
+ sameFormatErrorCount++;
71287
+ if (sameFormatErrorCount >= MAX_REPEATED_FORMAT_ERRORS) {
71288
+ const errorDesc = isWrapped ? "wrapped tool format" : unrecognizedTool;
71289
+ console.error(`[ERROR] Format error category '${errorCategory}' repeated ${sameFormatErrorCount} times. Breaking loop early to prevent infinite iteration.`);
71290
+ 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.`;
71291
+ break;
71292
+ }
71293
+ } else {
71294
+ lastFormatErrorType = errorCategory;
71295
+ sameFormatErrorCount = 1;
71296
+ }
71297
+ } else {
71298
+ lastFormatErrorType = null;
71299
+ sameFormatErrorCount = 0;
71300
+ }
71078
71301
  }
71079
71302
  if (currentMessages.length > MAX_HISTORY_MESSAGES) {
71080
71303
  const messagesBefore = currentMessages.length;
@@ -71591,7 +71814,7 @@ Convert your previous response content into actual JSON data that follows this s
71591
71814
  */
71592
71815
  clone(options = {}) {
71593
71816
  const {
71594
- sessionId = randomUUID5(),
71817
+ sessionId = randomUUID6(),
71595
71818
  stripInternalMessages = true,
71596
71819
  keepSystemMessage = true,
71597
71820
  deepCopy = true,
@@ -71818,7 +72041,7 @@ import { readFileSync as readFileSync2, existsSync as existsSync7 } from "fs";
71818
72041
  import { resolve as resolve7 } from "path";
71819
72042
 
71820
72043
  // src/agent/acp/server.js
71821
- import { randomUUID as randomUUID6 } from "crypto";
72044
+ import { randomUUID as randomUUID7 } from "crypto";
71822
72045
 
71823
72046
  // src/agent/acp/connection.js
71824
72047
  import { EventEmitter as EventEmitter6 } from "events";
@@ -72310,7 +72533,7 @@ var ACPServer = class {
72310
72533
  * Handle new session request
72311
72534
  */
72312
72535
  async handleNewSession(params) {
72313
- const sessionId = params?.sessionId || randomUUID6();
72536
+ const sessionId = params?.sessionId || randomUUID7();
72314
72537
  const mode = params?.mode || SessionMode.NORMAL;
72315
72538
  const session = new ACPSession(sessionId, mode);
72316
72539
  this.sessions.set(sessionId, session);
@@ -72482,7 +72705,7 @@ var ACPServer = class {
72482
72705
  };
72483
72706
 
72484
72707
  // src/agent/acp/tools.js
72485
- import { randomUUID as randomUUID7 } from "crypto";
72708
+ import { randomUUID as randomUUID8 } from "crypto";
72486
72709
 
72487
72710
  // src/agent/index.js
72488
72711
  dotenv3.config();
@@ -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