[Fix] lfm2 detector: recover tool calls dropped by common model-outpu… (#34237)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
co-authored by
Claude
Xinyuan Tong
parent
e3a008a9db
commit
27aa48bca1
@@ -19,6 +19,7 @@ Also supports JSON format:
|
||||
|
||||
import ast
|
||||
import json
|
||||
import keyword as _python_keyword
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
@@ -46,6 +47,387 @@ _PYTHONIC_NAME_LITERALS = {
|
||||
"null": None,
|
||||
}
|
||||
|
||||
_QUOTE_FOLLOWERS = {",", ")", "]", "}", ":"}
|
||||
_RESERVED_KW_SUFFIX = "_pyreservedkw_"
|
||||
|
||||
|
||||
def _rename_reserved_kwargs(text: str) -> Tuple[str, bool]:
|
||||
"""Rename Python-keyword parameter names so the text parses.
|
||||
|
||||
Tools legitimately name parameters ``from``/``in``/``class``, but
|
||||
``memory_get(from=1)`` is a Python ``SyntaxError``. Rename ``from=`` to
|
||||
``from_pyreservedkw_=`` (outside string literals, keyword-argument
|
||||
position only), parse, then restore via
|
||||
:func:`_restore_reserved_kwarg_names`. Returns (rewritten_text, changed).
|
||||
"""
|
||||
out: List[str] = []
|
||||
quote: Optional[str] = None
|
||||
changed = False
|
||||
last_sig = ""
|
||||
index, length = 0, len(text)
|
||||
while index < length:
|
||||
char = text[index]
|
||||
if quote is not None:
|
||||
out.append(char)
|
||||
if char == "\\" and index + 1 < length:
|
||||
out.append(text[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
if char == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if char in {"'", '"'}:
|
||||
quote = char
|
||||
out.append(char)
|
||||
last_sig = char
|
||||
index += 1
|
||||
continue
|
||||
if char.isalpha() or char == "_":
|
||||
end = index
|
||||
while end < length and (text[end].isalnum() or text[end] == "_"):
|
||||
end += 1
|
||||
name = text[index:end]
|
||||
look = end
|
||||
while look < length and text[look].isspace():
|
||||
look += 1
|
||||
if (
|
||||
_python_keyword.iskeyword(name)
|
||||
and look < length
|
||||
and text[look] == "="
|
||||
and (look + 1 >= length or text[look + 1] != "=")
|
||||
and last_sig in {"(", ","}
|
||||
):
|
||||
out.append(name + _RESERVED_KW_SUFFIX)
|
||||
changed = True
|
||||
else:
|
||||
out.append(name)
|
||||
last_sig = name[-1]
|
||||
index = end
|
||||
continue
|
||||
out.append(char)
|
||||
if not char.isspace():
|
||||
last_sig = char
|
||||
index += 1
|
||||
return "".join(out), changed
|
||||
|
||||
|
||||
def _restore_reserved_kwarg_names(arguments: dict) -> dict:
|
||||
"""Exact inverse of :func:`_rename_reserved_kwargs` on a decoded dict."""
|
||||
restored = {}
|
||||
for key, value in arguments.items():
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.endswith(_RESERVED_KW_SUFFIX)
|
||||
and _python_keyword.iskeyword(key[: -len(_RESERVED_KW_SUFFIX)])
|
||||
):
|
||||
restored[key[: -len(_RESERVED_KW_SUFFIX)]] = value
|
||||
else:
|
||||
restored[key] = value
|
||||
return restored
|
||||
|
||||
|
||||
def _is_escaped(text: str, index: int) -> bool:
|
||||
"""Whether the char at ``index`` follows an odd run of backslashes."""
|
||||
backslashes = 0
|
||||
j = index - 1
|
||||
while j >= 0 and text[j] == "\\":
|
||||
backslashes += 1
|
||||
j -= 1
|
||||
return backslashes % 2 == 1
|
||||
|
||||
|
||||
def _escape_nested_quotes_in_strings(text: str) -> Tuple[str, bool]:
|
||||
"""Close a broken string literal at the only closing quote that works.
|
||||
|
||||
Shell commands nest unescaped same-style quotes inside a string argument
|
||||
(``command='sed -n '360,450p' f.py'`` or a quoted ``python3 -c`` payload),
|
||||
which Python reads as juxtaposed garbage, so the call is dropped even
|
||||
though the intent is unambiguous. A string is broken when its first
|
||||
unescaped quote cannot syntactically close it (what follows is none of
|
||||
``,)]}:``). For a broken string, every syntactically plausible closing
|
||||
quote is tried — interior quotes escaped, the rest kept verbatim — and
|
||||
the result validated with ``ast.parse``. Exactly one parsing candidate
|
||||
means recovery; zero or several means genuine ambiguity and the text is
|
||||
returned unchanged rather than guessed at.
|
||||
|
||||
Returns (rewritten_text, changed).
|
||||
"""
|
||||
|
||||
def unescaped_quotes(start: int, quote: str) -> List[int]:
|
||||
positions = []
|
||||
j = start
|
||||
while j < len(text):
|
||||
if text[j] == "\\":
|
||||
j += 2
|
||||
continue
|
||||
if text[j] == quote:
|
||||
positions.append(j)
|
||||
j += 1
|
||||
return positions
|
||||
|
||||
def is_closer(pos: int) -> bool:
|
||||
k = pos + 1
|
||||
while k < len(text) and text[k].isspace():
|
||||
k += 1
|
||||
return k < len(text) and text[k] in _QUOTE_FOLLOWERS
|
||||
|
||||
# A late-closing reading can swallow a whole sibling call into the
|
||||
# string value (``f(a='x 'y'), g(...)`` parsing as one call with
|
||||
# ``g(...)`` inside ``a``) — worse than dropping it, since the tool then
|
||||
# runs with corrupted arguments. Counting brackets is immune to the
|
||||
# broken quote, so the block's call count is the invariant.
|
||||
expected_calls = len(_split_top_level_calls(text, respect_strings=False))
|
||||
prefix: List[str] = []
|
||||
index = 0
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if char not in {"'", '"'}:
|
||||
prefix.append(char)
|
||||
index += 1
|
||||
continue
|
||||
quotes = unescaped_quotes(index + 1, char)
|
||||
if not quotes:
|
||||
return text, False
|
||||
if is_closer(quotes[0]):
|
||||
prefix.append(text[index : quotes[0] + 1])
|
||||
index = quotes[0] + 1
|
||||
continue
|
||||
winners = []
|
||||
for close in (j for j in quotes if is_closer(j)):
|
||||
interior: List[str] = []
|
||||
for j in range(index + 1, close):
|
||||
if text[j] == char and not _is_escaped(text, j):
|
||||
interior.append("\\")
|
||||
interior.append(text[j])
|
||||
candidate = "".join(
|
||||
["".join(prefix), char, "".join(interior), char, text[close + 1 :]]
|
||||
)
|
||||
try:
|
||||
module = safe_ast_parse(_escape_ctrl_chars_in_strings(candidate))
|
||||
except (SyntaxError, ValueError):
|
||||
continue
|
||||
if expected_calls > 1 and _top_level_call_count(module) < expected_calls:
|
||||
continue
|
||||
winners.append(candidate)
|
||||
if len(winners) == 1:
|
||||
return winners[0], True
|
||||
return text, False
|
||||
return text, False
|
||||
|
||||
|
||||
def _escape_ctrl_chars_in_strings(text: str) -> str:
|
||||
"""Escape raw control chars inside string literals of pythonic text.
|
||||
|
||||
Models frequently place raw newlines inside a string argument (multi-line
|
||||
shell commands), which is invalid Python, and a NUL byte anywhere makes
|
||||
``ast.parse`` raise ``ValueError``. Escaping ``\\n``/``\\r``/``\\t``/
|
||||
``\\x00`` only inside string literals makes the text parseable while the
|
||||
escape sequences evaluate back to the exact original value.
|
||||
"""
|
||||
out: List[str] = []
|
||||
quote: Optional[str] = None
|
||||
index, length = 0, len(text)
|
||||
while index < length:
|
||||
char = text[index]
|
||||
if quote is None:
|
||||
if char in {"'", '"'}:
|
||||
quote = char
|
||||
out.append(char)
|
||||
elif char == "\\" and index + 1 < length:
|
||||
out.append(char)
|
||||
out.append(text[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
elif char == quote:
|
||||
quote = None
|
||||
out.append(char)
|
||||
elif char == "\n":
|
||||
out.append("\\n")
|
||||
elif char == "\r":
|
||||
out.append("\\r")
|
||||
elif char == "\t":
|
||||
out.append("\\t")
|
||||
elif char == "\x00":
|
||||
out.append("\\x00")
|
||||
else:
|
||||
out.append(char)
|
||||
index += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _normalize_leading_zero_ints(text: str) -> str:
|
||||
"""Strip leading zeros from decimal int literals (``month=07`` -> ``7``).
|
||||
|
||||
Zero-padded integers are a ``SyntaxError`` no other rewrite recovers.
|
||||
Only rewrites outside string literals; tokens that are already valid
|
||||
Python (``0x``/``0o``/``0b``, floats, exponents, all-zero literals,
|
||||
fractional parts like ``1.07``) are left untouched.
|
||||
"""
|
||||
out: List[str] = []
|
||||
quote: Optional[str] = None
|
||||
index, length = 0, len(text)
|
||||
while index < length:
|
||||
char = text[index]
|
||||
if quote is not None:
|
||||
out.append(char)
|
||||
if char == "\\" and index + 1 < length:
|
||||
out.append(text[index + 1])
|
||||
index += 2
|
||||
continue
|
||||
if char == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if char in {"'", '"'}:
|
||||
quote = char
|
||||
out.append(char)
|
||||
index += 1
|
||||
continue
|
||||
if char.isalpha() or char == "_":
|
||||
end = index
|
||||
while end < length and (text[end].isalnum() or text[end] == "_"):
|
||||
end += 1
|
||||
out.append(text[index:end])
|
||||
index = end
|
||||
continue
|
||||
if char.isdigit():
|
||||
end = index
|
||||
while end < length and (text[end].isdigit() or text[end] == "_"):
|
||||
end += 1
|
||||
token = text[index:end]
|
||||
digits = token.replace("_", "")
|
||||
follower = text[end] if end < length else ""
|
||||
preceded_by_dot = index > 0 and text[index - 1] == "."
|
||||
if (
|
||||
digits[0] == "0"
|
||||
and digits.strip("0")
|
||||
and not preceded_by_dot
|
||||
and follower not in {".", "e", "E", "j", "J"}
|
||||
):
|
||||
out.append(str(int(digits)))
|
||||
else:
|
||||
out.append(token)
|
||||
index = end
|
||||
continue
|
||||
out.append(char)
|
||||
index += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _recovery_candidates(content: str) -> List[Tuple[str, bool]]:
|
||||
"""Progressive rewrites for content that failed to parse.
|
||||
|
||||
Each rewrite is a no-op on already-valid text; the first candidate whose
|
||||
result parses wins. Nested-quote recovery is re-escaped, since requoting
|
||||
can move raw newlines inside the string. The flag marks candidates that
|
||||
went through reserved-keyword renaming; only their decoded arguments get
|
||||
the original parameter names restored.
|
||||
"""
|
||||
escaped = _escape_ctrl_chars_in_strings(_normalize_leading_zero_ints(content))
|
||||
candidates: List[Tuple[str, bool]] = [(escaped, False)]
|
||||
requoted, requote_changed = _escape_nested_quotes_in_strings(escaped)
|
||||
if requote_changed:
|
||||
candidates.append((_escape_ctrl_chars_in_strings(requoted), False))
|
||||
for text, _ in list(candidates):
|
||||
renamed, kw_renamed = _rename_reserved_kwargs(text)
|
||||
if kw_renamed:
|
||||
candidates.append((renamed, True))
|
||||
# A call can stack both quirks; renaming first lets requote
|
||||
# validate candidates the keyword SyntaxError otherwise blocks.
|
||||
requoted_after, requote_after_changed = _escape_nested_quotes_in_strings(
|
||||
renamed
|
||||
)
|
||||
if requote_after_changed:
|
||||
candidates.append((_escape_ctrl_chars_in_strings(requoted_after), True))
|
||||
return candidates
|
||||
|
||||
|
||||
def _split_top_level_calls(text: str, *, respect_strings: bool = True) -> List[str]:
|
||||
"""Split a pythonic call block into top-level call segments.
|
||||
|
||||
``[a(x=1), b(y=2)]`` becomes ``["a(x=1)", "b(y=2)"]``: one enclosing
|
||||
bracket pair is stripped and only commas at bracket depth 0 separate
|
||||
segments. With ``respect_strings=False`` only brackets are counted,
|
||||
which a broken quote cannot desynchronize; string arguments always sit
|
||||
at depth >= 1, so their commas still never split.
|
||||
"""
|
||||
text = text.strip()
|
||||
if text.startswith("[") and text.endswith("]"):
|
||||
text = text[1:-1]
|
||||
segments: List[str] = []
|
||||
start = 0
|
||||
depth = 0
|
||||
quote: Optional[str] = None
|
||||
index = 0
|
||||
while index < len(text):
|
||||
char = text[index]
|
||||
if respect_strings and quote is not None:
|
||||
if char == "\\":
|
||||
index += 2
|
||||
continue
|
||||
if char == quote:
|
||||
quote = None
|
||||
index += 1
|
||||
continue
|
||||
if respect_strings and char in {"'", '"'}:
|
||||
quote = char
|
||||
elif char in "([{":
|
||||
depth += 1
|
||||
elif char in ")]}":
|
||||
depth -= 1
|
||||
elif char == "," and depth == 0:
|
||||
segments.append(text[start:index])
|
||||
start = index + 1
|
||||
index += 1
|
||||
segments.append(text[start:])
|
||||
return [segment.strip() for segment in segments if segment.strip()]
|
||||
|
||||
|
||||
def _top_level_call_count(module: ast.Module) -> int:
|
||||
"""Number of calls in a parsed ``[a(...), b(...)]`` block."""
|
||||
if not module.body:
|
||||
return 0
|
||||
value = getattr(module.body[0], "value", None)
|
||||
if isinstance(value, ast.List):
|
||||
return sum(1 for element in value.elts if isinstance(element, ast.Call))
|
||||
return 1 if isinstance(value, ast.Call) else 0
|
||||
|
||||
|
||||
def _salvage_calls_from_unparsable_block(text: str) -> List[Tuple[ast.Call, bool]]:
|
||||
"""Recover individual calls from a block ``ast.parse`` cannot handle.
|
||||
|
||||
When the block as a whole is a SyntaxError no rewrite recovers, there
|
||||
is no call list at all and one bad call drops every parseable sibling,
|
||||
leaving an agent loop with no tool result. Split with both scanning
|
||||
strategies and parse each segment on its own through the rewrite
|
||||
ladder. A wrongly split segment simply fails to parse and is dropped,
|
||||
so this can only under-recover, never attribute arguments to the wrong
|
||||
call. Each call carries the reserved-keyword flag of the candidate it
|
||||
parsed from.
|
||||
"""
|
||||
best: List[Tuple[ast.Call, bool]] = []
|
||||
for respect_strings in (True, False):
|
||||
segments = _split_top_level_calls(text, respect_strings=respect_strings)
|
||||
if len(segments) < 2:
|
||||
continue
|
||||
calls: List[Tuple[ast.Call, bool]] = []
|
||||
for segment in segments:
|
||||
for candidate, kw_renamed in [(segment, False)] + _recovery_candidates(
|
||||
segment
|
||||
):
|
||||
try:
|
||||
module = safe_ast_parse(candidate)
|
||||
except (SyntaxError, ValueError):
|
||||
continue
|
||||
parsed = getattr(module.body[0], "value", None) if module.body else None
|
||||
if isinstance(parsed, ast.Call):
|
||||
calls.append((parsed, kw_renamed))
|
||||
break
|
||||
if len(calls) > len(best):
|
||||
best = calls
|
||||
return best
|
||||
|
||||
|
||||
class Lfm2Detector(BaseFormatDetector):
|
||||
"""
|
||||
@@ -85,7 +467,14 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
Reuses pattern from PythonicDetector.
|
||||
"""
|
||||
if isinstance(val, ast.Constant):
|
||||
return val.value
|
||||
if val.value is None or isinstance(val.value, (str, int, float)):
|
||||
return val.value
|
||||
# bytes/Ellipsis/complex have no JSON form; raising ValueError
|
||||
# here lets the per-call handler skip this call instead of a
|
||||
# TypeError inside json.dumps dropping every sibling call.
|
||||
raise ValueError(
|
||||
f"Constant has no JSON representation: {type(val.value).__name__}"
|
||||
)
|
||||
elif isinstance(val, ast.Dict):
|
||||
return {
|
||||
self._get_parameter_value(k): self._get_parameter_value(v)
|
||||
@@ -96,6 +485,16 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
return [self._get_parameter_value(v) for v in val.elts]
|
||||
elif isinstance(val, ast.Tuple):
|
||||
return tuple(self._get_parameter_value(v) for v in val.elts)
|
||||
elif isinstance(val, ast.Set):
|
||||
# JSON has no set type; decode as a list in source order.
|
||||
return [self._get_parameter_value(v) for v in val.elts]
|
||||
elif isinstance(val, ast.JoinedStr) and all(
|
||||
isinstance(part, ast.Constant) for part in val.values
|
||||
):
|
||||
# A placeholder-free f-string (f'hello') is a plain string
|
||||
# constant, but Python parses it as JoinedStr; f-strings with
|
||||
# real placeholders still fall through to the raise below.
|
||||
return "".join(str(part.value) for part in val.values)
|
||||
elif isinstance(val, ast.Name):
|
||||
# Python True/False/None are ast.Constant on modern Python, but
|
||||
# accept their legacy node shape plus LFM2's JSON-literal spellings.
|
||||
@@ -103,12 +502,12 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
return _PYTHONIC_NAME_LITERALS[val.id]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unsupported name reference: {val.id}") from None
|
||||
elif isinstance(val, ast.UnaryOp) and isinstance(val.op, ast.USub):
|
||||
# Handle negative numbers like -5
|
||||
elif isinstance(val, ast.UnaryOp) and isinstance(val.op, (ast.USub, ast.UAdd)):
|
||||
# Handle signed numbers like -5 and +5
|
||||
inner = self._get_parameter_value(val.operand)
|
||||
if isinstance(inner, (int, float)):
|
||||
return -inner
|
||||
raise ValueError(f"Cannot negate non-numeric value: {inner}")
|
||||
if isinstance(inner, (int, float)) and not isinstance(inner, bool):
|
||||
return -inner if isinstance(val.op, ast.USub) else inner
|
||||
raise ValueError(f"Cannot apply sign to non-numeric value: {inner}")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Tool call arguments must be literals, got: {type(val).__name__}"
|
||||
@@ -128,7 +527,12 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
return ".".join(reversed(parts))
|
||||
|
||||
def _parse_pythonic_call(
|
||||
self, call: ast.Call, call_index: int, tool_indices: Dict[str, int]
|
||||
self,
|
||||
call: ast.Call,
|
||||
call_index: int,
|
||||
tool_indices: Dict[str, int],
|
||||
*,
|
||||
restore_reserved_kwarg: bool = False,
|
||||
) -> Optional[ToolCallItem]:
|
||||
"""
|
||||
Parse a single AST Call node into a ToolCallItem.
|
||||
@@ -137,6 +541,8 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
call: AST Call node representing a function call
|
||||
call_index: Index of this call in the list of calls
|
||||
tool_indices: Mapping of tool names to their indices
|
||||
restore_reserved_kwarg: Whether the parsed text went through
|
||||
reserved-keyword renaming
|
||||
|
||||
Returns:
|
||||
ToolCallItem if successful, None if the call should be skipped
|
||||
@@ -156,12 +562,31 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
|
||||
return None # Skip unknown tools (default legacy behavior)
|
||||
|
||||
if call.args:
|
||||
# Only keyword arguments carry parameter names; positional
|
||||
# values used to be dropped silently, emitting a
|
||||
# successful-looking call with arguments missing. Reject
|
||||
# instead (parseable sibling calls are kept).
|
||||
logger.warning(f"Tool call {function_name} has positional arguments")
|
||||
return None
|
||||
|
||||
# Parse arguments
|
||||
arguments = {}
|
||||
for keyword in call.keywords:
|
||||
if keyword.arg is None:
|
||||
# **kwargs unpacking - skip for now
|
||||
logger.warning("Tool call with **kwargs unpacking is not supported")
|
||||
# **-unpacking is ast.keyword(arg=None); the kwargs used to
|
||||
# be skipped silently, emitting the call with arguments
|
||||
# missing. Merge dict literals with Python's
|
||||
# later-binding-wins semantics and reject anything else.
|
||||
try:
|
||||
unpacked = self._get_parameter_value(keyword.value)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to parse **-unpacked arguments: {e}")
|
||||
return None
|
||||
if not isinstance(unpacked, dict):
|
||||
logger.warning("**-unpacked arguments must be a dict literal")
|
||||
return None
|
||||
arguments.update(unpacked)
|
||||
continue
|
||||
try:
|
||||
arguments[keyword.arg] = self._get_parameter_value(keyword.value)
|
||||
@@ -169,10 +594,24 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
logger.warning(f"Failed to parse argument {keyword.arg}: {e}")
|
||||
return None
|
||||
|
||||
if restore_reserved_kwarg:
|
||||
# Unconditional restore would rewrite a parameter literally
|
||||
# named e.g. ``in_pyreservedkw_`` to ``in`` on the normal path.
|
||||
arguments = _restore_reserved_kwarg_names(arguments)
|
||||
|
||||
try:
|
||||
# allow_nan=False: a non-finite float (e.g. the literal 1e999
|
||||
# overflowing to inf) would otherwise serialize as Infinity,
|
||||
# which is not valid JSON for downstream clients.
|
||||
parameters = json.dumps(arguments, ensure_ascii=False, allow_nan=False)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Arguments of {function_name} are not valid JSON: {e}")
|
||||
return None
|
||||
|
||||
return ToolCallItem(
|
||||
tool_index=call_index, # Use the call index in the response, not tool position
|
||||
name=function_name,
|
||||
parameters=json.dumps(arguments, ensure_ascii=False),
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
def _parse_pythonic_content(
|
||||
@@ -192,7 +631,37 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
tool_indices = self._get_tool_indices(tools)
|
||||
|
||||
try:
|
||||
module = safe_ast_parse(content)
|
||||
kw_renamed = False
|
||||
try:
|
||||
module = safe_ast_parse(content)
|
||||
except (SyntaxError, ValueError):
|
||||
# Recoverable model quirks are rewritten value-preservingly;
|
||||
# the first rewrite that parses wins. Unrecoverable text
|
||||
# re-raises the original error.
|
||||
for candidate, kw_renamed in _recovery_candidates(content):
|
||||
try:
|
||||
module = safe_ast_parse(candidate)
|
||||
break
|
||||
except (SyntaxError, ValueError):
|
||||
continue
|
||||
else:
|
||||
# The block as a whole is unrecoverable. Split it into
|
||||
# top-level segments and parse each on its own so one bad
|
||||
# call does not drop every parseable sibling.
|
||||
salvaged = _salvage_calls_from_unparsable_block(content)
|
||||
if not salvaged:
|
||||
raise
|
||||
calls = []
|
||||
for call_index, (call, segment_kw_renamed) in enumerate(salvaged):
|
||||
item = self._parse_pythonic_call(
|
||||
call,
|
||||
call_index,
|
||||
tool_indices,
|
||||
restore_reserved_kwarg=segment_kw_renamed,
|
||||
)
|
||||
if item is not None:
|
||||
calls.append(item)
|
||||
return calls, ""
|
||||
parsed = getattr(module.body[0], "value", None) if module.body else None
|
||||
|
||||
if parsed is None:
|
||||
@@ -215,13 +684,18 @@ class Lfm2Detector(BaseFormatDetector):
|
||||
|
||||
calls = []
|
||||
for call_index, call in enumerate(call_nodes):
|
||||
item = self._parse_pythonic_call(call, call_index, tool_indices)
|
||||
item = self._parse_pythonic_call(
|
||||
call,
|
||||
call_index,
|
||||
tool_indices,
|
||||
restore_reserved_kwarg=kw_renamed,
|
||||
)
|
||||
if item is not None:
|
||||
calls.append(item)
|
||||
|
||||
return calls, ""
|
||||
|
||||
except SyntaxError as e:
|
||||
except (SyntaxError, ValueError) as e:
|
||||
return [], f"Python syntax error: {e}"
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error in pythonic tool call parsing")
|
||||
|
||||
@@ -95,14 +95,29 @@ class PythonicDetector(BaseFormatDetector):
|
||||
if not envs.SGLANG_FORWARD_UNKNOWN_TOOLS.get():
|
||||
continue # Skip unknown tools (default legacy behavior)
|
||||
|
||||
arguments = {}
|
||||
for keyword in call.keywords:
|
||||
arguments[keyword.arg] = self._get_parameter_value(keyword.value)
|
||||
# Convert each call on its own: an unconvertible argument used
|
||||
# to escape to the outer handler and drop every parseable
|
||||
# sibling call in the block.
|
||||
try:
|
||||
arguments = {}
|
||||
for keyword in call.keywords:
|
||||
arguments[keyword.arg] = self._get_parameter_value(
|
||||
keyword.value
|
||||
)
|
||||
# allow_nan=False: a non-finite float (e.g. the literal
|
||||
# 1e999 overflowing to inf) would otherwise serialize as
|
||||
# Infinity, which is not valid JSON for downstream clients.
|
||||
parameters = json.dumps(
|
||||
arguments, ensure_ascii=False, allow_nan=False
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Skipping tool call {function_name}: {e}")
|
||||
continue
|
||||
calls.append(
|
||||
ToolCallItem(
|
||||
tool_index=call_index, # Use the call index in the response, not tool position
|
||||
name=function_name,
|
||||
parameters=json.dumps(arguments, ensure_ascii=False),
|
||||
parameters=parameters,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -207,7 +222,14 @@ class PythonicDetector(BaseFormatDetector):
|
||||
|
||||
def _get_parameter_value(self, val):
|
||||
if isinstance(val, ast.Constant):
|
||||
return val.value
|
||||
if val.value is None or isinstance(val.value, (str, int, float)):
|
||||
return val.value
|
||||
# bytes/Ellipsis/complex have no JSON form; raising here lets the
|
||||
# per-call handler skip this call instead of a TypeError inside
|
||||
# json.dumps dropping every sibling call in the block.
|
||||
raise ValueError(
|
||||
f"Constant has no JSON representation: {type(val.value).__name__}"
|
||||
)
|
||||
elif isinstance(val, ast.Dict):
|
||||
return {
|
||||
k.value: self._get_parameter_value(v)
|
||||
|
||||
@@ -769,6 +769,27 @@ class TestPythonicDetector(unittest.TestCase):
|
||||
self.assertEqual(params["location"], "Mars")
|
||||
self.assertEqual(params["unit"], "celsius")
|
||||
|
||||
def test_non_finite_argument_never_emits_invalid_json(self):
|
||||
"""A 1e999 literal overflows to inf and json.dumps rendered it as
|
||||
Infinity — parameters no JSON parser accepts, delivered as a
|
||||
successful call. The call is skipped instead."""
|
||||
text = "[get_weather(location='Tokyo', unit=1e999)]"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
for call in result.calls:
|
||||
json.loads(call.parameters)
|
||||
self.assertEqual(result.calls, [])
|
||||
|
||||
def test_unconvertible_argument_skips_only_that_call(self):
|
||||
"""A bytes argument is an ast.Constant, so it passed value
|
||||
extraction and only failed later inside json.dumps, escaping to the
|
||||
block-level handler and dropping every parseable sibling call."""
|
||||
text = "[get_weather(location='Tokyo'), search(query=b'raw')]"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual([c.name for c in result.calls], ["get_weather"])
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"location": "Tokyo"})
|
||||
|
||||
|
||||
class TestMistralDetector(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -4052,6 +4073,245 @@ class TestLfm2Detector(unittest.TestCase):
|
||||
self.assertEqual(result.calls[0].name, "get_weather")
|
||||
self.assertEqual(result.calls[1].name, "search")
|
||||
|
||||
# ==================== recovery tests (dropped-call regressions) ====================
|
||||
|
||||
def test_multiline_string_argument_recovered(self):
|
||||
"""A raw newline inside a string argument (multi-line shell command)
|
||||
is invalid Python, so ast.parse failed and the whole call was
|
||||
dropped. The value must round-trip with the newline intact."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='line one\nline two')]<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["query"], "line one\nline two")
|
||||
|
||||
def test_nul_byte_in_string_argument_recovered(self):
|
||||
"""A NUL byte anywhere makes ast.parse raise ValueError (not
|
||||
SyntaxError), so the call was dropped with no recovery path."""
|
||||
text = "<|tool_call_start|>[search(query='printf a\x00b')]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["query"], "printf a\x00b")
|
||||
|
||||
def test_nested_quotes_recovered(self):
|
||||
"""Unescaped same-style quotes nested in a shell command
|
||||
(sed -n '360,450p') read as string/number juxtaposition, a
|
||||
SyntaxError, so the call was dropped even though only one closing
|
||||
quote yields parseable text."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='sed -n '360,450p' f.py')]"
|
||||
"<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["query"], "sed -n '360,450p' f.py")
|
||||
|
||||
def test_ambiguous_nested_quotes_not_guessed(self):
|
||||
"""When a later string argument's closing quote is also a plausible
|
||||
closer, the nesting is genuinely ambiguous; recovery must NOT guess
|
||||
a reading (guards the recovery predicate degrading to greedy)."""
|
||||
text = (
|
||||
"<|tool_call_start|>[get_weather(city='echo 'hi', unit='celsius')]"
|
||||
"<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(result.calls, [])
|
||||
|
||||
def test_reserved_keyword_parameter_recovered(self):
|
||||
"""A parameter named after a Python keyword (from=1) is a
|
||||
SyntaxError; the call was dropped. The original parameter name must
|
||||
be restored in the decoded arguments."""
|
||||
text = "<|tool_call_start|>[search(query='M.md', from=1)]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params, {"query": "M.md", "from": 1})
|
||||
|
||||
def test_zero_padded_int_recovered(self):
|
||||
"""Zero-padded ints (day=07) are a SyntaxError ("leading zeros in
|
||||
decimal integer literals"); the call was dropped."""
|
||||
text = "<|tool_call_start|>[get_weather(city='NYC', day=07)]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["day"], 7)
|
||||
|
||||
def test_explicit_positive_number(self):
|
||||
"""An explicitly signed positive number (+7) is UnaryOp(UAdd), which
|
||||
only had a USub branch, so the call was dropped."""
|
||||
text = "<|tool_call_start|>[search(query='x', limit=+7)]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["limit"], 7)
|
||||
|
||||
def test_set_argument_decoded_as_list(self):
|
||||
"""A set argument ({'a', 'b'}) raised in _get_parameter_value and
|
||||
dropped the call; JSON has no set type so it decodes as a list."""
|
||||
text = "<|tool_call_start|>[search(query={'a', 'b'})]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["query"], ["a", "b"])
|
||||
|
||||
def test_constant_fstring_argument(self):
|
||||
"""A placeholder-free f-string (f'hello') parses as JoinedStr, not
|
||||
Constant, and dropped the call although it is a plain string."""
|
||||
text = "<|tool_call_start|>[search(query=f'hello')]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params["query"], "hello")
|
||||
|
||||
def test_bytes_argument_skips_only_that_call(self):
|
||||
"""A bytes argument passed _get_parameter_value (it is an
|
||||
ast.Constant) and only failed later as TypeError inside json.dumps,
|
||||
which escaped the per-call handler and dropped every sibling call in
|
||||
the block."""
|
||||
text = (
|
||||
"<|tool_call_start|>[get_weather(city='SF'), search(query=b'z')]"
|
||||
"<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "get_weather")
|
||||
|
||||
def test_non_finite_number_never_emits_invalid_json(self):
|
||||
"""The literal 1e999 overflows to float inf, and json.dumps rendered
|
||||
it as Infinity — parameters that no JSON parser accepts. The call
|
||||
must be skipped instead; every emitted parameters string must be
|
||||
valid JSON."""
|
||||
text = "<|tool_call_start|>[search(query='x', limit=1e999)]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
for call in result.calls:
|
||||
json.loads(call.parameters)
|
||||
self.assertEqual(result.calls, [])
|
||||
|
||||
def test_kwargs_unpack_merges_dict(self):
|
||||
"""**-unpacked kwargs were skipped silently, emitting the call with
|
||||
arguments missing; a dict literal merges with later-binding-wins
|
||||
semantics instead, and non-dict operands reject the call."""
|
||||
text = "<|tool_call_start|>[search(**{'query': 'x'}, limit=2)]<|tool_call_end|>"
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params, {"query": "x", "limit": 2})
|
||||
|
||||
bad = "<|tool_call_start|>[search(**[1, 2])]<|tool_call_end|>"
|
||||
self.assertEqual(self.detector.detect_and_parse(bad, self.tools).calls, [])
|
||||
|
||||
def test_positional_argument_call_not_silently_corrupted(self):
|
||||
"""get_weather('Paris', unit='celsius') used to silently drop
|
||||
'Paris' and emit a successful call with only {"unit": "celsius"} —
|
||||
a wrong execution instead of a visible failure. The call is
|
||||
rejected; a keyword-only sibling still comes through."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='x'), "
|
||||
"get_weather('Paris', unit='celsius')]<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "search")
|
||||
|
||||
def test_good_call_survives_unparsable_block(self):
|
||||
"""A genuinely ambiguous nested quote makes the whole block a
|
||||
SyntaxError, so no call list exists and the parseable sibling died
|
||||
with the block, leaving the agent loop with no tool result."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='ok'), "
|
||||
"get_weather(city='x 'y', unit='c')]<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
self.assertEqual(result.calls[0].name, "search")
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"query": "ok"})
|
||||
|
||||
def test_swallowing_reading_rejected(self):
|
||||
"""Closing the broken string late makes the text parse by absorbing
|
||||
the sibling call into the argument value, so the tool would run with
|
||||
corrupted arguments. Rejecting readings that lose calls leaves the
|
||||
correct early close and recovers both calls."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='x 'y'), "
|
||||
"get_weather(city='p 'q')]<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual([c.name for c in result.calls], ["search", "get_weather"])
|
||||
self.assertEqual(json.loads(result.calls[0].parameters), {"query": "x 'y"})
|
||||
self.assertEqual(json.loads(result.calls[1].parameters), {"city": "p 'q"})
|
||||
|
||||
def test_unrecoverable_block_reports_no_calls(self):
|
||||
"""Splitting must not fabricate calls: when no segment parses, the
|
||||
block yields no tool calls at all."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='x 'y' 'z), "
|
||||
"get_weather(city='p 'q' 'r)]<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(result.calls, [])
|
||||
|
||||
def test_streaming_recovers_multiline(self):
|
||||
"""Streaming buffers the block and delegates to detect_and_parse;
|
||||
an incremental rewrite of the streaming path would bypass the
|
||||
recovery rewrites and re-drop multi-line commands."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='line one\nline two')]<|tool_call_end|>"
|
||||
)
|
||||
detector = Lfm2Detector()
|
||||
calls = []
|
||||
for i in range(0, len(text), 7):
|
||||
result = detector.parse_streaming_increment(text[i : i + 7], self.tools)
|
||||
calls.extend(result.calls)
|
||||
|
||||
self.assertEqual(len(calls), 1)
|
||||
params = json.loads(calls[0].parameters)
|
||||
self.assertEqual(params["query"], "line one\nline two")
|
||||
|
||||
def test_reserved_kwarg_suffix_parameter_not_rewritten(self):
|
||||
"""A parameter literally named in_pyreservedkw_ must survive the
|
||||
normal parse path; only recovery-renamed kwargs get restored."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(query='x', in_pyreservedkw_=5)]"
|
||||
"<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params, {"query": "x", "in_pyreservedkw_": 5})
|
||||
|
||||
def test_reserved_kwarg_with_nested_quote_recovered(self):
|
||||
"""A keyword-named parameter holding a nested-quote command needs
|
||||
the rename and requote rewrites to compose."""
|
||||
text = (
|
||||
"<|tool_call_start|>[search(from='sed -n '1,5p' f.py')]" "<|tool_call_end|>"
|
||||
)
|
||||
result = self.detector.detect_and_parse(text, self.tools)
|
||||
|
||||
self.assertEqual(len(result.calls), 1)
|
||||
params = json.loads(result.calls[0].parameters)
|
||||
self.assertEqual(params, {"from": "sed -n '1,5p' f.py"})
|
||||
|
||||
# ==================== structure_info tests ====================
|
||||
|
||||
def test_supports_structural_tag(self):
|
||||
|
||||
Reference in New Issue
Block a user