-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Description
👋 Hi everyone, thank you in advance for taking your time to review this issue!
Describe the bug
When updating the session state using the state_delta field within an EventActions object (returned by a custom after_tool_callback), the custom state update is lost if the Event contains a function_response but no text content field. This issue specifically occurs when skip_summarization is set to True. As a result, even though the state_delta field is correctly set in the emitted event, the state is not updated and the field appears as an empty string in the final state.
To Reproduce
Given a FunctionTool wrapping the following function:
async def custom_tool_function(input: str) -> dict[str, any]:
...
return {
"result": result,
"status": "success",
}And a callback function:
def custom_after_tool_callback(
tool: base_tool.BaseTool,
args: typing.Dict[str, typing.Any],
tool_context: tool_context.ToolContext,
tool_response: typing.Dict[str, typing.Any],
) -> None:
...
tool_context.actions.skip_summarization = True
tool_context.actions.state_delta[output_key] = result
return NoneSteps to reproduce:
- Call the custom
tooland trigger the callback. - Set
skip_summarization = Trueand updatestate_deltawith custom tool output. - Emit the event, confirming that the
state_deltais included correctly. - Check the final state (or event before updating the state), where the expected value in
state_deltais an empty string.
Observed behavior
The emitted event initially contains the correct state_delta, but the final state includes the state_delta with an empty string as the value for the respective output_key, despite the state_delta being correctly set in the event. The state_delta is not stored as expected.
Here is the current code in the flow:
Event Emission (Expected behavior)
Initially, the event contains the correct state_delta:
Event(
content=Content(
parts=[
Part(
function_response=FunctionResponse(
id='call_K74WBIwpMhj35PJIOJk7ILbQ',
name='<... function ...>',
response={
'result': [<... items ...>],
'status': 'success'
}
)
),
],
role='user'
),
actions=EventActions(
skip_summarization=True,
state_delta={'<... function name ...>': [<... items ...>]},
),
id='29c25193-2f5f-4886-8d3b-de6356cd245d',
timestamp=1760448942.311269
)
Final State (Observed behavior)
The final state incorrectly shows an empty string for the state_delta:
Event(
content=Content(
parts=[
Part(
function_response=FunctionResponse(
id='call_K74WBIwpMhj35PJIOJk7ILbQ',
name='<... function ...>',
response={
'result': [<... items ...>],
'status': 'success'
}
)
),
],
role='user'
),
actions=EventActions(
skip_summarization=True,
state_delta={'<... function name ...>': ''},
),
id='29c25193-2f5f-4886-8d3b-de6356cd245d',
timestamp=1760448942.311269
)
Methods Involved
is_final_response() currently used to check if the event is a final response (in code):
def is_final_response(self) -> bool:
"""Returns whether the event is the final response of an agent."""
if self.actions.skip_summarization or self.long_running_tool_ids:
return True
...__maybe_save_output_to_state() used to save the model output to state (in code):
def __maybe_save_output_to_state(self, event: Event):
"""Saves the model output to state if needed."""
if event.author != self.name:
logger.debug('Skipping output save for agent %s: event authored by %s', self.name, event.author)
return
if (
self.output_key
and event.is_final_response()
and event.content
and event.content.parts
):
result = ''.join(
part.text
for part in event.content.parts
if part.text and not part.thought
)
if self.output_schema:
if not result.strip():
return
result = self.output_schema.model_validate_json(result).model_dump(
exclude_none=True
)
event.actions.state_delta[self.output_key] = resultAs seen in the code, the __maybe_save_output_to_state method looks for a text field within the event content and uses it to determine the final result. However, in cases where skip_summarization is True and no text part exists, the method sets the state_delta to an empty string. This is the case here, as we only have a function_response.
Expected behavior
The state update should persist in the final state with the correct value in state_delta. If the state_delta is correctly set in the emitted event, it should be stored properly in the final state, without being overridden by an empty string. This should not only work with a text content, but also work with a function_response in the case of a use of skip_summerization.
Workaround
Interestingly, a workaround that seems to solve the issue is by setting an output_schema on the LlmAgent:
While this workaround does seem to correctly store the state, this should technically not work according to the documentation, which states that output_schema should not be set when using tools. Despite this, the workaround successfully resolves the issue by triggering the expected behavior.
Potential solution
The issue seems to stem from the method __maybe_save_output_to_state, which checks for event.is_final_response() and attempts to extract a text part from the event. Since skip_summarization is set to True, the Event is marked as a final response, but when no text content is available, the result defaults to an empty string.
A potential solution is to modify the __maybe_save_output_to_state method to handle cases where there is no text field but a valid state_delta exists. The method could check if state_delta is populated in the event and save the values to the state directly, bypassing the text extraction logic.
def __maybe_save_output_to_state(self, event: Event):
"""Saves the model output to state if needed."""
if event.author != self.name:
logger.debug('Skipping output save for agent %s: event authored by %s', self.name, event.author)
return
# Check if state_delta is populated, even if there is no text content
if event.actions.state_delta:
for key, value in event.actions.state_delta.items():
self.state[key] = value
return
if (
self.output_key
and event.is_final_response()
and event.content
and event.content.parts
):
result = ''.join(
part.text
for part in event.content.parts
if part.text and not part.thought
)
if self.output_schema:
if not result.strip():
return
result = self.output_schema.model_validate_json(result).model_dump(
exclude_none=True
)
event.actions.state_delta[self.output_key] = resultThis way, if state_delta is set, the method will prioritize saving it directly, even if no text content is present.
Desktop
- OS: macOS Sequoia 15.6.1 (24G90)
- Python version: Python 3.13.8
- ADK version: 1.16.0
Model Information
- Are you using LiteLLM: Yes
- Which model is being used (e.g., gemini-2.5-pro): azure/gpt-4.1
👉 I hope the problem description and inputs help to figure out the issues and a potential resolution. If you're interested, I can also create a PR to fix this issue based on your feedbacks and opinions!