@probelabs/probe 0.6.0-rc224 → 0.6.0-rc226

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 (33) hide show
  1. package/bin/binaries/probe-v0.6.0-rc226-aarch64-apple-darwin.tar.gz +0 -0
  2. package/bin/binaries/probe-v0.6.0-rc226-aarch64-unknown-linux-musl.tar.gz +0 -0
  3. package/bin/binaries/probe-v0.6.0-rc226-x86_64-apple-darwin.tar.gz +0 -0
  4. package/bin/binaries/probe-v0.6.0-rc226-x86_64-pc-windows-msvc.zip +0 -0
  5. package/bin/binaries/probe-v0.6.0-rc226-x86_64-unknown-linux-musl.tar.gz +0 -0
  6. package/build/agent/ProbeAgent.js +361 -36
  7. package/build/agent/index.js +570 -57
  8. package/build/agent/mcp/xmlBridge.js +10 -7
  9. package/build/agent/simpleTelemetry.js +198 -0
  10. package/build/agent/tools.js +8 -5
  11. package/build/tools/analyzeAll.js +6 -1
  12. package/build/tools/bash.js +18 -3
  13. package/build/tools/edit.js +19 -10
  14. package/build/tools/vercel.js +17 -7
  15. package/build/utils/path-validation.js +148 -1
  16. package/cjs/agent/ProbeAgent.cjs +392 -56
  17. package/cjs/agent/simpleTelemetry.cjs +177 -0
  18. package/cjs/index.cjs +569 -56
  19. package/package.json +1 -1
  20. package/src/agent/ProbeAgent.js +361 -36
  21. package/src/agent/mcp/xmlBridge.js +10 -7
  22. package/src/agent/simpleTelemetry.js +198 -0
  23. package/src/agent/tools.js +8 -5
  24. package/src/tools/analyzeAll.js +6 -1
  25. package/src/tools/bash.js +18 -3
  26. package/src/tools/edit.js +19 -10
  27. package/src/tools/vercel.js +17 -7
  28. package/src/utils/path-validation.js +148 -1
  29. package/bin/binaries/probe-v0.6.0-rc224-aarch64-apple-darwin.tar.gz +0 -0
  30. package/bin/binaries/probe-v0.6.0-rc224-aarch64-unknown-linux-musl.tar.gz +0 -0
  31. package/bin/binaries/probe-v0.6.0-rc224-x86_64-apple-darwin.tar.gz +0 -0
  32. package/bin/binaries/probe-v0.6.0-rc224-x86_64-pc-windows-msvc.zip +0 -0
  33. package/bin/binaries/probe-v0.6.0-rc224-x86_64-unknown-linux-musl.tar.gz +0 -0
@@ -71,6 +71,7 @@ import { RetryManager, createRetryManagerFromEnv } from './RetryManager.js';
71
71
  import { FallbackManager, createFallbackManagerFromEnv, buildFallbackProvidersFromEnv } from './FallbackManager.js';
72
72
  import { handleContextLimitError } from './contextCompactor.js';
73
73
  import { formatErrorForAI, ParameterError } from '../utils/error-types.js';
74
+ import { getCommonPrefix, toRelativePath, safeRealpath } from '../utils/path-validation.js';
74
75
  import { truncateIfNeeded, getMaxOutputTokens } from './outputTruncator.js';
75
76
  import { DelegationManager } from '../delegate.js';
76
77
  import {
@@ -269,8 +270,15 @@ export class ProbeAgent {
269
270
  this.allowedFolders = [process.cwd()];
270
271
  }
271
272
 
272
- // Working directory for resolving relative paths (separate from allowedFolders security)
273
- this.cwd = options.cwd || null;
273
+ // Compute workspace root as common prefix of all allowed folders
274
+ // This provides a single "root" for relative path resolution and default cwd
275
+ // IMPORTANT: workspaceRoot is NOT a security boundary - all security checks
276
+ // must be performed against this.allowedFolders, not workspaceRoot
277
+ this.workspaceRoot = getCommonPrefix(this.allowedFolders);
278
+
279
+ // Working directory for resolving relative paths
280
+ // If not explicitly provided, use workspace root for consistency
281
+ this.cwd = options.cwd || this.workspaceRoot;
274
282
 
275
283
  // API configuration
276
284
  this.clientApiProvider = options.provider || null;
@@ -289,6 +297,8 @@ export class ProbeAgent {
289
297
  console.log(`[DEBUG] Maximum tool iterations configured: ${MAX_TOOL_ITERATIONS}`);
290
298
  console.log(`[DEBUG] Allow Edit (implement tool): ${this.allowEdit}`);
291
299
  console.log(`[DEBUG] Search delegation enabled: ${this.searchDelegate}`);
300
+ console.log(`[DEBUG] Workspace root: ${this.workspaceRoot}`);
301
+ console.log(`[DEBUG] Working directory (cwd): ${this.cwd}`);
292
302
  }
293
303
 
294
304
  // Initialize tools
@@ -406,6 +416,209 @@ export class ProbeAgent {
406
416
  return mcpToolNames.filter(toolName => this._isMcpToolAllowed(toolName));
407
417
  }
408
418
 
419
+ /**
420
+ * Check if tracer is AppTracer (expects sessionId as first param) vs SimpleAppTracer
421
+ * @returns {boolean} - True if tracer is AppTracer style (requires sessionId)
422
+ * @private
423
+ */
424
+ _isAppTracerStyle() {
425
+ // AppTracer has recordThinkingContent(sessionId, iteration, content) signature
426
+ // SimpleAppTracer has recordThinkingContent(content, metadata) signature
427
+ // We detect by checking if there's a sessionSpans map (AppTracer-specific)
428
+ return this.tracer && typeof this.tracer.sessionSpans !== 'undefined';
429
+ }
430
+
431
+ /**
432
+ * Record an error classification event for telemetry
433
+ * Provides unified error recording across all error types
434
+ * @param {string} errorType - Error type (wrapped_tool, unrecognized_tool, no_tool_call, circuit_breaker)
435
+ * @param {string} message - Error message
436
+ * @param {Object} context - Additional context data
437
+ * @param {number} iteration - Current iteration number
438
+ * @private
439
+ */
440
+ _recordErrorTelemetry(errorType, message, context, iteration) {
441
+ if (!this.tracer) return;
442
+
443
+ if (this._isAppTracerStyle() && typeof this.tracer.recordErrorClassification === 'function') {
444
+ // AppTracer style: (sessionId, iteration, errorType, details)
445
+ this.tracer.recordErrorClassification(this.sessionId, iteration, errorType, {
446
+ message,
447
+ context
448
+ });
449
+ } else if (typeof this.tracer.recordErrorEvent === 'function') {
450
+ // SimpleAppTracer style: (errorType, details)
451
+ this.tracer.recordErrorEvent(errorType, {
452
+ message,
453
+ context: { ...context, iteration }
454
+ });
455
+ } else {
456
+ this.tracer.addEvent(`error.${errorType}`, {
457
+ 'error.type': errorType,
458
+ 'error.message': message,
459
+ 'error.recoverable': errorType !== 'circuit_breaker',
460
+ 'error.context': JSON.stringify(context).substring(0, 1000),
461
+ 'iteration': iteration
462
+ });
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Record AI thinking content for telemetry
468
+ * @param {string} thinkingContent - The thinking content
469
+ * @param {number} iteration - Current iteration number
470
+ * @private
471
+ */
472
+ _recordThinkingTelemetry(thinkingContent, iteration) {
473
+ if (!this.tracer || !thinkingContent) return;
474
+
475
+ if (this._isAppTracerStyle() && typeof this.tracer.recordThinkingContent === 'function') {
476
+ // AppTracer style: (sessionId, iteration, content)
477
+ this.tracer.recordThinkingContent(this.sessionId, iteration, thinkingContent);
478
+ } else if (typeof this.tracer.recordThinkingContent === 'function') {
479
+ // SimpleAppTracer style: (content, metadata)
480
+ this.tracer.recordThinkingContent(thinkingContent, { iteration });
481
+ } else {
482
+ this.tracer.addEvent('ai.thinking', {
483
+ 'ai.thinking.content': thinkingContent.substring(0, 50000),
484
+ 'ai.thinking.length': thinkingContent.length,
485
+ 'iteration': iteration
486
+ });
487
+ }
488
+ }
489
+
490
+ /**
491
+ * Record AI tool decision for telemetry
492
+ * @param {string} toolName - The tool name
493
+ * @param {Object} params - Tool parameters
494
+ * @param {number} responseLength - Length of AI response
495
+ * @param {number} iteration - Current iteration number
496
+ * @private
497
+ */
498
+ _recordToolDecisionTelemetry(toolName, params, responseLength, iteration) {
499
+ if (!this.tracer) return;
500
+
501
+ if (this._isAppTracerStyle() && typeof this.tracer.recordAIToolDecision === 'function') {
502
+ // AppTracer style: (sessionId, iteration, toolName, params)
503
+ this.tracer.recordAIToolDecision(this.sessionId, iteration, toolName, params);
504
+ } else if (typeof this.tracer.recordToolDecision === 'function') {
505
+ // SimpleAppTracer style: (toolName, params, metadata)
506
+ this.tracer.recordToolDecision(toolName, params, {
507
+ iteration,
508
+ 'ai.tool_decision.raw_response_length': responseLength
509
+ });
510
+ } else {
511
+ this.tracer.addEvent('ai.tool_decision', {
512
+ 'ai.tool_decision.name': toolName,
513
+ 'ai.tool_decision.params': JSON.stringify(params || {}).substring(0, 2000),
514
+ 'ai.tool_decision.raw_response_length': responseLength,
515
+ 'iteration': iteration
516
+ });
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Record tool result for telemetry
522
+ * @param {string} toolName - The tool name
523
+ * @param {string|Object} result - Tool result
524
+ * @param {boolean} success - Whether tool succeeded
525
+ * @param {number} durationMs - Execution duration in milliseconds
526
+ * @param {number} iteration - Current iteration number
527
+ * @private
528
+ */
529
+ _recordToolResultTelemetry(toolName, result, success, durationMs, iteration) {
530
+ if (!this.tracer) return;
531
+
532
+ if (this._isAppTracerStyle() && typeof this.tracer.recordToolResult === 'function') {
533
+ // AppTracer style: (sessionId, iteration, toolName, result, success, durationMs)
534
+ this.tracer.recordToolResult(this.sessionId, iteration, toolName, result, success, durationMs);
535
+ } else if (typeof this.tracer.recordToolResult === 'function') {
536
+ // SimpleAppTracer style: (toolName, result, success, durationMs, metadata)
537
+ this.tracer.recordToolResult(toolName, result, success, durationMs, { iteration });
538
+ } else {
539
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result || '');
540
+ this.tracer.addEvent('tool.result', {
541
+ 'tool.name': toolName,
542
+ 'tool.result': resultStr.substring(0, 10000),
543
+ 'tool.result.length': resultStr.length,
544
+ 'tool.duration_ms': durationMs,
545
+ 'tool.success': success,
546
+ 'iteration': iteration
547
+ });
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Record MCP tool lifecycle event for telemetry
553
+ * @param {string} phase - 'start' or 'end'
554
+ * @param {string} toolName - MCP tool name
555
+ * @param {Object} params - Tool parameters (for start) or null (for end)
556
+ * @param {number} iteration - Current iteration number
557
+ * @param {Object} [endData] - Additional data for end phase (result, success, durationMs, error)
558
+ * @private
559
+ */
560
+ _recordMcpToolTelemetry(phase, toolName, params, iteration, endData = null) {
561
+ if (!this.tracer) return;
562
+
563
+ if (phase === 'start') {
564
+ if (this._isAppTracerStyle() && typeof this.tracer.recordMcpToolStart === 'function') {
565
+ // AppTracer style: (sessionId, iteration, toolName, serverName, params)
566
+ this.tracer.recordMcpToolStart(this.sessionId, iteration, toolName, 'mcp', params);
567
+ } else if (typeof this.tracer.recordMcpToolStart === 'function') {
568
+ // SimpleAppTracer style: (toolName, serverName, params, metadata)
569
+ this.tracer.recordMcpToolStart(toolName, 'mcp', params, { iteration });
570
+ } else {
571
+ this.tracer.addEvent('mcp.tool.start', {
572
+ 'mcp.tool.name': toolName,
573
+ 'mcp.tool.server': 'mcp',
574
+ 'mcp.tool.params': JSON.stringify(params || {}).substring(0, 2000),
575
+ 'iteration': iteration
576
+ });
577
+ }
578
+ } else if (phase === 'end' && endData) {
579
+ const { result, success, durationMs, error } = endData;
580
+ if (this._isAppTracerStyle() && typeof this.tracer.recordMcpToolEnd === 'function') {
581
+ // AppTracer style: (sessionId, iteration, toolName, serverName, result, success, durationMs, error)
582
+ this.tracer.recordMcpToolEnd(this.sessionId, iteration, toolName, 'mcp', result, success, durationMs, error);
583
+ } else if (typeof this.tracer.recordMcpToolEnd === 'function') {
584
+ // SimpleAppTracer style: (toolName, serverName, result, success, durationMs, error, metadata)
585
+ this.tracer.recordMcpToolEnd(toolName, 'mcp', result, success, durationMs, error, { iteration });
586
+ } else {
587
+ const resultStr = typeof result === 'string' ? result : JSON.stringify(result || '');
588
+ this.tracer.addEvent('mcp.tool.end', {
589
+ 'mcp.tool.name': toolName,
590
+ 'mcp.tool.server': 'mcp',
591
+ 'mcp.tool.result': resultStr.substring(0, 10000),
592
+ 'mcp.tool.result.length': resultStr.length,
593
+ 'mcp.tool.duration_ms': durationMs,
594
+ 'mcp.tool.success': success,
595
+ 'mcp.tool.error': error,
596
+ 'iteration': iteration
597
+ });
598
+ }
599
+ }
600
+ }
601
+
602
+ /**
603
+ * Record iteration lifecycle event for telemetry
604
+ * @param {string} phase - 'end' (start is already handled elsewhere)
605
+ * @param {number} iteration - Current iteration number
606
+ * @param {Object} data - Additional iteration data
607
+ * @private
608
+ */
609
+ _recordIterationTelemetry(phase, iteration, data = {}) {
610
+ if (!this.tracer) return;
611
+
612
+ if (typeof this.tracer.recordIterationEvent === 'function') {
613
+ this.tracer.recordIterationEvent(phase, iteration, data);
614
+ } else {
615
+ this.tracer.addEvent(`iteration.${phase}`, {
616
+ 'iteration': iteration,
617
+ ...data
618
+ });
619
+ }
620
+ }
621
+
409
622
  /**
410
623
  * Initialize the agent asynchronously (must be called after constructor)
411
624
  * This method initializes MCP and merges MCP tools into the tool list, and loads history from storage
@@ -529,8 +742,9 @@ export class ProbeAgent {
529
742
  const configOptions = {
530
743
  sessionId: this.sessionId,
531
744
  debug: this.debug,
532
- // Use explicit cwd if set, otherwise fall back to first allowed folder
533
- cwd: this.cwd || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd()),
745
+ // Use cwd (which defaults to workspaceRoot in constructor)
746
+ cwd: this.cwd,
747
+ workspaceRoot: this.workspaceRoot,
534
748
  allowedFolders: this.allowedFolders,
535
749
  outline: this.outline,
536
750
  searchDelegate: this.searchDelegate,
@@ -1409,7 +1623,8 @@ export class ProbeAgent {
1409
1623
  }
1410
1624
 
1411
1625
  // Security validation: check if path is within any allowed directory
1412
- // Use normalize() after resolve() to handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1626
+ // Use safeRealpath() to resolve symlinks and handle path traversal attempts (e.g., '/allowed/../etc/passwd')
1627
+ // This prevents symlink bypass attacks (e.g., /tmp -> /private/tmp on macOS)
1413
1628
  const allowedDirs = this.allowedFolders && this.allowedFolders.length > 0 ? this.allowedFolders : [process.cwd()];
1414
1629
 
1415
1630
  let absolutePath;
@@ -1417,20 +1632,20 @@ export class ProbeAgent {
1417
1632
 
1418
1633
  // If absolute path, check if it's within any allowed directory
1419
1634
  if (isAbsolute(imagePath)) {
1420
- // Normalize to resolve any '..' sequences
1421
- absolutePath = normalize(resolve(imagePath));
1635
+ // Use safeRealpath to resolve symlinks for security
1636
+ absolutePath = safeRealpath(resolve(imagePath));
1422
1637
  isPathAllowed = allowedDirs.some(dir => {
1423
- const normalizedDir = normalize(resolve(dir));
1638
+ const resolvedDir = safeRealpath(dir);
1424
1639
  // Ensure the path is within the allowed directory (add separator to prevent prefix attacks)
1425
- return absolutePath === normalizedDir || absolutePath.startsWith(normalizedDir + sep);
1640
+ return absolutePath === resolvedDir || absolutePath.startsWith(resolvedDir + sep);
1426
1641
  });
1427
1642
  } else {
1428
1643
  // For relative paths, try resolving against each allowed directory
1429
1644
  for (const dir of allowedDirs) {
1430
- const normalizedDir = normalize(resolve(dir));
1431
- const resolvedPath = normalize(resolve(dir, imagePath));
1645
+ const resolvedDir = safeRealpath(dir);
1646
+ const resolvedPath = safeRealpath(resolve(dir, imagePath));
1432
1647
  // Ensure the resolved path is within the allowed directory
1433
- if (resolvedPath === normalizedDir || resolvedPath.startsWith(normalizedDir + sep)) {
1648
+ if (resolvedPath === resolvedDir || resolvedPath.startsWith(resolvedDir + sep)) {
1434
1649
  absolutePath = resolvedPath;
1435
1650
  isPathAllowed = true;
1436
1651
  break;
@@ -1667,7 +1882,8 @@ export class ProbeAgent {
1667
1882
  return this.architectureContext;
1668
1883
  }
1669
1884
 
1670
- const rootDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
1885
+ // Use workspaceRoot for consistent path handling
1886
+ const rootDirectory = this.workspaceRoot || (this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd());
1671
1887
  const configuredName =
1672
1888
  typeof this.architectureFileName === 'string' ? this.architectureFileName.trim() : '';
1673
1889
  const hasConfiguredName = !!configuredName;
@@ -1825,6 +2041,10 @@ export class ProbeAgent {
1825
2041
  }
1826
2042
 
1827
2043
  _getSkillsRepoRoot() {
2044
+ // Use workspaceRoot for consistent path handling
2045
+ if (this.workspaceRoot) {
2046
+ return resolve(this.workspaceRoot);
2047
+ }
1828
2048
  if (this.allowedFolders && this.allowedFolders.length > 0) {
1829
2049
  return resolve(this.allowedFolders[0]);
1830
2050
  }
@@ -1905,7 +2125,7 @@ ${extractGuidance}
1905
2125
  // Add repository structure if available
1906
2126
  if (this.fileList) {
1907
2127
  systemPrompt += `\n\n# Repository Structure\n`;
1908
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2128
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
1909
2129
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
1910
2130
  systemPrompt += '```\n' + this.fileList + '\n```\n';
1911
2131
  }
@@ -1967,7 +2187,7 @@ ${extractGuidance}
1967
2187
  // Add repository structure if available
1968
2188
  if (this.fileList) {
1969
2189
  systemPrompt += `\n\n# Repository Structure\n`;
1970
- systemPrompt += `You are working with a repository located at: ${this.allowedFolders[0]}\n\n`;
2190
+ systemPrompt += `You are working with a repository located at: ${this.workspaceRoot}\n\n`;
1971
2191
  systemPrompt += `Here's an overview of the repository structure (showing up to 100 most relevant files):\n\n`;
1972
2192
  systemPrompt += '```\n' + this.fileList + '\n```\n';
1973
2193
  }
@@ -2281,10 +2501,29 @@ Follow these instructions carefully:
2281
2501
  }
2282
2502
  }
2283
2503
 
2284
- // Add folder information
2285
- const searchDirectory = this.allowedFolders.length > 0 ? this.allowedFolders[0] : process.cwd();
2504
+ // Add folder information using workspace root and relative paths
2505
+ const searchDirectory = this.workspaceRoot;
2286
2506
  if (this.debug) {
2287
- console.log(`[DEBUG] Generating file list for base directory: ${searchDirectory}...`);
2507
+ console.log(`[DEBUG] Generating file list for workspace root: ${searchDirectory}...`);
2508
+ }
2509
+
2510
+ // Convert allowed folders to relative paths for cleaner AI context
2511
+ // Add ./ prefix to make it clear these are relative paths
2512
+ const relativeWorkspaces = this.allowedFolders.map(f => {
2513
+ const rel = toRelativePath(f, this.workspaceRoot);
2514
+ // Add ./ prefix if not already starting with . and not an absolute path
2515
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2516
+ return './' + rel;
2517
+ }
2518
+ return rel;
2519
+ }).filter(f => f && f !== '.');
2520
+
2521
+ // Describe available paths in a user-friendly way
2522
+ let workspaceDesc;
2523
+ if (relativeWorkspaces.length === 0) {
2524
+ workspaceDesc = '. (current directory)';
2525
+ } else {
2526
+ workspaceDesc = relativeWorkspaces.join(', ');
2288
2527
  }
2289
2528
 
2290
2529
  try {
@@ -2292,15 +2531,15 @@ Follow these instructions carefully:
2292
2531
  directory: searchDirectory,
2293
2532
  maxFiles: 100,
2294
2533
  respectGitignore: !process.env.PROBE_NO_GITIGNORE || process.env.PROBE_NO_GITIGNORE === '',
2295
- cwd: process.cwd()
2534
+ cwd: this.workspaceRoot
2296
2535
  });
2297
2536
 
2298
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2537
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\nHere's an overview of the repository structure (showing up to 100 most relevant files):\n\n\`\`\`\n${files}\n\`\`\`\n\n`;
2299
2538
  } catch (error) {
2300
2539
  if (this.debug) {
2301
2540
  console.log(`[DEBUG] Could not generate file list: ${error.message}`);
2302
2541
  }
2303
- systemMessage += `\n# Repository Structure\n\nYou are working with a repository located at: ${searchDirectory}\n\n`;
2542
+ systemMessage += `\n# Repository Structure\n\nYou are working with a workspace. Available paths: ${workspaceDesc}\n\n`;
2304
2543
  }
2305
2544
 
2306
2545
  // Add architecture context if available
@@ -2308,7 +2547,15 @@ Follow these instructions carefully:
2308
2547
  systemMessage += this.getArchitectureSection();
2309
2548
 
2310
2549
  if (this.allowedFolders.length > 0) {
2311
- systemMessage += `\n**Important**: For security reasons, you can only search within these allowed folders: ${this.allowedFolders.join(', ')}\n\n`;
2550
+ const relativeAllowed = this.allowedFolders.map(f => {
2551
+ const rel = toRelativePath(f, this.workspaceRoot);
2552
+ // Add ./ prefix if not already starting with . and not an absolute path
2553
+ if (rel && rel !== '.' && !rel.startsWith('.') && !rel.startsWith('/')) {
2554
+ return './' + rel;
2555
+ }
2556
+ return rel;
2557
+ });
2558
+ systemMessage += `\n**Important**: For security reasons, you can only access these paths: ${relativeAllowed.join(', ')}\n\n`;
2312
2559
  }
2313
2560
 
2314
2561
  return systemMessage;
@@ -2854,8 +3101,18 @@ Follow these instructions carefully:
2854
3101
  const parsedTool = (this.mcpBridge && !options._disableTools)
2855
3102
  ? parseHybridXmlToolCall(assistantResponseContent, nativeTools, this.mcpBridge)
2856
3103
  : parseXmlToolCallWithThinking(assistantResponseContent, validTools);
3104
+
3105
+ // Capture AI thinking content if present (for debugging and telemetry)
3106
+ if (parsedTool?.thinkingContent) {
3107
+ this._recordThinkingTelemetry(parsedTool.thinkingContent, currentIteration);
3108
+ }
3109
+
2857
3110
  if (parsedTool) {
2858
3111
  const { toolName, params } = parsedTool;
3112
+
3113
+ // Record AI tool decision for telemetry
3114
+ this._recordToolDecisionTelemetry(toolName, params, assistantResponseContent.length, currentIteration);
3115
+
2859
3116
  if (this.debug) console.log(`[DEBUG] Parsed tool call: ${toolName} with params:`, params);
2860
3117
 
2861
3118
  if (toolName === 'attempt_completion') {
@@ -2962,6 +3219,9 @@ Follow these instructions carefully:
2962
3219
 
2963
3220
  if (type === 'mcp' && this.mcpBridge && this.mcpBridge.isMcpTool(toolName)) {
2964
3221
  // Execute MCP tool
3222
+ const mcpStartTime = Date.now();
3223
+ this._recordMcpToolTelemetry('start', toolName, params, currentIteration);
3224
+
2965
3225
  try {
2966
3226
  // Log MCP tool execution in debug mode
2967
3227
  if (this.debug) {
@@ -2999,6 +3259,15 @@ Follow these instructions carefully:
2999
3259
  console.error(`[WARN] Tool output truncation failed: ${truncateError.message}`);
3000
3260
  }
3001
3261
 
3262
+ // Record MCP tool end event (success)
3263
+ const mcpDurationMs = Date.now() - mcpStartTime;
3264
+ this._recordMcpToolTelemetry('end', toolName, null, currentIteration, {
3265
+ result: toolResultContent,
3266
+ success: true,
3267
+ durationMs: mcpDurationMs,
3268
+ error: null
3269
+ });
3270
+
3002
3271
  // Log MCP tool result in debug mode
3003
3272
  if (this.debug) {
3004
3273
  const preview = toolResultContent.length > 500 ? toolResultContent.substring(0, 500) + '...' : toolResultContent;
@@ -3009,8 +3278,19 @@ Follow these instructions carefully:
3009
3278
  console.error(`[DEBUG] ========================================\n`);
3010
3279
  }
3011
3280
 
3281
+ // Add assistant message with tool call (matching native tool pattern)
3282
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3012
3283
  currentMessages.push({ role: 'user', content: `<tool_result>\n${toolResultContent}\n</tool_result>` });
3013
3284
  } catch (error) {
3285
+ // Record MCP tool end event (failure)
3286
+ const mcpDurationMs = Date.now() - mcpStartTime;
3287
+ this._recordMcpToolTelemetry('end', toolName, null, currentIteration, {
3288
+ result: null,
3289
+ success: false,
3290
+ durationMs: mcpDurationMs,
3291
+ error: error.message
3292
+ });
3293
+
3014
3294
  console.error(`Error executing MCP tool ${toolName}:`, error);
3015
3295
 
3016
3296
  // Log MCP tool error in debug mode
@@ -3023,24 +3303,27 @@ Follow these instructions carefully:
3023
3303
 
3024
3304
  // Format error with structured information for AI
3025
3305
  const errorXml = formatErrorForAI(error);
3306
+ // Add assistant message with tool call (matching native tool pattern)
3307
+ currentMessages.push({ role: 'assistant', content: assistantResponseContent });
3026
3308
  currentMessages.push({ role: 'user', content: `<tool_result>\n${errorXml}\n</tool_result>` });
3027
3309
  }
3028
3310
  } else if (this.toolImplementations[toolName]) {
3029
3311
  // Execute native tool
3030
3312
  try {
3031
3313
  // Add sessionId and workingDirectory to params for tool execution
3032
- // Validate and resolve workingDirectory
3033
- // Priority: explicit cwd > first allowed folder > process.cwd()
3034
- let resolvedWorkingDirectory = this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3314
+ // Validate and resolve workingDirectory using safeRealpath for symlink security
3315
+ // Consistent fallback chain: workspaceRoot > cwd > allowedFolders[0] > process.cwd()
3316
+ let resolvedWorkingDirectory = this.workspaceRoot || this.cwd || (this.allowedFolders && this.allowedFolders[0]) || process.cwd();
3035
3317
  if (params.workingDirectory) {
3036
3318
  // Resolve relative paths against the current working directory context, not process.cwd()
3037
- const requestedDir = isAbsolute(params.workingDirectory)
3319
+ // Use safeRealpath to resolve symlinks and prevent bypass attacks
3320
+ const requestedDir = safeRealpath(isAbsolute(params.workingDirectory)
3038
3321
  ? resolve(params.workingDirectory)
3039
- : resolve(resolvedWorkingDirectory, params.workingDirectory);
3322
+ : resolve(resolvedWorkingDirectory, params.workingDirectory));
3040
3323
  // Check if the requested directory is within allowed folders
3041
3324
  const isWithinAllowed = !this.allowedFolders || this.allowedFolders.length === 0 ||
3042
3325
  this.allowedFolders.some(folder => {
3043
- const resolvedFolder = resolve(folder);
3326
+ const resolvedFolder = safeRealpath(folder);
3044
3327
  return requestedDir === resolvedFolder || requestedDir.startsWith(resolvedFolder + sep);
3045
3328
  });
3046
3329
  if (isWithinAllowed) {
@@ -3118,6 +3401,7 @@ Follow these instructions carefully:
3118
3401
  };
3119
3402
 
3120
3403
  let toolResult;
3404
+ const toolStartTime = Date.now();
3121
3405
  try {
3122
3406
  if (this.tracer) {
3123
3407
  toolResult = await this.tracer.withSpan('tool.call', executeToolCall, {
@@ -3128,7 +3412,11 @@ Follow these instructions carefully:
3128
3412
  } else {
3129
3413
  toolResult = await executeToolCall();
3130
3414
  }
3131
-
3415
+
3416
+ // Record tool result in telemetry
3417
+ const toolDurationMs = Date.now() - toolStartTime;
3418
+ this._recordToolResultTelemetry(toolName, toolResult, true, toolDurationMs, currentIteration);
3419
+
3132
3420
  // Log tool result in debug mode
3133
3421
  if (this.debug) {
3134
3422
  const resultPreview = typeof toolResult === 'string'
@@ -3201,6 +3489,22 @@ Follow these instructions carefully:
3201
3489
  content: toolResultMessage
3202
3490
  });
3203
3491
 
3492
+ // Record conversation turns in telemetry
3493
+ if (this.tracer) {
3494
+ if (typeof this.tracer.recordConversationTurn === 'function') {
3495
+ this.tracer.recordConversationTurn('assistant', assistantResponseContent, {
3496
+ iteration: currentIteration,
3497
+ has_tool_call: true,
3498
+ tool_name: toolName
3499
+ });
3500
+ this.tracer.recordConversationTurn('tool_result', toolResultContent, {
3501
+ iteration: currentIteration,
3502
+ tool_name: toolName,
3503
+ tool_success: true
3504
+ });
3505
+ }
3506
+ }
3507
+
3204
3508
  // NOTE: Automatic image processing removed (GitHub issue #305)
3205
3509
  // Images are now only loaded when the AI explicitly calls the readImage tool
3206
3510
  // This prevents: 1) implicit behavior that users don't expect
@@ -3294,6 +3598,10 @@ Follow these instructions carefully:
3294
3598
  if (this.debug) {
3295
3599
  console.log(`[DEBUG] Detected wrapped tool '${wrappedToolName}' in assistant response - wrong XML format.`);
3296
3600
  }
3601
+
3602
+ // Record wrapped tool error in telemetry
3603
+ this._recordErrorTelemetry('wrapped_tool', 'Tool call wrapped in markdown', { toolName: wrappedToolName }, currentIteration);
3604
+
3297
3605
  const toolError = new ParameterError(
3298
3606
  `Tool '${wrappedToolName}' found but in WRONG FORMAT - do not wrap tools in other XML tags.`,
3299
3607
  {
@@ -3318,12 +3626,19 @@ Remove ALL wrapper tags and use <${wrappedToolName}> directly as the outermost t
3318
3626
  if (this.debug) {
3319
3627
  console.log(`[DEBUG] Detected unrecognized tool '${unrecognizedTool}' in assistant response.`);
3320
3628
  }
3629
+
3630
+ // Record unrecognized tool error in telemetry
3631
+ this._recordErrorTelemetry('unrecognized_tool', `Unknown tool: ${unrecognizedTool}`, { toolName: unrecognizedTool, validTools }, currentIteration);
3632
+
3321
3633
  const toolError = new ParameterError(`Tool '${unrecognizedTool}' is not available in this context.`, {
3322
3634
  suggestion: `Available tools: ${validTools.join(', ')}. Please use one of these tools instead.`
3323
3635
  });
3324
3636
  reminderContent = `<tool_result>\n${formatErrorForAI(toolError)}\n</tool_result>`;
3325
3637
  } else {
3326
- // No tool call detected at all - check if this is the last iteration
3638
+ // No tool call detected at all - record in telemetry
3639
+ this._recordErrorTelemetry('no_tool_call', 'AI response did not contain tool call', { responsePreview: assistantResponseContent.substring(0, 500) }, currentIteration);
3640
+
3641
+ // Check if this is the last iteration
3327
3642
  // On the last iteration, if the AI gave a substantive response without using
3328
3643
  // attempt_completion, accept it as the final answer rather than losing the content
3329
3644
  if (currentIteration >= maxIterations) {
@@ -3439,6 +3754,10 @@ Note: <attempt_complete></attempt_complete> reuses your PREVIOUS assistant messa
3439
3754
  sameFormatErrorCount++;
3440
3755
  if (sameFormatErrorCount >= MAX_REPEATED_FORMAT_ERRORS) {
3441
3756
  const errorDesc = isWrapped ? 'wrapped tool format' : unrecognizedTool;
3757
+
3758
+ // Record circuit breaker error in telemetry
3759
+ this._recordErrorTelemetry('circuit_breaker', 'Format error limit exceeded', { formatErrorCount: sameFormatErrorCount, errorCategory }, currentIteration);
3760
+
3442
3761
  console.error(`[ERROR] Format error category '${errorCategory}' repeated ${sameFormatErrorCount} times. Breaking loop early to prevent infinite iteration.`);
3443
3762
  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.`;
3444
3763
  break;
@@ -3454,13 +3773,19 @@ Note: <attempt_complete></attempt_complete> reuses your PREVIOUS assistant messa
3454
3773
  }
3455
3774
  }
3456
3775
 
3776
+ // Record iteration end event
3777
+ this._recordIterationTelemetry('end', currentIteration, {
3778
+ 'iteration.completed': completionAttempted,
3779
+ 'iteration.message_count': currentMessages.length
3780
+ });
3781
+
3457
3782
  // Keep message history manageable
3458
3783
  if (currentMessages.length > MAX_HISTORY_MESSAGES) {
3459
3784
  const messagesBefore = currentMessages.length;
3460
3785
  const systemMsg = currentMessages[0]; // Keep system message
3461
3786
  const recentMessages = currentMessages.slice(-MAX_HISTORY_MESSAGES + 1);
3462
3787
  currentMessages = [systemMsg, ...recentMessages];
3463
-
3788
+
3464
3789
  if (this.debug) {
3465
3790
  console.log(`[DEBUG] Trimmed message history from ${messagesBefore} to ${currentMessages.length} messages`);
3466
3791
  }
@@ -3611,7 +3936,7 @@ Convert your previous response content into actual JSON data that follows this s
3611
3936
 
3612
3937
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
3613
3938
  debug: this.debug,
3614
- path: this.allowedFolders[0],
3939
+ path: this.workspaceRoot || this.allowedFolders[0],
3615
3940
  provider: this.clientApiProvider,
3616
3941
  model: this.model,
3617
3942
  tracer: this.tracer
@@ -3701,7 +4026,7 @@ Convert your previous response content into actual JSON data that follows this s
3701
4026
 
3702
4027
  const { JsonFixingAgent } = await import('./schemaUtils.js');
3703
4028
  const jsonFixer = new JsonFixingAgent({
3704
- path: this.allowedFolders[0],
4029
+ path: this.workspaceRoot || this.allowedFolders[0],
3705
4030
  provider: this.clientApiProvider,
3706
4031
  model: this.model,
3707
4032
  debug: this.debug,
@@ -3789,7 +4114,7 @@ Convert your previous response content into actual JSON data that follows this s
3789
4114
 
3790
4115
  const mermaidValidation = await validateAndFixMermaidResponse(finalResult, {
3791
4116
  debug: this.debug,
3792
- path: this.allowedFolders[0],
4117
+ path: this.workspaceRoot || this.allowedFolders[0],
3793
4118
  provider: this.clientApiProvider,
3794
4119
  model: this.model,
3795
4120
  tracer: this.tracer
@@ -3945,7 +4270,7 @@ Convert your previous response content into actual JSON data that follows this s
3945
4270
 
3946
4271
  const finalMermaidValidation = await validateAndFixMermaidResponse(finalResult, {
3947
4272
  debug: this.debug,
3948
- path: this.allowedFolders[0],
4273
+ path: this.workspaceRoot || this.allowedFolders[0],
3949
4274
  provider: this.clientApiProvider,
3950
4275
  model: this.model,
3951
4276
  tracer: this.tracer
@@ -4143,7 +4468,7 @@ Convert your previous response content into actual JSON data that follows this s
4143
4468
  allowEdit: this.allowEdit,
4144
4469
  enableDelegate: this.enableDelegate,
4145
4470
  architectureFileName: this.architectureFileName,
4146
- path: this.allowedFolders[0], // Use first allowed folder as primary path
4471
+ // Pass allowedFolders which will recompute workspaceRoot correctly
4147
4472
  allowedFolders: [...this.allowedFolders],
4148
4473
  cwd: this.cwd, // Preserve explicit working directory
4149
4474
  provider: this.clientApiProvider,