mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
[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:
1 parent
752ee66506
commit
109244a76a
6 files changed
+132
No files matched your search
@@ -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"},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user