Skip to content

feat(protocols)!: accept the root chat message role - #414

Open
ayerofieiev-tt wants to merge 2 commits into
ai-dynamo:mainfrom
ayerofieiev-tt:feat/protocols-root-role
Open

ayerofieiev-tt wants to merge 2 commits into
ai-dynamo:mainfrom
ayerofieiev-tt:feat/protocols-root-role

Conversation

@ayerofieiev-tt

Copy link
Copy Markdown
Contributor

Why

MiniMax-M3 adds a root message role: a system-prompt channel that ranks above system. Its chat template expects messages[0].role == "root", renders it as the system prompt, and demotes a following system/developer turn to a lower-priority developer prompt (chat_template.jinja L99-L114). MiniMax's provider verifier requires endpoints to accept it (TestRoleRoot, m3_text_tests.py L953-L1117: test_11_01_role_root_accepted L1050, test_11_02_root_overrides_system L1070, test_11_04_only_root_identity L1104).

Today any such request fails during deserialization with HTTP 400:

unknown variant `root`, expected one of `developer`, `system`, `user`, `assistant`, `tool`, `function`

Mapping root onto System would not help: the template only sees the role string, so root must reach it unchanged. This is the same situation as developer, which is a first-class variant re-serialized as "developer".

What changed

  • ChatCompletionRequestMessage::Root(ChatCompletionRequestSystemMessage), serialized with "role": "root". It reuses the system message payload, so consumers that treat root like system can write System(m) | Root(m).
  • The hand-written Wire deserializer accepts root, with the same field guards as developer: Kimi tools and partial are rejected on root.
  • No From impl: the payload type already converts into System, and a second impl would conflict. Construct it with ChatCompletionRequestMessage::Root(...).
  • No renderer change was needed. The HF template path serializes the typed messages as they are, so root reaches Jinja unchanged. The model-specific native renderers (DeepSeek, Kimi K3, Inkling) are left as they are because their reference encoders have no root role.

Semver: adding a variant to this exhaustive public enum is a breaking change, hence !. As with #375, dynamo-parsers and dynamo-renderer will need a release against the new protocols major.

Tests

  • root_message_round_trips_with_its_role: string and text-part content deserialize to Root and serialize back to the same JSON with "role": "root".
  • request_keeps_root_ahead_of_system: a full request with root + system + user, including the verifier's thinking / reasoning_split extras, keeps the order and roles when serialized back.
  • message_rejects_unknown_role: an unknown role is still rejected, and the error lists root.
  • message_rejects_tools_and_partial_on_wrong_roles: adds root cases.
  • protocol_schema::custom_deserializers_produce_schema_valid_canonical_messages: the canonical root message validates against the exported schema.
  • renderer::template::oai::root_role_reaches_the_template_verbatim: an excerpt of M3's role-mapping logic renders root as the system prompt and system as the developer prompt.
  • Manual check, not committed: rendering root + system + user through the full MiniMax-M3 chat_template.jinja puts the root content in the system block and the system content in the developer block.
cargo fmt --all -- --check
cargo test --workspace --all-targets --locked
cargo test -p dynamo-protocols --features protocol-schema --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings

Dynamo follow-up

Dynamo pins dynamo-protocols = "=8.1.0". After the bump, this exhaustive match stops compiling:

  • lib/llm/src/protocols/common/input_trigger.rs:41-49 (classify_create_chat_completion_request): add Root(_) to the System(_) | Developer(_) => InputTrigger::Other arm.

Every other ChatCompletionRequestMessage match in lib/llm has a wildcard arm or uses matches!. Root cannot carry tools, so preprocessor.rs:3400, which detects dynamic tools on system messages, is still correct.

🤖 Generated with Claude Code

MiniMax-M3's chat template reads messages[0].role == "root" as its
top-priority system prompt, so the role has to reach the template
verbatim; requests carrying it were rejected as an unknown variant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Artem Yerofieiev <ayerofieiev@tenstorrent.com>
@copy-pr-bot

copy-pr-bot Bot commented Oct 15, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@ayerofieiev-tt
ayerofieiev-tt marked this pull request as draft October 15, 2026 02:31
Comment thread protocols/src/types/chat.rs Resolved
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Artem Yerofieiev <ayerofieiev@tenstorrent.com>
@ayerofieiev-tt

Copy link
Copy Markdown
Contributor Author

@GuanLuo @keivenchang let me know if this approach does not work. It would be helpful to get this reviewed soon. Thank you!

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.

@luckyq luckyq left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Summary: Fixes MiniMax-M3 requests whose first message carries "role": "root" being rejected at parse time with unknown variant 'root', expected one of developer, system, user, assistant, tool, function (HTTP 400), which makes M3 deployments fail MiniMax-Provider-Verifier tests 11_01 / 11_02 / 11_04. It adds a Root(ChatCompletionRequestSystemMessage) variant to ChatCompletionRequestMessage plus a matching Wire::Root arm with the same tools / partial guards as developer, so the literal root role reaches the Jinja template and the model keeps its root-over-system priority. No blockers.

Problem / current state on main

  • protocols/src/types/chat.rs — ChatCompletionRequestMessage is a closed serde-tagged enum with six variants; the hand-written Deserialize mirrors them in Wire, so any other role string is a hard error before the renderer runs.
  • Remapping root to System would not be enough: the HF renderer serializes typed messages back to JSON and the M3 template branches on the literal role string (messages[0].role == "root" → system block; a following system/developer turn → demoted to the developer block).

What the PR changes

  • New public variant Root (serialized "role": "root", reuses the system payload type) and the Wire::Root deserializer arm rejecting tools and partial.
  • Tests: round-trip, unknown-role rejection, forbidden-field cases, schema validity under protocol-schema, renderer test showing root reaches a Jinja template verbatim.
  • No renderer or parser logic changes needed; they operate on JSON role strings.

Non-blocking

  • Semver: new variant in an exhaustive public enum, so the ! is correct. dynamo-parsers and dynamo-renderer need a release against the new protocols major, and ai-dynamo/dynamo must add a Root(_) arm to the exhaustive match in lib/llm/src/protocols/common/input_trigger.rs when it moves off the dynamo-protocols = "=8.1.0" pin. Worth stating in the PR description so the downstream bump is not missed.

Non-blocking (housekeeping before merge)

  • The bot thread from 2026-10-21 (Root reuses the system payload, so a tools field serializes but will not deserialize) is still unanswered; a one-line reply or a note in the description would close it.
  • The PR description carries an AI-assistant attribution footer and the commits carry AI co-author trailers; repo convention is to strip both.
  • No Rust CI has run on this fork yet (pull-request/414 mirror branch does not exist); a maintainer needs to issue /ok to test before merge, and ai-dynamo/dynamo needs a Root(_) arm in input_trigger.rs when it bumps the protocols pin.

Checked

  • Enum and custom Deserialize at PR head.
  • Every ChatCompletionRequestMessage:: match in protocols, renderer and parsers: none exhaustive outside chat.rs; the dynamic_system_tools wildcard is correct for Root.
  • Renderer normalize_system_messages against the actual MiniMax-M3 chat_template.jinja: the template never raises on a non-leading system message, so the demote path cannot fire for M3.
  • Schema snapshots: none committed, nothing to regenerate.
  • Clean merge onto current main; CI state and existing review threads.

@luckyq

luckyq commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

/ok to test 08e8c43

@luckyq

luckyq commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

Two things to clear before this can merge:

  • The rust job failed on this head for an environment reason, not your change: the branch's ci.yml still uses runs-on: ubuntu-latest, whose image now ships Python 3.14, and pyo3-ffi 0.22.6 (built for parsers/v2-py) supports at most 3.13 (run 40938965874). main switched to ubuntu-24.04 in ci: pin GitHub-hosted runners from ubuntu-latest to ubuntu-24.04 #444. Please merge or rebase onto current main and I'll re-run /ok to test.
  • release / preview will reject the head as long as the title carries ! without an explicit major: release-plz would bump dynamo-protocols to the next major, and the repo requires that to be set in the PR ("Reject generated major versions", same as fix(protocols)!: retain token IDs on logprob candidates #334). The ! is correct here (new variant in the exhaustive public ChatCompletionRequestMessage enum), so keep it and set the major explicitly in protocols/Cargo.toml plus any workspace pins. fix(protocols)!: retain token IDs on logprob candidates #334 is doing the same for the same crate; whichever lands second just reuses the new major after rebasing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants