Skip to content

Commit 4e44632

Browse files
baptmontGWeale
authored andcommitted
feat(workflow): Allow ToolNode to accept JSON string or Content inputs
Merge #6065 **Please ensure you have read the [contribution guide](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) before creating a pull request.** ### Link to Issue or Description of Change **1. Link to an existing issue (if applicable):** - Closes: N/A - Related: N/A **2. Or, if no issue exists, describe the change:** **Problem:** Previously, `ToolNode` in ADK workflows required input to be strictly a Python `dict` or `None`. This limited workflow flexibility, preventing users from chaining a `ToolNode` directly downstream of nodes (like LLM Agents or custom function nodes) that output inputs as serialized JSON strings or `types.Content` objects without manual parsing boilerplate. **Solution:** Enhanced `ToolNode._run_impl` in [_tool_node.py](file:///usr/local/google/home/westerberg/Documents/github/adk-python-fork/src/google/adk/workflow/_tool_node.py) to dynamically coerce input formats before tool execution: - Extract text from `types.Content` when received. - Strip and deserialize JSON strings via `json.loads` if received. - Coerce `None` to `{}`. - Keep strict validation raising `TypeError` for non-dictionary representations (e.g. lists, invalid JSON). --- ### Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. **Pytest Summary:** ``` tests/unittests/workflow/test_tool_node.py ....... [100%] ======================== 7 passed, 4 warnings in 2.80s ========================= ``` **Manual End-to-End (E2E) Tests:** Verified all workflow tests pass locally with no regressions: ``` uv run pytest tests/unittests/workflow/ ... ==== 600 passed, 10 skipped, 26 xfailed, 9 xpassed, 237 warnings in 15.17s ===== ``` --- ### Checklist - [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [x] Any dependent changes have been merged and published in downstream modules. Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#6065 from baptmont:tool_node_args 36edc1f PiperOrigin-RevId: 940575182
1 parent 7c79230 commit 4e44632

2 files changed

Lines changed: 157 additions & 0 deletions

File tree

src/google/adk/workflow/_tool_node.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
"""A node that wraps an ADK Tool."""
1818

1919
from collections.abc import AsyncGenerator
20+
import json
2021
from typing import Any
2122
import uuid
2223

24+
from google.genai import types
2325
from pydantic import ConfigDict
2426
from pydantic import Field
2527
from typing_extensions import override
@@ -28,6 +30,7 @@
2830
from ..events.event import Event
2931
from ..tools.base_tool import BaseTool
3032
from ..tools.tool_context import ToolContext
33+
from ..utils.content_utils import extract_text_from_content
3134
from ._base_node import BaseNode
3235
from ._retry_config import RetryConfig
3336

@@ -67,6 +70,19 @@ async def _run_impl(
6770
)
6871

6972
args = node_input
73+
if isinstance(args, types.Content):
74+
args = extract_text_from_content(args)
75+
76+
if isinstance(args, str):
77+
args = args.strip()
78+
if not args:
79+
args = None
80+
else:
81+
try:
82+
args = json.loads(args)
83+
except json.JSONDecodeError:
84+
pass
85+
7086
if args is None:
7187
args = {}
7288
elif not isinstance(args, dict):
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for ToolNode input parsing and execution."""
16+
17+
from typing import Any
18+
19+
from google.adk.events.event import Event
20+
from google.adk.tools.base_tool import BaseTool
21+
from google.adk.workflow import START
22+
from google.adk.workflow._tool_node import _ToolNode as ToolNode
23+
from google.adk.workflow._workflow import Workflow
24+
from google.genai import types
25+
import pytest
26+
27+
from . import workflow_testing_utils
28+
from .. import testing_utils
29+
30+
31+
class MockTool(BaseTool):
32+
"""A mock tool that returns the args it was called with."""
33+
34+
def __init__(self, name="mock_tool", description="Mock tool"):
35+
super().__init__(name=name, description=description)
36+
37+
async def run_async(self, *, args: dict[str, Any], tool_context) -> Any:
38+
return args
39+
40+
41+
async def _run_tool_node_wf(node_input: Any) -> list[Any]:
42+
"""Runs a workflow with a ToolNode that receives node_input."""
43+
tool_node = ToolNode(tool=MockTool())
44+
45+
def start_node():
46+
return Event(output=node_input)
47+
48+
wf = Workflow(
49+
name="tool_node_test_wf",
50+
edges=[
51+
(START, start_node),
52+
(start_node, tool_node),
53+
],
54+
)
55+
app_instance = testing_utils.App(name="test_app", root_agent=wf)
56+
runner = testing_utils.InMemoryRunner(app=app_instance)
57+
events = await runner.run_async("start")
58+
return workflow_testing_utils.simplify_events_with_node(events)
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_tool_node_accepts_dict():
63+
"""Tests that ToolNode accepts a dict as input and passes it to the tool."""
64+
input_dict = {"param_a": 1, "param_b": "value"}
65+
simplified = await _run_tool_node_wf(input_dict)
66+
assert (
67+
"tool_node_test_wf@1/mock_tool@1",
68+
{"output": input_dict},
69+
) in simplified
70+
71+
72+
@pytest.mark.asyncio
73+
async def test_tool_node_accepts_none():
74+
"""Tests that ToolNode accepts None, converting it to an empty dict."""
75+
simplified = await _run_tool_node_wf(None)
76+
assert ("tool_node_test_wf@1/mock_tool@1", {"output": {}}) in simplified
77+
78+
79+
@pytest.mark.asyncio
80+
@pytest.mark.parametrize("empty_input", ["", " ", "\n\t"])
81+
async def test_tool_node_accepts_empty_string(empty_input):
82+
"""Tests that ToolNode treats an empty/whitespace string as no arguments."""
83+
simplified = await _run_tool_node_wf(empty_input)
84+
assert ("tool_node_test_wf@1/mock_tool@1", {"output": {}}) in simplified
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_tool_node_accepts_json_string():
89+
"""Tests that ToolNode accepts a valid JSON string representing a dict."""
90+
json_str = '{"param_a": 1, "param_b": "value"}'
91+
simplified = await _run_tool_node_wf(json_str)
92+
assert (
93+
"tool_node_test_wf@1/mock_tool@1",
94+
{"output": {"param_a": 1, "param_b": "value"}},
95+
) in simplified
96+
97+
98+
@pytest.mark.asyncio
99+
async def test_tool_node_accepts_content_with_json_string():
100+
"""Tests that ToolNode accepts a types.Content containing a JSON string."""
101+
json_str = '{"param_a": 1, "param_b": "value"}'
102+
content = types.Content(
103+
parts=[types.Part.from_text(text=json_str)], role="user"
104+
)
105+
simplified = await _run_tool_node_wf(content)
106+
assert (
107+
"tool_node_test_wf@1/mock_tool@1",
108+
{"output": {"param_a": 1, "param_b": "value"}},
109+
) in simplified
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_tool_node_rejects_non_dict_json_string():
114+
"""Tests that ToolNode raises TypeError if JSON string represents a non-dict (e.g. list)."""
115+
json_str = "[1, 2, 3]"
116+
with pytest.raises(
117+
TypeError, match="The input to ToolNode must be a dictionary"
118+
):
119+
await _run_tool_node_wf(json_str)
120+
121+
122+
@pytest.mark.asyncio
123+
async def test_tool_node_rejects_invalid_json_string():
124+
"""Tests that ToolNode raises TypeError if string input is not valid JSON."""
125+
invalid_str = "not a json"
126+
with pytest.raises(
127+
TypeError, match="The input to ToolNode must be a dictionary"
128+
):
129+
await _run_tool_node_wf(invalid_str)
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_tool_node_rejects_non_dict_content():
134+
"""Tests that ToolNode raises TypeError if Content contains non-dict text."""
135+
content = types.Content(
136+
parts=[types.Part.from_text(text="not a json")], role="user"
137+
)
138+
with pytest.raises(
139+
TypeError, match="The input to ToolNode must be a dictionary"
140+
):
141+
await _run_tool_node_wf(content)

0 commit comments

Comments
 (0)