Repository navigation
server: replies written as messages to recipients split into reasoning, answer and tool calls - #1336
ajensenwaud wants to merge 5 commits into
Conversation
ashhart
left a comment
There was a problem hiding this comment.
Thanks @ajensenwaud for splitting this out. Rebased onto current main, it conflicts with #1299's cut tool calls in chat.zig; we kept main's token_ids and length cut condition and added or srv.recipients in both places (please confirm). Other families look unaffected: everything is gated on srv.recipients, which no template on main enables, and the Harmony test and goldens pass. Before landing:
- A call cut by
max_tokensbefore<atem:invoke name="…">completes falls back to the raw body, socontentshows<atem:function_…markup, whole and streamed (recipient_reply.zigaround line 109). That's the class #1298/#1299 fixed for GLM. An unclosed call shouldn't fall back to markup; please add cut tests mirroring #1299's. recipient_reply.zig(around line 87) assumes the prompt ends at<|start|>assistant. If the generation prompt already opensto=self<|message|>(as the test's own fake template does), the reasoning is silently dropped. Please handle both, or show the real template only produces the first.- Without tools in the request, call messages are rewritten as
<tool_call>XML incontent, which is markup the model never wrote. chat.zig(around line 242) turns thinking on for recipient models even when the request asks for thinking off.- No family on main produces this format yet, so this would land with (or just before) the Muse Spark family.
|
Fixing this. |
403ddb3 to
8644068
Compare
|
Done, fixes are in a new commit (your rebase resolution is right):
|
|
Review against main 03c2820. This looks right: detection needs the four markers as single tokens plus a The one thing holding it: no family on main emits this format until Muse Spark lands (#1295), so there's nothing to device-check yet. We'll land it with #1295 (or with whichever first family uses it) after a served chat and tool call, whole and streamed, including a call cut by |
…g, answer and tool calls A tokenizer with <|start|>, <|message|>, <|eom|> and <|eot|> as single tokens and a chat template that writes to=self messages (Muse Spark Open's) replies as messages to recipients. self is reasoning_content; user, commentary and no recipient are content; any other recipient is a tool call whose <atem:function_calls> block becomes the <tool_call><function=...> markup tool_parse and tool_stream already read, so its arguments take the tools' schema types. While a reply streams, an unfinished header, a call message that has not closed and a partial marker are held back, so every split extends the last and the finished split extends them all; the finished reply feeds the call streamer once more, so a last call ended by the end token streams too. Harmony's tokenizers have no <|eom|> or <|eot|>, so gpt-oss replies keep their channel split; tests cover both formats whole and streamed.
…ompt's open message read, thinking off kept off A call message max_tokens cuts stays open as far as it was written, named by its header until its invoke names the tool, so tool_stream reads it as the reply's cut call, whole and streamed, as ashhart#1299 made a GLM call; content never shows its markup. A stop string inside a call keeps the call's text, as the other formats keep theirs. The reply goes on from the prompt's last unclosed <|start|> header, so a generation prompt that already opened to=self<|message|> keeps the reasoning after it; Muse Spark Open's template ends at <|start|>assistant, with or without reasoning_effort. Only calls to offered tools become <tool_call> markup: with no tools, or a tool not offered, a call message stays as the model wrote it, as DSML, LFM2 and Qwen's XML calls do. Thinking off no longer turns the split's reasoning on: self messages stay out of the reply, and the template, which has no thinking switch, hears its lowest reasoning effort rather than its default (xhigh). Raw completion prompts keep the model's text. Tests cut a call before its invoke names the tool, inside a value, a value's closer, a parameter's name, between parameters, after the invoke closed, and in a call's header, at strides 1, 3, 7 and whole; and cover an opened prompt, no tools, an unoffered tool and thinking off.
8644068 to
558f5a8
Compare
…e the last call; a reply with no markup streams as it comes
…espace back, as the finished reply strips it
|
Both of your notes are fixed in be4286a. A streamed reply now reads only the text since the last token instead of the whole reply again, and a reply with no markup streams as it comes. While streaming, a message's trailing whitespace is held back, so the streamed text ends up the same as the whole reply. The new tests check that a streamed read matches a fresh read at every length. Served check on Muse (built from #1295's head, which carries this), temperature 0, each case whole and streamed, 6/6 pass:
In every case the streamed content, reasoning and calls match the whole reply. |
|
Thanks @ajensenwaud. Both notes are fixed, and the tests that compare a streamed read with a fresh read at every Nothing else holds it. We'll land it with #1295 once that's ready, after repeating your six served Muse cases on our |
With main's loop-guard stop, chat.zig plus the recipient-reply hook reached 603 lines, past lean's 600. flag (Python's bool(fields.get(key))) is a fields helper, so it moves there unchanged; chat.zig calls fields_mod.flag.
|
One commit on top, |
|
Thanks @ajensenwaud. Moving One small thing: the description still gives the first push's numbers ( Nothing else holds this PR. It lands with #1295, after we repeat your six served Muse cases on our GB10 pair: chat and a tool call, with thinking on and off, and the call cut by |
|
Thanks @ashhart. The description is refreshed to match the branch: |
What this changes
Muse Spark Open's chat template has the model reply as a series of messages, each addressed to a recipient:
to=self<|message|>…<|eom|><|start|>assistant to=user<|message|>…<|eot|>. Tool calls are messages addressed to thetool, and their bodies are
<atem:function_calls>blocks. The server on main does not recognise this format, so thewhole reply, markers included, ends up in
content. This PR addszig/src/server/recipient_reply.zig(270 lines):detect). The format is used when<|start|>,<|message|>,<|eom|>and<|eot|>are each asingle token and the chat template contains
to=self. This is set once inServer.initassrv.recipients,alongside the existing Harmony and Gemma channel-marker check. There is no hook per model family.
split):selfgoes toreasoning_content.user,commentaryand a missing recipient go tocontent.<atem:invoke>/<atem:parameter>block is rewritten as theQwen3-Coder
<tool_call><function=…><parameter=…>markup thattool_parseandtool_streamalready read, sothe OpenAI
tool_callsarguments get the tools' schema types (an integer parameter comes out as3, not"3").esc_attr) are undone. Parameter values are kept byte for byte, as the templatewrites them.
closed yet, and a trailing partial marker. As a result, each streamed split extends the previous one, and the
finished split extends them all. Leading whitespace is stripped from each piece in both modes, so this prefix
property holds.
Reading.of). Each chat reads its reply with the request's tools and thinking switch, andwith the message its prompt left open (a prompt that opened the
selfmessage keeps the reasoning that follows).A streamed read goes on from the last call's bytes, so streaming costs no rescans; it equals a fresh read at every
length.
End): still streaming, the model's end token, a stop string, ormax_tokens. A call thatmax_tokenscuts is the reply's cut tool call, whole and streamed, as GLM's cut call is; the one-call policy hides it.
selfmessages stay out of the reply, and the template, which has no switch, hears itslowest effort (
Server.off_effort,minimalcoerced to the template's levels) instead of its default.chat.zig(596 lines merged with main15a0b2a9):Reading; the streamed and finished splits use it.Generation.thinkingis unchanged, so logprobs and reasoning counts follow the request's switch.ended by
<|eot|>(the end token, which is stripped) would never stream, even though earlier calls ended by<|eom|>did.flagreader moves unchanged tofields.zig, keepingchat.zigunder 600 lines besidemain's loop-guard stop.
gpt-oss's Harmony tokenizer also uses
<|start|>and<|message|>, but it has no<|eom|>or<|eot|>, and itstemplate never writes
to=self. Sosrv.recipientsstays off and Harmony replies still go throughparseHarmony.The old gated engine's
0023chat-rule hook is not carried over: detection uses the markers and the template instead.This replaces the recipient half of the old patch
0047. Thegeneration_config.jsonEOS half is already on main.Receipt
up/main15a0b2a9, Zig 0.17.0, x86_64 Linux host (no GPU needed). Served on themuse_sparkfamily (Muse Spark Open on CUDA: EXL3, DFlash2, two GPUs #1295) at TP=2 on two GB10s: one chat and one tool call against the real checkpoint parse intoreasoning_content,contentandtool_calls(get_weatherwith typed arguments).processing.
zig/src/server/recipient_reply_test.zig, 431 lines, 10 tests, andrecipient_cut_test.zig, 69 lines,3 tests, both registered in
server/root.zig):to=selftemplate. It is off for a Harmony tokenizer and template.both
<tool_call>blocks. Every streamed prefix only grows, never shows<|oratem, holds the open callback, and is a prefix of the finished split. A reply with no markup is returned plain.
calls+tool_parse.parsegives{"city":"Rio de Janeiro","days":4}against a schema with an integerdays. A name escape (a&b) is undone.openai.runwith a byte tokenizer whose markers are single tokens:reasoning_contentandcontent.content: "Checking both.", two calls and identicalschema-typed arguments, whole and streamed. The streamed case checks that the last call, ended by the end
token, is streamed.
<|channel|>analysis/final), whole and streamed, still givescontent: "Blue sky."andreasoning_content: "Think.".selfmessage keeps the reasoning that follows it; a call to a tool not offered staysas the model wrote it; thinking off keeps
selfout of the reply and the template at its lowest effort; astreamed read equals a fresh read at every length; a reply with no markup streams as it comes.
recipient_cut_test.zig: a callmax_tokenscuts is the reply's tool call, cut, whole and streamed at everystride; a call cut before its invoke names the tool is the header's call; the one-call policy hides it.
contentand there are no calls. The Harmony testpasses before and after, as a regression guard.
python3 tools/zig/lean_check.pyreports 428 problems onup/main15a0b2a9and 428 merged with thisbranch, so there are 0 new findings.
git diff --check: clean.15a0b2a9:zig build test-server-cpu881/881 passed (the 13 new tests included), andtest-goldenpasses.zig fmt --checkis clean.max_tokens, each wholeand streamed) on the
muse_sparkfamily (Muse Spark Open on CUDA: EXL3, DFlash2, two GPUs #1295) at TP=2 on two GB10s, onbe4286a2(the last commit only movesflag).Checklist
"draft": false, a resumed prompt equals a fresh one, and concurrent equals solo. (Notoken path change.)