feat(qwen35): render tool calls in native XML, not legacy Hermes JSON#513
feat(qwen35): render tool calls in native XML, not legacy Hermes JSON#513nwoolmer wants to merge 2 commits into
Conversation
qwen3.5/3.6 are XML-native (`<function=NAME><parameter=ARG>`); the write path was still emitting Hermes JSON in three places, fighting the model's training and (with the grammar on) inducing the dropped-outer-brace bug. - Per-model grammar gating (cli): resolveToolGrammar() picks the tool format from the registry `default_tool_format` (or a hermes/carnice heuristic). Non-Hermes qwen defaults grammar OFF -> model emits its native XML; Hermes models (carnice) keep grammar ON/JSON. Applied in serve() too. registry.json marks the 4 carnice cards hermes. - CLI tools-block example (Part A): for qwen3_5/qwen3_5_moe with grammar off, show the native `<tool_call><function=><parameter=>` example instead of the JSON one. Keyed on the live grammar env so the example always matches what the model will emit. - Cache-miss history re-render (Part B): build_cached_history's ChatScaffold miss branch (HIPFIRE_JINJA_CHAT=0 fallback) gains a ToolCallRender param; grammar-off qwen re-renders a historical tool_call as native XML via render_tool_call_qwen_xml(). Both call sites (plan_prompt_cache LCP planning + the render) read the same env-derived format so their token streams stay byte-consistent. Note: on the DEFAULT path qwen3.6 renders through its bundled chat_template (jinja_active), which is already native XML for tools-block and history; Part A/B harden the HIPFIRE_JINJA_CHAT=0 hand-rolled fallback. The real forcing signal was the grammar, now off by default for non-Hermes qwen. Tests: prompt_frame red->green for XML and JSON history render; full hipfire-runtime lib (315) + qwen35 grammar (75) green. Validated live on qwen3.6-27b.mq4: single-turn 5/5 angle-bracket-heavy writes clean, multi-turn clean, and the jinja=0 cache path exercised (eligible=true, Assistant tool_call miss -> XML render, lcp=256 forward extension). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @nwoolmer's task in 3m 4s —— View job Review complete
Verdict: Minor — one real gate-coverage gap, rest is nits. The core logic is clean and consistent: Findings:
GPU gates still required: |
|
/gate |
|
/gate |
|
/gate |
|
/gate |
5 similar comments
|
/gate |
|
/gate |
|
/gate |
|
/gate |
|
/gate |
GPU PR Gate (Tier 3)
Bill of Debt (blocking):
Actionable next steps:
This PR is blocked until the above are resolved and all three required archs (gfx1100, gfx1151, gfx1201) plus all three behavior tests pass. |
…t the CLI
The native-XML-vs-Hermes decision lived only in the CLI's applyConfigEnv,
which sets HIPFIRE_QWEN35_GRAMMAR before spawning the daemon. Anything that
launches examples/daemon DIRECTLY -- the GPU behavior gate, coherence-gate.sh,
agentic-gate.sh -- left the env unset, and all four daemon sites defaulted
grammar ON (Hermes JSON). So the native-XML path this PR ships (plain
qwen3.5/3.6) was never exercised by the gates, and the GPU behavior gate
REJECTed all three tool-call checks (native-xml-toolcall,
history-cache-miss-xml-replay, grammar-explicit-override).
Move the default into the lib (prompt_frame::qwen35_grammar_on /
qwen35_history_render) and key it on the model path so a daemon-direct launch
resolves correctly: explicit HIPFIRE_QWEN35_GRAMMAR wins ("0" = off
kill-switch, any other set value = on); otherwise carnice (Hermes-native)
=> ON, plain qwen3.5/3.6 (XML-native) => OFF. Wire it at all four grammar /
history-render sites in the daemon (generate, generate_multi, generate_dflash,
plan_prompt_cache). Mirrors the CLI's resolveToolGrammar exactly, so `serve`
and direct-spawn agree -- which also closes the review's gate-coverage gap
(the gates now exercise the shipped native-XML path automatically).
Also addresses the two review nits on render_tool_call_qwen_xml: a debug_assert
that arguments is a JSON object, and a comment documenting the accepted
degrade-to-cache-miss for unescaped parameter values.
Validated: 5 new prompt_frame unit tests (grammar default/override/history);
and end-to-end on gfx1151 via the daemon batch protocol -- plain qwen3.6-27b
emits native <function=write_file> XML with the env unset, and Hermes
{"name":"write_file"} JSON with HIPFIRE_QWEN35_GRAMMAR=1.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
/gate |
Parse Qwen-native XML at the shared runtime boundary so daemon tool_calls events, finish_reason, and assistant-cache fingerprints agree with the Bun serve parser. Resolve model-derived grammar per request instead of persisting the prewarm model's choice in process.env; explicit user overrides still win. Observed on hiptrx gfx1201 device 3 with qwen3.6-35b-a3b.mq4r: rebased PR #513 emitted a correct XML write_file block, but /v1/chat/completions returned finish_reason=stop. Master and beta returned finish_reason=tool_calls. The serve_harness five-prompt floor remained byte-identical between beta and PR #513. Tests: - cargo test -p hipfire-runtime emit_text --locked - cargo check -p hipfire-runtime --example daemon --locked - bun test cli/serve_ux_parse.test.ts cli/parse_tool_calls.test.ts - git diff --check
Rebased contributor head 25de968 onto codex/beta, then fixed the two validation blockers found on the user-facing Bun serve path: native-XML calls now produce daemon structured events/finish_reason=tool_calls, and grammar selection is resolved per requested model without prewarm-model bleed. hiptrx gfx1201, ROCR_VISIBLE_DEVICES=3 model: /home/kaden/.hipfire/models/qwen3.6-35b-a3b.mq4r model md5: 2a2be837d58ef8e57f8cf2b907050770 request md5: 1c4046d4f2119b4e7f05124a510197e0 A/B: - beta: write_file parsed, finish_reason=tool_calls - rebased #513: write_file parsed by CLI fallback, finish_reason=stop (rejected) - reconciled #513: native XML parsed at runtime, finish_reason=tool_calls (pass) - serve_harness battery: beta and reconciled #513 byte-identical, 0 empty, 0 attractors Redline staging-base gate: - stable decode capture: 733 launches, 23 kernels, hash 3318ffca3daf2338 - 23 AQL contracts - 15-position retained-PM4 shadow parity bit-exact Local gates: - cargo test -p hipfire-runtime emit_text --locked - cargo check -p hipfire-runtime --example daemon --locked - bun test cli/serve_ux_parse.test.ts cli/parse_tool_calls.test.ts - git diff --check
Carry PR #513's per-model default_tool_format through registry/v1 so the production dynamic-registry path does not discard Carnice's Hermes selection. Validate the field in both the Bun loader and registry generator. Tests: bun test cli/registry_loader.test.ts cli/serve_ux_parse.test.ts cli/parse_tool_calls.test.ts (79 pass)
|
Folded into #534 as a cleanly rebased/cherry-picked delta on |
Follow-on to #508 (the dropped-outer-brace grammar fix). That PR made the Hermes-JSON grammar correct; this PR stops forcing qwen3.5/3.6 into Hermes JSON at all, since those models are XML-native (
<function=NAME><parameter=ARG>). Emitting a JSON example / re-rendering history as JSON fights the model's training and is what induced the drift in the first place.Changes
cli/index.ts,registry.json):resolveToolGrammar()picks the tool format from the registrydefault_tool_format(added"hermes"to the 4 carnice cards) or a hermes/carnice heuristic. Non-Hermes qwen defaults the Hermes JSON grammar OFF → the model emits its native XML; Hermes-format models (carnice) keep it ON. Applied inserve()too.qwen3_5/qwen3_5_moewith the grammar off, the injected example is<tool_call><function=><parameter=>instead of the Hermes JSON one. Keyed on the live grammar env so the example always matches what the model will actually emit.build_cached_history's ChatScaffold miss branch (theHIPFIRE_JINJA_CHAT=0hand-rolled fallback) gains aToolCallRenderparam; grammar-off qwen re-renders a historicaltool_callas native XML viarender_tool_call_qwen_xml(). Both call sites (LCP planning inplan_prompt_cache+ the actual render) read the same env-derived format so their token streams stay byte-consistent.Note on scope
On the default path qwen3.6 already renders through its bundled
chat_template(jinja_activeis on by default), which is native XML for both the tools-block and history — so the real forcing signal was the grammar (now off for non-Hermes qwen). Parts A/B additionally harden theHIPFIRE_JINJA_CHAT=0hand-rolled fallback so it's consistent too.Testing
prompt_framered→green tests for both XML and Hermes-JSON history render; fullhipfire-runtimelib (315) +hipfire-arch-qwen35grammar (75) green.<<,#include <stdio.h>,<vector>, HTML,a < b); multi-turn (history tool_call + tool result → new call) clean; theHIPFIRE_JINJA_CHAT=0cache path exercised (eligible=true, Assistant tool_call miss → XML render,lcp=256forward extension).🤖 Generated with Claude Code