Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/google/adk/plugins/base_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,24 @@ async def on_tool_error_callback(
allows the original error to be raised.
"""
pass

async def on_state_change_callback(
self,
*,
invocation_context: InvocationContext,
state_delta: dict[str, Any],
) -> None:
"""Callback executed when session state changes via event.actions.state_delta.

This callback is invoked whenever an event with a non-empty state_delta
is yielded from the runner, allowing plugins to observe and log state
changes. The callback is observational only - the return value is ignored.

Args:
invocation_context: The context for the entire invocation.
state_delta: A copy of the state changes. Plugins should not mutate this.

Returns:
None
"""
pass
22 changes: 22 additions & 0 deletions src/google/adk/plugins/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"after_model_callback",
"on_tool_error_callback",
"on_model_error_callback",
"on_state_change_callback",
]

logger = logging.getLogger("google_adk." + __name__)
Expand Down Expand Up @@ -257,6 +258,27 @@ async def run_on_tool_error_callback(
error=error,
)

async def run_on_state_change_callback(
self,
*,
invocation_context: InvocationContext,
state_delta: dict[str, Any],
) -> None:
"""Runs the `on_state_change_callback` for all plugins.

Args:
invocation_context: The invocation context.
state_delta: A copy of the state changes.

Returns:
None. This callback is observational only.
"""
await self._run_callbacks(
"on_state_change_callback",
invocation_context=invocation_context,
state_delta=state_delta,
)
Comment on lines +276 to +280
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using _run_callbacks here introduces an early-exit behavior if any plugin returns a non-None value. For an observational callback like on_state_change_callback, all registered plugins should be notified regardless of the return values of others. The current implementation could lead to some plugins missing state change notifications if a preceding plugin in the chain returns a value.

To ensure all plugins are reliably called, consider implementing this method by looping through the plugins directly. This would align better with the 'observational only' nature of this callback.

    for plugin in self.plugins:
      callback_method = getattr(plugin, "on_state_change_callback")
      try:
        await callback_method(
            invocation_context=invocation_context,
            state_delta=state_delta,
        )
      except Exception as e:
        error_message = (
            f"Error in plugin '{plugin.name}' during 'on_state_change_callback'"
            f" callback: {e}"
        )
        logger.error(error_message, exc_info=True)
        raise RuntimeError(error_message) from e


async def _run_callbacks(
self, callback_name: PluginCallbackName, **kwargs: Any
) -> Optional[Any]:
Expand Down
12 changes: 12 additions & 0 deletions src/google/adk/runners.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,8 +840,20 @@ async def _exec_with_plugin(
modified_event, invocation_context.run_config
)
yield modified_event
# Detect state_delta changes after yielding the modified event
if modified_event.actions.state_delta:
await plugin_manager.run_on_state_change_callback(
invocation_context=invocation_context,
state_delta=modified_event.actions.state_delta.copy(),
)
else:
yield event
# Detect state_delta changes after yielding the original event
if event.actions.state_delta:
await plugin_manager.run_on_state_change_callback(
invocation_context=invocation_context,
state_delta=event.actions.state_delta.copy(),
)
Comment on lines +843 to +856
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to detect and handle state_delta is duplicated in both the if modified_event: block and the else block. To improve maintainability and reduce redundancy, this could be refactored. A single block of code after the if/else could determine which event to yield (modified_event or event) and then perform the state_delta check on that final event.


# Step 4: Run the after_run callbacks to perform global cleanup tasks or
# finalizing logs and metrics data.
Expand Down