Describe the Bug:
The documented contract for after_model_callback says a callback may return a replacement response ("When present, the actual model response will be ignored and the provided content will be returned to user" - agents/llm_agent.py). During SSE streaming, however, the callback is invoked on every streamedLlmResponse, including partial=True deltas (flows/llm_flows/base_llm_flow.py:1833-1840 on main), and the returned replacement is used wholesale:
if altered := (await self._handle_after_model_callback(...)):
llm_response = altered
LlmResponse.partial defaults to None (models/llm_response.py), and per the streaming protocol described in LlmResponse's own docstring, a response with partial "false or unset" is a final response. So any callback that rebuilds an LlmResponse, exactly what the contract suggests, silently converts every streaming delta into a final response. Neither _handle_after_model_callback (module level, base_llm_flow.py:295) nor the call sites carry partial (or turn_complete) over from the response being replaced.
Downstream, Runner persists every non-partial event (runners.py: if not event.partial: await self.session_service.append_event(...)), so each delta fragment is appended to the session as a separate complete model event - the conversation history is corrupted for all subsequent turns.
To Reproduce:
Self-contained repro, no API key required (fake model follows the partial-deltas-then-aggregated-final protocol from LlmResponse's docstring):
# repro_partial_loss.py - run: pip install google-adk==2.8.0 && python repro_partial_loss.py
"""Repro: after_model_callback silently drops `partial` on streamed responses.
A fake streaming model yields 3 partial deltas + 1 final aggregated response (the protocol described in LlmResponse's own docstring). The
after_model_callback follows the documented contract ("When present, the actual model response will be ignored and the provided content will be returned to user") and returns a rebuilt LlmResponse.
Result: every streamed chunk loses partial=True, so (a) clients see N "final" responses instead of deltas, and (b) Runner persists every delta
into the session as a separate complete event (runners.py: `if not event.partial: append_event(...)`).
"""
import asyncio
from typing import AsyncGenerator
from google.adk.agents import LlmAgent
from google.adk.agents.run_config import RunConfig, StreamingMode
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_request import LlmRequest
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.genai import types
def _text_response(text: str, partial: bool | None = None) -> LlmResponse:
return LlmResponse(
content=types.Content(role="model", parts=[types.Part(text=text)]),
partial=partial,
)
class FakeStreamingLlm(BaseLlm):
"""Emits: 'Hello ' / 'brave ' / 'world.' as partials, then the full text."""
@classmethod
def supported_models(cls) -> list[str]:
return [".*"]
async def generate_content_async(
self, llm_request: LlmRequest, stream: bool = False
) -> AsyncGenerator[LlmResponse, None]:
deltas = ["Hello ", "brave ", "world."]
if stream:
for d in deltas:
yield _text_response(d, partial=True)
yield _text_response("".join(deltas)) # aggregated final (partial unset)
def scrub_callback(callback_context, llm_response: LlmResponse):
"""A guardrail-style callback written exactly per the documented contract:
build and return a replacement LlmResponse."""
if not (llm_response.content and llm_response.content.parts):
return None
text = llm_response.content.parts[0].text or ""
return _text_response(text.replace("brave", "[REDACTED]"))
async def run(label: str, callback):
agent = LlmAgent(
name="repro_agent",
model=FakeStreamingLlm(model="fake-streaming"),
after_model_callback=callback,
)
runner = InMemoryRunner(agent=agent, app_name="repro")
session = await runner.session_service.create_session(
app_name="repro", user_id="u"
)
print(f"\n=== {label} ===")
n = 0
async for event in runner.run_async(
user_id="u",
session_id=session.id,
new_message=types.Content(role="user", parts=[types.Part(text="hi")]),
run_config=RunConfig(streaming_mode=StreamingMode.SSE),
):
n += 1
text = ""
if event.content and event.content.parts and event.content.parts[0].text:
text = event.content.parts[0].text
print(f" event {n}: partial={event.partial!r:6} "
f"final={event.is_final_response()!s:5} text={text!r}")
stored = await runner.session_service.get_session(
app_name="repro", user_id="u", session_id=session.id
)
model_events = [e for e in stored.events if e.author == "repro_agent"]
print(f" session now holds {len(model_events)} persisted model event(s):")
for e in model_events:
t = e.content.parts[0].text if (e.content and e.content.parts) else ""
print(f" - {t!r}")
async def main():
await run("CONTROL: no after_model_callback", None)
await run("BUG: doc-contract callback (rebuilds LlmResponse)", scrub_callback)
asyncio.run(main())
def scrub_callback_copy(callback_context, llm_response: LlmResponse):
"""Workaround: model_copy(update=...) preserves partial/turn_complete."""
if not (llm_response.content and llm_response.content.parts):
return None
text = llm_response.content.parts[0].text or ""
new_content = types.Content(
role="model", parts=[types.Part(text=text.replace("brave", "[REDACTED]"))])
return llm_response.model_copy(update={"content": new_content})
asyncio.run(run("WORKAROUND: model_copy(update=...)", scrub_callback_copy))
Actual output on google-adk 2.8.0:
=== CONTROL: no after_model_callback ===
event 1: partial=True final=False text='Hello '
event 2: partial=True final=False text='brave '
event 3: partial=True final=False text='world.'
event 4: partial=None final=True text='Hello brave world.'
session now holds 1 persisted model event(s):
- 'Hello brave world.'
=== BUG: doc-contract callback (rebuilds LlmResponse) ===
event 1: partial=None final=True text='Hello '
event 2: partial=None final=True text='[REDACTED] '
event 3: partial=None final=True text='world.'
event 4: partial=None final=True text='Hello [REDACTED] world.'
session now holds 4 persisted model event(s):
- 'Hello '
- '[REDACTED] '
- 'world.'
- 'Hello [REDACTED] world.'
=== WORKAROUND: model_copy(update=...) ===
event 1: partial=True final=False text='Hello '
event 2: partial=True final=False text='[REDACTED] '
event 3: partial=True final=False text='world.'
event 4: partial=None final=True text='Hello [REDACTED] world.'
session now holds 1 persisted model event(s):
- 'Hello [REDACTED] world.'
Expected behavior:
A callback replacement that does not explicitly set streaming-control fields should not change the streaming semantics of the response it replaces: deltas stay partial=True, exactly one final event is persisted to the session (the WORKAROUND run in the output above shows the expected shape).
Impact:
- Session corruption: every delta is appended to the session as a
complete model event; later turns see N fragment messages as context.
- Clients in SSE mode receive N "final" responses (
is_final_response() == True for every delta) - duplicated/mangled rendering.
- Same field-loss class affects
turn_complete (live path checks
event.turn_complete to close the live request queue - a rebuilt response
loses it) and usage_metadata (token accounting silently dropped).
Workaround:
Rebuild via model_copy instead of the constructor:
return llm_response.model_copy(update={"content": new_content})
Verified (third run in the repro/output above): deltas stay partial, session gets exactly 1 event. But nothing in the docs points users to this, and the natural reading of the contract produces the broken version.
Suggested fix (happy to send a PR):
Treat None as "unset" and inherit streaming-control fields from the response being replaced, at both call sites (live: base_llm_flow.py:1791-1798, SSE: 1833-1840):
def _inherit_unset_streaming_fields(original, replacement):
if replacement.partial is None and original.partial is not None:
replacement.partial = original.partial
if replacement.turn_complete is None and original.turn_complete is not None:
replacement.turn_complete = original.turn_complete
return replacement
An explicit partial=False set by a callback is still respected.
Alternatively (or additionally): document the model_copy requirement in the after_model_callback contract.
Desktop:
- OS: macOS 15 (Darwin 25.6)
- Python: 3.13.12
- google-adk: 2.8.0 (also present in 1.18.0 — call sites at
base_llm_flow.py:757-760 and 796-799 there; the pattern is unchanged across the 1.x→2.x line)
Additional context:
Found running a production multi-agent HR assistant where a guardrail-style after_model_callback (PII scrubbing) rebuilt responses per the documented contract; symptom was mid-stream output mangling plus duplicated assistant messages accumulating in session history.
Describe the Bug:
The documented contract for
after_model_callbacksays a callback may return a replacement response ("When present, the actual model response will be ignored and the provided content will be returned to user" -agents/llm_agent.py). During SSE streaming, however, the callback is invoked on every streamedLlmResponse, includingpartial=Truedeltas (flows/llm_flows/base_llm_flow.py:1833-1840on main), and the returned replacement is used wholesale:LlmResponse.partialdefaults toNone(models/llm_response.py), and per the streaming protocol described inLlmResponse's own docstring, a response withpartial"false or unset" is a final response. So any callback that rebuilds anLlmResponse, exactly what the contract suggests, silently converts every streaming delta into a final response. Neither_handle_after_model_callback(module level,base_llm_flow.py:295) nor the call sites carrypartial(orturn_complete) over from the response being replaced.Downstream,
Runnerpersists every non-partial event (runners.py:if not event.partial: await self.session_service.append_event(...)), so each delta fragment is appended to the session as a separate complete model event - the conversation history is corrupted for all subsequent turns.To Reproduce:
Self-contained repro, no API key required (fake model follows the partial-deltas-then-aggregated-final protocol from
LlmResponse's docstring):Actual output on google-adk 2.8.0:
Expected behavior:
A callback replacement that does not explicitly set streaming-control fields should not change the streaming semantics of the response it replaces: deltas stay
partial=True, exactly one final event is persisted to the session (the WORKAROUND run in the output above shows the expected shape).Impact:
complete model event; later turns see N fragment messages as context.
is_final_response() == Truefor every delta) - duplicated/mangled rendering.turn_complete(live path checksevent.turn_completeto close the live request queue - a rebuilt responseloses it) and
usage_metadata(token accounting silently dropped).Workaround:
Rebuild via
model_copyinstead of the constructor:Verified (third run in the repro/output above): deltas stay partial, session gets exactly 1 event. But nothing in the docs points users to this, and the natural reading of the contract produces the broken version.
Suggested fix (happy to send a PR):
Treat
Noneas "unset" and inherit streaming-control fields from the response being replaced, at both call sites (live:base_llm_flow.py:1791-1798, SSE:1833-1840):An explicit
partial=Falseset by a callback is still respected.Alternatively (or additionally): document the
model_copyrequirement in theafter_model_callbackcontract.Desktop:
base_llm_flow.py:757-760and796-799there; the pattern is unchanged across the 1.x→2.x line)Additional context:
Found running a production multi-agent HR assistant where a guardrail-style
after_model_callback(PII scrubbing) rebuilt responses per the documented contract; symptom was mid-stream output mangling plus duplicated assistant messages accumulating in session history.