Repository navigation
server: one-call streams send the whole reply's content - #2049
Conversation
|
The cancelled |
|
Thanks @devopsfortunato. Rechecking #1396 on current main first made this easy to review. The one-call fix reads right. Holding prose after a hidden envelope until the finish is a clean way to keep the stream equal to the whole reply. Requests without tools are untouched. One thing before it lands. |
9467a1d to
7484be9
Compare
|
Done in
One honest note, in case it changes what you'd like pinned: I measured the chunk sequence on upstream |
|
Thanks @devopsfortunato. Both asks are covered:
Thanks too for measuring main's own sequence. You're right that main already sends |
With parallel_tool_calls false, a streamed reply now carries the same content and finish reason as the whole reply in the cases ashhart#1396 lists that still reproduced on main: - item 1: a rejected whole envelope in any case or NAME: spelling (<foo:tool_call>, <TOOL_CALL>, <Tool_Call>) stayed in the whole reply's content but was dropped from the stream. The one-call text filter now holds the prose after a hidden closed envelope, and the finish sends the reply's own content past what streamed. - item 2: the stream's end-of-reply prose took the cut-call branch in one-call mode, which the whole reply never does, so a rejected envelope before a cut call and the cut prose's trailing newline were lost. endProse now uses finishedProse in one-call mode. - item 4 already agrees on main; it is kept as a regression case, and the trailing-newline variant that did differ in one-call mode is fixed by the finishedProse/kept change. tool_single_stream_test.zig drives every case whole and streamed, one byte a round and batched.
|
Thank you @devopsfortunato. This is merged into main with your commits under your own name, so you're listed as a TensorFold contributor. It ships in the next release, credited to you in the changelog and the release notes. |
What this changes
With
parallel_tool_calls: false, a streamed reply now sends the same content, arguments and finish reason as the whole reply in every case #1396 lists. The whole reply's behavior is unchanged.I rechecked all of #1396's open items on current
mainfirst, as asked in the issue. Item 3 was fixed in 1.1.7. Of what remained:Before.<foo:tool_call>{"name":"other",…}</foo:tool_call>Tail.as content, the stream sentBefore.Tail.— also for<TOOL_CALL>and<Tool_Call>;<tool_call>{invalid}</tool_call>already agreed. Cause: in one-call mode the streamed prose goes throughPolicy.text→TextFilter, which hides everycallTagenvelope (any case, anyNAME:prefix) and keeps emitting the text after it, while the whole reply keeps a rejected whole envelope in its content (Policy.parsedContent), andPolicy.keptat the finish can only append a suffix, so an interior envelope could never reach the stream. Fix: once the filter has hidden a closed envelope it holds all later prose (deferred), and the finish sends the reply's final content past what streamed.cutProse, which rstrips) in one-call mode, while the whole reply turns that branch off in one-call mode (openai.zig!policy.single). A rejected envelope before a cut call (<tool_call>{"name":"other",…}</tool_call>Between.then a cut GLM call) and the cut prose's trailing newline were lost. Fix:endProsetakes the single flag and usesfinishedProsein one-call mode.main; it is kept as a regression case. The trailing-newline variant of it did differ in one-call mode (whole content pyStripped by the parse, stream keeping the newline);finishedProsenow always rstrips andPolicy.keptdelivers any non-empty rest, so the finish sends exactly the final content's tail. Default-mode wire bytes are unchanged.Behavior change worth naming: in one-call mode, prose written after a hidden call envelope now arrives at the finish, as one content delta from the reply's final content, instead of streaming past a dropped envelope. Default mode is byte-identical to
main: both changed predicates (endProse's rstrip andPolicy.kept's whitespace-only rest) are gated on the one-call flag,finishedProseis untouched, and the unrelatedsharedreformat inchat.zigfrom the first revision was reverted for a smaller #1665 rebase. A new test pins the default-mode content chunk sequence for a tool-offering, call-free reply that ends with text plus a newline, in both modes and both engine batching shapes.Tokens, the engine and requests without tools are untouched.
maina6c5b97eb0b352eccdfa1703958bc068df0eeee8(one commit,9467a1db8). Independent of Streamed chat: GLM and DSML tool calls arrive in one piece, only XML calls stream their arguments #1975's GLM streaming work; the two only share the test-import line inzig/src/server/root.zig, a trivial rebase either way.atc overlap --json: 16 open pull requests share files; 10 merge clean, 6 reportunknown(pullFilesUnavailable). Of those, dsv41: serve structured output with the lane core's grammar masks #1665 (dsv41 structured output) is a real textual conflict inzig/src/server/chat.zig—git merge-treeconfirms it. Both changes edit the sameGenerationlines in a file at the 600-line limit, so this needs a small rebase against whichever lands first. The other five (2009, 1980, 1871, 1295, 1336) touchdocs/api.mdor unrelated regions and merge clean by inspection.Checks on this head
7484be91b(basea6c5b97eb0b)python3 tools/zig/lean_check.py, on the base and on this head: base 424 problems, head 424 problems,lean_new.py0 new..zig-cacheper run; these are the no-GPU CI steps from.github/workflows/no-gpu.yml):zig build test --test-timeout 10m→ 494/494 steps; 6567 passed, 48 skipped, 0 failedzig build test-kernel-contract→ 22/22 steps; 177/177zig build test-golden→ 5/5 steps, no golden file changedzig build test-server-security test-pull-security→ 14/14 steps; 28/28zig build fuzz-cortheon→ 3/3 steps; 19/19zig build test-cuda-host→ 293/293 steps; 2652/2655 (3 skipped)zig build(plain) → 31/31 stepszig build test-server-cpu→ 5/5 steps; 955/955python3 -m unittest discover -s tools/tests -p "test_bench*.py"→ 74 OK;tools/zigflashnext_batch_serve → 8 OK; build_options → 5 OK (2 skipped)zig fmt --check build.zig zig→ clean;git diff --check→ cleanunicodedataand this change doesn't touch the tables; GPU and model checks don't apply to a server-only parsing change.New tests:
zig/src/server/tool_single_stream_test.zigdrives every case whole vs streamed through the real chat path (openai.runwith a byte-token engine, batched and one character per round): items 1a–1d, 2a–2c, 4a/4b with and without a trailing newline in both modes, an accepted call whose prose streams before it, and the default-mode chunk-sequence pin (a tool-offering, call-free reply endingabc\n: per-tokena,b,c,\nand batchedabc,\n, identical in default and one-call mode, joined content equal to the whole reply's). 1a–1c, 2a–2c and the single-mode trailing-newline case were observed failing before this change; 1d and the no-newline 4a/4b are regression assertions only.Review: two rounds by GLM-5.3 on the first revision (one medium finding on the GLM PR, none here), plus the maintainer's review that led to this revision; a final GLM-5.3 round on this revision approved with no correctness findings.
Receipt
n/a — the change is in the HTTP server's one-call streaming filter and prose handling (
tool_stream.zig,chat.zig,reply_text.zig); it never reaches inference, so no exactness or speed receipt applies. Drafted output, resumed prompts and concurrency are unaffected for the same reason.Checklist