mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 06:15:58 -10:00
Tool calls: a misspelt opener (<toolcall>, <tool-call>) followed by <function=NAME> of a declared tool opens the call, where a <tool_call> would; prose and code stay text (#5276)
This commit is contained in:
1 parent
68ab15ddc1
commit
63fd759227
2 files changed
+149
-3
No files matched your search
+45
-3
@@ -780,6 +780,9 @@ _PREFIX_SPACED = re.compile(r"(<function=\s*(?:tools|functions|default_api)[ \t]
|
||||
_PREFIX_PART = re.compile(r"<function=\s*(?:tools|functions|default_api)[ \t]+[^<>\s]{0,80}\s*$")
|
||||
_PREFIX_BARE = re.compile(r"(<tool_call>\s*)<function=\s*(tools|functions|default_api)\s*>(.*?)</function>", re.S)
|
||||
_PREFIX_BARE_OPEN = re.compile(r"<tool_call>\s*<function=\s*(tools|functions|default_api)\s*>(?!.*</function>)", re.S)
|
||||
# #5276: `<toolcall>` / `<tool-call>` as the opener (then whitespace, `<function=NAME>`), and such an opener still arriving
|
||||
_MISSPELT_OPEN = re.compile(r"<tool-?call>(\s*)<function=(\s*[^<>\s]+\s*)>")
|
||||
_MISSPELT_PART = re.compile(r"<tool-?call>\s*<function=[^<>]{0,80}$|<tool-?call>\s*(?:<(?:f(?:u(?:n(?:c(?:t(?:i(?:o(?:n)?)?)?)?)?)?)?)?)?$")
|
||||
_LOOSE_PART = re.compile(r"<\s*(?:function[ \t]*[=:]?\s*|=\s*)?[A-Za-z0-9_.\-]*\s*$")
|
||||
|
||||
|
||||
@@ -1249,6 +1252,17 @@ class OutputParser:
|
||||
self._restore(snap)
|
||||
return ok
|
||||
|
||||
def _opens_at(self, p: int) -> bool:
|
||||
"""self.buf[p] would open a call as `<tool_call>` does: in the reasoning at the start of a line outside code, in the answer
|
||||
anywhere outside code (a fence, inline code, an indented block)."""
|
||||
if self.state == "reasoning":
|
||||
return self._ok_at(p)
|
||||
snap = self._snap()
|
||||
self._track(self.buf[:p])
|
||||
ok = not self._in_code()
|
||||
self._restore(snap)
|
||||
return ok
|
||||
|
||||
def _callable(self, name: str) -> bool:
|
||||
"""#3075: may a call for `name` be a call? A tool the request declared (or its Responses spelling); with
|
||||
none declared, only where `declared_only` is off."""
|
||||
@@ -1600,16 +1614,16 @@ class OutputParser:
|
||||
`<function=tools>` (the bare prefix) is the one declared tool that takes the call's parameters. Rewrites the complete
|
||||
ones in self.buf to `<function=NAME>` and returns the tail of a call still arriving (held back by feed), else None.
|
||||
Only a declared name is ever the result; with none, or with two that fit, the text is left as it was."""
|
||||
held = self._opener()
|
||||
buf = self.buf
|
||||
if "<function=" not in buf:
|
||||
return None
|
||||
return held
|
||||
def spaced(m):
|
||||
name = m.group(2).strip()
|
||||
return f"<function={name}>" if name in self.schemas else m.group(0)
|
||||
buf = _PREFIX_SPACED.sub(spaced, buf)
|
||||
held = None
|
||||
m = _PREFIX_PART.search(buf)
|
||||
if m:
|
||||
if m and held is None:
|
||||
held, buf = buf[m.start():], buf[:m.start()]
|
||||
if self.recover:
|
||||
def bare(m):
|
||||
@@ -1624,6 +1638,34 @@ class OutputParser:
|
||||
self.buf = buf
|
||||
return held
|
||||
|
||||
def _opener(self) -> str | None:
|
||||
"""#5276: `<toolcall>` / `<tool-call>` (the opener written as text, misspelled) followed by `<function=NAME>` of a declared
|
||||
tool is `<tool_call>`, where a `<tool_call>` would open a call (start of a line, outside code). Prose that names the
|
||||
misspelled tag, or any other follower, is left as it was. Returns the tail of an opener still arriving (held back)."""
|
||||
buf = self.buf
|
||||
if "<tool" not in buf:
|
||||
return None
|
||||
pos, out = 0, []
|
||||
for m in _MISSPELT_OPEN.finditer(buf):
|
||||
name = m.group(2).strip()
|
||||
name = self.aliases.get(name, name)
|
||||
if name in self.schemas and self._opens_at(m.start()):
|
||||
out.append(buf[pos:m.start()] + CALL_START + m.group(1) + FUNC_START + m.group(2) + ">")
|
||||
pos = m.end()
|
||||
buf = "".join(out) + buf[pos:]
|
||||
held = None
|
||||
m = _MISSPELT_PART.search(buf)
|
||||
tail = buf[m.start():] if m else None
|
||||
if tail is None: # the opener itself still arriving: a prefix of `<toolcall>`
|
||||
for k in range(min(len(buf), 11), 5, -1):
|
||||
if any(w.startswith(buf[-k:]) for w in ("<toolcall>", "<tool-call>")) and buf[-k:] != "<tool_":
|
||||
tail = buf[-k:]
|
||||
break
|
||||
if tail:
|
||||
held, buf = tail, buf[:len(buf) - len(tail)]
|
||||
self.buf = buf
|
||||
return held
|
||||
|
||||
def _tool_by_params(self, names: list[str]) -> str | None:
|
||||
"""The one declared tool whose properties hold every one of `names` and whose required parameters are all in them."""
|
||||
found = []
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""serve/test_tool_opener_misspelt.py - #5276: `<toolcall>` (no underscore) before `<function=NAME>` of a declared tool opens the call.
|
||||
|
||||
Seen live (Qwen3.8-Flash-Next UD-Q4_K_XL, OpenMinis on /v1/messages): the model wrote the opener as ordinary text, misspelled
|
||||
`<toolcall>`, then a whole call of a declared tool and the real `</tool_call>`. The block came back as answer text, the turn
|
||||
ended and the agent waited for a result that never came. The misspelling counts only where a `<tool_call>` would open a call
|
||||
(start of a line, outside code) and only when a declared tool's `<function=NAME>` follows; prose never triggers it.
|
||||
|
||||
python -m unittest serve.test_tool_opener_misspelt -v
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from serve.frontend import OutputParser # noqa: E402
|
||||
|
||||
BASH = {"name": "bash", "parameters": {"properties": {"command": {"type": "string"}, "tooltitle": {"type": "string"}}}}
|
||||
BODY = ("<function=bash>\n<parameter=command>\ncd proj && rg -o 'x' landing.html | sort | uniq -c\n</parameter>\n"
|
||||
"<parameter=tooltitle>\nCount\n</parameter>\n</function>\n")
|
||||
ARGS = {"command": "cd proj && rg -o 'x' landing.html | sort | uniq -c", "tooltitle": "Count"}
|
||||
|
||||
|
||||
def run(text, stream_tools=False, recover=True, thinking=False, step=3, tools=(BASH,)):
|
||||
p = OutputParser(thinking=thinking, tools=list(tools), stream_tools=stream_tools, recover=recover)
|
||||
evs = []
|
||||
for i in range(0, len(text), step):
|
||||
evs += p.feed(text[i:i + step])
|
||||
evs += p.finish("stop")
|
||||
return evs
|
||||
|
||||
|
||||
def calls(evs):
|
||||
return [(e.call.name, e.call.arguments) for e in evs if e.kind == "tool_call"]
|
||||
|
||||
|
||||
def text(evs):
|
||||
return "".join(e.text or "" for e in evs if e.kind in ("content", "reasoning"))
|
||||
|
||||
|
||||
class Misspelt(unittest.TestCase):
|
||||
def test_issue_block(self):
|
||||
for opener in ("<toolcall>", "<tool-call>"):
|
||||
for stream in (False, True):
|
||||
for step in (1, 3, 1000):
|
||||
for recover in (False, True):
|
||||
evs = run(opener + "\n" + BODY + "</tool_call>", stream, recover, step=step)
|
||||
self.assertEqual(calls(evs), [("bash", ARGS)], (opener, stream, step, recover))
|
||||
self.assertEqual(text(evs).strip(), "")
|
||||
|
||||
def test_without_the_closing_tag_as_the_real_opener(self):
|
||||
self.assertEqual(calls(run("<toolcall>\n" + BODY)), calls(run("<tool_call>\n" + BODY)))
|
||||
|
||||
def test_mid_line_as_the_real_opener(self):
|
||||
t = "ok <toolcall>\n" + BODY + "</tool_call>"
|
||||
self.assertEqual(calls(run(t)), calls(run(t.replace("<toolcall>", "<tool_call>"))))
|
||||
self.assertEqual(calls(run(t)), [("bash", ARGS)])
|
||||
|
||||
def test_in_the_reasoning_mid_line_stays_text(self):
|
||||
evs = run("hm <toolcall>\n" + BODY + "</tool_call>\n</think>\n", thinking=True)
|
||||
self.assertEqual(calls(evs), [])
|
||||
|
||||
def test_text_before_is_kept(self):
|
||||
evs = run("I will count.\n<toolcall>\n" + BODY + "</tool_call>")
|
||||
self.assertEqual(calls(evs), [("bash", ARGS)])
|
||||
self.assertEqual(text(evs).strip(), "I will count.")
|
||||
|
||||
def test_in_the_reasoning(self):
|
||||
evs = run("hm\n<toolcall>\n" + BODY + "</tool_call>\n</think>\n", thinking=True)
|
||||
self.assertEqual(calls(evs), [("bash", ARGS)])
|
||||
|
||||
def test_prose_naming_the_tag_stays_text(self):
|
||||
for t in ("The model may write <toolcall> by mistake.",
|
||||
"Write <toolcall> and then a call.\n",
|
||||
"<toolcall>\nnot a call\n</tool_call>",
|
||||
"<toolcall>",
|
||||
"<toolcall>\n<function=nothing>\n<parameter=a>\n1\n</parameter>\n</function>\n</tool_call>"):
|
||||
for stream in (False, True):
|
||||
evs = run(t, stream)
|
||||
self.assertEqual(calls(evs), [], t)
|
||||
self.assertEqual(text(evs), t)
|
||||
|
||||
def test_code_fence_stays_text(self):
|
||||
t = "Example:\n```\n<toolcall>\n" + BODY + "</tool_call>\n```\n"
|
||||
evs = run(t)
|
||||
self.assertEqual(calls(evs), [])
|
||||
self.assertEqual(text(evs), t)
|
||||
|
||||
def test_indented_code_stays_text(self):
|
||||
t = "Example:\n\n <toolcall>\n " + BODY.replace("\n", "\n ") + "</tool_call>\n"
|
||||
self.assertEqual(calls(run(t)), [])
|
||||
|
||||
def test_no_tools_declared(self):
|
||||
t = "<toolcall>\n" + BODY + "</tool_call>"
|
||||
self.assertEqual(calls(run(t, tools=())), [])
|
||||
self.assertEqual(text(run(t, tools=())), t)
|
||||
|
||||
def test_the_real_opener_is_unchanged(self):
|
||||
evs = run("<tool_call>\n" + BODY + "</tool_call>")
|
||||
self.assertEqual(calls(evs), [("bash", ARGS)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user