Skip to content

Python: fix(core): fold streamed text runs in one pass instead of repeated += - #8953

Draft
Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/coalesce-text-linear
Draft

Yufeng He (he-yufeng) wants to merge 3 commits into
microsoft:mainfrom
he-yufeng:fix/coalesce-text-linear

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

Motivation & Context

Aggregating streamed text chunks is quadratic today. _coalesce_text_content folds chunks with repeated +=, and every Content.__add__ rebuilds the text string, re-merges additional_properties, and re-concatenates the raw_representation lists, so n chunks cost O(n^2). Measured on current main, doubling the input roughly quadruples the time:

chunks 2,000 4,000 8,000 16,000
before 0.006s 0.019s 0.073s 0.302s
after 0.001s 0.002s 0.005s 0.011s

Long reasoning or tool-heavy streams pay this on every aggregated response.

Description & Review Guide

  • What are the major changes? _coalesce_text_content now collects each mergeable run and folds it in one pass via a new _merge_content_run helper: one "".join per segment, a single props merge, one flattened raw-representation list. The split decisions are unchanged: text runs still split on a model_output_kind change, text_reasoning runs still split on conflicting ids or a reasoning-text/summary mix.
  • What is the impact of these changes? Same output, linear time. One subtlety is pinned down in code and tests: the old path deep-copied the run head, and deepcopy discards raw_representation (_SHALLOW_COPY_FIELDS), so the head chunk's raw value never survived aggregation. The one-pass fold reproduces that by skipping the run head when flattening raws.
  • What do you want reviewers to focus on? _merge_content_run's equivalence with repeated +=: props earliest-wins, annotations concatenated in order, protected_data last-non-null wins, text stays None only when every chunk had None. The new test_coalesce_matches_repeated_add runs both the old fold (kept in the test as an oracle) and the new one over mixed streams and asserts identical results; test_coalesce_fold_does_not_readd_chunks fails if aggregation ever goes back through per-chunk __add__.

Related Issue

Fixes #8908

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The new merge path loses the run head’s deep-copy isolation for mutable properties and annotations.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Optimizes streamed text aggregation from quadratic to linear time while preserving merge semantics.

Changes:

  • Adds one-pass merging for text and reasoning runs.
  • Adds equivalence and performance regression tests.
File Description
python/​packages/​core/​agent_framework/​_types.py Implements linear-time content aggregation.
python/​packages/​core/​tests/​core/​test_types.py Tests merge equivalence and behavior.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/core/agent_framework/_types.py
@CloneOfAlex

Copy link
Copy Markdown

Comment 1: python/packages/core/tests/core/test_types.py, line 2571 (assert actual == expected, type_str)

Heads-up: Content.__eq__ compares _to_dict(), and _to_dict() doesn't include raw_representation. So this assertion passes no matter how raw values get combined, and the "raw representations of mixed shapes" stream above doesn't actually check the raw-flattening logic. Right now the only real raw coverage is the hard-coded expectation in test_coalesce_fold_does_not_readd_chunks.

I ran old vs. new side by side on 50k random streams while comparing raw values, and they matched. So the code looks correct today, but this test wouldn't catch a regression. Suggest checking raw values explicitly:

        assert actual == expected, type_str
        # Content.__eq__ excludes raw_representation, so compare it explicitly.
        assert [c.raw_representation for c in actual] == [c.raw_representation for c in expected], type_str

Small related gap: the comment on the second stream says "annotations," but no chunk there passes annotations=. Adding one case where the head has annotations and one where only a later chunk does would cover that path against the oracle too.


Comment 2: python/packages/core/agent_framework/_types.py, line 2318 (id=next((c.id for c in run if c.id), None),)

This is slightly different from the old fold. self.id or other.id, applied repeatedly, gives the first non-empty id, or if there isn't one, the last value seen. That means an empty-string id could come through as "", while this always returns None.

Repro (main vs. this branch):

c = [Content.from_text_reasoning(text="a"), Content.from_text_reasoning(id="", text="b")]
_coalesce_text_content(c, "text_reasoning")
c[0].id  # main: ''   this PR: None

It's an edge case, but it was the only divergence I found in about 20k fuzzed streams, and the PR promises identical output. This keeps the old semantics exactly:

        id=next((c.id for c in run if c.id), run[-1].id),

Comment 3: python/packages/core/agent_framework/_types.py, line 2363 (the text_reasoning split checks)

Design concern, not a bug: this PR copies the rules from Content.__add__ into two places.

  1. Split rules. The id-conflict and reasoning-text/summary checks here mirror the two AdditionItemMismatch conditions in _add_text_reasoning_content. The old code got them for free by catching the exception.
  2. Merge rules. _merge_content_run re-implements _add_text_content / _add_text_reasoning_content plus the _combine_* helpers.

If someone later adds a field or a new mismatch rule to __add__, coalescing will quietly stop matching it. A few options, from most to least effort:

  • Extract the split conditions into a single helper (e.g. _text_reasoning_mismatch(...)) that both _add_text_reasoning_content and this loop call.
  • Make test_coalesce_matches_repeated_add randomized with a fixed seed, generating a few hundred streams that mix ids, None/"" text, reasoning_text flags, annotations, raw shapes and refusal markers. Because the oracle uses the live __add__, any future drift would fail CI. It runs in milliseconds.
  • At minimum, add a comment in _add_text_content / _add_text_reasoning_content pointing to _merge_content_run, so the two stay in sync.

@he-yufeng

Copy link
Copy Markdown
Contributor Author

Thanks for the careful pass, especially the fuzzing. All three points checked out locally; fixed in d5a534e.

Comment 2 (id fallback). Reproduced: None or "" folds to "" on the old path while the one-pass returned None. Now falls back to run[-1].id, which matches repeated or exactly (all-falsy runs end on the last chunk's value; any truthy id still wins from the front). Your repro is pinned as test_coalesce_text_reasoning_empty_id_matches_repeated_add.

Comment 1 (raw coverage). Right on both counts, and there was a second layer underneath: expected = [deepcopy(c) for c in stream] stripped raw_representation off the oracle side before folding (__deepcopy__ drops _SHALLOW_COPY_FIELDS), so even an explicit raw comparison against that expected would have compared against all-None. The oracle now builds expected with shallow copy so raws survive on later chunks, the explicit per-item raw assertion is in, and streams with annotations on the head / on a later chunk / on both are added.

Comment 3 (drift). Took your second option plus the third: _add_text_content / _add_text_reasoning_content now carry a keep-in-sync note pointing at _merge_content_run, and there is a seeded fuzz test (300 random streams, ids, None/"" text, reasoning_text flags, refusal markers, annotations, mixed raw shapes) whose oracle folds through the live __add__, so any future rule added to the add-path without a mirror fails CI. Mutation check: reverting either fix turns the new tests red.

Copilot's deepcopy point (same theme as your aliasing concern): also real. The old path's initial deepcopy(head) detached the head's nested additional_properties values and annotation dicts; the one-pass merge reused them, so mutating the source head after aggregation leaked into the aggregate (verified locally, old path does not leak). The merge now deep-copies the head's contributions, pinned by test_coalesce_detaches_run_head_nested_values.

…eated +=

_coalesce_text_content folded chunks with first_new_content += content, and
every Content.__add__ rebuilds the text string, re-merges
additional_properties, and re-concatenates raw_representation lists, so
aggregating n chunks cost O(n^2) (microsoft#8908).

Collect each mergeable run and fold it in one pass in _merge_content_run:
one join per segment, a single props merge (the earliest chunk still wins
per key), one flattened raw-representation list. The fold reproduces the old
semantics exactly, including the run head's raw representation being dropped
(the old path deep-copied the head, and deepcopy discards raw_representation
via _SHALLOW_COPY_FIELDS) and the text_reasoning id / reasoning-text split
boundaries.

16000 chunks: 0.302s -> 0.011s on this machine.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
…un head in the one-pass fold

Two exactness gaps against the old fold, both found by fuzzing old vs new:

- id for a text_reasoning run fell back to None when no chunk carried a
  truthy id, but repeated `self.id or other.id` yields the last chunk's
  value there (so an all-falsy run ending in id="" kept ""). Fall back to
  run[-1].id.
- the old path deep-copied the run head before folding, so the aggregate
  never aliased the head's nested additional_properties values or
  annotation dicts. The one-pass merge reused them. Deep-copy the head's
  contributions the same way.

Also harden the equivalence tests: compare raw_representation explicitly
(Content.__eq__ skips it, and the oracle's expected side now uses shallow
copies so deepcopy does not strip raw up front), cover annotations on
head/later chunks, and add a seeded fuzz test folding random streams
through the live __add__ so future drift between the add-path and the
one-pass fold fails CI. Cross-reference _merge_content_run from
_add_text_content/_add_text_reasoning_content.
…the detach test

The four test type checkers all rejected subscripting Content's optional
additional_properties/annotations directly. Bind and assert them first.

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and fixed the Test Typing Checks leg: the detach test subscripted Content's optional additional_properties/annotations directly, which all four test checkers reject. They are bound and asserted first now (3dd8732). mypy/ty/pyrefly clean locally on the touched file; zuban has no findings in the edited region.

@CloneOfAlex

Copy link
Copy Markdown

Thanks Yufeng He (@he-yufeng), I re-checked the branch at 3dd8732 against all three of my comments and Copilot's finding.

What I verified

  • Comment 1 (raw coverage): test_coalesce_matches_repeated_add now builds expected with a shallow copy (so __deepcopy__ no longer strips raws on the oracle side) and asserts raw_representation explicitly. Streams with annotations on the head, on a later chunk, and on both are covered. Good catch on the deepcopy layer underneath.
  • Comment 2 (id fallback): next((c.id for c in run if c.id), run[-1].id) matches repeated self.id or other.id, and the "" repro is pinned in test_coalesce_text_reasoning_empty_id_matches_repeated_add.
  • Comment 3 (drift): The keep-in-sync notes are in _add_text_content / _add_text_reasoning_content, and the seeded fuzz test (300 streams) folds through the live __add__, so a future rule added only to the add-path will fail CI. That addresses my concern; the shared split helper can be a follow-up if maintainers want it.
  • Copilot's deep-copy finding: The head's additional_properties and annotations are deep-copied in _merge_content_run, and test_coalesce_detaches_run_head_nested_values covers it.

Independent check: I ran main's fold and this branch's fold side by side on 20k seeded random streams (text and text_reasoning, mixed ids incl. ""/None, None/"" text, reasoning_text flags, model_output_kind splits, annotations, mixed raw shapes, interleaved non-text content). Output matched on all of them, comparing to_dict() plus raw_representation, with identical exceptions. A second 5k-stream run that mutates the source chunks' nested props and annotations after folding also matched, so aliasing is the same as the old path.

Note: the fixes referenced as d5a534e are now 376748d / 3dd8732 after the force-push. The branch is also out of date with main again, and the workflows are waiting on maintainer approval.

LGTM from my side.

This branch was successfully deployed

1 active deployment
github-app-auth — 3dd8732c Deployed Oct 2, 2026 by he-yufeng via add_label #24291
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Streaming response aggregation is O(n^2) in the number of text chunks

3 participants