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
1439 lines
44 KiB
Python
1439 lines
44 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 Runner(node=...).
|
|
|
|
Verifies that Runner can execute standalone BaseNode instances,
|
|
persist events to session, handle resume (HITL), and yield events correctly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Any
|
|
from typing import AsyncGenerator
|
|
|
|
from google.adk.agents.callback_context import CallbackContext
|
|
from google.adk.agents.context import Context
|
|
from google.adk.agents.invocation_context import InvocationContext
|
|
from google.adk.agents.llm_agent import LlmAgent
|
|
from google.adk.agents.run_config import RunConfig
|
|
from google.adk.apps.app import App
|
|
from google.adk.events.event import Event
|
|
from google.adk.plugins.base_plugin import BasePlugin
|
|
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._base_node import BaseNode
|
|
from google.adk.workflow._base_node import START
|
|
from google.adk.workflow._errors import DynamicNodeFailError
|
|
from google.adk.workflow._workflow import Workflow
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _EchoNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
text = node_input.parts[0].text if node_input else 'empty'
|
|
yield f'Echo: {text}'
|
|
|
|
|
|
def _user_message(text: str = 'hello') -> types.Content:
|
|
return types.Content(parts=[types.Part(text=text)], role='user')
|
|
|
|
|
|
async def _run_node(node, message='hello'):
|
|
"""Run a BaseNode via Runner(node=...) and return (events, ss, session)."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=node, 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 _make_interrupt_event(fc_name='get_input', fc_id='fc-1'):
|
|
"""Create an interrupt Event with a long-running function call."""
|
|
return Event(
|
|
content=types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_call=types.FunctionCall(
|
|
name=fc_name, args={}, id=fc_id
|
|
)
|
|
)
|
|
]
|
|
),
|
|
long_running_tool_ids={fc_id},
|
|
)
|
|
|
|
|
|
def _make_resume_message(fc_name='get_input', fc_id='fc-1', response=None):
|
|
"""Create a user message with a function response for resuming."""
|
|
return types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name=fc_name,
|
|
id=fc_id,
|
|
response=response or {},
|
|
)
|
|
)
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
|
|
async def _run_two_turns(node, msg1_text, resume_msg):
|
|
"""Run a node for two turns: initial message then resume."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=node, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
msg1 = types.Content(parts=[types.Part(text=msg1_text)], role='user')
|
|
events1 = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg1
|
|
):
|
|
events1.append(event)
|
|
|
|
events2 = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=resume_msg
|
|
):
|
|
events2.append(event)
|
|
|
|
return events1, events2, runner, ss, session
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Basic execution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_simple_node_output():
|
|
"""Runner yields output from a simple BaseNode."""
|
|
events, _, _ = await _run_node(_EchoNode(name='echo'), message='hi')
|
|
|
|
output_events = [e for e in events if e.output is not None]
|
|
assert [e.output for e in output_events] == ['Echo: hi']
|
|
assert output_events[0].node_info.path == 'echo@1'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_intermediate_events_yielded():
|
|
"""Runner yields intermediate events (e.g. state), not just output."""
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield Event(state={'step': 'processing'})
|
|
yield 'final_result'
|
|
|
|
events, _, _ = await _run_node(_Node(name='steps'))
|
|
|
|
state_events = [e for e in events if e.actions.state_delta]
|
|
assert len(state_events) >= 1
|
|
assert [e.output for e in events if e.output is not None] == ['final_result']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_event_author_defaults_to_node_name():
|
|
"""Events are attributed to the node's name by default."""
|
|
events, _, _ = await _run_node(_EchoNode(name='my_node'), message='hi')
|
|
|
|
output_events = [e for e in events if e.output is not None]
|
|
assert output_events[0].author == 'my_node'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_error_propagates():
|
|
"""A node that raises propagates the exception to the caller."""
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
raise RuntimeError('node failure')
|
|
yield # pylint: disable=unreachable
|
|
|
|
with pytest.raises(RuntimeError, match='node failure'):
|
|
await _run_node(_Node(name='error'))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_yielding_none_produces_no_output():
|
|
"""A node that yields None produces no output event."""
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield None
|
|
|
|
events, _, _ = await _run_node(_Node(name='nil'))
|
|
|
|
assert [e.output for e in events if e.output is not None] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workflow_node_output():
|
|
"""Runner drives a Workflow and yields its terminal output."""
|
|
|
|
def upper(node_input: str) -> str:
|
|
return node_input.upper()
|
|
|
|
wf = Workflow(name='wf', edges=[(START, upper)])
|
|
events, _, _ = await _run_node(wf, message='hi')
|
|
|
|
output_events = [e for e in events if e.output == 'HI']
|
|
assert len(output_events) == 1
|
|
assert output_events[0].node_info.path == 'wf@1/upper@1'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Session persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_events_persisted_to_session():
|
|
"""Non-partial events are persisted to the session."""
|
|
_, ss, session = await _run_node(_EchoNode(name='echo'), message='hi')
|
|
|
|
updated = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
session_outputs = [e.output for e in updated.events if e.output is not None]
|
|
assert 'Echo: hi' in session_outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_invocations_accumulate_events():
|
|
"""Each invocation appends events; session accumulates across runs."""
|
|
node = _EchoNode(name='echo')
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=node, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
for msg_text in ['first', 'second', 'third']:
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
parts=[types.Part(text=msg_text)], role='user'
|
|
),
|
|
):
|
|
pass
|
|
|
|
updated = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
outputs = [e.output for e in updated.events if e.output is not None]
|
|
assert outputs == ['Echo: first', 'Echo: second', 'Echo: third']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_config_custom_metadata_stamps_user_event():
|
|
"""The node path stamps the user event with run-level custom_metadata."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test', node=_EchoNode(name='echo'), session_service=ss
|
|
)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=_user_message('hi'),
|
|
run_config=RunConfig(custom_metadata={'turn_id': 't-1'}),
|
|
):
|
|
pass
|
|
|
|
updated = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
user_event = next(e for e in updated.events if e.author == 'user')
|
|
assert user_event.custom_metadata == {'turn_id': 't-1'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# yield_user_message
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_yield_user_message_true():
|
|
"""When yield_user_message=True, user event is yielded before node events."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test', node=_EchoNode(name='echo'), session_service=ss
|
|
)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
msg = types.Content(parts=[types.Part(text='hi')], role='user')
|
|
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=msg,
|
|
yield_user_message=True,
|
|
):
|
|
events.append(event)
|
|
|
|
user_events = [e for e in events if e.author == 'user']
|
|
assert len(user_events) == 1
|
|
assert user_events[0].content.parts[0].text == 'hi'
|
|
assert events[0].author == 'user'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_yield_user_message_false_by_default():
|
|
"""By default, user event is not yielded to the caller."""
|
|
events, _, _ = await _run_node(_EchoNode(name='echo'), message='hi')
|
|
|
|
user_events = [e for e in events if e.author == 'user']
|
|
assert user_events == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_runner_applies_state_delta_before_base_node_runs():
|
|
"""A BaseNode sees run_async state_delta as session state."""
|
|
|
|
class _StateReaderNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'state:{ctx.state["test_state"]}'
|
|
|
|
session_service = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test',
|
|
node=_StateReaderNode(name='reader'),
|
|
session_service=session_service,
|
|
)
|
|
session = await session_service.create_session(app_name='test', user_id='u')
|
|
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=_user_message(),
|
|
state_delta={'test_state': 'must_change'},
|
|
):
|
|
events.append(event)
|
|
|
|
updated = await session_service.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
user_events = [event for event in updated.events if event.author == 'user']
|
|
|
|
assert [event.output for event in events if event.output is not None] == [
|
|
'state:must_change'
|
|
]
|
|
assert updated.state['test_state'] == 'must_change'
|
|
assert user_events[0].actions.state_delta == {'test_state': 'must_change'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_runner_yields_user_event_with_state_delta():
|
|
"""yield_user_message=True yields the user event with state_delta."""
|
|
|
|
class _NoopNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield 'done'
|
|
|
|
session_service = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test',
|
|
node=_NoopNode(name='noop'),
|
|
session_service=session_service,
|
|
)
|
|
session = await session_service.create_session(app_name='test', user_id='u')
|
|
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=_user_message(),
|
|
state_delta={'test_state': 'must_change'},
|
|
yield_user_message=True,
|
|
):
|
|
events.append(event)
|
|
|
|
assert events[0].author == 'user'
|
|
assert events[0].actions.state_delta == {'test_state': 'must_change'}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_runner_applies_state_delta_before_llm_agent_runs():
|
|
"""An LlmAgent callback sees run_async state_delta before model execution."""
|
|
|
|
captured_state_value = None
|
|
|
|
def _before_agent_callback(
|
|
callback_context: CallbackContext,
|
|
) -> types.Content:
|
|
nonlocal captured_state_value
|
|
captured_state_value = callback_context.state['test_state']
|
|
return types.Content(
|
|
role='model',
|
|
parts=[types.Part(text=f'state:{captured_state_value}')],
|
|
)
|
|
|
|
session_service = InMemorySessionService()
|
|
agent = LlmAgent(
|
|
name='state_agent',
|
|
before_agent_callback=_before_agent_callback,
|
|
)
|
|
runner = Runner(app_name='test', agent=agent, session_service=session_service)
|
|
session = await session_service.create_session(app_name='test', user_id='u')
|
|
|
|
events: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=_user_message(),
|
|
state_delta={'test_state': 'must_change'},
|
|
):
|
|
events.append(event)
|
|
|
|
updated = await session_service.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
user_events = [event for event in updated.events if event.author == 'user']
|
|
response_texts = [
|
|
part.text
|
|
for event in events
|
|
if event.content
|
|
for part in event.content.parts
|
|
if part.text
|
|
]
|
|
|
|
assert captured_state_value == 'must_change'
|
|
assert 'state:must_change' in response_texts
|
|
assert user_events[0].actions.state_delta == {'test_state': 'must_change'}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resume (HITL)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_node_resume():
|
|
"""A standalone node resumes with resume_inputs from function response."""
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'result: {ctx.resume_inputs["fc-1"]["value"]}'
|
|
return
|
|
yield _make_interrupt_event()
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_Node(name='standalone'),
|
|
'go',
|
|
_make_resume_message(response={'value': 42}),
|
|
)
|
|
|
|
assert any(e.long_running_tool_ids for e in events1)
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'result: 42' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_preserves_original_user_content():
|
|
"""On resume, Runner passes the original text as node_input, not the FR."""
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
text = (
|
|
node_input.parts[0].text
|
|
if node_input and hasattr(node_input, 'parts')
|
|
else str(node_input)
|
|
)
|
|
yield f'original:{text}'
|
|
return
|
|
yield _make_interrupt_event(fc_name='tool')
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_Node(name='node'),
|
|
'my original input',
|
|
_make_resume_message(fc_name='tool', response={'v': 1}),
|
|
)
|
|
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'original:my original input' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_populates_invocation_user_content():
|
|
"""On resume via a function response, ic.user_content is the original turn."""
|
|
seen: list[Any] = []
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
user_content = ctx.get_invocation_context().user_content
|
|
seen.append(user_content.parts[0].text if user_content else None)
|
|
yield 'resumed'
|
|
return
|
|
yield _make_interrupt_event(fc_name='tool')
|
|
|
|
await _run_two_turns(
|
|
_Node(name='node'),
|
|
'remember me',
|
|
_make_resume_message(fc_name='tool', response={'v': 1}),
|
|
)
|
|
|
|
assert seen == ['remember me']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_by_invocation_id_populates_user_content():
|
|
"""Resuming by invocation_id alone recovers the original user_content."""
|
|
seen: list[Any] = []
|
|
|
|
class _Node(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
user_content = ctx.get_invocation_context().user_content
|
|
seen.append(user_content.parts[0].text if user_content else None)
|
|
yield _make_interrupt_event(fc_name='tool')
|
|
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=_Node(name='node'), session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
parts=[types.Part(text='original text')], role='user'
|
|
),
|
|
):
|
|
pass
|
|
|
|
updated = await ss.get_session(
|
|
app_name='test', user_id='u', session_id=session.id
|
|
)
|
|
invocation_id = updated.events[0].invocation_id
|
|
|
|
async for _ in runner.run_async(
|
|
user_id='u', session_id=session.id, invocation_id=invocation_id
|
|
):
|
|
pass
|
|
|
|
assert seen == ['original text', 'original text']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plain_text_does_not_trigger_resume():
|
|
"""Sending plain text (no FR) starts fresh, does not enter resume path."""
|
|
node = _EchoNode(name='echo')
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=node, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# Run 1
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(parts=[types.Part(text='first')], role='user'),
|
|
):
|
|
pass
|
|
|
|
# Run 2: plain text — should start fresh
|
|
events2: list[Event] = []
|
|
async for event in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(parts=[types.Part(text='second')], role='user'),
|
|
):
|
|
events2.append(event)
|
|
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert outputs == ['Echo: second']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resume validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_raises_on_unmatched_fr():
|
|
"""Runner raises when function response has no matching FC in session."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test', node=_EchoNode(name='echo'), session_service=ss
|
|
)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
msg = _make_resume_message(fc_name='unknown', fc_id='no-such-fc')
|
|
|
|
with pytest.raises(ValueError, match='Function call not found'):
|
|
async for _ in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_raises_on_multi_invocation_fr():
|
|
"""Runner raises when FRs resolve to different invocations."""
|
|
call_count = [0]
|
|
|
|
class _InterruptNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
call_count[0] += 1
|
|
fc_id = f'fc-{call_count[0]}'
|
|
yield _make_interrupt_event(fc_name='tool', fc_id=fc_id)
|
|
|
|
wf = Workflow(
|
|
name='wf',
|
|
edges=[(START, _InterruptNode(name='ask'))],
|
|
)
|
|
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 with fc-1
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(parts=[types.Part(text='go')], role='user'),
|
|
):
|
|
pass
|
|
|
|
# Run 2: interrupts with fc-2 (different invocation)
|
|
async for _ in runner.run_async(
|
|
user_id='u',
|
|
session_id=session.id,
|
|
new_message=types.Content(
|
|
parts=[types.Part(text='go again')], role='user'
|
|
),
|
|
):
|
|
pass
|
|
|
|
# Run 3: send FRs for both fc-1 and fc-2 (different invocations)
|
|
msg3 = types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='tool', id='fc-1', response={'r': 1}
|
|
)
|
|
),
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='tool', id='fc-2', response={'r': 2}
|
|
)
|
|
),
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
with pytest.raises(ValueError, match='resolve to multiple invocations'):
|
|
async for _ in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg3
|
|
):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mixed_fr_and_text_raises():
|
|
"""Message with both function responses and text is rejected."""
|
|
ss = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test', node=_EchoNode(name='echo'), session_service=ss
|
|
)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
msg = types.Content(
|
|
parts=[
|
|
types.Part(text='some text'),
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='tool', id='fc-1', response={'v': 1}
|
|
)
|
|
),
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
with pytest.raises(ValueError, match='cannot contain both'):
|
|
async for _ in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Default scheduler & ctx.create_task cleanup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_works_without_workflow():
|
|
"""ctx.run_node() works in a standalone BaseNode (default scheduler)."""
|
|
|
|
class _ChildNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'child got: {node_input}'
|
|
|
|
class _ParentNode(BaseNode):
|
|
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
result = await ctx.run_node(_ChildNode(name='child'), 'hello')
|
|
yield f'parent got: {result}'
|
|
|
|
events, _, _ = await _run_node(_ParentNode(name='parent'), message='go')
|
|
|
|
outputs = [e.output for e in events if e.output is not None]
|
|
assert 'parent got: child got: hello' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_propagates_error_without_workflow():
|
|
"""A standalone node propagates errors raised by its dynamically executed child nodes."""
|
|
|
|
class _ChildNode(BaseNode):
|
|
"""A helper child node that fails."""
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
raise ValueError('child failure')
|
|
yield
|
|
|
|
class _ParentNode(BaseNode):
|
|
"""A helper parent node that calls the child."""
|
|
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
try:
|
|
await ctx.run_node(_ChildNode(name='child'), 'hello')
|
|
except DynamicNodeFailError as e:
|
|
yield f'parent caught: {type(e).__name__}'
|
|
raise
|
|
yield 'parent got success'
|
|
|
|
# Arrange
|
|
ss = InMemorySessionService()
|
|
runner = Runner(
|
|
app_name='test',
|
|
node=_ParentNode(name='parent'),
|
|
session_service=ss,
|
|
)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
msg = types.Content(parts=[types.Part(text='go')], role='user')
|
|
events = []
|
|
|
|
# Act
|
|
# The runner unwraps DynamicNodeFailError to the original ValueError
|
|
with pytest.raises(ValueError, match='child failure'):
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg
|
|
):
|
|
events.append(event)
|
|
|
|
# Assert
|
|
# Verify that parent node caught DynamicNodeFailError before propagating
|
|
parent_caught_events = [
|
|
e.output
|
|
for e in events
|
|
if isinstance(e.output, str) and 'parent caught' in e.output
|
|
]
|
|
assert parent_caught_events == ['parent caught: DynamicNodeFailError']
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_use_as_output_attributes_child_output_to_parent():
|
|
"""Child output with use_as_output=True is attributed to the parent node."""
|
|
|
|
class _ChildNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield 'child result'
|
|
|
|
class _ParentNode(BaseNode):
|
|
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
result = await ctx.run_node(
|
|
_ChildNode(name='child'), 'hello', use_as_output=True
|
|
)
|
|
yield f'parent got: {result}'
|
|
|
|
events, _, _ = await _run_node(_ParentNode(name='parent'), message='go')
|
|
|
|
# The child's output event should list the parent's path in output_for.
|
|
# With use_as_output=True, the parent's own yield is suppressed —
|
|
# only the child's output (attributed to the parent) is emitted.
|
|
child_output = next(e for e in events if e.output == 'child result')
|
|
assert 'parent@1/child@1' in child_output.node_info.path
|
|
assert any(
|
|
'parent' in p and 'child' not in p
|
|
for p in child_output.node_info.output_for
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_wait_for_output():
|
|
"""Dynamic node with wait_for_output=True re-runs on resume if no output.
|
|
|
|
Setup: ParentNode calls MockNode (wait_for_output=True).
|
|
MockNode yields no output on first call, output on second call.
|
|
Act:
|
|
- Turn 1: Run parent. Child yields no output and waits. Parent interrupts.
|
|
- Turn 2: Resume parent. Child runs again and produces output.
|
|
Assert:
|
|
- Parent receives child's output in Turn 2.
|
|
"""
|
|
|
|
# Arrange
|
|
calls = [0]
|
|
|
|
class _MockNode(BaseNode):
|
|
wait_for_output: bool = True
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
calls[0] += 1
|
|
if calls[0] == 2:
|
|
yield 'success'
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
res = await ctx.run_node(_MockNode(name='child'))
|
|
if res == 'success':
|
|
yield 'completed'
|
|
return
|
|
yield _make_interrupt_event(fc_name='ask', fc_id='fc-1')
|
|
|
|
# Act
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_ParentNode(name='parent'),
|
|
'go',
|
|
_make_resume_message(fc_name='ask', fc_id='fc-1', response={}),
|
|
)
|
|
|
|
# Assert
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'completed' in outputs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DefaultNodeScheduler — dynamic child resume via ctx.run_node()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_child_resume_via_default_scheduler():
|
|
"""Completed children are cached on resume; interrupted child re-runs.
|
|
|
|
Setup: ParentNode calls ChildA and ChildB in sequence.
|
|
ChildA completes on first run. ChildB yields an interrupt on first run.
|
|
Act:
|
|
- Turn 1: Run parent. ChildA completes, ChildB interrupts.
|
|
- Turn 2: Resume with response for ChildB's interrupt.
|
|
Assert:
|
|
- Turn 1: ChildB's interrupt is propagated.
|
|
- Turn 2: Parent completes with combined output using cached ChildA result.
|
|
"""
|
|
|
|
class _ChildA(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield 'child_a_output'
|
|
|
|
class _ChildB(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'resumed: {ctx.resume_inputs["fc-1"]["answer"]}'
|
|
return
|
|
yield _make_interrupt_event(fc_name='ask', fc_id='fc-1')
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
a = await ctx.run_node(_ChildA(name='a'), 'input_a')
|
|
b = await ctx.run_node(_ChildB(name='b'), 'input_b')
|
|
yield f'{a} + {b}'
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_ParentNode(name='parent'),
|
|
'go',
|
|
_make_resume_message(
|
|
fc_name='ask', fc_id='fc-1', response={'answer': 42}
|
|
),
|
|
)
|
|
|
|
assert any(e.long_running_tool_ids for e in events1)
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'child_a_output + resumed: 42' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_default_scheduler_caches_by_call_count():
|
|
"""Only interrupted children re-run; completed children are skipped.
|
|
|
|
Setup: Parent calls ChildA, ChildB, and ChildC in sequence.
|
|
A and B complete on first run. C yields an interrupt on first run.
|
|
Act:
|
|
- Turn 1: Run parent. A & B complete, C interrupts.
|
|
- Turn 2: Resume with response for C's interrupt.
|
|
Assert:
|
|
- Turn 1: C's interrupt is propagated.
|
|
- Turn 2: Parent completes. Call counts verify A and B were not re-run on resume.
|
|
"""
|
|
|
|
call_counts = {'a': 0, 'b': 0, 'c': 0}
|
|
|
|
class _CountingChild(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
call_counts[self.name] += 1
|
|
if self.name == 'c':
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield 'c_resumed'
|
|
return
|
|
yield _make_interrupt_event(fc_name='tool', fc_id='fc-1')
|
|
return
|
|
yield f'{self.name}_out'
|
|
|
|
class _Parent(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
a = await ctx.run_node(_CountingChild(name='a'), 'x')
|
|
b = await ctx.run_node(_CountingChild(name='b'), 'y')
|
|
c = await ctx.run_node(_CountingChild(name='c'), 'z')
|
|
yield f'{a},{b},{c}'
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_Parent(name='p'),
|
|
'go',
|
|
_make_resume_message(fc_name='tool', fc_id='fc-1', response={}),
|
|
)
|
|
|
|
assert any(e.long_running_tool_ids for e in events1)
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'a_out,b_out,c_resumed' in outputs
|
|
assert call_counts == {'a': 1, 'b': 1, 'c': 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_use_as_output_with_resume():
|
|
"""use_as_output child resumes correctly; child output is attributed to parent."""
|
|
|
|
class _Child(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'approved: {ctx.resume_inputs["fc-1"]["ok"]}'
|
|
return
|
|
yield _make_interrupt_event(fc_name='approve', fc_id='fc-1')
|
|
|
|
class _Parent(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
result = await ctx.run_node(
|
|
_Child(name='child'), 'data', use_as_output=True
|
|
)
|
|
yield f'parent saw: {result}'
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_Parent(name='parent'),
|
|
'go',
|
|
_make_resume_message(
|
|
fc_name='approve', fc_id='fc-1', response={'ok': True}
|
|
),
|
|
)
|
|
|
|
assert any(e.long_running_tool_ids for e in events1)
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert any('approved: True' in o for o in outputs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_nested_ctx_run_node_resume():
|
|
"""Nested ctx.run_node(): outer → middle → inner; inner interrupts and resumes."""
|
|
|
|
call_counts = {'outer': 0, 'middle': 0, 'inner': 0}
|
|
|
|
class _Inner(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
call_counts['inner'] += 1
|
|
if ctx.resume_inputs and 'fc-1' in ctx.resume_inputs:
|
|
yield f'inner_resumed:{ctx.resume_inputs["fc-1"]["v"]}'
|
|
return
|
|
yield _make_interrupt_event(fc_name='ask', fc_id='fc-1')
|
|
|
|
class _Middle(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
call_counts['middle'] += 1
|
|
inner_out = await ctx.run_node(_Inner(name='inner'), 'go')
|
|
yield f'middle({inner_out})'
|
|
|
|
class _Outer(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
call_counts['outer'] += 1
|
|
mid_out = await ctx.run_node(_Middle(name='middle'), 'start')
|
|
yield f'outer({mid_out})'
|
|
|
|
events1, events2, _, _, _ = await _run_two_turns(
|
|
_Outer(name='top'),
|
|
'go',
|
|
_make_resume_message(fc_name='ask', fc_id='fc-1', response={'v': 99}),
|
|
)
|
|
|
|
# Turn 1: inner interrupts, propagated through middle and outer.
|
|
assert any(e.long_running_tool_ids for e in events1)
|
|
|
|
# Turn 2: inner resumes, middle and outer produce final output.
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert 'outer(middle(inner_resumed:99))' in outputs
|
|
|
|
# Outer and middle re-run on resume; inner runs twice (interrupt + resume).
|
|
assert call_counts == {'outer': 2, 'middle': 2, 'inner': 2}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_use_as_output_nested_delegation():
|
|
"""Nested use_as_output delegates all the way up with run_ids."""
|
|
|
|
class _Inner(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield 'inner_val'
|
|
|
|
class _Middle(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
await ctx.run_node(_Inner(name='inner'), 'go', use_as_output=True)
|
|
if False:
|
|
yield
|
|
|
|
class _Outer(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
await ctx.run_node(_Middle(name='middle'), 'start', use_as_output=True)
|
|
if False:
|
|
yield
|
|
|
|
# When
|
|
events, _, _ = await _run_node(_Outer(name='outer'), message='go')
|
|
|
|
# Then
|
|
inner_output = next(e for e in events if e.output == 'inner_val')
|
|
output_for = inner_output.node_info.output_for
|
|
paths = output_for
|
|
|
|
assert len(output_for) == 3
|
|
assert any('middle' in p for p in paths)
|
|
assert any('outer' in p for p in paths)
|
|
assert any('inner' in p for p in paths)
|
|
for p in output_for:
|
|
assert '@' in p
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_auto_increments_run_id():
|
|
"""ctx.run_node() auto-increments run_id for the same node name."""
|
|
|
|
class _ChildNode(BaseNode):
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'run:{ctx.run_id}'
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
r1 = await ctx.run_node(_ChildNode(name='child'))
|
|
r2 = await ctx.run_node(_ChildNode(name='child'))
|
|
yield f'{r1},{r2}'
|
|
|
|
events, _, _ = await _run_node(_ParentNode(name='parent'), message='go')
|
|
|
|
outputs = [e.output for e in events if e.output is not None]
|
|
assert 'run:1,run:2' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_parallel_interrupts():
|
|
"""Parallel ctx.run_node() calls that both interrupt and then resume.
|
|
|
|
Setup: ParentNode calls two instances of InterruptChild in parallel.
|
|
Both children yield interrupts on the first turn with unique IDs.
|
|
Act:
|
|
- Turn 1: Run parent. Both children interrupt.
|
|
- Turn 2: Resume with responses for both children in a single message.
|
|
Assert:
|
|
- Turn 1: Two unique interrupts are yielded.
|
|
- Turn 2: Both children resume, find their inputs, and complete. Parent
|
|
produces combined output.
|
|
"""
|
|
|
|
class _InterruptChild(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
target_id = f'fc-{ctx.run_id}'
|
|
if ctx.resume_inputs and target_id in ctx.resume_inputs:
|
|
yield f"resumed:{ctx.resume_inputs[target_id]['v']}"
|
|
return
|
|
yield _make_interrupt_event(fc_name='ask', fc_id=target_id)
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
t1 = ctx.run_node(_InterruptChild(name='child'))
|
|
t2 = ctx.run_node(_InterruptChild(name='child'))
|
|
r1, r2 = await asyncio.gather(t1, t2)
|
|
yield f'{r1},{r2}'
|
|
|
|
events1, ss, session = await _run_node(
|
|
_ParentNode(name='parent'), message='go'
|
|
)
|
|
|
|
interrupts = [e for e in events1 if e.long_running_tool_ids]
|
|
assert len(interrupts) == 2
|
|
|
|
fc_ids = []
|
|
for e in interrupts:
|
|
fc_ids.extend(e.long_running_tool_ids)
|
|
assert len(set(fc_ids)) == 2 # Should be unique
|
|
|
|
resume_msg = types.Content(
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='ask', id=fc_ids[0], response={'v': 10}
|
|
)
|
|
),
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name='ask', id=fc_ids[1], response={'v': 20}
|
|
)
|
|
),
|
|
],
|
|
role='user',
|
|
)
|
|
|
|
events2 = []
|
|
runner = Runner(
|
|
app_name='test', node=_ParentNode(name='parent'), session_service=ss
|
|
)
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=resume_msg
|
|
):
|
|
events2.append(event)
|
|
|
|
outputs = [e.output for e in events2 if e.output is not None]
|
|
assert (
|
|
'resumed:10,resumed:20' in outputs or 'resumed:20,resumed:10' in outputs
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_parallel_deterministic_ids():
|
|
"""Parallel ctx.run_node() calls within the same process receive deterministic IDs.
|
|
|
|
Setup: ParentNode calls two instances of ChildNode in parallel.
|
|
Act: Run parent.
|
|
Assert: Outputs confirm both children received distinct, auto-incremented run_ids (1 and 2).
|
|
"""
|
|
|
|
class _ChildNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'run:{ctx.run_id}'
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
t1 = ctx.run_node(_ChildNode(name='child'))
|
|
t2 = ctx.run_node(_ChildNode(name='child'))
|
|
r1, r2 = await asyncio.gather(t1, t2)
|
|
yield f'{r1},{r2}'
|
|
|
|
events, _, _ = await _run_node(_ParentNode(name='parent'), message='go')
|
|
outputs = [e.output for e in events if e.output is not None]
|
|
assert 'run:1,run:2' in outputs or 'run:2,run:1' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_custom_numeric_id_raises_value_error():
|
|
"""Passing a completely numeric explicit run_id is immediately rejected.
|
|
|
|
Setup: ParentNode calls ChildNode with a custom numeric run_id="5".
|
|
Act: Run parent.
|
|
Assert: ValueError is raised with a message about collision prevention.
|
|
"""
|
|
|
|
class _ChildNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'run:{ctx.run_id}'
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
await ctx.run_node(_ChildNode(name='child'), run_id='5')
|
|
yield 'should not reach'
|
|
|
|
with pytest.raises(ValueError, match='must contain non-numeric characters'):
|
|
await _run_node(_ParentNode(name='parent'), message='go')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_custom_non_numeric_id_accepted():
|
|
"""Passing an explicit run_id containing non-numeric characters is safely accepted.
|
|
|
|
Setup: ParentNode calls ChildNode with a custom run_id="user-123".
|
|
Act: Run parent.
|
|
Assert: Output reflects the custom run_id without errors.
|
|
"""
|
|
|
|
class _ChildNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
yield f'run:{ctx.run_id}'
|
|
|
|
class _ParentNode(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
r1 = await ctx.run_node(_ChildNode(name='child'), run_id='user-123')
|
|
yield r1
|
|
|
|
events, _, _ = await _run_node(_ParentNode(name='parent'), message='go')
|
|
outputs = [e.output for e in events if e.output is not None]
|
|
assert 'run:user-123' in outputs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_node_isolation_across_invocations():
|
|
"""Verify that a new invocation ignores events from a previous invocation for dynamic nodes."""
|
|
|
|
call_counts = {'child': 0}
|
|
|
|
@node(name='child')
|
|
async def counting_child(ctx: Context, node_input: Any):
|
|
call_counts['child'] += 1
|
|
yield f"child_out_{call_counts['child']}"
|
|
|
|
class _Parent(BaseNode):
|
|
rerun_on_resume: bool = True
|
|
|
|
async def _run_impl(
|
|
self, *, ctx: Context, node_input: Any
|
|
) -> AsyncGenerator[Any, None]:
|
|
res = await ctx.run_node(counting_child, 'x')
|
|
yield res
|
|
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app_name='test', node=_Parent(name='p'), session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
# Invocation 1
|
|
msg1 = types.Content(parts=[types.Part(text='go 1')], role='user')
|
|
events1 = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg1
|
|
):
|
|
events1.append(event)
|
|
|
|
assert call_counts['child'] == 1
|
|
outputs1 = [e.output for e in events1 if e.output is not None]
|
|
assert 'child_out_1' in outputs1
|
|
|
|
# Invocation 2 (New invocation in SAME session)
|
|
msg2 = types.Content(parts=[types.Part(text='go 2')], role='user')
|
|
events2 = []
|
|
async for event in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=msg2
|
|
):
|
|
events2.append(event)
|
|
|
|
# If isolation works, CounterNode should run AGAIN!
|
|
assert call_counts['child'] == 2
|
|
outputs2 = [e.output for e in events2 if e.output is not None]
|
|
assert 'child_out_2' in outputs2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin lifecycle on the node path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _AfterRunCountingPlugin(BasePlugin):
|
|
"""Counts how many times after_run_callback is dispatched."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(name='after_run_counter')
|
|
self.after_run_calls = 0
|
|
|
|
async def after_run_callback(
|
|
self, *, invocation_context: InvocationContext
|
|
) -> None:
|
|
self.after_run_calls += 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_after_run_callback_dispatched_on_workflow_root():
|
|
"""Runner dispatches plugin after_run_callback on a Workflow(BaseNode) root."""
|
|
|
|
def terminal(node_input: str) -> str:
|
|
return node_input.upper()
|
|
|
|
plugin = _AfterRunCountingPlugin()
|
|
workflow = Workflow(name='wf', edges=[(START, terminal)])
|
|
app = App(name='test', root_agent=workflow, plugins=[plugin])
|
|
ss = InMemorySessionService()
|
|
runner = Runner(app=app, session_service=ss)
|
|
session = await ss.create_session(app_name='test', user_id='u')
|
|
|
|
async for _ in runner.run_async(
|
|
user_id='u', session_id=session.id, new_message=_user_message('hi')
|
|
):
|
|
pass
|
|
|
|
assert plugin.after_run_calls == 1
|