Python: fix(core): fold streamed text runs in one pass instead of repeated += - #8953
Yufeng He (he-yufeng) wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
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
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 1: Heads-up: 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: Small related gap: the comment on the second stream says "annotations," but no chunk there passes Comment 2: This is slightly different from the old fold. Repro ( 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: NoneIt'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: Comment 3: Design concern, not a bug: this PR copies the rules from
If someone later adds a field or a new mismatch rule to
|
|
Thanks for the careful pass, especially the fuzzing. All three points checked out locally; fixed in d5a534e. Comment 2 (id fallback). Reproduced: Comment 1 (raw coverage). Right on both counts, and there was a second layer underneath: Comment 3 (drift). Took your second option plus the third: Copilot's deepcopy point (same theme as your aliasing concern): also real. The old path's initial |
…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>
d5a534e to
3dd8732
Compare
|
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. |
|
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
Independent check: I ran Note: the fixes referenced as d5a534e are now 376748d / 3dd8732 after the force-push. The branch is also out of date with LGTM from my side. |

Motivation & Context
Aggregating streamed text chunks is quadratic today.
_coalesce_text_contentfolds chunks with repeated+=, and everyContent.__add__rebuilds the text string, re-mergesadditional_properties, and re-concatenates theraw_representationlists, so n chunks cost O(n^2). Measured on current main, doubling the input roughly quadruples the time:Long reasoning or tool-heavy streams pay this on every aggregated response.
Description & Review Guide
_coalesce_text_contentnow collects each mergeable run and folds it in one pass via a new_merge_content_runhelper: one"".joinper segment, a single props merge, one flattened raw-representation list. The split decisions are unchanged: text runs still split on amodel_output_kindchange,text_reasoningruns still split on conflicting ids or a reasoning-text/summary mix.deepcopydiscardsraw_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._merge_content_run's equivalence with repeated+=: props earliest-wins, annotations concatenated in order,protected_datalast-non-null wins, text staysNoneonly when every chunk hadNone. The newtest_coalesce_matches_repeated_addruns 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_chunksfails if aggregation ever goes back through per-chunk__add__.Related Issue
Fixes #8908
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.