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): 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.""" diff --git a/tests/unittests/utils/test_schema_utils.py b/tests/unittests/utils/test_schema_utils.py index aaeb48cc654..19d24171166 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}) == {} diff --git a/tests/unittests/workflow/test_function_node.py b/tests/unittests/workflow/test_function_node.py index 1d884fe8fe0..89260f8347b 100644 --- a/tests/unittests/workflow/test_function_node.py +++ b/tests/unittests/workflow/test_function_node.py @@ -2349,3 +2349,103 @@ def gen() -> Generator[int, None, None]: assert not closed await agen.aclose() assert closed + + +@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)