fix: prevent parser poisoning from inline tool-call JSON patterns
When the streaming parser encountered fragments of JSON that looked like
partial tool calls (e.g., {"tool":) embedded in inline text (like code
examples or prose), it would incorrectly enter JSON parsing mode and
poison the parser state, causing control to be returned to the user
mid-task.
This fix:
- Adds sanitize_inline_tool_patterns() to detect tool-call patterns that
are NOT on their own line and replace the opening brace with a Unicode
homoglyph (fullwidth left curly bracket U+FF5B)
- Integrates sanitization into process_chunk() before text is buffered
- Updates system prompts to instruct LLMs to use homoglyphs when showing
example tool call JSON in prose
- Adds comprehensive tests for the sanitization logic
Real tool calls from LLMs always appear on their own line, so those are
left untouched. Only inline patterns (with non-whitespace before them)
are sanitized.
This commit is contained in:
@@ -138,6 +138,7 @@ After discovering where WebDriver tools live:
|
|||||||
- Use Markdown formatting for all responses except tool calls.
|
- Use Markdown formatting for all responses except tool calls.
|
||||||
- Whenever taking actions, use the pronoun 'I'
|
- Whenever taking actions, use the pronoun 'I'
|
||||||
- When you discover features, patterns and code locations, call `remember` to save them.
|
- When you discover features, patterns and code locations, call `remember` to save them.
|
||||||
|
- When showing example tool call JSON in prose or code blocks, use the fullwidth left curly bracket `{` (U+FF5B) instead of `{` to prevent parser confusion.
|
||||||
";
|
";
|
||||||
|
|
||||||
pub const SYSTEM_PROMPT_FOR_NATIVE_TOOL_USE: &'static str = SYSTEM_NATIVE_TOOL_CALLS;
|
pub const SYSTEM_PROMPT_FOR_NATIVE_TOOL_USE: &'static str = SYSTEM_NATIVE_TOOL_CALLS;
|
||||||
@@ -337,6 +338,7 @@ Do NOT save duplicates - check the Project Memory section (loaded at startup) to
|
|||||||
- Use Markdown formatting for all responses except tool calls.
|
- Use Markdown formatting for all responses except tool calls.
|
||||||
- Whenever taking actions, use the pronoun 'I'
|
- Whenever taking actions, use the pronoun 'I'
|
||||||
- After discovering code locations via search tools, call `remember` to save them.
|
- After discovering code locations via search tools, call `remember` to save them.
|
||||||
|
- When showing example tool call JSON in prose or code blocks, use the fullwidth left curly bracket `{` (U+FF5B) instead of `{` to prevent parser confusion.
|
||||||
";
|
";
|
||||||
|
|
||||||
pub const SYSTEM_PROMPT_FOR_NON_NATIVE_TOOL_USE: &'static str = SYSTEM_NON_NATIVE_TOOL_USE;
|
pub const SYSTEM_PROMPT_FOR_NON_NATIVE_TOOL_USE: &'static str = SYSTEM_NON_NATIVE_TOOL_USE;
|
||||||
|
|||||||
@@ -16,6 +16,62 @@ const TOOL_CALL_PATTERNS: [&str; 4] = [
|
|||||||
r#"{ "tool" :"#,
|
r#"{ "tool" :"#,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Unicode homoglyph for left curly brace, used to sanitize inline tool-call-like patterns.
|
||||||
|
/// This is the "FULLWIDTH LEFT CURLY BRACKET" (U+FF5B).
|
||||||
|
pub const LBRACE_HOMOGLYPH: char = '{';
|
||||||
|
|
||||||
|
/// Sanitize text content by replacing tool-call-like patterns that appear inline
|
||||||
|
/// (not on their own line) with homoglyphs to prevent parser poisoning.
|
||||||
|
///
|
||||||
|
/// Real tool calls from LLMs always appear on their own line. When we see patterns
|
||||||
|
/// like `{"tool":` embedded within other text (e.g., in code examples, inline code,
|
||||||
|
/// or prose), we replace the opening brace with a homoglyph to prevent the streaming
|
||||||
|
/// parser from incorrectly entering JSON parsing mode.
|
||||||
|
///
|
||||||
|
/// This function is called on each chunk of streamed content before it's added to
|
||||||
|
/// the text buffer.
|
||||||
|
pub fn sanitize_inline_tool_patterns(text: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(text.len());
|
||||||
|
let lines: Vec<&str> = text.split('\n').collect();
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this line starts with a tool call pattern (after trimming whitespace)
|
||||||
|
let trimmed = line.trim_start();
|
||||||
|
let is_standalone_tool_call = TOOL_CALL_PATTERNS.iter().any(|p| trimmed.starts_with(p));
|
||||||
|
|
||||||
|
if is_standalone_tool_call {
|
||||||
|
// This looks like a real tool call on its own line - leave it alone
|
||||||
|
result.push_str(line);
|
||||||
|
} else {
|
||||||
|
// Check if there are any tool call patterns embedded in this line
|
||||||
|
let mut line_result = line.to_string();
|
||||||
|
for pattern in &TOOL_CALL_PATTERNS {
|
||||||
|
// Find all occurrences of the pattern in this line
|
||||||
|
let mut search_start = 0;
|
||||||
|
while let Some(pos) = line_result[search_start..].find(pattern) {
|
||||||
|
let abs_pos = search_start + pos;
|
||||||
|
// Check if this pattern is at the start of the trimmed line
|
||||||
|
// (which we already handled above)
|
||||||
|
let before = &line_result[..abs_pos];
|
||||||
|
if !before.trim().is_empty() {
|
||||||
|
// There's non-whitespace before this pattern - it's inline, sanitize it
|
||||||
|
let replacement = format!("{}{}", LBRACE_HOMOGLYPH, &pattern[1..]);
|
||||||
|
line_result = format!("{}{}{}", &line_result[..abs_pos], replacement, &line_result[abs_pos + pattern.len()..]);
|
||||||
|
}
|
||||||
|
search_start = abs_pos + pattern.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push_str(&line_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Modern streaming tool parser that properly handles native tool calls and SSE chunks.
|
/// Modern streaming tool parser that properly handles native tool calls and SSE chunks.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct StreamingToolParser {
|
pub struct StreamingToolParser {
|
||||||
@@ -101,9 +157,11 @@ impl StreamingToolParser {
|
|||||||
pub fn process_chunk(&mut self, chunk: &g3_providers::CompletionChunk) -> Vec<ToolCall> {
|
pub fn process_chunk(&mut self, chunk: &g3_providers::CompletionChunk) -> Vec<ToolCall> {
|
||||||
let mut completed_tools = Vec::new();
|
let mut completed_tools = Vec::new();
|
||||||
|
|
||||||
// Add text content to buffer
|
// Add text content to buffer after sanitizing inline tool-call patterns
|
||||||
|
// to prevent parser poisoning from examples/code blocks
|
||||||
if !chunk.content.is_empty() {
|
if !chunk.content.is_empty() {
|
||||||
self.text_buffer.push_str(&chunk.content);
|
let sanitized = sanitize_inline_tool_patterns(&chunk.content);
|
||||||
|
self.text_buffer.push_str(&sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle native tool calls - return them immediately when received.
|
// Handle native tool calls - return them immediately when received.
|
||||||
@@ -459,6 +517,7 @@ Some text after"#;
|
|||||||
"Second tool call should have command 'second', got {:?}", tools[1].args);
|
"Second tool call should have command 'second', got {:?}", tools[1].args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_find_first_vs_last_tool_call() {
|
fn test_find_first_vs_last_tool_call() {
|
||||||
let text = r#"{"tool": "first"} and {"tool": "second"}"#;
|
let text = r#"{"tool": "first"} and {"tool": "second"}"#;
|
||||||
@@ -471,4 +530,87 @@ Some text after"#;
|
|||||||
assert!(first_pos.unwrap() < last_pos.unwrap(),
|
assert!(first_pos.unwrap() < last_pos.unwrap(),
|
||||||
"First position ({:?}) should be less than last position ({:?})", first_pos, last_pos);
|
"First position ({:?}) should be less than last position ({:?})", first_pos, last_pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_preserves_standalone() {
|
||||||
|
// Tool call on its own line should NOT be sanitized
|
||||||
|
let input = r#"{"tool": "shell", "args": {"command": "ls"}}"#;
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert_eq!(result, input, "Standalone tool call should not be modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_preserves_indented_standalone() {
|
||||||
|
// Tool call with leading whitespace should NOT be sanitized
|
||||||
|
let input = r#" {"tool": "shell", "args": {"command": "ls"}}"#;
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert_eq!(result, input, "Indented standalone tool call should not be modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_sanitizes_inline() {
|
||||||
|
// Tool call pattern embedded in text should be sanitized
|
||||||
|
let input = "Here is an example: {\"tool\": \"shell\"} in text";
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert!(!result.contains("{\"tool\":"), "Inline pattern should be sanitized");
|
||||||
|
assert!(result.contains(LBRACE_HOMOGLYPH), "Should contain homoglyph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_sanitizes_in_code_block() {
|
||||||
|
// Tool call pattern in inline code should be sanitized
|
||||||
|
let input = "Use `{\"tool\": \"shell\"}` to run commands";
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert!(!result.contains("{\"tool\":"), "Pattern in code should be sanitized");
|
||||||
|
assert!(result.contains(LBRACE_HOMOGLYPH), "Should contain homoglyph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_multiline() {
|
||||||
|
// Mixed: standalone on one line, inline on another
|
||||||
|
let input = "Some text with {\"tool\": \"inline\"} here\n{\"tool\": \"standalone\", \"args\": {}}\nMore text";
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
|
||||||
|
// The inline one should be sanitized
|
||||||
|
let lines: Vec<&str> = result.lines().collect();
|
||||||
|
assert!(lines[0].contains(LBRACE_HOMOGLYPH), "First line should have homoglyph");
|
||||||
|
|
||||||
|
// The standalone one should NOT be sanitized
|
||||||
|
assert!(lines[1].starts_with("{\"tool\":"), "Second line should be unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_multiple_inline() {
|
||||||
|
// Multiple inline patterns on same line
|
||||||
|
let input = "Compare {\"tool\": \"a\"} with {\"tool\": \"b\"}";
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
|
||||||
|
// Both should be sanitized
|
||||||
|
assert!(!result.contains("{\"tool\":"), "All inline patterns should be sanitized");
|
||||||
|
// Count homoglyphs - should be 2
|
||||||
|
let homoglyph_count = result.matches(LBRACE_HOMOGLYPH).count();
|
||||||
|
assert_eq!(homoglyph_count, 2, "Should have 2 homoglyphs, got {}", homoglyph_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_no_false_positives() {
|
||||||
|
// Regular JSON that doesn't match tool patterns should be unchanged
|
||||||
|
let input = "Some {\"key\": \"value\"} json";
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert_eq!(result, input, "Non-tool JSON should not be modified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_inline_tool_patterns_all_pattern_variants() {
|
||||||
|
// Test all whitespace variants are caught
|
||||||
|
let inputs = [
|
||||||
|
"text { \"tool\":\"x\"} more",
|
||||||
|
"text {\"tool\" :\"x\"} more",
|
||||||
|
"text { \"tool\" :\"x\"} more",
|
||||||
|
];
|
||||||
|
for input in inputs {
|
||||||
|
let result = sanitize_inline_tool_patterns(input);
|
||||||
|
assert!(result.contains(LBRACE_HOMOGLYPH), "Pattern in '{}' should be sanitized", input);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user