Skip to content

Commit bfc236d

Browse files
boyangsvlcopybara-github
authored andcommitted
feat: Expose parameter_binding in @node decorator
Support parameter_binding in @node decorator to allow configuring dictionary key binding for function nodes. Add tests to verify. Co-authored-by: Bo Yang <ybo@google.com> PiperOrigin-RevId: 933952179
1 parent 89d9bda commit bfc236d

3 files changed

Lines changed: 59 additions & 5 deletions

File tree

src/google/adk/workflow/_node.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from collections.abc import AsyncGenerator
2020
from collections.abc import Callable
2121
from typing import Any
22+
from typing import Literal
2223
from typing import overload
2324
from typing import TYPE_CHECKING
2425
from typing import TypeVar
@@ -38,7 +39,7 @@
3839
from ..agents.context import Context
3940
from ..auth.auth_tool import AuthConfig
4041

41-
T = TypeVar("T", bound=Callable[..., Any])
42+
T = TypeVar('T', bound=Callable[..., Any])
4243

4344

4445
@overload
@@ -50,6 +51,7 @@ def node(
5051
timeout: float | None = None,
5152
parallel_worker: bool = False,
5253
auth_config: AuthConfig | None = None,
54+
parameter_binding: Literal['state', 'node_input'] = 'state',
5355
) -> Callable[
5456
[T], function_node.FunctionNode | parallel_worker_lib._ParallelWorker
5557
]:
@@ -66,6 +68,7 @@ def node(
6668
timeout: float | None = None,
6769
parallel_worker: bool = False,
6870
auth_config: AuthConfig | None = None,
71+
parameter_binding: Literal['state', 'node_input'] = 'state',
6972
) -> base_node.BaseNode:
7073
...
7174

@@ -79,6 +82,7 @@ def node(
7982
timeout: float | None = None,
8083
parallel_worker: bool = False,
8184
auth_config: AuthConfig | None = None,
85+
parameter_binding: Literal['state', 'node_input'] = 'state',
8286
) -> Any:
8387
"""Decorator or function to wrap a NodeLike in a node or override its properties.
8488
@@ -107,6 +111,11 @@ async def my_func3(): ...
107111
parallel_worker: If True, wraps the node in a _ParallelWorker.
108112
auth_config: If provided, the framework requests user authentication
109113
before running the node. Requires rerun_on_resume=True.
114+
parameter_binding: How function parameters are bound. ``'state'``
115+
(default) binds parameters from ``ctx.state``. ``'node_input'``
116+
binds parameters from ``node_input`` dict and infers
117+
``input_schema`` / ``output_schema`` from the function signature
118+
(used when the node acts as an agent's tool).
110119
111120
Returns:
112121
If used as a decorator factory (@node() or @node(...)), returns a decorator.
@@ -126,6 +135,7 @@ def wrapper(
126135
retry_config=retry_config,
127136
timeout=timeout,
128137
auth_config=auth_config,
138+
parameter_binding=parameter_binding,
129139
)
130140
if parallel_worker:
131141
return parallel_worker_lib._ParallelWorker(node=built_node)
@@ -142,6 +152,7 @@ def wrapper(
142152
retry_config=retry_config,
143153
timeout=timeout,
144154
auth_config=auth_config,
155+
parameter_binding=parameter_binding,
145156
)
146157
if parallel_worker:
147158
return parallel_worker_lib._ParallelWorker(node=built_node)
@@ -166,7 +177,7 @@ def model_post_init(self, __context: Any) -> None:
166177
# to avoid infinite recursion when its run() method is called.
167178
# The cloned node preserves the class identity and behavior of the
168179
# original (essential for LlmAgent and Workflow subclasses).
169-
worker_node = self.model_copy(update={"parallel_worker": False})
180+
worker_node = self.model_copy(update={'parallel_worker': False})
170181

171182
inner = parallel_worker_lib._ParallelWorker(node=worker_node)
172183
self._inner_node = inner
@@ -181,7 +192,7 @@ def model_copy(
181192
copied = super().model_copy(update=update, deep=deep)
182193

183194
if copied.parallel_worker:
184-
worker_node = copied.model_copy(update={"parallel_worker": False})
195+
worker_node = copied.model_copy(update={'parallel_worker': False})
185196
copied._inner_node = parallel_worker_lib._ParallelWorker(node=worker_node)
186197
copied.rerun_on_resume = copied._inner_node.rerun_on_resume
187198

@@ -195,7 +206,7 @@ async def run_node_impl(
195206
Subclasses can directly benefit from advanced flags like parallel_worker
196207
by providing their custom execution logic here.
197208
"""
198-
raise NotImplementedError("run_node_impl must be implemented.")
209+
raise NotImplementedError('run_node_impl must be implemented.')
199210
yield
200211

201212
@override
@@ -205,7 +216,7 @@ async def _run_impl(
205216
"""Dispatches to run_node_impl() or parallel_worker inner node."""
206217
if self.parallel_worker:
207218
if self._inner_node is None:
208-
raise ValueError("inner_node is not initialized for parallel worker.")
219+
raise ValueError('inner_node is not initialized for parallel worker.')
209220
async for output in self._inner_node.run(ctx=ctx, node_input=node_input):
210221
yield output
211222
else:

src/google/adk/workflow/utils/_workflow_graph_utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from typing import Any
2020
from typing import cast
21+
from typing import Literal
2122

2223
from ...tools.base_tool import BaseTool
2324
from .._base_node import BaseNode
@@ -45,6 +46,7 @@ def build_node(
4546
retry_config: RetryConfig | None = None,
4647
timeout: float | None = None,
4748
auth_config: Any = None,
49+
parameter_binding: Literal['state', 'node_input'] = 'state',
4850
) -> BaseNode:
4951
"""Converts a NodeLike to a BaseNode, wrapping async funcs in FunctionNode.
5052
@@ -57,6 +59,11 @@ def build_node(
5759
wrapped node.
5860
timeout: If provided, overrides the timeout property of the wrapped node.
5961
auth_config: If provided, passed to FunctionNode for authentication.
62+
parameter_binding: How function parameters are bound. ``'state'``
63+
(default) binds parameters from ``ctx.state``. ``'node_input'``
64+
binds parameters from ``node_input`` dict and infers
65+
``input_schema`` / ``output_schema`` from the function signature
66+
(used when the node acts as an agent's tool).
6067
6168
Returns:
6269
A BaseNode instance.
@@ -128,6 +135,7 @@ def build_node(
128135
retry_config=retry_config,
129136
timeout=timeout,
130137
auth_config=auth_config,
138+
parameter_binding=parameter_binding,
131139
)
132140
else:
133141
raise ValueError(

tests/unittests/workflow/test_workflow_node.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,41 @@ def my_func2():
160160
assert not my_func2.rerun_on_resume
161161

162162

163+
def test_node_decorator_parameter_binding():
164+
"""Tests that @node decorator can configure parameter_binding."""
165+
166+
@node(parameter_binding="node_input")
167+
def my_func(foo: str):
168+
return f"Hello {foo}"
169+
170+
assert isinstance(my_func, FunctionNode)
171+
assert my_func.parameter_binding == "node_input"
172+
173+
174+
@pytest.mark.asyncio
175+
async def test_node_decorator_parameter_binding_execution():
176+
"""Tests execution of a node with parameter_binding='node_input'."""
177+
178+
async def producer_func() -> dict[str, Any]:
179+
return {"foo": "hello", "bar": 42}
180+
181+
@node(parameter_binding="node_input")
182+
def my_func(foo: str, bar: int):
183+
return f"{foo}:{bar}"
184+
185+
wf = Workflow(
186+
name="test_agent",
187+
edges=[
188+
(START, producer_func),
189+
(producer_func, my_func),
190+
],
191+
)
192+
events, _, _ = await _run_workflow(wf)
193+
194+
by_node = _output_by_node(events)
195+
assert ("my_func", "hello:42") in by_node
196+
197+
163198
def test_node_function_with_base_node():
164199
"""Tests that node() function returns a copied node when given a BaseNode."""
165200

0 commit comments

Comments
 (0)