Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/google/adk/utils/_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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):
Expand Down
9 changes: 7 additions & 2 deletions src/google/adk/workflow/_base_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
32 changes: 32 additions & 0 deletions tests/unittests/utils/test_schema_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}) == {}
100 changes: 100 additions & 0 deletions tests/unittests/workflow/test_function_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading