diff --git a/src/google/adk/utils/_schema_utils.py b/src/google/adk/utils/_schema_utils.py index 9ef27ccdd10..a04051ca3f7 100644 --- a/src/google/adk/utils/_schema_utils.py +++ b/src/google/adk/utils/_schema_utils.py @@ -327,6 +327,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: @@ -337,7 +338,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 f0a821528d9..cb7c86328fe 100644 --- a/src/google/adk/workflow/_base_node.py +++ b/src/google/adk/workflow/_base_node.py @@ -110,7 +110,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 @@ -146,7 +147,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 b85b1b63c2c..fc06aee0383 100644 --- a/tests/unittests/utils/test_schema_utils.py +++ b/tests/unittests/utils/test_schema_utils.py @@ -595,3 +595,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 c462f66bff0..3e2db885149 100644 --- a/tests/unittests/workflow/test_function_node.py +++ b/tests/unittests/workflow/test_function_node.py @@ -2475,3 +2475,103 @@ def produce() -> dict[str, Any]: '7:5', ] assert validations == 1 + + +@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)