@probelabs/probe 0.6.0-rc255 → 0.6.0-rc256

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.
@@ -623,9 +623,9 @@ class ProbeAgentMcpServer {
623
623
  // Retry once with correction prompt
624
624
  const correctionPrompt = createJsonCorrectionPrompt(result, schema, validation.error);
625
625
  try {
626
- result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true });
626
+ result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 });
627
627
  result = cleanSchemaResponse(result);
628
-
628
+
629
629
  // Validate again after correction
630
630
  const finalValidation = validateJsonResponse(result);
631
631
  if (!finalValidation.isValid && args.debug) {
@@ -971,11 +971,11 @@ async function main() {
971
971
  try {
972
972
  if (appTracer) {
973
973
  result = await appTracer.withSpan('agent.json_correction',
974
- () => agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true }),
974
+ () => agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 }),
975
975
  { 'original_error': validation.error }
976
976
  );
977
977
  } else {
978
- result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true });
978
+ result = await agent.answer(correctionPrompt, [], { schema, _schemaFormatted: true, _disableTools: true, _maxIterationsOverride: 3 });
979
979
  }
980
980
  result = cleanSchemaResponse(result);
981
981
 
@@ -6,6 +6,7 @@
6
6
  import { MCPClientManager } from './client.js';
7
7
  import { loadMCPConfiguration } from './config.js';
8
8
  import { processXmlWithThinkingAndRecovery } from '../xmlParsingUtils.js';
9
+ import { unescapeXmlEntities } from '../../tools/common.js';
9
10
 
10
11
  /**
11
12
  * Convert MCP tool to XML definition format
@@ -111,7 +112,7 @@ export function parseXmlMcpToolCall(xmlString, mcpToolNames = []) {
111
112
  let match;
112
113
  while ((match = paramPattern.exec(content)) !== null) {
113
114
  const [, paramName, paramValue] = match;
114
- params[paramName] = paramValue.trim();
115
+ params[paramName] = unescapeXmlEntities(paramValue.trim());
115
116
  }
116
117
  }
117
118
 
@@ -393,7 +394,7 @@ function parseNativeXmlTool(xmlString, toolName) {
393
394
  const [, paramName, paramValue] = match;
394
395
  // Skip if this is the params tag itself (MCP format)
395
396
  if (paramName !== 'params') {
396
- params[paramName] = paramValue.trim();
397
+ params[paramName] = unescapeXmlEntities(paramValue.trim());
397
398
  }
398
399
  }
399
400
 
@@ -603,6 +603,17 @@ export function validateJsonResponse(response, options = {}) {
603
603
  }
604
604
  }
605
605
 
606
+ // Quick recovery: try to extract a valid JSON prefix before returning error.
607
+ // This handles the common case where AI returns valid JSON followed by markdown content,
608
+ // e.g., "Unexpected non-whitespace character after JSON at position 477" (issue #447).
609
+ const prefixResult = tryExtractValidJsonPrefix(responseToValidate, { schema, debug });
610
+ if (prefixResult && prefixResult.isValid) {
611
+ if (debug) {
612
+ console.log(`[DEBUG] JSON validation: Recovered valid JSON prefix (${prefixResult.extracted.length} chars) from response with trailing content`);
613
+ }
614
+ return { isValid: true, parsed: prefixResult.parsed };
615
+ }
616
+
606
617
  // Create enhanced error message with context snippet
607
618
  let enhancedError = error.message;
608
619
  let errorContext = null;
@@ -667,6 +678,122 @@ ${pointer} here`;
667
678
  }
668
679
  }
669
680
 
681
+ /**
682
+ * Try to extract a valid JSON prefix from a string that has trailing non-JSON content.
683
+ * This handles the common case where an AI returns valid JSON followed by markdown text.
684
+ *
685
+ * Uses bracket-matching to find the end of the first complete JSON object/array,
686
+ * then validates the prefix with JSON.parse(). Optionally validates against a schema.
687
+ *
688
+ * @param {string} response - The full response string
689
+ * @param {Object} [options] - Options
690
+ * @param {Object|string} [options.schema] - JSON schema to validate against
691
+ * @param {boolean} [options.debug=false] - Enable debug logging
692
+ * @returns {Object|null} - {isValid: true, parsed: Object, extracted: string} or null if no valid prefix found
693
+ */
694
+ export function tryExtractValidJsonPrefix(response, options = {}) {
695
+ const { schema = null, debug = false } = options;
696
+
697
+ if (!response || typeof response !== 'string') {
698
+ return null;
699
+ }
700
+
701
+ const trimmed = response.trim();
702
+ if (trimmed.length === 0) {
703
+ return null;
704
+ }
705
+
706
+ // Must start with { or [
707
+ const firstChar = trimmed[0];
708
+ if (firstChar !== '{' && firstChar !== '[') {
709
+ return null;
710
+ }
711
+
712
+ // First, check if the full string already parses - no need to extract prefix
713
+ try {
714
+ JSON.parse(trimmed);
715
+ return null; // Full string is valid JSON, no extraction needed
716
+ } catch {
717
+ // Expected - continue with prefix extraction
718
+ }
719
+
720
+ // Find the end of the first complete JSON object/array using bracket matching
721
+ const openChar = firstChar;
722
+ const closeChar = openChar === '{' ? '}' : ']';
723
+ let depth = 0;
724
+ let inString = false;
725
+ let escapeNext = false;
726
+ let endPos = -1;
727
+
728
+ for (let i = 0; i < trimmed.length; i++) {
729
+ const char = trimmed[i];
730
+
731
+ if (escapeNext) {
732
+ escapeNext = false;
733
+ continue;
734
+ }
735
+
736
+ if (char === '\\' && inString) {
737
+ escapeNext = true;
738
+ continue;
739
+ }
740
+
741
+ if (char === '"') {
742
+ inString = !inString;
743
+ continue;
744
+ }
745
+
746
+ if (inString) {
747
+ continue;
748
+ }
749
+
750
+ if (char === openChar) {
751
+ depth++;
752
+ } else if (char === closeChar) {
753
+ depth--;
754
+ if (depth === 0) {
755
+ endPos = i + 1;
756
+ break;
757
+ }
758
+ }
759
+ }
760
+
761
+ if (endPos <= 0 || endPos >= trimmed.length) {
762
+ return null; // No complete JSON found, or JSON spans the entire string
763
+ }
764
+
765
+ // Check that there's actual non-whitespace trailing content
766
+ const remainder = trimmed.substring(endPos).trim();
767
+ if (remainder.length === 0) {
768
+ return null; // Only whitespace after JSON, no trailing content issue
769
+ }
770
+
771
+ // Try to parse the prefix
772
+ const prefix = trimmed.substring(0, endPos);
773
+ try {
774
+ const parsed = JSON.parse(prefix);
775
+
776
+ if (debug) {
777
+ console.log(`[DEBUG] tryExtractValidJsonPrefix: Extracted valid JSON prefix (${prefix.length} chars), stripped trailing content (${remainder.length} chars)`);
778
+ }
779
+
780
+ // If schema provided, validate against it
781
+ if (schema) {
782
+ const schemaValidation = validateJsonResponse(prefix, { debug, schema });
783
+ if (!schemaValidation.isValid) {
784
+ if (debug) {
785
+ console.log(`[DEBUG] tryExtractValidJsonPrefix: Prefix is valid JSON but fails schema validation: ${schemaValidation.error}`);
786
+ }
787
+ return null;
788
+ }
789
+ }
790
+
791
+ return { isValid: true, parsed, extracted: prefix };
792
+ } catch {
793
+ return null;
794
+ }
795
+ }
796
+
670
797
  /**
671
798
  * Validate that the cleaned response is valid XML if expected
672
799
  * @param {string} response - Cleaned response
@@ -521,6 +521,23 @@ function getValidParamsForTool(toolName) {
521
521
  return [];
522
522
  }
523
523
 
524
+ /**
525
+ * Unescape standard XML entities in a string value.
526
+ * Order matters: &amp; must be decoded LAST to avoid double-decoding
527
+ * (e.g., &amp;lt; should become &lt;, not <).
528
+ * @param {string} str - The string to unescape
529
+ * @returns {string} The unescaped string
530
+ */
531
+ export function unescapeXmlEntities(str) {
532
+ if (typeof str !== 'string') return str;
533
+ return str
534
+ .replace(/&lt;/g, '<')
535
+ .replace(/&gt;/g, '>')
536
+ .replace(/&quot;/g, '"')
537
+ .replace(/&apos;/g, "'")
538
+ .replace(/&amp;/g, '&');
539
+ }
540
+
524
541
  // Simple XML parser helper - safer string-based approach
525
542
  export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
526
543
  // Find the tool that appears EARLIEST in the string
@@ -609,10 +626,10 @@ export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
609
626
  paramCloseIndex = nextTagIndex;
610
627
  }
611
628
 
612
- let paramValue = innerContent.substring(
629
+ let paramValue = unescapeXmlEntities(innerContent.substring(
613
630
  paramOpenIndex + paramOpenTag.length,
614
631
  paramCloseIndex
615
- ).trim();
632
+ ).trim());
616
633
 
617
634
  // Basic type inference (can be improved)
618
635
  if (paramValue.toLowerCase() === 'true') {
@@ -633,7 +650,7 @@ export function parseXmlToolCall(xmlString, validTools = DEFAULT_VALID_TOOLS) {
633
650
 
634
651
  // Special handling for attempt_completion - use entire inner content as result
635
652
  if (toolName === 'attempt_completion') {
636
- params['result'] = innerContent.trim();
653
+ params['result'] = unescapeXmlEntities(innerContent.trim());
637
654
  // Remove command parameter if it was parsed by generic logic above (legacy compatibility)
638
655
  if (params.command) {
639
656
  delete params.command;