From a46ec79c702f2f4ae2720908aa5a3422a15687fe Mon Sep 17 00:00:00 2001 From: alpineq Date: Fri, 4 Sep 2026 18:42:29 +0300 Subject: [PATCH] FIX(qwen35-spec): parse tool calls whenever the request carries tools, independent of the grammar switch --- crates/hipfire-arch-qwen35/map.md | 4 ++-- crates/hipfire-arch-qwen35/src/spec_emit.rs | 7 ++++++- crates/hipfire-daemon/map.md | 4 ++-- crates/hipfire-daemon/src/slots.rs | 1 + crates/hipfire-generate/map.md | 6 +++--- crates/hipfire-generate/src/common.rs | 1 + crates/hipfire-generate/src/dense.rs | 1 + crates/hipfire-generate/src/qwen.rs | 10 +++++----- .../tests/ds4_malformed_terminal_tests.rs | 1 + .../tests/qwen_dflash_semantic_terminal_tests.rs | 5 +++++ crates/hipfire-runtime/map.md | 6 +++--- crates/hipfire-runtime/src/emit_text.rs | 9 +++++++-- crates/hipfire-runtime/src/spec.rs | 5 +++++ 13 files changed, 42 insertions(+), 18 deletions(-) diff --git a/crates/hipfire-arch-qwen35/map.md b/crates/hipfire-arch-qwen35/map.md index 94a8f3efd..0ca062d26 100644 --- a/crates/hipfire-arch-qwen35/map.md +++ b/crates/hipfire-arch-qwen35/map.md @@ -48,7 +48,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/scheduler.rs`](src/scheduler.rs) | 142 | 3 | 4 | | [`src/serve_engine.rs`](src/serve_engine.rs) | 1,273 | 8 | 2 | | [`src/slot_batch.rs`](src/slot_batch.rs) | 123 | 4 | 6 | -| [`src/spec_emit.rs`](src/spec_emit.rs) | 908 | 4 | 12 | +| [`src/spec_emit.rs`](src/spec_emit.rs) | 913 | 4 | 12 | | [`src/spec_impl.rs`](src/spec_impl.rs) | 643 | 1 | 0 | | [`src/speculative.rs`](src/speculative.rs) | 7,743 | 69 | 13 | @@ -97,6 +97,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 29 modules · 57,281 lines · 436 public items · 189 tests · 4 examples +- 29 modules · 57,286 lines · 436 public items · 189 tests · 4 examples diff --git a/crates/hipfire-arch-qwen35/src/spec_emit.rs b/crates/hipfire-arch-qwen35/src/spec_emit.rs index 4a37ebb46..7a5b6fff0 100644 --- a/crates/hipfire-arch-qwen35/src/spec_emit.rs +++ b/crates/hipfire-arch-qwen35/src/spec_emit.rs @@ -94,6 +94,7 @@ impl<'a> Qwen35Emit<'a> { let tool_protocol_enabled = ctx.tools.is_some(); let tool_schemas: Vec = ctx .tools + .filter(|_| ctx.grammar) .map(|arr| { arr.iter() .filter_map(|t| { @@ -177,7 +178,8 @@ impl<'a> Qwen35Emit<'a> { } } } - Err(_) => { + Err(e) => { + eprintln!("[qwen35-spec] {e}"); self.router_malformed = true; } } @@ -613,6 +615,7 @@ mod tests { eos: 9, im_end: Some(1), tools: Some(&[]), + grammar: true, stop: Vec::new(), max_think: 0, max_tokens: 256, @@ -837,6 +840,7 @@ mod tests { eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: vec![first_text.clone()], max_think: 0, max_tokens: 256, @@ -865,6 +869,7 @@ mod tests { eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: vec!["STOP".to_string()], max_think: 0, max_tokens: 256, diff --git a/crates/hipfire-daemon/map.md b/crates/hipfire-daemon/map.md index 77509dd6c..02caddbb9 100644 --- a/crates/hipfire-daemon/map.md +++ b/crates/hipfire-daemon/map.md @@ -24,7 +24,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | File | Lines | Public items | Tests | |---|---:|---:|---:| | [`src/main.rs`](src/main.rs) | 4,148 | 1 | 0 | -| [`src/slots.rs`](src/slots.rs) | 1,526 | 22 | 14 | +| [`src/slots.rs`](src/slots.rs) | 1,527 | 22 | 14 | ### Public API surface @@ -44,6 +44,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 2 modules · 5,674 lines · 23 public items · 14 tests · 0 examples +- 2 modules · 5,675 lines · 23 public items · 14 tests · 0 examples diff --git a/crates/hipfire-daemon/src/slots.rs b/crates/hipfire-daemon/src/slots.rs index 9daa7f6ef..5695866a0 100644 --- a/crates/hipfire-daemon/src/slots.rs +++ b/crates/hipfire-daemon/src/slots.rs @@ -640,6 +640,7 @@ impl SlotBackend { eos: self.tokenizer.eos_id, im_end: self.tokenizer.special_token_id("<|im_end|>"), tools: None, + grammar: true, stop: Vec::new(), max_think: 0, max_tokens, diff --git a/crates/hipfire-generate/map.md b/crates/hipfire-generate/map.md index f59f344a3..43a49bac8 100644 --- a/crates/hipfire-generate/map.md +++ b/crates/hipfire-generate/map.md @@ -24,8 +24,8 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside |---|---:|---:|---:| | [`src/ar.rs`](src/ar.rs) | 4,623 | 49 | 0 | | [`src/batch.rs`](src/batch.rs) | 3,465 | 8 | 0 | -| [`src/common.rs`](src/common.rs) | 1,569 | 48 | 1 | -| [`src/dense.rs`](src/dense.rs) | 8,455 | 91 | 4 | +| [`src/common.rs`](src/common.rs) | 1,570 | 48 | 1 | +| [`src/dense.rs`](src/dense.rs) | 8,456 | 91 | 4 | | [`src/lib.rs`](src/lib.rs) | 58 | 7 | 0 | | [`src/qwen.rs`](src/qwen.rs) | 6,312 | 60 | 1 | | [`src/redline.rs`](src/redline.rs) | 4,361 | 49 | 1 | @@ -55,6 +55,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 8 modules · 31,785 lines · 321 public items · 224 tests · 0 examples +- 8 modules · 31,787 lines · 321 public items · 224 tests · 0 examples diff --git a/crates/hipfire-generate/src/common.rs b/crates/hipfire-generate/src/common.rs index 724abff7e..1141662ee 100644 --- a/crates/hipfire-generate/src/common.rs +++ b/crates/hipfire-generate/src/common.rs @@ -1255,6 +1255,7 @@ pub struct SpecEmitRequest { pub im_end: Option, /// Raw tool definitions (OpenAI-shape JSON); `None`/empty ⇒ no tool grammar. pub tools: Option>, + pub grammar: bool, pub stop: Vec, pub max_think: usize, pub assistant_prefix: hipfire_runtime::prompt_frame::AssistantPrefix, diff --git a/crates/hipfire-generate/src/dense.rs b/crates/hipfire-generate/src/dense.rs index aac549982..82ab6e7dd 100644 --- a/crates/hipfire-generate/src/dense.rs +++ b/crates/hipfire-generate/src/dense.rs @@ -501,6 +501,7 @@ pub fn generate_deepseek4_spec( SpecEmitRequest { im_end: None, tools: tools.map(|t| t.to_vec()), + grammar: true, stop: Vec::new(), max_think: 0, assistant_prefix: hipfire_runtime::prompt_frame::AssistantPrefix::Plain, diff --git a/crates/hipfire-generate/src/qwen.rs b/crates/hipfire-generate/src/qwen.rs index 5bea5b5ba..c848d4676 100644 --- a/crates/hipfire-generate/src/qwen.rs +++ b/crates/hipfire-generate/src/qwen.rs @@ -2280,11 +2280,9 @@ pub fn generate_dflash( std::env::var("HIPFIRE_QWEN35_GRAMMAR").ok().as_deref(), &m.model_path, ); - let emit_tools: Option> = if grammar_enabled { - tools.map(|t| t.to_vec()) - } else { - None - }; + // Tools always reach the emitter: they arm the tool-call PARSER. Whether the + // grammar matcher is armed rides on `grammar_enabled` alone. + let emit_tools: Option> = tools.map(|t| t.to_vec()); // The decode core (slot guard, prefill, accept-window loop, bake, finish) is // the arch-generic `generate_spec`. This wrapper owns the qwen35/llama-specific @@ -2337,6 +2335,7 @@ pub fn generate_dflash( SpecEmitRequest { im_end: im_end_token, tools: emit_tools, + grammar: grammar_enabled, stop: stop.to_vec(), max_think: max_think_tokens, assistant_prefix: spec_assistant_prefix(started_in_think), @@ -3100,6 +3099,7 @@ pub fn generate_spec( eos: slot.eos_token(), im_end: emit_req.im_end, tools: emit_req.tools.as_deref(), + grammar: emit_req.grammar, stop: emit_req.stop, max_think: emit_req.max_think, max_tokens, diff --git a/crates/hipfire-generate/tests/ds4_malformed_terminal_tests.rs b/crates/hipfire-generate/tests/ds4_malformed_terminal_tests.rs index 6c6133e86..a41bc8e37 100644 --- a/crates/hipfire-generate/tests/ds4_malformed_terminal_tests.rs +++ b/crates/hipfire-generate/tests/ds4_malformed_terminal_tests.rs @@ -671,6 +671,7 @@ use hipfire_engine::terminal::{set_active_attempt_id, ClientTerminalDecision}; eos: 7, im_end: None, tools: None, + grammar: true, stop: Vec::new(), max_think: 0, max_tokens: 16, diff --git a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs index 38a8e59d8..ef3bf86ac 100644 --- a/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs +++ b/crates/hipfire-generate/tests/qwen_dflash_semantic_terminal_tests.rs @@ -140,6 +140,7 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; eos: 9, im_end: Some(1), tools: Some(&[]), + grammar: true, stop: Vec::new(), max_think: 0, max_tokens: 256, @@ -820,6 +821,7 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: Vec::new(), max_think: 1, max_tokens: 256, @@ -2418,6 +2420,7 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: vec![first_text.clone()], max_think: 0, max_tokens: 256, @@ -2519,6 +2522,7 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: vec![stop_text.clone()], max_think: 0, max_tokens: 256, @@ -2699,6 +2703,7 @@ use hipfire_runtime::emit_text::extract_tool_calls_from_text; eos: 9, im_end: Some(1), tools: None, + grammar: true, stop: vec![first_text.clone()], max_think: 0, max_tokens: 1, diff --git a/crates/hipfire-runtime/map.md b/crates/hipfire-runtime/map.md index 1995fd991..6600fde3e 100644 --- a/crates/hipfire-runtime/map.md +++ b/crates/hipfire-runtime/map.md @@ -42,7 +42,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/dflash_generic.rs`](src/dflash_generic.rs) | 1,365 | 3 | 13 | | [`src/dspark_block_controller.rs`](src/dspark_block_controller.rs) | 442 | 0 | 10 | | [`src/dspark_core.rs`](src/dspark_core.rs) | 1,773 | 11 | 0 | -| [`src/emit_text.rs`](src/emit_text.rs) | 1,702 | 20 | 39 | +| [`src/emit_text.rs`](src/emit_text.rs) | 1,707 | 20 | 39 | | [`src/eos_filter.rs`](src/eos_filter.rs) | 919 | 9 | 27 | | [`src/ep.rs`](src/ep.rs) | 287 | 2 | 0 | | [`src/eval_common.rs`](src/eval_common.rs) | 231 | 3 | 0 | @@ -70,7 +70,7 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside | [`src/semantic.rs`](src/semantic.rs) | 773 | 29 | 16 | | [`src/serve/mod.rs`](src/serve/mod.rs) | 215 | 12 | 3 | | [`src/session_table.rs`](src/session_table.rs) | 654 | 19 | 24 | -| [`src/spec.rs`](src/spec.rs) | 1,923 | 35 | 15 | +| [`src/spec.rs`](src/spec.rs) | 1,928 | 35 | 15 | | [`src/spec_ngram.rs`](src/spec_ngram.rs) | 491 | 4 | 3 | | [`src/swap/mod.rs`](src/swap/mod.rs) | 176 | 10 | 3 | | [`src/swap/snapshot.rs`](src/swap/snapshot.rs) | 392 | 9 | 7 | @@ -156,6 +156,6 @@ _Generated by `scripts/check-crate-maps.py` from the tree — do not edit inside ### Totals -- 58 modules · 53,079 lines · 893 public items · 617 tests · 132 examples +- 58 modules · 53,089 lines · 893 public items · 617 tests · 132 examples diff --git a/crates/hipfire-runtime/src/emit_text.rs b/crates/hipfire-runtime/src/emit_text.rs index 12bc84ea9..8af011bd0 100644 --- a/crates/hipfire-runtime/src/emit_text.rs +++ b/crates/hipfire-runtime/src/emit_text.rs @@ -455,7 +455,10 @@ impl ToolOutputRouter { Ok(true) } None => Err(self.latch_malformed( - "tool_call body is empty, truncated, or not fully valid JSON/Qwen XML", + &format!( + "tool_call body is empty, truncated, or not fully valid JSON/Qwen XML; body={:?}", + &body[..body.len().min(200)] + ), )), } } @@ -1680,7 +1683,9 @@ mod tests { fn disabled_router_keeps_tool_like_text_visible_and_non_executable() { let text = "\n\n"; let mut router = ToolOutputRouter::disabled(); - let events = router.push(text).expect("tool-free text must remain visible"); + let events = router + .push(text) + .expect("tool-free text must remain visible"); assert_eq!(events.len(), 1); match &events[0] { ToolRouteEvent::VisibleText(visible) => assert_eq!(visible.as_str(), text), diff --git a/crates/hipfire-runtime/src/spec.rs b/crates/hipfire-runtime/src/spec.rs index 1499f231a..2c56729c3 100644 --- a/crates/hipfire-runtime/src/spec.rs +++ b/crates/hipfire-runtime/src/spec.rs @@ -1396,6 +1396,11 @@ pub struct SpecEmitCtx<'a> { /// extracts its own grammar `ToolSchema` from these; `None`/empty ⇒ no /// tool-call grammar. pub tools: Option<&'a [serde_json::Value]>, + /// Arm grammar-guided decoding. Separate from `tools` on purpose: the + /// tool-call PARSER must follow the request carrying tools, while the + /// matcher follows the arch's grammar switch — coupling them left Qwen + /// native XML calls unparsed whenever grammar was off. + pub grammar: bool, /// User stop sequences matched against the decoded suffix. pub stop: Vec, /// `max_think_tokens` budget (0 ⇒ no think force-close).