-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(runner): add metadata parameter to Runner.run_async() #3985
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
3fd6c93
ace7e09
72c6208
79277df
7329a33
a33506f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -400,6 +400,7 @@ async def run_async( | |||||
| new_message: Optional[types.Content] = None, | ||||||
| state_delta: Optional[dict[str, Any]] = None, | ||||||
| run_config: Optional[RunConfig] = None, | ||||||
| metadata: Optional[dict[str, Any]] = None, | ||||||
| ) -> AsyncGenerator[Event, None]: | ||||||
| """Main entry method to run the agent in this runner. | ||||||
|
|
||||||
|
|
@@ -417,6 +418,9 @@ async def run_async( | |||||
| new_message: A new message to append to the session. | ||||||
| state_delta: Optional state changes to apply to the session. | ||||||
| run_config: The run config for the agent. | ||||||
| metadata: Optional per-request metadata that will be passed to callbacks. | ||||||
| This allows passing request-specific context such as user_id, trace_id, | ||||||
| or memory context keys to before_model_callback and other callbacks. | ||||||
|
|
||||||
| Yields: | ||||||
| The events generated by the agent. | ||||||
|
|
@@ -426,13 +430,16 @@ async def run_async( | |||||
| new_message are None. | ||||||
| """ | ||||||
| run_config = run_config or RunConfig() | ||||||
| # Create a shallow copy to isolate from caller's modifications | ||||||
| metadata = metadata.copy() if metadata else None | ||||||
|
||||||
| metadata = metadata.copy() if metadata else None | |
| metadata = metadata.copy() if metadata is not None else None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent accidental modification of the original metadata dictionary by the caller of run_async, it's a good practice to work with a copy of the metadata. Since dictionaries are mutable, any changes made to metadata within the runner's logic would also affect the caller's original dictionary. Creating a shallow copy here isolates the runner's execution context from the caller. This is especially important as run_async is an async generator, and the caller might modify the metadata dictionary while iterating over the yielded events.
| metadata=metadata, | |
| metadata=metadata.copy() if metadata is not None else None, |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,8 @@ | |
| from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService | ||
| from google.adk.cli.utils.agent_loader import AgentLoader | ||
| from google.adk.events.event import Event | ||
| from google.adk.models.llm_request import LlmRequest | ||
| from google.adk.models.llm_response import LlmResponse | ||
| from google.adk.plugins.base_plugin import BasePlugin | ||
| from google.adk.runners import Runner | ||
| from google.adk.sessions.in_memory_session_service import InMemorySessionService | ||
|
|
@@ -1038,5 +1040,142 @@ def test_infer_agent_origin_detects_mismatch_for_user_agent( | |
| assert "actual_name" in runner._app_name_alignment_hint | ||
|
|
||
|
|
||
| class TestRunnerMetadata: | ||
| """Tests for Runner metadata parameter functionality.""" | ||
|
Comment on lines
+1103
to
+1104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test suite for metadata is comprehensive for data propagation. It would be beneficial to also add a test case that explicitly verifies the behavior of the shallow copy of the
This would ensure that the isolation behavior is well-understood and prevent future regressions. |
||
|
|
||
| def setup_method(self): | ||
| """Set up test fixtures.""" | ||
| self.session_service = InMemorySessionService() | ||
| self.artifact_service = InMemoryArtifactService() | ||
| self.root_agent = MockLlmAgent("root_agent") | ||
| self.runner = Runner( | ||
| app_name="test_app", | ||
| agent=self.root_agent, | ||
| session_service=self.session_service, | ||
| artifact_service=self.artifact_service, | ||
| ) | ||
|
|
||
| def test_new_invocation_context_with_metadata(self): | ||
| """Test that _new_invocation_context correctly passes metadata.""" | ||
| mock_session = Session( | ||
| id=TEST_SESSION_ID, | ||
| app_name=TEST_APP_ID, | ||
| user_id=TEST_USER_ID, | ||
| events=[], | ||
| ) | ||
|
|
||
| test_metadata = {"user_id": "test123", "trace_id": "trace456"} | ||
| invocation_context = self.runner._new_invocation_context( | ||
| mock_session, metadata=test_metadata | ||
| ) | ||
|
|
||
| assert invocation_context.metadata == test_metadata | ||
| assert invocation_context.metadata["user_id"] == "test123" | ||
| assert invocation_context.metadata["trace_id"] == "trace456" | ||
|
|
||
| def test_new_invocation_context_without_metadata(self): | ||
| """Test that _new_invocation_context works without metadata.""" | ||
| mock_session = Session( | ||
| id=TEST_SESSION_ID, | ||
| app_name=TEST_APP_ID, | ||
| user_id=TEST_USER_ID, | ||
| events=[], | ||
| ) | ||
|
|
||
| invocation_context = self.runner._new_invocation_context(mock_session) | ||
|
|
||
| assert invocation_context.metadata is None | ||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_run_async_passes_metadata_to_invocation_context(self): | ||
| """Test that run_async correctly passes metadata to before_model_callback.""" | ||
| # Capture metadata received in callback | ||
| captured_metadata = None | ||
|
|
||
| def before_model_callback(callback_context, llm_request): | ||
| nonlocal captured_metadata | ||
| captured_metadata = llm_request.metadata | ||
| # Return a response to skip actual LLM call | ||
| return LlmResponse( | ||
| content=types.Content( | ||
| role="model", parts=[types.Part(text="Test response")] | ||
| ) | ||
| ) | ||
|
|
||
| # Create agent with before_model_callback | ||
| agent_with_callback = LlmAgent( | ||
| name="callback_agent", | ||
| model="gemini-2.0-flash", | ||
| before_model_callback=before_model_callback, | ||
| ) | ||
|
|
||
| runner_with_callback = Runner( | ||
| app_name="test_app", | ||
| agent=agent_with_callback, | ||
| session_service=self.session_service, | ||
| artifact_service=self.artifact_service, | ||
| ) | ||
|
|
||
| session = await self.session_service.create_session( | ||
| app_name=TEST_APP_ID, user_id=TEST_USER_ID, session_id=TEST_SESSION_ID | ||
| ) | ||
|
|
||
| test_metadata = {"experiment_id": "exp-001", "variant": "B"} | ||
|
|
||
| async for event in runner_with_callback.run_async( | ||
| user_id=TEST_USER_ID, | ||
| session_id=TEST_SESSION_ID, | ||
| new_message=types.Content( | ||
| role="user", parts=[types.Part(text="Hello")] | ||
| ), | ||
| metadata=test_metadata, | ||
| ): | ||
| pass | ||
|
|
||
| # Verify metadata was passed to before_model_callback | ||
| assert captured_metadata is not None | ||
| assert captured_metadata == test_metadata | ||
| assert captured_metadata["experiment_id"] == "exp-001" | ||
| assert captured_metadata["variant"] == "B" | ||
|
|
||
| def test_metadata_field_in_invocation_context(self): | ||
| """Test that InvocationContext model accepts metadata field.""" | ||
| mock_session = Session( | ||
| id=TEST_SESSION_ID, | ||
| app_name=TEST_APP_ID, | ||
| user_id=TEST_USER_ID, | ||
| events=[], | ||
| ) | ||
|
|
||
| test_metadata = {"key1": "value1", "key2": 123} | ||
|
|
||
| # This should not raise a validation error | ||
| invocation_context = InvocationContext( | ||
| session_service=self.session_service, | ||
| invocation_id="test_inv_id", | ||
| agent=self.root_agent, | ||
| session=mock_session, | ||
| metadata=test_metadata, | ||
| ) | ||
|
|
||
| assert invocation_context.metadata == test_metadata | ||
|
|
||
| def test_metadata_field_in_llm_request(self): | ||
| """Test that LlmRequest model accepts metadata field.""" | ||
| test_metadata = {"context_key": "ctx123", "user_info": {"name": "test"}} | ||
|
|
||
| llm_request = LlmRequest(metadata=test_metadata) | ||
|
|
||
| assert llm_request.metadata == test_metadata | ||
| assert llm_request.metadata["context_key"] == "ctx123" | ||
| assert llm_request.metadata["user_info"]["name"] == "test" | ||
|
|
||
| def test_llm_request_without_metadata(self): | ||
| """Test that LlmRequest works without metadata.""" | ||
| llm_request = LlmRequest() | ||
|
|
||
| assert llm_request.metadata is None | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| pytest.main([__file__]) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent potential subtle bugs, it's a good practice to clarify the copy behavior of the
metadatadictionary in the docstring. Since a shallow copy is performed, modifications to nested mutable objects within a callback will affect the original object passed by the caller. Please add a note about this to help users of the API understand this behavior and avoid unexpected side effects. For example, you could add:Note: A shallow copy is made of this dictionary, so changes to nested mutable objects will affect the original object.