ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
451 lines
13 KiB
Python
451 lines
13 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for @node decorator and behavior."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest import mock
|
|
|
|
from google.adk.agents.base_agent import BaseAgent
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.apps import App
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.tools.base_tool import BaseTool
|
|
from google.adk.workflow import FunctionNode
|
|
from google.adk.workflow import START
|
|
from google.adk.workflow._base_node import BaseNode
|
|
from google.adk.workflow._node import node
|
|
from google.adk.workflow._node import Node
|
|
from google.adk.workflow._parallel_worker import _ParallelWorker as ParallelWorker
|
|
from google.adk.workflow._retry_config import RetryConfig
|
|
from google.adk.workflow._tool_node import _ToolNode as ToolNode
|
|
from google.adk.workflow._workflow import Workflow
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
from .. import testing_utils
|
|
|
|
ANY = mock.ANY
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _run_workflow(wf, message="start"):
|
|
"""Run a Workflow through Runner, return collected events."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name="test", node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name="test", user_id="u")
|
|
msg = types.Content(parts=[types.Part(text=message)], role="user")
|
|
events = []
|
|
async for event in runner.run_async(
|
|
user_id="u", session_id=session.id, new_message=msg
|
|
):
|
|
events.append(event)
|
|
return events, ss, session
|
|
|
|
|
|
def _output_by_node(events):
|
|
"""Extract (node_name_from_path, output) for child node events."""
|
|
results = []
|
|
for e in events:
|
|
if e.output is not None and e.node_info.path and "/" in e.node_info.path:
|
|
node_name = e.node_info.path.rsplit("/", 1)[-1]
|
|
if "@" in node_name:
|
|
node_name = node_name.rsplit("@", 1)[0]
|
|
results.append((node_name, e.output))
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_decorator():
|
|
"""Tests that @node decorator can wrap a function and override its name."""
|
|
|
|
@node(name="decorated_node")
|
|
def my_func():
|
|
return "Hello from decorated_func"
|
|
|
|
assert my_func.name == "decorated_node"
|
|
|
|
wf = Workflow(
|
|
name="test_agent",
|
|
edges=[
|
|
(START, my_func),
|
|
],
|
|
)
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
by_node = _output_by_node(events)
|
|
assert ("decorated_node", "Hello from decorated_func") in by_node
|
|
|
|
|
|
def test_node_parallel_worker_instance():
|
|
"""Tests that node() can wrap a node in ParallelWorker."""
|
|
|
|
@node(parallel_worker=True)
|
|
def my_func(node_input):
|
|
return node_input
|
|
|
|
assert isinstance(my_func, ParallelWorker)
|
|
assert my_func.name == "my_func"
|
|
|
|
def other_func(x):
|
|
return x
|
|
|
|
parallel_node = node(other_func, parallel_worker=True)
|
|
assert isinstance(parallel_node, ParallelWorker)
|
|
assert parallel_node.name == "other_func"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_parallel_worker_execution():
|
|
"""Tests that a node with parallel_worker=True correctly processes inputs."""
|
|
|
|
@node(parallel_worker=True)
|
|
async def my_func(node_input):
|
|
return node_input * 2
|
|
|
|
async def producer_func() -> list[int]:
|
|
return [1, 2, 3]
|
|
|
|
wf = Workflow(
|
|
name="test_agent",
|
|
edges=[
|
|
(START, producer_func),
|
|
(producer_func, my_func),
|
|
],
|
|
)
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
by_node = _output_by_node(events)
|
|
assert ("producer_func", [1, 2, 3]) in by_node
|
|
assert ("my_func", [2, 4, 6]) in by_node
|
|
|
|
|
|
def test_node_decorator_rerun_on_resume():
|
|
"""Tests that @node decorator can override rerun_on_resume."""
|
|
|
|
@node(name="decorated_node", rerun_on_resume=True)
|
|
def my_func():
|
|
return "Hello from decorated_func"
|
|
|
|
assert isinstance(my_func, FunctionNode)
|
|
assert my_func.rerun_on_resume
|
|
|
|
@node()
|
|
def my_func2():
|
|
return "Hello from decorated_func2"
|
|
|
|
assert isinstance(my_func2, FunctionNode)
|
|
assert not my_func2.rerun_on_resume
|
|
|
|
|
|
def test_node_decorator_parameter_binding():
|
|
"""Tests that @node decorator can configure parameter_binding."""
|
|
|
|
@node(parameter_binding="node_input")
|
|
def my_func(foo: str):
|
|
return f"Hello {foo}"
|
|
|
|
assert isinstance(my_func, FunctionNode)
|
|
assert my_func.parameter_binding == "node_input"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_decorator_parameter_binding_execution():
|
|
"""Tests execution of a node with parameter_binding='node_input'."""
|
|
|
|
async def producer_func() -> dict[str, Any]:
|
|
return {"foo": "hello", "bar": 42}
|
|
|
|
@node(parameter_binding="node_input")
|
|
def my_func(foo: str, bar: int):
|
|
return f"{foo}:{bar}"
|
|
|
|
wf = Workflow(
|
|
name="test_agent",
|
|
edges=[
|
|
(START, producer_func),
|
|
(producer_func, my_func),
|
|
],
|
|
)
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
by_node = _output_by_node(events)
|
|
assert ("my_func", "hello:42") in by_node
|
|
|
|
|
|
def test_node_function_with_base_node():
|
|
"""Tests that node() function returns a copied node when given a BaseNode."""
|
|
|
|
@node(name="original")
|
|
def original():
|
|
pass
|
|
|
|
wrapped = node(original, name="overridden", rerun_on_resume=True)
|
|
|
|
assert isinstance(wrapped, FunctionNode)
|
|
assert wrapped is not original
|
|
assert wrapped.name == "overridden"
|
|
assert wrapped.rerun_on_resume
|
|
|
|
|
|
class MyTool(BaseTool):
|
|
name = "tool"
|
|
description = "desc"
|
|
|
|
async def _run_async_impl(self):
|
|
return "done"
|
|
|
|
|
|
def test_node_no_unnecessary_wrap():
|
|
"""Tests that node() does not wrap LlmAgent, Agent, Tool, or func in OverridingNode."""
|
|
|
|
llm_agent = LlmAgent(name="llm")
|
|
llm_node = node(llm_agent, name="overridden_llm")
|
|
|
|
assert isinstance(llm_node, LlmAgent)
|
|
assert llm_node.name == "overridden_llm"
|
|
assert llm_node.mode == "single_turn"
|
|
|
|
agent = BaseAgent(name="agent")
|
|
agent_node_inst = node(agent, name="overridden_agent", rerun_on_resume=True)
|
|
assert isinstance(agent_node_inst, BaseAgent)
|
|
assert agent_node_inst.name == "overridden_agent"
|
|
assert agent_node_inst.rerun_on_resume
|
|
|
|
tool_inst = MyTool(name="tool", description="desc")
|
|
t_node = node(tool_inst, name="overridden_tool")
|
|
assert isinstance(t_node, ToolNode)
|
|
assert t_node.name == "overridden_tool"
|
|
|
|
def my_func():
|
|
pass
|
|
|
|
f_node = node(my_func, name="overridden_func", rerun_on_resume=True)
|
|
assert isinstance(f_node, FunctionNode)
|
|
assert f_node.name == "overridden_func"
|
|
assert f_node.rerun_on_resume
|
|
|
|
|
|
class StatefulTool(BaseTool):
|
|
"""A tool that modifies state via tool_context."""
|
|
|
|
async def run_async(self, *, args, tool_context):
|
|
tool_context.state["tool_key"] = "tool_value"
|
|
tool_context.state["tool_count"] = 10
|
|
return {"status": "ok"}
|
|
|
|
|
|
from .workflow_testing_utils import simplify_events_with_node
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_node_state_delta():
|
|
"""Tests that state set via tool_context.state in ToolNode is persisted."""
|
|
|
|
tool_node = ToolNode(
|
|
tool=StatefulTool(name="stateful_tool", description="Sets state values"),
|
|
)
|
|
|
|
def read_state(tool_key: str, tool_count: int) -> str:
|
|
return f"tool_key={tool_key}, tool_count={tool_count}"
|
|
|
|
def start_node():
|
|
return {}
|
|
|
|
wf = Workflow(
|
|
name="test_tool_node_state_delta",
|
|
edges=[
|
|
(START, start_node),
|
|
(start_node, tool_node),
|
|
(tool_node, read_state),
|
|
],
|
|
)
|
|
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
simplified = simplify_events_with_node(
|
|
events, include_workflow_output=True, include_state_delta=True
|
|
)
|
|
|
|
assert (
|
|
"test_tool_node_state_delta@1/stateful_tool@1",
|
|
{"output": {"status": "ok"}},
|
|
) in [(e[0], {"output": e[1].get("output")}) for e in simplified]
|
|
|
|
assert (
|
|
"test_tool_node_state_delta@1/read_state@1",
|
|
{
|
|
"output": "tool_key=tool_value, tool_count=10",
|
|
},
|
|
) in [(e[0], {"output": e[1].get("output")}) for e in simplified]
|
|
|
|
|
|
class _CustomNode(Node):
|
|
custom_val: str = "hello"
|
|
rerun_on_resume: bool = True
|
|
|
|
async def run_node_impl(self, *, ctx, node_input):
|
|
yield f"subclass: {self.custom_val} -> {node_input}"
|
|
|
|
|
|
def test_node_subclassing_model_copy_preserves_identity():
|
|
"""Tests that Node.model_copy preserves the subclass class identity."""
|
|
node_inst = _CustomNode(
|
|
name="subclass", parallel_worker=True, custom_val="barrier"
|
|
)
|
|
assert node_inst.parallel_worker is True
|
|
|
|
cloned = node_inst.model_copy()
|
|
assert isinstance(cloned, _CustomNode)
|
|
assert cloned.custom_val == "barrier"
|
|
assert cloned.parallel_worker is True
|
|
assert isinstance(cloned._inner_node, ParallelWorker)
|
|
# Confirm inner node wraps a clone of _CustomNode preserving identity!
|
|
assert isinstance(cloned._inner_node._node, _CustomNode)
|
|
assert cloned._inner_node._node.custom_val == "barrier"
|
|
assert cloned._inner_node._node.parallel_worker is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_subclassing_execution_with_parallel_worker():
|
|
"""Tests that a subclassed Node with parallel_worker=True executes successfully."""
|
|
subclass_node = _CustomNode(
|
|
name="subclass", parallel_worker=True, custom_val="workflow"
|
|
)
|
|
|
|
async def producer():
|
|
return ["input1", "input2"]
|
|
|
|
wf = Workflow(
|
|
name="test_agent",
|
|
edges=[
|
|
(START, producer),
|
|
(producer, subclass_node),
|
|
],
|
|
)
|
|
|
|
events, _, _ = await _run_workflow(wf)
|
|
by_node = _output_by_node(events)
|
|
|
|
assert ("producer", ["input1", "input2"]) in by_node
|
|
assert (
|
|
"subclass",
|
|
["subclass: workflow -> input1", "subclass: workflow -> input2"],
|
|
) in by_node
|
|
|
|
|
|
def test_node_decorator_parallel_worker_max_parallel_workers():
|
|
"""Tests that node() correctly sets max_parallel_workers on ParallelWorker."""
|
|
|
|
@node(parallel_worker=True, max_parallel_workers=3)
|
|
def my_func(node_input):
|
|
return node_input
|
|
|
|
assert isinstance(my_func, ParallelWorker)
|
|
assert my_func.max_parallel_workers == 3
|
|
|
|
|
|
def test_node_decorator_invalid_max_parallel_workers():
|
|
"""Tests that node() raises ValueError if max_parallel_workers is set without parallel_worker."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="max_parallel_workers can only be set when parallel_worker is True",
|
|
):
|
|
|
|
@node(parallel_worker=False, max_parallel_workers=3)
|
|
def my_func(node_input):
|
|
return node_input
|
|
|
|
|
|
def test_node_subclass_invalid_max_parallel_workers():
|
|
"""Tests that Node subclass raises ValidationError if max_parallel_workers is set without parallel_worker."""
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
_CustomNode(name="subclass", parallel_worker=False, max_parallel_workers=3)
|
|
|
|
assert (
|
|
"max_parallel_workers can only be set when parallel_worker is True"
|
|
in str(exc_info.value)
|
|
)
|
|
|
|
|
|
def test_node_subclassing_model_copy_preserves_max_parallel_workers():
|
|
"""Tests that Node.model_copy preserves max_parallel_workers."""
|
|
node_inst = _CustomNode(
|
|
name="subclass",
|
|
parallel_worker=True,
|
|
max_parallel_workers=5,
|
|
custom_val="barrier",
|
|
)
|
|
assert node_inst.parallel_worker is True
|
|
assert node_inst.max_parallel_workers == 5
|
|
assert node_inst._inner_node.max_parallel_workers == 5
|
|
|
|
cloned = node_inst.model_copy()
|
|
assert isinstance(cloned, _CustomNode)
|
|
assert cloned.parallel_worker is True
|
|
assert cloned.max_parallel_workers == 5
|
|
assert isinstance(cloned._inner_node, ParallelWorker)
|
|
assert cloned._inner_node.max_parallel_workers == 5
|
|
assert cloned._inner_node._node.parallel_worker is False
|
|
|
|
|
|
def test_node_decorator_invalid_max_parallel_workers_less_than_one():
|
|
"""Tests that node() raises ValueError if max_parallel_workers is less than 1."""
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="max_parallel_workers must be greater than or equal to 1",
|
|
):
|
|
|
|
@node(parallel_worker=True, max_parallel_workers=0)
|
|
def my_func(node_input):
|
|
return node_input
|
|
|
|
|
|
def test_node_subclass_invalid_max_parallel_workers_less_than_one():
|
|
"""Tests that Node subclass raises ValidationError if max_parallel_workers is less than 1."""
|
|
from pydantic import ValidationError
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
_CustomNode(name="subclass", parallel_worker=True, max_parallel_workers=0)
|
|
|
|
assert "max_parallel_workers must be greater than or equal to 1" in str(
|
|
exc_info.value
|
|
)
|
|
|
|
|
|
def test_parallel_worker_invalid_max_parallel_workers_less_than_one():
|
|
"""Tests that ParallelWorker constructor raises ValueError if max_parallel_workers is less than 1."""
|
|
|
|
def dummy_func(x):
|
|
return x
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="max_parallel_workers must be greater than or equal to 1",
|
|
):
|
|
ParallelWorker(node=dummy_func, max_parallel_workers=0)
|