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
1125 lines
31 KiB
Python
1125 lines
31 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.
|
|
|
|
import copy
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
import uuid
|
|
|
|
from google.adk.agents.context import Context
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.apps.app import App
|
|
from google.adk.events.event import Event
|
|
from google.adk.events.request_input import RequestInput
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.tools.long_running_tool import LongRunningFunctionTool
|
|
from google.adk.workflow import BaseNode
|
|
from google.adk.workflow import JoinNode
|
|
from google.adk.workflow._base_node import START
|
|
from google.adk.workflow._node_status import NodeStatus
|
|
from google.adk.workflow._workflow import Workflow
|
|
from google.adk.workflow.utils._workflow_hitl_utils import create_request_input_response
|
|
from google.adk.workflow.utils._workflow_hitl_utils import get_request_input_interrupt_ids
|
|
from google.adk.workflow.utils._workflow_hitl_utils import has_request_input_function_call
|
|
from google.genai import types
|
|
from pydantic import ConfigDict
|
|
from pydantic import Field
|
|
import pytest
|
|
from typing_extensions import override
|
|
|
|
from .. import testing_utils
|
|
from .workflow_testing_utils import find_function_call_event
|
|
from .workflow_testing_utils import InputCapturingNode
|
|
from .workflow_testing_utils import RequestInputNode
|
|
from .workflow_testing_utils import simplify_events_with_node
|
|
from .workflow_testing_utils import TestingNode
|
|
|
|
|
|
def long_running_tool_func():
|
|
"""A test tool that simulates a long-running operation."""
|
|
return None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_as_node(request: pytest.FixtureRequest):
|
|
"""Tests that a Workflow can be used as a node in another Workflow."""
|
|
|
|
async def nested_func(node_input: types.Content):
|
|
return 'I am nested'
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[('START', nested_func)],
|
|
)
|
|
|
|
async def output_func(node_input: str):
|
|
return 'I am outer'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', nested_agent), (nested_agent, output_func)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
events = await runner.run_async(testing_utils.get_user_content('hello'))
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_func@1',
|
|
{
|
|
'output': 'I am nested',
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/output_func@1',
|
|
{
|
|
'output': 'I am outer',
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_with_join_node(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that a nested Workflow with JoinNode works correctly."""
|
|
|
|
async def nested_node_a():
|
|
return {'a': 1}
|
|
|
|
async def nested_node_b():
|
|
return {'b': 2}
|
|
|
|
async def nested_node_c():
|
|
return {'c': 3}
|
|
|
|
nested_join_node = JoinNode(name='nested_join')
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[
|
|
('START', nested_node_a),
|
|
('START', nested_node_c),
|
|
(nested_node_a, nested_join_node),
|
|
(nested_node_c, nested_node_b),
|
|
(nested_node_b, nested_join_node),
|
|
],
|
|
)
|
|
|
|
async def output_func(node_input: dict):
|
|
return (
|
|
'Joined output:'
|
|
f' a={node_input["nested_node_a"]["a"]},'
|
|
f' b={node_input["nested_node_b"]["b"]}'
|
|
)
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', nested_agent), (nested_agent, output_func)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
events = await runner.run_async(testing_utils.get_user_content('hello'))
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert sorted(simplified_events[0:2], key=lambda x: x[0]) == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_node_a@1',
|
|
{'output': {'a': 1}},
|
|
),
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_node_c@1',
|
|
{'output': {'c': 3}},
|
|
),
|
|
]
|
|
assert simplified_events[2:] == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_node_b@1',
|
|
{'output': {'b': 2}},
|
|
),
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_join@1',
|
|
{
|
|
'output': {'nested_node_a': {'a': 1}, 'nested_node_b': {'b': 2}},
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/output_func@1',
|
|
{
|
|
'output': 'Joined output: a=1, b=2',
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_updates_state_outer_reads(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that outer workflow can read state updated by nested workflow."""
|
|
|
|
async def nested_state_updater(ctx: Context):
|
|
yield Event(
|
|
state={'my_key': 'my_value'},
|
|
)
|
|
yield 'nested agent finished'
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[('START', nested_state_updater)],
|
|
)
|
|
|
|
def outer_state_reader(my_key: str, node_input: str):
|
|
return f'Nested agent output: {node_input}, state value: {my_key}'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', nested_agent), (nested_agent, outer_state_reader)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
events = await runner.run_async(testing_utils.get_user_content('hello'))
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/nested_state_updater@1',
|
|
{
|
|
'output': 'nested agent finished',
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/outer_state_reader@1',
|
|
{
|
|
'output': (
|
|
'Nested agent output: nested agent finished, state value:'
|
|
' my_value'
|
|
),
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_intermediate_nodes(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that only the final output of a nested workflow is passed to the outer workflow."""
|
|
|
|
node_a = TestingNode(name='NodeA', output='Inner Intermediate')
|
|
node_b = TestingNode(name='NodeB', output='Inner Final')
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[
|
|
('START', node_a),
|
|
(node_a, node_b),
|
|
],
|
|
)
|
|
|
|
output_node = InputCapturingNode(name='OutputNode')
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[
|
|
('START', nested_agent),
|
|
(nested_agent, output_node),
|
|
],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
events = await runner.run_async(testing_utils.get_user_content('start'))
|
|
|
|
assert output_node.received_inputs == ['Inner Final']
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/NodeA@1',
|
|
{'output': 'Inner Intermediate'},
|
|
),
|
|
(
|
|
'outer_agent@1/nested_agent@1/NodeB@1',
|
|
{'output': 'Inner Final'},
|
|
),
|
|
(
|
|
'outer_agent@1/OutputNode@1',
|
|
{'output': {'received': 'Inner Final'}},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_with_hitl(request: pytest.FixtureRequest):
|
|
"""Tests that a nested Workflow with HITL works correctly."""
|
|
# Given: A nested workflow with an LLM agent that calls a long running tool
|
|
llm_agent = LlmAgent(
|
|
name='llm_agent',
|
|
model=testing_utils.MockModel.create(
|
|
responses=[
|
|
types.Part.from_function_call(
|
|
name='long_running_tool_func',
|
|
args={},
|
|
),
|
|
types.Part.from_text(text='LLM response after tool'),
|
|
]
|
|
),
|
|
tools=[LongRunningFunctionTool(func=long_running_tool_func)],
|
|
)
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[('START', llm_agent)],
|
|
)
|
|
|
|
async def output_func(node_input: Any):
|
|
return 'I am outer'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', nested_agent), (nested_agent, output_func)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
|
|
# When: Starting the workflow
|
|
user_event = testing_utils.get_user_content('start workflow')
|
|
events1 = await runner.run_async(user_event)
|
|
|
|
# Then: It should yield a FunctionCall event and wait for tool execution
|
|
ev = find_function_call_event(events1, 'long_running_tool_func')
|
|
assert ev is not None
|
|
function_call_id = ev.content.parts[0].function_call.id
|
|
|
|
simplified_events1 = simplify_events_with_node(events1)
|
|
assert simplified_events1 == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/llm_agent@1',
|
|
types.Part.from_function_call(name='long_running_tool_func', args={}),
|
|
),
|
|
]
|
|
|
|
tool_response = testing_utils.UserContent(
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
id=function_call_id,
|
|
name='long_running_tool_func',
|
|
response={'result': 'Final tool output'},
|
|
)
|
|
)
|
|
)
|
|
|
|
invocation_id = events1[0].invocation_id
|
|
events2 = await runner.run_async(
|
|
new_message=tool_response,
|
|
invocation_id=invocation_id,
|
|
)
|
|
|
|
simplified_events2 = simplify_events_with_node(events2)
|
|
assert simplified_events2 == [
|
|
('outer_agent@1/nested_agent@1/llm_agent@1', 'LLM response after tool'),
|
|
(
|
|
'outer_agent@1/output_func@1',
|
|
{'output': 'I am outer'},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_with_request_input_event_hitl(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Nested workflow correctly propagates RequestInput and resumes.
|
|
|
|
Setup: outer_agent -> nested_agent -> node_hitl.
|
|
Act:
|
|
- Run 1: start workflow, node_hitl yields RequestInput.
|
|
- Run 2: resume with user response.
|
|
Assert:
|
|
- Run 1: returns RequestInput event.
|
|
- Run 2: node_hitl receives input and completes.
|
|
"""
|
|
|
|
class NodeHitl(BaseNode):
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
rerun_on_resume: bool = Field(default=True)
|
|
name: str = Field(default='node_hitl')
|
|
|
|
@override
|
|
async def _run_impl(
|
|
self,
|
|
*,
|
|
ctx: Context,
|
|
node_input: Any,
|
|
) -> AsyncGenerator[Any, None]:
|
|
if resume_input := ctx.resume_inputs.get('request_input'):
|
|
yield f'Resumed with user input: {resume_input}'
|
|
else:
|
|
yield RequestInput(
|
|
message='requesting input via RequestInputEvent',
|
|
interrupt_id='request_input',
|
|
)
|
|
|
|
# Given: A nested workflow where the inner node requests input
|
|
node_hitl_instance = NodeHitl()
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[('START', node_hitl_instance)],
|
|
)
|
|
|
|
async def output_func():
|
|
return 'I am outer'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', nested_agent), (nested_agent, output_func)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
|
|
# When: Starting the workflow
|
|
user_event = testing_utils.get_user_content('start workflow')
|
|
events1 = await runner.run_async(user_event)
|
|
|
|
# Then: It should yield a RequestInput event
|
|
req_events = [e for e in events1 if has_request_input_function_call(e)]
|
|
assert req_events
|
|
interrupt_id = get_request_input_interrupt_ids(req_events[0])[0]
|
|
assert interrupt_id == 'request_input'
|
|
|
|
simplified_events1 = simplify_events_with_node(events1)
|
|
assert simplified_events1 == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/node_hitl@1',
|
|
testing_utils.simplify_content(copy.deepcopy(req_events[0].content)),
|
|
),
|
|
]
|
|
|
|
# When: Resuming with user response
|
|
hitl_response = types.Content(
|
|
parts=[
|
|
create_request_input_response(
|
|
interrupt_id, {'response': 'user input for hitl'}
|
|
)
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
invocation_id = events1[0].invocation_id
|
|
events2 = await runner.run_async(
|
|
new_message=hitl_response,
|
|
invocation_id=invocation_id,
|
|
)
|
|
|
|
# Then: It should complete and pass output to the outer workflow
|
|
simplified_events2 = simplify_events_with_node(events2)
|
|
assert simplified_events2 == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/node_hitl@1',
|
|
{
|
|
'output': (
|
|
"Resumed with user input: {'response': 'user input for hitl'}"
|
|
),
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/output_func@1',
|
|
{'output': 'I am outer'},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_agent_with_request_input_piped_to_next_node(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that user response to RequestInput in nested agent is piped to next node."""
|
|
ask_user = RequestInputNode(
|
|
name='ask_user',
|
|
message='Please provide input',
|
|
)
|
|
capture_node = InputCapturingNode(name='capture_node')
|
|
|
|
sub_agent = Workflow(
|
|
name='sub_agent',
|
|
edges=[
|
|
('START', ask_user),
|
|
(ask_user, capture_node),
|
|
],
|
|
)
|
|
|
|
root_agent = Workflow(
|
|
name='root_agent',
|
|
edges=[('START', sub_agent)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=root_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
|
|
user_event = testing_utils.get_user_content('start workflow')
|
|
events1 = await runner.run_async(user_event)
|
|
|
|
req_events = [e for e in events1 if has_request_input_function_call(e)]
|
|
assert req_events
|
|
interrupt_id = get_request_input_interrupt_ids(req_events[0])[0]
|
|
assert interrupt_id
|
|
hitl_response_payload = {'response': 'user input for hitl'}
|
|
hitl_response = types.Content(
|
|
parts=[
|
|
create_request_input_response(interrupt_id, hitl_response_payload)
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
invocation_id = events1[0].invocation_id
|
|
await runner.run_async(
|
|
new_message=hitl_response,
|
|
invocation_id=invocation_id,
|
|
)
|
|
|
|
assert capture_node.received_inputs == [hitl_response_payload]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_chain_input_propagation(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
|
|
async def create_output_a():
|
|
return 'output of A'
|
|
|
|
nested_agent_a = Workflow(
|
|
name='nested_agent_a',
|
|
edges=[('START', create_output_a)],
|
|
)
|
|
|
|
capture_node_b = InputCapturingNode(name='capture_node_b')
|
|
nested_agent_b = Workflow(
|
|
name='nested_agent_b',
|
|
edges=[('START', capture_node_b)],
|
|
)
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[
|
|
('START', nested_agent_a),
|
|
(nested_agent_a, nested_agent_b),
|
|
],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
await runner.run_async(testing_utils.get_user_content('hello'))
|
|
|
|
assert capture_node_b.received_inputs == ['output of A']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_with_tool_calls(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that a nested Workflow works correctly with two tool calls."""
|
|
tool_call_count = 0
|
|
|
|
def simple_tool() -> str:
|
|
nonlocal tool_call_count
|
|
tool_call_count += 1
|
|
return f'Tool output {tool_call_count}'
|
|
|
|
# Given: A nested workflow where the inner node calls a tool
|
|
llm_agent = LlmAgent(
|
|
name='llm_agent',
|
|
model=testing_utils.MockModel.create(
|
|
responses=[
|
|
types.Part.from_function_call(
|
|
name='simple_tool',
|
|
args={},
|
|
),
|
|
types.Part.from_text(text='LLM response after tools'),
|
|
]
|
|
),
|
|
tools=[simple_tool],
|
|
)
|
|
|
|
nested_agent = Workflow(
|
|
name='nested_agent',
|
|
edges=[('START', llm_agent)],
|
|
)
|
|
|
|
async def output_func():
|
|
return 'I am outer'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[
|
|
('START', nested_agent),
|
|
(nested_agent, output_func),
|
|
],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
|
|
# When: Starting the workflow
|
|
user_event = testing_utils.get_user_content('start workflow')
|
|
events = await runner.run_async(user_event)
|
|
|
|
# Then: It should call the tool and yield events
|
|
assert tool_call_count == 1
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
|
|
# Extract the dynamically generated function_call_id from events.
|
|
ev = find_function_call_event(events, 'simple_tool')
|
|
assert ev is not None
|
|
function_call_id = ev.content.parts[0].function_call.id
|
|
|
|
assert simplified_events == [
|
|
(
|
|
'outer_agent@1/nested_agent@1/llm_agent@1',
|
|
types.Part.from_function_call(name='simple_tool', args={}),
|
|
),
|
|
(
|
|
'outer_agent@1/nested_agent@1/llm_agent@1',
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='simple_tool',
|
|
response={'result': 'Tool output 1'},
|
|
)
|
|
),
|
|
),
|
|
('outer_agent@1/nested_agent@1/llm_agent@1', 'LLM response after tools'),
|
|
(
|
|
'outer_agent@1/output_func@1',
|
|
{
|
|
'output': 'I am outer',
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_three_level_nested_workflow(request: pytest.FixtureRequest):
|
|
"""Tests a 3-level nested Workflow structure."""
|
|
|
|
async def inner_func():
|
|
return 'I am inner'
|
|
|
|
inner_agent = Workflow(
|
|
name='inner_agent',
|
|
edges=[('START', inner_func)],
|
|
)
|
|
|
|
async def middle_func():
|
|
return 'I am middle'
|
|
|
|
middle_agent = Workflow(
|
|
name='middle_agent',
|
|
edges=[('START', inner_agent), (inner_agent, middle_func)],
|
|
)
|
|
|
|
async def outer_func():
|
|
return 'I am outer'
|
|
|
|
outer_agent = Workflow(
|
|
name='outer_agent',
|
|
edges=[('START', middle_agent), (middle_agent, outer_func)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=outer_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
events = await runner.run_async(testing_utils.get_user_content('hello'))
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'outer_agent@1/middle_agent@1/inner_agent@1/inner_func@1',
|
|
{
|
|
'output': 'I am inner',
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/middle_agent@1/middle_func@1',
|
|
{
|
|
'output': 'I am middle',
|
|
},
|
|
),
|
|
(
|
|
'outer_agent@1/outer_func@1',
|
|
{
|
|
'output': 'I am outer',
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_grandchild_workflow_names(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that grandchild workflow agents with same name can coexist."""
|
|
|
|
# Given: A workflow hierarchy where grandchild workflows have the same name
|
|
async def grandchild_func():
|
|
return 'I am grandchild'
|
|
|
|
grandchild_agent = Workflow(
|
|
name='grandchild',
|
|
edges=[('START', grandchild_func)],
|
|
)
|
|
|
|
child1_agent = Workflow(
|
|
name='child1',
|
|
edges=[('START', copy.deepcopy(grandchild_agent))],
|
|
)
|
|
|
|
child2_agent = Workflow(
|
|
name='child2',
|
|
edges=[('START', copy.deepcopy(grandchild_agent))],
|
|
)
|
|
|
|
root_agent = Workflow(
|
|
name='root',
|
|
edges=[('START', child1_agent), (child1_agent, child2_agent)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=root_agent,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
# When: Starting the workflow
|
|
user_content = testing_utils.get_user_content('hello')
|
|
events = await runner.run_async(user_content)
|
|
|
|
# Then: It should execute both grandchildren successfully
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'root@1/child1@1/grandchild@1/grandchild_func@1',
|
|
{'output': 'I am grandchild'},
|
|
),
|
|
(
|
|
'root@1/child2@1/grandchild@1/grandchild_func@1',
|
|
{'output': 'I am grandchild'},
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_name_in_ancestral_path(
|
|
request: pytest.FixtureRequest,
|
|
):
|
|
"""Tests that agent with same name can exist in ancestral path (A->B->A)."""
|
|
|
|
async def func_a():
|
|
return 'I am A'
|
|
|
|
agent_a_child = Workflow(
|
|
name='A',
|
|
edges=[('START', func_a)],
|
|
)
|
|
|
|
agent_b = Workflow(
|
|
name='B',
|
|
edges=[('START', agent_a_child)],
|
|
)
|
|
|
|
agent_a_root = Workflow(
|
|
name='A',
|
|
edges=[('START', agent_b)],
|
|
)
|
|
|
|
app = App(
|
|
name=request.function.__name__,
|
|
root_agent=agent_a_root,
|
|
)
|
|
runner = testing_utils.InMemoryRunner(app=app)
|
|
user_content = testing_utils.get_user_content('hello')
|
|
events = await runner.run_async(user_content)
|
|
|
|
simplified_events = simplify_events_with_node(events)
|
|
assert simplified_events == [
|
|
(
|
|
'A@1/B@1/A@1/func_a@1',
|
|
{'output': 'I am A'},
|
|
),
|
|
]
|
|
|
|
|
|
# --- Helpers moved from test_workflow.py ---
|
|
|
|
|
|
class _OutputNode(BaseNode):
|
|
"""Yields a fixed output value."""
|
|
|
|
value: Any = None
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield self.value
|
|
|
|
|
|
class _InputCapturingNode(BaseNode):
|
|
"""Captures node_input for later assertion."""
|
|
|
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
received_inputs: list[Any] = Field(default_factory=list)
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
self.received_inputs.append(node_input)
|
|
yield {'received': node_input}
|
|
|
|
|
|
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 _outputs(events):
|
|
"""Extract non-None outputs from events."""
|
|
return [e.output for e in events if e.output is not None]
|
|
|
|
|
|
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 moved from test_workflow.py ---
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_completes():
|
|
"""Inner workflow runs to completion, outer continues downstream."""
|
|
inner_node = _OutputNode(name='inner_node', value='inner_result')
|
|
inner_wf = Workflow(name='inner_wf', edges=[(START, inner_node)])
|
|
before = _OutputNode(name='before', value='before_result')
|
|
after = _InputCapturingNode(name='after')
|
|
wf = Workflow(name='wf', edges=[(START, before, inner_wf, after)])
|
|
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
by_node = _output_by_node(events)
|
|
assert ('before', 'before_result') in by_node
|
|
assert ('inner_node', 'inner_result') in by_node
|
|
assert after.received_inputs == ['inner_result']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_event_author():
|
|
"""Events are authored by the nearest orchestrator (workflow/agent).
|
|
|
|
Setup: outer_wf → inner_wf → inner_node.
|
|
Assert:
|
|
- inner_node's events are authored by inner_wf (nearest).
|
|
- outer_wf's direct children are authored by outer_wf.
|
|
- inner_wf overrides the author for its subtree.
|
|
"""
|
|
inner_node = _OutputNode(name='inner_node', value='inner_result')
|
|
inner_wf = Workflow(name='inner_wf', edges=[(START, inner_node)])
|
|
outer_node = _OutputNode(name='outer_node', value='outer_result')
|
|
wf = Workflow(
|
|
name='outer_wf',
|
|
edges=[(START, outer_node, inner_wf)],
|
|
)
|
|
|
|
events, _, _ = await _run_workflow(wf)
|
|
|
|
# outer_node's events authored by outer_wf (nearest orchestrator).
|
|
outer_events = [
|
|
e for e in events if e.node_info.path == 'outer_wf@1/outer_node@1'
|
|
]
|
|
assert outer_events
|
|
assert all(e.author == 'outer_wf' for e in outer_events)
|
|
|
|
# inner_node's events authored by inner_wf (nearest orchestrator),
|
|
# NOT outer_wf.
|
|
inner_events = [
|
|
e
|
|
for e in events
|
|
if e.node_info.path == 'outer_wf@1/inner_wf@1/inner_node@1'
|
|
]
|
|
assert inner_events
|
|
assert all(e.author == 'inner_wf' for e in inner_events)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_interrupt_and_resume():
|
|
"""Inner workflow child interrupts, outer resumes on FR."""
|
|
|
|
class _InterruptNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
fc_id = ctx.state.get('_nested_fc')
|
|
if fc_id and ctx.resume_inputs and fc_id in ctx.resume_inputs:
|
|
ctx.state['_nested_fc'] = None
|
|
response = ctx.resume_inputs[fc_id]['answer']
|
|
yield f'approved:{response}'
|
|
return
|
|
fc_id = f'fc-{uuid.uuid4().hex[:8]}'
|
|
ctx.state['_nested_fc'] = fc_id
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='inner_tool', args={}, id=fc_id
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={fc_id},
|
|
)
|
|
|
|
inner_wf = Workflow(
|
|
name='inner_wf',
|
|
edges=[(START, _InterruptNode(name='approval'))],
|
|
)
|
|
before = _OutputNode(name='before', value='before_result')
|
|
after = _InputCapturingNode(name='after')
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[(START, before, inner_wf, after)],
|
|
)
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# Run 1: before completes, inner_wf/approval interrupts
|
|
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
|
|
events1: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg1
|
|
):
|
|
events1.append(event)
|
|
|
|
# Should have interrupt from inner's child
|
|
interrupt_events = [e for e in events1 if e.long_running_tool_ids]
|
|
assert len(interrupt_events) == 1
|
|
assert interrupt_events[0].long_running_tool_ids is not None
|
|
fc_id = list(interrupt_events[0].long_running_tool_ids)[0]
|
|
|
|
# Workflow-level interrupt events should NOT be persisted
|
|
# (they're _adk_internal). Only the leaf child's event at
|
|
# 'wf/inner_wf/approval' should have interrupt ids in session.
|
|
updated_session = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
assert updated_session is not None
|
|
wf_interrupt_events = [
|
|
e
|
|
for e in updated_session.events
|
|
if e.long_running_tool_ids and e.node_info.path in ('wf', 'wf/inner_wf')
|
|
]
|
|
assert wf_interrupt_events == []
|
|
|
|
# Run 2: resume
|
|
msg2 = types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='inner_tool',
|
|
id=fc_id,
|
|
response={'answer': 'yes'},
|
|
)
|
|
)
|
|
],
|
|
role='user',
|
|
)
|
|
events2: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg2
|
|
):
|
|
events2.append(event)
|
|
|
|
# Inner resumed, after should receive inner's output
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'approved:yes' in outputs
|
|
assert after.received_inputs == ['approved:yes']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_workflow_partial_resume():
|
|
"""Partial FR re-runs nested Workflow, resolved child completes while unresolved stays interrupted.
|
|
|
|
Setup: outer_wf → inner_wf → (child_a, child_b) → join.
|
|
Both children interrupt on first run.
|
|
Act:
|
|
- Run 2: resolve only child_a's FR.
|
|
- Run 3: resolve child_b's FR.
|
|
Assert:
|
|
- Run 2: child_a produces output, invocation still interrupted.
|
|
- Run 3: child_b produces output, join completes, no interrupts.
|
|
"""
|
|
|
|
class _InterruptOnce(BaseNode):
|
|
"""Interrupts on first run, yields resume response on second."""
|
|
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
fc_id = f'fc-{self.name}'
|
|
if ctx.resume_inputs and fc_id in ctx.resume_inputs:
|
|
yield f'{self.name}:{ctx.resume_inputs[fc_id]}'
|
|
return
|
|
yield RequestInput(interrupt_id=fc_id)
|
|
|
|
child_a = _InterruptOnce(name='child_a')
|
|
child_b = _InterruptOnce(name='child_b')
|
|
join = JoinNode(name='join', wait_for_output=True)
|
|
|
|
inner_wf = Workflow(
|
|
name='inner_wf',
|
|
edges=[
|
|
(START, child_a),
|
|
(START, child_b),
|
|
(child_a, join),
|
|
(child_b, join),
|
|
],
|
|
)
|
|
|
|
outer_wf = Workflow(
|
|
name='outer',
|
|
edges=[(START, inner_wf)],
|
|
)
|
|
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=outer_wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# Run 1: both children interrupt
|
|
msg1 = types.Content(parts=[types.Part(text='go')], role='user')
|
|
events1: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg1
|
|
):
|
|
events1.append(event)
|
|
|
|
interrupt_ids = set()
|
|
for e in events1:
|
|
if e.long_running_tool_ids:
|
|
interrupt_ids.update(e.long_running_tool_ids)
|
|
assert 'fc-child_a' in interrupt_ids
|
|
assert 'fc-child_b' in interrupt_ids
|
|
|
|
# Run 2: resolve only child_a
|
|
msg2 = types.Content(
|
|
parts=[create_request_input_response('fc-child_a', {'v': 'a'})],
|
|
role='user',
|
|
)
|
|
events2: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg2
|
|
):
|
|
events2.append(event)
|
|
|
|
# child_a should have produced output
|
|
child_a_outputs = [
|
|
e.output
|
|
for e in events2
|
|
if e.node_info.path and 'child_a' in e.node_info.path and e.output
|
|
]
|
|
assert any('child_a:' in str(o) for o in child_a_outputs)
|
|
|
|
# Run 3: resolve child_b → join completes, workflow finishes
|
|
msg3 = types.Content(
|
|
parts=[create_request_input_response('fc-child_b', {'v': 'b'})],
|
|
role='user',
|
|
)
|
|
events3: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg3
|
|
):
|
|
events3.append(event)
|
|
|
|
# child_b should have produced output
|
|
child_b_outputs = [
|
|
e.output
|
|
for e in events3
|
|
if e.node_info.path and 'child_b' in e.node_info.path and e.output
|
|
]
|
|
assert any('child_b:' in str(o) for o in child_b_outputs)
|
|
|
|
# join should have completed (no more interrupts)
|
|
final_interrupts = set()
|
|
for e in events3:
|
|
if e.long_running_tool_ids:
|
|
final_interrupts.update(e.long_running_tool_ids)
|
|
assert not final_interrupts
|