Skip to content

Commit 196f770

Browse files
DeanChensjcopybara-github
authored andcommitted
chore: Rebase static graph dispatch onto dynamic scheduler
Use Context.run_node from within _workflow.py so static graphs also use the unified transfer loop. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 940728866
1 parent 3870032 commit 196f770

4 files changed

Lines changed: 58 additions & 47 deletions

File tree

src/google/adk/workflow/_workflow.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@
3737
from ._dynamic_node_scheduler import DynamicNodeState
3838
from ._graph import EdgeItem
3939
from ._graph import Graph
40-
from ._node_runner import NodeRunner
4140
from ._node_state import NodeState
4241
from ._node_status import NodeStatus
4342
from ._trigger import Trigger
@@ -536,7 +535,7 @@ def _start_node_task(
536535
node_name: str,
537536
trigger: Trigger,
538537
) -> None:
539-
"""Create NodeRunner and start asyncio task for a node."""
538+
"""Start asyncio task for scheduling and executing a node."""
540539

541540
assert self.graph is not None
542541

@@ -602,22 +601,24 @@ async def return_ctx():
602601
key
603602
].isolation_scope
604603

605-
runner = NodeRunner(
606-
node=node,
607-
parent_ctx=ctx,
608-
run_id=run_id,
609-
use_as_output=is_terminal,
610-
use_sub_branch=trigger.use_sub_branch,
611-
override_branch=trigger.branch,
612-
override_isolation_scope=self._compute_isolation_scope_for_node(
613-
node, trigger, ctx, run_id
614-
),
615-
)
616604
resume_inputs = (
617605
dict(node_state.resume_inputs) if node_state.resume_inputs else None
618606
)
619607
loop_state.pending_tasks[node_name] = asyncio.create_task(
620-
runner.run(node_input=trigger.input, resume_inputs=resume_inputs)
608+
ctx._run_node_internal(
609+
node,
610+
node_input=trigger.input,
611+
use_sub_branch=trigger.use_sub_branch,
612+
override_branch=trigger.branch,
613+
override_isolation_scope=self._compute_isolation_scope_for_node(
614+
node, trigger, ctx, run_id
615+
),
616+
return_ctx=True,
617+
resume_inputs=resume_inputs,
618+
run_id=run_id,
619+
use_as_output=is_terminal,
620+
skip_run_id_validation=True,
621+
)
621622
)
622623

623624
def _make_schedule_dynamic_node(

tests/unittests/workflow/test_workflow_hitl.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1858,15 +1858,16 @@ async def _run_impl(
18581858
async def test_multiple_invocations_isolation(request: pytest.FixtureRequest):
18591859
"""Verify that a new invocation ignores events from a previous invocation."""
18601860

1861+
run_counts = []
1862+
18611863
class CounterNode(BaseNode):
18621864
name: str = Field(default='counter_node')
1863-
run_count: int = Field(default=0)
18641865

18651866
async def _run_impl(
18661867
self, *, ctx: Context, node_input: Any
18671868
) -> AsyncGenerator[Any, None]:
1868-
self.run_count += 1
1869-
yield f'Run {self.run_count}'
1869+
run_counts.append(1)
1870+
yield f'Run {len(run_counts)}'
18701871

18711872
node_a = CounterNode()
18721873
wf = Workflow(name='wf', edges=[(START, node_a)])
@@ -1883,7 +1884,7 @@ async def _run_impl(
18831884
):
18841885
events1.append(event)
18851886

1886-
assert node_a.run_count == 1
1887+
assert len(run_counts) == 1
18871888

18881889
# Invocation 2 (New invocation in SAME session)
18891890
msg2 = types.Content(parts=[types.Part(text='go 2')], role='user')
@@ -1894,7 +1895,7 @@ async def _run_impl(
18941895
events2.append(event)
18951896

18961897
# If isolation works, CounterNode should run AGAIN!
1897-
assert node_a.run_count == 2
1898+
assert len(run_counts) == 2
18981899

18991900

19001901
@pytest.mark.asyncio

tests/unittests/workflow/test_workflow_live.py

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from google.adk.workflow._base_node import START
2828
from google.adk.workflow._workflow import Workflow
2929
from google.genai import types
30+
from pydantic import Field
3031
import pytest
3132

3233
from . import testing_utils
@@ -39,6 +40,7 @@ class _MockNonLiveNode(BaseNode):
3940

4041
called: bool = False
4142
actual_input: Any = None
43+
shared_state: dict[str, Any] = Field(default_factory=dict)
4244

4345
def __init__(self, *, name: str):
4446
super().__init__(name=name)
@@ -51,6 +53,8 @@ async def _run_impl(
5153
) -> AsyncGenerator[Any, None]:
5254
self.called = True
5355
self.actual_input = node_input
56+
self.shared_state["called"] = True
57+
self.shared_state["actual_input"] = node_input
5458
yield Event(output=f"{self.name}_output")
5559

5660

@@ -72,29 +76,6 @@ async def _run_impl(
7276
yield Event(output=self.output_value)
7377

7478

75-
class _DynamicLiveSchedulerNode(BaseNode):
76-
"""A node that dynamically schedules a child live node using ctx.run_node()."""
77-
78-
child_node: BaseNode | None = None
79-
child_output: Any = None
80-
81-
def __init__(self, *, name: str, child_node: BaseNode):
82-
super().__init__(name=name, rerun_on_resume=True)
83-
self.child_node = child_node
84-
85-
async def _run_impl(
86-
self,
87-
*,
88-
ctx: Context,
89-
node_input: Any,
90-
) -> AsyncGenerator[Any, None]:
91-
if self.child_node:
92-
self.child_output = await ctx.run_node(
93-
self.child_node, node_input=node_input
94-
)
95-
yield Event(output=f"{self.name}_output")
96-
97-
9879
# --- Live Workflow Unit Tests (TDD) ---
9980

10081

@@ -468,6 +449,30 @@ async def test_nested_live_node_and_outer_live_node():
468449
@pytest.mark.asyncio
469450
async def test_dynamic_node_scheduling_of_live_node():
470451
"""CUJ 4: A node in workflow dynamically schedules a live node using ctx.run_node()."""
452+
453+
class _DynamicLiveSchedulerNode(BaseNode):
454+
"""A node that dynamically schedules a child live node using ctx.run_node()."""
455+
456+
child_node: BaseNode | None = None
457+
child_output: Any = None
458+
shared_state: dict[str, Any] = Field(default_factory=dict)
459+
460+
def __init__(self, *, name: str, child_node: BaseNode):
461+
super().__init__(name=name, rerun_on_resume=True)
462+
self.child_node = child_node
463+
464+
async def _run_impl(
465+
self,
466+
*,
467+
ctx: Context,
468+
node_input: Any,
469+
) -> AsyncGenerator[Any, None]:
470+
if self.child_node:
471+
output = await ctx.run_node(self.child_node, node_input=node_input)
472+
self.child_output = output
473+
self.shared_state["child_output"] = output
474+
yield Event(output=f"{self.name}_output")
475+
471476
mock_model = testing_utils.MockModel.create(
472477
responses=[
473478
LlmResponse(
@@ -531,7 +536,9 @@ async def test_dynamic_node_scheduling_of_live_node():
531536
{"result": "DynamicLiveNode_output"},
532537
"SchedulerNode_output",
533538
]
534-
assert scheduler_node.child_output == {"result": "DynamicLiveNode_output"}
539+
assert scheduler_node.shared_state.get("child_output") == {
540+
"result": "DynamicLiveNode_output"
541+
}
535542

536543
# Assert content events
537544
content_texts = [
@@ -663,7 +670,7 @@ async def test_single_turn_agent_runs_as_non_live_in_live_session():
663670

664671
outputs = [e.output for e in events if e.output is not None]
665672
assert outputs == ["initial_text_input", "capture_output"]
666-
assert capture.actual_input == "SingleTurn_output"
673+
assert capture.shared_state.get("actual_input") == "SingleTurn_output"
667674
# Verify that the model received the initial_text_input (node_input) and NOT the live queue audio
668675
assert len(mock_model.requests) == 1
669676
assert (

tests/unittests/workflow/test_workflow_schema.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from google.adk.workflow._workflow import Workflow
2727
from google.genai import types
2828
from pydantic import BaseModel
29+
from pydantic import ValidationError
2930
import pytest
3031

3132
from .. import testing_utils
@@ -630,9 +631,10 @@ async def _run_impl(
630631
msg = types.Content(parts=[types.Part(text='hello')], role='user')
631632

632633
# We expect it to raise ValidationError
633-
from pydantic import ValidationError
634-
635-
with pytest.raises(ValidationError):
634+
with pytest.raises(
635+
ValueError,
636+
match=r"Runtime schema validation failed for dynamic node 'node'",
637+
):
636638
async for event in runner.run_async(
637639
user_id='u', session_id=session.id, new_message=msg
638640
):

0 commit comments

Comments
 (0)