Skip to content

qwen3_coder: non-streaming cuts a tool-call argument at a literal </tool_call>, </function> or <parameter= inside the value #715

Open

Description

@gdevenyi

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_regex also 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions