* inference: add passthrough tool-call healing core (heal_gate, heal_openai_message, StreamToolCallHealer, nudge helpers)
Small GGUF models often emit tool calls as text (<tool_call>{...}</tool_call>,
Gemma <|tool_call>, <function=> XML) instead of structured tool_calls. Studio's
enable-tools loop already heals these, but the client-tool passthrough
(unsloth run --disable-tools, unsloth start agents) relays them verbatim, so
the agent sees prose and the turn dies.
This module is the shared response-side repair layer the passthrough routes
will call: promote parsed text-form calls to structured calls, but only for
function names the client actually declared; coerce arguments through the same
canonical-key healing as the tool loop; never touch the upstream request body
(llama-server KV/slot reuse stays byte-identical). StreamToolCallHealer is the
streaming buffer-and-repair state machine: prose forwards immediately, only a
partial-signal tail or a suspected tool block is held, false alarms flush
verbatim, and a 64 KiB bound caps memory. nudge_should_retry/nudge_messages
support an opt-in single-retry nudge for non-streaming routes (wired later).
Kill-switch: UNSLOTH_DISABLE_TOOL_CALL_HEALING=1. Reuses
core/tool_healing.parse_tool_calls_from_text, strip_tool_call_markup, and
tool_loop_controller.coerce_tool_arguments unchanged.
* inference: heal text-form tool calls on the OpenAI and Responses passthrough
Wire the passthrough healing core into /v1/chat/completions and /v1/responses,
default ON whenever the request declares client tools:
Non-streaming: heal_openai_message runs inside the existing response-mutation
loop; a promoted call flips finish_reason to tool_calls and nulls the content,
and the verbatim-bytes fast path still applies when nothing was healed.
/v1/responses non-streaming inherits this through openai_chat_completions.
Streaming: a StreamToolCallHealer per stream. Ordinary prose relays
byte-for-byte (a fast path keeps upstream bytes when the healer passes a chunk
through whole); once a tool signal appears, content is held, and at the
finish/[DONE] boundary either synthetic delta.tool_calls chunks replace the
markup (finish_reason rewritten to tool_calls, including the synthetic-finish
path) or a false alarm flushes the held text verbatim. Structured upstream
deltas put the healer to sleep after flushing anything held, so grammar-mode
responses stay byte-identical. The Responses stream feeds healed calls through
the same per-call state machinery as structured deltas (indexes live in a
disjoint range so a healed call can never merge into a structured call's
state), and the visible/reasoning split runs first so reasoning text is never
promoted. parallel_tool_calls=false caps healed calls on every path.
The upstream request body is never touched and healing issues no extra
generation, so llama-server slot/KV-cache reuse is unchanged. Opt-out per
request with auto_heal_tool_calls=false (Responses reads it from the
extra-body); requests without tools relay verbatim.
* inference: heal text-form tool calls on the Anthropic /v1/messages passthrough
Streaming: AnthropicPassthroughEmitter.enable_healing(allowed_tools) routes
content deltas through the shared StreamToolCallHealer. A promoted call closes
any open text block (only the safe prose prefix ever streamed into it), opens a
synthetic tool_use block with a fresh toolu_* id, carries one input_json_delta,
and closes; finish() then forces stop_reason to tool_use unless a truncation
(max_tokens) wins. Structured upstream deltas flush anything held and put the
healer to sleep, so grammar-mode responses are untouched, as is every stream
where enable_healing is never called (Studio's own loop, no-tools requests).
disable_parallel_tool_use caps healed calls too.
Non-streaming: the OpenAI message dict is healed BEFORE block building, so the
existing tool_use promotion loop and stop_reason line treat promoted calls
exactly like native ones (finish_reason length still maps to max_tokens). The
legacy tool-XML strip still runs on remaining text, so opted-out requests keep
today's cleanup behavior byte-for-byte.
auto_heal_tool_calls is now a typed field on AnthropicMessagesRequest
(default True, mirroring Chat Completions) and threads into both passthrough
calls. Healing never touches the upstream request body.
* inference: opt-in single-retry tool-call nudge on the non-streaming passthrough
When the model clearly tried to call a tool (a tool signal in the text) but
healing produced nothing usable, re-ask once: the retry body is the original
body plus an assistant turn (the model's own failed text) and a short user
nudge naming the declared tools. The prompt prefix stays byte-identical, so
llama-server reuses the slot's KV cache and only the two-message suffix is
prefilled. The retry replaces the original response only when it actually
yields a promotable or structured call; on any error or still-garbage output
the original response is returned unchanged. Exactly one retry, non-streaming
OpenAI and Anthropic passthroughs only (a stream has already emitted bytes).
OPT-IN per user decision: nudge_tool_calls=true per request (typed on both
ChatCompletionRequest and AnthropicMessagesRequest, lifted from the Responses
extra-body), or UNSLOTH_TOOL_CALL_NUDGE=1 to flip the process default.
auto_heal_tool_calls=false disables healing AND the nudge.
Also align the non-streaming heal on allow_incomplete=True: the response is
final, so a trailing unclosed tool block is a model failure worth repairing,
matching the enable-tools loop's drain semantics.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: never assume the upstream response shape in the nudge helpers
llama-server error bodies can carry message: null (or no choices at all), and
_last_assistant_text / response_has_promotable_calls / nudge_should_retry
called .get() on the message without a dict check, so a malformed upstream
response raised an AttributeError the surrounding except tuples did not catch,
failing the request instead of degrading to 'nothing to heal'. Route the shape
probing through one _first_choice_message helper that returns None for any
non-dict message, and add a parametrized test over the malformed shapes.
* inference: constrain healing by tool_choice, preserve length finish_reason, keep healed event order in Responses streams
Three review findings on the passthrough healer:
- heal_gate now honors the request's tool_choice: "none" disables healing
outright and a forced function narrows the promotion allowlist to that
one function, so healing can never contradict the request's tool-choice
constraint. Wired through the OpenAI chat (stream and non-stream),
Responses, and Anthropic (converted shape) passthroughs.
- The OpenAI non-streaming heal only upgrades finish_reason "stop" to
"tool_calls"; a truncated generation keeps "length" (the healed call
stays attached) matching the streaming and Anthropic paths.
- The Responses stream emits healer events in order instead of collapsing
all text ahead of the healed calls, so text after a healed call no longer
jumps ahead of the function_call item and output indexes are claimed in
the order the model produced them.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: all-or-nothing promotion when a response mixes declared and undeclared text-form calls
Promoting a subset used to strip ALL tool markup from the content, which
silently deleted the text of any call naming an undeclared tool. The heal
now declines entirely when any parsed call is unpromotable, so the whole
message relays verbatim (pre-PR behavior) and no bytes are ever lost. In
streaming, a declared call that completed before an undeclared one arrived
is already emitted; the late undeclared markup still flushes as raw text.
The nudge helpers mirror the same contract via a shared predicate.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* tests: wrap long lines in the Responses healing tests to the project style
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: span-exact healing, disjoint healed stream indexes, per-call Responses message items, allowlisted nudge acceptance
Four review findings on the passthrough healer:
- parse_tool_calls_from_text gains an optional with_spans return so healing
removes EXACTLY the promoted calls' markup. This supersedes the previous
all-or-nothing rule: declared calls promote and every unpromoted byte
(undeclared calls, unparseable closed blocks, suppressed alternate
formats such as a <function=...> block after a JSON call) relays as text.
The stream healer also processes one block per pass, so text between two
healed calls keeps its document position instead of trailing them.
- The OpenAI chat stream shifts native tool-call delta indexes past any
already-emitted healed calls; clients merge deltas by index, so a healed
call and a later native call can no longer merge into one.
- A healed call in the Responses stream closes the open message item and
trailing text opens a fresh one with a later output index, matching the
native stream shape; response.completed snapshots every message item
with its own text.
- The nudge retry only replaces the original response when the retry's
structured call names a DECLARED tool; a hallucinated undeclared call is
not an improvement.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: stop the heal path folding trailing prose into a closed function call
parse_tool_calls_from_text(allow_incomplete=True) cut a <function=...> body only
at an end-anchored </function>, so a fully closed call followed by trailing prose
(<function=..>..</parameter></function> words) folded </parameter></function> and
the prose into the tool argument and deleted the prose from visible content. The
strict path (allow_incomplete=False) already cut at the real </function> via rfind.
Do the same in both modes: trim the body at the real </function> when present and
end the removal span there, falling back to the end-anchored strip and body_end
only when the call is genuinely truncated. Add a regression test.
* inference: one shared single-call budget for healed and native calls
Codex round 5: the parallel-call caps counted healed and native calls
separately, so a healed text-form call followed by a native structured
delta double-emitted on all three streaming surfaces when the client
disabled parallel calls.
- OpenAI SSE: once a healed call went out with parallel_tool_calls
false, native tool_call deltas are dropped instead of index-shifted.
- Anthropic emitter: native deltas skip block allocation when the
healed-plus-native count already filled the single slot, and healed
emission counts open native states too.
- Responses stream: native deltas that survived the chunk-level cap are
skipped once a healed call claimed the slot.
Also adds a span assertion for the closed-</function> trailing-prose
parse fixed in the previous commit.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* studio: relay undeclared text-form calls as text on Anthropic non-streaming
heal_openai_message promotes only declared text-form tool calls and
span-trims just their markup, deliberately leaving every unpromoted byte
(undeclared text-form calls included) in the content to relay as text.
The Anthropic non-streaming builder then ran a blanket _TOOL_XML_RE strip
over that content unconditionally, deleting the undeclared block before
building the text part, so Anthropic clients silently lost a call the
OpenAI non-streaming path preserves. The strip was harmless when healing
was all-or-nothing but became data loss once healing turned span-exact.
Gate the legacy strip on whether healing promoted a call, matching the
OpenAI passthrough and the intent already stated in the comment above.
Add a route-level regression test for the mixed declared+undeclared case.
* inference: require fully declared nudge retries; keep unpromoted Anthropic text
Codex round 6, two findings:
- response_has_promotable_calls accepted a nudge retry when any one
structured call named a declared tool, so a mixed retry (hallucinated
undeclared call plus a declared one) replaced the original and the
caller forwarded the undeclared call, or with parallel_tool_calls
false could keep only it. All structured retry calls must be declared.
- The Anthropic non-streaming builder still ran the legacy _TOOL_XML_RE
strip after span-exact healing, deleting undeclared or malformed call
text that healing deliberately preserved. The legacy strip now runs
only when healing is off (no declared tools, or opted out), matching
the OpenAI passthrough.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* inference: keep unpromoted Anthropic text whenever healing is active
The previous commit skipped the legacy strip only when a call was
actually promoted, so an undeclared-only (or malformed-only) response
was still silently emptied: exactly the dead-turn shape this path
exists to fix, and inconsistent with the OpenAI passthrough, which
relays those bytes verbatim. Gate the strip on healing being active
instead; opt-out and no-tools requests keep the legacy strip.
* Fix schema-aware tool healing for PR #6801
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix passthrough healing ordering for PR #6801
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Fix stream finish ordering for PR #6801
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com>
462 lines
17 KiB
Python
462 lines
17 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
|
|
|
|
"""Lightweight tool-call XML 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.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
|
|
# Pre-compiled patterns for tool XML stripping. The hyphen in the name
|
|
# char-class lets dashed MCP tool/parameter names (mcp__srv__list-issues,
|
|
# issue-number) parse alongside the built-ins.
|
|
_TOOL_CLOSED_PATS = [
|
|
re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL),
|
|
re.compile(r"<\|tool_call>.*?<tool_call\|>", re.DOTALL),
|
|
re.compile(r"<tool_call\|>"),
|
|
re.compile(r"<function=[\w-]+>.*?</function>", re.DOTALL),
|
|
]
|
|
_TOOL_ALL_PATS = _TOOL_CLOSED_PATS + [
|
|
re.compile(r"<tool_call>.*$", re.DOTALL),
|
|
re.compile(r"<\|tool_call>.*$", re.DOTALL),
|
|
re.compile(r"<function=[\w-]+>.*$", re.DOTALL),
|
|
]
|
|
|
|
# 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>call:([\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*$")
|
|
_TC_PARAM_START_RE = re.compile(r"<parameter=([\w-]+)>\s*")
|
|
_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*:")
|
|
|
|
|
|
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 _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):
|
|
parts.append(json.dumps(raw.strip()) if raw.strip() else raw)
|
|
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
|
|
last_param_close = content.rfind(_PARAM_CLOSE_TAG, 0, pos)
|
|
last_func_close = content.rfind(_FUNC_CLOSE_TAG, 0, pos)
|
|
return last_param_start > max(last_param_close, last_func_close)
|
|
|
|
|
|
def parse_tool_calls_from_text(
|
|
content: str,
|
|
*,
|
|
id_offset: int = 0,
|
|
allow_incomplete: bool = True,
|
|
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>
|
|
|
|
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.
|
|
"""
|
|
tool_calls: list[dict] = []
|
|
call_spans: list[tuple] = []
|
|
# Collect every supported call format with spans, then emit in document
|
|
# order. A marker inside another call's argument string is data, not a
|
|
# separate executable call.
|
|
parsed_items = [] # (start, span_end, name, arguments)
|
|
candidates = [] # (start, brace_end, kind, match)
|
|
for m in _TC_JSON_START_RE.finditer(content):
|
|
if _inside_open_parameter(content, m.start()):
|
|
continue
|
|
end = _balanced_brace_end(content, m.end() - 1)
|
|
if end >= 0:
|
|
candidates.append((m.start(), end, "json", m))
|
|
for m in _TC_GEMMA_START_RE.finditer(content):
|
|
if _inside_open_parameter(content, m.start()):
|
|
continue
|
|
end = _balanced_brace_end(content, m.end() - 1, gemma_quotes = True)
|
|
if end >= 0:
|
|
candidates.append((m.start(), end, "gemma", m))
|
|
candidates.sort(key = lambda c: c[0])
|
|
|
|
candidate_spans = [(s, e) for s, e, _kind, _m in candidates]
|
|
for idx, (start, end, kind, m) in enumerate(candidates):
|
|
if any(s <= start and end <= e for j, (s, e) in enumerate(candidate_spans) if j != idx):
|
|
continue
|
|
if not allow_incomplete:
|
|
tail = content[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 : end + 1])
|
|
name = obj.get("name", "")
|
|
arguments = obj.get("arguments", {})
|
|
if isinstance(arguments, dict):
|
|
arguments = json.dumps(arguments)
|
|
else:
|
|
name = m.group(1)
|
|
arguments = json.dumps(_gemma_arguments_to_json(content[m.end() : end]))
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
span_end = 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 any(s <= fm.start() <= e for s, e in candidate_spans)
|
|
]
|
|
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 = body.rfind(_FUNC_CLOSE_TAG)
|
|
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)] = val.strip()
|
|
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] = val.strip()
|
|
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))
|
|
if with_spans:
|
|
return tool_calls, call_spans
|
|
return tool_calls
|
|
|
|
|
|
def strip_tool_call_markup(text: str, *, final: bool = False) -> 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.
|
|
"""
|
|
patterns = _TOOL_ALL_PATS if final else _TOOL_CLOSED_PATS
|
|
for pat in patterns:
|
|
text = pat.sub("", text)
|
|
return text.strip() if final else text
|