Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/google/adk/agents/loop_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ class LoopAgent(BaseAgent):
When sub-agent generates an event with escalate or max_iterations are
reached, the loop agent will stop.

``escalate=True`` with no ``escalation_context`` (including ``exit_loop``)
exits every enclosing LoopAgent. ``EscalationContext(type='parent')``
(including ``exit_current_loop``) exits only this loop so an outer loop
can continue.

.. deprecated::
LoopAgent is deprecated in favor of Workflow and will be removed in a
future version. Workflow cannot yet be used as an LlmAgent sub-agent.
Expand Down Expand Up @@ -112,9 +117,9 @@ async def _run_async_impl(

async with Aclosing(sub_agent.run_async(ctx)) as agen:
async for event in agen:
yield event
if event.actions.escalate:
if event.actions.consume_workflow_escalation(self.name):
should_exit = True
yield event
if ctx.should_pause_invocation(event):
pause_invocation = True

Expand Down
2 changes: 1 addition & 1 deletion src/google/adk/agents/parallel_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def _create_branch_ctx_for_sub_agent(

def _has_escalate_action(event: Event) -> bool:
"""Returns whether the event asks the parent workflow to exit early."""
return bool(event.actions.escalate)
return event.actions.is_active_escalation()


def _cancel_tasks(tasks: list[asyncio.Task[None]]) -> None:
Expand Down
2 changes: 2 additions & 0 deletions src/google/adk/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
# limitations under the License.

from .event import Event
from .event_actions import EscalationContext
from .event_actions import EventActions
from .request_input import RequestInput

__all__ = [
'EscalationContext',
'Event',
'EventActions',
'RequestInput',
Expand Down
71 changes: 71 additions & 0 deletions src/google/adk/events/event_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import logging
from typing import Any
from typing import cast
from typing import Literal
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
Expand Down Expand Up @@ -75,6 +76,34 @@ class EventCompaction(BaseModel): # type: ignore[misc]
"""The compacted content of the events."""


class EscalationContext(BaseModel): # type: ignore[misc]
"""Controls how far an ``escalate`` action travels in nested workflows.

``escalate=True`` with no context is treated as ``type='root'`` so existing
``exit_loop`` callers keep stopping every enclosing LoopAgent.
"""

model_config = ConfigDict(
extra='forbid',
alias_generator=alias_generators.to_camel,
populate_by_name=True,
)
"""The pydantic model config."""

type: Literal['parent', 'root'] = 'root'
"""Which ancestor should stop.

- ``root``: every enclosing LoopAgent exits (same as escalate=True alone).
- ``parent``: only the nearest LoopAgent exits; outer loops continue.
"""

handled_by: list[str] = Field(default_factory=list)
"""LoopAgents that already consumed a parent-scoped escalation."""

target_agent: Optional[str] = None
"""If set, only this named agent treats the event as a loop exit."""


class EventActions(BaseModel): # type: ignore[misc]
"""Represents the actions attached to an event."""

Expand Down Expand Up @@ -125,6 +154,13 @@ def _serialize_state_delta(
escalate: Optional[bool] = None
"""The agent is escalating to a higher level agent."""

escalation_context: Optional[EscalationContext] = None
"""Optional scope for ``escalate``.

When omitted, ``escalate=True`` stops every enclosing LoopAgent (root).
``EscalationContext(type='parent')`` stops only the nearest LoopAgent.
"""

requested_auth_configs: dict[str, AuthConfig] = Field(default_factory=dict)
"""Authentication configurations requested by tool responses.

Expand Down Expand Up @@ -200,3 +236,38 @@ def _serialize_agent_state(

set_model_response: Optional[Any] = None
"""The model response structured output."""

def is_active_escalation(self) -> bool:
"""Whether this event should still stop ancestor workflows.

Parent-scoped escalations already consumed by a LoopAgent are inactive so
outer loops and ParallelAgent siblings keep running.
"""
context = self.escalation_context
if context is None:
return bool(self.escalate)
if context.target_agent is not None:
return False
if context.type == 'parent' and context.handled_by:
return False
return bool(self.escalate) or context is not None

def consume_workflow_escalation(self, agent_name: str) -> bool:
"""Returns True if ``agent_name`` should exit because of this event.

Parent-scoped escalations are consumed by the nearest LoopAgent, which
records itself on ``escalation_context.handled_by``. Root-scoped
escalations (including bare ``escalate=True``) stay active for every
ancestor.
"""
context = self.escalation_context
if context is None:
return bool(self.escalate)
if context.target_agent is not None:
return context.target_agent == agent_name
if context.type == 'root':
return True
if context.handled_by:
return False
context.handled_by.append(agent_name)
return True
2 changes: 2 additions & 0 deletions src/google/adk/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from .discovery_engine_search_tool import SearchResultMode
from .enterprise_search_tool import enterprise_web_search_tool as enterprise_web_search
from .example_tool import ExampleTool
from .exit_loop_tool import exit_current_loop
from .exit_loop_tool import exit_loop
from .function_tool import FunctionTool
from .get_user_choice_tool import get_user_choice_tool as get_user_choice
Expand Down Expand Up @@ -68,6 +69,7 @@
'enterprise_web_search_tool',
),
'ExampleTool': ('.example_tool', 'ExampleTool'),
'exit_current_loop': ('.exit_loop_tool', 'exit_current_loop'),
'exit_loop': ('.exit_loop_tool', 'exit_loop'),
'FunctionTool': ('.function_tool', 'FunctionTool'),
'get_user_choice': ('.get_user_choice_tool', 'get_user_choice_tool'),
Expand Down
13 changes: 12 additions & 1 deletion src/google/adk/tools/exit_loop_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,24 @@

from __future__ import annotations

from ..events.event_actions import EscalationContext
from .tool_context import ToolContext


def exit_loop(tool_context: ToolContext) -> None:
"""Exits the loop.
"""Exits every enclosing loop.

Call this function only when you are instructed to do so.
"""
tool_context.actions.escalate = True
tool_context.actions.skip_summarization = True


def exit_current_loop(tool_context: ToolContext) -> None:
"""Exits only the nearest enclosing LoopAgent.

Outer loops keep running. Use ``exit_loop`` to stop every enclosing loop.
"""
tool_context.actions.escalate = True
tool_context.actions.escalation_context = EscalationContext(type='parent')
tool_context.actions.skip_summarization = True
6 changes: 6 additions & 0 deletions tests/unittests/a2a/converters/test_to_adk.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ def test_peer_supplied_actions_cannot_mutate_caller_session(self):
metadata = {
_get_adk_metadata_key("actions"): {
"escalate": True,
"escalationContext": {
"type": "parent",
"handledBy": ["attacker-loop"],
"targetAgent": "attacker-agent",
},
"stateDelta": {"app:is_admin": True, "user:persona": "attacker"},
"artifactDelta": {"report.pdf": 7},
"transferToAgent": "attacker-agent",
Expand Down Expand Up @@ -427,6 +432,7 @@ def test_peer_supplied_actions_cannot_mutate_caller_session(self):
assert event.actions.route is None
assert event.actions.render_ui_widgets is None
assert event.actions.set_model_response is None
assert event.actions.escalation_context is None
# Inert fields a peer may set are still honored.
assert event.actions.escalate is True

Expand Down
Loading