Files
Strata/serve/test_tool_name_space.py
Ron Forrester dd667967b9 serve: a tool call written <function= NAME> is the call NAME
The function name was everything between `<function=` and `>`, untrimmed. A model that writes a space after the `=`
(seen from Qwen3.8-Flash-Next IQ2_XS in Zed's agent panel) got:

- inside the thinking: the name " capture" matched no declared tool, so the #804 rescue kept the call as reasoning
  text; the reply ended with finish_reason "stop" and no tool_calls, and the agent stopped mid-task;
- after the thinking: a call named " capture", which the client does not know, its arguments typed without the
  tool's schema.

The name is now stripped where the call is parsed (parse_tool_call), where the #804 rescue checks it against the
declared tools, and where a finished call looks up its schema (_finish_call). A name with surrounding whitespace is
never meaningful, so no text becomes a call that was not one: the #804/#1058 rules still decide whether a call
inside the thinking is an act.

serve/test_tool_name_space.py: both places, streamed and not, fed in 3-character pieces; fails 4/4 before, passes
after. test_reasoning_rescue, test_reasoning_tools and test_frontend unchanged and passing.
2026-10-08 02:25:52 +02:00

47 lines
1.8 KiB
Python

"""serve/test_tool_name_space.py - a tool call written `<function= NAME>` (a space after the `=`) is the call NAME.
Seen live (Qwen3.8-Flash-Next IQ2_XS, Zed's agent panel): the model wrote `<function= capture>` inside its thinking;
the name was read as " capture", matched no declared tool, stayed reasoning text (#804), and the turn ended with no
call, so the agent stopped. After the thinking the same call came out named " capture", and its arguments were
typed without the tool's schema.
python -m unittest serve.test_tool_name_space -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
# "42" for a string parameter stays a string only when the call finds its tool's schema
SCHEMA = [{"name": "capture", "parameters": {"properties": {"content": {"type": "string"}}}}]
CALL = "<tool_call>\n<function= capture>\n<parameter=content>\n42\n</parameter>\n</function>\n</tool_call>"
def calls(text, stream_tools):
p = OutputParser(thinking=True, tools=SCHEMA, stream_tools=stream_tools)
evs = []
for i in range(0, len(text), 3): # fed in small pieces, as a stream arrives
evs += p.feed(text[i:i + 3])
evs += p.finish("stop")
return [(e.call.name, e.call.arguments) for e in evs if e.kind == "tool_call"]
class SpacedName(unittest.TestCase):
def check(self, text):
for stream_tools in (False, True):
with self.subTest(stream_tools=stream_tools):
self.assertEqual(calls(text, stream_tools), [("capture", {"content": "42"})])
def test_inside_the_thinking(self):
self.check("Let me save this.\n" + CALL + "\n")
def test_after_the_thinking(self):
self.check("Saving it.</think>\n\n" + CALL)
if __name__ == "__main__":
unittest.main()