fix(server): keep assistant text when a delta also carries tool calls - #690
waybarrios merged 3 commits into
Conversation
8ac106f to
bbe9488
Compare
Thump604
left a comment
There was a problem hiding this comment.
Good catch and the direction is right, but the tests do not cover the server paths changed here. They only call _parse_streaming_tool_content, so the Responses path and both OpenAI branches can regress while this stays green. Please add real streaming regressions for Responses, Anthropic, and OpenAI with and without reasoning. Assert text exactly once, tool calls exactly once, text before the call, and the terminal finish/usage event. This shared path needs integration coverage in the same PR.
|
Fair, and the gap was exactly as you describe: I changed three call sites and tested one helper. Added Assertions per path: text exactly once, tool calls exactly once, text before the call, and the terminal event ( Each path is mutation-checked independently, which is the part that matters given your point:
So none of the three can regress silently now. Two things worth noting from writing them, since both would have made the tests lie:
Repo suite: 2304 passed, lint and black clean under the exact CI invocation. |
bbe9488 to
add8d39
Compare
Thump604
left a comment
There was a problem hiding this comment.
Better coverage, but one real path is still broken. With a Responses reasoning parser active, _stream_responses_request emits delta_msg.content and immediately continues, so the tool parser is never called. I reproduced the tool marker leaking as output text with zero function-call events and a normal response.completed. Please route reasoning content through the tool parser and add that integration case. Also tighten the committed assertions to match the stated coverage: Anthropic and Responses should assert the call once and after the text; reasoning OpenAI should assert the call and terminal event too. Keeping changes requested.
|
You found a worse bug than the one this PR started from, and my tests could not have caught it — the Responses reasoning case was the one combination I did not cover. Confirmed: Fixed by routing the reasoning content through the same gate the non-reasoning branch below already uses — the markup check, Assertions tightened as asked, and two of them needed a different shape than I first wrote, which is worth recording:
Reasoning-OpenAI now asserts the call once and the terminal Five integration cases, each mutation-checked against its own path. Repo suite: 2305 passed, lint and black clean under the exact CI invocation. |
add8d39 to
c5ec0b1
Compare
Thump604
left a comment
There was a problem hiding this comment.
Re-reviewed the updated head. The Responses reasoning path now routes content through the tool parser, and the committed integration coverage exercises OpenAI (with and without reasoning), Anthropic, and Responses with text/call ordering, exactly-once assertions, and terminal events. Focused local run: 11 passed. CI is green. No remaining blocker.
| # Emit it as its own chunk first — dropping it with | ||
| # the `continue` below loses assistant text the | ||
| # non-streaming path returns. | ||
| leading = tool_result.get("content", "") |
There was a problem hiding this comment.
Nice direction, but content is not always text from before the call. The real Poolside streaming parser keeps parsing after </tool_call> and puts text from both sides into the same field.
For Before<tool_call>write_file</tool_call>After, it returns content="BeforeAfter" plus the call. This branch then emits BeforeAfter before the call, while the non-streaming parser returns only Before. That leaks and reorders After only in streaming responses.
Could we either make Poolside return only pre-call content when calls are present, matching non-streaming behavior, or extend the parser result with ordered segments? Please also add an endpoint regression using the real PoolsideV1ToolParser for this input and assert that the reconstructed text matches non-streaming output and the call appears once.
| @@ -0,0 +1,375 @@ | |||
| # SPDX-License-Identifier: Apache-2.0 | |||
There was a problem hiding this comment.
These tests currently are not executed by CI. Every pytest command in the workflow uses an explicit file list, and neither new file is included. Direct collection finds 11 tests, while the required jobs collect none of them.
There is a small trap if we add this file to the existing Apple command: -k "not Integration" matches the filename and deselects all five endpoint tests. A separate Apple Silicon invocation would be the smallest fix:
pytest \
tests/test_streaming_content_with_tool_calls.py \
tests/test_streaming_content_with_tool_calls_integration.py \
-v --tb=short|
Heads up: this one conflicts after #679/#684 landed, and it needs more than a rebase — I have the resolution locally but I am not pushing it, because it regresses. The single if "tool_calls" in tool_result:
content = tool_result.get("content", "")
if not content:
continuewhich is equivalent to this branch's formulation, so I took yours and the diff there is zero. The problem is elsewhere. Since this PR was opened, if tool_result is None and output.finished:
tool_result = _finalize_streaming_tool_result(tool_parser, tool_accumulated_text)This branch routes reasoning-branch content through the tool parser and, on the "no markup possible" path, appends it to So the two changes overlap semantically rather than textually, and reconciling them is a design question about who owns the buffer: either the reasoning branch stops accumulating what it has already emitted, or finalize learns what was already sent. Both are plausible, and both are easy to get subtly wrong in a streaming path whose failure mode is silently duplicated or dropped user-visible text. Do you have a preference? If the finalize path is meant to be the single owner of end-of-stream flushing, I will restructure this branch around that and re-push. I would rather ask than hand you a regression that shows up on one parser family only. |
Thump604
left a comment
There was a problem hiding this comment.
Blocking correctness issue remains on the current head, independent of the stale conflict:
The current PoolsideV1ToolParser.extract_tool_calls() returns non-streaming content only from before <tool_call> (vllm_mlx/tool_parsers/poolside_v1_tool_parser.py:126-134). Its streaming state machine consumes the closing tag and then runs _consume_text_before_tool() again (:200-210 and :303-310), so a single delta such as Before<tool_call>... </tool_call>After can return content="BeforeAfter" alongside the call. The PR's OpenAI branch emits that content before the call, while the non-streaming path returns only Before; the trailing text is reordered and the two API modes disagree. The added tests use a canned parser result and do not exercise the real Poolside parser or this post-call case.
Please rebase on current main and make the contract explicit: either discard post-call text consistently or preserve it as ordered segments in both streaming and non-streaming responses. Add a real Poolside endpoint regression covering text before/inside/after the call, exactly-once ordering, reasoning and non-reasoning paths, and terminal events. Also resolve the current-main finalization interaction (_finalize_streaming_tool_result) as a buffer-ownership decision rather than a textual conflict resolution.
The new tests are not currently in CI: .github/workflows/ci.yml uses explicit pytest file lists and omits both tests/test_streaming_content_with_tool_calls.py and tests/test_streaming_content_with_tool_calls_integration.py; the Apple command also filters out Integration. Add a CI invocation that actually collects them, then rerun the current checks and resolve the two outstanding inline discussions. My earlier approval was for the old head and is not evidence for this conflicting state.
c5ec0b1 to
8d51a3a
Compare
|
Rebased onto current What the conflict actually was. Between my branch and content=tool_result.get("content") or None,My branch emits that text as its own chunk before the tool-call chunk. Git merged both cleanly — no conflict marker — so the naive rebase emitted the same text twice. That is what I was seeing and did not want to push blind. Resolution. Text is emitted once, ahead of the call, and the tool-call chunk no longer repeats it: tool_calls=tool_result["tool_calls"],
# `leading` above already emitted this text as its own chunk,
# so repeating it here would double it.
content=None,I also took # main, equivalent to what this PR did:
if "tool_calls" in tool_result:
content = tool_result.get("content", "")
if not content:
continueSo the surviving change is smaller than before: the Responses reasoning branch (which still had Verification. Against clean With the fix: Wider regression run over the streaming, tool, responses and chat suites:
One caveat I want to be straight about: this is unit-level verification only. I could not run it against a live server for this revision — the box is memory-committed to another workload — so the ordering guarantee (text chunk strictly before the tool-call chunk) is asserted by the integration test's chunk-index check rather than observed on a real stream. @waybarrios this is ready for another look whenever you have time — the |
|
Thanks, Jan. I rechecked Two existing review items still need closure:
This follow-up is based on source and CI-configuration inspection; I have not run a new live-server test. I am not asking you to repeat the broad suite or produce another model benchmark. My remaining requests are the real-parser regression/contract disposition and wiring those tests into CI. |
8d51a3a to
4531162
Compare
|
Both items addressed in 1. Poolside prefix-only parityYou were right that this was the substantive one, and my first attempt at it was wrong in an instructive way, so here is what landed and what did not. What I tried first: recompute the text from the raw accumulated deltas, cutting at the earliest tool marker. That reproduces the non-streaming rule directly, and it passed the new parity test — but it broke Two defects in one: the prefix covers text the client has already received as ordinary content deltas, so it gets re-sent; and on a marker split across deltas the cut lands mid-marker and leaks the What landed instead keeps the parser's per-delta content (which is by construction the not-yet-emitted text) and removes only the trailing part, and only when it matches the raw text following the call: def _text_after_tool_call(parser, accumulated_text: str) -> str:
end_marker = getattr(parser, "_END", None)
if not isinstance(end_marker, str) or not end_marker:
return ""
tail = accumulated_text.rfind(end_marker)
return "" if tail < 0 else accumulated_text[tail + len(end_marker) :]Parsers that do not declare 2. Real-parser regression, not the canned one
Mutation-checked, because a regression test that cannot fail is not one. With the strip removed: which is your There is also a sibling test asserting the non-streaming contract straight from the parser, so if that behaviour ever changes deliberately, the failure says which side moved. One fixture correction fell out of this. The canned tests fed 3. CI wiringAdded a separate Apple Silicon step, without - name: Run streaming text-with-tool-calls regression tests
run: |
# Kept out of the list above because that invocation filters with
# -k "not Integration", which would deselect the endpoint file by
# name and silently skip every regression in it.
pytest \
tests/test_streaming_content_with_tool_calls.py \
tests/test_streaming_content_with_tool_calls_integration.py \
-v --tb=short \
-m "not slow"Ran that exact invocation locally: 13 passed. Verification
As before, this is unit-level only; I have not run a live server for this revision. |
|
Thanks, Jan. I checked One part of the existing Poolside parity request remains: the new real-parser endpoint test supplies the whole response in one delta. With the same payload split as:
the source path still emits the second delta as ordinary content: Poolside returns a content-only result, so it bypasses the helper inside the Could you parameterize the existing real-Poolside endpoint test with that split and preserve its prefix-only contract across deltas, while retaining the DeepSeek split-marker regression? That is the only remaining request from me. No new benchmark or parser redesign; the separately linked Poolside prerequisite remains an option as discussed. |
4531162 to
3755250
Compare
Every streaming path read `tool_calls in result` as "suppress everything", so a
parser that had buffered prose and then saw the whole tool-call block arrive in
one delta lost that prose. It has nowhere else to put it: the block is
complete, so there is no later delta to flush into, and the non-streaming path
returns the same text without complaint. The assistant message loses text based
on nothing but how the model's output happened to be chunked.
Three call sites, three spellings of the same assumption:
- `_parse_streaming_tool_content` (Anthropic paths) returned
`suppress = "tool_calls" in result`, and both callers `continue` on it —
even though they already do `tool_result.get("content", "")` right after.
- The Responses path did `if "tool_calls" in tool_result: continue` before
reading content at all.
- The OpenAI path emitted the tool-call chunk and then `continue`d, dropping
any content in the same result.
All three now emit the text. The OpenAI path sends it as its own chunk ahead of
the tool-call chunk, which is the shape the API expects; the others fall
through to the content handling they already had. Suppression is unchanged when
there is nothing to show — tool calls alone, empty content alongside calls, or
a None result meaning "inside markup, keep holding".
Found on the DeepSeek-V4 DSML parser, whose block can open and close in a
single delta, but nothing here is specific to it: any parser that buffers
across a block hits this whenever the block arrives whole.
Repo suite: 2300 passed; the 498 tool-parser tests are unaffected. Confirmed by
mutation — restoring `suppress = "tool_calls" in result` fails the
text-alongside-calls test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Good catch — that gap is real and my helper could not have covered it, because it lives inside the What was wrongYour trace is right. With the payload split so that the call completes in delta 1 and The fixSuppress ordinary content once a call has already been emitted, for parsers whose non-streaming contract keeps only the prefix: # Normal content from tool parser
content = tool_result.get("content", "")
if (
content
and tool_calls_detected
and _parser_drops_text_after_tool_call(tool_parser)
):
content = ""with the capability scoped rather than applied globally: def _parser_drops_text_after_tool_call(parser) -> bool:
"""Whether this parser's non-streaming contract discards post-call text."""
return isinstance(getattr(parser, "_END", None), str)I checked which parsers that selects before writing it — TestParameterised as you asked, three shapes over the same payload:
All three assert against Mutation-checked. With the suppression disabled, the two new shapes fail and the original still passes: so they cover the delta-boundary behaviour specifically, not just the end state. Verification
Rebased onto current |
3755250 to
b34bf8c
Compare
Thump604
left a comment
There was a problem hiding this comment.
Rechecked exact head b34bf8c. The new real-Poolside parameterization covers the original one-delta case plus both requested post-call multi-delta shapes, and it binds streamed text to the parser’s non-streaming prefix-only result. The suppression remains parser-scoped, preserving the existing DeepSeek split-marker behavior. I also inspected Apple run 33904155771: all 15 mapped regression cases executed and passed with a normal pytest summary. This closes my remaining source and CI requests. I did not require another live model run for this parser/transport correction.
|
I addressed both streaming regressions found during review. OpenAI preserves whitespace when text accompanies tool calls. Responses consistently applies Poolside’s text-before-tool-call policy, with and without reasoning, including text arriving in later chunks. Added 12 regression cases using the real Poolside parsers. All reproduced the failures before the fix and now pass. Local validation passed 296 parser-related tests and 60 transport tests with simulated engines. |
Fixes the streaming half of what @Thump604 found on #676: "buffered streaming drops text before a tool call when the text and complete DSML block arrive in one delta; the parser returns only tool_calls."
The parser side lands in #676. This is the server side, and it is not DeepSeek-specific — any parser that buffers across a block hits it whenever the block arrives whole.
The bug
Every streaming path reads
tool_calls in resultas "suppress everything". A parser that has buffered prose and then sees the entire tool-call block in one delta has nowhere else to put that prose: the block is complete, so there is no later delta to flush into, and the non-streaming path returns the same text without complaint. The assistant message loses text based on nothing but how the model's output happened to be chunked.Three call sites, three spellings of the same assumption:
_parse_streaming_tool_content(Anthropic ×2)suppress = "tool_calls" in result, and both callerscontinueon it — despite already doingtool_result.get("content", "")on the next lineif "tool_calls" in tool_result: continuebefore reading content at allcontinue— dropping content in the same resultThe fix
All three emit the text. The OpenAI path sends it as its own chunk ahead of the tool-call chunk, which is the shape the API expects; the other two fall through to content handling they already had.
Suppression is unchanged when there is nothing to show: tool calls alone, empty content alongside calls, and a
Noneresult meaning "inside markup, keep holding".Verification
tests/test_streaming_content_with_tool_calls.pycovers all five result shapes plus that the accumulated text still advances. Mutation-checked: restoringsuppress = "tool_calls" in resultfails the text-alongside-calls test.Repo suite: 2300 passed, with the 498 tool-parser tests unaffected — that mattered here, since this touches shared code behind all 19 parsers. The four failures I see locally reproduce on pristine
main(three Python 3.14, one missingffmpeg).The end-to-end regression through a parser that actually produces both in one delta goes with the parser fix in #676, so it does not sit here permanently skipped.