@@ -22,7 +22,7 @@ def _sanitize_json_string(text: str) -> str:
2222 escape_next = False
2323 continue
2424
25- if char == ' \\ ' :
25+ if char == " \\ " :
2626 result .append (char )
2727 escape_next = True
2828 continue
@@ -34,21 +34,92 @@ def _sanitize_json_string(text: str) -> str:
3434
3535 if in_string :
3636 # Escape control characters inside strings
37- if char == ' \n ' :
38- result .append (' \\ n' )
39- elif char == ' \r ' :
40- result .append (' \\ r' )
41- elif char == ' \t ' :
42- result .append (' \\ t' )
37+ if char == " \n " :
38+ result .append (" \\ n" )
39+ elif char == " \r " :
40+ result .append (" \\ r" )
41+ elif char == " \t " :
42+ result .append (" \\ t" )
4343 elif ord (char ) < 32 :
4444 # Other control characters - escape as unicode
45- result .append (f' \\ u{ ord (char ):04x} ' )
45+ result .append (f" \\ u{ ord (char ):04x} " )
4646 else :
4747 result .append (char )
4848 else :
4949 result .append (char )
5050
51- return '' .join (result )
51+ return "" .join (result )
52+
53+
54+ def _repair_unescaped_quotes (text : str ) -> str :
55+ """Attempt to escape unescaped double quotes inside JSON string values.
56+
57+ When an LLM embeds narrative text containing dialogue (e.g., She said
58+ "hello") into a JSON string, the inner quotes break parsing. This
59+ function heuristically detects quotes that appear inside a string value
60+ (not at a structural boundary) and escapes them.
61+
62+ Strategy: walk the text tracking JSON structural context. A quote that
63+ appears inside a string value and is followed by content that doesn't
64+ look like a JSON key or structural token is treated as a literal that
65+ needs escaping.
66+ """
67+ import re
68+
69+ # Quick check: if it parses, no repair needed
70+ try :
71+ json .loads (text )
72+ return text
73+ except Exception :
74+ pass
75+
76+ # Find all string value positions: after `"key":` patterns, the next
77+ # quote opens a string value. We re-escape any unescaped quotes
78+ # inside that value by looking for the *correct* closing quote
79+ # (one followed by , or } or ] or whitespace then one of those).
80+ # This is a heuristic — it won't handle all edge cases but covers the
81+ # common case of narrative dialogue embedded in tool params.
82+ structural_close = re .compile (r'"\s*[,}\]]' )
83+
84+ result = []
85+ i = 0
86+ n = len (text )
87+ while i < n :
88+ ch = text [i ]
89+ if ch == "\\ " :
90+ # Escaped char — pass through both characters
91+ result .append (text [i : i + 2 ])
92+ i += 2
93+ continue
94+ if ch == '"' :
95+ # Opening quote of a string — find the structural close
96+ result .append ('"' )
97+ i += 1
98+ # Scan for the closing quote (one followed by structural char)
99+ while i < n :
100+ ic = text [i ]
101+ if ic == "\\ " :
102+ result .append (text [i : i + 2 ])
103+ i += 2
104+ continue
105+ if ic == '"' :
106+ # Is this the structural close?
107+ rest = text [i :]
108+ if structural_close .match (rest ):
109+ result .append ('"' )
110+ i += 1
111+ break
112+ # Not structural — escape it
113+ result .append ('\\ "' )
114+ i += 1
115+ continue
116+ result .append (ic )
117+ i += 1
118+ continue
119+ result .append (ch )
120+ i += 1
121+
122+ return "" .join (result )
52123
53124
54125def _find_first_json_object (text : str ) -> str | None :
@@ -192,23 +263,32 @@ def _extract_json_object(text: str) -> dict[str, Any] | None:
192263 try :
193264 obj = json .loads (json_candidate )
194265 except json .JSONDecodeError as e :
195- # Try sanitizing control characters inside strings
196- if "control character" in str (e ).lower () or "invalid" in str (e ).lower ():
266+ # Try sanitizing (control chars, unescaped quotes) on any parse failure
267+ try :
268+ sanitized = _sanitize_json_string (json_candidate )
269+ obj = json .loads (sanitized )
270+ info ("JSON parse succeeded after sanitizing" )
271+ except Exception :
272+ # Last resort: try repairing unescaped quotes inside string values
197273 try :
198- sanitized = _sanitize_json_string (json_candidate )
199- obj = json .loads (sanitized )
200- info ("JSON parse succeeded after sanitizing control characters" )
201- except Exception as e2 :
202- warn ("JSON parse failed after sanitization: %s | len=%d | last_50: %s" ,
203- str (e2 )[:80 ], len (json_candidate ), json_candidate [- 50 :] if len (json_candidate ) > 50 else json_candidate )
274+ repaired = _repair_unescaped_quotes (json_candidate )
275+ obj = json .loads (repaired )
276+ info ("JSON parse succeeded after quote repair" )
277+ except Exception :
278+ warn (
279+ "JSON parse failed: %s | len=%d | last_50: %s" ,
280+ str (e )[:80 ],
281+ len (json_candidate ),
282+ json_candidate [- 50 :] if len (json_candidate ) > 50 else json_candidate ,
283+ )
204284 return None
205- else :
206- warn ("JSON parse failed: %s | len=%d | last_50: %s" ,
207- str (e )[:80 ], len (json_candidate ), json_candidate [- 50 :] if len (json_candidate ) > 50 else json_candidate )
208- return None
209285 except Exception as e :
210- warn ("JSON parse failed: %s | len=%d | last_50: %s" ,
211- str (e )[:80 ], len (json_candidate ), json_candidate [- 50 :] if len (json_candidate ) > 50 else json_candidate )
286+ warn (
287+ "JSON parse failed: %s | len=%d | last_50: %s" ,
288+ str (e )[:80 ],
289+ len (json_candidate ),
290+ json_candidate [- 50 :] if len (json_candidate ) > 50 else json_candidate ,
291+ )
212292 return None
213293
214294 return obj if isinstance (obj , dict ) else None
0 commit comments