mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
fix(a2a): stop a snapshot resending whitespace already delivered
The raw-offset mapping lands just past the last matched non-whitespace
character, so whitespace already emitted at the end of the accumulated text
was forwarded a second time by the next cumulative snapshot:
["Hello ", "Hello world"] -> "Hello world"
["OK\n", "OK\n"] -> "OK\n\n"
Drop only the overlap between the whitespace already sent and the whitespace
the snapshot repeats, rather than stripping the tail outright, so a snapshot
that introduces further whitespace keeps it:
["Hello ", "Hello \n\nworld"] -> "Hello \n\nworld"
Reported by Greptile on #39513.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
13a313987a
commit
cc7107c782
2 changed files with 25 additions and 2 deletions
|
|
@ -34,6 +34,16 @@ def _index_after(text: str, non_space_count: int) -> int:
|
|||
)
|
||||
|
||||
|
||||
def _trailing_whitespace(text: str) -> int:
|
||||
"""How many whitespace characters `text` ends with."""
|
||||
return len(text) - len(text.rstrip())
|
||||
|
||||
|
||||
def _leading_whitespace(text: str) -> int:
|
||||
"""How many whitespace characters `text` begins with."""
|
||||
return len(text) - len(text.lstrip())
|
||||
|
||||
|
||||
class A2AModelResponseIterator(BaseModelResponseIterator):
|
||||
"""
|
||||
Iterator for parsing A2A streaming responses.
|
||||
|
|
@ -135,8 +145,14 @@ class A2AModelResponseIterator(BaseModelResponseIterator):
|
|||
|
||||
if emitted_key and text_key.startswith(emitted_key):
|
||||
# A cumulative snapshot: emit only its tail, which is empty when the snapshot
|
||||
# just repeats everything sent so far.
|
||||
suffix: Final = text[_index_after(text, len(emitted_key)) :]
|
||||
# just repeats everything sent so far. The offset lands just past the last
|
||||
# matched non-whitespace character, so any whitespace already delivered at the
|
||||
# end of the emitted text would otherwise be sent a second time. Drop only that
|
||||
# overlap, so a snapshot introducing further whitespace (a paragraph break, say)
|
||||
# keeps it.
|
||||
tail: Final = text[_index_after(text, len(emitted_key)) :]
|
||||
already_sent: Final = min(_trailing_whitespace(self._emitted_text), _leading_whitespace(tail))
|
||||
suffix: Final = tail[already_sent:]
|
||||
self._emitted_text += suffix
|
||||
return suffix
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,13 @@ def test_kagent_stream_finishes_on_completed_state():
|
|||
pytest.param(["O", "K", "OK"], "OK", id="deltas_then_final_snapshot"),
|
||||
pytest.param(["O", "K", "OK", "OK"], "OK", id="deltas_then_repeated_snapshots"),
|
||||
pytest.param(["OK", "OK"], "OK", id="one_delta_then_equal_snapshot"),
|
||||
pytest.param(["Hello ", "Hello world"], "Hello world", id="snapshot_keeps_emitted_space_once"),
|
||||
pytest.param(["OK\n", "OK\n"], "OK\n", id="snapshot_keeps_emitted_newline_once"),
|
||||
pytest.param(
|
||||
["Hello ", "Hello \n\nworld"],
|
||||
"Hello \n\nworld",
|
||||
id="snapshot_keeps_its_own_new_whitespace",
|
||||
),
|
||||
# Known limitation: A2A marks no event as delta-or-snapshot, so a delta that
|
||||
# exactly reproduces the accumulated text is indistinguishable from a snapshot
|
||||
# and collapses. Duplicating a whole reply is the worse failure of the two.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue