coderouter-cli 2.7.0__py3-none-any.whl → 2.7.1__py3-none-any.whl

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.
@@ -23,15 +23,43 @@ Recognised shapes
23
23
  2. Bare JSON objects embedded in text:
24
24
  "Let me check the current directory. {\"name\":\"Bash\",\"arguments\":{}}"
25
25
  3. Multiple JSON objects in sequence (for multi-call turns).
26
+ 4. XML-flavoured forms (R3), seen with qwen2.5-coder at default temperature:
27
+ <echo message="probe"/> (attribute form)
28
+ <tool>{"name": "echo", "arguments": {...}}</tool> (wrapper form)
29
+ <echo>{"message": "probe"}</echo> (named-wrapper form)
26
30
 
27
31
  Each candidate is accepted only if it parses to one of:
28
32
  {"name": <str>, "arguments": <dict | str>} # direct shape
29
33
  {"function": {"name": <str>, "arguments": ...}} # OpenAI shape
34
+ ... plus the R2 key aliases (tool / tool_name / parameters / input / args).
30
35
 
31
- If `allowed_tool_names` is provided, the `name` must be in that set;
36
+ Lenient JSON (R1)
37
+ -----------------
38
+ When strict ``json.loads`` fails, a second, tolerant pass is attempted for the
39
+ malformed forms small models actually emit:
40
+ - double-braced objects ``{{...}}`` -> ``{...}`` (L2 default-temp form)
41
+ - trailing commas
42
+ - single-quoted strings / keys (Python-repr dicts)
43
+ - unquoted object keys
44
+ The lenient parse is a stdlib-only tokenizer; the resulting dict is still run
45
+ through the exact same shape + allow-list validation as a strict parse.
46
+
47
+ False-positive discipline
48
+ -------------------------
49
+ If `allowed_tool_names` is provided, the tool `name` must be in that set;
32
50
  otherwise any tool-shaped JSON is accepted. Passing the allow-list is
33
- strongly recommended to avoid false positives (a model legitimately
34
- discussing JSON in prose).
51
+ strongly recommended.
52
+
53
+ In addition, two context guards keep genuine documentation / source code from
54
+ being turned into executable tool calls, even when it contains an allow-listed
55
+ name:
56
+ - programming-language code fences (```python, ```js, ...) are protected:
57
+ their interior is never scanned by the bare-JSON or XML scanners.
58
+ - bare JSON / XML immediately introduced by an explanatory cue
59
+ ("for example", "you would write", "is documented as", ...) is treated
60
+ as prose and left in place.
61
+ These guards are conservative: they only ever *suppress* an extraction, so a
62
+ name that is not in the allow-list is still never repaired.
35
63
  """
36
64
 
37
65
  from __future__ import annotations
@@ -45,32 +73,68 @@ __all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
45
73
 
46
74
 
47
75
  # ------------------------------------------------------------------
48
- # Tool-call shape detection + normalisation
76
+ # R2: shape detection + normalisation (with key aliases)
49
77
  # ------------------------------------------------------------------
50
78
 
79
+ # Accepted aliases for the tool-name key and the arguments key. Order does not
80
+ # matter for detection, but ambiguity (more than one distinct alias present)
81
+ # is treated as "not a tool call" and left untouched (safe side).
82
+ _NAME_KEYS = ("name", "tool", "tool_name")
83
+ _ARG_KEYS = ("arguments", "parameters", "input", "args")
84
+
85
+
86
+ def _pick_unique(obj: dict[str, Any], keys: tuple[str, ...]) -> tuple[str | None, Any]:
87
+ """Return (matched_key, value) if exactly one of ``keys`` is present.
88
+
89
+ If zero keys are present, returns (None, None). If more than one key is
90
+ present the result is ambiguous -> returns ("<ambiguous>", None) so callers
91
+ can decline to repair.
92
+ """
93
+ present = [k for k in keys if k in obj]
94
+ if not present:
95
+ return None, None
96
+ if len(present) > 1:
97
+ return "<ambiguous>", None
98
+ k = present[0]
99
+ return k, obj[k]
100
+
51
101
 
52
102
  def _looks_like_tool_call(obj: Any, allowed: set[str] | None) -> tuple[str, Any] | None:
53
- """Return (name, arguments) if obj looks like a tool call, else None."""
103
+ """Return (name, arguments) if obj looks like a tool call, else None.
104
+
105
+ Recognises:
106
+ - direct shape with name-key alias + args-key alias
107
+ - OpenAI ``{"function": {...}}`` wrapper (recursed into)
108
+ Ambiguous objects (multiple name aliases or multiple arg aliases) are
109
+ rejected.
110
+ """
54
111
  if not isinstance(obj, dict):
55
112
  return None
56
113
 
57
- # Direct shape: {"name": "...", "arguments": ...}
58
- name = obj.get("name")
59
- if isinstance(name, str) and "arguments" in obj and (allowed is None or name in allowed):
60
- return name, obj["arguments"]
61
-
62
114
  # OpenAI function shape: {"function": {"name": "...", "arguments": ...}}
63
115
  fn = obj.get("function")
64
116
  if isinstance(fn, dict):
65
- inner_name = fn.get("name")
66
- if (
67
- isinstance(inner_name, str)
68
- and "arguments" in fn
69
- and (allowed is None or inner_name in allowed)
70
- ):
71
- return inner_name, fn["arguments"]
117
+ inner = _looks_like_tool_call(fn, allowed)
118
+ if inner is not None:
119
+ return inner
120
+ # fall through: maybe the outer dict itself is tool-shaped
121
+
122
+ name_key, name = _pick_unique(obj, _NAME_KEYS)
123
+ if name_key == "<ambiguous>":
124
+ return None
125
+ arg_key, args = _pick_unique(obj, _ARG_KEYS)
126
+ if arg_key == "<ambiguous>":
127
+ return None
72
128
 
73
- return None
129
+ if not isinstance(name, str) or name_key is None:
130
+ return None
131
+ if arg_key is None:
132
+ # A tool-shaped object must carry an arguments container to be a call;
133
+ # a lone {"name": "..."} is not distinguishable from ordinary data.
134
+ return None
135
+ if allowed is not None and name not in allowed:
136
+ return None
137
+ return name, args
74
138
 
75
139
 
76
140
  def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
@@ -89,17 +153,198 @@ def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
89
153
  }
90
154
 
91
155
 
156
+ # ------------------------------------------------------------------
157
+ # R1: lenient JSON parsing (only tried after strict json.loads fails)
158
+ # ------------------------------------------------------------------
159
+
160
+
161
+ def _strip_double_braces(s: str) -> str:
162
+ """Collapse a fully double-braced object ``{{...}}`` to ``{...}``.
163
+
164
+ Only collapses when the whole (stripped) string is wrapped in exactly one
165
+ extra layer of braces on both ends — the L2 default-temp failure form
166
+ ``{{"name": ...}}``. A single wrap is removed; deeper nesting is left as-is
167
+ (the tokenizer below handles the resulting single-braced object).
168
+ """
169
+ t = s.strip()
170
+ if t.startswith("{{") and t.endswith("}}"):
171
+ return t[1:-1].strip()
172
+ return s
173
+
174
+
175
+ def _lenient_json_loads(raw: str) -> Any:
176
+ """Best-effort parse of near-JSON emitted by small models.
177
+
178
+ Handles, in a single tolerant tokenizer pass:
179
+ - single-quoted strings ('...') -> double-quoted
180
+ - unquoted object keys -> quoted
181
+ - trailing commas -> dropped
182
+ - (double braces are stripped by the caller before this runs)
183
+
184
+ Returns the parsed object, or raises ValueError if the tokenizer cannot
185
+ produce syntactically valid JSON. Never executes the input.
186
+ """
187
+ s = raw.strip()
188
+ if not s:
189
+ raise ValueError("empty")
190
+
191
+ out: list[str] = []
192
+ i = 0
193
+ n = len(s)
194
+ # Stack tracks whether the current container is an object ('{') so we can
195
+ # know when a bareword is a key (needs quoting) vs a value.
196
+ while i < n:
197
+ c = s[i]
198
+
199
+ # --- string literals: normalise quote char, copy verbatim otherwise ---
200
+ if c == '"' or c == "'":
201
+ quote = c
202
+ j = i + 1
203
+ buf = ['"']
204
+ while j < n:
205
+ cj = s[j]
206
+ if cj == "\\":
207
+ # keep escape pair verbatim (but a backslash-escaped single
208
+ # quote inside a single-quoted string becomes a plain ').
209
+ if j + 1 < n:
210
+ nxt = s[j + 1]
211
+ if quote == "'" and nxt == "'":
212
+ buf.append("'")
213
+ j += 2
214
+ continue
215
+ buf.append(cj)
216
+ buf.append(nxt)
217
+ j += 2
218
+ continue
219
+ buf.append("\\")
220
+ j += 1
221
+ continue
222
+ if cj == quote:
223
+ j += 1
224
+ break
225
+ if cj == '"' and quote == "'":
226
+ # a double quote inside a single-quoted string must be
227
+ # escaped in the JSON output.
228
+ buf.append('\\"')
229
+ j += 1
230
+ continue
231
+ buf.append(cj)
232
+ j += 1
233
+ else:
234
+ raise ValueError("unterminated string")
235
+ buf.append('"')
236
+ out.append("".join(buf))
237
+ i = j
238
+ continue
239
+
240
+ # --- unquoted key/identifier: quote it ---
241
+ if c.isalpha() or c == "_":
242
+ j = i
243
+ while j < n and (s[j].isalnum() or s[j] in "_-."):
244
+ j += 1
245
+ word = s[i:j]
246
+ lowered = word.lower()
247
+ if lowered in ("true", "false", "null"):
248
+ out.append(lowered)
249
+ else:
250
+ # bareword -> treat as a quoted key/string token
251
+ out.append('"' + word + '"')
252
+ i = j
253
+ continue
254
+
255
+ # --- trailing comma: drop a comma that is followed only by ws then } or ] ---
256
+ if c == ",":
257
+ k = i + 1
258
+ while k < n and s[k] in " \t\r\n":
259
+ k += 1
260
+ if k < n and s[k] in "}]":
261
+ i += 1 # skip the comma
262
+ continue
263
+ out.append(",")
264
+ i += 1
265
+ continue
266
+
267
+ out.append(c)
268
+ i += 1
269
+
270
+ candidate = "".join(out)
271
+ try:
272
+ return json.loads(candidate)
273
+ except json.JSONDecodeError as exc: # pragma: no cover - defensive
274
+ raise ValueError(f"lenient parse failed: {exc}") from exc
275
+
276
+
277
+ def _parse_json_object(body: str) -> Any:
278
+ """Strict json.loads, then a lenient fallback. Raises ValueError on failure."""
279
+ try:
280
+ return json.loads(body)
281
+ except json.JSONDecodeError:
282
+ pass
283
+ return _lenient_json_loads(_strip_double_braces(body))
284
+
285
+
92
286
  # ------------------------------------------------------------------
93
287
  # Scanners: fenced code blocks, then balanced braces in remaining text
94
288
  # ------------------------------------------------------------------
95
289
 
96
290
  # Match ```json ... ``` or ``` ... ``` with anything after the fence tag line.
97
- # Group 1 captures the body.
291
+ # Group 1 captures the (optional) language tag, group 2 the body.
98
292
  _FENCED_RE = re.compile(
99
- r"```(?:\w+)?[ \t]*\r?\n(.*?)\r?\n?```",
293
+ r"```([\w+-]*)[ \t]*\r?\n(.*?)\r?\n?```",
100
294
  re.DOTALL,
101
295
  )
102
296
 
297
+ # Language tags that denote *real source code* (not a serialised tool call).
298
+ # A fence carrying one of these is protected: its interior is never scanned by
299
+ # the bare-JSON / XML scanners, so an allow-listed name written as a code
300
+ # literal inside such a fence is not turned into a tool call.
301
+ _CODE_FENCE_LANGS = frozenset(
302
+ {
303
+ "python",
304
+ "py",
305
+ "python3",
306
+ "js",
307
+ "javascript",
308
+ "ts",
309
+ "typescript",
310
+ "jsx",
311
+ "tsx",
312
+ "go",
313
+ "golang",
314
+ "rust",
315
+ "rs",
316
+ "java",
317
+ "c",
318
+ "cpp",
319
+ "c++",
320
+ "cs",
321
+ "csharp",
322
+ "ruby",
323
+ "rb",
324
+ "php",
325
+ "sh",
326
+ "bash",
327
+ "shell",
328
+ "zsh",
329
+ "sql",
330
+ "html",
331
+ "css",
332
+ "yaml",
333
+ "yml",
334
+ "toml",
335
+ "ini",
336
+ "kotlin",
337
+ "swift",
338
+ "scala",
339
+ "lua",
340
+ "perl",
341
+ "r",
342
+ "dart",
343
+ "elixir",
344
+ "haskell",
345
+ }
346
+ )
347
+
103
348
 
104
349
  def _extract_tool_call_fenced_blocks(
105
350
  text: str,
@@ -107,25 +352,32 @@ def _extract_tool_call_fenced_blocks(
107
352
  ) -> tuple[str, list[dict[str, Any]]]:
108
353
  """Pull tool-call-shaped ```...``` blocks out of text.
109
354
 
110
- Only fenced blocks whose body parses to a recognised tool-call shape are
111
- removed from the text and returned as normalised OpenAI tool_calls. Any
112
- other fenced block (prose, real source code, non-tool JSON) is preserved
113
- verbatim in the returned text — the removal decision and the extraction
114
- decision use the exact same predicate, so a fenced block is never dropped
115
- without also being surfaced as a tool call (bug H2: data loss when a
116
- response mixed a code example with a tool-call block).
355
+ Only fenced blocks whose body parses (strictly or leniently) to a
356
+ recognised tool-call shape are removed from the text and returned as
357
+ normalised OpenAI tool_calls. Any other fenced block (prose, real source
358
+ code, non-tool JSON) is preserved verbatim in the returned text — the
359
+ removal decision and the extraction decision use the exact same predicate,
360
+ so a fenced block is never dropped without also being surfaced as a tool
361
+ call (bug H2).
362
+
363
+ Fences carrying a *programming-language* tag (``python``, ``js`` ...) are
364
+ protected: their body is never parsed as a tool call, even if it happens to
365
+ contain a tool-shaped literal. This is the code-fence false-positive guard.
117
366
 
118
367
  Returns (text_without_tool_fences, tool_calls).
119
368
  """
120
369
  tool_calls: list[dict[str, Any]] = []
121
370
 
122
371
  def _repair(match: re.Match[str]) -> str:
123
- body = match.group(1).strip()
372
+ lang = (match.group(1) or "").strip().lower()
373
+ body = match.group(2).strip()
374
+ if lang in _CODE_FENCE_LANGS:
375
+ return match.group(0) # protected source-code fence
124
376
  if not body.startswith("{"):
125
377
  return match.group(0) # keep non-JSON fenced blocks (e.g. code)
126
378
  try:
127
- obj = json.loads(body)
128
- except json.JSONDecodeError:
379
+ obj = _parse_json_object(body)
380
+ except ValueError:
129
381
  return match.group(0) # keep unparseable fenced blocks
130
382
  hit = _looks_like_tool_call(obj, allowed)
131
383
  if hit is None:
@@ -138,12 +390,36 @@ def _extract_tool_call_fenced_blocks(
138
390
  return cleaned, tool_calls
139
391
 
140
392
 
393
+ def _protected_code_spans(text: str) -> list[tuple[int, int]]:
394
+ """Byte spans of programming-language code fences to exclude from scanning.
395
+
396
+ Returns (start, end) index pairs covering the *entire* fenced block
397
+ (fence markers included) for fences whose language tag is a real
398
+ programming language. Used to shield code from the bare-JSON / XML scanners.
399
+ """
400
+ spans: list[tuple[int, int]] = []
401
+ for m in _FENCED_RE.finditer(text):
402
+ lang = (m.group(1) or "").strip().lower()
403
+ if lang in _CODE_FENCE_LANGS:
404
+ spans.append((m.start(), m.end()))
405
+ return spans
406
+
407
+
408
+ def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
409
+ return any(start <= pos < end for start, end in spans)
410
+
411
+
141
412
  def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
142
413
  """Find top-level `{...}` JSON substrings by a brace-counter scan.
143
414
 
144
415
  Returns a list of (start, end_exclusive, substring). Handles escape
145
416
  sequences and string literals so braces inside JSON strings do not
146
417
  confuse the counter. Malformed (unclosed) candidates are skipped.
418
+
419
+ Note: this deliberately matches strict JSON strings (double-quoted) for
420
+ brace balancing. Single-quoted / unquoted malformed objects are found by
421
+ :func:`_find_candidate_object_spans` instead, which brace-balances without
422
+ assuming JSON string syntax.
147
423
  """
148
424
  out: list[tuple[int, int, str]] = []
149
425
  n = len(text)
@@ -185,6 +461,287 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
185
461
  return out
186
462
 
187
463
 
464
+ def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
465
+ """Brace-balance ``{...}`` spans, tolerating single/double quoted strings.
466
+
467
+ Unlike :func:`_find_balanced_json_objects`, this counts braces while
468
+ respecting BOTH ``'`` and ``"`` string delimiters, so it can locate
469
+ Python-repr / single-quoted malformed objects for the lenient parser.
470
+ Returns (start, end_exclusive, substring).
471
+ """
472
+ out: list[tuple[int, int, str]] = []
473
+ n = len(text)
474
+ i = 0
475
+ while i < n:
476
+ if text[i] != "{":
477
+ i += 1
478
+ continue
479
+ depth = 0
480
+ j = i
481
+ quote: str | None = None
482
+ escape = False
483
+ while j < n:
484
+ c = text[j]
485
+ if escape:
486
+ escape = False
487
+ elif quote is not None:
488
+ if c == "\\":
489
+ escape = True
490
+ elif c == quote:
491
+ quote = None
492
+ else:
493
+ if c == '"' or c == "'":
494
+ quote = c
495
+ elif c == "{":
496
+ depth += 1
497
+ elif c == "}":
498
+ depth -= 1
499
+ if depth == 0:
500
+ out.append((i, j + 1, text[i : j + 1]))
501
+ i = j + 1
502
+ break
503
+ j += 1
504
+ else:
505
+ i += 1
506
+ continue
507
+ return out
508
+
509
+
510
+ # ------------------------------------------------------------------
511
+ # Prose-cue guard: bare JSON introduced as an *example* is not a call
512
+ # ------------------------------------------------------------------
513
+
514
+ # If one of these phrases appears immediately before a bare JSON object (within
515
+ # a short window, on the same clause), the object is being *described* rather
516
+ # than emitted as a call. Conservative: only suppresses, never forces a repair.
517
+ _PROSE_CUE_RE = re.compile(
518
+ r"(?:"
519
+ r"for\s+example|for\s+instance|e\.?g\.?|such\s+as|"
520
+ r"you\s+(?:would|could|can|might)\s+write|"
521
+ r"you\s+would\s+(?:use|call)|"
522
+ r"(?:is|are)\s+documented(?:\s+as)?|documented\s+like|"
523
+ r"looks?\s+like\s+this|written\s+as|the\s+format\s+is|"
524
+ r"syntax\s+is|例えば|たとえば|のように書"
525
+ r")"
526
+ r"[^\n{]{0,40}$",
527
+ re.IGNORECASE,
528
+ )
529
+
530
+
531
+ def _preceded_by_prose_cue(text: str, start: int) -> bool:
532
+ """True if a documentation/example cue immediately precedes position ``start``."""
533
+ prefix = text[:start]
534
+ # Look only within the current line/clause (last ~80 chars, no newline jump
535
+ # further than the immediate lead-in).
536
+ tail = prefix[-80:]
537
+ return bool(_PROSE_CUE_RE.search(tail))
538
+
539
+
540
+ # ------------------------------------------------------------------
541
+ # R3: XML-flavoured tool-call forms
542
+ # ------------------------------------------------------------------
543
+
544
+ # Tags that are known NOT to be tool calls: reasoning wrappers and common HTML.
545
+ _NON_TOOL_TAGS = frozenset(
546
+ {
547
+ "think",
548
+ "thinking",
549
+ "thought",
550
+ "reasoning",
551
+ "scratchpad",
552
+ "reflection",
553
+ "answer",
554
+ "final",
555
+ "p",
556
+ "div",
557
+ "span",
558
+ "ul",
559
+ "ol",
560
+ "li",
561
+ "a",
562
+ "b",
563
+ "i",
564
+ "em",
565
+ "strong",
566
+ "code",
567
+ "pre",
568
+ "br",
569
+ "hr",
570
+ "h1",
571
+ "h2",
572
+ "h3",
573
+ "h4",
574
+ "h5",
575
+ "h6",
576
+ "table",
577
+ "tr",
578
+ "td",
579
+ "th",
580
+ "img",
581
+ "button",
582
+ "form",
583
+ "input",
584
+ "label",
585
+ "section",
586
+ "article",
587
+ "header",
588
+ "footer",
589
+ "nav",
590
+ "tool", # handled separately as a generic wrapper (body carries name)
591
+ }
592
+ )
593
+
594
+ # Generic wrapper tags whose *body* carries the actual tool-call JSON.
595
+ _WRAPPER_TAGS = frozenset({"tool", "tool_call", "function_call", "invoke"})
596
+
597
+ # <tag attr="v" .../> or <tag attr="v" ...> (self-closing or open; we only
598
+ # treat the self-closing / immediately-closed form as an attribute call).
599
+ _XML_SELFCLOSE_RE = re.compile(
600
+ r"<([A-Za-z_][\w.-]*)((?:\s+[\w.:-]+\s*=\s*\"[^\"]*\")*)\s*/>",
601
+ )
602
+ # <tag> ... </tag> wrapper.
603
+ _XML_WRAPPER_RE = re.compile(
604
+ r"<([A-Za-z_][\w.-]*)\s*>(.*?)</\1\s*>",
605
+ re.DOTALL,
606
+ )
607
+ _XML_ATTR_RE = re.compile(r"([\w.:-]+)\s*=\s*\"([^\"]*)\"")
608
+
609
+
610
+ def _known_non_tool_tag_ranges(text: str) -> list[tuple[int, int]]:
611
+ """Ranges covered by <think>...</think> (and similar) reasoning blocks.
612
+
613
+ XML extraction must not reach inside these, so a tool-shaped tag mentioned
614
+ in reasoning is never executed.
615
+ """
616
+ ranges: list[tuple[int, int]] = []
617
+ for tag in ("think", "thinking", "thought", "reasoning", "scratchpad"):
618
+ for m in re.finditer(
619
+ rf"<{tag}\b[^>]*>.*?</{tag}\s*>", text, re.DOTALL | re.IGNORECASE
620
+ ):
621
+ ranges.append((m.start(), m.end()))
622
+ return ranges
623
+
624
+
625
+ def _extract_xml_tool_calls(
626
+ text: str,
627
+ allowed: set[str] | None,
628
+ protected: list[tuple[int, int]],
629
+ ) -> tuple[str, list[dict[str, Any]]]:
630
+ """Extract R3 XML-flavoured tool calls, honouring guards.
631
+
632
+ Returns (text_with_xml_calls_removed, tool_calls). Only forms whose tag /
633
+ inner name resolve to an allow-listed tool are removed.
634
+ """
635
+ if allowed is None:
636
+ # Without an allow-list, XML extraction is too risky (any <tag> could
637
+ # look like a call). Skip R3 entirely — keeps false positives at zero.
638
+ return text, []
639
+ if "<" not in text:
640
+ return text, []
641
+
642
+ tool_calls: list[dict[str, Any]] = []
643
+ reasoning = _known_non_tool_tag_ranges(text)
644
+ guard = protected + reasoning
645
+ removals: list[tuple[int, int]] = []
646
+
647
+ def _blocked(pos: int) -> bool:
648
+ return _in_spans(pos, guard) or _preceded_by_prose_cue(text, pos)
649
+
650
+ # --- wrapper form: <tool>{JSON}</tool> and <name>{JSON}</name> ---
651
+ for m in _XML_WRAPPER_RE.finditer(text):
652
+ tag = m.group(1)
653
+ inner = m.group(2).strip()
654
+ tag_l = tag.lower()
655
+ if _blocked(m.start()):
656
+ continue
657
+ if tag_l in _WRAPPER_TAGS:
658
+ # Body carries the whole tool-call object.
659
+ if not inner.startswith("{"):
660
+ continue
661
+ try:
662
+ obj = _parse_json_object(inner)
663
+ except ValueError:
664
+ continue
665
+ hit = _looks_like_tool_call(obj, allowed)
666
+ if hit is None:
667
+ continue
668
+ name, args = hit
669
+ tool_calls.append(_normalise_to_openai_tool_call(name, args))
670
+ removals.append((m.start(), m.end()))
671
+ continue
672
+ # Named-wrapper: the tag itself is the tool name, body is the arguments.
673
+ if tag_l in _NON_TOOL_TAGS:
674
+ continue
675
+ if tag not in allowed:
676
+ continue
677
+ if not inner.startswith("{"):
678
+ continue
679
+ try:
680
+ args_obj = _parse_json_object(inner)
681
+ except ValueError:
682
+ continue
683
+ if not isinstance(args_obj, dict):
684
+ continue
685
+ tool_calls.append(_normalise_to_openai_tool_call(tag, args_obj))
686
+ removals.append((m.start(), m.end()))
687
+
688
+ # --- self-closing attribute form: <echo message="probe"/> ---
689
+ for m in _XML_SELFCLOSE_RE.finditer(text):
690
+ tag = m.group(1)
691
+ if tag.lower() in _NON_TOOL_TAGS:
692
+ continue
693
+ if tag not in allowed:
694
+ continue
695
+ if _blocked(m.start()):
696
+ continue
697
+ # Don't double-extract something already inside a wrapper removal.
698
+ if _in_spans(m.start(), removals):
699
+ continue
700
+ attrs = dict(_XML_ATTR_RE.findall(m.group(2) or ""))
701
+ tool_calls.append(_normalise_to_openai_tool_call(tag, attrs))
702
+ removals.append((m.start(), m.end()))
703
+
704
+ if not removals:
705
+ return text, []
706
+
707
+ # Remove matched spans back-to-front; merge overlaps first.
708
+ removals.sort()
709
+ merged: list[tuple[int, int]] = []
710
+ for s, e in removals:
711
+ if merged and s < merged[-1][1]:
712
+ merged[-1] = (merged[-1][0], max(merged[-1][1], e))
713
+ else:
714
+ merged.append((s, e))
715
+ cleaned = text
716
+ for s, e in reversed(merged):
717
+ cleaned = cleaned[:s] + cleaned[e:]
718
+ return cleaned, tool_calls
719
+
720
+
721
+ # ------------------------------------------------------------------
722
+ # Residue cleanup (trap #2)
723
+ # ------------------------------------------------------------------
724
+
725
+ # Empty fenced blocks left after a tool-call body was removed.
726
+ _EMPTY_FENCE_RE = re.compile(r"```[\w+-]*[ \t]*\r?\n?[ \t]*\r?\n?```")
727
+ # Array skeletons like "[, ]" or "[ , , ]" left after removing array elements.
728
+ _EMPTY_ARRAY_RE = re.compile(r"\[\s*(?:,\s*)+\]")
729
+
730
+
731
+ def _cleanup_residue(text: str) -> str:
732
+ """Remove cosmetic leftovers from extraction (empty fences, ``[,]``).
733
+
734
+ Only *empty* fenced blocks (a fence pair with nothing between them) are
735
+ removed — a lone closing ``` that still belongs to an intact, preserved
736
+ code fence is left alone, so protected source blocks stay valid.
737
+ """
738
+ text = _EMPTY_FENCE_RE.sub("", text)
739
+ text = _EMPTY_ARRAY_RE.sub("", text)
740
+ # Collapse a lone remaining empty array pair.
741
+ text = re.sub(r"\[\s*\]", "", text)
742
+ return text
743
+
744
+
188
745
  # ------------------------------------------------------------------
189
746
  # Public API
190
747
  # ------------------------------------------------------------------
@@ -217,17 +774,31 @@ def repair_tool_calls_in_text(
217
774
  # common shape when a chat-tuned model explains what it's doing. Fenced
218
775
  # blocks that are NOT tool calls (prose, real source code, plain JSON
219
776
  # examples) are left in place so we never drop legitimate content (H2).
777
+ # Programming-language fences are protected outright.
220
778
  cleaned, fenced_tool_calls = _extract_tool_call_fenced_blocks(text, allowed)
221
779
  extracted.extend(fenced_tool_calls)
222
780
 
223
- # 2. Scan remaining text for bare JSON objects.
224
- # We walk from back to front so removals by slicing don't shift
225
- # the indices of earlier matches.
226
- candidates = _find_balanced_json_objects(cleaned)
227
- # Tentatively evaluate each; keep only the ones that are tool-call-shaped.
781
+ # 2. R3: XML-flavoured forms (only when an allow-list constrains names).
782
+ # Runs on the fence-stripped text; protected code fences and reasoning
783
+ # blocks are shielded.
784
+ protected = _protected_code_spans(cleaned)
785
+ cleaned, xml_tool_calls = _extract_xml_tool_calls(cleaned, allowed, protected)
786
+ extracted.extend(xml_tool_calls)
787
+
788
+ # 3. Scan remaining text for bare JSON objects (strict then lenient).
789
+ # Skip anything inside a protected code fence or introduced by a prose
790
+ # example cue. Walk back-to-front so slicing removals don't shift the
791
+ # indices of earlier matches.
792
+ protected = _protected_code_spans(cleaned)
228
793
  spans_to_remove: list[tuple[int, int]] = []
229
794
  repaired_from_bare: list[dict[str, Any]] = []
230
- for start, end, substr in candidates:
795
+
796
+ # First pass: strict-JSON balanced objects.
797
+ for start, end, substr in _find_balanced_json_objects(cleaned):
798
+ if _in_spans(start, protected):
799
+ continue
800
+ if _preceded_by_prose_cue(cleaned, start):
801
+ continue
231
802
  try:
232
803
  obj = json.loads(substr)
233
804
  except json.JSONDecodeError:
@@ -239,13 +810,47 @@ def repair_tool_calls_in_text(
239
810
  repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
240
811
  spans_to_remove.append((start, end))
241
812
 
242
- # Remove the matched spans from the text back-to-front.
243
- for start, end in reversed(spans_to_remove):
813
+ # Second pass: lenient objects (single-quoted / unquoted / trailing comma /
814
+ # double-brace) that strict parsing missed. Skip spans already claimed.
815
+ for start, end, substr in _find_candidate_object_spans(cleaned):
816
+ if _in_spans(start, spans_to_remove):
817
+ continue
818
+ if _in_spans(start, protected):
819
+ continue
820
+ if _preceded_by_prose_cue(cleaned, start):
821
+ continue
822
+ # Skip if strict JSON already parses this (handled in first pass).
823
+ try:
824
+ json.loads(substr)
825
+ continue # strict-parseable -> either already handled or non-tool
826
+ except json.JSONDecodeError:
827
+ pass
828
+ try:
829
+ obj = _lenient_json_loads(_strip_double_braces(substr))
830
+ except ValueError:
831
+ continue
832
+ hit = _looks_like_tool_call(obj, allowed)
833
+ if hit is None:
834
+ continue
835
+ name, args = hit
836
+ repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
837
+ spans_to_remove.append((start, end))
838
+
839
+ # Remove the matched spans from the text back-to-front (merge overlaps).
840
+ spans_to_remove.sort()
841
+ merged: list[tuple[int, int]] = []
842
+ for s, e in spans_to_remove:
843
+ if merged and s < merged[-1][1]:
844
+ merged[-1] = (merged[-1][0], max(merged[-1][1], e))
845
+ else:
846
+ merged.append((s, e))
847
+ for start, end in reversed(merged):
244
848
  cleaned = cleaned[:start] + cleaned[end:]
245
849
 
246
850
  extracted.extend(repaired_from_bare)
247
851
 
248
- # Collapse the whitespace left behind by removals.
852
+ # Residue cleanup (empty fences, [,], stray backticks) then whitespace.
853
+ cleaned = _cleanup_residue(cleaned)
249
854
  cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
250
855
  cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
251
856