Skip to content

Overridden state_delta by empty string in after_tool_callback after skip_summarization #3178

@unnmdnwb3

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!

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementationhelp wanted[Community] Extra attention is needed

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions