Repository navigation
qwen3_coder: non-streaming cuts a tool-call argument at a literal </tool_call>, </function> or <parameter= inside the value #715
Description
What happened
When a qwen3_coder tool-call argument contains the text </tool_call>, </function> or <parameter=, non-streaming /v1/chat/completions cuts the value at that text and returns the call without an error. A coding agent that writes or edits a file whose content mentions these tags (tool-call parsers, their tests, prompt templates, docs) gets a truncated content argument and writes a broken file. Streaming returns the full value for the same output.
literal inside content |
non-streaming content |
streaming content |
|---|---|---|
</tool_call> |
END = " |
END = "</tool_call>"\nprint(END) |
</function> |
END = " |
END = "</function>"\nprint(END) |
<parameter= |
END = " |
END = "<parameter="\nprint(END) |
Minimal reproduction (CPU only, from a clean checkout, PYTHONPATH=python python repro.py):
import asyncio, json
from types import SimpleNamespace
from freetoken.message.frontend import UserReply
from freetoken.server.generation import GenSpec, ToolCallsDelta, generate_events, generate_full
TOOLS = [{"type": "function", "function": {"name": "write", "parameters": {"type": "object",
"properties": {"path": {"type": "string"}, "content": {"type": "string"}}}}}]
class State: # stands in for the engine: replays the model's text, one piece per reply
def __init__(self, pieces):
self.config = SimpleNamespace(tool_call_parser="qwen3_coder", reasoning_parser=None)
self.pieces = pieces
async def wait_for_ack(self, uid):
for i, p in enumerate(self.pieces):
yield UserReply(uid=uid, incremental_output=p, finished=i == len(self.pieces) - 1,
prompt_tokens_delta=int(i == 0), completion_tokens_delta=1)
spec = lambda: GenSpec(messages=[], sampling_params=None, parser_tools=TOOLS, template_tools=TOOLS)
for literal in ["</tool_call>", "</function>", "<parameter="]:
value = f'END = "{literal}"\nprint(END)'
text = ("<tool_call>\n<function=write>\n<parameter=path>\nt.py\n</parameter>\n"
f"<parameter=content>\n{value}\n</parameter>\n</function>\n</tool_call>")
full = asyncio.run(generate_full(1, spec(), State([text])))
async def stream():
return [ev async for ev in generate_events(1, spec(), State(list(text)))]
args = "".join(c.parameters for ev in asyncio.run(stream()) if isinstance(ev, ToolCallsDelta) for c in ev.calls)
print(f"{literal!r:16} non-stream content={json.loads(full.tool_calls[0].parameters).get('content')!r}")
print(f"{'':16} stream content={json.loads(args).get('content')!r}")Output on main 9b68e00:
'</tool_call>' non-stream content='END = "'
stream content='END = "</tool_call>"\nprint(END)'
'</function>' non-stream content='END = "'
stream content='END = "</function>"\nprint(END)'
'<parameter=' non-stream content='END = "'
stream content='END = "<parameter="\nprint(END)'
Cause (current main, 9b68e00)
Qwen3CoderDetector.detect_and_parse (python/freetoken/server/function_call_parser.py:2036), which non-streaming reaches through generation._parse_tool_response (python/freetoken/server/generation.py:462), splits the text with three lazy regexes (function_call_parser.py:1934-1937):
tool_call_block_regex<tool_call>(.*?)</tool_call>ends the block at the first</tool_call>, even one inside a parameter value;function_regex<function=(.*?)</function>ends the function at the first</function>;parameter_regexalso ends a value at a lookahead(?=<parameter=)or(?=</function>).
The streaming path (InvokeParamStreamMixin, function_call_parser.py:660) instead stays inside a string value until </parameter>, so it keeps these literals.
Related: #710 reports a different failure of the same non-streaming parameter_regex (a value cut inside </parameter> by max_tokens).
Proposed fix
Have the one-shot parse follow the streaming grammar: inside a parameter, only </parameter> ends the value. The simplest way is for Qwen3CoderDetector.detect_and_parse to run the streaming parser over the whole text, so the two modes cannot drift apart. A literal </parameter> inside a value stays ambiguous in both modes; this proposal does not change that case.
Environment
Seen serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB; the reproduction above is CPU-only on main 9b68e00.
Related qwen3_coder stream/non-stream mismatches: #713, #714, #716.
Activity