coderouter-cli 2.6.1__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
72
121
 
73
- return None
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
128
+
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,28 +153,260 @@ 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
+ )
103
347
 
104
- def _extract_fenced_blocks(text: str) -> tuple[str, list[str]]:
105
- """Pull ```...``` blocks out of text. Returns (text_without_fences, bodies)."""
106
- bodies: list[str] = []
107
348
 
108
- def _collect(match: re.Match[str]) -> str:
109
- bodies.append(match.group(1))
110
- return "" # remove the fenced block from the text entirely
349
+ def _extract_tool_call_fenced_blocks(
350
+ text: str,
351
+ allowed: set[str] | None,
352
+ ) -> tuple[str, list[dict[str, Any]]]:
353
+ """Pull tool-call-shaped ```...``` blocks out of text.
354
+
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.
366
+
367
+ Returns (text_without_tool_fences, tool_calls).
368
+ """
369
+ tool_calls: list[dict[str, Any]] = []
370
+
371
+ def _repair(match: re.Match[str]) -> str:
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
376
+ if not body.startswith("{"):
377
+ return match.group(0) # keep non-JSON fenced blocks (e.g. code)
378
+ try:
379
+ obj = _parse_json_object(body)
380
+ except ValueError:
381
+ return match.group(0) # keep unparseable fenced blocks
382
+ hit = _looks_like_tool_call(obj, allowed)
383
+ if hit is None:
384
+ return match.group(0) # keep non-tool-call JSON blocks
385
+ name, args = hit
386
+ tool_calls.append(_normalise_to_openai_tool_call(name, args))
387
+ return "" # remove only recognised tool-call blocks
111
388
 
112
- cleaned = _FENCED_RE.sub(_collect, text)
113
- return cleaned, bodies
389
+ cleaned = _FENCED_RE.sub(_repair, text)
390
+ return cleaned, tool_calls
391
+
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)
114
410
 
115
411
 
116
412
  def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
@@ -119,6 +415,11 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
119
415
  Returns a list of (start, end_exclusive, substring). Handles escape
120
416
  sequences and string literals so braces inside JSON strings do not
121
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.
122
423
  """
123
424
  out: list[tuple[int, int, str]] = []
124
425
  n = len(text)
@@ -160,6 +461,287 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
160
461
  return out
161
462
 
162
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
+
163
745
  # ------------------------------------------------------------------
164
746
  # Public API
165
747
  # ------------------------------------------------------------------
@@ -188,33 +770,64 @@ def repair_tool_calls_in_text(
188
770
 
189
771
  extracted: list[dict[str, Any]] = []
190
772
 
191
- # 1. Pull fenced code blocks out first — they're the most common shape
192
- # when a chat-tuned model explains what it's doing.
193
- cleaned, fenced_bodies = _extract_fenced_blocks(text)
194
- for body in fenced_bodies:
195
- body = body.strip()
196
- if not body.startswith("{"):
773
+ # 1. Pull tool-call-shaped fenced code blocks out first — they're the most
774
+ # common shape when a chat-tuned model explains what it's doing. Fenced
775
+ # blocks that are NOT tool calls (prose, real source code, plain JSON
776
+ # examples) are left in place so we never drop legitimate content (H2).
777
+ # Programming-language fences are protected outright.
778
+ cleaned, fenced_tool_calls = _extract_tool_call_fenced_blocks(text, allowed)
779
+ extracted.extend(fenced_tool_calls)
780
+
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)
793
+ spans_to_remove: list[tuple[int, int]] = []
794
+ repaired_from_bare: list[dict[str, Any]] = []
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):
197
801
  continue
198
802
  try:
199
- obj = json.loads(body)
803
+ obj = json.loads(substr)
200
804
  except json.JSONDecodeError:
201
805
  continue
202
806
  hit = _looks_like_tool_call(obj, allowed)
203
- if hit is not None:
204
- name, args = hit
205
- extracted.append(_normalise_to_openai_tool_call(name, args))
807
+ if hit is None:
808
+ continue
809
+ name, args = hit
810
+ repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
811
+ spans_to_remove.append((start, end))
206
812
 
207
- # 2. Scan remaining text for bare JSON objects.
208
- # We walk from back to front so removals by slicing don't shift
209
- # the indices of earlier matches.
210
- candidates = _find_balanced_json_objects(cleaned)
211
- # Tentatively evaluate each; keep only the ones that are tool-call-shaped.
212
- spans_to_remove: list[tuple[int, int]] = []
213
- repaired_from_bare: list[dict[str, Any]] = []
214
- for start, end, substr in candidates:
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).
215
823
  try:
216
- obj = json.loads(substr)
824
+ json.loads(substr)
825
+ continue # strict-parseable -> either already handled or non-tool
217
826
  except json.JSONDecodeError:
827
+ pass
828
+ try:
829
+ obj = _lenient_json_loads(_strip_double_braces(substr))
830
+ except ValueError:
218
831
  continue
219
832
  hit = _looks_like_tool_call(obj, allowed)
220
833
  if hit is None:
@@ -223,13 +836,21 @@ def repair_tool_calls_in_text(
223
836
  repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
224
837
  spans_to_remove.append((start, end))
225
838
 
226
- # Remove the matched spans from the text back-to-front.
227
- for start, end in reversed(spans_to_remove):
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):
228
848
  cleaned = cleaned[:start] + cleaned[end:]
229
849
 
230
850
  extracted.extend(repaired_from_bare)
231
851
 
232
- # Collapse the whitespace left behind by removals.
852
+ # Residue cleanup (empty fences, [,], stray backticks) then whitespace.
853
+ cleaned = _cleanup_residue(cleaned)
233
854
  cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
234
855
  cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
235
856