coderouter-cli 2.7.0__py3-none-any.whl → 2.7.2__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.
- coderouter/config/capability_registry.py +22 -0
- coderouter/config/schemas.py +78 -0
- coderouter/ingress/anthropic_routes.py +108 -0
- coderouter/logging.py +5 -0
- coderouter/routing/capability.py +217 -0
- coderouter/routing/fallback.py +152 -0
- coderouter/translation/tool_repair.py +1267 -38
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.2.dist-info}/METADATA +28 -9
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.2.dist-info}/RECORD +12 -12
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.2.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.2.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.7.0.dist-info → coderouter_cli-2.7.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -23,19 +23,72 @@ 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)
|
|
30
|
+
5. Nested-XML name-attribute forms (R4a), where the tool name lives in a
|
|
31
|
+
``name`` attribute rather than the tag itself (L2 default-temp residual):
|
|
32
|
+
<tools><function name="echo" arguments='{...}'/></tools> (container form)
|
|
33
|
+
<function name="echo" arguments='{...}'/> (bare call-tag form)
|
|
34
|
+
<tool name="read_file" args='{...}'/> (args alias)
|
|
35
|
+
The container/call tags are a fixed known set; the ``name`` attribute must
|
|
36
|
+
be allow-listed and the ``arguments``/``args`` value is delegated to R1.
|
|
37
|
+
6. JSON envelope forms (R4b), where the model echoes a response wrapper:
|
|
38
|
+
{"tool_calls": [{"name": "echo", "arguments": {...}}, ...]} (OpenAI list)
|
|
39
|
+
{"function_call": {"name": ..., "arguments": "<JSON string>"}} (legacy)
|
|
40
|
+
The envelope is unwrapped and each inner object is run through the same
|
|
41
|
+
shape + allow-list validation; the legacy ``arguments`` string is
|
|
42
|
+
double-parsed. Works both fenced and bare.
|
|
43
|
+
7. Call-syntax forms (R4c), the "name + parens + args" family, recognised
|
|
44
|
+
ONLY inside a fenced block or on its own standalone line (never inline in
|
|
45
|
+
prose):
|
|
46
|
+
print(default_api.echo(message="probe")) (Gemma tool_code idiom)
|
|
47
|
+
echo(message="probe") (python kwargs)
|
|
48
|
+
echo(message: 'demo') (colon-separated kwargs)
|
|
49
|
+
write_note({"path": "a", "text": "b"}) (single JSON-object arg)
|
|
50
|
+
Guards: the function name must be allow-listed, the argument list must
|
|
51
|
+
parse completely (a broken inner JSON is left alone rather than executed
|
|
52
|
+
with corrupt args), and an explanatory example cue preceding the fence
|
|
53
|
+
suppresses extraction.
|
|
26
54
|
|
|
27
55
|
Each candidate is accepted only if it parses to one of:
|
|
28
56
|
{"name": <str>, "arguments": <dict | str>} # direct shape
|
|
29
57
|
{"function": {"name": <str>, "arguments": ...}} # OpenAI shape
|
|
58
|
+
... plus the R2 key aliases (tool / tool_name / parameters / input / args).
|
|
30
59
|
|
|
31
|
-
|
|
60
|
+
Lenient JSON (R1)
|
|
61
|
+
-----------------
|
|
62
|
+
When strict ``json.loads`` fails, a second, tolerant pass is attempted for the
|
|
63
|
+
malformed forms small models actually emit:
|
|
64
|
+
- double-braced objects ``{{...}}`` -> ``{...}`` (L2 default-temp form)
|
|
65
|
+
- trailing commas
|
|
66
|
+
- single-quoted strings / keys (Python-repr dicts)
|
|
67
|
+
- unquoted object keys
|
|
68
|
+
The lenient parse is a stdlib-only tokenizer; the resulting dict is still run
|
|
69
|
+
through the exact same shape + allow-list validation as a strict parse.
|
|
70
|
+
|
|
71
|
+
False-positive discipline
|
|
72
|
+
-------------------------
|
|
73
|
+
If `allowed_tool_names` is provided, the tool `name` must be in that set;
|
|
32
74
|
otherwise any tool-shaped JSON is accepted. Passing the allow-list is
|
|
33
|
-
strongly recommended
|
|
34
|
-
|
|
75
|
+
strongly recommended.
|
|
76
|
+
|
|
77
|
+
In addition, two context guards keep genuine documentation / source code from
|
|
78
|
+
being turned into executable tool calls, even when it contains an allow-listed
|
|
79
|
+
name:
|
|
80
|
+
- programming-language code fences (```python, ```js, ...) are protected:
|
|
81
|
+
their interior is never scanned by the bare-JSON or XML scanners.
|
|
82
|
+
- bare JSON / XML immediately introduced by an explanatory cue
|
|
83
|
+
("for example", "you would write", "is documented as", ...) is treated
|
|
84
|
+
as prose and left in place.
|
|
85
|
+
These guards are conservative: they only ever *suppress* an extraction, so a
|
|
86
|
+
name that is not in the allow-list is still never repaired.
|
|
35
87
|
"""
|
|
36
88
|
|
|
37
89
|
from __future__ import annotations
|
|
38
90
|
|
|
91
|
+
import contextlib
|
|
39
92
|
import json
|
|
40
93
|
import re
|
|
41
94
|
import uuid
|
|
@@ -45,30 +98,131 @@ __all__ = ["deduplicate_tool_calls", "repair_tool_calls_in_text"]
|
|
|
45
98
|
|
|
46
99
|
|
|
47
100
|
# ------------------------------------------------------------------
|
|
48
|
-
#
|
|
101
|
+
# R2: shape detection + normalisation (with key aliases)
|
|
49
102
|
# ------------------------------------------------------------------
|
|
50
103
|
|
|
104
|
+
# Accepted aliases for the tool-name key and the arguments key. Order does not
|
|
105
|
+
# matter for detection, but ambiguity (more than one distinct alias present)
|
|
106
|
+
# is treated as "not a tool call" and left untouched (safe side).
|
|
107
|
+
_NAME_KEYS = ("name", "tool", "tool_name")
|
|
108
|
+
_ARG_KEYS = ("arguments", "parameters", "input", "args")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _pick_unique(obj: dict[str, Any], keys: tuple[str, ...]) -> tuple[str | None, Any]:
|
|
112
|
+
"""Return (matched_key, value) if exactly one of ``keys`` is present.
|
|
113
|
+
|
|
114
|
+
If zero keys are present, returns (None, None). If more than one key is
|
|
115
|
+
present the result is ambiguous -> returns ("<ambiguous>", None) so callers
|
|
116
|
+
can decline to repair.
|
|
117
|
+
"""
|
|
118
|
+
present = [k for k in keys if k in obj]
|
|
119
|
+
if not present:
|
|
120
|
+
return None, None
|
|
121
|
+
if len(present) > 1:
|
|
122
|
+
return "<ambiguous>", None
|
|
123
|
+
k = present[0]
|
|
124
|
+
return k, obj[k]
|
|
125
|
+
|
|
51
126
|
|
|
52
127
|
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.
|
|
128
|
+
"""Return (name, arguments) if obj looks like a tool call, else None.
|
|
129
|
+
|
|
130
|
+
Recognises:
|
|
131
|
+
- direct shape with name-key alias + args-key alias
|
|
132
|
+
- OpenAI ``{"function": {...}}`` wrapper (recursed into)
|
|
133
|
+
Ambiguous objects (multiple name aliases or multiple arg aliases) are
|
|
134
|
+
rejected.
|
|
135
|
+
"""
|
|
54
136
|
if not isinstance(obj, dict):
|
|
55
137
|
return None
|
|
56
138
|
|
|
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
139
|
# OpenAI function shape: {"function": {"name": "...", "arguments": ...}}
|
|
63
140
|
fn = obj.get("function")
|
|
64
141
|
if isinstance(fn, dict):
|
|
65
|
-
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
142
|
+
inner = _looks_like_tool_call(fn, allowed)
|
|
143
|
+
if inner is not None:
|
|
144
|
+
return inner
|
|
145
|
+
# fall through: maybe the outer dict itself is tool-shaped
|
|
146
|
+
|
|
147
|
+
name_key, name = _pick_unique(obj, _NAME_KEYS)
|
|
148
|
+
if name_key == "<ambiguous>":
|
|
149
|
+
return None
|
|
150
|
+
arg_key, args = _pick_unique(obj, _ARG_KEYS)
|
|
151
|
+
if arg_key == "<ambiguous>":
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
if not isinstance(name, str) or name_key is None:
|
|
155
|
+
return None
|
|
156
|
+
if arg_key is None:
|
|
157
|
+
# A tool-shaped object must carry an arguments container to be a call;
|
|
158
|
+
# a lone {"name": "..."} is not distinguishable from ordinary data.
|
|
159
|
+
return None
|
|
160
|
+
if allowed is not None and name not in allowed:
|
|
161
|
+
return None
|
|
162
|
+
return name, args
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# R4b: JSON envelope keys whose value carries the actual tool call(s).
|
|
166
|
+
# {"tool_calls": [ {call}, {call}, ... ]} -> a list of calls
|
|
167
|
+
# {"function_call": {call}} -> a single call
|
|
168
|
+
# These are the OpenAI response-envelope shapes a model sometimes echoes back
|
|
169
|
+
# into the text body verbatim. The envelope wrapper is unwrapped and each inner
|
|
170
|
+
# object is validated by the same _looks_like_tool_call predicate.
|
|
171
|
+
_ENVELOPE_LIST_KEY = "tool_calls"
|
|
172
|
+
_ENVELOPE_SINGLE_KEY = "function_call"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _expand_tool_call_envelope(
|
|
176
|
+
obj: Any, allowed: set[str] | None
|
|
177
|
+
) -> list[tuple[str, Any]] | None:
|
|
178
|
+
"""Unwrap an R4b JSON envelope into a list of (name, arguments) tuples.
|
|
179
|
+
|
|
180
|
+
Recognises exactly two response-envelope wrappers:
|
|
181
|
+
- ``{"tool_calls": [ ... ]}`` (OpenAI tool_calls list)
|
|
182
|
+
- ``{"function_call": { ... }}`` (OpenAI legacy single call)
|
|
183
|
+
|
|
184
|
+
The dict must carry the envelope key and nothing else that would make it
|
|
185
|
+
ambiguous with a plain tool-call object (i.e. it must NOT itself already
|
|
186
|
+
look like a direct call). Every inner element must resolve to an
|
|
187
|
+
allow-listed call, otherwise the whole envelope is declined (all-or-nothing
|
|
188
|
+
keeps false positives at zero — a half-recognised wrapper is suspicious).
|
|
189
|
+
|
|
190
|
+
Returns the list of calls, or None if ``obj`` is not an envelope. An empty
|
|
191
|
+
envelope (no valid inner calls) also returns None.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(obj, dict):
|
|
194
|
+
return None
|
|
195
|
+
# If the object is itself a direct tool call, it is not an envelope; let the
|
|
196
|
+
# ordinary single-call path handle it (avoids double extraction).
|
|
197
|
+
if _looks_like_tool_call(obj, allowed) is not None:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
if _ENVELOPE_LIST_KEY in obj:
|
|
201
|
+
raw = obj[_ENVELOPE_LIST_KEY]
|
|
202
|
+
if not isinstance(raw, list) or not raw:
|
|
203
|
+
return None
|
|
204
|
+
calls: list[tuple[str, Any]] = []
|
|
205
|
+
for item in raw:
|
|
206
|
+
hit = _looks_like_tool_call(item, allowed)
|
|
207
|
+
if hit is None:
|
|
208
|
+
return None # all-or-nothing
|
|
209
|
+
calls.append(hit)
|
|
210
|
+
return calls or None
|
|
211
|
+
|
|
212
|
+
if _ENVELOPE_SINGLE_KEY in obj:
|
|
213
|
+
inner = obj[_ENVELOPE_SINGLE_KEY]
|
|
214
|
+
hit = _looks_like_tool_call(inner, allowed)
|
|
215
|
+
if hit is None:
|
|
216
|
+
return None
|
|
217
|
+
name, args = hit
|
|
218
|
+
# Legacy shape carries arguments as a JSON *string*; double-parse it so
|
|
219
|
+
# the normaliser emits a proper arguments object where possible. If the
|
|
220
|
+
# inner string is not valid JSON, keep it verbatim (bug-for-bug parity
|
|
221
|
+
# with bare_json_04's string-arguments behaviour).
|
|
222
|
+
if isinstance(args, str):
|
|
223
|
+
with contextlib.suppress(ValueError):
|
|
224
|
+
args = _parse_json_object(args)
|
|
225
|
+
return [(name, args)]
|
|
72
226
|
|
|
73
227
|
return None
|
|
74
228
|
|
|
@@ -89,17 +243,198 @@ def _normalise_to_openai_tool_call(name: str, arguments: Any) -> dict[str, Any]:
|
|
|
89
243
|
}
|
|
90
244
|
|
|
91
245
|
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# R1: lenient JSON parsing (only tried after strict json.loads fails)
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _strip_double_braces(s: str) -> str:
|
|
252
|
+
"""Collapse a fully double-braced object ``{{...}}`` to ``{...}``.
|
|
253
|
+
|
|
254
|
+
Only collapses when the whole (stripped) string is wrapped in exactly one
|
|
255
|
+
extra layer of braces on both ends — the L2 default-temp failure form
|
|
256
|
+
``{{"name": ...}}``. A single wrap is removed; deeper nesting is left as-is
|
|
257
|
+
(the tokenizer below handles the resulting single-braced object).
|
|
258
|
+
"""
|
|
259
|
+
t = s.strip()
|
|
260
|
+
if t.startswith("{{") and t.endswith("}}"):
|
|
261
|
+
return t[1:-1].strip()
|
|
262
|
+
return s
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _lenient_json_loads(raw: str) -> Any:
|
|
266
|
+
"""Best-effort parse of near-JSON emitted by small models.
|
|
267
|
+
|
|
268
|
+
Handles, in a single tolerant tokenizer pass:
|
|
269
|
+
- single-quoted strings ('...') -> double-quoted
|
|
270
|
+
- unquoted object keys -> quoted
|
|
271
|
+
- trailing commas -> dropped
|
|
272
|
+
- (double braces are stripped by the caller before this runs)
|
|
273
|
+
|
|
274
|
+
Returns the parsed object, or raises ValueError if the tokenizer cannot
|
|
275
|
+
produce syntactically valid JSON. Never executes the input.
|
|
276
|
+
"""
|
|
277
|
+
s = raw.strip()
|
|
278
|
+
if not s:
|
|
279
|
+
raise ValueError("empty")
|
|
280
|
+
|
|
281
|
+
out: list[str] = []
|
|
282
|
+
i = 0
|
|
283
|
+
n = len(s)
|
|
284
|
+
# Stack tracks whether the current container is an object ('{') so we can
|
|
285
|
+
# know when a bareword is a key (needs quoting) vs a value.
|
|
286
|
+
while i < n:
|
|
287
|
+
c = s[i]
|
|
288
|
+
|
|
289
|
+
# --- string literals: normalise quote char, copy verbatim otherwise ---
|
|
290
|
+
if c == '"' or c == "'":
|
|
291
|
+
quote = c
|
|
292
|
+
j = i + 1
|
|
293
|
+
buf = ['"']
|
|
294
|
+
while j < n:
|
|
295
|
+
cj = s[j]
|
|
296
|
+
if cj == "\\":
|
|
297
|
+
# keep escape pair verbatim (but a backslash-escaped single
|
|
298
|
+
# quote inside a single-quoted string becomes a plain ').
|
|
299
|
+
if j + 1 < n:
|
|
300
|
+
nxt = s[j + 1]
|
|
301
|
+
if quote == "'" and nxt == "'":
|
|
302
|
+
buf.append("'")
|
|
303
|
+
j += 2
|
|
304
|
+
continue
|
|
305
|
+
buf.append(cj)
|
|
306
|
+
buf.append(nxt)
|
|
307
|
+
j += 2
|
|
308
|
+
continue
|
|
309
|
+
buf.append("\\")
|
|
310
|
+
j += 1
|
|
311
|
+
continue
|
|
312
|
+
if cj == quote:
|
|
313
|
+
j += 1
|
|
314
|
+
break
|
|
315
|
+
if cj == '"' and quote == "'":
|
|
316
|
+
# a double quote inside a single-quoted string must be
|
|
317
|
+
# escaped in the JSON output.
|
|
318
|
+
buf.append('\\"')
|
|
319
|
+
j += 1
|
|
320
|
+
continue
|
|
321
|
+
buf.append(cj)
|
|
322
|
+
j += 1
|
|
323
|
+
else:
|
|
324
|
+
raise ValueError("unterminated string")
|
|
325
|
+
buf.append('"')
|
|
326
|
+
out.append("".join(buf))
|
|
327
|
+
i = j
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
# --- unquoted key/identifier: quote it ---
|
|
331
|
+
if c.isalpha() or c == "_":
|
|
332
|
+
j = i
|
|
333
|
+
while j < n and (s[j].isalnum() or s[j] in "_-."):
|
|
334
|
+
j += 1
|
|
335
|
+
word = s[i:j]
|
|
336
|
+
lowered = word.lower()
|
|
337
|
+
if lowered in ("true", "false", "null"):
|
|
338
|
+
out.append(lowered)
|
|
339
|
+
else:
|
|
340
|
+
# bareword -> treat as a quoted key/string token
|
|
341
|
+
out.append('"' + word + '"')
|
|
342
|
+
i = j
|
|
343
|
+
continue
|
|
344
|
+
|
|
345
|
+
# --- trailing comma: drop a comma that is followed only by ws then } or ] ---
|
|
346
|
+
if c == ",":
|
|
347
|
+
k = i + 1
|
|
348
|
+
while k < n and s[k] in " \t\r\n":
|
|
349
|
+
k += 1
|
|
350
|
+
if k < n and s[k] in "}]":
|
|
351
|
+
i += 1 # skip the comma
|
|
352
|
+
continue
|
|
353
|
+
out.append(",")
|
|
354
|
+
i += 1
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
out.append(c)
|
|
358
|
+
i += 1
|
|
359
|
+
|
|
360
|
+
candidate = "".join(out)
|
|
361
|
+
try:
|
|
362
|
+
return json.loads(candidate)
|
|
363
|
+
except json.JSONDecodeError as exc: # pragma: no cover - defensive
|
|
364
|
+
raise ValueError(f"lenient parse failed: {exc}") from exc
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _parse_json_object(body: str) -> Any:
|
|
368
|
+
"""Strict json.loads, then a lenient fallback. Raises ValueError on failure."""
|
|
369
|
+
try:
|
|
370
|
+
return json.loads(body)
|
|
371
|
+
except json.JSONDecodeError:
|
|
372
|
+
pass
|
|
373
|
+
return _lenient_json_loads(_strip_double_braces(body))
|
|
374
|
+
|
|
375
|
+
|
|
92
376
|
# ------------------------------------------------------------------
|
|
93
377
|
# Scanners: fenced code blocks, then balanced braces in remaining text
|
|
94
378
|
# ------------------------------------------------------------------
|
|
95
379
|
|
|
96
380
|
# Match ```json ... ``` or ``` ... ``` with anything after the fence tag line.
|
|
97
|
-
# Group 1 captures the body.
|
|
381
|
+
# Group 1 captures the (optional) language tag, group 2 the body.
|
|
98
382
|
_FENCED_RE = re.compile(
|
|
99
|
-
r"```(
|
|
383
|
+
r"```([\w+-]*)[ \t]*\r?\n(.*?)\r?\n?```",
|
|
100
384
|
re.DOTALL,
|
|
101
385
|
)
|
|
102
386
|
|
|
387
|
+
# Language tags that denote *real source code* (not a serialised tool call).
|
|
388
|
+
# A fence carrying one of these is protected: its interior is never scanned by
|
|
389
|
+
# the bare-JSON / XML scanners, so an allow-listed name written as a code
|
|
390
|
+
# literal inside such a fence is not turned into a tool call.
|
|
391
|
+
_CODE_FENCE_LANGS = frozenset(
|
|
392
|
+
{
|
|
393
|
+
"python",
|
|
394
|
+
"py",
|
|
395
|
+
"python3",
|
|
396
|
+
"js",
|
|
397
|
+
"javascript",
|
|
398
|
+
"ts",
|
|
399
|
+
"typescript",
|
|
400
|
+
"jsx",
|
|
401
|
+
"tsx",
|
|
402
|
+
"go",
|
|
403
|
+
"golang",
|
|
404
|
+
"rust",
|
|
405
|
+
"rs",
|
|
406
|
+
"java",
|
|
407
|
+
"c",
|
|
408
|
+
"cpp",
|
|
409
|
+
"c++",
|
|
410
|
+
"cs",
|
|
411
|
+
"csharp",
|
|
412
|
+
"ruby",
|
|
413
|
+
"rb",
|
|
414
|
+
"php",
|
|
415
|
+
"sh",
|
|
416
|
+
"bash",
|
|
417
|
+
"shell",
|
|
418
|
+
"zsh",
|
|
419
|
+
"sql",
|
|
420
|
+
"html",
|
|
421
|
+
"css",
|
|
422
|
+
"yaml",
|
|
423
|
+
"yml",
|
|
424
|
+
"toml",
|
|
425
|
+
"ini",
|
|
426
|
+
"kotlin",
|
|
427
|
+
"swift",
|
|
428
|
+
"scala",
|
|
429
|
+
"lua",
|
|
430
|
+
"perl",
|
|
431
|
+
"r",
|
|
432
|
+
"dart",
|
|
433
|
+
"elixir",
|
|
434
|
+
"haskell",
|
|
435
|
+
}
|
|
436
|
+
)
|
|
437
|
+
|
|
103
438
|
|
|
104
439
|
def _extract_tool_call_fenced_blocks(
|
|
105
440
|
text: str,
|
|
@@ -107,26 +442,51 @@ def _extract_tool_call_fenced_blocks(
|
|
|
107
442
|
) -> tuple[str, list[dict[str, Any]]]:
|
|
108
443
|
"""Pull tool-call-shaped ```...``` blocks out of text.
|
|
109
444
|
|
|
110
|
-
Only fenced blocks whose body parses
|
|
111
|
-
removed from the text and returned as
|
|
112
|
-
other fenced block (prose, real source
|
|
113
|
-
verbatim in the returned text — the
|
|
114
|
-
decision use the exact same predicate,
|
|
115
|
-
without also being surfaced as a tool
|
|
116
|
-
|
|
445
|
+
Only fenced blocks whose body parses (strictly or leniently) to a
|
|
446
|
+
recognised tool-call shape are removed from the text and returned as
|
|
447
|
+
normalised OpenAI tool_calls. Any other fenced block (prose, real source
|
|
448
|
+
code, non-tool JSON) is preserved verbatim in the returned text — the
|
|
449
|
+
removal decision and the extraction decision use the exact same predicate,
|
|
450
|
+
so a fenced block is never dropped without also being surfaced as a tool
|
|
451
|
+
call (bug H2).
|
|
452
|
+
|
|
453
|
+
Fences carrying a *programming-language* tag (``python``, ``js`` ...) are
|
|
454
|
+
protected: their body is never parsed as a tool call, even if it happens to
|
|
455
|
+
contain a tool-shaped literal. This is the code-fence false-positive guard.
|
|
117
456
|
|
|
118
457
|
Returns (text_without_tool_fences, tool_calls).
|
|
119
458
|
"""
|
|
120
459
|
tool_calls: list[dict[str, Any]] = []
|
|
121
460
|
|
|
122
461
|
def _repair(match: re.Match[str]) -> str:
|
|
123
|
-
|
|
462
|
+
lang = (match.group(1) or "").strip().lower()
|
|
463
|
+
body = match.group(2).strip()
|
|
464
|
+
if lang in _CODE_FENCE_LANGS:
|
|
465
|
+
return match.group(0) # protected source-code fence
|
|
466
|
+
# R4c: call-syntax family (name(...)) inside a non-code fence. Only
|
|
467
|
+
# attempted when the body is a single call line and its name is
|
|
468
|
+
# allow-listed with fully-parseable arguments; an example cue on the
|
|
469
|
+
# line(s) preceding the fence suppresses it.
|
|
470
|
+
if not body.startswith("{") and allowed is not None:
|
|
471
|
+
if not _preceded_by_prose_cue_before_fence(text, match.start()):
|
|
472
|
+
call_hits = _extract_call_syntax_lines(body, allowed)
|
|
473
|
+
if call_hits:
|
|
474
|
+
for name, args in call_hits:
|
|
475
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
476
|
+
return ""
|
|
477
|
+
return match.group(0) # keep other non-JSON fenced blocks (code)
|
|
124
478
|
if not body.startswith("{"):
|
|
125
479
|
return match.group(0) # keep non-JSON fenced blocks (e.g. code)
|
|
126
480
|
try:
|
|
127
|
-
obj =
|
|
128
|
-
except
|
|
481
|
+
obj = _parse_json_object(body)
|
|
482
|
+
except ValueError:
|
|
129
483
|
return match.group(0) # keep unparseable fenced blocks
|
|
484
|
+
# R4b: response-envelope wrappers ({"tool_calls": [...]}, ...).
|
|
485
|
+
envelope = _expand_tool_call_envelope(obj, allowed)
|
|
486
|
+
if envelope is not None:
|
|
487
|
+
for name, args in envelope:
|
|
488
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
489
|
+
return ""
|
|
130
490
|
hit = _looks_like_tool_call(obj, allowed)
|
|
131
491
|
if hit is None:
|
|
132
492
|
return match.group(0) # keep non-tool-call JSON blocks
|
|
@@ -138,12 +498,47 @@ def _extract_tool_call_fenced_blocks(
|
|
|
138
498
|
return cleaned, tool_calls
|
|
139
499
|
|
|
140
500
|
|
|
501
|
+
def _protected_code_spans(text: str) -> list[tuple[int, int]]:
|
|
502
|
+
"""Byte spans of programming-language code fences to exclude from scanning.
|
|
503
|
+
|
|
504
|
+
Returns (start, end) index pairs covering the *entire* fenced block
|
|
505
|
+
(fence markers included) for fences whose language tag is a real
|
|
506
|
+
programming language. Used to shield code from the bare-JSON / XML scanners.
|
|
507
|
+
"""
|
|
508
|
+
spans: list[tuple[int, int]] = []
|
|
509
|
+
for m in _FENCED_RE.finditer(text):
|
|
510
|
+
lang = (m.group(1) or "").strip().lower()
|
|
511
|
+
if lang in _CODE_FENCE_LANGS:
|
|
512
|
+
spans.append((m.start(), m.end()))
|
|
513
|
+
return spans
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _all_fence_spans(text: str) -> list[tuple[int, int]]:
|
|
517
|
+
"""Byte spans of *every* fenced block (any language tag or none).
|
|
518
|
+
|
|
519
|
+
Used by the standalone-line R4c scanner: a call line that survives inside a
|
|
520
|
+
fence was already offered to the fenced R4c path (and, if suppressed by an
|
|
521
|
+
example cue, must stay suppressed), so the standalone scanner must never
|
|
522
|
+
reach inside any fence.
|
|
523
|
+
"""
|
|
524
|
+
return [(m.start(), m.end()) for m in _FENCED_RE.finditer(text)]
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
|
|
528
|
+
return any(start <= pos < end for start, end in spans)
|
|
529
|
+
|
|
530
|
+
|
|
141
531
|
def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
142
532
|
"""Find top-level `{...}` JSON substrings by a brace-counter scan.
|
|
143
533
|
|
|
144
534
|
Returns a list of (start, end_exclusive, substring). Handles escape
|
|
145
535
|
sequences and string literals so braces inside JSON strings do not
|
|
146
536
|
confuse the counter. Malformed (unclosed) candidates are skipped.
|
|
537
|
+
|
|
538
|
+
Note: this deliberately matches strict JSON strings (double-quoted) for
|
|
539
|
+
brace balancing. Single-quoted / unquoted malformed objects are found by
|
|
540
|
+
:func:`_find_candidate_object_spans` instead, which brace-balances without
|
|
541
|
+
assuming JSON string syntax.
|
|
147
542
|
"""
|
|
148
543
|
out: list[tuple[int, int, str]] = []
|
|
149
544
|
n = len(text)
|
|
@@ -185,6 +580,764 @@ def _find_balanced_json_objects(text: str) -> list[tuple[int, int, str]]:
|
|
|
185
580
|
return out
|
|
186
581
|
|
|
187
582
|
|
|
583
|
+
def _find_candidate_object_spans(text: str) -> list[tuple[int, int, str]]:
|
|
584
|
+
"""Brace-balance ``{...}`` spans, tolerating single/double quoted strings.
|
|
585
|
+
|
|
586
|
+
Unlike :func:`_find_balanced_json_objects`, this counts braces while
|
|
587
|
+
respecting BOTH ``'`` and ``"`` string delimiters, so it can locate
|
|
588
|
+
Python-repr / single-quoted malformed objects for the lenient parser.
|
|
589
|
+
Returns (start, end_exclusive, substring).
|
|
590
|
+
"""
|
|
591
|
+
out: list[tuple[int, int, str]] = []
|
|
592
|
+
n = len(text)
|
|
593
|
+
i = 0
|
|
594
|
+
while i < n:
|
|
595
|
+
if text[i] != "{":
|
|
596
|
+
i += 1
|
|
597
|
+
continue
|
|
598
|
+
depth = 0
|
|
599
|
+
j = i
|
|
600
|
+
quote: str | None = None
|
|
601
|
+
escape = False
|
|
602
|
+
while j < n:
|
|
603
|
+
c = text[j]
|
|
604
|
+
if escape:
|
|
605
|
+
escape = False
|
|
606
|
+
elif quote is not None:
|
|
607
|
+
if c == "\\":
|
|
608
|
+
escape = True
|
|
609
|
+
elif c == quote:
|
|
610
|
+
quote = None
|
|
611
|
+
else:
|
|
612
|
+
if c == '"' or c == "'":
|
|
613
|
+
quote = c
|
|
614
|
+
elif c == "{":
|
|
615
|
+
depth += 1
|
|
616
|
+
elif c == "}":
|
|
617
|
+
depth -= 1
|
|
618
|
+
if depth == 0:
|
|
619
|
+
out.append((i, j + 1, text[i : j + 1]))
|
|
620
|
+
i = j + 1
|
|
621
|
+
break
|
|
622
|
+
j += 1
|
|
623
|
+
else:
|
|
624
|
+
i += 1
|
|
625
|
+
continue
|
|
626
|
+
return out
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# ------------------------------------------------------------------
|
|
630
|
+
# Prose-cue guard: bare JSON introduced as an *example* is not a call
|
|
631
|
+
# ------------------------------------------------------------------
|
|
632
|
+
|
|
633
|
+
# If one of these phrases appears immediately before a bare JSON object (within
|
|
634
|
+
# a short window, on the same clause), the object is being *described* rather
|
|
635
|
+
# than emitted as a call. Conservative: only suppresses, never forces a repair.
|
|
636
|
+
#
|
|
637
|
+
# The vocabulary is deliberately broad — documentation / example / "here is the
|
|
638
|
+
# format" framings all mark the following block as *illustrative*. It stays
|
|
639
|
+
# clear of tokens that appear in genuine descriptive prose which nonetheless
|
|
640
|
+
# precedes a real call (e.g. "The `echo` function echoes back the provided
|
|
641
|
+
# message." for python_call_04), so words like ``function`` / ``message`` are
|
|
642
|
+
# NOT cues.
|
|
643
|
+
_PROSE_CUE_RE = re.compile(
|
|
644
|
+
r"(?:"
|
|
645
|
+
r"for\s+example|for\s+instance|e\.?g\.?|such\s+as|"
|
|
646
|
+
r"you\s+(?:would|could|can|might)\s+write|"
|
|
647
|
+
r"you\s+would\s+(?:use|call)|"
|
|
648
|
+
r"(?:is|are)\s+documented(?:\s+as)?|documented(?:\s+like)?|"
|
|
649
|
+
r"looks?\s+like|written\s+as|"
|
|
650
|
+
r"as\s+follows|"
|
|
651
|
+
r"(?:calling\s+)?convention|"
|
|
652
|
+
r"below\s+is|"
|
|
653
|
+
r"(?:this|the|that)\s+format|the\s+format\s+is|"
|
|
654
|
+
r"sample|payload|"
|
|
655
|
+
r"syntax|signature|"
|
|
656
|
+
r"look(?:s|ing)?\s+in|"
|
|
657
|
+
r"例えば|たとえば|のように書"
|
|
658
|
+
r")"
|
|
659
|
+
r"[^\n{]{0,80}$",
|
|
660
|
+
re.IGNORECASE,
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _preceded_by_prose_cue(text: str, start: int) -> bool:
|
|
665
|
+
"""True if a documentation/example cue precedes position ``start``.
|
|
666
|
+
|
|
667
|
+
Two windows are checked so a cue on the *previous* line still guards a call
|
|
668
|
+
that a model placed on its own line under an introductory sentence
|
|
669
|
+
(``"Here's a sample payload:\n{...}"`` — negative_19 / negative_22):
|
|
670
|
+
|
|
671
|
+
1. the current same-clause lead-in — the object's own line, explicitly
|
|
672
|
+
sliced from the last newline, which catches inline "..., e.g. {...}"
|
|
673
|
+
framings; and
|
|
674
|
+
2. the immediately preceding non-blank line, matched whole, which catches
|
|
675
|
+
"Here's how it looks in this format:" one line above the object.
|
|
676
|
+
|
|
677
|
+
Design note (colon-terminated lead-ins): a legitimate call announcement such
|
|
678
|
+
as ``"I'll call it now:"`` ends in a cue-adjacent colon and, when it carries
|
|
679
|
+
a cue word (e.g. "sample", "payload", "format"), is deliberately suppressed
|
|
680
|
+
together with the illustrative ones. Under the FP-0%-first principle this
|
|
681
|
+
trade-off is accepted on purpose: losing the occasional genuine "here it
|
|
682
|
+
comes" preamble is preferable to executing a described example as a real
|
|
683
|
+
call. (The bare cue guard here still keys off the *vocabulary*, so a
|
|
684
|
+
cue-free colon lead-in like "Here's the tool call:" stays repairable; the
|
|
685
|
+
unconditional colon rule lives only on the call-syntax fence path.)
|
|
686
|
+
|
|
687
|
+
Conservative: only ever suppresses, never forces a repair.
|
|
688
|
+
"""
|
|
689
|
+
prefix = text[:start]
|
|
690
|
+
# (1) same-clause lead-in. Slice the object's *own* line explicitly from the
|
|
691
|
+
# last newline so the cue anchor is measured against the current line's
|
|
692
|
+
# real end, not the whole-string end. Anchoring on ``prefix[-200:]``
|
|
693
|
+
# alone breaks when the object sits at a line start (prefix ends "\n\n"):
|
|
694
|
+
# the ``[^\n{]`` tail then collapses to zero width and the anchor can no
|
|
695
|
+
# longer reach a cue that lives before the newline. Isolating the current
|
|
696
|
+
# line keeps window (1) strictly same-line, leaving cross-line cues to
|
|
697
|
+
# window (2).
|
|
698
|
+
nl = prefix.rfind("\n")
|
|
699
|
+
current_line = prefix[nl + 1 :] if nl != -1 else prefix
|
|
700
|
+
if _PROSE_CUE_RE.search(current_line[-200:]):
|
|
701
|
+
return True
|
|
702
|
+
# (2) the nearest non-blank line above the object.
|
|
703
|
+
lines = prefix.splitlines()
|
|
704
|
+
while lines and not lines[-1].strip():
|
|
705
|
+
lines.pop()
|
|
706
|
+
# Drop the current (partial) line the object sits on — but ONLY when it
|
|
707
|
+
# actually exists. When ``prefix`` ends in a newline the object is at a line
|
|
708
|
+
# start, so ``splitlines()`` already excludes any current-line residue and
|
|
709
|
+
# the last surviving entry *is* the lead-in line; popping it unconditionally
|
|
710
|
+
# would discard the cue line itself (negative_19 / negative_23 off-by-one).
|
|
711
|
+
if lines and not prefix.endswith("\n"):
|
|
712
|
+
lines.pop()
|
|
713
|
+
while lines and not lines[-1].strip():
|
|
714
|
+
lines.pop()
|
|
715
|
+
if lines:
|
|
716
|
+
lead = lines[-1].strip()
|
|
717
|
+
if _PROSE_CUE_RE.search(lead[-200:]):
|
|
718
|
+
return True
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _preceded_by_prose_cue_before_fence(text: str, fence_start: int) -> bool:
|
|
723
|
+
"""True if the last non-blank line before a fence is an example cue.
|
|
724
|
+
|
|
725
|
+
The bare-JSON cue guard (:func:`_preceded_by_prose_cue`) only looks at the
|
|
726
|
+
immediate same-clause lead-in, which does not span the blank line(s) that
|
|
727
|
+
separate an introductory sentence from a following ```...``` fence. R4c
|
|
728
|
+
call-syntax fences are commonly introduced on a *separate* line
|
|
729
|
+
("For example, you would write:\\n\\n```..."), so this looks back across
|
|
730
|
+
blank lines to the nearest non-empty line and applies the same cue regex.
|
|
731
|
+
|
|
732
|
+
This is the discriminator between negative_15 (example-cue + fenced call ->
|
|
733
|
+
suppress) and python_call_04 (descriptive prose + fenced call -> repair).
|
|
734
|
+
Conservative: only ever suppresses, never forces a repair.
|
|
735
|
+
"""
|
|
736
|
+
prefix = text[:fence_start]
|
|
737
|
+
lines = prefix.splitlines()
|
|
738
|
+
# Drop the (possibly empty) trailing fragment and any blank separator lines
|
|
739
|
+
# so we land on the last line carrying actual prose.
|
|
740
|
+
while lines and not lines[-1].strip():
|
|
741
|
+
lines.pop()
|
|
742
|
+
if not lines:
|
|
743
|
+
return False
|
|
744
|
+
lead = lines[-1].strip()
|
|
745
|
+
# A lead-in line ending in a colon ("... as follows:", "... this format:",
|
|
746
|
+
# a long "For example, ...:") is an introduction to an illustrative block,
|
|
747
|
+
# so a call-syntax fence that follows it is being *shown*, not invoked. This
|
|
748
|
+
# is a general signal that does not depend on the cue vocabulary and so is
|
|
749
|
+
# robust to paraphrase and to arbitrarily long lead-in lines (negative_18 /
|
|
750
|
+
# 20 / 21). It is applied ONLY on this call-syntax fence path — bare JSON /
|
|
751
|
+
# JSON-envelope fences are introduced by legitimate colon lead-ins too
|
|
752
|
+
# ("Here's the tool call:", "First:") and must stay repairable.
|
|
753
|
+
if lead.endswith((":", ":")): # noqa: RUF001 — full-width colon (U+FF1A) is intentional
|
|
754
|
+
return True
|
|
755
|
+
# Otherwise fall back to the cue vocabulary anywhere in the lead-in line.
|
|
756
|
+
return bool(_PROSE_CUE_RE.search(lead[-200:]))
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
# ------------------------------------------------------------------
|
|
760
|
+
# R4c: call-syntax family (name + parens + args)
|
|
761
|
+
# ------------------------------------------------------------------
|
|
762
|
+
|
|
763
|
+
# Head of a call line: an optional ``print(`` wrapper, an optional
|
|
764
|
+
# ``default_api.`` prefix (Gemma tool_code idiom), then the tool name and its
|
|
765
|
+
# opening paren. Anchored at the start of a (stripped) line so an inline
|
|
766
|
+
# mid-prose call is never a candidate.
|
|
767
|
+
_CALL_HEAD_RE = re.compile(
|
|
768
|
+
r"^(?P<print>print\s*\(\s*)?"
|
|
769
|
+
r"(?:default_api\s*\.\s*)?"
|
|
770
|
+
r"(?P<name>[A-Za-z_]\w*)\s*\("
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# A single ``key = value`` / ``key : value`` kwargs pair.
|
|
774
|
+
_CALL_KWARG_RE = re.compile(r"^(?P<key>[A-Za-z_]\w*)\s*(?:=|:)\s*(?P<val>.*)$", re.DOTALL)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _find_matching_paren(s: str, open_idx: str | int) -> int:
|
|
778
|
+
"""Index of the ``)`` matching the ``(`` at ``open_idx``, or -1.
|
|
779
|
+
|
|
780
|
+
Balances parentheses while respecting single/double quoted strings (so a
|
|
781
|
+
paren inside a string literal does not close the call).
|
|
782
|
+
"""
|
|
783
|
+
depth = 0
|
|
784
|
+
i = int(open_idx)
|
|
785
|
+
n = len(s)
|
|
786
|
+
quote: str | None = None
|
|
787
|
+
escape = False
|
|
788
|
+
while i < n:
|
|
789
|
+
c = s[i]
|
|
790
|
+
if escape:
|
|
791
|
+
escape = False
|
|
792
|
+
elif quote is not None:
|
|
793
|
+
if c == "\\":
|
|
794
|
+
escape = True
|
|
795
|
+
elif c == quote:
|
|
796
|
+
quote = None
|
|
797
|
+
else:
|
|
798
|
+
if c == '"' or c == "'":
|
|
799
|
+
quote = c
|
|
800
|
+
elif c == "(":
|
|
801
|
+
depth += 1
|
|
802
|
+
elif c == ")":
|
|
803
|
+
depth -= 1
|
|
804
|
+
if depth == 0:
|
|
805
|
+
return i
|
|
806
|
+
i += 1
|
|
807
|
+
return -1
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _split_top_level_commas(body: str) -> list[str] | None:
|
|
811
|
+
"""Split ``body`` on top-level commas, respecting quotes and brackets.
|
|
812
|
+
|
|
813
|
+
Returns the list of segments, or None if the string/bracket nesting is
|
|
814
|
+
unbalanced (which means the arguments are corrupt and must not be repaired).
|
|
815
|
+
"""
|
|
816
|
+
parts: list[str] = []
|
|
817
|
+
cur: list[str] = []
|
|
818
|
+
depth = 0
|
|
819
|
+
quote: str | None = None
|
|
820
|
+
escape = False
|
|
821
|
+
for c in body:
|
|
822
|
+
if escape:
|
|
823
|
+
cur.append(c)
|
|
824
|
+
escape = False
|
|
825
|
+
continue
|
|
826
|
+
if quote is not None:
|
|
827
|
+
cur.append(c)
|
|
828
|
+
if c == "\\":
|
|
829
|
+
escape = True
|
|
830
|
+
elif c == quote:
|
|
831
|
+
quote = None
|
|
832
|
+
continue
|
|
833
|
+
if c == '"' or c == "'":
|
|
834
|
+
quote = c
|
|
835
|
+
cur.append(c)
|
|
836
|
+
continue
|
|
837
|
+
if c in "([{":
|
|
838
|
+
depth += 1
|
|
839
|
+
cur.append(c)
|
|
840
|
+
continue
|
|
841
|
+
if c in ")]}":
|
|
842
|
+
depth -= 1
|
|
843
|
+
cur.append(c)
|
|
844
|
+
continue
|
|
845
|
+
if c == "," and depth == 0:
|
|
846
|
+
parts.append("".join(cur))
|
|
847
|
+
cur = []
|
|
848
|
+
continue
|
|
849
|
+
cur.append(c)
|
|
850
|
+
if quote is not None or depth != 0:
|
|
851
|
+
return None
|
|
852
|
+
tail = "".join(cur).strip()
|
|
853
|
+
if tail:
|
|
854
|
+
parts.append(tail)
|
|
855
|
+
return parts
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _parse_call_value(raw: str) -> tuple[bool, Any]:
|
|
859
|
+
"""Parse a single kwargs value. Returns (ok, value).
|
|
860
|
+
|
|
861
|
+
Tries strict JSON, then the lenient JSON pipe (which normalises single
|
|
862
|
+
quotes, unquoted words, etc.). ``ok`` is False when the value cannot be
|
|
863
|
+
parsed at all, so a corrupt argument fails the whole call (guard b).
|
|
864
|
+
"""
|
|
865
|
+
v = raw.strip()
|
|
866
|
+
if not v:
|
|
867
|
+
return False, None
|
|
868
|
+
try:
|
|
869
|
+
return True, json.loads(v)
|
|
870
|
+
except json.JSONDecodeError:
|
|
871
|
+
pass
|
|
872
|
+
try:
|
|
873
|
+
return True, _lenient_json_loads(v)
|
|
874
|
+
except ValueError:
|
|
875
|
+
return False, None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _parse_call_args(body: str, name: str) -> tuple[str, Any] | None:
|
|
879
|
+
"""Parse a call's argument list into (name, arguments) or None.
|
|
880
|
+
|
|
881
|
+
Handles all three R4c argument styles with a single splitter:
|
|
882
|
+
- ``key="value"`` / ``key=value`` (Python kwargs)
|
|
883
|
+
- ``key: 'value'`` (colon-separated, Ruby/Swift flavour)
|
|
884
|
+
- ``({...})`` a single JSON-object positional argument (JS flavour)
|
|
885
|
+
|
|
886
|
+
Every argument must parse completely; a broken inner value causes the whole
|
|
887
|
+
call to be declined (guard b — a form-only fix with corrupt args is more
|
|
888
|
+
harmful than no repair).
|
|
889
|
+
"""
|
|
890
|
+
body = body.strip()
|
|
891
|
+
if not body:
|
|
892
|
+
# Zero-arg call: valid, empty arguments object.
|
|
893
|
+
return name, {}
|
|
894
|
+
|
|
895
|
+
# Single JSON-object positional argument: write_note({...}).
|
|
896
|
+
if body.startswith("{"):
|
|
897
|
+
try:
|
|
898
|
+
obj = _parse_json_object(body)
|
|
899
|
+
except ValueError:
|
|
900
|
+
return None
|
|
901
|
+
if not isinstance(obj, dict):
|
|
902
|
+
return None
|
|
903
|
+
return name, obj
|
|
904
|
+
|
|
905
|
+
segments = _split_top_level_commas(body)
|
|
906
|
+
if not segments:
|
|
907
|
+
return None
|
|
908
|
+
out: dict[str, Any] = {}
|
|
909
|
+
for seg in segments:
|
|
910
|
+
m = _CALL_KWARG_RE.match(seg.strip())
|
|
911
|
+
if not m:
|
|
912
|
+
return None # positional args / unparseable -> decline
|
|
913
|
+
ok, val = _parse_call_value(m.group("val"))
|
|
914
|
+
if not ok:
|
|
915
|
+
return None
|
|
916
|
+
out[m.group("key")] = val
|
|
917
|
+
return name, out
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _extract_one_call_line(line: str, allowed: set[str]) -> tuple[str, Any] | None:
|
|
921
|
+
"""Extract a single call-syntax invocation from one standalone line.
|
|
922
|
+
|
|
923
|
+
The line must be *entirely* a call (optionally wrapped in ``print(...)``);
|
|
924
|
+
trailing garbage after the closing paren disqualifies it, so an inline
|
|
925
|
+
call embedded in a sentence is never matched. The name must be allow-listed
|
|
926
|
+
and the arguments must parse fully.
|
|
927
|
+
"""
|
|
928
|
+
stripped = line.strip()
|
|
929
|
+
m = _CALL_HEAD_RE.match(stripped)
|
|
930
|
+
if not m:
|
|
931
|
+
return None
|
|
932
|
+
name = m.group("name")
|
|
933
|
+
if name not in allowed:
|
|
934
|
+
return None
|
|
935
|
+
open_idx = m.end() - 1
|
|
936
|
+
close_idx = _find_matching_paren(stripped, open_idx)
|
|
937
|
+
if close_idx < 0:
|
|
938
|
+
return None
|
|
939
|
+
inner = stripped[open_idx + 1 : close_idx]
|
|
940
|
+
rest = stripped[close_idx + 1 :].strip()
|
|
941
|
+
if m.group("print"):
|
|
942
|
+
# A print(...) wrapper must be closed by exactly one trailing ')'.
|
|
943
|
+
if not rest.startswith(")"):
|
|
944
|
+
return None
|
|
945
|
+
rest = rest[1:].strip()
|
|
946
|
+
if rest:
|
|
947
|
+
return None # trailing garbage -> not a clean standalone call
|
|
948
|
+
return _parse_call_args(inner, name)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def _extract_call_syntax_lines(
|
|
952
|
+
block: str, allowed: set[str]
|
|
953
|
+
) -> list[tuple[str, Any]]:
|
|
954
|
+
"""Extract call-syntax invocations from the non-blank lines of ``block``.
|
|
955
|
+
|
|
956
|
+
Used for fence interiors (one or more call lines). Every non-blank line
|
|
957
|
+
must resolve to an allow-listed call with fully-parseable arguments; if any
|
|
958
|
+
line fails, the whole block is declined (all-or-nothing keeps a mixed
|
|
959
|
+
code/call fence from being partially executed).
|
|
960
|
+
"""
|
|
961
|
+
lines = [ln for ln in block.splitlines() if ln.strip()]
|
|
962
|
+
if not lines:
|
|
963
|
+
return []
|
|
964
|
+
hits: list[tuple[str, Any]] = []
|
|
965
|
+
for ln in lines:
|
|
966
|
+
hit = _extract_one_call_line(ln, allowed)
|
|
967
|
+
if hit is None:
|
|
968
|
+
return []
|
|
969
|
+
hits.append(hit)
|
|
970
|
+
return hits
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _extract_r4c_standalone_lines(
|
|
974
|
+
text: str,
|
|
975
|
+
allowed: set[str],
|
|
976
|
+
protected: list[tuple[int, int]],
|
|
977
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
978
|
+
"""Extract R4c call-syntax invocations that stand alone on their own line.
|
|
979
|
+
|
|
980
|
+
Complements the fenced R4c path: a call like ``write_note({...})`` may
|
|
981
|
+
appear bare (no fence) as the whole response or on its own line. Each
|
|
982
|
+
candidate line must be *entirely* a call (``_extract_one_call_line``
|
|
983
|
+
enforces the no-trailing-garbage rule), so an inline call embedded in a
|
|
984
|
+
sentence is never a candidate. Lines inside a protected code fence, or
|
|
985
|
+
introduced by an example cue, are skipped.
|
|
986
|
+
|
|
987
|
+
Returns (text_with_call_lines_removed, tool_calls).
|
|
988
|
+
"""
|
|
989
|
+
if "(" not in text:
|
|
990
|
+
return text, []
|
|
991
|
+
tool_calls: list[dict[str, Any]] = []
|
|
992
|
+
out_lines: list[str] = []
|
|
993
|
+
pos = 0
|
|
994
|
+
changed = False
|
|
995
|
+
for line in text.splitlines(keepends=True):
|
|
996
|
+
line_start = pos
|
|
997
|
+
pos += len(line)
|
|
998
|
+
stripped = line.strip()
|
|
999
|
+
if not stripped:
|
|
1000
|
+
out_lines.append(line)
|
|
1001
|
+
continue
|
|
1002
|
+
if _in_spans(line_start, protected):
|
|
1003
|
+
out_lines.append(line)
|
|
1004
|
+
continue
|
|
1005
|
+
if _preceded_by_prose_cue(text, line_start):
|
|
1006
|
+
out_lines.append(line)
|
|
1007
|
+
continue
|
|
1008
|
+
hit = _extract_one_call_line(stripped, allowed)
|
|
1009
|
+
if hit is None:
|
|
1010
|
+
out_lines.append(line)
|
|
1011
|
+
continue
|
|
1012
|
+
name, args = hit
|
|
1013
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
1014
|
+
changed = True
|
|
1015
|
+
# Drop the call line entirely (keep a trailing newline structure sane).
|
|
1016
|
+
if not changed:
|
|
1017
|
+
return text, []
|
|
1018
|
+
return "".join(out_lines), tool_calls
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# ------------------------------------------------------------------
|
|
1022
|
+
# R3: XML-flavoured tool-call forms
|
|
1023
|
+
# ------------------------------------------------------------------
|
|
1024
|
+
|
|
1025
|
+
# Tags that are known NOT to be tool calls: reasoning wrappers and common HTML.
|
|
1026
|
+
_NON_TOOL_TAGS = frozenset(
|
|
1027
|
+
{
|
|
1028
|
+
"think",
|
|
1029
|
+
"thinking",
|
|
1030
|
+
"thought",
|
|
1031
|
+
"reasoning",
|
|
1032
|
+
"scratchpad",
|
|
1033
|
+
"reflection",
|
|
1034
|
+
"answer",
|
|
1035
|
+
"final",
|
|
1036
|
+
"p",
|
|
1037
|
+
"div",
|
|
1038
|
+
"span",
|
|
1039
|
+
"ul",
|
|
1040
|
+
"ol",
|
|
1041
|
+
"li",
|
|
1042
|
+
"a",
|
|
1043
|
+
"b",
|
|
1044
|
+
"i",
|
|
1045
|
+
"em",
|
|
1046
|
+
"strong",
|
|
1047
|
+
"code",
|
|
1048
|
+
"pre",
|
|
1049
|
+
"br",
|
|
1050
|
+
"hr",
|
|
1051
|
+
"h1",
|
|
1052
|
+
"h2",
|
|
1053
|
+
"h3",
|
|
1054
|
+
"h4",
|
|
1055
|
+
"h5",
|
|
1056
|
+
"h6",
|
|
1057
|
+
"table",
|
|
1058
|
+
"tr",
|
|
1059
|
+
"td",
|
|
1060
|
+
"th",
|
|
1061
|
+
"img",
|
|
1062
|
+
"button",
|
|
1063
|
+
"form",
|
|
1064
|
+
"input",
|
|
1065
|
+
"label",
|
|
1066
|
+
"section",
|
|
1067
|
+
"article",
|
|
1068
|
+
"header",
|
|
1069
|
+
"footer",
|
|
1070
|
+
"nav",
|
|
1071
|
+
"tool", # handled separately as a generic wrapper (body carries name)
|
|
1072
|
+
}
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
# Generic wrapper tags whose *body* carries the actual tool-call JSON.
|
|
1076
|
+
_WRAPPER_TAGS = frozenset({"tool", "tool_call", "function_call", "invoke"})
|
|
1077
|
+
|
|
1078
|
+
# <tag attr="v" .../> or <tag attr="v" ...> (self-closing or open; we only
|
|
1079
|
+
# treat the self-closing / immediately-closed form as an attribute call).
|
|
1080
|
+
_XML_SELFCLOSE_RE = re.compile(
|
|
1081
|
+
r"<([A-Za-z_][\w.-]*)((?:\s+[\w.:-]+\s*=\s*\"[^\"]*\")*)\s*/>",
|
|
1082
|
+
)
|
|
1083
|
+
# <tag> ... </tag> wrapper.
|
|
1084
|
+
_XML_WRAPPER_RE = re.compile(
|
|
1085
|
+
r"<([A-Za-z_][\w.-]*)\s*>(.*?)</\1\s*>",
|
|
1086
|
+
re.DOTALL,
|
|
1087
|
+
)
|
|
1088
|
+
_XML_ATTR_RE = re.compile(r"([\w.:-]+)\s*=\s*\"([^\"]*)\"")
|
|
1089
|
+
|
|
1090
|
+
# R4a: nested-XML name-attribute forms.
|
|
1091
|
+
# Known call/container tags whose ``name`` attribute (not the tag itself)
|
|
1092
|
+
# carries the tool name. Restricting to this fixed set — rather than treating
|
|
1093
|
+
# any tag with a ``name`` attribute as a call — keeps arbitrary markup (e.g.
|
|
1094
|
+
# <input name="q"/>) from being mistaken for a tool call.
|
|
1095
|
+
_R4A_CALL_TAGS = frozenset({"function", "tool", "function_call", "invoke", "tool_call"})
|
|
1096
|
+
# Container tags that merely wrap one or more call tags; scanned through, never
|
|
1097
|
+
# themselves a call.
|
|
1098
|
+
_R4A_CONTAINER_TAGS = frozenset({"tools", "tool_calls", "function_calls", "invoke"})
|
|
1099
|
+
# A self-closing call tag: <function name="..." arguments='...'/>. Attribute
|
|
1100
|
+
# values may be single- OR double-quoted; the ``arguments`` value is captured
|
|
1101
|
+
# raw and delegated to the JSON pipe (R1/R2). ``\1`` on the quote char keeps the
|
|
1102
|
+
# value greedy up to the matching closing quote.
|
|
1103
|
+
_R4A_ATTR_RE = re.compile(
|
|
1104
|
+
r"""([\w.:-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')""",
|
|
1105
|
+
)
|
|
1106
|
+
_R4A_SELFCLOSE_RE = re.compile(
|
|
1107
|
+
r"""<([A-Za-z_][\w.-]*)((?:\s+[\w.:-]+\s*=\s*(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'))+)\s*/>""",
|
|
1108
|
+
)
|
|
1109
|
+
# R4a attribute keys that hold the tool name / the arguments payload.
|
|
1110
|
+
_R4A_NAME_ATTRS = ("name",)
|
|
1111
|
+
_R4A_ARG_ATTRS = ("arguments", "args", "parameters", "input")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _r4a_parse_attrs(attr_blob: str) -> dict[str, str]:
|
|
1115
|
+
"""Parse an XML attribute blob into {key: value}, unescaping quoted values.
|
|
1116
|
+
|
|
1117
|
+
A double-quoted value may carry backslash-escaped inner quotes
|
|
1118
|
+
(``arguments="{\\"command\\": ...}"``); those are unescaped so the value is
|
|
1119
|
+
valid JSON before it reaches the parse pipe. Single-quoted values are taken
|
|
1120
|
+
verbatim (their inner double quotes are already literal JSON).
|
|
1121
|
+
"""
|
|
1122
|
+
out: dict[str, str] = {}
|
|
1123
|
+
for m in _R4A_ATTR_RE.finditer(attr_blob):
|
|
1124
|
+
key = m.group(1)
|
|
1125
|
+
if m.group(2) is not None: # double-quoted
|
|
1126
|
+
val = m.group(2).replace('\\"', '"').replace("\\\\", "\\")
|
|
1127
|
+
else: # single-quoted
|
|
1128
|
+
val = m.group(3) or ""
|
|
1129
|
+
out[key] = val
|
|
1130
|
+
return out
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _r4a_build_call(
|
|
1134
|
+
attrs: dict[str, str], allowed: set[str]
|
|
1135
|
+
) -> tuple[str, Any] | None:
|
|
1136
|
+
"""Turn parsed R4a attributes into (name, arguments) if valid, else None."""
|
|
1137
|
+
name = None
|
|
1138
|
+
for k in _R4A_NAME_ATTRS:
|
|
1139
|
+
if k in attrs:
|
|
1140
|
+
name = attrs[k]
|
|
1141
|
+
break
|
|
1142
|
+
if not name or name not in allowed:
|
|
1143
|
+
return None
|
|
1144
|
+
args: Any = {}
|
|
1145
|
+
for k in _R4A_ARG_ATTRS:
|
|
1146
|
+
if k in attrs:
|
|
1147
|
+
raw = attrs[k].strip()
|
|
1148
|
+
if not raw:
|
|
1149
|
+
args = {}
|
|
1150
|
+
break
|
|
1151
|
+
try:
|
|
1152
|
+
args = _parse_json_object(raw)
|
|
1153
|
+
except ValueError:
|
|
1154
|
+
return None # malformed arguments -> decline (safe side)
|
|
1155
|
+
break
|
|
1156
|
+
return name, args
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _extract_r4a_nested_xml(
|
|
1160
|
+
text: str,
|
|
1161
|
+
allowed: set[str] | None,
|
|
1162
|
+
guard: list[tuple[int, int]],
|
|
1163
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
1164
|
+
"""Extract R4a nested-XML name-attribute tool calls.
|
|
1165
|
+
|
|
1166
|
+
Handles both the bare call tag (``<function name=.../>``) and the same tag
|
|
1167
|
+
inside a container (``<tools>...</tools>``). Containers are transparent —
|
|
1168
|
+
the scanner simply finds every allow-listed call tag, wherever it sits, and
|
|
1169
|
+
removes it; a lone empty ``<tools></tools>`` shell left behind is trimmed by
|
|
1170
|
+
residue cleanup. Reasoning-block / example-cue guards are honoured.
|
|
1171
|
+
|
|
1172
|
+
Returns (text_with_calls_removed, tool_calls).
|
|
1173
|
+
"""
|
|
1174
|
+
if allowed is None or "<" not in text:
|
|
1175
|
+
return text, []
|
|
1176
|
+
tool_calls: list[dict[str, Any]] = []
|
|
1177
|
+
removals: list[tuple[int, int]] = []
|
|
1178
|
+
for m in _R4A_SELFCLOSE_RE.finditer(text):
|
|
1179
|
+
tag = m.group(1).lower()
|
|
1180
|
+
if tag not in _R4A_CALL_TAGS:
|
|
1181
|
+
continue
|
|
1182
|
+
if _in_spans(m.start(), guard) or _preceded_by_prose_cue(text, m.start()):
|
|
1183
|
+
continue
|
|
1184
|
+
attrs = _r4a_parse_attrs(m.group(2) or "")
|
|
1185
|
+
hit = _r4a_build_call(attrs, allowed)
|
|
1186
|
+
if hit is None:
|
|
1187
|
+
continue
|
|
1188
|
+
name, args = hit
|
|
1189
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
1190
|
+
removals.append((m.start(), m.end()))
|
|
1191
|
+
|
|
1192
|
+
if not removals:
|
|
1193
|
+
return text, []
|
|
1194
|
+
# Also sweep up now-empty container shells around removed calls.
|
|
1195
|
+
cleaned = text
|
|
1196
|
+
removals.sort()
|
|
1197
|
+
for s, e in reversed(removals):
|
|
1198
|
+
cleaned = cleaned[:s] + cleaned[e:]
|
|
1199
|
+
for ctag in _R4A_CONTAINER_TAGS:
|
|
1200
|
+
cleaned = re.sub(
|
|
1201
|
+
rf"<{ctag}\s*>\s*</{ctag}\s*>", "", cleaned, flags=re.IGNORECASE
|
|
1202
|
+
)
|
|
1203
|
+
return cleaned, tool_calls
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _known_non_tool_tag_ranges(text: str) -> list[tuple[int, int]]:
|
|
1207
|
+
"""Ranges covered by <think>...</think> (and similar) reasoning blocks.
|
|
1208
|
+
|
|
1209
|
+
XML extraction must not reach inside these, so a tool-shaped tag mentioned
|
|
1210
|
+
in reasoning is never executed.
|
|
1211
|
+
"""
|
|
1212
|
+
ranges: list[tuple[int, int]] = []
|
|
1213
|
+
for tag in ("think", "thinking", "thought", "reasoning", "scratchpad"):
|
|
1214
|
+
for m in re.finditer(
|
|
1215
|
+
rf"<{tag}\b[^>]*>.*?</{tag}\s*>", text, re.DOTALL | re.IGNORECASE
|
|
1216
|
+
):
|
|
1217
|
+
ranges.append((m.start(), m.end()))
|
|
1218
|
+
return ranges
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _extract_xml_tool_calls(
|
|
1222
|
+
text: str,
|
|
1223
|
+
allowed: set[str] | None,
|
|
1224
|
+
protected: list[tuple[int, int]],
|
|
1225
|
+
) -> tuple[str, list[dict[str, Any]]]:
|
|
1226
|
+
"""Extract R3 XML-flavoured tool calls, honouring guards.
|
|
1227
|
+
|
|
1228
|
+
Returns (text_with_xml_calls_removed, tool_calls). Only forms whose tag /
|
|
1229
|
+
inner name resolve to an allow-listed tool are removed.
|
|
1230
|
+
"""
|
|
1231
|
+
if allowed is None:
|
|
1232
|
+
# Without an allow-list, XML extraction is too risky (any <tag> could
|
|
1233
|
+
# look like a call). Skip R3 entirely — keeps false positives at zero.
|
|
1234
|
+
return text, []
|
|
1235
|
+
if "<" not in text:
|
|
1236
|
+
return text, []
|
|
1237
|
+
|
|
1238
|
+
tool_calls: list[dict[str, Any]] = []
|
|
1239
|
+
reasoning = _known_non_tool_tag_ranges(text)
|
|
1240
|
+
guard = protected + reasoning
|
|
1241
|
+
removals: list[tuple[int, int]] = []
|
|
1242
|
+
|
|
1243
|
+
def _blocked(pos: int) -> bool:
|
|
1244
|
+
return _in_spans(pos, guard) or _preceded_by_prose_cue(text, pos)
|
|
1245
|
+
|
|
1246
|
+
# --- wrapper form: <tool>{JSON}</tool> and <name>{JSON}</name> ---
|
|
1247
|
+
for m in _XML_WRAPPER_RE.finditer(text):
|
|
1248
|
+
tag = m.group(1)
|
|
1249
|
+
inner = m.group(2).strip()
|
|
1250
|
+
tag_l = tag.lower()
|
|
1251
|
+
if _blocked(m.start()):
|
|
1252
|
+
continue
|
|
1253
|
+
if tag_l in _WRAPPER_TAGS:
|
|
1254
|
+
# Body carries the whole tool-call object.
|
|
1255
|
+
if not inner.startswith("{"):
|
|
1256
|
+
continue
|
|
1257
|
+
try:
|
|
1258
|
+
obj = _parse_json_object(inner)
|
|
1259
|
+
except ValueError:
|
|
1260
|
+
continue
|
|
1261
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
1262
|
+
if hit is None:
|
|
1263
|
+
continue
|
|
1264
|
+
name, args = hit
|
|
1265
|
+
tool_calls.append(_normalise_to_openai_tool_call(name, args))
|
|
1266
|
+
removals.append((m.start(), m.end()))
|
|
1267
|
+
continue
|
|
1268
|
+
# Named-wrapper: the tag itself is the tool name, body is the arguments.
|
|
1269
|
+
if tag_l in _NON_TOOL_TAGS:
|
|
1270
|
+
continue
|
|
1271
|
+
if tag not in allowed:
|
|
1272
|
+
continue
|
|
1273
|
+
if not inner.startswith("{"):
|
|
1274
|
+
continue
|
|
1275
|
+
try:
|
|
1276
|
+
args_obj = _parse_json_object(inner)
|
|
1277
|
+
except ValueError:
|
|
1278
|
+
continue
|
|
1279
|
+
if not isinstance(args_obj, dict):
|
|
1280
|
+
continue
|
|
1281
|
+
tool_calls.append(_normalise_to_openai_tool_call(tag, args_obj))
|
|
1282
|
+
removals.append((m.start(), m.end()))
|
|
1283
|
+
|
|
1284
|
+
# --- self-closing attribute form: <echo message="probe"/> ---
|
|
1285
|
+
for m in _XML_SELFCLOSE_RE.finditer(text):
|
|
1286
|
+
tag = m.group(1)
|
|
1287
|
+
if tag.lower() in _NON_TOOL_TAGS:
|
|
1288
|
+
continue
|
|
1289
|
+
if tag not in allowed:
|
|
1290
|
+
continue
|
|
1291
|
+
if _blocked(m.start()):
|
|
1292
|
+
continue
|
|
1293
|
+
# Don't double-extract something already inside a wrapper removal.
|
|
1294
|
+
if _in_spans(m.start(), removals):
|
|
1295
|
+
continue
|
|
1296
|
+
attrs = dict(_XML_ATTR_RE.findall(m.group(2) or ""))
|
|
1297
|
+
tool_calls.append(_normalise_to_openai_tool_call(tag, attrs))
|
|
1298
|
+
removals.append((m.start(), m.end()))
|
|
1299
|
+
|
|
1300
|
+
if not removals:
|
|
1301
|
+
return text, []
|
|
1302
|
+
|
|
1303
|
+
# Remove matched spans back-to-front; merge overlaps first.
|
|
1304
|
+
removals.sort()
|
|
1305
|
+
merged: list[tuple[int, int]] = []
|
|
1306
|
+
for s, e in removals:
|
|
1307
|
+
if merged and s < merged[-1][1]:
|
|
1308
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
1309
|
+
else:
|
|
1310
|
+
merged.append((s, e))
|
|
1311
|
+
cleaned = text
|
|
1312
|
+
for s, e in reversed(merged):
|
|
1313
|
+
cleaned = cleaned[:s] + cleaned[e:]
|
|
1314
|
+
return cleaned, tool_calls
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
# ------------------------------------------------------------------
|
|
1318
|
+
# Residue cleanup (trap #2)
|
|
1319
|
+
# ------------------------------------------------------------------
|
|
1320
|
+
|
|
1321
|
+
# Empty fenced blocks left after a tool-call body was removed.
|
|
1322
|
+
_EMPTY_FENCE_RE = re.compile(r"```[\w+-]*[ \t]*\r?\n?[ \t]*\r?\n?```")
|
|
1323
|
+
# Array skeletons like "[, ]" or "[ , , ]" left after removing array elements.
|
|
1324
|
+
_EMPTY_ARRAY_RE = re.compile(r"\[\s*(?:,\s*)+\]")
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _cleanup_residue(text: str) -> str:
|
|
1328
|
+
"""Remove cosmetic leftovers from extraction (empty fences, ``[,]``).
|
|
1329
|
+
|
|
1330
|
+
Only *empty* fenced blocks (a fence pair with nothing between them) are
|
|
1331
|
+
removed — a lone closing ``` that still belongs to an intact, preserved
|
|
1332
|
+
code fence is left alone, so protected source blocks stay valid.
|
|
1333
|
+
"""
|
|
1334
|
+
text = _EMPTY_FENCE_RE.sub("", text)
|
|
1335
|
+
text = _EMPTY_ARRAY_RE.sub("", text)
|
|
1336
|
+
# Collapse a lone remaining empty array pair.
|
|
1337
|
+
text = re.sub(r"\[\s*\]", "", text)
|
|
1338
|
+
return text
|
|
1339
|
+
|
|
1340
|
+
|
|
188
1341
|
# ------------------------------------------------------------------
|
|
189
1342
|
# Public API
|
|
190
1343
|
# ------------------------------------------------------------------
|
|
@@ -217,21 +1370,63 @@ def repair_tool_calls_in_text(
|
|
|
217
1370
|
# common shape when a chat-tuned model explains what it's doing. Fenced
|
|
218
1371
|
# blocks that are NOT tool calls (prose, real source code, plain JSON
|
|
219
1372
|
# examples) are left in place so we never drop legitimate content (H2).
|
|
1373
|
+
# Programming-language fences are protected outright.
|
|
220
1374
|
cleaned, fenced_tool_calls = _extract_tool_call_fenced_blocks(text, allowed)
|
|
221
1375
|
extracted.extend(fenced_tool_calls)
|
|
222
1376
|
|
|
223
|
-
# 2.
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
|
|
227
|
-
|
|
1377
|
+
# 2. R3: XML-flavoured forms (only when an allow-list constrains names).
|
|
1378
|
+
# Runs on the fence-stripped text; protected code fences and reasoning
|
|
1379
|
+
# blocks are shielded.
|
|
1380
|
+
protected = _protected_code_spans(cleaned)
|
|
1381
|
+
cleaned, xml_tool_calls = _extract_xml_tool_calls(cleaned, allowed, protected)
|
|
1382
|
+
extracted.extend(xml_tool_calls)
|
|
1383
|
+
|
|
1384
|
+
# 2b. R4a: nested-XML name-attribute forms (<function name=.../> etc.).
|
|
1385
|
+
# Same guards as R3: protected code fences + reasoning blocks are
|
|
1386
|
+
# shielded, and an example cue immediately before a call tag suppresses
|
|
1387
|
+
# it.
|
|
1388
|
+
protected = _protected_code_spans(cleaned)
|
|
1389
|
+
r4a_guard = protected + _known_non_tool_tag_ranges(cleaned)
|
|
1390
|
+
cleaned, r4a_tool_calls = _extract_r4a_nested_xml(cleaned, allowed, r4a_guard)
|
|
1391
|
+
extracted.extend(r4a_tool_calls)
|
|
1392
|
+
|
|
1393
|
+
# 2c. R4c: standalone call-syntax lines outside any fence
|
|
1394
|
+
# (e.g. ``write_note({...})`` alone on a line). Fenced call-syntax was
|
|
1395
|
+
# already handled in step 1. Only whole-line calls with an allow-listed
|
|
1396
|
+
# name and fully-parseable arguments are taken; inline / mid-prose forms
|
|
1397
|
+
# are never candidates.
|
|
1398
|
+
if allowed is not None:
|
|
1399
|
+
cleaned, r4c_tool_calls = _extract_r4c_standalone_lines(
|
|
1400
|
+
cleaned, allowed, _all_fence_spans(cleaned)
|
|
1401
|
+
)
|
|
1402
|
+
extracted.extend(r4c_tool_calls)
|
|
1403
|
+
|
|
1404
|
+
# 3. Scan remaining text for bare JSON objects (strict then lenient).
|
|
1405
|
+
# Skip anything inside a protected code fence or introduced by a prose
|
|
1406
|
+
# example cue. Walk back-to-front so slicing removals don't shift the
|
|
1407
|
+
# indices of earlier matches.
|
|
1408
|
+
protected = _protected_code_spans(cleaned)
|
|
228
1409
|
spans_to_remove: list[tuple[int, int]] = []
|
|
229
1410
|
repaired_from_bare: list[dict[str, Any]] = []
|
|
230
|
-
|
|
1411
|
+
|
|
1412
|
+
# First pass: strict-JSON balanced objects.
|
|
1413
|
+
for start, end, substr in _find_balanced_json_objects(cleaned):
|
|
1414
|
+
if _in_spans(start, protected):
|
|
1415
|
+
continue
|
|
1416
|
+
if _preceded_by_prose_cue(cleaned, start):
|
|
1417
|
+
continue
|
|
231
1418
|
try:
|
|
232
1419
|
obj = json.loads(substr)
|
|
233
1420
|
except json.JSONDecodeError:
|
|
234
1421
|
continue
|
|
1422
|
+
# R4b: response-envelope wrappers ({"tool_calls": [...]}, ...) expand to
|
|
1423
|
+
# one or more inner calls before the direct-shape check.
|
|
1424
|
+
envelope = _expand_tool_call_envelope(obj, allowed)
|
|
1425
|
+
if envelope is not None:
|
|
1426
|
+
for name, args in envelope:
|
|
1427
|
+
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
1428
|
+
spans_to_remove.append((start, end))
|
|
1429
|
+
continue
|
|
235
1430
|
hit = _looks_like_tool_call(obj, allowed)
|
|
236
1431
|
if hit is None:
|
|
237
1432
|
continue
|
|
@@ -239,13 +1434,47 @@ def repair_tool_calls_in_text(
|
|
|
239
1434
|
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
240
1435
|
spans_to_remove.append((start, end))
|
|
241
1436
|
|
|
242
|
-
#
|
|
243
|
-
|
|
1437
|
+
# Second pass: lenient objects (single-quoted / unquoted / trailing comma /
|
|
1438
|
+
# double-brace) that strict parsing missed. Skip spans already claimed.
|
|
1439
|
+
for start, end, substr in _find_candidate_object_spans(cleaned):
|
|
1440
|
+
if _in_spans(start, spans_to_remove):
|
|
1441
|
+
continue
|
|
1442
|
+
if _in_spans(start, protected):
|
|
1443
|
+
continue
|
|
1444
|
+
if _preceded_by_prose_cue(cleaned, start):
|
|
1445
|
+
continue
|
|
1446
|
+
# Skip if strict JSON already parses this (handled in first pass).
|
|
1447
|
+
try:
|
|
1448
|
+
json.loads(substr)
|
|
1449
|
+
continue # strict-parseable -> either already handled or non-tool
|
|
1450
|
+
except json.JSONDecodeError:
|
|
1451
|
+
pass
|
|
1452
|
+
try:
|
|
1453
|
+
obj = _lenient_json_loads(_strip_double_braces(substr))
|
|
1454
|
+
except ValueError:
|
|
1455
|
+
continue
|
|
1456
|
+
hit = _looks_like_tool_call(obj, allowed)
|
|
1457
|
+
if hit is None:
|
|
1458
|
+
continue
|
|
1459
|
+
name, args = hit
|
|
1460
|
+
repaired_from_bare.append(_normalise_to_openai_tool_call(name, args))
|
|
1461
|
+
spans_to_remove.append((start, end))
|
|
1462
|
+
|
|
1463
|
+
# Remove the matched spans from the text back-to-front (merge overlaps).
|
|
1464
|
+
spans_to_remove.sort()
|
|
1465
|
+
merged: list[tuple[int, int]] = []
|
|
1466
|
+
for s, e in spans_to_remove:
|
|
1467
|
+
if merged and s < merged[-1][1]:
|
|
1468
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
|
|
1469
|
+
else:
|
|
1470
|
+
merged.append((s, e))
|
|
1471
|
+
for start, end in reversed(merged):
|
|
244
1472
|
cleaned = cleaned[:start] + cleaned[end:]
|
|
245
1473
|
|
|
246
1474
|
extracted.extend(repaired_from_bare)
|
|
247
1475
|
|
|
248
|
-
#
|
|
1476
|
+
# Residue cleanup (empty fences, [,], stray backticks) then whitespace.
|
|
1477
|
+
cleaned = _cleanup_residue(cleaned)
|
|
249
1478
|
cleaned = re.sub(r"[ \t]+\n", "\n", cleaned)
|
|
250
1479
|
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
251
1480
|
|