Skip to content

Commit 6f66814

Browse files
DeanChensjcopybara-github
authored andcommitted
feat: Add strict input schema validation for LlmAgent workflow nodes
In preparation for natively supporting task-mode agents as Workflow nodes, we need to enforce input boundaries at the wrapper level. Currently, bad `node_input` bypasses validation and generates invalid JSON strings for the LLM. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 944894496
1 parent 5b1088a commit 6f66814

8 files changed

Lines changed: 215 additions & 72 deletions

File tree

src/google/adk/utils/_schema_utils.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,81 @@ def validate_schema(schema: SchemaType, json_text: str) -> Any:
125125
elif is_list_of_basemodel(schema):
126126
# For list[BaseModel], use TypeAdapter to validate
127127
type_adapter = TypeAdapter(schema)
128-
validated = type_adapter.validate_json(json_text)
128+
validated: list[Any] = type_adapter.validate_json(json_text)
129129
return [item.model_dump(exclude_none=True) for item in validated]
130130
else:
131131
# For other schema types (list[str], dict, Schema, etc.),
132-
# just parse JSON without pydantic validation
133132
return json.loads(json_text)
133+
134+
135+
def validate_node_data(
136+
schema: Optional[SchemaType],
137+
data: Any,
138+
*,
139+
preserve_content: bool = False,
140+
) -> Any:
141+
"""Validates and sanitizes node input or output data against a schema."""
142+
if data is None or schema is None:
143+
return data
144+
145+
if isinstance(schema, (dict, types.Schema)):
146+
return data
147+
148+
def _to_serializable(val: Any) -> Any:
149+
if isinstance(val, BaseModel):
150+
return val.model_dump(exclude_none=True)
151+
if isinstance(val, list):
152+
return [_to_serializable(item) for item in val]
153+
if isinstance(val, dict):
154+
return {k: _to_serializable(v) for k, v in val.items()}
155+
return val
156+
157+
def _validate_python_object(val: Any) -> Any:
158+
validated: Any = TypeAdapter(schema).validate_python(val)
159+
return _to_serializable(validated)
160+
161+
# If schema expects Content, do not unwrap
162+
if isinstance(schema, type) and issubclass(schema, types.Content):
163+
return _validate_python_object(data)
164+
if schema is types.Content:
165+
return _validate_python_object(data)
166+
167+
if isinstance(data, types.Content):
168+
# Extract text part
169+
text_parts = [p.text for p in data.parts if p.text] if data.parts else []
170+
text_str = "".join(text_parts)
171+
172+
# Validate the text
173+
if schema is str:
174+
validated_payload = text_str
175+
else:
176+
# Try to parse text as JSON first
177+
try:
178+
parsed_json = json.loads(text_str)
179+
validated_payload = _validate_python_object(parsed_json)
180+
except json.JSONDecodeError:
181+
# Fallback to validate raw string
182+
validated_payload = _validate_python_object(text_str)
183+
184+
if not preserve_content:
185+
return validated_payload
186+
187+
# Re-wrap in Content
188+
new_parts = [p for p in data.parts if not p.text] if data.parts else []
189+
new_parts.append(
190+
types.Part(
191+
text=json.dumps(validated_payload)
192+
if not isinstance(validated_payload, str)
193+
else validated_payload
194+
)
195+
)
196+
return types.Content(role=data.role, parts=new_parts)
197+
198+
# If data is a string (but not wrapped in Content)
199+
if isinstance(data, str):
200+
if schema is str:
201+
return data
202+
return _validate_python_object(data)
203+
204+
# For any other Python object (dict, BaseModel instance, etc.)
205+
return _validate_python_object(data)

src/google/adk/workflow/_base_node.py

Lines changed: 5 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,13 @@
1919
from typing import final
2020
from typing import TYPE_CHECKING
2121

22-
from google.genai import types
2322
from pydantic import BaseModel
2423
from pydantic import ConfigDict
2524
from pydantic import Field
2625
from pydantic import field_validator
27-
from pydantic import TypeAdapter
28-
from pydantic import ValidationError
2926

3027
from ..utils._schema_utils import SchemaType
31-
from ..utils.content_utils import extract_text_from_content
28+
from ..utils._schema_utils import validate_node_data
3229
from ._retry_config import RetryConfig
3330

3431
if TYPE_CHECKING:
@@ -123,64 +120,16 @@ def _validate_name(cls, v: str) -> str:
123120
"""
124121

125122
def _validate_schema(self, data: Any, schema: Any) -> Any:
126-
"""Validates data against a schema using ``TypeAdapter``.
127-
128-
Handles BaseModel, list[BaseModel], primitive types, unions, and
129-
generic aliases. Descriptive schemas (``types.Schema``, raw
130-
``dict``) are skipped. Any BaseModel instances in the validated
131-
result are converted to dicts via ``model_dump()`` to keep
132-
``Event.output`` JSON-serializable.
133-
"""
134-
if data is None or schema is None:
135-
return data
136-
137-
if isinstance(schema, (dict, types.Schema)):
138-
return data
139-
140-
validated = TypeAdapter(schema).validate_python(data)
141-
return self._to_serializable(validated)
123+
"""Validates data against a schema using validate_node_data helper."""
124+
return validate_node_data(schema, data)
142125

143126
def _validate_input_data(self, data: Any) -> Any:
144127
"""Validates data against input_schema if set."""
145-
if self.input_schema and isinstance(data, types.Content):
146-
# Extract text from Content (e.g. user input from START node).
147-
text = extract_text_from_content(data)
148-
if self.input_schema is str:
149-
return text
150-
# If schema is defined, try to parse the text as JSON.
151-
try:
152-
return TypeAdapter(self.input_schema).validate_json(text)
153-
except ValidationError:
154-
# Fallback to validate_python if it's a raw string matching the schema.
155-
try:
156-
return TypeAdapter(self.input_schema).validate_python(text)
157-
except ValidationError:
158-
pass
159-
return self._validate_schema(data, self.input_schema)
128+
return validate_node_data(self.input_schema, data, preserve_content=False)
160129

161130
def _validate_output_data(self, data: Any) -> Any:
162131
"""Validates data against output_schema if set."""
163-
if not self.output_schema:
164-
return data
165-
166-
# 1. Try standard validation first
167-
try:
168-
return self._validate_schema(data, self.output_schema)
169-
except ValidationError as e:
170-
# 2. If failed, try to parse JSON ONLY if it's Content
171-
if isinstance(data, types.Content):
172-
text = extract_text_from_content(data)
173-
if self.output_schema is str:
174-
return text
175-
if text.strip():
176-
try:
177-
validated = TypeAdapter(self.output_schema).validate_json(text)
178-
return self._to_serializable(validated)
179-
except ValidationError:
180-
pass
181-
182-
# 3. If not Content or parsing failed, re-raise original error
183-
raise e
132+
return validate_node_data(self.output_schema, data, preserve_content=False)
184133

185134
@staticmethod
186135
def _to_serializable(data: Any) -> Any:

src/google/adk/workflow/_dynamic_node_scheduler.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,10 +170,9 @@ async def __call__(
170170
try:
171171
node_input = node._validate_input_data(node_input)
172172
except ValidationError as e:
173-
raise ValueError(
174-
'Runtime schema validation failed for dynamic node'
175-
f" '{node_name or node.name}'. Input does not match"
176-
f' input_schema: {e}'
173+
raise ValidationError.from_exception_data(
174+
title=f"dynamic node '{node_name or node.name}'",
175+
line_errors=e.errors(), # type: ignore[arg-type]
177176
) from e
178177

179178
logger.debug('node %s schedule start.', node_path)

tests/unittests/utils/test_schema_utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@
1717
from google.adk.utils._schema_utils import get_list_inner_type
1818
from google.adk.utils._schema_utils import is_basemodel_schema
1919
from google.adk.utils._schema_utils import is_list_of_basemodel
20+
from google.adk.utils._schema_utils import validate_node_data
2021
from google.adk.utils._schema_utils import validate_schema
22+
from google.genai import types
2123
from pydantic import BaseModel
24+
from pydantic import ValidationError
25+
import pytest
2226

2327

2428
class SampleModel(BaseModel):
@@ -144,3 +148,63 @@ def test_dict_schema(self):
144148
json_text = '{"key1": 1, "key2": 2}'
145149
result = validate_schema(dict[str, int], json_text)
146150
assert result == {'key1': 1, 'key2': 2}
151+
152+
153+
class TestValidateNodeData:
154+
"""Tests for validate_node_data function."""
155+
156+
def test_none_schema_or_data_returns_data(self):
157+
"""Bypasses validation if schema or data is None."""
158+
assert validate_node_data(None, 'some_data') == 'some_data'
159+
assert validate_node_data(SampleModel, None) is None
160+
161+
def test_dict_or_types_schema_returns_data(self):
162+
"""Bypasses validation if schema is dict or types.Schema."""
163+
assert validate_node_data({'key': int}, 'some_data') == 'some_data'
164+
# Mock types.Schema
165+
schema = types.Schema(type=types.Type.STRING)
166+
assert validate_node_data(schema, 'some_data') == 'some_data'
167+
168+
def test_content_schema_returns_data(self):
169+
"""Bypasses validation if target schema is types.Content or subclass."""
170+
result = validate_node_data(
171+
types.Content, types.Content(role='user', parts=[])
172+
)
173+
assert result == {'role': 'user', 'parts': []}
174+
175+
def test_plain_basemodel_schema_validates_raw_dict(self):
176+
"""Validates raw dict data against BaseModel schema."""
177+
result = validate_node_data(SampleModel, {'name': 'test', 'value': 42})
178+
assert result == {'name': 'test', 'value': 42}
179+
180+
def test_content_data_and_preserve_content(self):
181+
"""Validates wrapped content and wraps result back into Content."""
182+
data = types.Content(
183+
role='user',
184+
parts=[types.Part(text='{"name": "test", "value": 42}')],
185+
)
186+
result = validate_node_data(SampleModel, data, preserve_content=True)
187+
assert isinstance(result, types.Content)
188+
assert result.role == 'user'
189+
assert len(result.parts) == 1
190+
assert result.parts[0].text == '{"name": "test", "value": 42}'
191+
192+
def test_content_data_no_preserve_content(self):
193+
"""Validates wrapped content and returns unwrapped dictionary."""
194+
data = types.Content(
195+
role='user',
196+
parts=[types.Part(text='{"name": "test", "value": 42}')],
197+
)
198+
result = validate_node_data(SampleModel, data, preserve_content=False)
199+
assert isinstance(result, dict)
200+
assert result == {'name': 'test', 'value': 42}
201+
202+
def test_raw_json_string_validated_against_basemodel_schema(self):
203+
"""Raw JSON string fails validation against BaseModel schema (not auto-parsed)."""
204+
with pytest.raises(ValidationError):
205+
validate_node_data(SampleModel, '{"name": "test", "value": 42}')
206+
207+
def test_raw_string_not_parsed_with_str_schema(self):
208+
"""Bypasses JSON parsing if schema is str."""
209+
result = validate_node_data(str, 'hello')
210+
assert result == 'hello'

tests/unittests/workflow/test_dynamic_node_scheduler.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from google.adk.workflow._node_status import NodeStatus
3333
from google.adk.workflow._workflow import _LoopState
3434
from pydantic import BaseModel
35+
from pydantic import ValidationError
3536
import pytest
3637

3738
# --- Fixtures ---
@@ -639,10 +640,7 @@ async def test_runtime_schema_validation_raises():
639640

640641
node = BaseNode(name='child', input_schema=_ModelA)
641642

642-
with pytest.raises(
643-
ValueError,
644-
match=r"Runtime schema validation failed for dynamic node 'child'",
645-
):
643+
with pytest.raises(ValidationError):
646644
await scheduler(
647645
ctx,
648646
node,

tests/unittests/workflow/test_llm_agent_as_node.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,3 +1361,67 @@ async def test_three_layer_llm_agent_transfer_round_trip(
13611361
if p.text
13621362
]
13631363
assert any('Welcome back to root!' in t for t in content_texts3)
1364+
1365+
1366+
@pytest.mark.asyncio
1367+
async def test_workflow_node_with_valid_input_schema_completes_successfully(
1368+
request: pytest.FixtureRequest,
1369+
):
1370+
"""A valid node_input payload successfully passes schema validation."""
1371+
1372+
# Arrange
1373+
class InputSchema(BaseModel):
1374+
required_field: str
1375+
1376+
agent = LlmAgent(
1377+
name='schema_agent',
1378+
model='test_model',
1379+
input_schema=InputSchema,
1380+
instruction='Just say hi',
1381+
mode='single_turn',
1382+
)
1383+
wrapper = build_node(agent)
1384+
wf = Workflow(
1385+
name='test_workflow',
1386+
edges=[('START', wrapper)],
1387+
)
1388+
runner = _new_workflow_runner(wf, request.function.__name__)
1389+
agent_clone = next(n for n in wf.graph.nodes if n.name == wrapper.name)
1390+
1391+
# Act
1392+
with _mock_agent_run(agent_clone, content_text='hi'):
1393+
events = await runner.run_async('{"required_field": "hello"}')
1394+
1395+
# Assert
1396+
assert len(events) > 0
1397+
1398+
1399+
@pytest.mark.asyncio
1400+
async def test_workflow_node_with_invalid_input_schema_raises_validation_error(
1401+
request: pytest.FixtureRequest,
1402+
):
1403+
"""An invalid node_input payload raises a pydantic ValidationError."""
1404+
1405+
# Arrange
1406+
class InputSchema(BaseModel):
1407+
required_field: str
1408+
1409+
agent = LlmAgent(
1410+
name='schema_agent',
1411+
model='test_model',
1412+
input_schema=InputSchema,
1413+
instruction='Just say hi',
1414+
mode='single_turn',
1415+
)
1416+
wrapper = build_node(agent)
1417+
wf = Workflow(
1418+
name='test_workflow',
1419+
edges=[('START', wrapper)],
1420+
)
1421+
runner = _new_workflow_runner(wf, request.function.__name__)
1422+
agent_clone = next(n for n in wf.graph.nodes if n.name == wrapper.name)
1423+
1424+
# Act / Assert
1425+
with _mock_agent_run(agent_clone, content_text='hi'):
1426+
with pytest.raises(ValidationError):
1427+
await runner.run_async('{"wrong_field": "hello"}')

tests/unittests/workflow/test_workflow_failures.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,13 +1082,13 @@ async def test_fail_fast_preserves_completed_siblings(
10821082
async def succeeding_node(ctx: Context):
10831083
nonlocal node_success_started, node_success_completed
10841084
node_success_started = True
1085-
await asyncio.sleep(0.1)
1085+
await asyncio.sleep(0)
10861086
node_success_completed = True
10871087
return 'success_output'
10881088

10891089
@node()
10901090
async def failing_node(ctx: Context):
1091-
await asyncio.sleep(0.1)
1091+
await asyncio.sleep(0)
10921092
raise ValueError('Fail')
10931093

10941094
wf = Workflow(

tests/unittests/workflow/test_workflow_schema.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -631,10 +631,7 @@ async def _run_impl(
631631
msg = types.Content(parts=[types.Part(text='hello')], role='user')
632632

633633
# We expect it to raise ValidationError
634-
with pytest.raises(
635-
ValueError,
636-
match=r"Runtime schema validation failed for dynamic node 'node'",
637-
):
634+
with pytest.raises(ValidationError):
638635
async for event in runner.run_async(
639636
user_id='u', session_id=session.id, new_message=msg
640637
):

0 commit comments

Comments
 (0)