Skip to content

refactor(streaming)!: replace stream_with_chunking with single-task stream() - #1543

Merged
ajbozarth merged 7 commits into
generative-computing:mainfrom
ajbozarth:refactor/1440-stream-replacement-single-task
Aug 18, 2026
Merged

refactor(streaming)!: replace stream_with_chunking with single-task stream()#1543
ajbozarth merged 7 commits into
generative-computing:mainfrom
ajbozarth:refactor/1440-stream-replacement-single-task

Conversation

@ajbozarth

Copy link
Copy Markdown
Contributor

Pull Request

Issue

Fixes #1440

Description

Replaces the two-task stream_with_chunking() + StreamChunkingResult with a single-task stream()/Streamer primitive consumed by a plain async for, and factors the inline chunk-boundary bookkeeping into a stateful Chunker. Builds on the POC in #1409.

stream() returns a Streamer driven by one async generator on the caller's task — there is no background orchestration task. It is consumed with async for, ideally inside async with, which provides a guaranteed cleanup contract (aclose()/__aexit__) that cancels an abandoned generation on an early break, an exception, or external cancellation (e.g. an asyncio.wait_for timeout) — the terminal events fire and the thunk is finalized on every path. Typed StreamEvents are emitted through the streaming_event plugin hook rather than a result.events() iterator, and acomplete() is removed.

Key changes:

  • stream() / Streamer — single async generator on the caller's task; terminal state (failed_early, failure_reason, streaming_failures, full_text, final_validations, mot) lives on the thin Streamer handle.
  • Chunker — new stateful driver that wraps a stateless ChunkingStrategy and holds only the pending fragment between deltas; delta-invariant (any slicing yields the same chunks as one split() over the whole text).
  • Chunking strategy renamesSentenceChunker/WordChunker/ParagraphChunkerSentenceChunking/WordChunking/ParagraphChunking, freeing the Chunker name for the driver. String aliases (chunking="sentence", etc.) are unchanged.
  • ModelOutputThunk iterator API__aiter__/__anext__ wrap astream() in the async-iterator protocol with a single-consumer guard, plus aclose()/async with cleanup. astream() itself is unchanged.
  • Telemetry — with the whole run on one task, the cross-task span-reattachment machinery and the STREAMING_ORCHESTRATION_START/_END hooks are removed; the span is renamed stream_with_chunkingstream; CompletedEvent moves to the streaming_event hook.
  • Docs and examples — tutorials, how-to, tracing, and the streaming examples updated to the new API; added a multi-stream events example (multi_stream_events.py) and a raw MOT async-iterator example (async-iterator.py); added a migration guide at docs/dev/migrate-streaming-v0.8.md.

Breaking change, no deprecation shim: stream_with_chunking(), StreamChunkingResult, and the ...Chunker strategy names are removed. Migration guide: docs/dev/migrate-streaming-v0.8.md.

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code was added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

Rewrote test/stdlib/test_streaming.py for the new API and added the Chunker delta-invariance suite to test/stdlib/test_chunking.py. Retargeted the telemetry span/metrics tests and the hook-call-site tests to the new topology (stream span roots the chat span; events via the streaming_event hook). Added the mocked integration twin for the streaming span-topology test so it is covered in the fast tier, not only behind the slow e2e path. Verified ruff, ruff format, and mypy clean; ran the tutorial example code against Ollama to confirm the documented sample output shapes.

Attribution

  • AI coding assistants used

Adding a new component, requirement, sampling strategy, or tool?

  • Component
  • Requirement
  • Sampling Strategy
  • Tool

…tream()

Replace the two-task stream_with_chunking() + StreamChunkingResult with a
single-task stream()/Streamer primitive consumed by `async for`, and factor
chunk-boundary bookkeeping into a stateful Chunker.

stream() returns a Streamer driven by one async generator on the caller's
task -- no background orchestration task. Consume it with `async for`, ideally
inside `async with` for a guaranteed cleanup contract (aclose()/__aexit__)
that cancels an abandoned generation on early break or exception. Typed
StreamEvents are emitted through the streaming_event hook rather than a
result.events() iterator; acomplete() is removed.

Also:
- Rename chunking strategies SentenceChunker/WordChunker/ParagraphChunker to
  SentenceChunking/WordChunking/ParagraphChunking; Chunker now names the new
  stateful driver.
- Add ModelOutputThunk async-iterator API (__aiter__/__anext__) with a
  single-consumer guard, plus aclose()/async-with cleanup.
- Move STREAMING_START into stream() so the stream span correctly parents the
  backend chat span; remove the cross-task span reattachment machinery.
- Update docs, tutorials, and examples to the new API; add a multi-stream
  events example and an async-iterator example.

BREAKING CHANGE: stream_with_chunking(), StreamChunkingResult, and the
...Chunker strategy names are removed with no deprecation shim. See
docs/dev/migrate-streaming-v0.8.md for migration.

Closes generative-computing#1440

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth
ajbozarth requested a review from a team as a code owner August 13, 2026 01:59
@github-actions github-actions Bot added the enhancement New feature or request label Aug 13, 2026
@ajbozarth ajbozarth added area/stdlib Core abstractions: Context, MOT, SamplingStrategy, formatters, serialization area/streaming Streaming chunks, events, per-chunk validation area/telemetry OTel spans, metrics, tracing, semconv labels Aug 13, 2026
@ajbozarth ajbozarth self-assigned this Aug 13, 2026
@ajbozarth
ajbozarth requested a review from jakelorocco August 13, 2026 02:01

@AngeloDanducci AngeloDanducci 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.

Generally LGTM, a few review comments. I'll leave the larger design discussion to Jake since I think he's been more in the loop on the streaming refactor.

Comment thread mellea/telemetry/tracing_plugins.py
Comment thread mellea/telemetry/tracing.py Outdated

@planetf1 planetf1 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.

Nice simplification overall — dropping the cross-task span-reattachment machinery in particular removes some genuinely hairy code. Four things below are worth fixing before merge; none call the core design into question, they're all narrow and fixable.

Comment thread mellea/stdlib/chunking.py
Comment thread mellea/stdlib/streaming.py
Comment thread docs/dev/migrate-streaming-v0.8.md
Comment thread test/stdlib/test_streaming.py

@planetf1 planetf1 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.

Two more from a second pass, plus one I've folded into the existing thread on tracing_plugins.py:395 as a reply rather than a new comment since it's building on the same spot.

Comment thread docs/dev/migrate-streaming-v0.8.md
Comment thread mellea/stdlib/streaming.py
Comment thread mellea/stdlib/chunking.py
Comment thread mellea/stdlib/streaming.py Outdated
Comment on lines +193 to +196
Iterate the returned `Streamer` object with `async for` to receive the output
as validated chunks, ideally inside `async with` so the stream is released on
every exit. The attributes below track progress and outcome. Instances are
created by `stream`; do not instantiate directly.

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.

Absent my other comments about the multi-chat streaming, I think it's unclear what the chunk actually contains here. Could you please add either an in docstring example or say that it returns string segments of the model output thunk?

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.

I added a clarifying sentence in c6a130a

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.

I did some tests with this multi stream events example, and I'm worried that it shows our current approach is actually difficult to utilize.

A few thoughts:

  1. It is quite difficult to correlate a stream, the events, and the place those events need to go. For instance, I could get the stream id from the streamer and give it to some tui consumer. But I would have to have the plugin route to some queue / map that the tui consumer can grab events with that stream id from.
  2. What is the preferred pattern if a user is creating and constantly streaming new mots? I feel like that pattern would either require multiple plugins that basically do the same thing (which might be a performance issue); and/or a complicated pattern to route the events to the correct consumer.
  3. Is it possible to just have the streamer return events as well as mot chunks (or a different function of the streamer that can return the events) allowing the user to choose what to consume?

Additionally, I wonder if we could just register handlers directly to a streamer instead of having to go through the plugin system. Or maybe there's a way to utilize the plugin system to do something similar? After trying to utilize the plugin system to do some things beyond the example below, I'm just worried that it's quite convoluted to handle events from streamers.

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.

I actually went back and forth on how this should work a few items before settling on this implementation. I am open to further design discussions around it at or after Monday scrum.

If I'm understanding you correctly though this isn't an issue with the current implementation as it stands but the design we chose to implement. If that's the case we could choose to deal with this in a follow up PR before the next release. If you disagree and want to block merge of this PR on that design discussion we can do that too.

Even if we redesign, these hooks would need to stick around for the telemetry at least.

Comment thread mellea/stdlib/chunking.py
Comment on lines +368 to +377
# Carry forward whatever follows the last emitted chunk. split() may drop
# inter-chunk whitespace, so locate each chunk by position rather than
# string-subtracting, then keep the raw suffix as the new pending fragment.
cursor = 0
for c in chunks:
pos = self._pending.find(c, cursor)
if pos >= 0:
cursor = pos + len(c)
self._pending = self._pending[cursor:]
return chunks

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.

Are we ensuring anywhere that the final mot == what was actually received in chunks from the model even if the chunker edits things during streaming?

I also think that if split drops whitespace, isn't it possible for .find to fail here, messing up _pending?

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.

I dug into this and part of it was already addressed in a fix suggested by @planetf1 above, I'll include Claudes explanation:

Good instinct — the mutation-via-find() case is now a hard error: Chunker.feed() raises ValueError if split() returns a chunk that isn't a verbatim substring of the buffered text, so a normalizing/rewriting strategy fails loudly instead of silently re-emitting. On the mot question: the final mot can't diverge from what the model produced, because mot.value is accumulated from the raw deltas directly and the chunker never writes back to it — chunking only shapes the consumer-facing async for stream. The only place chunk text feeds back into state is full_text on early exit (accumulated[:emitted_end], located by find()), and that's covered by the same substring guard.

Comment on lines +247 to +249
def __aiter__(self) -> AsyncIterator[str]:
"""Return the generator that drives generation and yields chunks."""
return self._gen

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.

Should this get similar handling as mots to prevent multi-consumers?

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.

I actually asked this myself during my own self review, I'll let Claude explain:

Streamer doesn't need the MOT-style guard, because the failure mode it protects against can't occur here. MOT's __aiter__ returns self and re-arms, so a second async for would re-drive and split the stream — hence its explicit guard. Streamer's __aiter__ returns the same async generator object (self._gen), which is single-consumer by construction: once the first async for exhausts it, a second one just gets StopAsyncIteration immediately and yields nothing — no split, no re-run, full_text/mot intact (verified). So a second iteration is inert rather than dangerous. It's silent rather than loud (you get [], not an error); happy to add an explicit raise if you'd prefer the louder contract, but there's no correctness risk either way.

Add Streamer.completed_normally, a driver-set flag that is True only on
natural completion. Unlike `not failed_early`, it is False after an early
break, giving callers a correct "did this finish?" signal; docs, examples,
and the migration guide now use it, and the stream() chunk contract is
documented.

Harden _finalize so STREAMING_END fires even if the CompletedEvent emission
is cancelled or raises, via a nested try/finally kept on the caller's task.
asyncio.shield is avoided: it would detach the task-affine OTel token on the
wrong task.

Chunker.feed() now raises ValueError when split() returns a chunk that is not
a verbatim substring of the buffered text, rather than silently re-emitting;
the precondition is documented on split().

Fix a full_text_length regression on the stream span (it recorded the raw
accumulated length instead of the emitted-text length main used) and namespace
the four stream span attributes under mellea.streaming.*. Remove an unused
asyncio import from tracing.py.

Gate the hook-observing streaming tests with _cpex_skip so the core streaming
suite still runs without the optional hooks extra.

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth

Copy link
Copy Markdown
Contributor Author

@AngeloDanducci @planetf1 @jakelorocco I believe I had addressed all your review (except the open design point @jakelorocco raise) and left response on every comment. If you could re-review and mark them as resolved.

@jakelorocco jakelorocco 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.

lgtm; a few minor issues and discussed verbally about next steps

Comment thread mellea/stdlib/chunking.py
Comment on lines +379 to +390
cursor = 0
for c in chunks:
pos = self._pending.find(c, cursor)
if pos < 0:
raise ValueError(
f"{type(self._strategy).__name__}.split() returned a chunk that "
"is not a verbatim substring of the buffered text; split() must "
"not mutate chunk text (see ChunkingStrategy.split)."
)
cursor = pos + len(c)
self._pending = self._pending[cursor:]
return chunks

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.

Should we also enforce that the chunker always advances? or if not, that it doesn't produce empty chunks? This doesn't seem to impact our chunking strategies but maybe we should include this check to ensure third party chunkers don't fail in these modes?

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.

added another check for empty strings in 9d9f5ee which together with the existing check catch not advancing

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.

Good catch — added in 2663b15. feed() now raises ValueError before the find() loop when split() returns an empty string. Without it, find("", cursor) always returns 0, so _pending wouldn't advance and the empty string would be silently emitted as a chunk. The verbatim-substring guard catches mutation but not zero-length — they need separate checks.

The always-advances property follows from the two guards together: a non-empty verbatim-substring chunk strictly advances the cursor. Added test_chunker_rejects_empty_chunk alongside the existing test_chunker_rejects_mutating_strategy.

Comment thread test/stdlib/test_streaming.py Outdated
) -> PartialValidationResult:
_ = chunk, backend, ctx
return PartialValidationResult("fail", reason="nope")
@pytest.mark.asyncio

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.

I think this marker should be elsewhere / deleted, not on the class?

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.

removed in 9d9f5ee

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.

_FailOnSecondReq is a Requirement subclass used as a test fixture, not a test class. The marker had no effect and was misleading. Removed in 2663b15.

Comment thread mellea/stdlib/streaming.py Outdated
for every requirement that returned `"fail"` during streaming.
failed_early: `True` if a requirement returned `"fail"` during streaming
and the stream stopped before natural completion.
completed_normally: `True` only if the stream reached its natural end.

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.

Can you please add detail here that completed_normally doesn't prevent the final validation from raising an exception, etc...?

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.

addressed in 9d9f5ee

Add an empty-chunk guard to Chunker.feed() so a custom split() that
returns empty strings fails loudly; together with the existing substring
check this guarantees the buffer always advances. Clarify that
completed_normally reflects reaching the stream's end prior to final
validation. Remove a stray asyncio marker from a Requirement subclass.

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth

Copy link
Copy Markdown
Contributor Author

@jakelorocco and @planetf1 I responded to or addressed your latest review. If you could check one more time.

In addition I am investigating the events API after our design discussion during scrum and intend to either open a follow up issue or PR to address it rather than include it in this PR. I'll open that Issue or PR by EOD once I have it properly scoped

@ajbozarth

Copy link
Copy Markdown
Contributor Author

Per the design questions in #1543 (comment) and the discuss we had today in scrum I investigated better ways to consume the stream using events rather than output.

My initial attempt was to extend stream()/Streamer to take a flag that changes what it iterates on (events/output), but that ran into issues handling the error path. So I pivoted to an idea I had about wrapping the stream in another iterator that just drives the normal stream in the background. I had previously discarded this idea as convoluted, but decided to give it another try and ended up with the draft POC in ajbozarth#3 (opened in my fork so it diffs against this branch)

Feel free to review that draft and when I return from my trip next week I'll pick it back up and finish it based on feedback.

Comment thread mellea/stdlib/chunking.py
A cancellation delivered while STREAMING_END is being dispatched can curtail
delivery to the remaining subscribers with no retry, since the _finalized
guard is set before teardown. Document this at the guard so it reads as a
known trade-off rather than an oversight.

Assisted-by: Claude Code
Signed-off-by: Alex Bozarth <ajbozart@us.ibm.com>
@ajbozarth

Copy link
Copy Markdown
Contributor Author

@planetf1 @jakelorocco @AngeloDanducci unless I hear otherwise I intend to add this to the merge queue after from lunch (in ~2hr)

@ajbozarth
ajbozarth enabled auto-merge August 18, 2026 20:16
@ajbozarth
ajbozarth added this pull request to the merge queue Aug 18, 2026
Merged via the queue into generative-computing:main with commit 058e3dd Aug 18, 2026
11 checks passed
@ajbozarth
ajbozarth deleted the refactor/1440-stream-replacement-single-task branch August 18, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/stdlib Core abstractions: Context, MOT, SamplingStrategy, formatters, serialization area/streaming Streaming chunks, events, per-chunk validation area/telemetry OTel spans, metrics, tracing, semconv enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: replace stream_with_chunking with single-task stream()

4 participants