serve: a tool call inside a code fence or inline code in the visible answer is text (#1058)

The live smoke showed the same quoted-example problem in the answer: a model that wrote the call format in a
fenced example after </think> got a real tool call. The content parser now uses the fence / inline-code
tracking of the reasoning rescue: an opener inside a ``` or ~~~ fence (tracked across stream chunks) or inside
inline code stays text, the decision depending only on the text before it, so streamed and whole outputs agree.
Openers at the top level fire as before, including one after a sentence on the same line (none of the 37
corpus specimens needed that, but the old tests do and nothing argues against it). A closed call at the end of
an answer cut by max tokens stays a call.

Tests: quoted calls stay text and real ones fire at every chunk split; OpenAI (whole and streamed), Anthropic
and Responses over HTTP.
This commit is contained in:
Niko1221 committed 2026-10-06 11:25:45 +02:00
1 parent b6c2e01589
commit 82f46a8c8f
2 files changed
+64 -4

No files matched your search

+19 -4
View File
@@ -635,6 +635,10 @@ class OutputParser:
inline code."""
return not self.fence and not self.line.strip() and self.ticks % 2 == 0
def _in_code(self) -> bool:
"""The text so far leaves the next character inside a code fence or inline code."""
return bool(self.fence) or (self.ticks + self.line.count("`")) % 2 == 1
def _release(self, deliver: bool) -> list[Event]:
"""Settle the calls waiting in self.pending: events for real calls, or all of it back as reasoning text."""
out = []
@@ -847,6 +851,7 @@ class OutputParser:
out.append(Event("reasoning", self._track(self.buf[:i])))
self.buf = self.buf[i + len(THINK_END):]
self.state, self.lead = "content", True
self.fence, self.line, self.ticks = "", "", 0 # the answer's own text starts here
elif self.state == "content":
if self.lead: # newlines right after </think> or a call
stripped = self.buf.lstrip("\n")
@@ -862,15 +867,25 @@ class OutputParser:
while j > 0 and self.buf[j - 1] == "\n":
j -= 1
if j > 0:
out.append(Event("content", self.buf[:j]))
out.append(Event("content", self._track(self.buf[:j])))
self.buf = self.buf[j:]
return out
# #1058: an opener inside a code fence or inline code is text (a quoted example), never a call. The
# text before it decides, so streamed and whole outputs agree. Mid-sentence openers still count.
snap = (self.fence, self.line, self.ticks)
self._track(self.buf[:i])
in_code = self._in_code()
self.fence, self.line, self.ticks = snap
if in_code:
out.append(Event("content", self._track(self.buf[:i + len(CALL_START)])))
self.buf = self.buf[i + len(CALL_START):]
continue
# A call is `<tool_call>` and then (after whitespace) `<function=`; the tag with anything else after
# it is prose that names the format ("I'll use a <tool_call> block") - content, not a malformed call
# that ends the request. Until its follower has arrived it is held, like a partial tag.
after = self.buf[i + len(CALL_START):].lstrip()
if after and not after.startswith(FUNC_START) and not FUNC_START.startswith(after):
out.append(Event("content", self.buf[:i + len(CALL_START)]))
out.append(Event("content", self._track(self.buf[:i + len(CALL_START)])))
self.buf = self.buf[i + len(CALL_START):]
continue
if not after.startswith(FUNC_START):
@@ -878,11 +893,11 @@ class OutputParser:
while j > 0 and self.buf[j - 1] == "\n":
j -= 1
if j > 0:
out.append(Event("content", self.buf[:j]))
out.append(Event("content", self._track(self.buf[:j])))
self.buf = self.buf[j:]
return out
if i and self.buf[:i].strip():
out.append(Event("content", self.buf[:i].rstrip("\n")))
out.append(Event("content", self._track(self.buf[:i].rstrip("\n"))))
self.buf = self.buf[i + len(CALL_START):]
self.state = "call"
else:
+45
View File
@@ -173,6 +173,43 @@ class Gates(unittest.TestCase):
self.assertEqual((p.rescued, p.refused), (1, 0))
class VisibleAnswer(unittest.TestCase):
"""#1058: the same fence / inline-code rule for a call in the visible answer (after </think>)."""
HEAD = "ok</think>\n\n"
def run_all(self, text, finish=None):
res = set()
for width in WIDTHS:
evs = parse(text, width, SCHEMA, finish)
res.add((tuple(calls(evs)), "".join(e.text for e in evs if e.kind == "content")))
self.assertEqual(len(res), 1, res) # every chunking agrees
return res.pop()
def test_quoted_calls_stay_text(self):
for body in ("Format:\n```xml\n" + RM + "\n```\nDone.", "Format:\n~~~\n" + RM + "\n~~~\nDone.",
"Use `" + RM + "` like this.", "Use `x`, then `\n" + RM + "`", "```\n" + RM,
"Text\n\n```python\nx = 1\n```\n```\n" + CALL):
with self.subTest(body=body[:30]):
got, text = self.run_all(self.HEAD + body)
self.assertEqual(got, ())
self.assertEqual(text, body) # nothing lost
def test_real_calls_still_fire(self):
for body in (CALL, "Let me do it.\n\n" + CALL, "Let me do it. " + CALL, "```\ncode\n```\n" + CALL,
"`a` and `b` " + CALL, "~~~\nx\n~~~\n\n" + CALL):
with self.subTest(body=body[:30]):
self.assertEqual(self.run_all(self.HEAD + body)[0], ("write",))
self.assertEqual(self.run_all(self.HEAD + CALL + "\n" + CALL)[0], ("write", "write"))
def test_a_closed_call_in_a_length_cut_answer_stays_a_call(self):
self.assertEqual(self.run_all(self.HEAD + "Now.\n" + CALL, finish="length")[0], ("write",))
def test_without_thinking(self):
p = OutputParser(thinking=False, tools=SCHEMA)
evs = p.feed("```\n" + RM + "\n```") + p.finish()
self.assertEqual(calls(evs), [])
class OverHttp(unittest.TestCase):
"""The same rules through the real server: OpenAI chat (whole and streamed), Anthropic and Responses."""
@@ -237,6 +274,14 @@ class OverHttp(unittest.TestCase):
a = self.ask(script, max_tokens=tokens)
self.assertEqual(set(a.values()), {0}, a)
def test_visible_answer_fences_on_all_three_apis(self):
for name, body, want in (("fence", "Format:\n```xml\n" + RM + "\n```\nDone.", 0),
("inline", "Use `" + RM + "` here.", 0),
("real", "Doing it.\n\n" + CALL, 1)):
with self.subTest(name):
a = self.ask( "ok</think>\n\n" + body)
self.assertEqual(set(a.values()), {want}, a)
if __name__ == "__main__":
unittest.main()