Skip to content

feat(symphony): Qwen3 speaks the tagged call syntax, and starts inside a thought the prompt opened - #2838

Merged
slin1237 merged 2 commits into
mainfrom
feat/symphony-tagged-format
Oct 7, 2026
Merged

slin1237 merged 2 commits into
mainfrom
feat/symphony-tagged-format

Conversation

@slin1237

@slin1237 slin1237 commented Oct 7, 2026

Copy link
Copy Markdown
Member

Description

Follows #2831 (merged as 396c78e) on main. Part of the Symphony plan (S2, the Qwen family's second call syntax).

Problem

Qwen3 reads a call only as Qwen3 writes it, a JSON object between <tool_call> and </tool_call>. Qwen 3.5 and later and Qwen3-Coder write the same region as tags, <function=NAME> and <parameter=KEY> around each value's text, which #2823 and #2831 taught the tagged assembler to turn into events; no format ran it yet. Replaying bellwether's recorded cases for those models also showed two things the format did not know: Qwen 3.5's template opens <think> in the generation prompt, so the output starts inside the thought and a parser that waits for <think> reports \n</think>\n\n as content; and BFCL declares a list whose members come from an enum as {"type": "array", "enum": [...]}, which Kind::of read as a string, so [] reached the client as "[]".

Solution

  • CallSyntax::{Json, Tagged(Declared)}. The format holds the syntax and runs the matching assembler inside the call region; everything else about the region is shared (the index and id, the surplus after the call, the end of the stream, the early close by marker). Qwen3::new() is the JSON syntax as before; Qwen3::with_tagged_calls(Declared::of(&request.tools)) is the tagged one. Both are exported with Declared.
  • The prompt decides where the output starts. A prompt whose last <think> comes after its last </think> starts the output in reasoning, with ReasoningStart pushed for the prompt (leaves_thinking_open). Qwen3's prompt opens nothing, and a prompt that disables thinking ends with <think>\n\n</think>\n\n and leaves nothing open, so nothing changes for them. The module doc's sentence that the prompt was ignored is replaced by this rule.
  • A type decides alone (fix(symphony) commit): the enum of strings speaks only for a schema with no type, as vLLM reads type when it is there.
  • The parity test covers the tagged models. tagged_parse_fixtures_match_the_reference replays each tagged slug bellwether has recorded through with_tagged_calls, typed by the case's request tools, after the prompt tail its template leaves (<think>\n; <think>\n\n</think>\n\n when the request's chat_template_kwargs turn thinking off; nothing for Qwen3-Coder). The fixtures carry the request and the output but not the rendered prompt, so the tail is stated in the test per model; a generation_prompt field on bellwether's parse cases would remove that, and it is on Simo's list. The known-difference list matches a case's id after its slug, so one list serves every model.
  • Two classes of difference the corpus has are allowed and counted, not hidden. Each allowed case prints its class, and the summary line counts them. (1) A reference argument whose type the output cannot carry: BFCL answers false for smoking_allowed, declared a string enum of "True", "False", "dontcare"; 2 for number_of_rooms declared a string; null for to declared a string. The template writes the value as it writes the string, the parser gives the string back as vLLM v0.31.0 does, and bellwether Extract multimodal module into standalone llm-multimodal crate #56 refuses such a case at record time; the fixtures replayed here predate it. (2) Reasoning in a reference whose template writes no thought (Qwen3-Coder), which the output cannot carry: bellwether refactor: Extract MCP module into standalone workspace crate #52.

Changes

  • crates/symphony/src/formats/qwen3.rs: CallSyntax, enum Call, with_tagged_calls, the prompt rule, module doc; five new tests (a tagged call after thinking, two tagged calls and an undeclared tool, the early close and the cut stream, every chunking of a tagged output, the prompt that opens the thought).
  • crates/symphony/src/formats/mod.rs, src/lib.rs: exports.
  • crates/symphony/src/tagged/value.rs: admitted_types reads the enum only without a type; doc; two tests.
  • crates/symphony/src/tagged/assembler.rs: one test, the BFCL array shape through the assembler.
  • crates/symphony/tests/bellwether_parse_fixtures.rs: TAGGED with GenerationPrompt, Request.chat_template_kwargs, Allowance, parity taking the prompt tail and the allowances, KNOWN_TAGGED_DIFFERENCES, suffix-matched known ids; tests/common/mod.rs: replay_after.

Test Plan

  • cargo +nightly fmt -p smg-symphony --check, cargo clippy -p smg-symphony --all-targets --all-features -- -D warnings, cargo test -p smg-symphony (177 lib tests, 3 fixture tests skipping without BELLWETHER_FIXTURES, 7 contract tests), RUSTDOCFLAGS="-D warnings" cargo doc -p smg-symphony --no-deps: all exit 0 at 5c61011 (on main eda1134).

  • Preview against bellwether's scale-run fixtures of 2026-10-06 (local, not CI; CI's pinned fixtures have Qwen3-8B only). BELLWETHER_FIXTURES=<plain tree> cargo test -p smg-symphony --test bellwether_parse_fixtures tagged_parse_fixtures_match_the_reference passes, 0 differences, 239 s:

    Model Cases Bitwise Separators only (Extract reasoning-parser crate #17) Listed (Extract protocols crate #16) Allowed for the corpus
    qwen3.5-27b, qwen3.6-27b, qwen3.8-27b (each) 2436 7 2312 2 115
    qwen3-coder-30b-a3b-instruct, qwen3-coder-next (each) 2435 2313 2 1 119

    qwen3.5-9b has no parse sets yet and is skipped with a notice. Before the prompt rule, the thinking models had 7 bitwise and 2427 differences (content: Some("\n</think>\n\n")); before the type fix, the coder models had 2313 bitwise and 120 differences.

  • Cross-check of the allowance: bellwether record at Extract multimodal module into standalone llm-multimodal crate #56's head, offline, on Qwen3.6-27B's bfcl-live-multiple, bfcl-live-simple and bfcl-parallel-multiple, refuses 112 cases; the allowed cases of this test on the same three sets are the same 112 ids.

  • The Qwen3-8B tests are unchanged in what they accept: the same two known differences, now matched after the slug.

Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes
  • Documentation updated (module docs)
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

slin1237 and others added 2 commits October 6, 2026 19:58
…dds nothing

BFCL declares a list whose members come from an enum as
{"type": "array", "items": {"type": "string"}, "enum": [...]}. Kind::of
added `string` for the enum and read the parameter as a string, so the
value `[]` reached the client as "[]". vLLM reads `type` when it is there;
now so does Symphony, and the enum of strings speaks only for a schema
with no `type`. Found by replaying bellwether's qwen3-coder-next parse
cases (bfcl-live-multiple-146-58-0).

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
…e a thought the prompt opened

The format takes a CallSyntax: Json (Qwen3's object) or Tagged with the
request's declared parameter types (Qwen 3.5 and later, Qwen3-Coder), and
runs the matching assembler inside the call region; Qwen3::with_tagged_calls
builds the second. Everything else about the region is shared: the index,
the id, the surplus after the call, the end of the stream and the early
close by marker.

The prompt now decides where the output starts. Qwen 3.5's template opens
<think> in the generation prompt, so the output starts inside the thought
and its first </think> closes it; a prompt whose last <think> comes after
its last </think> starts the output in reasoning, with ReasoningStart
pushed for the prompt. Qwen3's prompt opens nothing, and a prompt that
disables thinking closes it itself, so nothing changes for them.

The parity test replays the tagged models bellwether has recorded
(qwen3.5-27b, qwen3.6-27b, qwen3.8-27b, qwen3-coder-30b-a3b-instruct,
qwen3-coder-next; qwen3.5-9b when it is), each after the prompt tail its
template leaves, read from the case's request for thinking off. It allows
two classes of difference the corpus itself has and counts them: a
reference argument whose type the output cannot carry (bellwether #56
refuses those at record time) and reasoning in a reference whose template
writes no thought (bellwether #52). On the scale-run fixtures of
2026-10-06 that is 0 differences: per thinking model 2436 cases, 7
bitwise, 2312 separators only, 2 listed, 115 allowed; per coder model
2435 cases, 2313 bitwise, 2 separators only, 1 listed, 119 allowed. For
Qwen3.6-27B the 112 allowed cases of three sets are exactly the 112 that
bellwether #56 refuses.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Co-authored-by: Chang Su <8605658+CatherineSue@users.noreply.github.com>
@slin1237
slin1237 requested a review from CatherineSue as a code owner October 7, 2026 02:59
@github-actions github-actions Bot added tests Test changes symphony crates/symphony: the unified output parser labels Oct 7, 2026
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Team
  • Run ID: d9c1ef9c-de11-461b-ab02-b9ab75803667
📥 Commits

Reviewing files that changed from the base of the PR and between eda1134 and 5c61011.

📒 Files selected for processing (7)
  • crates/symphony/src/formats/mod.rs
  • crates/symphony/src/formats/qwen3.rs
  • crates/symphony/src/lib.rs
  • crates/symphony/src/tagged/assembler.rs
  • crates/symphony/src/tagged/value.rs
  • crates/symphony/tests/bellwether_parse_fixtures.rs
  • crates/symphony/tests/common/mod.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 3 remain after this review.


📝 Summary

Summary by CodeRabbit

  • New Features
    • Added support for both JSON-object and tagged tool-call formats, including typed values based on declared tool schemas.
    • Parsing now accounts for prompt context when determining whether content begins in reasoning or regular text.
  • Bug Fixes
    • Improved interpretation of schema types and nullable values when enums are present.
  • Tests
    • Expanded coverage for tool-call formats, schema values, and parsing against model fixtures.

Walkthrough

Qwen3 now supports JSON and tagged tool-call syntax. Prompt markers determine whether output starts in reasoning or content. The crate exports the new call syntax and tool declaration types. Tagged-model fixture parity checks now account for prompt tails and specified corpus differences.

Changes

Qwen3 Tagged Call Parsing

Layer / File(s) Summary
Tagged value type rules
crates/symphony/src/tagged/value.rs, crates/symphony/src/tagged/assembler.rs
A present schema type determines admitted value types even when the schema also has an enum. Tests cover typed arrays, integers, strings, and nullable strings.
Qwen3 syntax and prompt parsing
crates/symphony/src/formats/*, crates/symphony/src/lib.rs
Qwen3 supports JSON and tagged call syntax. Tagged calls use declared tool schemas when available, and call closure differs for a marker and stream end. Prompt markers determine the initial reasoning region. Tests cover tagged calls, call closure, chunking, and prompt handling.
Tagged model fixture parity
crates/symphony/tests/bellwether_parse_fixtures.rs, crates/symphony/tests/common/mod.rs
Fixture parity selects parser and prompt-tail settings for tagged models. It classifies only specified corpus differences as allowed and reports remaining failures. replay_after feeds prompt text before replaying output.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant InputClient
  participant Qwen3
  participant TaggedAssembler
  participant EventStream
  InputClient->>Qwen3: Supply prompt and output chunks
  Qwen3->>TaggedAssembler: Feed tagged call bytes and declared tools
  TaggedAssembler-->>Qwen3: Return call events
  Qwen3->>EventStream: Emit relabeled events
Loading

Suggested reviewers: key4ng

Merge Risk: ⚪ Minimal · up to 5c610

This change adds tagged tool-call parsing for Qwen3 and prompt-based reasoning detection. No actionable merge-blocking risk was identified, and the author reports that the standard checks passed.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 77.27% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 44 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: tagged call syntax and prompt-dependent reasoning state.
Description check ✅ Passed The description explains the tagged-call support, prompt handling, schema change, and fixture tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested a review from key4ng October 7, 2026 03:00

/// Whether the prompt ends inside the thinking region: its last `<think>` comes after its last
/// `</think>`.
fn leaves_thinking_open(prompt: &str) -> bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Important: This scans the whole rendered prompt, so a literal <think> anywhere in the conversation flips the output into reasoning. That includes a user message, a system prompt, or a tool result.

Input::Prompt::text is the full prompt "decoded with special tokens kept". Take a conversation where any earlier message has a <think> with no </think> after it. Some examples:

  • a user asks "why does my output start with <think>?"
  • Qwen3-Coder reads a file or grep result that contains "<think>"
  • an earlier assistant turn was cut by length inside its thought, which the Qwen3 template keeps as-is

In each case rfind("<think>") > rfind("</think>"), and the parser starts in Region::Reasoning. For a model that never writes </think> (Qwen3-Coder, Qwen3-Instruct-2507), nothing ever closes that region:

  • The whole answer comes back as reasoning_content instead of content.
  • Every <tool_call> hits the (Region::Reasoning, _) arm in marker() and becomes reasoning text, so the tool calls are lost without any error.

This also changes Qwen3::new(), which used to ignore the prompt.

The prompt only decides anything inside the turn being generated, so scope the search to that turn:

fn leaves_thinking_open(prompt: &str) -> bool {
    // Only the turn being generated: a `<think>` quoted in an earlier message opens nothing.
    let turn = prompt.rfind("<|im_start|>").map_or(prompt, |at| &prompt[at..]);
    match (turn.rfind(MARKERS[THINK_OPEN]), turn.rfind(MARKERS[THINK_CLOSE])) {
        (Some(open), Some(close)) => open > close,
        (Some(_), None) => true,
        (None, _) => false,
    }
}

Please also add a case to a_prompt_that_opens_the_thought_starts_the_output_in_reasoning. Use a prompt like "<|im_start|>user\nwhat is <think>?<|im_end|>\n<|im_start|>assistant\n" with TAGGED_CALL as output, and assert that the output starts in content and the call is parsed.

continue;
}
let spelled_the_same = match (value, reference) {
(serde_json::Value::String(text), reference) if !reference.is_string() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: DeclaredTypeConflict is broader than its doc says, and it can hide the very regression this PR fixes.

The doc on the variant (line 88) says the reference's argument is "a boolean, a number or null for a parameter the tool declares string". The match arm checks neither part:

  • It accepts any non-string reference, including arrays and objects, compared by reference.to_string().
  • It never looks up what the tool actually declares.

So before the fix(symphony) commit, the BFCL case bfcl-live-multiple-146-58-0 would land here. The parser said {"metrics": "[]"} and the reference has {"metrics": []}. Since Value::Array([]).to_string() == "[]", that case would be printed as "allowed: declared-type conflict (corpus)" instead of failing. The same goes for any future Kind::of regression that turns a declared integer, number, boolean, or array into a string. That contradicts the module doc's claim (line 19) that "the classes cannot hide anything else".

Cheapest fix: match the doc and allow only scalar references:

(serde_json::Value::String(text), reference @ (serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null)) => {

Stricter fix: pass the fixture's Declared in and require declared.kind(name, key) to be Some(Kind::String | Kind::NullableString). That way the allowance only covers parameters the tool really declares string.

/// Text after a call's object and before its closing marker: each run of whitespace is the
/// template's wrapping and is dropped, each run of anything else is malformed. Classifying run
/// by run keeps the result the same wherever the chunks were cut.
/// Text after a call and before its closing marker: each run of whitespace is the template's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: This doc comment now says "after a call" so it covers both syntaxes. But the Malformed reason it emits is still TEXT_AFTER_THE_OBJECT (line 70): "text between a call's object and its closing marker". With the tagged syntax there is no object; the surplus comes after </function>. Clients and logs will see that reason for tagged calls, so it should be reworded to match. For example: "text between a call and its closing marker".

@slin1237
slin1237 merged commit 529e3a3 into main Oct 7, 2026
43 checks passed
@slin1237
slin1237 deleted the feat/symphony-tagged-format branch October 7, 2026 03:59
slin1237 added a commit that referenced this pull request Oct 7, 2026
Brings the three commits main gained since eda1134 (#2835 Cargo.lock's
toml entry, #2833 the tagged discovery config with one runtime
conversion, #2838 Qwen3's tagged call syntax) under the leap branch.
Resolutions, both import lists:

- model_gateway/src/config/builder.rs: the `crate::config` import takes
  main's `KubernetesDiscoveryConfig` and the leap's `KvIndexKind`, in
  rustfmt order.
- model_gateway/src/main.rs: the same pair in the same list.

Cargo.lock, bindings/python/src/lib.rs, config/types.rs and
config/validation.rs merged on their own; the bindings build the
discovery config in main's enum form and the runtime conversion is
main's.

Signed-off-by: Simo Lin <25425177+slin1237@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

symphony crates/symphony: the unified output parser tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant