The None that corrupted my agent's chat history
Two bugs in Google's Agent Development Kit, one field apart, with the same root cause. A callback written the way the docs show turned every streamed fragment into a saved message, then erased token counts. Both fixes are now merged into google/adk-python, one as my own commit.

The outcome, up front. Two fixes merged into google/adk-python, Google’s open-source agent framework:
- Streaming flag loss, issue #7035 and my PR #7038. Merged via PR #7036 on Sep 10, 2026; the Google maintainer credited the helper name and the rule to my report. Shipped in v2.10.0.
- Token accounting loss, issue #7451 and my PR #7449. Merged the next day as commit 8e386a9, authored by me. Shipped in v2.12.0.
I run a multi-agent HR assistant in production. Its root agent has an after_model_callback that turns the model’s citation links into numbered sources, with the list of documents at the end of the answer. It follows the pattern in the ADK docs: take the model’s response, build a new one with the changed text, return it.
In August, while I was building a new web app for the assistant, answers started showing the literal words chunk_link where the numbered sources should have been. Half of that was an ordinary bug in where the links came from. The other half was stranger. The callback was running on every streamed fragment, not just the finished answer, and every fragment it rebuilt came back looking finished.
I patched my own callback that day. Two weeks later I went back to check whether the framework itself still had the problem. It did, on the latest release, and nobody had reported it.
This is the story of that bug, the one type annotation behind it, and a second bug hiding one field over.
What the callback is supposed to do
In the Agent Development Kit (ADK), after_model_callback runs on every response the model produces. Return None and the response goes through untouched. Return an LlmResponse and yours replaces the model’s. The docs’ own example shows the second form. It builds a new LlmResponse, copies over grounding_metadata, and leaves a comment: copy other relevant fields if necessary. It doesn’t say which fields are relevant.
def scrub_callback(callback_context, llm_response: LlmResponse):
if not (llm_response.content and llm_response.content.parts):
return None
text = llm_response.content.parts[0].text or ""
return LlmResponse(
content=types.Content(
role="model",
parts=[types.Part(text=text.replace("brave", "[REDACTED]"))],
),
)
Mine had the same shape, down to that comment. It added citations where this example redacts a word, but the structure is identical. Nothing about it looked wrong, and on a single complete response it wasn’t.
Reproducing it without an API key
The symptom only shows up under streaming, so to prove it was the framework and not my code, I needed it outside my agent entirely. I wrote a fake model: a class that yields three partial fragments and then one combined final response, the way a real streaming model does. No network, no key, deterministic. Then I ran the same conversation twice, once with no callback and once with the callback above.
Here is the run on ADK 2.9.2, the last release before the fix:
=== 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)
=== 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.'
The history should hold one message. It held four. And every fragment had been shown to the client as a final answer, which explains the garbled answers I had been seeing.
The root cause is a three-state boolean
LlmResponse.partial is declared like this:
partial: Optional[bool] = None
That field has three states: True, False, and “nobody set it”. A streaming model sets it to True on every fragment. My callback built a brand new LlmResponse and never mentioned partial, so on the rebuilt object it was None.
Downstream, the runner decides what to persist with a plain truth test:
if not event.partial:
... # append the event to the session
None is falsy. So every rebuilt fragment counted as “not partial”: final, renderable, worth saving. Three fragments and one complete reply became four saved messages, and the client was told each one was the end of the answer.
Nothing crashed and nothing logged a warning. The framework had simply lost the ability to tell “not set” from “not partial” the moment the object was replaced instead of edited.
The workaround, and the fix
If you are on an ADK release before 2.10.0, the workaround is one line. Edit a copy of the response instead of building a new one, so every field you didn’t touch survives:
return llm_response.model_copy(update={"content": new_content})
The proper fix lives in the framework. When a callback returns a replacement, ADK now carries over the streaming fields the callback left unset, and only those:
updates = {}
if replacement.partial is None and original.partial is not None:
updates["partial"] = original.partial
if replacement.turn_complete is None and original.turn_complete is not None:
updates["turn_complete"] = original.turn_complete
if updates:
return replacement.model_copy(update=updates)
return replacement
The rule in one sentence: inherit a field only when the replacement left it unset, and an explicit value always wins. A callback that deliberately sets partial=False still gets what it asked for. And the helper returns a copy rather than mutating the callback’s object, so a callback that reuses one response across several deltas never sees it change under its feet.
Getting it fixed in google/adk-python
On September 6 I opened issue #7035 with the fake-model reproduction, the three-state diagnosis and the proposed rule, and the same day I pushed PR #7038 implementing it with four tests. Another contributor had opened PR #7036 with the same approach minutes before mine, built from the report. That one was merged on September 10, co-authored by a Google maintainer, and closed the issue.
It was not my PR that landed. But when the maintainer closed mine on September 16, the comment read:

The last line turned out to matter most: usage_metadata still isn’t carried over, so open a new issue for that.
The first release with the fix is v2.10.0 (September 25). v2.9.2, released a week earlier, was cut from an older branch and still has the bug, so check your version before assuming you are clear.
The second bug, one field over, and my own commit
usage_metadata is the token accounting on a response: prompt tokens, output tokens, cached tokens, the numbers the provider bills you for. It describes the model call, not the content. Rewriting the words of a reply does not change what the call cost.
But it is also Optional, defaulting to None, and the finalizer that turns a response into a session event copies only the fields that are not None. So when a callback rebuilt the response and left usage unset, the final event and the persisted session event carried no usage at all. The model call had happened, the provider had billed for it, and from the session’s point of view it had cost nothing.
The first fix covered only the two streaming fields, and this loss is easy to miss. Plugins that read usage in their own callbacks run before the agent’s callback, so their logs still showed the numbers. Everything downstream of the event lost them: the session history, per-call cost attribution, the BigQuery analytics plugin, the A2A converters. If you were reconciling your agent’s token spend against your provider’s invoice from session data, you were undercounting every reply a callback had rebuilt.
A fake model of the same kind, now reporting 128 tokens on its final response, with a rebuilding callback, run four ways on ADK 2.11.0:
| Run | Streaming | Usage on the final event | Usage persisted |
|---|---|---|---|
| Control, no callback | no | 128 | 128 |
| Rebuilt response | no | None | None |
| Control, no callback | SSE | 128 | 128 |
| Rebuilt response | SSE | None | None |
The partial flags were correct in all four runs, which confirmed the first fix was in and this was the gap left beside it.
The fix is the same rule applied to one more field. I extended the helper to inherit usage_metadata when the replacement leaves it unset, renamed it _inherit_unset_model_fields to match its wider scope, and kept the two properties from the first fix: an explicit value on the replacement always wins, and the callback’s object is never mutated.
I kept the scope to that one field on purpose. grounding_metadata already had its own handling. finish_reason and error_code stay with the callback, because a guardrail may legitimately change them: a callback that blocks a reply should be able to say so. Usage is different. There is no content rewrite that makes None a more accurate token count than the one the provider reported.
I filed issue #7451 and PR #7449 together on October 7, with three new tests. The next day it was confirmed on 2.11.0, main and the 1.x line, and merged as commit 8e386a9, with my name on it and only cosmetic edits from the import. It shipped in v2.12.0 on October 13. The same week, an RFC on which LlmResponse fields the framework owns cited it as one instance of the problem. That is the right question, and a bigger one than either bug.
What I took from it
Optional[bool] = None is three states, not two. Replacing a whole object quietly turns “not set” into the default, and downstream code reads the default as False, or as “nothing to copy”. The trap is not specific to ADK. It shows up in Pydantic models, in protobuf optional fields, and in every PUT-versus-PATCH argument you have ever had about an API.
If you write callbacks or middleware, edit a copy. model_copy(update=...) keeps the fields that belong to the framework. Building a fresh object is the documented pattern in a lot of places, and it is the pattern that loses data.
Test under streaming. The first bug only exists when partial is True, and partial is only True while a response is streaming. A test that sends one complete response will never see it.
When you fix one field, look at its neighbours. The second bug sat next to the first in the same object, with the same shape. The maintainer spotted it while closing the first one, and once it was filed it took a day to merge.
If you design frameworks, decide which fields you own. Carry them over when a callback leaves them unset. Let explicit values win. Write that rule down where the people writing callbacks will read it.
The whole thing started as “why does my chat say chunk_link”. It ended as two fixes in a Google framework, one with my name on the commit, and a rule I now check in every hook I write: when you replace an object, what did you silently reset?
More of my work: github.com/zhuhongd.
