diff --git a/core/config/meta/constants.go b/core/config/meta/constants.go index 19eebcb50..cc1027f2b 100644 --- a/core/config/meta/constants.go +++ b/core/config/meta/constants.go @@ -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"}, +} diff --git a/core/config/meta/registry.go b/core/config/meta/registry.go index fbf40fa8b..44447a6d6 100644 --- a/core/config/meta/registry.go +++ b/core/config/meta/registry.go @@ -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 diff --git a/core/config/model_config.go b/core/config/model_config.go index 600519c7f..ecc234d8c 100644 --- a/core/config/model_config.go +++ b/core/config/model_config.go @@ -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"` diff --git a/core/http/endpoints/openai/chat.go b/core/http/endpoints/openai/chat.go index 72fe59b0e..a5b731d9e 100644 --- a/core/http/endpoints/openai/chat.go +++ b/core/http/endpoints/openai/chat.go @@ -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 diff --git a/core/http/endpoints/openai/chat_test.go b/core/http/endpoints/openai/chat_test.go index 0ef0991dd..6e8aa27f4 100644 --- a/core/http/endpoints/openai/chat_test.go +++ b/core/http/endpoints/openai/chat_test.go @@ -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{ diff --git a/docs/content/advanced/model-configuration.md b/docs/content/advanced/model-configuration.md index d629e1be8..44f388bdd 100644 --- a/docs/content/advanced/model-configuration.md +++ b/docs/content/advanced/model-configuration.md @@ -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