From ebcaa72ff94433af2d51403bcea9ba52aed2fc21 Mon Sep 17 00:00:00 2001 From: Hulk Date: Tue, 6 Oct 2026 23:01:43 +0800 Subject: [PATCH 1/6] fix(workflow): preserve nullable values in schema normalization (1/4) AI-assisted implementation and independent AI review. Temporary own-fork review branch, part 1 of 4; not submitted as an upstream contribution. This commit alone is incomplete. Preserve None only when requested for input normalization. The complete four-file change was tested: 143 focused tests passed; independent InMemoryRunner matrix verified 11 expected behaviors. Broader related run: 1218 passed, 1 skipped, 5 xfailed, 9 setup errors from missing mocker; an A2A-dependent module was excluded. Formatting checks, full pre-commit, tox/Python-version matrix, complete suite, and clean-wheel E2E remain unverified. --- src/google/adk/utils/_schema_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 0ba1f49b4d7..105a836f2d2 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -286,6 +286,7 @@ def validate_node_data( data: Any, *, preserve_content: bool = False, + exclude_none: bool = True, ) -> Any: """Validates and sanitizes node input or output data against a schema.""" if data is None or schema is None: @@ -296,7 +297,7 @@ def validate_node_data( def _to_serializable(val: Any) -> Any: if isinstance(val, BaseModel): - return val.model_dump(exclude_none=True) + return val.model_dump(exclude_none=exclude_none) if isinstance(val, list): return [_to_serializable(item) for item in val] if isinstance(val, dict): From 432ecdb5b3b3061491f2cba1f3efaabe82fa4198 Mon Sep 17 00:00:00 2001 From: Hulk Date: Tue, 6 Oct 2026 23:03:07 +0800 Subject: [PATCH 2/6] fix(workflow): preserve explicit null node inputs (2/4) AI-assisted implementation and independent AI review. Temporary own-fork review branch, part 2 of 4; not submitted as an upstream contribution. Regression test files follow. The complete four-file change passed 143 focused tests and an independent 11-behavior InMemoryRunner matrix. Full formatter/pre-commit checks, complete repository suite, tox/Python-version matrix, and clean-wheel E2E remain unverified. --- src/google/adk/workflow/_base_node.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/google/adk/workflow/_base_node.py b/src/google/adk/workflow/_base_node.py index b51aa4614f0..e87b08e668b 100644 --- a/src/google/adk/workflow/_base_node.py +++ b/src/google/adk/workflow/_base_node.py @@ -109,7 +109,8 @@ def _validate_name(cls, v: str) -> str: input_schema: SchemaType | None = None """Schema to validate and coerce node input data. - Validated with ``TypeAdapter``. A raw ``dict`` JSON schema or a genai + Validated with ``TypeAdapter``; a validated ``BaseModel`` is dumped to a + dict with ``None`` fields preserved. A raw ``dict`` JSON schema or a genai ``Schema`` is accepted but never enforced. ``None`` means no input validation (the default). ``FunctionNode`` fills @@ -145,7 +146,11 @@ def _validate_schema(self, data: Any, schema: Any) -> Any: def _validate_input_data(self, data: Any) -> Any: """Validates data against input_schema if set.""" - return validate_node_data(self.input_schema, data, preserve_content=False) + # Input may be validated again before binding function parameters. Dropping + # None makes a required nullable field missing, or substitutes its default. + return validate_node_data( + self.input_schema, data, preserve_content=False, exclude_none=False + ) def _validate_output_data(self, data: Any) -> Any: """Validates data against output_schema if set.""" From 2be5eb6c3eb04c2627759525532fb1072be62b4e Mon Sep 17 00:00:00 2001 From: Hulk Date: Tue, 6 Oct 2026 23:04:49 +0800 Subject: [PATCH 3/6] test(workflow): cover nullable schema inputs (3/4) AI-assisted implementation and independent AI review. Temporary own-fork review branch, part 3 of 4; not submitted as an upstream contribution. Workflow regression test file follows. The complete four-file change passed 143 focused tests and an independent 11-behavior InMemoryRunner matrix. Full formatter/pre-commit checks, complete repository suite, tox/Python-version matrix, and clean-wheel E2E remain unverified. --- tests/unittests/utils/test_schema_utils.py | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index aaeb48cc654..9e649fe4ef6 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -562,3 +562,35 @@ def test_preprocess_args_signature_none_returns_copy(self): result = preprocess_args(raw_args, None) assert result == raw_args assert result is not raw_args + + +@pytest.mark.parametrize('container', ['model', 'list', 'dict']) +@pytest.mark.parametrize('exclude_none', [False, True]) +def test_validate_node_data_nullable_fields(container, exclude_none): + """Input conversion can retain None without changing output omission.""" + + class NullableModel(BaseModel): + value: str | None + + schema = NullableModel + data = {'value': None} + expected = {} if exclude_none else {'value': None} + if container == 'list': + schema = list[NullableModel] + data = [data] + expected = [expected] + elif container == 'dict': + schema = dict[str, NullableModel] + data = {'item': data} + expected = {'item': expected} + + assert validate_node_data(schema, data, exclude_none=exclude_none) == expected + + +def test_validate_node_data_omits_none_by_default(): + """Existing callers keep their output serialization behavior.""" + + class NullableModel(BaseModel): + value: str | None + + assert validate_node_data(NullableModel, {'value': None}) == {} From 39688f69ff53a9eafea434c7c0abb04b0c7f943f Mon Sep 17 00:00:00 2001 From: Hulk Date: Tue, 6 Oct 2026 23:05:58 +0800 Subject: [PATCH 4/6] test(workflow): preserve explicit null node inputs (4/4) Input normalization currently omits validated BaseModel fields whose value is None. Re-validating that normalized input can reject a required nullable field or replace an explicit None with a non-None default. Preserve None only for input normalization and retain the existing output-schema omission behavior. Add regression coverage for message and dictionary inputs, nullable defaults, nested models, and missing required nullable fields. AI-assisted implementation and independent AI review. Temporary review branch; formatting checks are incomplete. Not submitted as an upstream contribution. This completes the four reviewed files. Verification on Python 3.12.14: - Pristine-baseline new Workflow cases: 4 failed, 3 passed. - Patched focused tests: 143 passed. - Independent public InMemoryRunner matrix: 11 expected behaviors verified. - git diff --check, compileall, and repository compliance checks passed. - Broader related run: 1218 passed, 1 skipped, 5 xfailed, 9 setup errors from a missing mocker fixture; an A2A-dependent module was excluded. Limits: full pre-commit/formatter checks, tox/Python-version matrix, complete repository suite, and clean-wheel E2E remain unverified. This input-only change does not alter typed output None omission or other repeated-coercion behavior. --- .../unittests/workflow/test_function_node.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/tests/unittests/workflow/test_function_node.py b/tests/unittests/workflow/test_function_node.py index 1aa9ab6d82a..c4e2d5a4939 100644 --- a/tests/unittests/workflow/test_function_node.py +++ b/tests/unittests/workflow/test_function_node.py @@ -2027,3 +2027,101 @@ def collect_as_list(x: int) -> list[int]: and e.output is not None ] assert outputs == [[10, 11]] + + +@pytest.mark.parametrize('input_from_message', [False, True]) +async def test_function_node_preserves_required_nullable_input( + input_from_message, +): + """A valid required nullable field reaches the function as None.""" + + class NullableInput(BaseModel): + value: str | None + + def produce(): + return {'value': None} + + def consume(node_input: NullableInput): + return 'null' if node_input.value is None else node_input.value + + edges = ( + [(START, consume)] + if input_from_message + else [(START, produce, consume)] + ) + workflow = Workflow(name='nullable_input', edges=edges) + + events, _, _ = await run_workflow(workflow, message='{"value": null}') + + assert [event.output for event in events if event.node_name == 'consume'] == [ + 'null' + ] + + +@pytest.mark.parametrize( + ('payload', 'expected'), + [({'value': None}, 'null'), ({}, 'fallback'), ({'value': 'set'}, 'set')], +) +async def test_function_node_preserves_nullable_input_with_default( + payload, expected +): + """An explicit None is distinct from an omitted field with a default.""" + + class NullableInput(BaseModel): + value: str | None = 'fallback' + + def produce(): + return payload + + def consume(node_input: NullableInput): + return 'null' if node_input.value is None else node_input.value + + workflow = Workflow(name='nullable_default', edges=[(START, produce, consume)]) + + events, _, _ = await run_workflow(workflow) + + assert [event.output for event in events if event.node_name == 'consume'] == [ + expected + ] + + +async def test_function_node_preserves_nested_nullable_input(): + """Nullable fields in nested input models survive repeated validation.""" + + class Item(BaseModel): + value: str | None + + class NullableInput(BaseModel): + items: list[Item] + + def produce(): + return {'items': [{'value': None}, {'value': 'set'}]} + + def consume(node_input: NullableInput): + return [item.value for item in node_input.items] + + workflow = Workflow(name='nullable_nested', edges=[(START, produce, consume)]) + + events, _, _ = await run_workflow(workflow) + + assert [event.output for event in events if event.node_name == 'consume'] == [ + [None, 'set'] + ] + + +async def test_function_node_rejects_missing_required_nullable_input(): + """A nullable field with no default is still required.""" + + class NullableInput(BaseModel): + value: str | None + + def produce(): + return {} + + def consume(node_input: NullableInput): + return 'unexpected' + + workflow = Workflow(name='nullable_missing', edges=[(START, produce, consume)]) + + with pytest.raises(ValueError, match='Field required'): + await run_workflow(workflow) From 61c244f25a8bf281c25c265330723e643aca5b24 Mon Sep 17 00:00:00 2001 From: BigHulk Date: Wed, 7 Oct 2026 02:20:32 +0800 Subject: [PATCH 5/6] style: format nullable workflow regression tests Apply the configured pyink formatting to the two regression test files. Their ASTs and the production implementation are unchanged. AI assistance: OpenAI Codex assisted formatting and validation. --- tests/unittests/utils/test_schema_utils.py | 18 +++++++++--------- tests/unittests/workflow/test_function_node.py | 12 +++++++----- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index 9e649fe4ef6..19d24171166 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -564,8 +564,8 @@ def test_preprocess_args_signature_none_returns_copy(self): assert result is not raw_args -@pytest.mark.parametrize('container', ['model', 'list', 'dict']) -@pytest.mark.parametrize('exclude_none', [False, True]) +@pytest.mark.parametrize("container", ["model", "list", "dict"]) +@pytest.mark.parametrize("exclude_none", [False, True]) def test_validate_node_data_nullable_fields(container, exclude_none): """Input conversion can retain None without changing output omission.""" @@ -573,16 +573,16 @@ class NullableModel(BaseModel): value: str | None schema = NullableModel - data = {'value': None} - expected = {} if exclude_none else {'value': None} - if container == 'list': + data = {"value": None} + expected = {} if exclude_none else {"value": None} + if container == "list": schema = list[NullableModel] data = [data] expected = [expected] - elif container == 'dict': + elif container == "dict": schema = dict[str, NullableModel] - data = {'item': data} - expected = {'item': expected} + data = {"item": data} + expected = {"item": expected} assert validate_node_data(schema, data, exclude_none=exclude_none) == expected @@ -593,4 +593,4 @@ def test_validate_node_data_omits_none_by_default(): class NullableModel(BaseModel): value: str | None - assert validate_node_data(NullableModel, {'value': None}) == {} + assert validate_node_data(NullableModel, {"value": None}) == {} diff --git a/tests/unittests/workflow/test_function_node.py b/tests/unittests/workflow/test_function_node.py index c4e2d5a4939..676d7c871bb 100644 --- a/tests/unittests/workflow/test_function_node.py +++ b/tests/unittests/workflow/test_function_node.py @@ -2045,9 +2045,7 @@ def consume(node_input: NullableInput): return 'null' if node_input.value is None else node_input.value edges = ( - [(START, consume)] - if input_from_message - else [(START, produce, consume)] + [(START, consume)] if input_from_message else [(START, produce, consume)] ) workflow = Workflow(name='nullable_input', edges=edges) @@ -2076,7 +2074,9 @@ def produce(): def consume(node_input: NullableInput): return 'null' if node_input.value is None else node_input.value - workflow = Workflow(name='nullable_default', edges=[(START, produce, consume)]) + workflow = Workflow( + name='nullable_default', edges=[(START, produce, consume)] + ) events, _, _ = await run_workflow(workflow) @@ -2121,7 +2121,9 @@ def produce(): def consume(node_input: NullableInput): return 'unexpected' - workflow = Workflow(name='nullable_missing', edges=[(START, produce, consume)]) + workflow = Workflow( + name='nullable_missing', edges=[(START, produce, consume)] + ) with pytest.raises(ValueError, match='Field required'): await run_workflow(workflow) From dad5ca17894ad4a0c34962326db118d24e7f0181 Mon Sep 17 00:00:00 2001 From: BigHulk Date: Thu, 8 Oct 2026 13:16:41 +0800 Subject: [PATCH 6/6] test: return a typed Kubernetes job from the GKE fixture --- tests/unittests/code_executors/test_gke_code_executor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unittests/code_executors/test_gke_code_executor.py b/tests/unittests/code_executors/test_gke_code_executor.py index c3230859014..21b9e6cdb0b 100644 --- a/tests/unittests/code_executors/test_gke_code_executor.py +++ b/tests/unittests/code_executors/test_gke_code_executor.py @@ -52,6 +52,11 @@ def mock_k8s_clients(): ) as mock_client_class: mock_batch_v1 = MagicMock(spec=client.BatchV1Api) mock_core_v1 = MagicMock(spec=client.CoreV1Api) + mock_batch_v1.create_namespaced_job.return_value = client.V1Job( + api_version="batch/v1", + kind="Job", + metadata=client.V1ObjectMeta(name="test-job", uid="test-job-uid"), + ) mock_client_class.BatchV1Api.return_value = mock_batch_v1 mock_client_class.CoreV1Api.return_value = mock_core_v1 yield {