feat: Implement checkpoint and resume logic for LoopAgent

PiperOrigin-RevId: 813096880
This commit is contained in:
Shangjie Chen
2025-09-29 22:45:57 -07:00
committed by Copybara-Service
parent d5c46e4960
commit ce9c39f5a8
4 changed files with 215 additions and 41 deletions
+64 -16
View File
@@ -16,6 +16,7 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Any from typing import Any
from typing import AsyncGenerator from typing import AsyncGenerator
from typing import ClassVar from typing import ClassVar
@@ -24,15 +25,17 @@ from typing import Optional
from typing_extensions import override from typing_extensions import override
from ..agents.invocation_context import InvocationContext
from ..events.event import Event from ..events.event import Event
from ..utils.context_utils import Aclosing from ..utils.context_utils import Aclosing
from ..utils.feature_decorator import experimental from ..utils.feature_decorator import experimental
from .base_agent import BaseAgent from .base_agent import BaseAgent
from .base_agent import BaseAgentState from .base_agent import BaseAgentState
from .base_agent_config import BaseAgentConfig from .base_agent_config import BaseAgentConfig
from .invocation_context import InvocationContext
from .loop_agent_config import LoopAgentConfig from .loop_agent_config import LoopAgentConfig
logger = logging.getLogger('google_adk.' + __name__)
@experimental @experimental
class LoopAgentState(BaseAgentState): class LoopAgentState(BaseAgentState):
@@ -69,11 +72,32 @@ class LoopAgent(BaseAgent):
if not self.sub_agents: if not self.sub_agents:
return return
times_looped = 0 agent_state = self._load_agent_state(ctx, LoopAgentState)
while not self.max_iterations or times_looped < self.max_iterations: is_resuming_at_current_agent = agent_state is not None
for sub_agent in self.sub_agents: times_looped, start_index = self._get_start_state(agent_state)
should_exit = False
pause_invocation = False should_exit = False
pause_invocation = False
while (
not self.max_iterations or times_looped < self.max_iterations
) and not (should_exit or pause_invocation):
for i in range(start_index, len(self.sub_agents)):
sub_agent = self.sub_agents[i]
if ctx.is_resumable and not is_resuming_at_current_agent:
# If we are resuming from the current event, it means the same event
# has already been logged, so we should avoid yielding it again.
agent_state = LoopAgentState(
current_sub_agent=sub_agent.name,
times_looped=times_looped,
)
yield self._create_agent_state_event(ctx, agent_state=agent_state)
# Reset the sub-agent's state in the context to ensure that each
# sub-agent starts fresh.
if not is_resuming_at_current_agent:
ctx.reset_agent_state(sub_agent.name)
is_resuming_at_current_agent = False
async with Aclosing(sub_agent.run_async(ctx)) as agen: async with Aclosing(sub_agent.run_async(ctx)) as agen:
async for event in agen: async for event in agen:
@@ -83,18 +107,42 @@ class LoopAgent(BaseAgent):
if ctx.should_pause_invocation(event): if ctx.should_pause_invocation(event):
pause_invocation = True pause_invocation = True
# Indicates that the loop agent should exist after running this if should_exit or pause_invocation:
# sub-agent. break # break inner for loop
if should_exit:
return
# Indicates that the invocation should be paused after running this
# sub-agent.
if pause_invocation:
return
# Restart from the beginning of the loop.
start_index = 0
times_looped += 1 times_looped += 1
return
# If the invocation is paused, we should not yield the end of agent event.
if pause_invocation:
return
if ctx.is_resumable:
yield self._create_agent_state_event(ctx, end_of_agent=True)
def _get_start_state(
self,
agent_state: Optional[LoopAgentState],
) -> tuple[int, int]:
"""Computes the start state of the loop agent from the agent state."""
if not agent_state:
return 0, 0
times_looped = agent_state.times_looped
start_index = 0
if agent_state.current_sub_agent:
try:
sub_agent_names = [sub_agent.name for sub_agent in self.sub_agents]
start_index = sub_agent_names.index(agent_state.current_sub_agent)
except ValueError:
# A sub-agent was removed so the agent name is not found.
# For now, we restart from the beginning.
logger.warning(
'Sub-agent %s was not found. Restarting from the beginning.',
agent_state.current_sub_agent,
)
return times_looped, start_index
@override @override
async def _run_live_impl( async def _run_live_impl(
+112 -24
View File
@@ -19,6 +19,8 @@ from typing import AsyncGenerator
from google.adk.agents.base_agent import BaseAgent from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.loop_agent import LoopAgent from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.loop_agent import LoopAgentState
from google.adk.apps import ResumabilityConfig
from google.adk.events.event import Event from google.adk.events.event import Event
from google.adk.events.event_actions import EventActions from google.adk.events.event_actions import EventActions
from google.adk.sessions.in_memory_session_service import InMemorySessionService from google.adk.sessions.in_memory_session_service import InMemorySessionService
@@ -26,6 +28,10 @@ from google.genai import types
import pytest import pytest
from typing_extensions import override from typing_extensions import override
from .. import testing_utils
END_OF_AGENT = testing_utils.END_OF_AGENT
class _TestingAgent(BaseAgent): class _TestingAgent(BaseAgent):
@@ -72,13 +78,13 @@ class _TestingAgentWithEscalateAction(BaseAgent):
author=self.name, author=self.name,
invocation_id=ctx.invocation_id, invocation_id=ctx.invocation_id,
content=types.Content( content=types.Content(
parts=[types.Part(text=f'I have done my job after escalation!!')] parts=[types.Part(text='I have done my job after escalation!!')]
), ),
) )
async def _create_parent_invocation_context( async def _create_parent_invocation_context(
test_name: str, agent: BaseAgent test_name: str, agent: BaseAgent, resumable: bool = False
) -> InvocationContext: ) -> InvocationContext:
session_service = InMemorySessionService() session_service = InMemorySessionService()
session = await session_service.create_session( session = await session_service.create_session(
@@ -89,11 +95,13 @@ async def _create_parent_invocation_context(
agent=agent, agent=agent,
session=session, session=session,
session_service=session_service, session_service=session_service,
resumability_config=ResumabilityConfig(is_resumable=resumable),
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_async(request: pytest.FixtureRequest): @pytest.mark.parametrize('resumable', [True, False])
async def test_run_async(request: pytest.FixtureRequest, resumable: bool):
agent = _TestingAgent(name=f'{request.function.__name__}_test_agent') agent = _TestingAgent(name=f'{request.function.__name__}_test_agent')
loop_agent = LoopAgent( loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent', name=f'{request.function.__name__}_test_loop_agent',
@@ -103,15 +111,60 @@ async def test_run_async(request: pytest.FixtureRequest):
], ],
) )
parent_ctx = await _create_parent_invocation_context( parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent request.function.__name__, loop_agent, resumable=resumable
) )
events = [e async for e in loop_agent.run_async(parent_ctx)] events = [e async for e in loop_agent.run_async(parent_ctx)]
assert len(events) == 2 simplified_events = testing_utils.simplify_resumable_app_events(events)
assert events[0].author == agent.name if resumable:
assert events[1].author == agent.name expected_events = [
assert events[0].content.parts[0].text == f'Hello, async {agent.name}!' (
assert events[1].content.parts[0].text == f'Hello, async {agent.name}!' loop_agent.name,
{'current_sub_agent': agent.name, 'times_looped': 0},
),
(agent.name, f'Hello, async {agent.name}!'),
(
loop_agent.name,
{'current_sub_agent': agent.name, 'times_looped': 1},
),
(agent.name, f'Hello, async {agent.name}!'),
(loop_agent.name, END_OF_AGENT),
]
else:
expected_events = [
(agent.name, f'Hello, async {agent.name}!'),
(agent.name, f'Hello, async {agent.name}!'),
]
assert simplified_events == expected_events
@pytest.mark.asyncio
async def test_resume_async(request: pytest.FixtureRequest):
agent_1 = _TestingAgent(name=f'{request.function.__name__}_test_agent_1')
agent_2 = _TestingAgent(name=f'{request.function.__name__}_test_agent_2')
loop_agent = LoopAgent(
name=f'{request.function.__name__}_test_loop_agent',
max_iterations=2,
sub_agents=[
agent_1,
agent_2,
],
)
parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent, resumable=True
)
parent_ctx.agent_states[loop_agent.name] = LoopAgentState(
current_sub_agent=agent_2.name, times_looped=1
).model_dump(mode='json')
events = [e async for e in loop_agent.run_async(parent_ctx)]
simplified_events = testing_utils.simplify_resumable_app_events(events)
expected_events = [
(agent_2.name, f'Hello, async {agent_2.name}!'),
(loop_agent.name, END_OF_AGENT),
]
assert simplified_events == expected_events
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -129,7 +182,10 @@ async def test_run_async_skip_if_no_sub_agent(request: pytest.FixtureRequest):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_async_with_escalate_action(request: pytest.FixtureRequest): @pytest.mark.parametrize('resumable', [True, False])
async def test_run_async_with_escalate_action(
request: pytest.FixtureRequest, resumable: bool
):
non_escalating_agent = _TestingAgent( non_escalating_agent = _TestingAgent(
name=f'{request.function.__name__}_test_non_escalating_agent' name=f'{request.function.__name__}_test_non_escalating_agent'
) )
@@ -144,20 +200,52 @@ async def test_run_async_with_escalate_action(request: pytest.FixtureRequest):
sub_agents=[non_escalating_agent, escalating_agent, ignored_agent], sub_agents=[non_escalating_agent, escalating_agent, ignored_agent],
) )
parent_ctx = await _create_parent_invocation_context( parent_ctx = await _create_parent_invocation_context(
request.function.__name__, loop_agent request.function.__name__, loop_agent, resumable=resumable
) )
events = [e async for e in loop_agent.run_async(parent_ctx)] events = [e async for e in loop_agent.run_async(parent_ctx)]
# Only two events are generated because the sub escalating_agent escalates. simplified_events = testing_utils.simplify_resumable_app_events(events)
assert len(events) == 3
assert events[0].author == non_escalating_agent.name if resumable:
assert events[1].author == escalating_agent.name expected_events = [
assert events[0].content.parts[0].text == ( (
f'Hello, async {non_escalating_agent.name}!' loop_agent.name,
) {
assert events[1].content.parts[0].text == ( 'current_sub_agent': non_escalating_agent.name,
f'Hello, async {escalating_agent.name}!' 'times_looped': 0,
) },
assert ( ),
events[2].content.parts[0].text == 'I have done my job after escalation!!' (
) non_escalating_agent.name,
f'Hello, async {non_escalating_agent.name}!',
),
(
loop_agent.name,
{'current_sub_agent': escalating_agent.name, 'times_looped': 0},
),
(
escalating_agent.name,
f'Hello, async {escalating_agent.name}!',
),
(
escalating_agent.name,
'I have done my job after escalation!!',
),
(loop_agent.name, END_OF_AGENT),
]
else:
expected_events = [
(
non_escalating_agent.name,
f'Hello, async {non_escalating_agent.name}!',
),
(
escalating_agent.name,
f'Hello, async {escalating_agent.name}!',
),
(
escalating_agent.name,
'I have done my job after escalation!!',
),
]
assert simplified_events == expected_events
@@ -14,6 +14,7 @@
from google.adk.agents.llm_agent import Agent from google.adk.agents.llm_agent import Agent
from google.adk.agents.loop_agent import LoopAgent from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.loop_agent import LoopAgentState
from google.adk.agents.sequential_agent import SequentialAgent from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.agents.sequential_agent import SequentialAgentState from google.adk.agents.sequential_agent import SequentialAgentState
from google.adk.apps.app import App from google.adk.apps.app import App
@@ -469,12 +470,36 @@ def test_auto_to_loop(is_resumable: bool):
('root_agent', transfer_call_part('sub_agent_1')), ('root_agent', transfer_call_part('sub_agent_1')),
('root_agent', TRANSFER_RESPONSE_PART), ('root_agent', TRANSFER_RESPONSE_PART),
# Loops. # Loops.
(
'sub_agent_1',
LoopAgentState(current_sub_agent='sub_agent_1_1').model_dump(
mode='json'
),
),
('sub_agent_1_1', 'response1'), ('sub_agent_1_1', 'response1'),
('sub_agent_1_1', END_OF_AGENT), ('sub_agent_1_1', END_OF_AGENT),
(
'sub_agent_1',
LoopAgentState(current_sub_agent='sub_agent_1_2').model_dump(
mode='json'
),
),
('sub_agent_1_2', 'response2'), ('sub_agent_1_2', 'response2'),
('sub_agent_1_2', END_OF_AGENT), ('sub_agent_1_2', END_OF_AGENT),
(
'sub_agent_1',
LoopAgentState(
current_sub_agent='sub_agent_1_1', times_looped=1
).model_dump(mode='json'),
),
('sub_agent_1_1', 'response3'), ('sub_agent_1_1', 'response3'),
('sub_agent_1_1', END_OF_AGENT), ('sub_agent_1_1', END_OF_AGENT),
(
'sub_agent_1',
LoopAgentState(
current_sub_agent='sub_agent_1_2', times_looped=1
).model_dump(mode='json'),
),
# Exits. # Exits.
('sub_agent_1_2', Part.from_function_call(name='exit_loop', args={})), ('sub_agent_1_2', Part.from_function_call(name='exit_loop', args={})),
( (
@@ -484,7 +509,7 @@ def test_auto_to_loop(is_resumable: bool):
), ),
), ),
('sub_agent_1_2', END_OF_AGENT), ('sub_agent_1_2', END_OF_AGENT),
# Later expect the loop agent to also yield agent state events. ('sub_agent_1', END_OF_AGENT),
('root_agent', END_OF_AGENT), ('root_agent', END_OF_AGENT),
] ]
# Same session, different invocation. # Same session, different invocation.
@@ -21,6 +21,7 @@ from google.adk.agents.base_agent import BaseAgent
from google.adk.agents.invocation_context import InvocationContext from google.adk.agents.invocation_context import InvocationContext
from google.adk.agents.llm_agent import LlmAgent from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.loop_agent import LoopAgent from google.adk.agents.loop_agent import LoopAgent
from google.adk.agents.loop_agent import LoopAgentState
from google.adk.agents.parallel_agent import ParallelAgent from google.adk.agents.parallel_agent import ParallelAgent
from google.adk.agents.sequential_agent import SequentialAgent from google.adk.agents.sequential_agent import SequentialAgent
from google.adk.agents.sequential_agent import SequentialAgentState from google.adk.agents.sequential_agent import SequentialAgentState
@@ -368,8 +369,20 @@ class TestPauseInvocationWithLoopAgent(BasePauseInvocationTest):
): ):
"""Tests that a LoopAgent pauses on long running function call.""" """Tests that a LoopAgent pauses on long running function call."""
assert testing_utils.simplify_resumable_app_events(runner.run("test")) == [ assert testing_utils.simplify_resumable_app_events(runner.run("test")) == [
(
"root_agent",
LoopAgentState(current_sub_agent="sub_agent_1").model_dump(
mode="json"
),
),
("sub_agent_1", "sub agent 1 response"), ("sub_agent_1", "sub agent 1 response"),
("sub_agent_1", END_OF_AGENT), ("sub_agent_1", END_OF_AGENT),
(
"root_agent",
LoopAgentState(current_sub_agent="sub_agent_2").model_dump(
mode="json"
),
),
("sub_agent_2", Part.from_function_call(name="test_tool", args={})), ("sub_agent_2", Part.from_function_call(name="test_tool", args={})),
] ]