From 36986bd3982ca4e38e0851f920e672110611dec8 Mon Sep 17 00:00:00 2001 From: devjoinedthechat <235875317+devjoinedthechat@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:15:26 +0200 Subject: [PATCH 1/3] Fix Google ADK agents with output_schema ADK stores an agent's output_schema on the LlmRequest as a Python type, which the payload converter cannot serialize, so every workflow task failed when scheduling the model activity. Send the type's JSON schema instead; google-genai and LiteLlm derive the same schema from the type. --- CHANGELOG.md | 2 + .../contrib/google_adk_agents/_model.py | 22 ++++ .../test_google_adk_agents.py | 110 ++++++++++++++++++ 3 files changed, 134 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a2243ed..607c712b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ to include examples, links to docs, or any other relevant information. - `GoogleAdkPlugin` now passes the optional `anthropic`, `litellm`, and `openai` SDKs through the workflow sandbox. +- `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task + when calling the model. The schema type is now sent to the model activity as its JSON schema. - `contrib.deepagents`: prevent duplicate input messages after continue-as-new. - `DataConverter.payload_converter` and current workflow and activity payload converter accessors now return the configured converter without SDK-internal transfer type conversion. diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 1992d0f4c..71e4a641c 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -5,6 +5,8 @@ from google.adk.models import BaseLlm, LLMRegistry from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse +from google.genai import types +from pydantic import TypeAdapter import temporalio.workflow from temporalio import activity, workflow @@ -91,6 +93,25 @@ async def invoke_model_streaming( return responses +def _with_serializable_response_schema(llm_request: LlmRequest) -> LlmRequest: + """Return the request with a ``response_schema`` that can be serialized. + + ADK stores an agent's ``output_schema`` on the request as a Python type + (for example a Pydantic model class), which the payload converter cannot + serialize. google-genai and ADK's LiteLlm both turn such a type into its + JSON schema before calling the model, so sending the JSON schema instead + is equivalent. + """ + schema = llm_request.config.response_schema + if schema is None or isinstance(schema, (dict, types.Schema)): + return llm_request + request = llm_request.model_copy() + request.config = llm_request.config.model_copy( + update={"response_schema": TypeAdapter(schema).json_schema()} + ) + return request + + class TemporalModel(BaseLlm): """A Temporal-based LLM model that executes model invocations as activities.""" @@ -183,6 +204,7 @@ async def generate_content_async( if agent_name: config["summary"] = agent_name + llm_request = _with_serializable_response_schema(llm_request) if stream: if self._streaming_topic is None: raise ApplicationError( 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 fac3138d2..37677ee7d 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -43,6 +43,7 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.trace import get_tracer_provider, set_tracer_provider +from pydantic import BaseModel, TypeAdapter import temporalio.contrib.google_adk_agents.workflow from temporalio import activity, workflow @@ -53,6 +54,9 @@ TemporalMcpToolSetProvider, TemporalModel, ) +from temporalio.contrib.google_adk_agents._model import ( + _with_serializable_response_schema, +) from temporalio.contrib.opentelemetry import OpenTelemetryPlugin, create_tracer_provider from temporalio.worker import Worker from temporalio.workflow import ActivityConfig @@ -1168,3 +1172,109 @@ async def my_activity(city: str, count: int = 1) -> str: assert params == ["city", "count"] assert sig.parameters["city"].annotation is str assert sig.parameters["count"].default == 1 + + +class CityWeather(BaseModel): + city: str + temperature_c: float + + +class OutputSchemaModel(TestModel): + def responses(self) -> list[LlmResponse]: + return [ + LlmResponse( + content=Content( + role="model", + parts=[Part(text='{"city": "Paris", "temperature_c": 17.5}')], + ) + ) + ] + + @classmethod + def supported_models(cls) -> list[str]: + return ["output_schema_model"] + + +@workflow.defn +class OutputSchemaAgentWorkflow: + @workflow.run + async def run(self, prompt: str) -> dict[str, Any] | None: + agent = LlmAgent( + name="output_schema_agent", + model=TemporalModel("output_schema_model"), + output_schema=CityWeather, + output_key="weather", + ) + runner = InMemoryRunner(agent=agent, app_name="output_schema_app") + session = await runner.session_service.create_session( + app_name="output_schema_app", user_id="test" + ) + async with Aclosing( + runner.run_async( + user_id="test", + session_id=session.id, + new_message=types.Content(role="user", parts=[types.Part(text=prompt)]), + ) + ) as agen: + async for _ in agen: + pass + + final_session = await runner.session_service.get_session( + app_name="output_schema_app", user_id="test", session_id=session.id + ) + return final_session.state.get("weather") if final_session else None + + +@pytest.mark.asyncio +async def test_agent_with_output_schema(client: Client): + LLMRegistry.register(OutputSchemaModel) + + new_config = client.config() + new_config["plugins"] = [GoogleAdkPlugin()] + client = Client(**new_config) + + async with Worker( + client, + task_queue="adk-task-queue-output-schema", + workflows=[OutputSchemaAgentWorkflow], + max_cached_workflows=0, + ): + result = await client.execute_workflow( + OutputSchemaAgentWorkflow.run, + "What is the weather in Paris?", + id=f"output-schema-agent-workflow-{uuid.uuid4()}", + task_queue="adk-task-queue-output-schema", + execution_timeout=timedelta(seconds=60), + ) + + assert result == {"city": "Paris", "temperature_c": 17.5} + + +@pytest.mark.parametrize("schema", [CityWeather, list[CityWeather]]) +def test_output_schema_type_sent_as_json_schema(schema: Any) -> None: + request = LlmRequest( + model="gemini-2.0-flash", + contents=[Content(role="user", parts=[Part(text="hello")])], + config=types.GenerateContentConfig(), + ) + request.set_output_schema(schema) + + converted = _with_serializable_response_schema(request) + + assert request.config.response_schema is schema + assert converted.config.response_mime_type == "application/json" + converter = GoogleAdkPlugin()._configure_data_converter(None) + payloads = converter.payload_converter.to_payloads([converted]) + serialized = json.loads(payloads[0].data) + assert serialized["config"]["response_schema"] == TypeAdapter(schema).json_schema() + + +def test_json_output_schema_left_unchanged() -> None: + request = LlmRequest( + model="gemini-2.0-flash", + contents=[Content(role="user", parts=[Part(text="hello")])], + config=types.GenerateContentConfig(), + ) + request.set_output_schema(CityWeather.model_json_schema()) + + assert _with_serializable_response_schema(request) is request From 3d99b95d25f0962b2ae1fb14274c1cd8ebb106bc Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 1 Oct 2026 14:45:02 -0700 Subject: [PATCH 2/3] Normalize integer output enums for Google ADK --- CHANGELOG.md | 1 + .../contrib/google_adk_agents/_model.py | 14 ++++- .../test_google_adk_agents.py | 51 ++++++++++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251d64443..828fab631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ to include examples, links to docs, or any other relevant information. the workflow sandbox. - `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task when calling the model. The schema type is now sent to the model activity as its JSON schema. + Integer-valued output enums are normalized to strings to match Google GenAI. - `GoogleAdkPlugin` now passes OpenTelemetry modules through the workflow sandbox so ADK 2.9 graph workflows can load their context support during execution. - `contrib.deepagents`: prevent duplicate input messages after continue-as-new. diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index 71e4a641c..cb04a3d9d 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -1,6 +1,7 @@ from collections.abc import AsyncGenerator, Callable from dataclasses import dataclass from datetime import timedelta +from enum import Enum from google.adk.models import BaseLlm, LLMRegistry from google.adk.models.llm_request import LlmRequest @@ -100,14 +101,23 @@ def _with_serializable_response_schema(llm_request: LlmRequest) -> LlmRequest: (for example a Pydantic model class), which the payload converter cannot serialize. google-genai and ADK's LiteLlm both turn such a type into its JSON schema before calling the model, so sending the JSON schema instead - is equivalent. + is equivalent. Integer-valued enums are normalized to string enums to + match google-genai's enum handling. """ schema = llm_request.config.response_schema if schema is None or isinstance(schema, (dict, types.Schema)): return llm_request + response_schema = TypeAdapter(schema).json_schema() + if ( + isinstance(schema, type) + and issubclass(schema, Enum) + and any(isinstance(member.value, int) for member in schema) + ): + response_schema["type"] = "string" + response_schema["enum"] = [str(member.value) for member in schema] request = llm_request.model_copy() request.config = llm_request.config.model_copy( - update={"response_schema": TypeAdapter(schema).json_schema()} + update={"response_schema": response_schema} ) return request 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 37677ee7d..2affe553c 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -22,6 +22,7 @@ from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from datetime import timedelta +from enum import Enum, IntEnum from typing import Any import pytest @@ -1179,6 +1180,26 @@ class CityWeather(BaseModel): temperature_c: float +class NumericChoice(IntEnum): + FIRST = 10 + SECOND = 20 + + +class IntegerChoice(Enum): + FIRST = 1 + SECOND = 2 + + +class MixedChoice(Enum): + FIRST = 1 + SECOND = "other" + + +class StringChoice(str, Enum): + FIRST = "first" + SECOND = "second" + + class OutputSchemaModel(TestModel): def responses(self) -> list[LlmResponse]: return [ @@ -1250,7 +1271,7 @@ async def test_agent_with_output_schema(client: Client): assert result == {"city": "Paris", "temperature_c": 17.5} -@pytest.mark.parametrize("schema", [CityWeather, list[CityWeather]]) +@pytest.mark.parametrize("schema", [CityWeather, list[CityWeather], StringChoice]) def test_output_schema_type_sent_as_json_schema(schema: Any) -> None: request = LlmRequest( model="gemini-2.0-flash", @@ -1269,6 +1290,34 @@ def test_output_schema_type_sent_as_json_schema(schema: Any) -> None: assert serialized["config"]["response_schema"] == TypeAdapter(schema).json_schema() +@pytest.mark.parametrize( + ("schema", "expected_values"), + [ + (NumericChoice, ["10", "20"]), + (IntegerChoice, ["1", "2"]), + (MixedChoice, ["1", "other"]), + ], +) +def test_output_schema_integer_enum_is_serializable( + schema: type[Enum], expected_values: list[str] +) -> None: + request = LlmRequest( + model="gemini-2.0-flash", + config=types.GenerateContentConfig(), + ) + request.set_output_schema(schema) + + converted = _with_serializable_response_schema(request) + + assert request.config.response_schema is schema + converter = GoogleAdkPlugin()._configure_data_converter(None).payload_converter + payloads = converter.to_payloads([converted]) + restored = converter.from_payloads(payloads, [LlmRequest])[0] + response_schema = types.Schema.model_validate(restored.config.response_schema) + assert response_schema.type == types.Type.STRING + assert response_schema.enum == expected_values + + def test_json_output_schema_left_unchanged() -> None: request = LlmRequest( model="gemini-2.0-flash", From edb625841d8fbe4f31327deac8042e40ba4eb1fc Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Thu, 1 Oct 2026 14:52:18 -0700 Subject: [PATCH 3/3] Preserve custom Google ADK output schemas --- CHANGELOG.md | 1 + .../contrib/google_adk_agents/_model.py | 12 ++++++--- .../test_google_adk_agents.py | 26 +++++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 828fab631..15fe000ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ to include examples, links to docs, or any other relevant information. the workflow sandbox. - `contrib.google_adk_agents`: agents with an `output_schema` no longer fail every workflow task when calling the model. The schema type is now sent to the model activity as its JSON schema. + Custom Pydantic schema generation is preserved. Integer-valued output enums are normalized to strings to match Google GenAI. - `GoogleAdkPlugin` now passes OpenTelemetry modules through the workflow sandbox so ADK 2.9 graph workflows can load their context support during execution. diff --git a/temporalio/contrib/google_adk_agents/_model.py b/temporalio/contrib/google_adk_agents/_model.py index cb04a3d9d..bf076aea4 100644 --- a/temporalio/contrib/google_adk_agents/_model.py +++ b/temporalio/contrib/google_adk_agents/_model.py @@ -7,7 +7,7 @@ from google.adk.models.llm_request import LlmRequest from google.adk.models.llm_response import LlmResponse from google.genai import types -from pydantic import TypeAdapter +from pydantic import BaseModel, TypeAdapter import temporalio.workflow from temporalio import activity, workflow @@ -101,13 +101,17 @@ def _with_serializable_response_schema(llm_request: LlmRequest) -> LlmRequest: (for example a Pydantic model class), which the payload converter cannot serialize. google-genai and ADK's LiteLlm both turn such a type into its JSON schema before calling the model, so sending the JSON schema instead - is equivalent. Integer-valued enums are normalized to string enums to - match google-genai's enum handling. + is equivalent. Pydantic model classes use their ``model_json_schema`` + method to preserve custom schema generation. Integer-valued enums are + normalized to string enums to match google-genai's enum handling. """ schema = llm_request.config.response_schema if schema is None or isinstance(schema, (dict, types.Schema)): return llm_request - response_schema = TypeAdapter(schema).json_schema() + if isinstance(schema, type) and issubclass(schema, BaseModel): + response_schema = schema.model_json_schema() + else: + response_schema = TypeAdapter(schema).json_schema() if ( isinstance(schema, type) and issubclass(schema, Enum) 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 2affe553c..c0aef0ba4 100644 --- a/tests/contrib/google_adk_agents/test_google_adk_agents.py +++ b/tests/contrib/google_adk_agents/test_google_adk_agents.py @@ -1290,6 +1290,32 @@ def test_output_schema_type_sent_as_json_schema(schema: Any) -> None: assert serialized["config"]["response_schema"] == TypeAdapter(schema).json_schema() +def test_output_schema_preserves_custom_model_json_schema() -> None: + class CustomCityWeather(CityWeather): + @classmethod + def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]: + """Include the cities supported by the weather model.""" + schema = super().model_json_schema(*args, **kwargs) + schema["properties"]["city"]["enum"] = ["Paris", "London"] + return schema + + request = LlmRequest( + model="gemini-2.0-flash", + config=types.GenerateContentConfig(), + ) + request.set_output_schema(CustomCityWeather) + + converted = _with_serializable_response_schema(request) + converter = GoogleAdkPlugin()._configure_data_converter(None).payload_converter + payloads = converter.to_payloads([converted]) + restored = converter.from_payloads(payloads, [LlmRequest])[0] + response_schema = types.Schema.model_validate(restored.config.response_schema) + + assert request.config.response_schema is CustomCityWeather + assert response_schema.properties is not None + assert response_schema.properties["city"].enum == ["Paris", "London"] + + @pytest.mark.parametrize( ("schema", "expected_values"), [