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
1300 lines
40 KiB
Python
1300 lines
40 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 dynamic node scheduling in new Workflow.
|
|
|
|
Covers the three cases from doc 18 (Dynamic Node Resume via Lazy Scan):
|
|
- Case 1: Fresh execution (no prior events)
|
|
- Case 2: Completed dedup (return cached output on rerun)
|
|
- Case 3: Interrupted resume (rerun with resume_inputs)
|
|
|
|
Plus edge cases for multiple dynamic nodes, nested dynamic nodes,
|
|
and use_as_output delegation.
|
|
"""
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
|
|
from google.adk.agents.context import Context
|
|
from google.adk.events.event import Event
|
|
from google.adk.runners import Runner
|
|
from google.adk.sessions.in_memory_session_service import InMemorySessionService
|
|
from google.adk.workflow import node
|
|
from google.adk.workflow import START
|
|
from google.adk.workflow._errors import DynamicNodeFailError
|
|
from google.adk.workflow._workflow import Workflow
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
# --- Helpers ---
|
|
|
|
|
|
async def _run(
|
|
runner: Runner,
|
|
ss: InMemorySessionService,
|
|
session: Any,
|
|
message: str,
|
|
) -> list[Event]:
|
|
"""Send a text message and collect events."""
|
|
msg = types.Content(parts=[types.Part(text=message)], role='user')
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
events.append(event)
|
|
return events
|
|
|
|
|
|
async def _resume(
|
|
runner: Runner,
|
|
ss: InMemorySessionService,
|
|
session: Any,
|
|
fc_id: str,
|
|
response: Any,
|
|
) -> list[Event]:
|
|
"""Send a function response and collect events."""
|
|
if not isinstance(response, dict):
|
|
response = {'value': response}
|
|
msg = types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='tool', id=fc_id, response=response
|
|
)
|
|
)
|
|
],
|
|
role='user',
|
|
)
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
events.append(event)
|
|
return events
|
|
|
|
|
|
def _outputs(events: list[Event]) -> list[Any]:
|
|
"""Extract non-None outputs."""
|
|
return [e.output for e in events if e.output is not None]
|
|
|
|
|
|
def _interrupt_ids(events: list[Event]) -> set[str]:
|
|
"""Extract interrupt IDs from events."""
|
|
ids: set[str] = set()
|
|
for e in events:
|
|
if e.long_running_tool_ids:
|
|
ids.update(e.long_running_tool_ids)
|
|
return ids
|
|
|
|
|
|
# =========================================================================
|
|
# Fresh execution (no resume)
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_fresh_execution():
|
|
"""Dynamic node runs normally on first invocation.
|
|
|
|
Setup: Parent (rerun_on_resume=True) calls ctx.run_node(Child).
|
|
Action: Send a text message to trigger the workflow.
|
|
Assert: Parent receives Child's output and yields the combined result.
|
|
"""
|
|
|
|
@node
|
|
async def child(*, ctx, node_input):
|
|
yield f'child got: {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(child, node_input='hello')
|
|
yield f'parent got: {result}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'go')
|
|
|
|
outputs = _outputs(events)
|
|
assert 'parent got: child got: hello' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_with_downstream_static():
|
|
"""Dynamic child's output flows to a downstream static node.
|
|
|
|
Setup: Parent calls ctx.run_node(Child). Parent is followed by
|
|
a static After node in the graph.
|
|
Action: Send a text message.
|
|
Assert: After node receives Parent's output (which includes
|
|
Child's result) as node_input.
|
|
"""
|
|
|
|
@node
|
|
async def child(*, ctx, node_input):
|
|
yield f'child: {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(child, node_input='forwarded')
|
|
yield result
|
|
|
|
@node
|
|
async def after(*, ctx, node_input):
|
|
yield f'after: {node_input}'
|
|
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[(START, parent, after)],
|
|
)
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'hello')
|
|
outputs = _outputs(events)
|
|
assert 'after: child: forwarded' in outputs
|
|
|
|
|
|
# =========================================================================
|
|
# Single-level resume (interrupt → FR → resume)
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_interrupted_resume():
|
|
"""Dynamic child interrupts, then resumes with FR on parent rerun.
|
|
|
|
Setup: Parent calls ctx.run_node(Approver). Approver interrupts
|
|
with fc-1.
|
|
Action: Send FR for fc-1 with {answer: 'yes'}.
|
|
Assert:
|
|
- Approver resumes and outputs 'approved: yes'.
|
|
- Parent yields the final combined result.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def approver(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'approved: {ctx.resume_inputs["fc-1"]["answer"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='approve', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(approver)
|
|
yield f'final: {result}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 'yes'})
|
|
outputs = _outputs(events2)
|
|
assert 'final: approved: yes' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_completed_dedup_on_resume():
|
|
"""Completed dynamic child returns cached output when parent reruns.
|
|
|
|
Setup: Parent calls ctx.run_node(Completer) then
|
|
ctx.run_node(Interrupter). Completer completes, Interrupter
|
|
interrupts with fc-1.
|
|
Action: Send FR for fc-1 to resume.
|
|
Assert:
|
|
- Parent reruns (call_count increments to 2).
|
|
- Completer is NOT re-executed (dedup returns cached output).
|
|
- Interrupter resumes with the FR response.
|
|
- Final output combines both results.
|
|
"""
|
|
|
|
@node
|
|
async def completer(*, ctx, node_input):
|
|
yield 'completed_result'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def interrupter(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'resumed: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
call_count = [0]
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
call_count[0] += 1
|
|
|
|
r1 = await ctx.run_node(completer)
|
|
r2 = await ctx.run_node(interrupter)
|
|
yield f'{r1} + {r2}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: completer completes, interrupter interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert _interrupt_ids(events1)
|
|
assert call_count[0] == 1
|
|
|
|
# Run 2: resume interrupter
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'done')
|
|
|
|
# Parent should have rerun (call_count=2).
|
|
# Completer should NOT re-execute (dedup returns cached).
|
|
# Interrupter resumes with FR.
|
|
assert call_count[0] == 2
|
|
outputs = _outputs(events2)
|
|
assert 'completed_result + resumed: done' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_sequential_interrupts():
|
|
"""Sequential dynamic children interrupt one at a time.
|
|
|
|
Setup: Parent calls ctx.run_node(a) then ctx.run_node(b).
|
|
Both children interrupt, but sequentially — a interrupts first,
|
|
parent never reaches b until a is resolved.
|
|
Action: Resume a, then b.
|
|
Assert:
|
|
- Run 1: only fc-a interrupts (parent didn't reach b).
|
|
- Run 2 (resume fc-a): a completes, parent continues to b,
|
|
b interrupts with fc-b.
|
|
- Run 3 (resume fc-b): b completes, parent yields combined
|
|
output.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def a(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-a' in ctx.resume_inputs:
|
|
yield f'a: {ctx.resume_inputs["fc-a"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-a'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-a'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def b(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
|
|
yield f'b: {ctx.resume_inputs["fc-b"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-b'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-b'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
r1 = await ctx.run_node(a, node_input=node_input)
|
|
r2 = await ctx.run_node(b, node_input=node_input)
|
|
yield f'{r1} + {r2}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: a interrupts, parent never reaches b
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
ids1 = _interrupt_ids(events1)
|
|
assert 'fc-a' in ids1
|
|
assert 'fc-b' not in ids1
|
|
|
|
# Run 2: resume a → a completes, parent reaches b → b interrupts
|
|
events2 = await _resume(runner, ss, session, 'fc-a', 'done-a')
|
|
ids2 = _interrupt_ids(events2)
|
|
assert 'fc-b' in ids2
|
|
|
|
# Run 3: resume b → b completes, parent yields combined output
|
|
events3 = await _resume(runner, ss, session, 'fc-b', 'done-b')
|
|
outputs = _outputs(events3)
|
|
assert 'a: done-a + b: done-b' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_run_id_reused_on_resume():
|
|
"""Resumed dynamic child reuses run_id from original run.
|
|
|
|
Setup: Parent calls ctx.run_node(Interrupter). Interrupter
|
|
interrupts with fc-1.
|
|
Action: Send FR for fc-1.
|
|
Assert: The resumed Interrupter's output event has the same
|
|
run_id as the original interrupt event.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def interrupter(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield 'resumed'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(interrupter)
|
|
yield result
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
interrupt_event = [e for e in events1 if e.long_running_tool_ids][0]
|
|
original_run_id = interrupt_event.node_info.run_id
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'ok')
|
|
resumed_output_events = [
|
|
e
|
|
for e in events2
|
|
if e.output == 'resumed' and 'interrupter' in (e.node_info.path or '')
|
|
]
|
|
assert len(resumed_output_events) == 1
|
|
assert resumed_output_events[0].node_info.run_id == original_run_id
|
|
|
|
|
|
# =========================================================================
|
|
# Nested workflow + dynamic node combinations
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_static_workflow_with_dynamic_interrupt():
|
|
"""Static sub-workflow's node schedules a dynamic node that interrupts.
|
|
|
|
Setup: outer_wf → inner_wf (static). Inside inner_wf, a static
|
|
parent node calls ctx.run_node(Approver). Approver interrupts.
|
|
Action: Send FR to resume.
|
|
Assert: Approver resumes, parent completes, inner_wf completes,
|
|
outer_wf completes.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def approver(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'approved: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(approver)
|
|
yield f'parent: {result}'
|
|
|
|
inner_wf = Workflow(
|
|
name='inner_wf',
|
|
edges=[(START, parent)],
|
|
)
|
|
outer_wf = Workflow(
|
|
name='outer_wf',
|
|
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: dynamic approver interrupts inside inner_wf
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'yes')
|
|
outputs = _outputs(events2)
|
|
assert 'parent: approved: yes' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_workflow_with_static_interrupt():
|
|
"""Dynamic child is a Workflow whose static node interrupts.
|
|
|
|
Setup: Parent calls ctx.run_node(inner_wf). inner_wf has a
|
|
static Interrupter node that interrupts with fc-1.
|
|
Action: Send FR to resume.
|
|
Assert: Interrupter resumes, inner_wf completes, parent
|
|
receives inner_wf's output.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def step(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'done: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
inner_wf = Workflow(
|
|
name='inner_wf',
|
|
edges=[(START, step)],
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(inner_wf)
|
|
yield f'parent: {result}'
|
|
|
|
outer_wf = Workflow(
|
|
name='outer_wf',
|
|
edges=[(START, parent)],
|
|
)
|
|
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: inner_wf's static node interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'ok')
|
|
outputs = _outputs(events2)
|
|
assert 'parent: done: ok' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_workflow_with_nested_dynamic_interrupt():
|
|
"""Dynamic Workflow's inner node schedules another dynamic node.
|
|
|
|
Setup: Parent calls ctx.run_node(inner_wf). inner_wf has a
|
|
static Orchestrator node that calls ctx.run_node(Approver).
|
|
Approver interrupts with fc-1.
|
|
Action: Send FR to resume.
|
|
Assert: Approver resumes → Orchestrator completes → inner_wf
|
|
completes → Parent receives output. Three levels of dynamic
|
|
nesting resolved correctly.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def approver(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'approved: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def orch(*, ctx, node_input):
|
|
result = await ctx.run_node(approver)
|
|
yield f'orch: {result}'
|
|
|
|
inner_wf = Workflow(
|
|
name='inner_wf',
|
|
edges=[(START, orch)],
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(inner_wf)
|
|
yield f'parent: {result}'
|
|
|
|
outer_wf = Workflow(
|
|
name='outer_wf',
|
|
edges=[(START, parent)],
|
|
)
|
|
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: deeply nested approver interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'granted')
|
|
outputs = _outputs(events2)
|
|
assert 'parent: orch: approved: granted' in outputs
|
|
|
|
|
|
# =========================================================================
|
|
# Scoping: parallel parents with same-named dynamic children
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_parents_same_named_dynamic_children():
|
|
"""Two static parents schedule dynamic children with the same name.
|
|
|
|
Setup: outer_wf fans out to parent_a and parent_b (parallel).
|
|
Both call ctx.run_node(Child(name='child')). parent_a's child
|
|
completes, parent_b's child interrupts.
|
|
Action: Send FR to resume parent_b's child.
|
|
Assert:
|
|
- parent_a's child and parent_b's child are distinct (scoped
|
|
by parent_path: wf/parent_a/child vs wf/parent_b/child).
|
|
- On resume, parent_a's child returns cached output (dedup),
|
|
parent_b's child resumes with FR.
|
|
- Both parents complete.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def child(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
|
|
yield f'resumed: {ctx.resume_inputs["fc-b"]["value"]}'
|
|
return
|
|
if node_input == 'interrupt':
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-b'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-b'},
|
|
)
|
|
else:
|
|
yield f'child: {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent_a(*, ctx, node_input):
|
|
result = await ctx.run_node(child, node_input='complete')
|
|
yield f'a: {result}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent_b(*, ctx, node_input):
|
|
result = await ctx.run_node(child, node_input='interrupt')
|
|
yield f'b: {result}'
|
|
|
|
from google.adk.workflow import JoinNode
|
|
|
|
join = JoinNode(name='join')
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[
|
|
(START, parent_a, join),
|
|
(START, parent_b, join),
|
|
],
|
|
)
|
|
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: parent_a completes, parent_b's child interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-b' in _interrupt_ids(events1)
|
|
# parent_a should have completed
|
|
a_outputs = [
|
|
e.output for e in events1 if e.output and 'a: child' in str(e.output)
|
|
]
|
|
assert len(a_outputs) == 1
|
|
|
|
# Run 2: resume parent_b's child
|
|
events2 = await _resume(runner, ss, session, 'fc-b', 'done')
|
|
outputs = _outputs(events2)
|
|
# Both parents completed, join has both results
|
|
assert any('b: resumed: done' in str(o) for o in outputs)
|
|
|
|
|
|
# =========================================================================
|
|
# use_as_output + interrupt
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_use_as_output_with_interrupt():
|
|
"""Dynamic child with use_as_output=True interrupts then resumes.
|
|
|
|
Setup: Parent calls ctx.run_node(child, use_as_output=True).
|
|
Child interrupts with fc-1.
|
|
Action: Send FR for fc-1.
|
|
Assert:
|
|
- On resume, child resumes and its output becomes the parent's
|
|
output (use_as_output delegation).
|
|
- The parent's own output event is suppressed.
|
|
"""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def child(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'child: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(child, use_as_output=True)
|
|
# Set on ctx so orchestrator reads it. _output_delegated
|
|
# suppresses the output Event (child already emitted it).
|
|
ctx.output = result
|
|
yield # keep as async generator
|
|
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[(START, parent)],
|
|
)
|
|
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: child interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', 'approved')
|
|
outputs = _outputs(events2)
|
|
assert 'child: approved' in outputs
|
|
# Parent's output should be suppressed (use_as_output)
|
|
parent_outputs = [
|
|
e.output
|
|
for e in events2
|
|
if e.node_info.path == 'wf/parent' and e.output is not None
|
|
]
|
|
assert len(parent_outputs) == 0
|
|
|
|
|
|
# =========================================================================
|
|
# None-output completion after interrupt
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.xfail(
|
|
reason='No completion marker event for None output (event design flaw)'
|
|
)
|
|
async def test_dynamic_node_none_output_not_rerun():
|
|
"""Dynamic child that completed with None output is not re-run.
|
|
|
|
Setup: Parent calls ctx.run_node(A) then ctx.run_node(B).
|
|
A interrupts. On resume, A completes with no output (None).
|
|
Parent continues to B, which also interrupts.
|
|
Action: Resume B.
|
|
Assert:
|
|
- On Run 3, A should NOT re-run (it already completed).
|
|
- A should return None (cached), B resumes.
|
|
- Currently fails because A's None completion leaves no
|
|
trace in session events — the lazy scan thinks A still
|
|
needs to re-run.
|
|
"""
|
|
run_count_a = [0]
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def a(*, ctx, node_input):
|
|
run_count_a[0] += 1
|
|
if ctx.resume_inputs and 'fc-a' in ctx.resume_inputs:
|
|
# Complete with no output.
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-a'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-a'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def b(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-b' in ctx.resume_inputs:
|
|
yield f'b: {ctx.resume_inputs["fc-b"]["value"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-b'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-b'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
await ctx.run_node(a)
|
|
result = await ctx.run_node(b)
|
|
yield f'done: {result}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: A interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-a' in _interrupt_ids(events1)
|
|
assert run_count_a[0] == 1
|
|
|
|
# Run 2: resume A → A completes (None), parent reaches B → B interrupts
|
|
events2 = await _resume(runner, ss, session, 'fc-a', 'ok')
|
|
assert 'fc-b' in _interrupt_ids(events2)
|
|
assert run_count_a[0] == 2 # A re-ran once for resume
|
|
|
|
# Run 3: resume B → A should NOT re-run (already completed)
|
|
events3 = await _resume(runner, ss, session, 'fc-b', 'done')
|
|
assert run_count_a[0] == 2 # A should NOT have run again
|
|
outputs = _outputs(events3)
|
|
assert any('done:' in str(o) for o in outputs)
|
|
|
|
|
|
# =========================================================================
|
|
# rerun_on_resume=False for dynamic node
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_rerun_on_resume_false():
|
|
"""Dynamic child with rerun_on_resume=False auto-completes on resume.
|
|
|
|
Setup: Parent calls ctx.run_node(child). Child has
|
|
rerun_on_resume=False and interrupts with fc-1.
|
|
Action: Send FR for fc-1.
|
|
Assert:
|
|
- Child does NOT re-execute _run_impl.
|
|
- Child auto-completes with the FR response as output.
|
|
- Parent receives the auto-completed output.
|
|
"""
|
|
run_count = [0]
|
|
|
|
@node(rerun_on_resume=False)
|
|
async def child(*, ctx, node_input):
|
|
run_count[0] += 1
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(child)
|
|
yield f'parent: {result}'
|
|
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[(START, parent)],
|
|
)
|
|
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: child interrupts
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
assert 'fc-1' in _interrupt_ids(events1)
|
|
assert run_count[0] == 1
|
|
|
|
# Run 2: resume
|
|
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 42})
|
|
|
|
# Child should NOT have re-executed (run_count stays 1).
|
|
assert run_count[0] == 1
|
|
# Parent receives the FR response as child's output (unwrapped).
|
|
outputs = _outputs(events2)
|
|
assert "parent: {'answer': 42}" in outputs
|
|
|
|
|
|
# =========================================================================
|
|
# Sequential run_id
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_nodes_get_run_id_one():
|
|
"""Each distinct dynamic child gets run_id '1' for its first run."""
|
|
|
|
@node
|
|
async def step_a(*, ctx, node_input):
|
|
yield f'child: {node_input}'
|
|
|
|
@node
|
|
async def step_b(*, ctx, node_input):
|
|
yield f'child: {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
a = await ctx.run_node(step_a, node_input='x')
|
|
b = await ctx.run_node(step_b, node_input='y')
|
|
yield f'{a},{b}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'go')
|
|
|
|
child_events = [
|
|
(e.node_name, e.node_info.path.split('@')[-1])
|
|
for e in events
|
|
if e.output is not None
|
|
and e.node_name
|
|
and e.node_name.startswith('step_')
|
|
]
|
|
# Each dynamic child is a distinct path, each gets run_id '1'.
|
|
assert child_events == [('step_a', '1'), ('step_b', '1')]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_keeps_run_id_on_resume():
|
|
"""A dynamic node that interrupts and resumes keeps the same run_id."""
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def approver(*, ctx, node_input):
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'approved: {ctx.resume_inputs["fc-1"]["answer"]}'
|
|
return
|
|
yield Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='approve', args={}, id='fc-1'
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={'fc-1'},
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(approver)
|
|
yield f'final: {result}'
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
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: child interrupts.
|
|
events1 = await _run(runner, ss, session, 'go')
|
|
approver_run_ids_1 = [
|
|
e.node_info.path.split('@')[-1]
|
|
for e in events1
|
|
if e.node_info.path and 'approver@' in e.node_info.path
|
|
]
|
|
|
|
# Run 2: resume with function response.
|
|
events2 = await _resume(runner, ss, session, 'fc-1', {'answer': 'yes'})
|
|
approver_run_ids_2 = [
|
|
e.node_info.path.split('@')[-1]
|
|
for e in events2
|
|
if e.node_info.path and 'approver@' in e.node_info.path
|
|
]
|
|
|
|
# Same run_id across interrupt and resume.
|
|
assert approver_run_ids_1
|
|
assert approver_run_ids_2
|
|
assert approver_run_ids_1[0] == approver_run_ids_2[0]
|
|
|
|
|
|
# =========================================================================
|
|
# Custom run_id
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_run_id_used_on_events():
|
|
"""ctx.run_node(run_id=...) sets the custom run_id on child events."""
|
|
|
|
@node
|
|
async def child(*, ctx, node_input):
|
|
yield f'done: {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
result = await ctx.run_node(
|
|
child, node_input='hello', run_id='my-custom-id'
|
|
)
|
|
yield result
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'go')
|
|
|
|
child_events = [
|
|
e
|
|
for e in events
|
|
if e.node_info and e.node_info.path and 'child' in e.node_info.path
|
|
]
|
|
assert child_events
|
|
assert all(
|
|
e.node_info.path.split('@')[-1] == 'my-custom-id' for e in child_events
|
|
)
|
|
|
|
|
|
# =========================================================================
|
|
# Failure handling in dynamic nodes
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_failure_handling():
|
|
"""Dynamic node throws exception; parent catches it and continues."""
|
|
|
|
@node
|
|
async def failing_node(*, ctx, node_input):
|
|
if node_input == 'fail':
|
|
raise ValueError('Intentional Failure')
|
|
yield f'Processed {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
results = []
|
|
try:
|
|
await ctx.run_node(failing_node, node_input='fail')
|
|
except DynamicNodeFailError as e:
|
|
results.append(f'Caught: {str(e.error)}')
|
|
|
|
res = await ctx.run_node(failing_node, node_input='work')
|
|
results.append(f'Success: {res}')
|
|
yield results
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'go')
|
|
outputs = _outputs(events)
|
|
|
|
# Find the list output from parent
|
|
list_outputs = [o for o in outputs if isinstance(o, list)]
|
|
assert len(list_outputs) == 1
|
|
results = list_outputs[0]
|
|
assert 'Caught: Intentional Failure' in results
|
|
assert 'Success: Processed work' in results
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_resume_does_not_rerun_completed_llm_agent():
|
|
"""Completed LlmAgent node is not rerun upon workflow resumption.
|
|
|
|
Setup: Workflow with LlmAgent node and an interrupting node.
|
|
Act:
|
|
- Run 1: Start workflow, LlmAgent completes, workflow interrupts.
|
|
- Run 2: Resume workflow by resolving interrupt.
|
|
Assert:
|
|
- LlmAgent does not run again in Run 2.
|
|
"""
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
|
|
from tests.unittests import testing_utils
|
|
|
|
# Given a workflow with an LlmAgent and a mock model
|
|
mock_model = testing_utils.MockModel.create(
|
|
responses=['LLM output content', 'Duplicate run output']
|
|
)
|
|
|
|
agent = LlmAgent(name='my_agent', model=mock_model)
|
|
|
|
@node
|
|
async def interrupt_node(*, ctx, node_input):
|
|
event = Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name='tool', id='interrupt_1', args={}
|
|
)
|
|
)
|
|
]
|
|
)
|
|
)
|
|
event.long_running_tool_ids = {'interrupt_1'}
|
|
yield event
|
|
yield f'Resumed with {node_input}'
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
res = await ctx.run_node(agent, node_input='go')
|
|
res2 = await ctx.run_node(interrupt_node, node_input=res)
|
|
yield res2
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# When the workflow is run until it interrupts
|
|
await _run(runner, ss, session, 'go')
|
|
|
|
session = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
|
|
agent_events = [
|
|
e for e in session.events if e.node_info.name == 'my_agent' and e.content
|
|
]
|
|
assert len(agent_events) > 0
|
|
agent_event = agent_events[-1]
|
|
|
|
# Verify that runners.py cleared the output
|
|
assert agent_event.output is None
|
|
|
|
# When the workflow is resumed by resolving the interrupt
|
|
resume_events = await _resume(
|
|
runner, ss, session, fc_id='interrupt_1', response='done'
|
|
)
|
|
|
|
# Then the LlmAgent should not run again
|
|
agent_runs_again = [
|
|
e for e in resume_events if e.node_info.name == 'my_agent' and e.content
|
|
]
|
|
assert (
|
|
len(agent_runs_again) == 0
|
|
), 'Expected LlmAgent to NOT run again, but it did!'
|
|
|
|
|
|
# =========================================================================
|
|
# Parallel execution of dynamic nodes
|
|
# =========================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dynamic_node_parallel_execution():
|
|
"""Three parallel ctx.run_node calls via asyncio.gather return ordered results."""
|
|
|
|
@node
|
|
async def echo_node(*, ctx, node_input):
|
|
yield node_input
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent_node(*, ctx, node_input):
|
|
tasks = [ctx.run_node(echo_node, node_input=f'call_{i}') for i in range(3)]
|
|
results = await asyncio.gather(*tasks)
|
|
yield results
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent_node)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
events = await _run(runner, ss, session, 'go')
|
|
outputs = _outputs(events)
|
|
|
|
# Find the list output from parent
|
|
list_outputs = [o for o in outputs if isinstance(o, list)]
|
|
assert len(list_outputs) == 1
|
|
results = list_outputs[0]
|
|
assert results == ['call_0', 'call_1', 'call_2']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_inner_llm_agent_node_input_survives_tool_round_trip():
|
|
"""Inner LlmAgent's node_input reaches every LLM request across a tool round trip.
|
|
|
|
Setup: Workflow with a single dynamic node whose body invokes an inner
|
|
LlmAgent. The agent's mock model emits a function_call on the first
|
|
turn and final text on the second turn.
|
|
Act: Run the workflow once; the dynamic node passes USER_PHRASE to the
|
|
inner agent.
|
|
Assert:
|
|
- The mock model is called exactly twice (pre- and post-tool).
|
|
- USER_PHRASE appears in the user-role text of both LLM requests.
|
|
- The second request contains both function_call and function_response
|
|
(proving a real tool round trip occurred).
|
|
"""
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.tools import FunctionTool
|
|
|
|
from tests.unittests import testing_utils
|
|
|
|
USER_PHRASE = 'lookup price for token MARKER-7Z9'
|
|
|
|
def lookup_price(token: str) -> dict:
|
|
return {'price': '$9.99'}
|
|
|
|
def _user_texts(req) -> list[str]:
|
|
texts: list[str] = []
|
|
for content in req.contents or []:
|
|
if content.role != 'user':
|
|
continue
|
|
for part in content.parts or []:
|
|
if part.text and not part.text.startswith('For context:'):
|
|
texts.append(part.text)
|
|
return texts
|
|
|
|
def _part_kinds(req) -> list[str]:
|
|
kinds: list[str] = []
|
|
for content in req.contents or []:
|
|
for part in content.parts or []:
|
|
if part.function_call:
|
|
kinds.append('function_call')
|
|
elif part.function_response:
|
|
kinds.append('function_response')
|
|
else:
|
|
kinds.append('text')
|
|
return kinds
|
|
|
|
mock_model = testing_utils.MockModel.create(
|
|
responses=[
|
|
[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
id='fc1',
|
|
name='lookup_price',
|
|
args={'token': 'MARKER-7Z9'},
|
|
)
|
|
)
|
|
],
|
|
'The price is $9.99.',
|
|
]
|
|
)
|
|
|
|
inner_agent = LlmAgent(
|
|
name='inner_agent',
|
|
model=mock_model,
|
|
tools=[FunctionTool(lookup_price)],
|
|
)
|
|
|
|
@node(rerun_on_resume=True)
|
|
async def parent(*, ctx, node_input):
|
|
yield await ctx.run_node(inner_agent, node_input=USER_PHRASE)
|
|
|
|
wf = Workflow(name='wf', edges=[(START, parent)])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=wf, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# When the workflow runs end-to-end through the tool round trip
|
|
await _run(runner, ss, session, 'go')
|
|
|
|
# Then the model is invoked exactly twice
|
|
assert len(mock_model.requests) == 2
|
|
|
|
# And USER_PHRASE survives into both LLM requests
|
|
assert any(USER_PHRASE in t for t in _user_texts(mock_model.requests[0]))
|
|
assert any(
|
|
USER_PHRASE in t for t in _user_texts(mock_model.requests[1])
|
|
), 'original node_input was dropped from the second LLM request'
|
|
|
|
# And the second request reflects a real tool round trip
|
|
second_request_kinds = _part_kinds(mock_model.requests[1])
|
|
assert 'function_call' in second_request_kinds
|
|
assert 'function_response' in second_request_kinds
|