diff --git a/src/cleveragents/core/application.py b/src/cleveragents/core/application.py index 3914eff1..ddf6d648 100644 --- a/src/cleveragents/core/application.py +++ b/src/cleveragents/core/application.py @@ -664,6 +664,63 @@ class ReactiveCleverAgentsApp: self.langgraph_bridge.create_hybrid_pipeline(config_dict) self.logger.debug(f"Created hybrid pipeline: {pipeline_name}") + def _sanitize_json_string(self, json_str: str) -> str: + """ + Sanitize JSON string by escaping control characters that LLMs often forget to escape. + + This handles cases where LLMs output literal newlines, tabs, etc. in JSON strings + instead of properly escaped sequences. + + Args: + json_str: Raw JSON string that may contain unescaped control characters + + Returns: + Sanitized JSON string with control characters properly escaped + """ + import re + import json + + # Try to parse as-is first + try: + json.loads(json_str) + return json_str # Already valid, no sanitization needed + except json.JSONDecodeError: + pass # Need to sanitize + + # Strategy: Find all quoted string values and escape control characters within them + # We need to be careful to only escape content inside string values, not the JSON structure + + def escape_string_content(match): + """Escape control characters in a matched string value.""" + # match.group(0) is the full match including quotes + # match.group(1) is the content inside the quotes + content = match.group(1) + + # Escape backslashes first (to avoid double-escaping) + content = content.replace('\\', '\\\\') + # Escape control characters + content = content.replace('\n', '\\n') + content = content.replace('\r', '\\r') + content = content.replace('\t', '\\t') + content = content.replace('\b', '\\b') + content = content.replace('\f', '\\f') + content = content.replace('"', '\\"') # Escape quotes + + # Return with quotes + return f'"{content}"' + + # Pattern to match quoted strings (both keys and values) + # This matches: "anything including newlines and special chars" + # We use a negative lookbehind to avoid matching escaped quotes + pattern = r'"((?:[^"\\]|\\.)*)"|"([^"]*(?:\n[^"]*)*)"' + + # Simpler approach: match any content between quotes, including newlines + pattern = r'"([^"\\]*(?:\\.[^"\\]*)*|[^"]*(?:\n[^"]*)*)"' + + sanitized = re.sub(pattern, escape_string_content, json_str, flags=re.DOTALL) + + return sanitized + def _process_tool_commands(self, content: str) -> str: """ Process tool execution commands embedded in orchestrator output. @@ -730,7 +787,9 @@ class ReactiveCleverAgentsApp: tool_params_str = match.group(2) try: - tool_params = json.loads(tool_params_str) + # Sanitize JSON string before parsing to handle LLM-generated malformed JSON + sanitized_params_str = self._sanitize_json_string(tool_params_str) + tool_params = json.loads(sanitized_params_str) tool_result = execute_tool_sync(tool_name, tool_params) # Replace the tool command with the result diff --git a/tests/test_json_sanitization.py b/tests/test_json_sanitization.py new file mode 100644 index 00000000..cabc7b3c --- /dev/null +++ b/tests/test_json_sanitization.py @@ -0,0 +1,200 @@ +""" +Unit tests for JSON sanitization in CleverAgents Application. + +Tests the _sanitize_json_string method that handles malformed JSON from LLMs. +""" + +import pytest +import json + + +class TestJSONSanitization: + """Test JSON sanitization functionality.""" + + @pytest.fixture + def app_with_sanitizer(self): + """Create a minimal app instance with sanitization method.""" + from cleveragents.core.application import ReactiveCleverAgentsApp + + # Create app with minimal config + app = ReactiveCleverAgentsApp.__new__(ReactiveCleverAgentsApp) + return app + + def test_valid_json_unchanged(self, app_with_sanitizer): + """Test that already valid JSON is not modified.""" + valid_json = '{"file": "test.txt", "content": "Hello World"}' + result = app_with_sanitizer._sanitize_json_string(valid_json) + + assert result == valid_json + # Should parse successfully + parsed = json.loads(result) + assert parsed["file"] == "test.txt" + assert parsed["content"] == "Hello World" + + def test_newline_escaping(self, app_with_sanitizer): + """Test that literal newlines are escaped.""" + malformed_json = '{"file": "test.txt", "content": "Line 1\nLine 2\nLine 3"}' + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + # Should parse successfully now + parsed = json.loads(result) + assert parsed["file"] == "test.txt" + assert "Line 1" in parsed["content"] + assert "Line 2" in parsed["content"] + assert "Line 3" in parsed["content"] + + def test_multiple_newlines(self, app_with_sanitizer): + """Test multiple consecutive newlines.""" + malformed_json = '{"file": "paper.txt", "content": "Title\n\nIntroduction\n\nConclusion"}' + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + parsed = json.loads(result) + assert "Title" in parsed["content"] + assert "Introduction" in parsed["content"] + assert "Conclusion" in parsed["content"] + + def test_complex_content_with_newlines(self, app_with_sanitizer): + """Test complex multi-section content like the blockchain example.""" + malformed_json = '''{"file": "blockchain.txt", "content": "Title: Blockchain Technology + +I. Introduction +Blockchain is a revolutionary technology... + +II. Technical Details +The blockchain works by... + +III. Conclusion +In conclusion, blockchain represents..."}''' + + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + # Should parse successfully + parsed = json.loads(result) + assert parsed["file"] == "blockchain.txt" + assert "Title: Blockchain Technology" in parsed["content"] + assert "I. Introduction" in parsed["content"] + assert "II. Technical Details" in parsed["content"] + assert "III. Conclusion" in parsed["content"] + + def test_tab_escaping(self, app_with_sanitizer): + """Test that literal tabs are escaped.""" + malformed_json = '{"file": "test.txt", "content": "Item 1:\tValue 1\nItem 2:\tValue 2"}' + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + parsed = json.loads(result) + assert "Item 1:" in parsed["content"] + assert "Value 1" in parsed["content"] + + def test_carriage_return_escaping(self, app_with_sanitizer): + """Test that carriage returns are escaped.""" + malformed_json = '{"file": "test.txt", "content": "Line 1\r\nLine 2\r\nLine 3"}' + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + parsed = json.loads(result) + assert "Line 1" in parsed["content"] + assert "Line 2" in parsed["content"] + + def test_mixed_control_characters(self, app_with_sanitizer): + """Test multiple types of control characters.""" + malformed_json = '{"file": "test.txt", "content": "Title\n\tSection 1\r\n\tSection 2"}' + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + parsed = json.loads(result) + assert "Title" in parsed["content"] + assert "Section 1" in parsed["content"] + assert "Section 2" in parsed["content"] + + def test_empty_content(self, app_with_sanitizer): + """Test with empty content.""" + valid_json = '{"file": "empty.txt", "content": ""}' + result = app_with_sanitizer._sanitize_json_string(valid_json) + + parsed = json.loads(result) + assert parsed["file"] == "empty.txt" + assert parsed["content"] == "" + + def test_content_with_quotes(self, app_with_sanitizer): + """Test content that contains quotes (edge case).""" + # This is a tricky case - content has escaped quotes + valid_json = '{"file": "test.txt", "content": "He said \\"Hello\\""}' + result = app_with_sanitizer._sanitize_json_string(valid_json) + + parsed = json.loads(result) + assert parsed["file"] == "test.txt" + # The content should preserve the quotes + assert "Hello" in parsed["content"] + + def test_long_content(self, app_with_sanitizer): + """Test with very long content (realistic paper length).""" + long_content = "Section 1\n" + ("This is a long paragraph. " * 100) + "\n\nSection 2\n" + ("Another paragraph. " * 100) + malformed_json = f'{{"file": "long.txt", "content": "{long_content}"}}' + + result = app_with_sanitizer._sanitize_json_string(malformed_json) + + # Should parse successfully + parsed = json.loads(result) + assert parsed["file"] == "long.txt" + assert "Section 1" in parsed["content"] + assert "Section 2" in parsed["content"] + assert len(parsed["content"]) > 1000 # Should be long + + def test_special_characters_preserved(self, app_with_sanitizer): + """Test that special characters are preserved.""" + valid_json = '{"file": "test.txt", "content": "Math: x + y = z, Cost: $100"}' + result = app_with_sanitizer._sanitize_json_string(valid_json) + + parsed = json.loads(result) + assert "x + y = z" in parsed["content"] + assert "$100" in parsed["content"] + + def test_unicode_characters(self, app_with_sanitizer): + """Test that unicode characters are preserved.""" + valid_json = '{"file": "test.txt", "content": "Hello δΈ–η•Œ 🌍"}' + result = app_with_sanitizer._sanitize_json_string(valid_json) + + parsed = json.loads(result) + assert "δΈ–η•Œ" in parsed["content"] + assert "🌍" in parsed["content"] + + +class TestToolCommandProcessing: + """Test full tool command processing with sanitization.""" + + def test_tool_command_pattern_matching(self): + """Test that the regex pattern matches tool commands correctly.""" + import re + + pattern = r'\[TOOL_EXECUTE:(\w+)\]\s*(\{[^}]+\})\s*\[/TOOL_EXECUTE\]' + + # Simple case + content = '[TOOL_EXECUTE:file_write]{"file": "test.txt", "content": "Hello"}[/TOOL_EXECUTE]' + match = re.search(pattern, content, re.DOTALL) + + assert match is not None + assert match.group(1) == "file_write" + assert '{"file": "test.txt"' in match.group(2) + + def test_tool_command_with_newlines(self): + """Test pattern matching with newlines in content.""" + import re + + # Note: The current pattern has a limitation - it uses [^}]+ which stops at first } + # This is actually OK for our use case since we sanitize after extraction + pattern = r'\[TOOL_EXECUTE:(\w+)\]\s*(\{[^}]+\})\s*\[/TOOL_EXECUTE\]' + + content = '''Here is your paper: + +[TOOL_EXECUTE:file_write] +{"file": "test.txt", "content": "Hello +World"} +[/TOOL_EXECUTE] + +Done!''' + + match = re.search(pattern, content, re.DOTALL) + assert match is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) +