Files
unslothai--unsloth/studio/backend/core/tool_healing.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

1078 lines
44 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
#
# Bracket-tag, rehearsal, and thinking-block-strip logic adapted from forge
# (https://github.com/antoinezambelli/forge), Copyright (c) 2025-2026
# Antoine Zambelli, used under the MIT License.
"""Lightweight tool-call parsing and stripping helpers.
External inference servers import this module without pulling in the inference
orchestrator, structlog, httpx, or the rest of the studio backend. Kept in
lockstep with ``core/inference/tool_call_parser.py`` so those servers
(llama-server wrappers, llama-swap, custom shims) reuse the same logic. Any
change here must also land there.
Handles these serializations (see ``parse_tool_calls_from_text``):
* ``<tool_call>{json}</tool_call>``
* ``<|tool_call>call:name{...}<tool_call|>`` (Gemma)
* ``<function=name><parameter=k>v</parameter></function>``
* ``[TOOL_CALLS]name{json}`` (Mistral / Devstral fallback)
* ``name[ARGS]{json}`` (reasoning-model rehearsal)
"""
# PEP 604 annotations must stay import-safe on Python 3.9 (requires-python >=3.9).
from __future__ import annotations
import bisect
import json
import re
# One nesting level in the strip regexes; deeper may leak markup (still parsed).
_BRACKETED_JSON_ONE_LEVEL = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}"
# Rehearsal ``name[ARGS]{..}`` strips; group 1 = name for tool-list gating. Closed =
# complete body, tail = truncated; ``(?<!\[CALL_ID\])`` keeps the v11 call-id from reading as a name.
_REHEARSAL_CLOSED_STRIP_RE = re.compile(
r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*" + _BRACKETED_JSON_ONE_LEVEL, re.DOTALL
)
_REHEARSAL_TAIL_STRIP_RE = re.compile(r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*(?:\{.*)?$", re.DOTALL)
# Tool-XML strip patterns; hyphen in the name class covers dashed MCP names.
# Closed-pair patterns are named so _PAT_REQUIRED_TOKEN can skip a doomed lazy rescan when
# the close token is absent: an unguarded ``<tag>.*?</tag>`` rescans to EOF from every opener
# (quadratic on a stream of unclosed openers). Also reused by the quote-aware Gemma pre-pass.
_TC_JSON_CLOSED_PAT = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
_TC_GEMMA_CLOSED_PAT = re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL)
_TC_FUNC_CLOSED_PAT = re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL)
_TOOL_CLOSED_PATS = [
_TC_JSON_CLOSED_PAT,
_TC_GEMMA_CLOSED_PAT,
re.compile(r"<tool_call\|>"),
_TC_FUNC_CLOSED_PAT,
# Mirror the parser regexes: tolerate whitespace and v11 [CALL_ID]/[ARGS] metadata.
re.compile(
r"\[TOOL_CALLS\]\s*[\w-]+(?:\[CALL_ID\][\w-]+)?(?:\[ARGS\])?\s*"
+ _BRACKETED_JSON_ONE_LEVEL,
re.DOTALL,
),
_REHEARSAL_CLOSED_STRIP_RE,
# Drop the bare v11 [/TOOL_CALLS] closer the balanced scan leaves behind.
re.compile(r"\[/TOOL_CALLS\]"),
]
# Bare open markers strip a partial call mid-stream; the rehearsal tail needs `{` or EOF
# so prose ``foo[ARGS]`` survives. The XML open-tail forms reach EOF and are reused by
# _tool_call_markup_spans (a think tag in an unclosed call's args stays argument data).
_TOOL_OPEN_XML_TAIL_PATS = [
re.compile(r"<tool_call>.*$", re.DOTALL),
re.compile(r"<\|tool_call>.*$", re.DOTALL),
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
]
_TOOL_ALL_PATS = (
_TOOL_CLOSED_PATS
+ _TOOL_OPEN_XML_TAIL_PATS
+ [
re.compile(r"\[TOOL_CALLS\].*$", re.DOTALL),
_REHEARSAL_TAIL_STRIP_RE,
]
)
# Rehearsal strips (name in group 1); name-gated via ``enabled_tool_names``, strip-all when None.
_REHEARSAL_STRIP_PATS = frozenset({_REHEARSAL_CLOSED_STRIP_RE, _REHEARSAL_TAIL_STRIP_RE})
# Stripped before the quote-aware Gemma helper so a Gemma opener quoted in argument
# data cannot make the helper truncate the block and its tail.
_TOOL_CLOSED_BLOCK_PATS = [_TC_JSON_CLOSED_PAT, _TC_FUNC_CLOSED_PAT]
# A lazy closed-pair pattern whose close token is absent would rescan to EOF from every
# opener; skip that doomed (quadratic) pass. Shared by both strip helpers.
_PAT_REQUIRED_TOKEN = {
_TC_JSON_CLOSED_PAT: "</tool_call>",
_TC_GEMMA_CLOSED_PAT: "<tool_call|>",
_TC_FUNC_CLOSED_PAT: "</function>",
}
def strip_tool_patterns(text: str, patterns) -> str:
"""Apply ``patterns`` in order, skipping closed-pair passes with no close token."""
for pat in patterns:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
text = pat.sub("", text)
return text
def apply_tool_strip_patterns(
text: str,
patterns,
enabled_tool_names = None,
) -> str:
"""Apply strip ``patterns`` to ``text``. A bare rehearsal ``name[ARGS]{..}`` pattern
strips only when ``name`` is an enabled tool (or when ``enabled_tool_names`` is
``None``); every other pattern is removed unconditionally. A closed-pair pattern whose
close token is absent is skipped so an unclosed-marker stream stays linear."""
for pat in patterns:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
if enabled_tool_names is not None and pat in _REHEARSAL_STRIP_PATS:
text = pat.sub(lambda m: "" if m.group(1) in enabled_tool_names else m.group(0), text)
else:
text = pat.sub("", text)
return text
# Pre-compiled patterns for tool-call XML parsing.
_TC_JSON_START_RE = re.compile(r"<tool_call>\s*\{")
_TC_GEMMA_START_RE = re.compile(r"<\|tool_call>\s*call\s*:\s*([\w.\-]+)\s*\{")
_TC_FUNC_START_RE = re.compile(r"<function=([\w-]+)>\s*")
_TC_END_TAG_RE = re.compile(r"</tool_call>")
_TC_GEMMA_END_TAG_RE = re.compile(r"<tool_call\|>")
_TC_FUNC_CLOSE_RE = re.compile(r"\s*</function>\s*$")
# Horizontal-whitespace trailing class keeps the wrapping newline; _trim_param_value trims it.
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>[^\S\n]*")
_TC_PARAM_CLOSE_RE = re.compile(r"\s*</parameter>\s*$")
_GEMMA_QUOTE = '<|"|>'
_PARAM_CLOSE_TAG = "</parameter>"
_FUNC_CLOSE_TAG = "</function>"
# A bare (unquoted) Gemma value ends at `}` or at a comma that begins the next
# `key:` pair. A comma NOT followed by a key token is part of the value (e.g.
# `location:New York, NY`), so it must not terminate the value. The key token
# must be identifier-shaped (start with a letter or underscore); a comma
# followed by digits-then-colon is value text such as a timestamp or ratio
# (`meet at 10:00, 11:00 tomorrow`), not a new key.
_GEMMA_NEXT_KEY_RE = re.compile(r"\s*[A-Za-z_][\w.\-]*\s*:")
# A candidate starting inside a think block is a rehearsal (block kept so literal tags in
# real args survive); ``$`` accepts an unclosed block mid-stream.
_THINK_TAG_RE = re.compile(r"<think>.*?(?:</think>|$)|\[THINK\].*?(?:\[/THINK\]|$)", re.DOTALL)
# Bare open/close markers for prefilled-reasoning turns (template opens <think> in the prompt).
_THINK_OPEN_RE = re.compile(r"<think>|\[THINK\]")
_THINK_CLOSE_RE = re.compile(r"</think>|\[/THINK\]")
# Mistral canonical array: [TOOL_CALLS] + JSON list of {"name","arguments"} objects.
_MISTRAL_ARRAY_RE = re.compile(r"\[TOOL_CALLS\]\s*(?=\[)")
# Mistral name form + v11 [ARGS]/[CALL_ID] shapes; [CALL_ID] is metadata, not the name,
# and hyphens keep dashed MCP names whole.
_MISTRAL_BRACKET_RE = re.compile(
r"\[TOOL_CALLS\]\s*([\w-]+)(?:\[CALL_ID\][\w-]+)?(?:\[ARGS\])?\s*(?=\{)"
)
# Rehearsal ``name[ARGS]{json}`` (no [TOOL_CALLS]); the lookbehind keeps the v11 call-id
# from being taken as the function name.
_REHEARSAL_RE = re.compile(r"(?<!\[CALL_ID\])\b([\w-]+)\[ARGS\]\s*(?=\{)")
# Above this size skip the balanced scan; the linear regex catch-all bounds pathological output.
_MAX_BRACKET_SCAN_CHARS = 1_000_000
def _balanced_json_span(text: str, start: int) -> int | None:
"""Return the end index of a balanced JSON object opening at ``start``,
or ``None`` if the braces don't balance. Honors escapes and strings.
"""
if start >= len(text) or text[start] != "{":
return None
depth = 0
in_string = False
escape = False
for j in range(start, len(text)):
ch = text[j]
if escape:
escape = False
continue
if ch == "\\":
escape = True
continue
if in_string:
if ch == '"':
in_string = False
continue
if ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return j
return None
def _balanced_brace_end(
content: str,
brace_start: int,
*,
gemma_quotes: bool = False,
) -> int:
depth = 0
i = brace_start
in_string = False
in_gemma_string = False
while i < len(content):
if gemma_quotes and not in_string and content.startswith(_GEMMA_QUOTE, i):
in_gemma_string = not in_gemma_string
i += len(_GEMMA_QUOTE)
continue
ch = content[i]
if in_gemma_string:
i += 1
continue
if in_string:
if ch == "\\" and i + 1 < len(content):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _balanced_bracket_end(src: str, start: int) -> int:
"""Index of the ``]`` matching the ``[`` at ``start``, or -1. Tracks nested
``[]``/``{}`` and double-quoted strings."""
depth = 0
i = start
in_string = False
while i < len(src):
ch = src[i]
if in_string:
if ch == "\\" and i + 1 < len(src):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in "[{":
depth += 1
elif ch in "]}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _decode_array_items(text: str, body_start: int, body_end: int):
"""Return ``(objs, ends)`` for each top-level element of the JSON array between
``body_start`` (at or before its ``[``) and ``body_end`` (exclusive): the decoded
object and its absolute exclusive end offset.
Decoding element-by-element with ``raw_decode`` tolerates the comma-less object
separators the repo's own Mistral/Ollama multi-call templates emit
(``[{...}{...}]``; see ollama_template_mappers.py). A single ``json.loads`` of the
whole body rejects that form and would drop every call. The ends also tile the
region across the calls' spans so a with_spans consumer strips each exactly once."""
decoder = json.JSONDecoder()
objs: list = []
ends: list[int] = []
i = text.find("[", body_start)
if i < 0:
return objs, ends
i += 1
while i < body_end:
while i < body_end and text[i] in " \t\r\n,":
i += 1
if i >= body_end or text[i] == "]":
break
try:
obj, rel = decoder.raw_decode(text[i:body_end])
except (json.JSONDecodeError, ValueError):
break
i += rel
objs.append(obj)
ends.append(i)
return objs, ends
def _iter_bracket_spans(
text: str,
start: int = 0,
enabled_tool_names = None,
):
"""Yield ``(span_start, span_end, kind, match)`` for each balanced bracket-tag
call from ``start`` on, in document order; ``span_end`` exclusive. ``kind`` is
``"array"`` ([TOOL_CALLS] [..]), ``"name"`` ([TOOL_CALLS]name{..}, incl. v11
[CALL_ID]/[ARGS]) or ``"rehearsal"`` (name[ARGS]{..}).
``enabled_tool_names`` (set, or None = unrestricted) gates only the ambiguous
bare rehearsal form: name[ARGS]{..} is a call ONLY when ``name`` is enabled, so a
prose ``foo[ARGS]{..}`` (foo disabled) is neither parsed nor stripped. Explicit
[TOOL_CALLS] markers stay unconditional, keeping parse/strip/detection symmetric.
Balance-only (no JSON validation) so strip and parse share one scan. The cursor
jumps past each consumed span, so a marker inside consumed JSON is never
re-matched and each regex re-searches only once its match falls behind: linear."""
n = len(text)
specs = (
("array", _MISTRAL_ARRAY_RE),
("name", _MISTRAL_BRACKET_RE),
("rehearsal", _REHEARSAL_RE),
)
nexts = {kind: rx.search(text, start) for kind, rx in specs}
cursor = start
while cursor < n:
for kind, rx in specs:
m = nexts[kind]
if m is not None and m.start() < cursor:
nexts[kind] = rx.search(text, cursor)
live = [(kind, m) for kind, m in nexts.items() if m is not None]
if not live:
return
kind, m = min(live, key = lambda km: km[1].start())
if kind == "array":
end = _balanced_bracket_end(text, m.end())
end = None if end < 0 else end
else:
end = _balanced_json_span(text, m.end())
if end is None:
# Truncated body: skip and keep scanning; the caller's catch-all strips the tail.
cursor = m.end()
continue
if (
kind == "rehearsal"
and enabled_tool_names is not None
and m.group(1) not in enabled_tool_names
):
# Inactive-name rehearsal is prose: advance past its body without yielding.
cursor = end + 1
continue
yield (m.start(), end + 1, kind, m)
cursor = end + 1
def _split_top_level_commas(src: str) -> list:
"""Split on commas that are not inside a nested ``[]``/``{}`` or a string."""
parts: list[str] = []
depth = 0
in_string = False
start = 0
i = 0
while i < len(src):
ch = src[i]
if in_string:
if ch == "\\" and i + 1 < len(src):
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in "[{":
depth += 1
elif ch in "]}":
depth -= 1
elif ch == "," and depth == 0:
parts.append(src[start:i])
start = i + 1
i += 1
parts.append(src[start:])
return parts
def _quote_gemma_array_elements(body: str) -> str:
"""Normalise the elements of a Gemma array value so json.loads succeeds.
Gemma may emit ``labels:[bug,ui]`` without per-element quotes, or arrays of
objects (``items:[{path:a}]``) whose keys/values also lack quotes; left
as-is json.loads fails and the whole call is dropped. Bare string elements
are quoted, object and nested-array elements are normalised recursively, and
quoted strings (already normalised from ``<|"|>``), numbers, and JSON
literals are preserved."""
out: list[str] = []
for element in _split_top_level_commas(body):
stripped = element.strip()
if not stripped or stripped[0] == '"':
out.append(element)
continue
if stripped[0] == "{":
# Object element: quote its keys/bare values like a top-level object.
out.append(_quote_gemma_object_keys(stripped))
continue
if stripped[0] == "[":
# Nested array: normalise its elements too.
inner_end = _balanced_bracket_end(stripped, 0)
if inner_end == len(stripped) - 1:
out.append("[" + _quote_gemma_array_elements(stripped[1:inner_end]) + "]")
else:
out.append(element)
continue
try:
json.loads(stripped)
out.append(element)
except (json.JSONDecodeError, ValueError):
out.append(json.dumps(stripped))
return ",".join(out)
def _normalise_gemma_quoted_strings(src: str) -> str:
parts: list[str] = []
i = 0
while i < len(src):
if not src.startswith(_GEMMA_QUOTE, i):
parts.append(src[i])
i += 1
continue
end = src.find(_GEMMA_QUOTE, i + len(_GEMMA_QUOTE))
if end < 0:
parts.append(src[i:])
break
raw_value = src[i + len(_GEMMA_QUOTE) : end]
parts.append(json.dumps(raw_value))
i = end + len(_GEMMA_QUOTE)
return "".join(parts)
def _quote_gemma_object_keys(src: str) -> str:
parts: list[str] = []
i = 0
in_string = False
while i < len(src):
ch = src[i]
if in_string:
parts.append(ch)
if ch == "\\" and i + 1 < len(src):
parts.append(src[i + 1])
i += 2
continue
if ch == '"':
in_string = False
i += 1
continue
if ch == '"':
in_string = True
parts.append(ch)
i += 1
continue
if ch not in "{,":
parts.append(ch)
i += 1
continue
parts.append(ch)
i += 1
key_start = i
while i < len(src) and src[i].isspace():
i += 1
key_name_start = i
while i < len(src) and (src[i].isalnum() or src[i] in "_-."):
i += 1
key_name = src[key_name_start:i]
colon_pos = i
while colon_pos < len(src) and src[colon_pos].isspace():
colon_pos += 1
if key_name and colon_pos < len(src) and src[colon_pos] == ":":
parts.append(src[key_start:key_name_start])
parts.append(json.dumps(key_name))
parts.append(src[i:colon_pos])
parts.append(":")
i = colon_pos + 1
# Gemma may emit bare string values ({unit:celsius}); quote them so
# json.loads succeeds. JSON scalars/objects/arrays/quoted stay as-is.
ws = i
while i < len(src) and src[i].isspace():
i += 1
parts.append(src[ws:i])
if i < len(src) and src[i] == "[":
# Array value: quote bare string elements (e.g. labels:[bug,ui])
# so json.loads succeeds instead of dropping the call.
arr_end = _balanced_bracket_end(src, i)
if arr_end < 0:
parts.append(src[i:])
i = len(src)
else:
parts.append("[" + _quote_gemma_array_elements(src[i + 1 : arr_end]) + "]")
i = arr_end + 1
elif i < len(src) and src[i] not in '"{':
v_start = i
# Consume the bare value up to `}` or a comma that starts the
# next key:value pair; a comma inside the value (e.g.
# `New York, NY`) does not terminate it.
while i < len(src):
if src[i] == "}":
break
if src[i] == "," and _GEMMA_NEXT_KEY_RE.match(src, i + 1):
break
i += 1
raw = src[v_start:i]
try:
json.loads(raw.strip())
parts.append(raw)
except (json.JSONDecodeError, ValueError):
# Quote bare value; empty ({k:}) becomes "" so json.loads sees {"k":""} not invalid {"k":}.
parts.append(json.dumps(raw.strip()))
else:
parts.append(src[key_start:i])
return "".join(parts)
def _gemma_arguments_to_json(args_src: str) -> dict:
"""Parse Gemma 4's native call:name{key:value} argument object."""
args_src = args_src.strip()
if not args_src:
return {}
src = _normalise_gemma_quoted_strings(args_src)
src = "{" + src + "}"
src = _quote_gemma_object_keys(src)
return json.loads(src)
def _inside_open_parameter(content: str, pos: int) -> bool:
"""Return True when ``pos`` falls inside an unclosed parameter value."""
last_param_start = -1
for match in _TC_PARAM_START_RE.finditer(content, 0, pos):
last_param_start = match.start()
if last_param_start < 0:
return False
# The parameter's OWN close tag decides: if it closes after ``pos`` the position is
# argument data (even across literal function closes); an unclosed one falls back to func close.
own_close = content.find(_PARAM_CLOSE_TAG, last_param_start)
if own_close >= 0:
return own_close > pos
func_close = content.find(_FUNC_CLOSE_TAG, last_param_start)
return func_close < 0 or pos < func_close
def _func_close_index(content: str, body_start: int, body: str) -> int:
"""Index in ``body`` of the first ``</function>`` that is not argument
data (not inside an open parameter value); -1 when every close is data.
Taking the LAST close swallowed prose between the real close and a
literal ``</function>`` mentioned later in the answer."""
idx = body.find(_FUNC_CLOSE_TAG)
while idx >= 0:
if not _inside_open_parameter(content, body_start + idx):
return idx
idx = body.find(_FUNC_CLOSE_TAG, idx + 1)
return -1
def _trim_param_value(val: str) -> str:
"""Trim the single wrapping newline the chat template adds around an XML
parameter value, preserving indentation inside VALUE (``str.strip()`` destroyed
code/diff argument indentation)."""
if val.startswith("\n"):
val = val[1:]
if val.endswith("\n"):
val = val[:-1]
return val
def _marker_coverage(content: str, markers) -> list[tuple[int, int]]:
"""Coverage ``[start, end]`` per marker, used to skip markers that are another
call's data. Closes pair to markers via a per-format stack so an inner close
is not mistaken for the outer's. Unbalanced braces cover to EOF; balanced with
a paired close cover through it (markers before the close are data); balanced
without one cover only the braces, so a later sibling is still recovered."""
n = len(content)
brace_regions = [(s, be) for (s, be, _k, _m) in markers if be >= 0]
events = [] # (position, order) with order 0 = braces-done, 1 = close marker
for idx, (_start, brace_end, _kind, _m) in enumerate(markers):
if brace_end >= 0:
events.append((brace_end, 0, _kind, idx))
for kind, close_re in (("json", _TC_END_TAG_RE), ("gemma", _TC_GEMMA_END_TAG_RE)):
for cm in close_re.finditer(content):
# A close inside another call's balanced braces is quoted data; it
# must not pop an earlier close-less marker and swallow a sibling.
if any(s < cm.start() < be for s, be in brace_regions):
continue
events.append((cm.start(), 1, kind, cm.end()))
events.sort(key = lambda e: (e[0], e[1]))
waiting = {"json": [], "gemma": []}
close_end_for: dict[int, int] = {}
for _pos, order, kind, payload in events:
if order == 0:
waiting[kind].append(payload) # marker index, now awaiting its close
elif waiting[kind]:
close_end_for[waiting[kind].pop()] = payload # innermost open marker closes here
coverage = []
for idx, (start, brace_end, _kind, _m) in enumerate(markers):
if brace_end < 0:
coverage.append((start, n))
elif idx in close_end_for:
coverage.append((start, close_end_for[idx]))
else:
coverage.append((start, brace_end))
return coverage
def _build_markers(content: str):
"""JSON/Gemma tool markers as ``(start, brace_end, kind, match)`` in document
order; ``brace_end < 0`` marks an unbalanced (to-EOF) open."""
markers = []
for start_re, gemma, kind in (
(_TC_JSON_START_RE, False, "json"),
(_TC_GEMMA_START_RE, True, "gemma"),
):
for m in start_re.finditer(content):
if _inside_open_parameter(content, m.start()):
continue
brace_end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = gemma)
markers.append((m.start(), brace_end, kind, m))
markers.sort(key = lambda c: c[0])
return markers
def marker_coverage(content: str) -> list[tuple[int, int]]:
"""Coverage spans of JSON/Gemma tool markers so other parsers can treat markup
inside a marker's coverage (even a marker that failed to parse) as that call's
data rather than a sibling call."""
return _marker_coverage(content, _build_markers(content))
def parse_tool_calls_from_text(
content: str,
*,
id_offset: int = 0,
allow_incomplete: bool = True,
enabled_tool_names = None,
with_spans: bool = False,
):
"""Parse OpenAI-format tool calls from model text.
Handles formats like:
<tool_call>{"name":"web_search","arguments":{"query":"..."}}</tool_call>
<|tool_call>call:web_search{query:"..."}<tool_call|>
<tool_call><function=web_search><parameter=query>...</parameter></function></tool_call>
[TOOL_CALLS]web_search{"query":"..."} (Mistral / Devstral fallback)
web_search[ARGS]{"query":"..."} (reasoning-model rehearsal)
A call rehearsed inside a ``<think>`` / ``[THINK]`` block is skipped, not
executed; the block is kept so a literal tag in a real argument is preserved.
With ``with_spans=True`` returns ``(tool_calls, spans)`` where ``spans[i]``
is the half-open ``(start, end)`` byte range of ``tool_calls[i]``'s markup
in ``content`` (including its close tag when present), so a caller can
remove exactly the parsed markup and keep every other byte intact.
"""
# Candidates starting inside a think block are rehearsals, skipped; blocks are kept, and a
# think marker opening inside a call is argument data (excluded from spans).
_think_spans = _think_spans_outside_tool_markup(content)
_think_starts = [s for s, _e in _think_spans]
def _in_think(pos: int) -> bool:
# Spans are ordered and non-overlapping; bisect gives O(log M) per candidate.
i = bisect.bisect_right(_think_starts, pos) - 1
return i >= 0 and _think_spans[i][0] <= pos < _think_spans[i][1]
tool_calls: list[dict] = []
call_spans: list[tuple] = []
# Collect JSON/Gemma markers; _marker_coverage decides nesting so a marker inside
# another call's coverage (even one that failed to parse) is data, not executed. A
# marker opening inside a think block is a rehearsal and is skipped.
parsed_items = [] # (start, span_end, name, arguments) in document order
markers = [mk for mk in _build_markers(content) if not _in_think(mk[0])]
coverage = _marker_coverage(content, markers)
for idx, (start, brace_end, kind, m) in enumerate(markers):
if any(s <= start < e for j, (s, e) in enumerate(coverage) if j != idx):
continue
if brace_end < 0:
continue # unclosed: not parseable; the fallback still excludes its XML
if not allow_incomplete:
tail = content[brace_end + 1 :].lstrip()
close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
if close_re.match(tail) is None:
continue
try:
if kind == "json":
obj = json.loads(content[m.end() - 1 : brace_end + 1])
name = obj.get("name", "")
# Accept ``parameters`` alias for ``arguments`` (Llama-3.2 drift inside Hermes).
arguments = obj.get("arguments")
if arguments is None:
arguments = obj.get("parameters", {})
if isinstance(arguments, dict):
arguments = json.dumps(arguments)
else:
name = m.group(1)
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : brace_end]))
except (json.JSONDecodeError, ValueError):
continue
span_end = brace_end + 1
close_re = _TC_END_TAG_RE if kind == "json" else _TC_GEMMA_END_TAG_RE
ws = len(content[span_end:]) - len(content[span_end:].lstrip())
close_m = close_re.match(content, span_end + ws)
if close_m:
span_end = close_m.end()
parsed_items.append((start, span_end, name, arguments))
func_starts = [
fm
for fm in _TC_FUNC_START_RE.finditer(content)
if not _inside_open_parameter(content, fm.start())
and not _in_think(fm.start())
and not any(s <= fm.start() < e for s, e in coverage)
]
for idx, fm in enumerate(func_starts):
func_name = fm.group(1)
body_start = fm.end()
next_func = func_starts[idx + 1].start() if idx + 1 < len(func_starts) else len(content)
end_tag = _TC_END_TAG_RE.search(content[body_start:])
if end_tag:
body_end = body_start + end_tag.start()
else:
body_end = len(content)
body_end = min(body_end, next_func)
body = content[body_start:body_end]
close_idx = _func_close_index(content, body_start, body)
if close_idx >= 0:
span_end = body_start + close_idx + len(_FUNC_CLOSE_TAG)
body = body[:close_idx]
elif not allow_incomplete:
continue
else:
body = _TC_FUNC_CLOSE_RE.sub("", body)
span_end = body_end
arguments: dict = {}
param_starts = list(_TC_PARAM_START_RE.finditer(body))
if len(param_starts) == 1:
pm = param_starts[0]
val = body[pm.end() :]
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
continue
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[pm.group(1)] = _trim_param_value(val)
else:
valid_params = True
for pidx, pm in enumerate(param_starts):
param_name = pm.group(1)
val_start = pm.end()
next_param = (
param_starts[pidx + 1].start() if pidx + 1 < len(param_starts) else len(body)
)
val = body[val_start:next_param]
if not allow_incomplete:
stripped_val = val.rstrip()
if not stripped_val.endswith(_PARAM_CLOSE_TAG):
valid_params = False
break
val = stripped_val[: -len(_PARAM_CLOSE_TAG)]
else:
val = _TC_PARAM_CLOSE_RE.sub("", val)
arguments[param_name] = _trim_param_value(val)
if not valid_params:
continue
span_start = fm.start()
wrap_open = re.search(r"<tool_call>\s*$", content[:span_start])
wrap_close = re.match(r"\s*</tool_call>", content[span_end:])
if wrap_open and wrap_close:
span_start = wrap_open.start()
span_end += wrap_close.end()
parsed_items.append((span_start, span_end, func_name, json.dumps(arguments)))
parsed_items.sort(key = lambda item: item[0])
for start, span_end, name, arguments in parsed_items:
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {"name": name, "arguments": arguments},
}
)
call_spans.append((start, span_end))
# Patterns 3+4: Mistral [TOOL_CALLS] and bare rehearsal via one balanced scan in document
# order, so a Mistral call and a rehearsal in one message both parse.
if not tool_calls:
for start, end, kind, m in _iter_bracket_spans(
content, enabled_tool_names = enabled_tool_names
):
if _in_think(start):
continue
# Extend the region over an immediately-following v11 closer so with_spans consumers strip it too.
closer = re.match(r"\s*\[/TOOL_CALLS\]", content[end:])
region_end = end + closer.end() if closer else end
if kind == "array":
# Decode elements individually (comma-tolerant): one json.loads of the whole
# body rejects the comma-less multi-call arrays Mistral/Ollama templates emit.
payload, item_ends = _decode_array_items(content, m.end(), end)
if not payload:
continue
# Tile the region so every byte belongs to exactly one span; a with_spans consumer
# keeps skipped bytes visible and strips promoted markup exactly once.
tile_start = start
last_span_idx = -1
for item_idx, item in enumerate(payload):
if not isinstance(item, dict) or "name" not in item:
continue
args = item.get("arguments", {})
if isinstance(args, str):
# ``arguments`` may itself be a JSON string (OpenAI spec).
try:
args = json.loads(args)
except (json.JSONDecodeError, ValueError):
pass
if not isinstance(args, (dict, str)):
# ``"arguments": null`` (or any non-object scalar) becomes {} like the
# <tool_call> path, not the string "null" auto-heal would mangle to
# a bogus {"query":"null"}.
args = {}
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": item.get("name", ""),
# A bare scalar string stays raw (like the <tool_call> path);
# json.dumps would double-encode it so the arg healer wraps
# "weather" with its literal quotes.
"arguments": args if isinstance(args, str) else json.dumps(args),
},
}
)
item_end = item_ends[item_idx] if item_idx < len(item_ends) else region_end
last_span_idx = len(call_spans)
call_spans.append((tile_start, item_end))
tile_start = item_end
if last_span_idx >= 0:
tile_start, _tile_end = call_spans[last_span_idx]
call_spans[last_span_idx] = (tile_start, region_end)
else:
try:
payload = json.loads(content[m.end() : end])
except (json.JSONDecodeError, ValueError):
continue
if not isinstance(payload, dict):
continue
tool_calls.append(
{
"id": f"call_{id_offset + len(tool_calls)}",
"type": "function",
"function": {
"name": m.group(1),
"arguments": json.dumps(payload),
},
}
)
call_spans.append((start, region_end))
if with_spans:
return tool_calls, call_spans
return tool_calls
def _strip_bracket_tag_calls(text: str, enabled_tool_names = None) -> str:
"""Strip complete [TOOL_CALLS] arrays / name / bare name[ARGS]{..} calls with one
balanced forward scan, so nested JSON args are removed whole (a fixed-depth regex
left two-level args behind). Truncated tails go to the caller's catch-all. Linear.
``enabled_tool_names`` gates the rehearsal form (inactive-name prose kept; None
strips every span)."""
if len(text) > _MAX_BRACKET_SCAN_CHARS:
return text
out: list[str] = []
cursor = 0
for start, end, _kind, _m in _iter_bracket_spans(text, enabled_tool_names = enabled_tool_names):
out.append(text[cursor:start])
cursor = end
out.append(text[cursor:])
return "".join(out)
def _tool_call_markup_spans(text: str) -> list[tuple[int, int]]:
"""Spans of tool-call markup, so a literal <think>/[THINK] inside a call's args is
stripped WITH the call, not kept as a reasoning block. Covers closed XML/bracket
calls and an unclosed XML call (run via allow_incomplete); without the open-ended
span the unclosed call's markup would leak after execution."""
# Skip a lazy closed-pair pattern whose close token is absent: its finditer would rescan
# to EOF from every opener (quadratic on a stream of unclosed openers).
spans = [
m.span()
for pat in _TOOL_CLOSED_PATS
if (_PAT_REQUIRED_TOKEN.get(pat) is None or _PAT_REQUIRED_TOKEN[pat] in text)
for m in pat.finditer(text)
]
spans.extend((start, end) for start, end, _kind, _m in _iter_bracket_spans(text))
# An unclosed opener is a real incomplete call only outside closed/bracket spans.
for pat in _TOOL_OPEN_XML_TAIL_PATS:
for m in pat.finditer(text):
if not any(s <= m.start() < e for s, e in spans):
spans.append(m.span())
return spans
def _think_spans_outside_tool_markup(text: str) -> list[tuple[int, int]]:
"""<think>/[THINK] block spans, minus any whose opening marker sits INSIDE a
tool-call span (that tag is argument data, not reasoning). Keeping it would drop a
real call after it as rehearsed and leak the call's markup. START tested only, so
a greedy unclosed <think> past the call is still that call's argument data."""
think_spans = [m.span() for m in _THINK_TAG_RE.finditer(text)]
call_spans = _tool_call_markup_spans(text)
# Prefilled reasoning: the template opens <think> in the prompt, so add a leading span
# (0..close) to skip calls rehearsed there; guarded so a stray close in a normal answer is safe.
close = _THINK_CLOSE_RE.search(text)
if close is not None:
opener = _THINK_OPEN_RE.search(text)
if (
(opener is None or close.start() < opener.start())
and not any(cs <= close.start() < ce for cs, ce in call_spans)
and any(cs >= close.end() for cs, ce in call_spans)
):
think_spans = [(0, close.end())] + think_spans
if not think_spans:
return think_spans
if not call_spans:
return think_spans
return [(s, e) for (s, e) in think_spans if not any(cs <= s < ce for cs, ce in call_spans)]
def strip_outside_think(text: str, strip_segment) -> str:
"""Apply ``strip_segment(segment, is_last)`` to visible text around <think>/[THINK]
blocks, preserving the blocks verbatim (tool-looking text inside is rehearsal).
``is_last`` is True only after the final block, so trailing-tail patterns apply
only there. Shared by every strip path so they stay consistent."""
# A think marker opening inside a complete call is argument text; excluding it lets the
# stripper see the whole call. START-tested, so an unclosed match stays argument data.
think_spans = _think_spans_outside_tool_markup(text)
if not think_spans:
return strip_segment(text, True)
pieces: list[str] = []
prev = 0
for s, e in think_spans:
pieces.append(strip_segment(text[prev:s], False))
pieces.append(text[s:e])
prev = e
pieces.append(strip_segment(text[prev:], True))
return "".join(pieces)
def _strip_gemma_native_spans(text: str, *, final: bool) -> str:
"""Remove complete Gemma-native spans, brace/quote-balanced so a literal
``<tool_call|>`` in a quoted argument cannot truncate the span. An incomplete
span is dropped to EOF when ``final``, else kept (still streaming)."""
out: list[str] = []
cursor = 0
for match in _TC_GEMMA_START_RE.finditer(text):
start = match.start()
if start < cursor:
continue
brace_end = _balanced_brace_end(text, match.end() - 1, gemma_quotes = True)
if brace_end < 0:
# Unbalanced: nothing completes from here on. Drop the rest if final,
# else keep it; stop either way (rescanning would be quadratic).
if final:
out.append(text[cursor:start])
cursor = len(text)
break
# Junk between } and <tool_call|> is malformed-call markup: strip through
# the close, keep text after it. No close anywhere means stop (linear).
close = _TC_GEMMA_END_TAG_RE.search(text, brace_end + 1)
if close is None:
if final:
out.append(text[cursor:start])
cursor = len(text)
break
out.append(text[cursor:start])
cursor = close.end()
out.append(text[cursor:])
return "".join(out)
def _gemma_span_ranges(text: str) -> list:
"""``(start, end)`` of each complete Gemma-native span; same walk as
``_strip_gemma_native_spans`` without stripping."""
ranges: list[tuple] = []
cursor = 0
for match in _TC_GEMMA_START_RE.finditer(text):
start = match.start()
if start < cursor:
continue
brace_end = _balanced_brace_end(text, match.end() - 1, gemma_quotes = True)
if brace_end < 0:
break
close = _TC_GEMMA_END_TAG_RE.search(text, brace_end + 1)
if close is None:
break
ranges.append((start, close.end()))
cursor = close.end()
return ranges
def _strip_closed_blocks_outside_gemma(text: str) -> str:
"""Closed JSON/function pre-pass that skips matches starting inside a complete
Gemma span: deleting across the span boundary would mangle the Gemma close and
truncate the tail. A skipped match resumes at the covering span's end, so a
real function-XML call after the span is still stripped."""
ranges = _gemma_span_ranges(text)
if not ranges:
return strip_tool_patterns(text, _TOOL_CLOSED_BLOCK_PATS)
for pat in _TOOL_CLOSED_BLOCK_PATS:
token = _PAT_REQUIRED_TOKEN.get(pat)
if token is not None and token not in text:
continue
out: list[str] = []
pos = 0
while True:
m = pat.search(text, pos)
if m is None:
out.append(text[pos:])
break
covering = next((r for r in ranges if r[0] <= m.start() < r[1]), None)
if covering is not None:
out.append(text[pos : covering[1]])
pos = covering[1]
continue
out.append(text[pos : m.start()])
pos = m.end()
new_text = "".join(out)
if new_text != text:
text = new_text
ranges = _gemma_span_ranges(text)
return text
def _strip_markup_segment(
text: str,
*,
final: bool,
enabled_tool_names = None,
) -> str:
# Bracket-tag calls (Mistral/rehearsal) first via balanced scan (any nesting depth,
# rehearsal name-gated); then the quote-aware Gemma-native passes so a literal
# <tool_call|> in an argument cannot truncate a block; finally the regex XML/tail sweeps.
text = _strip_bracket_tag_calls(text, enabled_tool_names = enabled_tool_names)
text = _strip_closed_blocks_outside_gemma(text)
text = _strip_gemma_native_spans(text, final = final)
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
return apply_tool_strip_patterns(text, patterns, enabled_tool_names = enabled_tool_names)
def strip_tool_call_markup(
text: str,
*,
final: bool = False,
enabled_tool_names = None,
) -> str:
"""Strip tool-call XML markup from text.
When ``final`` is False, only fully closed tool-call blocks are removed.
When ``final`` is True, trailing incomplete tool-call blocks are removed
too, and the result is stripped of surrounding whitespace.
``<think>`` / ``[THINK]`` reasoning is preserved verbatim (see
``strip_outside_think``); the trailing-tail patterns apply only after the
last block. ``enabled_tool_names`` keeps an inactive-name ``foo[ARGS]{..}``
example visible (it is prose, not a call) so display cleanup matches detection.
"""
result = strip_outside_think(
text,
lambda seg, is_last: _strip_markup_segment(
seg, final = final and is_last, enabled_tool_names = enabled_tool_names
),
)
return result.strip() if final else result