Skip to content

Overridden state_delta by empty string in after_tool_callback after skip_summarization聽#3178

Closed

Description

@unnmdnwb3

馃憢 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 None

Steps to reproduce:

  1. Call the custom tool and trigger the callback.
  2. Set skip_summarization = True and update state_delta with custom tool output.
  3. Emit the event, confirming that the state_delta is included correctly.
  4. Check the final state (or event before updating the state), where the expected value in state_delta is 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] = result

As 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] = result

This 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!

Activity

  1. added
    core[Component] This issue is related to the core interface and implementation
    on Oct 15, 2025
  2. added theissue type on Oct 15, 2025
  3. Jacksunwei commented on Oct 23, 2025

    @Jacksunwei
    Collaborator

    A few questions to help narrow down the root cause:

    1. could you try this way to update state:
    tool_context.state[output_key] = result
    
    1. Does it also happen to gemini-2.5-flash or just when using LiteLlm?
  4. added this to the 10/27 ADK Week milestone on Oct 23, 2025
  5. aneeshgarg commented on Oct 29, 2025

    @aneeshgarg

    I am starting to work on this

  6. unnmdnwb3 commented on Oct 30, 2025

    @unnmdnwb3
    Author

    馃憢 Thank you for your responses!

    @Jacksunwei Regarding your questions:

    1. I did try to update the state directly (e.g. tool_context.state[output_key] = result) but without success!
    2. I only worked with LiteLlm and did not try gemini-2.5-flash.

    @aneeshgarg Sounds great!

    Thanks to both of you in advance! 馃檹

  7. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    on Feb 6, 2026
  8. added a commit that references this issue on Feb 27, 2026
    fe595fd
  9. yuvrajangadsingh commented on Feb 27, 2026

    @yuvrajangadsingh
    Contributor

    opened a fix at #4642. adds an early return in __maybe_save_output_to_state when there are no text parts, so callback-set state_delta values don't get overwritten. @Jacksunwei @aneeshgarg would appreciate a review

  10. added 3 commits that reference this issue on Feb 28, 2026
    6db186d
    b7b6845
    b853c60
  11. 5 remaining items

  12. added 11 commits that reference this issue on Mar 11, 2026
    8fa6005
    4f25a05
    0f3c0f2
    ee2af79
    ad3b404
    9c92400
    24ea808
    d3bb3e0
    acc4fd2
    6fdb3fa
    f4cc2f2
  13. self-assigned this
    on May 5, 2026
  14. added a commit that references this issue on May 5, 2026
    fc27203
  15. added a commit that references this issue on Aug 11, 2026
    26259fc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementationhelp wanted[Community] Extra attention is neededneeds review[Status] The PR/issue is awaiting review from the maintainer

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions