Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions protocols/src/types/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -978,6 +978,9 @@ pub struct ChatCompletionRequestAssistantMessage {
pub enum ChatCompletionRequestMessage {
#[cfg_attr(feature = "protocol-schema", schema(value_type = crate::schema::ChatCompletionRequestDeveloperMessage))]
Developer(ChatCompletionRequestDeveloperMessage),
/// MiniMax-M3's top-priority system prompt. Kept distinct from `System` so
/// chat templates see `role: "root"`; it cannot carry Kimi `tools`.
Root(ChatCompletionRequestSystemMessage),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing ChatCompletionRequestSystemMessage exposes its public tools field on Root, while the enum still derives Serialize. A caller can therefore construct Root with tools and serialize {"role":"root","tools":[...]}, but this deserializer rejects that same shape, so valid Rust values do not round-trip and the stated no-tools contract is unenforced on output. Give Root a payload that cannot contain tools, or reject such values during serialization.

🤖 AI Fix

Introduce a root-specific message payload without tools (preserving supported content/name fields) and use it for both serialization and deserialization.

System(ChatCompletionRequestSystemMessage),
User(ChatCompletionRequestUserMessage),
Assistant(ChatCompletionRequestAssistantMessage),
Expand Down Expand Up @@ -1020,6 +1023,7 @@ impl<'de> Deserialize<'de> for ChatCompletionRequestMessage {
#[serde(rename_all = "lowercase")]
enum Wire {
Developer(ForbidToolsAndPartial<ChatCompletionRequestDeveloperMessage>),
Root(ForbidToolsAndPartial<ChatCompletionRequestSystemMessage>),
System(ForbidPartial<ChatCompletionRequestSystemMessage>),
User(ForbidToolsAndPartial<ChatCompletionRequestUserMessage>),
Assistant(ForbidTools<ChatCompletionRequestAssistantMessage>),
Expand Down Expand Up @@ -1052,6 +1056,15 @@ impl<'de> Deserialize<'de> for ChatCompletionRequestMessage {
reject_forbidden::<D::Error>(partial, "partial", "assistant", "developer")?;
ChatCompletionRequestMessage::Developer(message)
}
Wire::Root(ForbidToolsAndPartial {
tools,
partial,
message,
}) => {
reject_forbidden::<D::Error>(tools, "tools", "system", "root")?;
reject_forbidden::<D::Error>(partial, "partial", "assistant", "root")?;
ChatCompletionRequestMessage::Root(message)
}
Wire::System(ForbidPartial { partial, message }) => {
reject_forbidden::<D::Error>(partial, "partial", "assistant", "system")?;
ChatCompletionRequestMessage::System(message)
Expand Down Expand Up @@ -2483,6 +2496,37 @@ mod tests {
}
}

#[test]
fn root_message_round_trips_with_its_role() {
for wire in [
serde_json::json!({"role": "root", "content": "You are a helpful assistant."}),
serde_json::json!({
"role": "root",
"content": [{"type": "text", "text": "You are a helpful assistant."}],
"name": "policy",
}),
] {
let parsed: ChatCompletionRequestMessage =
serde_json::from_value(wire.clone()).unwrap();
assert!(
matches!(parsed, ChatCompletionRequestMessage::Root(_)),
"{wire}"
);
assert_eq!(serde_json::to_value(&parsed).unwrap(), wire);
}
}

#[test]
fn message_rejects_unknown_role() {
let error = serde_json::from_value::<ChatCompletionRequestMessage>(
serde_json::json!({"role": "admin", "content": "hi"}),
)
.unwrap_err()
.to_string();
assert!(error.contains("unknown variant `admin`"), "{error}");
assert!(error.contains("root"), "{error}");
}

#[test]
fn message_rejects_tools_and_partial_on_wrong_roles() {
let tools = serde_json::json!([{"name": "lookup"}]);
Expand All @@ -2503,6 +2547,16 @@ mod tests {
serde_json::json!({"role": "developer", "content": "hi", "tools": tools}),
"`tools` is only accepted on system messages, not on role developer",
),
(
"tools on root",
serde_json::json!({"role": "root", "content": "hi", "tools": tools}),
"`tools` is only accepted on system messages, not on role root",
),
(
"partial on root",
serde_json::json!({"role": "root", "content": "hi", "partial": true}),
"`partial` is only accepted on assistant messages, not on role root",
),
(
"partial on user",
serde_json::json!({"role": "user", "content": "hi", "partial": true}),
Expand Down
1 change: 1 addition & 0 deletions protocols/tests/protocol_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ fn custom_deserializers_produce_schema_valid_canonical_messages() {
canonical::<CreateChatCompletionRequest>(json!({
"model":"test",
"messages":[
{"role":"root","content":"policy"},
{"role":"system","tools":[{"name":"lookup"}]},
{"role":"assistant","reasoning":"thinking","content":null,
"tool_calls":[{"id":"call1","function":{"name":"lookup","arguments":{"q":"hi"}}}]}
Expand Down
27 changes: 27 additions & 0 deletions renderer/src/template/oai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,33 @@ mod tests {
assert!(out.contains("<|im_start|>system\nmid-conversation reminder<|im_end|>"));
}

#[test]
fn root_role_reaches_the_template_verbatim() {
// MiniMax-M3's role mapping: root is the system prompt, a following
// system/developer turn becomes the developer prompt.
let f = formatter_for(concat!(
"{%- set ns = namespace(rest=messages, root=none, dev=none) -%}",
"{%- if ns.rest and ns.rest[0].role == 'root' -%}",
"{%- set ns.root = ns.rest[0].content -%}{%- set ns.rest = ns.rest[1:] -%}",
"{%- endif -%}",
"{%- if ns.rest and ns.rest[0].role in ['system', 'developer'] -%}",
"{%- set ns.dev = ns.rest[0].content -%}{%- set ns.rest = ns.rest[1:] -%}",
"{%- endif -%}",
"[system]{{ ns.root }}[developer]{{ ns.dev }}",
"{%- for m in ns.rest -%}[{{ m.role }}]{{ m.content }}{%- endfor -%}",
));
let out = render_shape(
&f,
json!([
{"role": "root", "content": "R"},
{"role": "system", "content": "S"},
{"role": "user", "content": "U"},
]),
)
.unwrap();
assert_eq!(out, "[system]R[developer]S[user]U");
}

#[test]
fn strict_leading_template_demotes_mid_system_but_keeps_user_turns_apart() {
let f = formatter_for(STRICT_LEADING_TMPL);
Expand Down
Loading