Repository navigation
fix: build chat template messages as llama.cpp does - #719
Merged
Merged
Conversation
Contributor
|
Chat app preview removed for |
This was referenced Sep 26, 2026
Closed
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds chat-template messages the way llama.cpp's
common_chat_msg::to_json_oaicompatdoes at the pinned7fe450e19, in one shared path (messagesForTemplate) that every handler now uses.content: null; it now gets"", as llama.cpp does, including on the image (typed-content) path. Only a template that reads content solely as parts (supports_typed_contentwithoutsupports_string_content) gets one empty text part instead, as llama.cpp's input normalizer does. At base, QwQ-32B threw (Call to non-function: Undefined), LFM2-8B printednull, and Devstral printed<function text>.splitToolResults(...).map(toJson), which skipped the typed-result conversion. Map and List results reached those templates raw. All six now go throughtemplateMessages, and FunctionGemma now renders its non-tool messages through it too.argumentsstayed a JSON string. llama.cpp decodes them into an object when the template reads them as one (supports_object_arguments, gated since ggml-org/llama.cpp#20612).JinjaAnalyzernow runs llama.cpp's own object-arguments probe conversation and setsTemplateCaps.supportsObjectArguments.messagesForTemplatedecodes JSON-object arguments. Other arguments stay strings.Why
nonewas the default:noneandnormalizeOnlycome from the per-formatfunc_args_not_stringport in #38, kept by #158. Upstream later replaced that with the caps check. No doc, test or commit gives a prompt-cache reason for keepingnone.Why #702 cannot hurt prompt reuse: native
ctx.cachedPromptTokens(llama_cpp_service.dart) and llama-web-bridge'sg_cached_prompt_tokenshold prompt tokens only, never generated tokens. Measured on qwen2.5-0.5b over a 3-turn tool conversation:Production-readiness scope
thinking, content blocks);/propsfor SmolVLM, TranslateGemma 2B, Ministral 3 and LFM2.5. It covers:Completeness checklist
PR type guidance
template_message_json_parity_test.dartholds 21 llama-server/apply-templateprompts (test/fixtures/template_message_json_upstream.{json,md}). Covered families:chat_template_handler_test.dart.jinja_analyzer_test.dart.Test Plan
At head
ad8dd330d, run serially on a loaded shared Mac.dart run tool/prepare_workspace.dart: no tracked changes, at head and base.dart format --output=none --set-exit-if-changed .dart analyzedart run tool/testing/check_platform_boundaries.dartdart test -p vm -j 1 --exclude-tags local-only: 3,498 pass. No test was removed compared with baseeeb382b55; 79 were added.dart test -p chrome --exclude-tags local-only: 1,655 pass. No test was removed; 56 were added../tool/testing/run_template_parity_suites.shpasses. Its unit template suite and compiled grammar row run with the llama.cpp validator.(cd example/chat_app && flutter test)passes: 491 tests.flutter test --platform chrome test/chat_generation_service_test.dartpasses.Differential against llama-server
7fe450e19/apply-template, baseeeb382b55vs headad8dd330d:test/fixtures,tool/litert_lm_templates, llama.cppmodels/templates).<__media_…__>marker, and LFM2-VL's<|im_end|>EOS, compared as the harness's</s>.The one image render: RWKV World with tools, image, a tool call, then a reasoning-only turn. Its only changed hunk inserts
Assistant: \n\nfor the reasoning-only turn, exactly as llama-server renders it.Assistant: \n\nfor the reasoning-only turn, exactly as llama-server renders it. Without tools, the head render equals the server's except for the<think>generation prompt llama-server adds, a gap that exists at base.The previous head
9cbf8e140moved 26 image renders away (Gemma 4, Kimi-K2, Apriel 1.6, TranslateGemma 2B, RWKV). This head fixes 25 of them; the 26th is the RWKV render above.Detected
supports_object_argumentsequals llama-server/propson 97 of 97 templates.Controls:
Base. With the head tests copied onto base
eeb382b55:Previous head
9cbf8e140. With the new fixture and test copied in, the 3 image cases fail: Gemma 4 closes the model turn after the tool response, Kimi-K2 prints[{'type': 'text', 'text': ''}], and Apriel 1.6 loses the tool call.Mutations, each catching its fix:
TemplateCapsobjectArgumentsofftemplateMessagescall bypassedevery handler renders one tool message per tool result)Parser and grammar. Negating the parallel-calls cap fails the Ministral and Nemotron parser-bound tests. Negating the typed-content cap fails the audio rollback and compiled-grammar e2e cases.
Matrix Evidence
root-vm/root-chromead8dd330dstructured-output-adversarial7fe450e19fixturesrun_template_parity_suites.sh; differential, controls and mutations aboveexamples-testshigh-risk-exact-head-independent-qaclaude-opus-5-5-fresh-subagent-audit719ad8dd330d: 0 PR-caused P1s, 0 unresolved threads (record below)release-metadata-verificationReview Notes
ad8dd330dbyclaude-opus-5-5-fresh-subagent-audit719; round 1 at9cbf8e140was rejected for a P1, fixed here.ad8dd330de1d129013cc67c2ce4b3978073e5ea6.High-risk regression review
structuredOutput), fromclassify_high_risk_changes.dart.{"auditor_identity":"claude-opus-5-5-fresh-subagent-audit719","audit_kind":"operator-owned","audit_head_sha":"ad8dd330de1d129013cc67c2ce4b3978073e5ea6","audit_base_sha":"eeb382b552b5eaecba39338f7a540f673ec3e59e","decision":"accepted","unresolved_review_threads":0,"known_pr_caused_p1_regressions":0,"summary":"2026-09-25 round 1 (9cbf8e14): rejected for a P1: multimodal null content became [{'type':'text','text':''}], so Gemma 4 31B/LiteRT gemma4 closed the model turn after a tool response, Apriel-1.6 dropped the tool call, and Kimi-K2/TranslateGemma printed the list repr. Round 2 (ad8dd330): null becomes '' unless the template is typed-only by TemplateCaps. All 5 repros fixed (4 byte-identical to base, Kimi-K2 only toward-server arg spacing). Own differential, 101 templates x 28 conversations vs llama-server 7fe450e19 (text + multimodal) plus 8 local GGUF templates: 0 new throws, non-prompt fields unchanged, 2 away (RWKV World) whose single hunk inserts 'Assistant: \\n\\n' exactly as llama-server does, with the distance rise from pre-existing generic tool JSON. Typed-only rule flips no template; the SmolVLM/TranslateGemma 2B/Ministral 3/LFM2.5 caps mismatches produce nothing worse than base. Ministral-3-14B image+reasoning throw and TranslateGemma 2B renders identical to base. Object-args probe matches /props on 101/101. Mutations red; all gates green; CI green; 0 threads."}ad8dd330de1d129013cc67c2ce4b3978073e5ea6/eeb382b552b5eaecba39338f7a540f673ec3e59e.templateMessagescall bypassed, fails a test. Each core change (content, the typed-only part and its wiring,objectArguments, the probe, the probe's tool message) fails tests when mutated.7fe450e19emissions of their templates.7fe450e19emissions.independent_auditand the QA row passing,high_risk_readiness.dartexits 2 (unverifiedPrerequisites). The local evidence is consistent; only the external prerequisites are unverified.