chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
# 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)
|
||||
Reference in New Issue
Block a user