[chat] feat: template.system_messages_after_first — merge or forward late system turns (#11906)

* feat(chat): template.system_messages_after_first — merge or forward late system turns

Tokenizer chat templates such as Qwen3.8 / Qwen3.8-Flash-Next raise
'System message must be at the beginning' for system-role messages that
appear after the leading system block, while agent frameworks (cogito tool
selection and adjustment prompts) legitimately append system instructions
mid-conversation. Every such request failed with a 500 (48 errors in one
10-task agent run).

New per-model option template.system_messages_after_first:
  merge  fold late system turns into the leading system message
  user   forward them as user-role turns at their original position
Default (unset) keeps the current pass-through behaviour.

Fixes #11876

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* docs(model-config): document template.system_messages_after_first

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

* fix(config/meta): register template.system_messages_after_first in the field registry

TestAllFieldsHaveRegistryEntries requires every model-config field to have
a registry entry. Adds the entry (templates section, select component) and
the option list for the new field so the coverage gate passes.

Assisted-by: Claude:claude-fable-5-1
Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>

---------

Signed-off-by: Stefan Walcz <stefan.walcz@walcz.de>
This commit is contained in:
Stefan Walcz authored and GitHub committed 2026-09-09 22:21:32 +02:00
1 parent 752ee66506
commit 109244a76a
6 files changed
+132

No files matched your search

+9
View File
@@ -99,3 +99,12 @@ var DiffusersSchedulerOptions = []FieldOption{
{Value: "heun", Label: "Heun"},
{Value: "unipc", Label: "UniPC"},
}
// SystemMessagesAfterFirstOptions are the values of template.system_messages_after_first:
// how system messages that appear after the first turn are handled before the chat
// template runs (empty = pass through unchanged, which strict Jinja templates reject).
var SystemMessagesAfterFirstOptions = []FieldOption{
{Value: "", Label: "Pass through (default)"},
{Value: "merge", Label: "Merge into the first system message"},
{Value: "user", Label: "Forward as user messages"},
}
+8
View File
@@ -382,6 +382,14 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Description: "Use the chat template from the model's tokenizer config",
Order: 44,
},
"template.system_messages_after_first": {
Section: "templates",
Label: "System Messages After First",
Description: "How system messages that appear after the first turn are handled before templating: merge into the first system message, or forward as user messages. Empty passes them through unchanged, which strict Jinja templates reject.",
Component: "select",
Options: SystemMessagesAfterFirstOptions,
Order: 45,
},
// Router section template — kept in the templates UI section
// (rather than the router section under "other") so operators
// editing prompt shapes find all template-typed fields in one
+10
View File
@@ -1351,6 +1351,16 @@ type TemplateConfig struct {
// that can use the tokenizers specified in the JSON config files of the models
UseTokenizerTemplate bool `yaml:"use_tokenizer_template,omitempty" json:"use_tokenizer_template,omitempty"`
// SystemMessagesAfterFirst controls what happens to system-role messages that
// appear after the leading system block. Some tokenizer chat templates (e.g.
// Qwen3.8 / Flash-Next) raise "System message must be at the beginning" for
// them, while agent frameworks (cogito tool selection, adjustment prompts)
// legitimately append system instructions mid-conversation.
// ""/"error": pass through unchanged (template decides)
// "merge": fold them into the leading system message
// "user": forward them as user-role instructions (keeps their position)
SystemMessagesAfterFirst string `yaml:"system_messages_after_first,omitempty" json:"system_messages_after_first,omitempty"`
// JoinChatMessagesByCharacter is a string that will be used to join chat messages together.
// It defaults to \n
JoinChatMessagesByCharacter *string `yaml:"join_chat_messages_by_character,omitempty" json:"join_chat_messages_by_character,omitempty"`
+60
View File
@@ -66,6 +66,65 @@ func stripEmptySystemMessages(messages []schema.Message) []schema.Message {
return out
}
// normalizeLateSystemMessages handles system-role messages that appear after the
// leading system block, according to template.system_messages_after_first:
// "merge" folds them into the first system message (created if absent), "user"
// forwards them as user-role turns at their original position. Any other value
// returns the messages unchanged. Needed for tokenizer templates that reject
// late system turns (Qwen3.8: "System message must be at the beginning") while
// agent frameworks append instructions mid-conversation.
func normalizeLateSystemMessages(messages []schema.Message, mode string) []schema.Message {
if mode != "merge" && mode != "user" {
return messages
}
lead := 0
for lead < len(messages) && messages[lead].Role == "system" {
lead++
}
late := false
for _, m := range messages[lead:] {
if m.Role == "system" {
late = true
break
}
}
if !late {
return messages
}
out := make([]schema.Message, 0, len(messages)+1)
out = append(out, messages[:lead]...)
if mode == "merge" && lead == 0 {
out = append(out, schema.Message{Role: "system"})
}
for _, m := range messages[lead:] {
if m.Role != "system" {
out = append(out, m)
continue
}
text := strings.TrimSpace(messageText(m))
if text == "" {
continue
}
switch mode {
case "merge":
first := &out[0]
joined := strings.TrimSpace(messageText(*first))
if joined != "" {
joined += "\n\n"
}
joined += text
first.Content = joined
first.StringContent = joined
case "user":
m.Role = "user"
m.Content = text
m.StringContent = text
out = append(out, m)
}
}
return out
}
// mergeToolCallDeltas merges streaming tool call deltas into complete tool calls.
// In SSE streaming, a single tool call arrives as multiple chunks sharing the same Index:
// the first chunk carries the ID, Type, and Name; subsequent chunks append to Arguments.
@@ -182,6 +241,7 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
// Drop blank system turns from the web UI (and similar clients) so they
// cannot suppress the model YAML system_prompt / tokenizer defaults.
input.Messages = stripEmptySystemMessages(input.Messages)
input.Messages = normalizeLateSystemMessages(input.Messages, config.TemplateConfig.SystemMessagesAfterFirst)
// Tokenizer-template models pass messages through to the backend as-is,
// so apply the configured system_prompt when the request did not supply
+44
View File
@@ -378,6 +378,50 @@ var _ = Describe("system message helpers", func() {
})
})
Describe("normalizeLateSystemMessages", func() {
msgs := func() []schema.Message {
return []schema.Message{
{Role: "system", Content: "lead", StringContent: "lead"},
{Role: "user", Content: "q", StringContent: "q"},
{Role: "assistant", Content: "a", StringContent: "a"},
{Role: "system", Content: "late", StringContent: "late"},
{Role: "user", Content: "q2", StringContent: "q2"},
}
}
It("leaves messages untouched by default", func() {
out := normalizeLateSystemMessages(msgs(), "")
Expect(out).To(HaveLen(5))
Expect(out[3].Role).To(Equal("system"))
})
It("merge folds late system turns into the leading one", func() {
out := normalizeLateSystemMessages(msgs(), "merge")
Expect(out).To(HaveLen(4))
Expect(out[0].Role).To(Equal("system"))
Expect(out[0].StringContent).To(Equal("lead\n\nlate"))
for _, m := range out[1:] {
Expect(m.Role).NotTo(Equal("system"))
}
})
It("merge creates a leading system message when none exists", func() {
in := msgs()[1:]
out := normalizeLateSystemMessages(in, "merge")
Expect(out[0].Role).To(Equal("system"))
Expect(out[0].StringContent).To(Equal("late"))
Expect(out).To(HaveLen(4))
})
It("user forwards late system turns as user turns in place", func() {
out := normalizeLateSystemMessages(msgs(), "user")
Expect(out).To(HaveLen(5))
Expect(out[3].Role).To(Equal("user"))
Expect(out[3].StringContent).To(Equal("late"))
Expect(out[0].Role).To(Equal("system"))
})
It("does nothing when no late system turn exists", func() {
in := msgs()[:3]
Expect(normalizeLateSystemMessages(in, "user")).To(HaveLen(3))
})
})
Describe("stripEmptySystemMessages", func() {
It("removes blank system turns and keeps the rest", func() {
in := []schema.Message{
@@ -674,6 +674,7 @@ Templates use Go templates with [Sprig functions](http://masterminds.github.io/s
| `template.multimodal` | string | Template for multimodal interactions |
| `template.reply_prefix` | string | Prefix to add to model replies |
| `template.use_tokenizer_template` | bool | Use tokenizer's built-in template (vLLM/transformers) |
| `template.system_messages_after_first` | string | What to do with `system`-role messages that appear after the leading system block: `merge` folds them into the first system message, `user` forwards them as user-role turns at their position. Unset keeps them as-is. Needed for tokenizer templates that reject late system turns (e.g. Qwen3.8) while agent frameworks append instructions mid-conversation. |
| `template.join_chat_messages_by_character` | string | Character to join chat messages (default: `\n`) |
### Template Variables