[Rust Frontend] Handle `continue_final_message` with renderer sentinel (#47844)
Signed-off-by: Bugen Zhao <i@bugenzhao.com>
Bugen Zhao committed
Jul 8, 2026 at 19:57 UTC
2cae98dfa59bc58d939f526465ff025670d002f0
3 files changed
+200
-17
.buildkite/test-amd.yaml
+1
-1
@@ -2026,7 +2026,7 @@ steps:
2026
- pytest -v -s entrypoints/serve/instrumentator/test_basic.py -k "not show_version and not server_load"
2027
- pytest -v -s entrypoints/scale_out/token_in_token_out/test_serving_tokens.py -k "not stream and not lora and not test_generate_logprobs and not stop_string_workflow"
2028
- pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist"
2029
- - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info and not test_tokenize_chat"
2029
+ - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info"
2030
2031
- label: Rust Frontend Core Correctness # TBD
2032
timeout_in_minutes: 180
.buildkite/test_areas/rust_frontend.yaml
+1
-1
@@ -64,7 +64,7 @@ steps:
64
- pytest -v -s entrypoints/serve/instrumentator/test_metrics.py -k "text and not show and not run_batch and not test_metrics_counts and not test_metrics_exist"
65
# - pytest -v -s entrypoints/serve/dev/test_sleep.py
66
# /tokenizer_info is not implemented in the Rust frontend (the CLI flag is accepted as a no-op).
67
- - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info and not test_tokenize_chat"
67
+ - pytest -v -s entrypoints/serve/tokenize/test_tokenization.py -k "not tokenizer_info"
68
69
- label: Rust Frontend Core Correctness
70
timeout_in_minutes: 30
rust/src/chat/src/renderer/hf/mod.rs
+198
-15
@@ -155,11 +155,23 @@ impl HfChatRenderer {
155
effective_template: &CompiledChatTemplate,
156
request: &ChatRequest,
157
) -> Result<RenderedPrompt> {
158
- let messages = to_template_messages(
158
+ let mut messages = to_template_messages(
159
&request.messages,
160
effective_template.content_format(),
161
self.multimodal.as_ref(),
162
)?;
163
+
164
+ // Handling of `continue_final_message`:
165
+ // Append a sentinel tag to the final message content, render as usual, then
166
+ // truncate the rendered prompt at the tag so any template suffix after the
167
+ // final message content (e.g. the end-of-turn marker) is dropped.
168
+ let final_message_text = if request.chat_options.continue_final_message() {
169
+ let final_message = messages.last_mut().ok_or(Error::EmptyMessages)?;
170
+ Some(append_continue_final_message_tag(final_message)?)
171
+ } else {
172
+ None
173
+ };
174
+
175
let tools = request.tool_parsing_enabled().then(|| to_template_tools(&request.tools));
176
trace!(
177
message_count = messages.len(),
@@ -183,6 +195,13 @@ impl HfChatRenderer {
195
})
196
.map_err(|error| Error::ChatTemplate(error.to_report_string()))?;
197
198
+ let prompt = match &final_message_text {
199
+ Some(final_message_text) => {
200
+ truncate_prompt_at_continue_final_message_tag(prompt, final_message_text)?
201
+ }
202
+ None => prompt,
203
+ };
204
+
205
trace!(
206
prompt_len = prompt.len(),
207
prompt, "rendered chat template prompt"
@@ -429,6 +448,74 @@ fn to_template_string_content(
448
}
449
}
450
451
+/// Sentinel appended to the final message content when `continue_final_message`
452
+/// is requested, used to locate the truncation point in the rendered prompt.
453
+///
454
+/// Same literal as `transformers`. Occurrences of this string earlier in the
455
+/// prompt are harmless because truncation uses the rightmost match, and the
456
+/// appended sentinel ends up last as long as the template renders messages in
457
+/// order.
458
+const CONTINUE_FINAL_MESSAGE_TAG: &str = "CONTINUE_FINAL_MESSAGE_TAG ";
459
+
460
+/// Append [`CONTINUE_FINAL_MESSAGE_TAG`] to the trailing text of the final
461
+/// message, returning the original text for post-render validation.
462
+// TODO: transformers v5 also allows continuing a non-`content` field (e.g.
463
+// `reasoning_content`) by passing a field name; only the boolean form is
464
+// supported here.
465
+fn append_continue_final_message_tag(message: &mut TemplateMessage) -> Result<String> {
466
+ let text = match &mut message.content {
467
+ TemplateContent::String(text) => Some(text),
468
+ // Pick the last text part in the message.
469
+ TemplateContent::OpenAi(parts) => parts.iter_mut().rev().find_map(|part| match part {
470
+ TemplateContentPart::Text { text } => Some(text),
471
+ TemplateContentPart::Image => None,
472
+ }),
473
+ };
474
+ let text = text.ok_or_else(|| {
475
+ Error::ChatTemplate(
476
+ "continue_final_message is set but there is no text to continue \
477
+ in the final message"
478
+ .to_string(),
479
+ )
480
+ })?;
481
+
482
+ let original = text.clone();
483
+ text.push_str(CONTINUE_FINAL_MESSAGE_TAG);
484
+ Ok(original)
485
+}
486
+
487
+/// Truncate the rendered prompt at [`CONTINUE_FINAL_MESSAGE_TAG`] so that it
488
+/// ends exactly with the final message content, dropping any template suffix
489
+/// such as end-of-turn markers.
490
+fn truncate_prompt_at_continue_final_message_tag(
491
+ mut rendered: String,
492
+ final_message_text: &str,
493
+) -> Result<String> {
494
+ let tag_loc = rendered
495
+ .rfind(CONTINUE_FINAL_MESSAGE_TAG.trim_end())
496
+ .filter(|_| rendered.contains(final_message_text.trim()));
497
+ let Some(tag_loc) = tag_loc else {
498
+ return Err(Error::ChatTemplate(format!(
499
+ "continue_final_message is set but the final message does not appear \
500
+ in the prompt after applying the chat template! This can happen if \
501
+ the chat template deletes portions of the final message. Final \
502
+ message to continue: {}",
503
+ final_message_text.trim(),
504
+ )));
505
+ };
506
+
507
+ if rendered[tag_loc..].starts_with(CONTINUE_FINAL_MESSAGE_TAG) {
508
+ // The template preserved spacing, so a plain cut at the tag suffices.
509
+ rendered.truncate(tag_loc);
510
+ } else {
511
+ // The template trimmed the trailing spacing of the message content, so
512
+ // apply the same trimming to the retained prefix.
513
+ rendered.truncate(tag_loc);
514
+ rendered.truncate(rendered.trim_end().len());
515
+ }
516
+ Ok(rendered)
517
+}
518
+
519
fn to_template_tools(tools: &[ChatTool]) -> Vec<TemplateTool> {
520
tools
521
.iter()
@@ -548,28 +635,124 @@ mod tests {
635
ChatRole::Assistant,
636
"The capital of",
637
)]);
638
+ let template =
639
+ "{% if continue_final_message %}continue:{% endif %}{{ messages[0].content }}";
640
552
- assert_eq!(
553
- render(
554
- Some("{% if continue_final_message %}continue{% else %}new{% endif %}"),
555
- &request,
556
- )
557
- .unwrap(),
558
- "new"
559
- );
641
+ assert_eq!(render(Some(template), &request).unwrap(), "The capital of");
642
643
request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
644
645
assert_eq!(
564
- render(
565
- Some("{% if continue_final_message %}continue{% else %}new{% endif %}"),
566
- &request,
567
- )
568
- .unwrap(),
569
- "continue"
646
+ render(Some(template), &request).unwrap(),
647
+ "continue:The capital of"
648
);
649
}
650
651
+ #[test]
652
+ fn continue_final_message_truncates_template_suffix() {
653
+ let mut request = sample_request(vec![
654
+ ChatMessage::text(ChatRole::User, "What is the capital of France?"),
655
+ ChatMessage::text(ChatRole::Assistant, "The capital of"),
656
+ ]);
657
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
658
+
659
+ // The Qwen3 template is unaware of `continue_final_message`; the
660
+ // end-of-turn marker it appends must still be stripped.
661
+ let rendered = render(Some(QWEN3_0_6B_TEMPLATE), &request).unwrap();
662
+
663
+ expect![[r#"
664
+ <|im_start|>user
665
+ What is the capital of France?<|im_end|>
666
+ <|im_start|>assistant
667
+ <think>
668
+
669
+ </think>
670
+
671
+ The capital of"#]]
672
+ .assert_eq(&rendered);
673
+ }
674
+
675
+ #[test]
676
+ fn continue_final_message_trims_like_the_template_does() {
677
+ let mut request = sample_request(vec![ChatMessage::text(ChatRole::Assistant, "Sure, ")]);
678
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
679
+
680
+ // The template trims the trailing spacing of the message content, so
681
+ // the truncated prompt must be trimmed the same way.
682
+ let rendered = render(
683
+ Some("{{ messages[0].content.strip() }}<|im_end|>"),
684
+ &request,
685
+ )
686
+ .unwrap();
687
+
688
+ assert_eq!(rendered, "Sure,");
689
+ }
690
+
691
+ #[test]
692
+ fn continue_final_message_appends_to_last_text_part() {
693
+ // The renderer itself is role-agnostic like transformers (the
694
+ // assistant-final restriction is enforced by request validation
695
+ // upstream), so a multimodal user message exercises the part
696
+ // selection: the sentinel must attach to the last *text* part,
697
+ // skipping the trailing image.
698
+ let mut request = sample_request(vec![ChatMessage::user(vec![
699
+ ChatContentPart::text("Sure,"),
700
+ ChatContentPart::image_url("data:image/png;base64,test"),
701
+ ])]);
702
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
703
+
704
+ let rendered = render_mm(
705
+ "{% for item in messages[0].content %}{% if item.type == 'image' %}<image>{% else %}{{ item.text }}{% endif %}{% endfor %}<|im_end|>",
706
+ &request,
707
+ ChatTemplateContentFormatOption::OpenAi,
708
+ )
709
+ .unwrap()
710
+ .prompt;
711
+
712
+ // Anything rendered after the continued text (here the image
713
+ // placeholder and the end marker) is truncated away, matching
714
+ // transformers.
715
+ assert_eq!(rendered, Prompt::Text("Sure,".to_string()));
716
+ }
717
+
718
+ #[test]
719
+ fn continue_final_message_composes_with_aware_templates() {
720
+ // A template that reads `continue_final_message` and skips its own
721
+ // end-of-turn marker must produce the same prompt as an unaware one:
722
+ // the sentinel truncation degenerates to a cut at the very end.
723
+ let mut request = sample_request(vec![
724
+ ChatMessage::text(ChatRole::User, "hi"),
725
+ ChatMessage::text(ChatRole::Assistant, "Sure,"),
726
+ ]);
727
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
728
+
729
+ let aware = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}{% if not (loop.last and continue_final_message) %}<|im_end|>\n{% endif %}{% endfor %}";
730
+ let unaware = "{% for m in messages %}<|im_start|>{{ m.role }}\n{{ m.content }}<|im_end|>\n{% endfor %}";
731
+
732
+ let expected = "<|im_start|>user\nhi<|im_end|>\n<|im_start|>assistant\nSure,";
733
+ assert_eq!(render(Some(aware), &request).unwrap(), expected);
734
+ assert_eq!(render(Some(unaware), &request).unwrap(), expected);
735
+ }
736
+
737
+ #[test]
738
+ fn continue_final_message_errors_when_template_drops_final_message() {
739
+ let mut request = sample_request(vec![
740
+ ChatMessage::text(ChatRole::User, "hi"),
741
+ ChatMessage::text(ChatRole::Assistant, "Sure,"),
742
+ ]);
743
+ request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
744
+
745
+ let error = render(
746
+ Some(
747
+ "{% for m in messages %}{% if m.role == 'user' %}{{ m.content }}{% endif %}{% endfor %}",
748
+ ),
749
+ &request,
750
+ )
751
+ .unwrap_err();
752
+
753
+ assert!(matches!(error, Error::ChatTemplate(_)));
754
+ }
755
+
756
#[test]
757
fn chat_template_flattens_text_parts_for_string_templates() {
758
let request = sample_request(vec![ChatMessage::user(vec![