From 3e7164b7a6fd16ad1c678731e59eb391574b6384 Mon Sep 17 00:00:00 2001 From: Maple Xu Date: Wed, 15 Apr 2026 13:09:15 -0400 Subject: [PATCH 1/5] AI-60: Add AdkActivityConfig with summary_fn for dynamic activity summaries Introduce AdkActivityConfig extending ActivityConfig with a summary_fn field that accepts a callable for dynamic per-call summaries. When no summary_fn or static summary is set, falls back to reading adk_agent_name from LlmRequest labels for zero-config agent name display. Setting both summary and summary_fn raises ValueError to prevent ambiguity. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../contrib/google_adk_agents/__init__.py | 3 +- .../contrib/google_adk_agents/_model.py | 40 ++++++-- .../test_google_adk_agents.py | 93 +++++++++++++++++++ 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/temporalio/contrib/google_adk_agents/__init__.py b/temporalio/contrib/google_adk_agents/__init__.py index 3f236516b..6bf06914f 100644 --- a/temporalio/contrib/google_adk_agents/__init__.py +++ b/temporalio/contrib/google_adk_agents/__init__.py @@ -7,12 +7,13 @@ TemporalMcpToolSet, TemporalMcpToolSetProvider, ) -from temporalio.contrib.google_adk_agents._model import TemporalModel +from temporalio.contrib.google_adk_agents._model import AdkActivityConfig, TemporalModel from temporalio.contrib.google_adk_agents._plugin import ( GoogleAdkPlugin, ) __all__ = [ + "AdkActivityConfig", "GoogleAdkPlugin", "TemporalMcpToolSet", "TemporalMcpToolSetProvider", diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 6d1e7ffa9..034892887 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from datetime import timedelta from google.adk.models import BaseLlm, LLMRegistry @@ -10,6 +10,12 @@ from temporalio.workflow import ActivityConfig +class AdkActivityConfig(ActivityConfig, total=False): + """Activity config with ADK-specific options.""" + + summary_fn: Callable[[LlmRequest], str | None] | None + + @activity.defn async def invoke_model(llm_request: LlmRequest) -> list[LlmResponse]: """Activity that invokes an LLM model. @@ -40,21 +46,29 @@ class TemporalModel(BaseLlm): """A Temporal-based LLM model that executes model invocations as activities.""" def __init__( - self, model_name: str, activity_config: ActivityConfig | None = None + self, + model_name: str, + activity_config: AdkActivityConfig | ActivityConfig | None = None, ) -> None: """Initialize the TemporalModel. Args: model_name: The name of the model to use. activity_config: Configuration options for the activity execution. + + Raises: + ValueError: If both 'summary' and 'summary_fn' are set. """ super().__init__(model=model_name) self._model_name = model_name - self._activity_config = ActivityConfig( - start_to_close_timeout=timedelta(seconds=60) - ) - if activity_config: - self._activity_config.update(activity_config) + raw = dict(activity_config) if activity_config else {} + self._summary_fn: Callable[[LlmRequest], str | None] | None = raw.pop( + "summary_fn", None + ) # type: ignore[assignment] + raw.setdefault("start_to_close_timeout", timedelta(seconds=60)) + if raw.get("summary") is not None and self._summary_fn is not None: + raise ValueError("Cannot specify both 'summary' and 'summary_fn'") + self._activity_config = ActivityConfig(**raw) # type: ignore[typeddict-item] async def generate_content_async( self, llm_request: LlmRequest, stream: bool = False @@ -76,10 +90,20 @@ async def generate_content_async( yield response return + config = self._activity_config.copy() + if self._summary_fn is not None: + summary = self._summary_fn(llm_request) + if summary is not None: + config["summary"] = summary + elif "summary" not in config: + if llm_request.config and llm_request.config.labels: + agent_name = llm_request.config.labels.get("adk_agent_name") + if agent_name: + config["summary"] = agent_name responses = await workflow.execute_activity( invoke_model, args=[llm_request], - **self._activity_config, + **config, ) for response in responses: yield response diff --git a/tests/contrib/google_adk_agents/test_google_adk_agents.py b/tests/contrib/google_adk_agents/test_google_adk_agents.py index e35d58ea6..b75ec016b 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -580,6 +580,99 @@ async def test_unsetting_timeout(): assert model._activity_config.get("start_to_close_timeout", None) is None +class SummaryFnModel(TestModel): + """Returns a single text response for summary_fn testing.""" + + def responses(self) -> list[LlmResponse]: + return [ + LlmResponse(content=Content(role="model", parts=[Part(text="response")])), + ] + + @classmethod + def supported_models(cls) -> list[str]: + return ["summary_fn_model"] + + +@workflow.defn +class SummaryFnWorkflow: + @workflow.run + async def run(self, model_name: str) -> str | None: + from temporalio.contrib.google_adk_agents import AdkActivityConfig + + agent = Agent( + name="summary_fn_agent", + model=TemporalModel( + model_name, + AdkActivityConfig(summary_fn=lambda req: f"Invoking {req.model}"), + ), + ) + runner = InMemoryRunner(agent=agent, app_name="summary_fn_app") + session = await runner.session_service.create_session( + app_name="summary_fn_app", user_id="test" + ) + last_event = None + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + ) + ) as agen: + async for event in agen: + last_event = event + if last_event and last_event.content and last_event.content.parts: + return last_event.content.parts[0].text + return None + + +@pytest.mark.asyncio +async def test_summary_fn_produces_dynamic_summary(client: Client): + """summary_fn in AdkActivityConfig sets summary on invoke_model activity.""" + new_config = client.config() + new_config["plugins"] = [GoogleAdkPlugin()] + client = Client(**new_config) + LLMRegistry.register(SummaryFnModel) + + async with Worker( + client, + task_queue="adk-summary-fn-test", + workflows=[SummaryFnWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + SummaryFnWorkflow.run, + "summary_fn_model", + id=f"summary-fn-{uuid.uuid4()}", + task_queue="adk-summary-fn-test", + execution_timeout=timedelta(seconds=60), + ) + await handle.result() + + found = False + async for e in handle.fetch_history_events(): + if e.HasField("activity_task_scheduled_event_attributes"): + attrs = e.activity_task_scheduled_event_attributes + if attrs.activity_type.name == "invoke_model": + assert ( + e.user_metadata.summary.data == b'"Invoking summary_fn_model"' + ) + found = True + assert found, "No invoke_model activity found in history" + + +def test_summary_and_summary_fn_raises(): + """Cannot specify both summary and summary_fn.""" + from temporalio.contrib.google_adk_agents import AdkActivityConfig + + with pytest.raises( + ValueError, match="Cannot specify both 'summary' and 'summary_fn'" + ): + TemporalModel( + "m", + AdkActivityConfig(summary="static", summary_fn=lambda req: "dynamic"), + ) + + @pytest.mark.asyncio async def test_agent_outside_workflow(): """Test that an agent using TemporalModel and activity_tool works outside a Temporal workflow.""" From 398b93c3fe4da4ac8d0b68ce86b84bfe1122f759 Mon Sep 17 00:00:00 2001 From: Maple Xu Date: Wed, 15 Apr 2026 13:25:25 -0400 Subject: [PATCH 2/5] =?UTF-8?q?AI-60:=20Address=20auditor=20findings=20?= =?UTF-8?q?=E2=80=94=20determinism=20note,=20summary=5Ffn=20None=20test,?= =?UTF-8?q?=20label=20fallback=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../contrib/google_adk_agents/_model.py | 8 +- .../test_google_adk_agents.py | 83 +++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 034892887..1d278683b 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -11,7 +11,13 @@ class AdkActivityConfig(ActivityConfig, total=False): - """Activity config with ADK-specific options.""" + """Activity config with ADK-specific options. + + Attributes: + summary_fn: Optional callable that receives the LlmRequest and returns + a summary string (or None). Must be deterministic as it is called + during workflow execution. + """ summary_fn: Callable[[LlmRequest], str | None] | None diff --git a/tests/contrib/google_adk_agents/test_google_adk_agents.py b/tests/contrib/google_adk_agents/test_google_adk_agents.py index b75ec016b..b8f7551fc 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -660,6 +660,89 @@ async def test_summary_fn_produces_dynamic_summary(client: Client): assert found, "No invoke_model activity found in history" +@workflow.defn +class SummaryFnNoneWorkflow: + @workflow.run + async def run(self, model_name: str) -> str | None: + from temporalio.contrib.google_adk_agents import AdkActivityConfig + + agent = Agent( + name="none_summary_agent", + model=TemporalModel( + model_name, + AdkActivityConfig(summary_fn=lambda req: None), + ), + ) + runner = InMemoryRunner(agent=agent, app_name="none_summary_app") + session = await runner.session_service.create_session( + app_name="none_summary_app", user_id="test" + ) + last_event = None + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + ) + ) as agen: + async for event in agen: + last_event = event + if last_event and last_event.content and last_event.content.parts: + return last_event.content.parts[0].text + return None + + +@pytest.mark.asyncio +async def test_summary_fn_returning_none(client: Client): + """summary_fn returning None means no summary on the activity.""" + new_config = client.config() + new_config["plugins"] = [GoogleAdkPlugin()] + client = Client(**new_config) + LLMRegistry.register(SummaryFnModel) + + async with Worker( + client, + task_queue="adk-summary-fn-none-test", + workflows=[SummaryFnNoneWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + SummaryFnNoneWorkflow.run, + "summary_fn_model", + id=f"summary-fn-none-{uuid.uuid4()}", + task_queue="adk-summary-fn-none-test", + execution_timeout=timedelta(seconds=60), + ) + await handle.result() + + async for e in handle.fetch_history_events(): + if e.HasField("activity_task_scheduled_event_attributes"): + attrs = e.activity_task_scheduled_event_attributes + if attrs.activity_type.name == "invoke_model": + assert not e.user_metadata.summary.data + + +def test_adk_agent_name_label_fallback(): + """When no summary or summary_fn, adk_agent_name label is used as summary.""" + model = TemporalModel("m") + request = LlmRequest( + model="m", + config=types.GenerateContentConfig(labels={"adk_agent_name": "my_agent"}), + ) + # Simulate what generate_content_async does + config = model._activity_config.copy() + if model._summary_fn is not None: + summary = model._summary_fn(request) + if summary is not None: + config["summary"] = summary + elif "summary" not in config: + if request.config and request.config.labels: + agent_name = request.config.labels.get("adk_agent_name") + if agent_name: + config["summary"] = agent_name + assert config.get("summary") == "my_agent" + + def test_summary_and_summary_fn_raises(): """Cannot specify both summary and summary_fn.""" from temporalio.contrib.google_adk_agents import AdkActivityConfig From 2022310de963bee3e081c1b63623c449c1c3f2c9 Mon Sep 17 00:00:00 2001 From: Maple Xu Date: Fri, 17 Apr 2026 13:01:14 -0400 Subject: [PATCH 3/5] AI-60: Switch to summary_fn keyword param, address auditor findings Drop AdkActivityConfig in favor of a keyword-only summary_fn parameter on TemporalModel. Zero type: ignore comments needed. Auditor findings addressed: - Label fallback test rewritten as integration test - Exception propagation documented in summary_fn docstring - Empty string summary test added Co-Authored-By: Claude Opus 4.6 (1M context) --- .../contrib/google_adk_agents/__init__.py | 3 +- .../contrib/google_adk_agents/_model.py | 37 ++--- .../test_google_adk_agents.py | 153 ++++++++++++++---- 3 files changed, 142 insertions(+), 51 deletions(-) diff --git a/temporalio/contrib/google_adk_agents/__init__.py b/temporalio/contrib/google_adk_agents/__init__.py index 6bf06914f..3f236516b 100644 --- a/temporalio/contrib/google_adk_agents/__init__.py +++ b/temporalio/contrib/google_adk_agents/__init__.py @@ -7,13 +7,12 @@ TemporalMcpToolSet, TemporalMcpToolSetProvider, ) -from temporalio.contrib.google_adk_agents._model import AdkActivityConfig, TemporalModel +from temporalio.contrib.google_adk_agents._model import TemporalModel from temporalio.contrib.google_adk_agents._plugin import ( GoogleAdkPlugin, ) __all__ = [ - "AdkActivityConfig", "GoogleAdkPlugin", "TemporalMcpToolSet", "TemporalMcpToolSetProvider", diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 1d278683b..4f5cb69e7 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -10,18 +10,6 @@ from temporalio.workflow import ActivityConfig -class AdkActivityConfig(ActivityConfig, total=False): - """Activity config with ADK-specific options. - - Attributes: - summary_fn: Optional callable that receives the LlmRequest and returns - a summary string (or None). Must be deterministic as it is called - during workflow execution. - """ - - summary_fn: Callable[[LlmRequest], str | None] | None - - @activity.defn async def invoke_model(llm_request: LlmRequest) -> list[LlmResponse]: """Activity that invokes an LLM model. @@ -54,27 +42,34 @@ class TemporalModel(BaseLlm): def __init__( self, model_name: str, - activity_config: AdkActivityConfig | ActivityConfig | None = None, + activity_config: ActivityConfig | None = None, + *, + summary_fn: Callable[[LlmRequest], str | None] | None = None, ) -> None: """Initialize the TemporalModel. Args: model_name: The name of the model to use. activity_config: Configuration options for the activity execution. + summary_fn: Optional callable that receives the LlmRequest and + returns a summary string (or None) for the activity. Must be + deterministic as it is called during workflow execution. If + the callable raises, the exception will propagate and fail + the workflow task. Raises: ValueError: If both 'summary' and 'summary_fn' are set. """ super().__init__(model=model_name) self._model_name = model_name - raw = dict(activity_config) if activity_config else {} - self._summary_fn: Callable[[LlmRequest], str | None] | None = raw.pop( - "summary_fn", None - ) # type: ignore[assignment] - raw.setdefault("start_to_close_timeout", timedelta(seconds=60)) - if raw.get("summary") is not None and self._summary_fn is not None: - raise ValueError("Cannot specify both 'summary' and 'summary_fn'") - self._activity_config = ActivityConfig(**raw) # type: ignore[typeddict-item] + self._summary_fn = summary_fn + self._activity_config = ActivityConfig( + start_to_close_timeout=timedelta(seconds=60) + ) + if activity_config is not None: + if summary_fn is not None and activity_config.get("summary") is not None: + raise ValueError("Cannot specify both 'summary' and 'summary_fn'") + self._activity_config.update(activity_config) async def generate_content_async( self, llm_request: LlmRequest, stream: bool = False diff --git a/tests/contrib/google_adk_agents/test_google_adk_agents.py b/tests/contrib/google_adk_agents/test_google_adk_agents.py index b8f7551fc..0b6bbb395 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -597,13 +597,11 @@ def supported_models(cls) -> list[str]: class SummaryFnWorkflow: @workflow.run async def run(self, model_name: str) -> str | None: - from temporalio.contrib.google_adk_agents import AdkActivityConfig - agent = Agent( name="summary_fn_agent", model=TemporalModel( model_name, - AdkActivityConfig(summary_fn=lambda req: f"Invoking {req.model}"), + summary_fn=lambda req: f"Invoking {req.model}", ), ) runner = InMemoryRunner(agent=agent, app_name="summary_fn_app") @@ -627,7 +625,7 @@ async def run(self, model_name: str) -> str | None: @pytest.mark.asyncio async def test_summary_fn_produces_dynamic_summary(client: Client): - """summary_fn in AdkActivityConfig sets summary on invoke_model activity.""" + """summary_fn on TemporalModel sets summary on invoke_model activity.""" new_config = client.config() new_config["plugins"] = [GoogleAdkPlugin()] client = Client(**new_config) @@ -664,13 +662,11 @@ async def test_summary_fn_produces_dynamic_summary(client: Client): class SummaryFnNoneWorkflow: @workflow.run async def run(self, model_name: str) -> str | None: - from temporalio.contrib.google_adk_agents import AdkActivityConfig - agent = Agent( name="none_summary_agent", model=TemporalModel( model_name, - AdkActivityConfig(summary_fn=lambda req: None), + summary_fn=lambda req: None, ), ) runner = InMemoryRunner(agent=agent, app_name="none_summary_app") @@ -722,37 +718,138 @@ async def test_summary_fn_returning_none(client: Client): assert not e.user_metadata.summary.data -def test_adk_agent_name_label_fallback(): +@workflow.defn +class SummaryFnEmptyStringWorkflow: + @workflow.run + async def run(self, model_name: str) -> str | None: + agent = Agent( + name="empty_summary_agent", + model=TemporalModel( + model_name, + summary_fn=lambda req: "", + ), + ) + runner = InMemoryRunner(agent=agent, app_name="empty_summary_app") + session = await runner.session_service.create_session( + app_name="empty_summary_app", user_id="test" + ) + last_event = None + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + ) + ) as agen: + async for event in agen: + last_event = event + if last_event and last_event.content and last_event.content.parts: + return last_event.content.parts[0].text + return None + + +@pytest.mark.asyncio +async def test_summary_fn_empty_string(client: Client): + """summary_fn returning empty string is a valid summary.""" + new_config = client.config() + new_config["plugins"] = [GoogleAdkPlugin()] + client = Client(**new_config) + LLMRegistry.register(SummaryFnModel) + + async with Worker( + client, + task_queue="adk-summary-fn-empty-test", + workflows=[SummaryFnEmptyStringWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + SummaryFnEmptyStringWorkflow.run, + "summary_fn_model", + id=f"summary-fn-empty-{uuid.uuid4()}", + task_queue="adk-summary-fn-empty-test", + execution_timeout=timedelta(seconds=60), + ) + await handle.result() + + found = False + async for e in handle.fetch_history_events(): + if e.HasField("activity_task_scheduled_event_attributes"): + attrs = e.activity_task_scheduled_event_attributes + if attrs.activity_type.name == "invoke_model": + assert e.user_metadata.summary.data == b"" + found = True + assert found, "No invoke_model activity found in history" + + +@workflow.defn +class LabelFallbackWorkflow: + @workflow.run + async def run(self, model_name: str) -> str | None: + agent = Agent( + name="label_fallback_agent", + model=TemporalModel(model_name), + ) + runner = InMemoryRunner(agent=agent, app_name="label_fallback_app") + session = await runner.session_service.create_session( + app_name="label_fallback_app", user_id="test" + ) + last_event = None + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + ) + ) as agen: + async for event in agen: + last_event = event + if last_event and last_event.content and last_event.content.parts: + return last_event.content.parts[0].text + return None + + +@pytest.mark.asyncio +async def test_adk_agent_name_label_fallback(client: Client): """When no summary or summary_fn, adk_agent_name label is used as summary.""" - model = TemporalModel("m") - request = LlmRequest( - model="m", - config=types.GenerateContentConfig(labels={"adk_agent_name": "my_agent"}), - ) - # Simulate what generate_content_async does - config = model._activity_config.copy() - if model._summary_fn is not None: - summary = model._summary_fn(request) - if summary is not None: - config["summary"] = summary - elif "summary" not in config: - if request.config and request.config.labels: - agent_name = request.config.labels.get("adk_agent_name") - if agent_name: - config["summary"] = agent_name - assert config.get("summary") == "my_agent" + new_config = client.config() + new_config["plugins"] = [GoogleAdkPlugin()] + client = Client(**new_config) + LLMRegistry.register(SummaryFnModel) + + async with Worker( + client, + task_queue="adk-label-fallback-test", + workflows=[LabelFallbackWorkflow], + max_cached_workflows=0, + ): + handle = await client.start_workflow( + LabelFallbackWorkflow.run, + "summary_fn_model", + id=f"label-fallback-{uuid.uuid4()}", + task_queue="adk-label-fallback-test", + execution_timeout=timedelta(seconds=60), + ) + await handle.result() + + found = False + async for e in handle.fetch_history_events(): + if e.HasField("activity_task_scheduled_event_attributes"): + attrs = e.activity_task_scheduled_event_attributes + if attrs.activity_type.name == "invoke_model": + assert e.user_metadata.summary.data == b'"label_fallback_agent"' + found = True + assert found, "No invoke_model activity found in history" def test_summary_and_summary_fn_raises(): """Cannot specify both summary and summary_fn.""" - from temporalio.contrib.google_adk_agents import AdkActivityConfig - with pytest.raises( ValueError, match="Cannot specify both 'summary' and 'summary_fn'" ): TemporalModel( "m", - AdkActivityConfig(summary="static", summary_fn=lambda req: "dynamic"), + activity_config=ActivityConfig(summary="static"), + summary_fn=lambda req: "dynamic", ) From f96fab4bb91f96d88382e2608c70820c30e9e008 Mon Sep 17 00:00:00 2001 From: Maple Xu Date: Fri, 17 Apr 2026 14:10:40 -0400 Subject: [PATCH 4/5] AI-60: Qualify 'summary' as ActivityConfig summary in error message and docstring Co-Authored-By: Claude Opus 4.6 (1M context) --- temporalio/contrib/google_adk_agents/_model.py | 6 ++++-- tests/contrib/google_adk_agents/test_google_adk_agents.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 4f5cb69e7..8b32a7432 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -58,7 +58,7 @@ def __init__( the workflow task. Raises: - ValueError: If both 'summary' and 'summary_fn' are set. + ValueError: If both ``ActivityConfig["summary"]`` and ``summary_fn`` are set. """ super().__init__(model=model_name) self._model_name = model_name @@ -68,7 +68,9 @@ def __init__( ) if activity_config is not None: if summary_fn is not None and activity_config.get("summary") is not None: - raise ValueError("Cannot specify both 'summary' and 'summary_fn'") + raise ValueError( + "Cannot specify both ActivityConfig 'summary' and 'summary_fn'" + ) self._activity_config.update(activity_config) async def generate_content_async( diff --git a/tests/contrib/google_adk_agents/test_google_adk_agents.py b/tests/contrib/google_adk_agents/test_google_adk_agents.py index 0b6bbb395..82ecb861c 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -844,7 +844,8 @@ async def test_adk_agent_name_label_fallback(client: Client): def test_summary_and_summary_fn_raises(): """Cannot specify both summary and summary_fn.""" with pytest.raises( - ValueError, match="Cannot specify both 'summary' and 'summary_fn'" + ValueError, + match="Cannot specify both ActivityConfig 'summary' and 'summary_fn'", ): TemporalModel( "m", From 81d52383b8eba72df4b66fe78f358c4770fba5c0 Mon Sep 17 00:00:00 2001 From: Maple Xu Date: Tue, 21 Apr 2026 13:48:13 -0400 Subject: [PATCH 5/5] AI-60: Consolidate summary tests into single workflow run Run all 4 summary_fn variants (dynamic, None, empty, label fallback) as sequential agent invocations within one workflow, reducing CI overhead. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test_google_adk_agents.py | 263 +++--------------- 1 file changed, 43 insertions(+), 220 deletions(-) diff --git a/tests/contrib/google_adk_agents/test_google_adk_agents.py b/tests/contrib/google_adk_agents/test_google_adk_agents.py index 82ecb861c..2bea29efd 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -594,223 +594,40 @@ def supported_models(cls) -> list[str]: @workflow.defn -class SummaryFnWorkflow: +class SummaryTestWorkflow: @workflow.run - async def run(self, model_name: str) -> str | None: - agent = Agent( - name="summary_fn_agent", - model=TemporalModel( - model_name, - summary_fn=lambda req: f"Invoking {req.model}", - ), - ) - runner = InMemoryRunner(agent=agent, app_name="summary_fn_app") - session = await runner.session_service.create_session( - app_name="summary_fn_app", user_id="test" - ) - last_event = None - async with Aclosing( - runner.run_async( - user_id="test", - session_id=session.id, - new_message=types.Content(role="user", parts=[types.Part(text="hi")]), - ) - ) as agen: - async for event in agen: - last_event = event - if last_event and last_event.content and last_event.content.parts: - return last_event.content.parts[0].text - return None - - -@pytest.mark.asyncio -async def test_summary_fn_produces_dynamic_summary(client: Client): - """summary_fn on TemporalModel sets summary on invoke_model activity.""" - new_config = client.config() - new_config["plugins"] = [GoogleAdkPlugin()] - client = Client(**new_config) - LLMRegistry.register(SummaryFnModel) - - async with Worker( - client, - task_queue="adk-summary-fn-test", - workflows=[SummaryFnWorkflow], - max_cached_workflows=0, - ): - handle = await client.start_workflow( - SummaryFnWorkflow.run, - "summary_fn_model", - id=f"summary-fn-{uuid.uuid4()}", - task_queue="adk-summary-fn-test", - execution_timeout=timedelta(seconds=60), - ) - await handle.result() - - found = False - async for e in handle.fetch_history_events(): - if e.HasField("activity_task_scheduled_event_attributes"): - attrs = e.activity_task_scheduled_event_attributes - if attrs.activity_type.name == "invoke_model": - assert ( - e.user_metadata.summary.data == b'"Invoking summary_fn_model"' - ) - found = True - assert found, "No invoke_model activity found in history" - - -@workflow.defn -class SummaryFnNoneWorkflow: - @workflow.run - async def run(self, model_name: str) -> str | None: - agent = Agent( - name="none_summary_agent", - model=TemporalModel( - model_name, - summary_fn=lambda req: None, - ), - ) - runner = InMemoryRunner(agent=agent, app_name="none_summary_app") - session = await runner.session_service.create_session( - app_name="none_summary_app", user_id="test" - ) - last_event = None - async with Aclosing( - runner.run_async( - user_id="test", - session_id=session.id, - new_message=types.Content(role="user", parts=[types.Part(text="hi")]), - ) - ) as agen: - async for event in agen: - last_event = event - if last_event and last_event.content and last_event.content.parts: - return last_event.content.parts[0].text - return None - - -@pytest.mark.asyncio -async def test_summary_fn_returning_none(client: Client): - """summary_fn returning None means no summary on the activity.""" - new_config = client.config() - new_config["plugins"] = [GoogleAdkPlugin()] - client = Client(**new_config) - LLMRegistry.register(SummaryFnModel) - - async with Worker( - client, - task_queue="adk-summary-fn-none-test", - workflows=[SummaryFnNoneWorkflow], - max_cached_workflows=0, - ): - handle = await client.start_workflow( - SummaryFnNoneWorkflow.run, - "summary_fn_model", - id=f"summary-fn-none-{uuid.uuid4()}", - task_queue="adk-summary-fn-none-test", - execution_timeout=timedelta(seconds=60), - ) - await handle.result() - - async for e in handle.fetch_history_events(): - if e.HasField("activity_task_scheduled_event_attributes"): - attrs = e.activity_task_scheduled_event_attributes - if attrs.activity_type.name == "invoke_model": - assert not e.user_metadata.summary.data - - -@workflow.defn -class SummaryFnEmptyStringWorkflow: - @workflow.run - async def run(self, model_name: str) -> str | None: - agent = Agent( - name="empty_summary_agent", - model=TemporalModel( - model_name, - summary_fn=lambda req: "", - ), - ) - runner = InMemoryRunner(agent=agent, app_name="empty_summary_app") - session = await runner.session_service.create_session( - app_name="empty_summary_app", user_id="test" - ) - last_event = None - async with Aclosing( - runner.run_async( - user_id="test", - session_id=session.id, - new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + async def run(self, model_name: str) -> None: + modes = [ + ("dynamic", lambda req: f"Invoking {req.model}"), + ("none", lambda req: None), + ("empty", lambda req: ""), + ("label_fallback", None), + ] + for mode_name, summary_fn in modes: + agent = Agent( + name=f"summary_test_{mode_name}", + model=TemporalModel(model_name, summary_fn=summary_fn), ) - ) as agen: - async for event in agen: - last_event = event - if last_event and last_event.content and last_event.content.parts: - return last_event.content.parts[0].text - return None - - -@pytest.mark.asyncio -async def test_summary_fn_empty_string(client: Client): - """summary_fn returning empty string is a valid summary.""" - new_config = client.config() - new_config["plugins"] = [GoogleAdkPlugin()] - client = Client(**new_config) - LLMRegistry.register(SummaryFnModel) - - async with Worker( - client, - task_queue="adk-summary-fn-empty-test", - workflows=[SummaryFnEmptyStringWorkflow], - max_cached_workflows=0, - ): - handle = await client.start_workflow( - SummaryFnEmptyStringWorkflow.run, - "summary_fn_model", - id=f"summary-fn-empty-{uuid.uuid4()}", - task_queue="adk-summary-fn-empty-test", - execution_timeout=timedelta(seconds=60), - ) - await handle.result() - - found = False - async for e in handle.fetch_history_events(): - if e.HasField("activity_task_scheduled_event_attributes"): - attrs = e.activity_task_scheduled_event_attributes - if attrs.activity_type.name == "invoke_model": - assert e.user_metadata.summary.data == b"" - found = True - assert found, "No invoke_model activity found in history" - - -@workflow.defn -class LabelFallbackWorkflow: - @workflow.run - async def run(self, model_name: str) -> str | None: - agent = Agent( - name="label_fallback_agent", - model=TemporalModel(model_name), - ) - runner = InMemoryRunner(agent=agent, app_name="label_fallback_app") - session = await runner.session_service.create_session( - app_name="label_fallback_app", user_id="test" - ) - last_event = None - async with Aclosing( - runner.run_async( - user_id="test", - session_id=session.id, - new_message=types.Content(role="user", parts=[types.Part(text="hi")]), + runner = InMemoryRunner(agent=agent, app_name=f"summary_{mode_name}") + session = await runner.session_service.create_session( + app_name=f"summary_{mode_name}", user_id="test" ) - ) as agen: - async for event in agen: - last_event = event - if last_event and last_event.content and last_event.content.parts: - return last_event.content.parts[0].text - return None + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content( + role="user", parts=[types.Part(text="hi")] + ), + ) + ) as agen: + async for _ in agen: + pass @pytest.mark.asyncio -async def test_adk_agent_name_label_fallback(client: Client): - """When no summary or summary_fn, adk_agent_name label is used as summary.""" +async def test_summary_fn_variants(client: Client): + """Test summary_fn with dynamic, None, empty string, and label fallback.""" new_config = client.config() new_config["plugins"] = [GoogleAdkPlugin()] client = Client(**new_config) @@ -818,27 +635,33 @@ async def test_adk_agent_name_label_fallback(client: Client): async with Worker( client, - task_queue="adk-label-fallback-test", - workflows=[LabelFallbackWorkflow], + task_queue="adk-summary-test", + workflows=[SummaryTestWorkflow], max_cached_workflows=0, ): handle = await client.start_workflow( - LabelFallbackWorkflow.run, + SummaryTestWorkflow.run, "summary_fn_model", - id=f"label-fallback-{uuid.uuid4()}", - task_queue="adk-label-fallback-test", + id=f"summary-test-{uuid.uuid4()}", + task_queue="adk-summary-test", execution_timeout=timedelta(seconds=60), ) await handle.result() - found = False + summaries = [] async for e in handle.fetch_history_events(): if e.HasField("activity_task_scheduled_event_attributes"): attrs = e.activity_task_scheduled_event_attributes if attrs.activity_type.name == "invoke_model": - assert e.user_metadata.summary.data == b'"label_fallback_agent"' - found = True - assert found, "No invoke_model activity found in history" + summaries.append(e.user_metadata.summary.data) + + assert len(summaries) == 4 + assert summaries[0] == b'"Invoking summary_fn_model"' # dynamic + assert summaries[1] == b"" # none + assert summaries[2] == b"" # empty + assert ( + summaries[3] == b'"summary_test_label_fallback"' + ) # label fallback agent name def test_summary_and_summary_fn_raises():