Skip to content

Commit 8f98bcd

Browse files
GWealecopybara-github
authored andcommitted
fix: share one event id across the partial chunks of a streaming response
Streaming SSE responses minted a fresh event id for every partial chunk, so clients could not tell which partials belonged to the same response. Re-mint the id only after a complete (non-partial) event, so all partials and the aggregated final event of one LLM call share an id while each distinct call still gets its own. Function-call and function-response events keep their independent ids. The discriminator is partial-vs-complete rather than text-vs-function-call: partial chunks are never saved to the session or executed, and re-minting after each complete event keeps consecutive saved events distinct. This groups every partial chunk of one response under a single id without special-casing part types. Close google#1006 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 951058843
1 parent b549ab4 commit 8f98bcd

2 files changed

Lines changed: 205 additions & 2 deletions

File tree

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,8 +1019,10 @@ async def _run_one_step_async(
10191019
)
10201020
) as agen:
10211021
async for event in agen:
1022-
# Update the mutable event id to avoid conflict
1023-
model_response_event.id = Event.new_id()
1022+
# Partial chunks of one streaming response share the base id; mint a
1023+
# fresh id only after a complete event so distinct responses differ.
1024+
if not event.partial:
1025+
model_response_event.id = Event.new_id()
10241026
model_response_event.timestamp = platform_time.get_time()
10251027
yield event
10261028

tests/unittests/flows/llm_flows/test_progressive_sse_streaming.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,3 +894,204 @@ def track_execution(call_id: str) -> str:
894894
assert (
895895
len(function_response_events) == 1
896896
), f"Expected 1 function response event, got {len(function_response_events)}"
897+
898+
899+
def test_progressive_sse_partials_share_event_id():
900+
"""Partial chunks and the final event of one response share one id."""
901+
902+
response1 = LlmResponse(
903+
content=types.Content(
904+
role="model", parts=[types.Part.from_text(text="Checking weather...")]
905+
),
906+
)
907+
response2 = LlmResponse(
908+
content=types.Content(
909+
role="model",
910+
parts=[
911+
types.Part.from_function_call(
912+
name="get_weather", args={"location": "Tokyo"}
913+
)
914+
],
915+
),
916+
)
917+
response3 = LlmResponse(
918+
content=types.Content(
919+
role="model",
920+
parts=[
921+
types.Part.from_function_call(
922+
name="get_weather", args={"location": "New York"}
923+
)
924+
],
925+
),
926+
finish_reason=types.FinishReason.STOP,
927+
)
928+
929+
mock_model = StreamingMockModel(
930+
stream_chunks=[response1, response2, response3]
931+
)
932+
agent = Agent(name="weather_agent", model=mock_model, tools=[get_weather])
933+
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
934+
runner = InMemoryRunner(agent=agent)
935+
session = runner.session_service.create_session_sync(
936+
app_name=runner.app_name, user_id="test_user"
937+
)
938+
939+
events = []
940+
for event in runner.run(
941+
user_id="test_user",
942+
session_id=session.id,
943+
new_message=types.Content(
944+
role="user",
945+
parts=[types.Part.from_text(text="What is the weather?")],
946+
),
947+
run_config=run_config,
948+
):
949+
events.append(event)
950+
951+
assert len(events) == 6
952+
# events 0-2 are partials and event 3 is the aggregated final of one
953+
# streaming response, so they all share the id minted once for that call.
954+
assert events[0].id == events[1].id == events[2].id == events[3].id
955+
# The function-response event is a separate event with its own id.
956+
assert events[4].id != events[0].id
957+
# A distinct LLM call mints a fresh id.
958+
assert events[5].id != events[0].id
959+
960+
961+
def test_progressive_sse_text_stream_shares_event_id():
962+
"""Every partial and the final of a pure-text stream share one id."""
963+
964+
response1 = LlmResponse(
965+
content=types.Content(
966+
role="model", parts=[types.Part.from_text(text="Hello ")]
967+
),
968+
)
969+
response2 = LlmResponse(
970+
content=types.Content(
971+
role="model", parts=[types.Part.from_text(text="world")]
972+
),
973+
)
974+
response3 = LlmResponse(
975+
content=types.Content(
976+
role="model", parts=[types.Part.from_text(text="!")]
977+
),
978+
finish_reason=types.FinishReason.STOP,
979+
)
980+
981+
mock_model = StreamingMockModel(
982+
stream_chunks=[response1, response2, response3]
983+
)
984+
agent = Agent(name="text_stream_agent", model=mock_model)
985+
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
986+
runner = InMemoryRunner(agent=agent)
987+
session = runner.session_service.create_session_sync(
988+
app_name=runner.app_name, user_id="test_user"
989+
)
990+
991+
events = []
992+
for event in runner.run(
993+
user_id="test_user",
994+
session_id=session.id,
995+
new_message=types.Content(
996+
role="user",
997+
parts=[types.Part.from_text(text="Say hello.")],
998+
),
999+
run_config=run_config,
1000+
):
1001+
events.append(event)
1002+
1003+
model_events = [
1004+
e for e in events if e.author == "text_stream_agent" and e.content
1005+
]
1006+
assert any(e.partial for e in model_events)
1007+
assert any(not e.partial for e in model_events)
1008+
assert len({e.id for e in model_events}) == 1
1009+
1010+
1011+
class TwoFinalResponsesMockModel(BaseLlm):
1012+
"""Yields two separate non-partial responses (text then a function call)."""
1013+
1014+
model: str = "two-finals-mock"
1015+
call_count: int = 0
1016+
1017+
@classmethod
1018+
def supported_models(cls) -> list[str]:
1019+
return ["two-finals-mock"]
1020+
1021+
async def generate_content_async(
1022+
self, llm_request: LlmRequest, stream: bool = False
1023+
) -> AsyncGenerator[LlmResponse, None]:
1024+
self.call_count += 1
1025+
if self.call_count == 1:
1026+
yield LlmResponse(
1027+
content=types.Content(
1028+
role="model",
1029+
parts=[types.Part.from_text(text="Let me check the weather.")],
1030+
),
1031+
partial=False,
1032+
)
1033+
yield LlmResponse(
1034+
content=types.Content(
1035+
role="model",
1036+
parts=[
1037+
types.Part.from_function_call(
1038+
name="get_weather", args={"location": "Tokyo"}
1039+
)
1040+
],
1041+
),
1042+
partial=False,
1043+
finish_reason=types.FinishReason.STOP,
1044+
)
1045+
return
1046+
yield LlmResponse(
1047+
content=types.Content(
1048+
role="model", parts=[types.Part.from_text(text="Done.")]
1049+
),
1050+
partial=False,
1051+
finish_reason=types.FinishReason.STOP,
1052+
)
1053+
1054+
1055+
def test_progressive_sse_saved_events_get_distinct_ids():
1056+
"""Distinct saved (non-partial) events in one turn must get distinct ids.
1057+
1058+
A saved text event and a saved function-call event produced in the same
1059+
streaming turn must not collapse onto a shared id. The id is re-minted after
1060+
every complete event, so this must hold regardless of future refactors of the
1061+
id-minting loop.
1062+
"""
1063+
1064+
mock_model = TwoFinalResponsesMockModel()
1065+
agent = Agent(name="distinct_id_agent", model=mock_model, tools=[get_weather])
1066+
run_config = RunConfig(streaming_mode=StreamingMode.SSE)
1067+
runner = InMemoryRunner(agent=agent)
1068+
session = runner.session_service.create_session_sync(
1069+
app_name=runner.app_name, user_id="test_user"
1070+
)
1071+
1072+
events = []
1073+
for event in runner.run(
1074+
user_id="test_user",
1075+
session_id=session.id,
1076+
new_message=types.Content(
1077+
role="user",
1078+
parts=[types.Part.from_text(text="What is the weather?")],
1079+
),
1080+
run_config=run_config,
1081+
):
1082+
events.append(event)
1083+
1084+
# Only non-partial events are saved to the session.
1085+
saved_events = [e for e in events if not e.partial]
1086+
text_event = next(
1087+
e
1088+
for e in saved_events
1089+
if e.author == "distinct_id_agent"
1090+
and e.content
1091+
and any(p.text for p in e.content.parts)
1092+
)
1093+
function_call_event = next(e for e in saved_events if e.get_function_calls())
1094+
assert text_event.id != function_call_event.id
1095+
# Every saved event in the turn has a unique id.
1096+
saved_ids = [e.id for e in saved_events]
1097+
assert len(saved_ids) == len(set(saved_ids))

0 commit comments

Comments
 (0)