Skip to content

Commit 3cdc102

Browse files
DeanChensjcopybara-github
authored andcommitted
fix: Fix sub-branch event routing for nested sub-agents and tools in InvocationContext
- Allow sub-branch matching in `InvocationContext._is_branch_match` to ensure user confirmation function responses on descendant sub-branches (e.g. `my_wf_tool@fc-1.my_node_tool@fc-2`) are recognized by ancestor agents. - Add unit test `test_workflow_as_tool_nested_hitl` in `test_node_tool.py` to verify nested `NodeTool` and `WorkflowTool` event propagation. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 948474323
1 parent cba2827 commit 3cdc102

2 files changed

Lines changed: 98 additions & 34 deletions

File tree

src/google/adk/agents/invocation_context.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -435,12 +435,49 @@ def _get_events(
435435
if event.invocation_id == self.invocation_id
436436
]
437437
if current_branch:
438-
results = [
439-
event
440-
for event in results
441-
if event.branch == self.branch
442-
or (event.branch is None and event.author == "user")
443-
]
438+
439+
def _is_branch_match(event: Event) -> bool:
440+
"""Determines if an event belongs to the current branch or any descendant sub-branch."""
441+
if getattr(event, "author", None) == "user":
442+
frs = event.get_function_responses()
443+
if frs and self.branch and self.session:
444+
fr_ids = {fr.id for fr in frs if fr.id is not None}
445+
if fr_ids:
446+
# Gather function calls issued on this branch or descendant sub-branches
447+
# to verify the user response targets a call originated within this branch tree.
448+
branch_events = [
449+
e
450+
for e in self.session.events
451+
if e.branch
452+
and (
453+
e.branch == self.branch
454+
or e.branch.startswith(f"{self.branch}.")
455+
)
456+
]
457+
branch_fc_ids = {
458+
fc.id
459+
for e in branch_events
460+
for fc in e.get_function_calls()
461+
if fc.id is not None
462+
}
463+
# If user's response IDs do not match any function call on this branch tree,
464+
# prevent event leakage across parallel or unrelated branches.
465+
if not (fr_ids & branch_fc_ids):
466+
return False
467+
468+
# Match events yielded directly on this branch or on descendant sub-branches
469+
# (e.g. child NodeTool/WorkflowTool execution trees).
470+
if (
471+
event.branch is None
472+
or self.branch is None
473+
or event.branch == self.branch
474+
or (self.branch and event.branch.startswith(f"{self.branch}."))
475+
):
476+
return True
477+
return False
478+
return event.branch == self.branch
479+
480+
results = [e for e in results if _is_branch_match(e)]
444481
return results
445482

446483
def should_pause_invocation(self, event: Event) -> bool:

tests/unittests/workflow/test_node_tool.py

Lines changed: 55 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
import copy
1818
from typing import Any
1919

20+
from google.adk.agents.context import Context
2021
from google.adk.agents.llm_agent import LlmAgent
2122
from google.adk.apps.app import App
2223
from google.adk.apps.app import ResumabilityConfig
2324
from google.adk.events.event import Event
25+
from google.adk.events.request_input import RequestInput
2426
from google.adk.tools._node_tool import NodeTool
2527
from google.adk.tools.long_running_tool import LongRunningFunctionTool
2628
from google.adk.workflow import JoinNode
@@ -57,7 +59,6 @@ def test_node_tool_requires_input_schema():
5759
NodeTool(node=wf)
5860

5961

60-
@pytest.mark.skip(reason='Requires CL 2 subagent branch refactor')
6162
@pytest.mark.asyncio
6263
async def test_workflow_as_tool_hitl_resume(request: pytest.FixtureRequest):
6364
"""Workflow-as-a-tool suspends on RequestInput and resumes successfully.
@@ -176,7 +177,6 @@ def format_response(node_input: dict[str, Any]):
176177
assert 'Thank you! I received the user details.' in text_responses
177178

178179

179-
@pytest.mark.skip(reason='Requires CL 2 subagent branch refactor')
180180
@pytest.mark.asyncio
181181
async def test_workflow_as_tool_hitl_resume_non_resumable_app(
182182
request: pytest.FixtureRequest,
@@ -517,7 +517,6 @@ async def test_workflow_tool_with_nested_workflows(
517517
].function_response.response == {'result': 'inner_output'}
518518

519519

520-
@pytest.mark.skip(reason='Requires CL 2 subagent branch refactor')
521520
@pytest.mark.asyncio
522521
async def test_workflow_tool_with_dynamic_node_hitl_resume(
523522
request: pytest.FixtureRequest,
@@ -603,21 +602,30 @@ async def parent_node(*, ctx, node_input):
603602
assert 'Task completed.' in text_responses
604603

605604

606-
@pytest.mark.skip(
607-
reason='Known framework issue with MockModel nested HITL in sub-workflow'
608-
)
609605
@pytest.mark.asyncio
610606
async def test_workflow_as_tool_nested_hitl(request: pytest.FixtureRequest):
611607
"""Parent LLM agent -> workflow -> LLM agent -> NodeTool(HITL) propagation."""
608+
612609
# 1. Define the deepest node that raises RequestInput
613-
input_node = RequestInputNode(
614-
name='deep_input_node',
615-
message='Give me some input:',
616-
response_schema={
617-
'type': 'object',
618-
'properties': {'val': {'type': 'string'}},
619-
},
620-
)
610+
@node(name='deep_input_node', rerun_on_resume=True)
611+
def input_node(ctx: Context):
612+
resume_input = ctx.resume_inputs.get('deep_input_id')
613+
if not resume_input:
614+
yield RequestInput(
615+
interrupt_id='deep_input_id',
616+
message='Give me some input:',
617+
response_schema={
618+
'type': 'object',
619+
'properties': {'val': {'type': 'string'}},
620+
},
621+
)
622+
return
623+
user_val = (
624+
resume_input.get('val')
625+
if isinstance(resume_input, dict)
626+
else resume_input
627+
)
628+
yield Event(output=f'Processed: {user_val}')
621629

622630
# 2. Wrap it as a NodeTool
623631
input_node.input_schema = DummyRequest
@@ -708,22 +716,45 @@ async def test_workflow_as_tool_nested_hitl(request: pytest.FixtureRequest):
708716

709717

710718
@pytest.mark.skip(
711-
reason='Known framework issue with MockModel multi-HITL in nested workflow'
719+
reason='Requires Step 2 LRO pause resolution in base_llm_flow.py'
712720
)
713721
@pytest.mark.asyncio
714722
async def test_workflow_as_tool_nested_multi_hitl(
715723
request: pytest.FixtureRequest,
716724
):
717725
"""Parent LLM agent -> workflow -> LLM agent -> NodeTool(HITL) twice."""
726+
718727
# 1. Define the deepest node that raises RequestInput
719-
input_node = RequestInputNode(
720-
name='deep_input_node',
721-
message='Give me some input:',
722-
response_schema={
723-
'type': 'object',
724-
'properties': {'val': {'type': 'string'}},
725-
},
726-
)
728+
@node(name='deep_input_node', rerun_on_resume=True)
729+
def input_node(ctx: Context):
730+
resume_input_1 = ctx.resume_inputs.get('deep_input_id_1')
731+
resume_input_2 = ctx.resume_inputs.get('deep_input_id_2')
732+
if not resume_input_1:
733+
yield RequestInput(
734+
interrupt_id='deep_input_id_1',
735+
message='Give me some input:',
736+
response_schema={
737+
'type': 'object',
738+
'properties': {'val': {'type': 'string'}},
739+
},
740+
)
741+
return
742+
if not resume_input_2:
743+
yield RequestInput(
744+
interrupt_id='deep_input_id_2',
745+
message='Give me some input:',
746+
response_schema={
747+
'type': 'object',
748+
'properties': {'val': {'type': 'string'}},
749+
},
750+
)
751+
return
752+
user_val = (
753+
resume_input_2.get('val')
754+
if isinstance(resume_input_2, dict)
755+
else resume_input_2
756+
)
757+
yield Event(output=f'Processed: {user_val}')
727758

728759
# 2. Wrap it as a NodeTool
729760
input_node.input_schema = DummyRequest
@@ -734,10 +765,6 @@ async def test_workflow_as_tool_nested_multi_hitl(
734765
name='child_agent',
735766
model=testing_utils.MockModel.create(
736767
responses=[
737-
types.Part.from_function_call(
738-
name='my_node_tool',
739-
args={},
740-
),
741768
types.Part.from_function_call(
742769
name='my_node_tool',
743770
args={},
@@ -802,6 +829,7 @@ async def test_workflow_as_tool_nested_multi_hitl(
802829
new_message=testing_utils.UserContent(user_input1),
803830
invocation_id=invocation_id,
804831
)
832+
805833
request_input_event2 = workflow_testing_utils.find_function_call_event(
806834
events2, REQUEST_INPUT_FUNCTION_CALL_NAME
807835
)
@@ -825,7 +853,6 @@ async def test_workflow_as_tool_nested_multi_hitl(
825853
assert 'Parent agent finished successfully.' in text_responses
826854

827855

828-
@pytest.mark.skip(reason='Requires CL 2 subagent branch refactor')
829856
@pytest.mark.asyncio
830857
async def test_workflow_as_tool_nested_lro(request: pytest.FixtureRequest):
831858
"""Parent LLM agent -> workflow -> LLM agent -> LRO tool."""

0 commit comments

Comments
 (0)