qwen3_coder: a bare `<function=NAME>` block without `<tool_call>` is a call plus raw markup in non-streaming, and only text in streaming
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
調査の方向性
Start with Qwen3CoderDetector.detect_and_parse in python/freetoken/server/function_call_parser.py (around lines 2036-2059) and the streaming idle-mode branch in InvokeParamStreamMixin.parse_streaming_increment. Run the CPU reproduction in the issue, which uses FunctionCallParser with qwen3_coder. Done means a bare <function=NAME> block gives the same calls and content in streaming and non-streaming, and literal <function= in prose yields no call.
索引モデルが issue の本文から書いたものです。
説明
What happened
If the model writes a <function=NAME>…</function> block without the <tool_call> wrapper, the two modes handle it differently:
| Mode | tool_calls |
content |
|---|---|---|
| Non-streaming | the call | the call, plus the raw <function=…>…</function> markup |
| Streaming | none | the raw markup |
An agentic client executes the call when it is not streaming, and the call's markup is also shown as text. When it is streaming, the call is never executed. The non-streaming fallback also makes a call out of any literal <function= in prose or code. The tag does not need a closing </function> or a declared tool name, so an explanation such as "write a call as <function=NAME>" returns a call named NAME.
Minimal reproduction (CPU only, PYTHONPATH=python):
from freetoken.server.function_call_parser import FunctionCallParser
tools = [{"type": "function", "function": {"name": "edit", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}}}]
bare = ["Doing it.\n", "<", "function", "=", "edit", ">\n", "<parameter=path>\n", "a.py\n", "</parameter>\n", "</function>"]
p = FunctionCallParser(tools, "qwen3_coder")
ev = [e for c in bare for e in p.parse_stream_events(c)]
print("stream calls:", [e for e in ev if e[0] == "calls"], "text:", repr("".join(x for k, x in ev if k == "text")))
r = FunctionCallParser(tools, "qwen3_coder").parse_non_stream("".join(bare))
print("non-stream: ", r.calls, "content:", repr(r.normal_text))
r = FunctionCallParser(tools, "qwen3_coder").parse_non_stream("Write a call as `<function=NAME>` then the parameters.")
print("prose: ", r.calls)
stream calls: [] text: 'Doing it.\n<function=edit>\n<parameter=path>\na.py\n</parameter>\n</function>'
non-stream: [ToolCallItem(tool_index=0, name='edit', parameters='{"path": "a.py"}')] content: 'Doing it.\n<function=edit>\n<parameter=path>\na.py\n</parameter>\n</function>'
prose: [ToolCallItem(tool_index=0, name='NAME', parameters='{}')]
Cause (current main, 9b68e007)
python/freetoken/server/function_call_parser.py:2036-2059,Qwen3CoderDetector.detect_and_parse: if there is no<tool_call>…</tool_call>block, the whole text is parsed as a block (tool_call_blocks = [text], line 2046).function_regex(line 1935) also matches an unclosed<function=…up to the end of the text. However,normal_textis only cut at<tool_call>(line 2038), so with no wrapper the full text, markup included, stays innormal_text._parse_tool_response(generation.py:452-465) returns both.- The undeclared
NAMEis accepted because_should_forward_unknown_tool(line 63) is true by default (FREETOKEN_FORWARD_UNKNOWN_TOOLS, line 58). That default is fine for wrapped calls. In this fallback, it lets any<function=in plain text become a call. InvokeParamStreamMixin.parse_streaming_increment, idle mode (lines 745-764): the stream only enters a block at_ps_outer_open(<tool_call>), so a bare<function=is released as text.
Proposed fix
Handle the bare form the same way in both modes:
- When it is a call: only when
NAMEis a declared tool and the body after>starts, ignoring whitespace, with<parameter=or</function>. - Non-streaming: remove the parsed span from
content. - Streaming: in idle mode, hold back a possible
<function=prefix until the name and the first body tag decide the case. Then enter the invoke state as if<tool_call>had been seen, or release the held text as content.
With these checks, a literal <function= in prose or code stays text, and an undeclared name in the bare form is not forwarded. Wrapped calls are not affected, and neither is the forwarding of unknown tools inside <tool_call>. This is a different path from the truncated <tool_call> blocks in #710 and the partial openers in #347.
Environment
Found while serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB (I have not measured how often the model drops the <tool_call> wrapper); the reproduction above is CPU-only on main 9b68e007.
Related qwen3_coder stream/non-stream mismatches: #713, #715, #716.
- 主要言語
- Python
- スター
- 14.2k
- フォーク
- 1.4k
- 平均マージ
- 21時間 39分
- マージ済み PR(30日)
- 100
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
FlashML-org/FreeToken のほかの issue
-
Desktop feature
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
FlashML-org/FreeToken#800 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
FlashML-org/FreeToken#767 ·
メンテナーはふだん 1 日以内に返信
-
test_gemv_path_matches_the_transcription fails on sm_120 (bf16 rounding tie exceeds the tolerance)対応中かも @void-mckenzie が 16 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
FlashML-org/FreeToken#649 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
FlashML-org/FreeToken#648 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
FlashML-org/FreeToken#646 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
FlashML-org/FreeToken の issue をすべて見る
似ている issue
-
Support .aac audio uploads対応中かも @julien731 が今日担当しました。 オープンfeature
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
nimblehq/audio-transcriber#153 ·
-
help wanted
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
antonyrag/ragleap-core#1062 ·
-
needs-triage
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
gke-labs/kube-agents#4321 ·
メンテナーはふだん 1 日以内に返信
-
enhancement help wanted
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
Git-Hospital/githealthcheck#61 ·
メンテナーはふだん 1 日以内に返信
-
changelog investigate
難易度 2/5 1〜3時間 初心者へのやさしさ 67/100
ramnes/notion-sdk-py#433 ·