Skip to content

Enrich chat_parsing streaming events - #40478

Open
yonigozlan wants to merge 16 commits into
sgl-project:mainfrom
yonigozlan:feat/chat-parsing-events
Open

yonigozlan wants to merge 16 commits into
sgl-project:mainfrom
yonigozlan:feat/chat-parsing-events

Conversation

@yonigozlan

@yonigozlan yonigozlan commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Extend chat_parsing streaming events with just enough context for serving adapters, while keeping parsing results unchanged.

Context

Transformers response templates describe how generated text is split into structured message fields. The core parser can already parse those fields, but its original events are intentionally minimal.

A serving adapter needs a little more: where each delimiter sits in the parser input, the tool name captured by an opener before the region body exists, and a way to recover when one region fails to parse. Without that, every serving library has to reconstruct parser state on its own.

Event contract

Event Keys added by this PR Meaning
region_open start, end, captures Span of the opening delimiter in input_text (empty for implicit opens). Explicit opens also carry the opener's named groups.
region_chunk none text is raw region text. dirty=False only for untransformed text fields; whitespace trimming still happens at close.
region_close start, end Span of the closing delimiter (empty at implicit boundaries and end of stream), alongside the parsed value.
region_malformed new event Replaces region_close when a region's value fails to parse or transform. Carries start, end, and the original error.
  • ResponseParser.input_text exposes the raw input after start-anchor truncation. Before the first feed() it is exactly the truncated prefix, so its length marks where generated text begins.
  • Prompt bytes held back at the end of the prefix as a possible delimiter are not re-emitted as generated region_chunk text.
  • Named opener captures let an adapter resolve a tool name as soon as the opener matches, then wait for the close event before emitting parsed arguments.

Malformed output

  • Streaming: a region that fails to parse ends with region_malformed and parsing continues, so later content and valid calls still parse.
  • Non-streaming: parse_response() re-raises the original exception from the first malformed region, even when required fields are also missing.

Scope

The grammar, transforms, and tool-argument coercion are unchanged. This PR only enriches the event and recovery contract consumed by the adapters in the next layer.

Stacked on Port chat_parsing core.


CI States

Latest PR Test (Base): ❌ Run #37720990537
Latest PR Test (Extra): ❌ Run #37720990107
Latest PR Test (AMD ROCm 10): ❌ Run #37720990395

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file npu labels Sep 20, 2026
@yonigozlan
yonigozlan force-pushed the feat/chat-parsing-events branch 2 times, most recently from 43c3c83 to a4904b5 Compare September 20, 2026 16:30

@Rocketknight1 Rocketknight1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A lot of these changes seem useful! A couple of them change the spec, though, like strict for xml-inline, and I'm not sure if that's necessary or not. Also, in some cases there are probably more changes than necessary (I flagged a couple of examples, there may be more!)

Overall I like the integration, though, and if there are necessary changes then I'm willing to upstream them to transformers! But it'd be cool if we could make sure that the changes are 100% minimal and focused, rather than a big sprawling update with unnecessary changes!

Comment thread python/sglang/srt/parser/chat_parsing/response_parser.py Outdated
Comment thread python/sglang/srt/parser/chat_parsing/response_parser.py Outdated
Comment thread python/sglang/srt/parser/chat_parsing/response_parser.py Outdated
@yonigozlan
yonigozlan force-pushed the feat/chat-parsing-events branch from a4904b5 to ada8f35 Compare September 22, 2026 19:09
@yonigozlan

Copy link
Copy Markdown
Contributor Author

Thanks for the reviews @Rocketknight1 ! I simplified the additions a bit following your comments

@Rocketknight1

Copy link
Copy Markdown

Got it! If you're using agents for this, Opus 5.5 seems like it's a big improvement for simplifying/reviewing code. Might be worth handing the whole thing to it and asking if it can find conceptual simplifications or ways to reduce the diff without breaking funcitonality

@yonigozlan

Copy link
Copy Markdown
Contributor Author

Thanks @Rocketknight1 ! Made some more simplifications, the diff is now +52/-18 to response_parser.py, and is mostly adding support for returning malformed region instead of raising, +exact positions of closing delimiters. I don't think we can do less without compromising on correctness now. Indeed Opus 5.5 helped a lot there!

@Rocketknight1 Rocketknight1 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, that sounds great now! Thanks for the cleanup!

Comment thread python/sglang/srt/parser/chat_parsing/response_parser.py Outdated
@Jiminator
Jiminator dismissed their stale review September 26, 2026 08:23

resubmitting with commenti nstead of changes requested

@Jiminator Jiminator left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just left one comment, thank you for the contribution!

stream = ResponseParser(response_template, prefix=prefix, tools=tools)
events = stream.feed(text)
message, final_events = stream.finalize()
for event in events + final_events:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Could you include stream.initial_events in this malformed-event check. A malformed JSON region closed in the assistant prefill currently returns {} instead of raising, while the implementation in #40477 raises for the same input.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

yonigozlan and others added 10 commits September 27, 2026 15:51
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
A `repeats` field appends into the default list object owned by the
compiled template (and, through the shallow copy, into the caller's
spec dict), so one parse's tool calls leak into every later parse
sharing the template. Deep-copy defaults at template load and at
parser init, and add a regression test.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Delimiter text comes from the input offsets, and open events keep named
captures. Unmatched text around XML tags no longer fails the parse.
Keep the event contract to what adapters need: open, close and malformed
events carry the span of input_text they consumed, explicit opens carry
their captures, and malformed events carry the original error.

- Drop per-chunk offsets, prefix_end, close_start and closed; the prefix
  boundary is len(input_text) before the first feed().
- parse_response re-raises the original exception, like Transformers.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
`parse_response` only checked events from the generated text, so a
region the prefix left malformed was silently ignored. Include
`initial_events` in the check.

Co-authored-by: Cursor <cursoragent@cursor.com>
@yonigozlan
yonigozlan force-pushed the feat/chat-parsing-events branch from 4fce556 to 106b751 Compare September 27, 2026 20:23
@Jiminator Jiminator added run-ci CI: run the baseline test suite on this PR and removed run-ci CI: run the baseline test suite on this PR labels Sep 28, 2026

@Jiminator Jiminator left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@JustinTong0323 JustinTong0323 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The prefix fix and the simplifications look good. A few small things before this lands — the first one should be settled before the serving adapter builds on it.

# Prefix bytes held back as a possible delimiter belong to the prompt, not the chunk.
text = text[max(0, self._prefix_len - self._pos) :]
if text:
dirty = field.content not in STREAMABLE_PARSERS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Clean chunks still aren't final-value fragments: <n>1/2a/</n> emits two dirty=False chunks and then goes malformed at close, and a text field with a transform emits clean chunks whose close value is a dict. Settle this before the adapter relies on it — either restrict clean to text without transform, or redefine dirty and make code, docs and tests agree.

stream.feed(text)
message, _ = stream.finalize()
events = stream.feed(text)
message, final_events = stream.finalize()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

finalize() runs before any malformed event is inspected, so a missing required field masks the region's real error: template with required x and y, input <x>{</x> raises "fields missing: ['y']" instead of the JSON parse error. Check the recorded malformed events before finalizing.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fixed

f"Field '{field.name}': 'join' requires each match to parse to a string, "
f"got {type(value).__name__}."
)
except (KeyError, TypeError, ValueError) as error:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

RecursionError escapes this handler — a region body with deep nesting ('[' * 20000) aborts the whole stream instead of recovering (same through _coerce via the JSON content parser). Convert it to ValueError at the decode boundary so recover-and-continue holds.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fixed

self._opened = True
events.append({"type": "region_open", "field": field.name})
events.append(
{"type": "region_open", "field": field.name, "start": m.start(), "end": m.end(), "captures": self._captures}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This now aliases self._captures — a consumer mutating the event's captures dict changes the parsed value at close. Use a dict(...) copy to keep the snapshot semantics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fixed!

self.assertEqual(result, {"role": "assistant"})
self.assertEqual(final_events, [])

def test_region_events_expose_delimiter_offsets(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking: offsets are only exercised on single feeds. A mutation that mis-reports implicit-open offsets passes the whole suite — worth adding a chunking-invariance check on non-chunk events (compare full event dicts across chunkings, offsets included).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Fixed!

@Jiminator

Copy link
Copy Markdown
Collaborator

/tag-and-rerun-ci

@github-actions github-actions Bot added the run-ci CI: run the baseline test suite on this PR label Oct 5, 2026

@JustinTong0323 JustinTong0323 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@Jiminator Jiminator added the bypass-fail-fast CI: a failing job no longer aborts its siblings (lint still gates) label Oct 8, 2026

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

bypass-fail-fast CI: a failing job no longer aborts its siblings (lint still gates) dependencies Pull requests that update a dependency file npu run-ci CI: run the baseline test suite on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants