Skip to content

Commit 29ceadc

Browse files
author
David Montero
committed
feat(agents): add termination conditions for multi-agent workflows
Add a new google.adk.termination module with pluggable termination conditions that stop a LoopAgent or the Runner mid-run when a configured criterion is met. New termination conditions: - MaxIterationsTermination: stops after N events - TextMentionTermination: stops when a keyword appears in event content - TimeoutTermination: stops after a wall-clock duration - TokenUsageTermination: stops when token budgets are exceeded - FunctionCallTermination: stops when a specific function is called - ExternalTermination: stops on external signal (asyncio-based) - Composite OrTerminationCondition and AndTerminationCondition (also exposed via | and & operators) Integration points: - LoopAgent.termination_condition field: checked after each sub-agent event; emits a synthetic escalation event with ermination_reason set - Runner.run_async(..., termination_condition=...) parameter: checked after every event yielded by the root agent The ermination_reason field is added to EventActions to carry the human-readable reason on synthetic termination events. All conditions implement eset(), which is called automatically at the start of each run so the same instance can be reused across invocations. Cross-repo: aligned with adk-js implementation in google/adk-js#193. Closes #5211
1 parent 23bd95b commit 29ceadc

15 files changed

+1478
-0
lines changed

src/google/adk/agents/loop_agent.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@
2626
from typing_extensions import override
2727

2828
from ..events.event import Event
29+
from ..events.event_actions import EventActions
2930
from ..features import experimental
3031
from ..features import FeatureName
32+
from ..termination.termination_condition import TerminationCondition
3133
from ..utils.context_utils import Aclosing
3234
from .base_agent import BaseAgent
3335
from .base_agent import BaseAgentState
@@ -66,13 +68,28 @@ class LoopAgent(BaseAgent):
6668
escalates.
6769
"""
6870

71+
termination_condition: Optional[TerminationCondition] = None
72+
"""An optional termination condition that controls when the loop stops.
73+
74+
The condition is evaluated after each event emitted by a sub-agent. When
75+
it fires, the loop yields a final event with
76+
``actions.termination_reason`` set and ``actions.escalate`` set to
77+
``True``, then stops.
78+
79+
The condition is automatically reset at the start of each ``_run_async_impl``
80+
call, so the same instance can be reused across multiple runs.
81+
"""
82+
6983
@override
7084
async def _run_async_impl(
7185
self, ctx: InvocationContext
7286
) -> AsyncGenerator[Event, None]:
7387
if not self.sub_agents:
7488
return
7589

90+
if self.termination_condition:
91+
await self.termination_condition.reset()
92+
7693
agent_state = self._load_agent_state(ctx, LoopAgentState)
7794
is_resuming_at_current_agent = agent_state is not None
7895
times_looped, start_index = self._get_start_state(agent_state)
@@ -102,6 +119,21 @@ async def _run_async_impl(
102119
yield event
103120
if event.actions.escalate:
104121
should_exit = True
122+
123+
if self.termination_condition and not should_exit:
124+
result = await self.termination_condition.check([event])
125+
if result:
126+
termination_event = Event(
127+
invocation_id=ctx.invocation_id,
128+
author=self.name,
129+
actions=EventActions(
130+
escalate=True,
131+
termination_reason=result.reason,
132+
),
133+
)
134+
yield termination_event
135+
return
136+
105137
if ctx.should_pause_invocation(event):
106138
pause_invocation = True
107139

src/google/adk/events/event_actions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,7 @@ class EventActions(BaseModel):
112112

113113
render_ui_widgets: Optional[list[UiWidget]] = None
114114
"""List of UI widgets to be rendered by the UI."""
115+
116+
termination_reason: Optional[str] = None
117+
"""The human-readable reason the conversation was terminated by a
118+
TerminationCondition. Only set on synthetic termination events."""

src/google/adk/runners.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
from .sessions.in_memory_session_service import InMemorySessionService
6262
from .sessions.session import Session
6363
from .telemetry.tracing import tracer
64+
from .termination.termination_condition import TerminationCondition
6465
from .tools.base_toolset import BaseToolset
6566
from .utils._debug_output import print_event
6667
from .utils.context_utils import Aclosing
@@ -509,6 +510,7 @@ async def run_async(
509510
new_message: Optional[types.Content] = None,
510511
state_delta: Optional[dict[str, Any]] = None,
511512
run_config: Optional[RunConfig] = None,
513+
termination_condition: Optional[TerminationCondition] = None,
512514
) -> AsyncGenerator[Event, None]:
513515
"""Main entry method to run the agent in this runner.
514516
@@ -526,6 +528,8 @@ async def run_async(
526528
new_message: A new message to append to the session.
527529
state_delta: Optional state changes to apply to the session.
528530
run_config: The run config for the agent.
531+
termination_condition: An optional condition that stops the run when
532+
triggered. Reset automatically before the run begins.
529533
530534
Yields:
531535
The events generated by the agent.
@@ -602,9 +606,24 @@ async def _run_with_trace(
602606
return
603607

604608
async def execute(ctx: InvocationContext) -> AsyncGenerator[Event]:
609+
if termination_condition:
610+
await termination_condition.reset()
605611
async with Aclosing(ctx.agent.run_async(ctx)) as agen:
606612
async for event in agen:
607613
yield event
614+
if termination_condition:
615+
termination_result = await termination_condition.check([event])
616+
if termination_result:
617+
termination_event = Event(
618+
invocation_id=ctx.invocation_id,
619+
author=ctx.agent.name,
620+
actions=EventActions(
621+
escalate=True,
622+
termination_reason=termination_result.reason,
623+
),
624+
)
625+
yield termination_event
626+
return
608627

609628
async with Aclosing(
610629
self._exec_with_plugin(
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from .external_termination import ExternalTermination
16+
from .function_call_termination import FunctionCallTermination
17+
from .max_iterations_termination import MaxIterationsTermination
18+
from .termination_condition import AndTerminationCondition
19+
from .termination_condition import OrTerminationCondition
20+
from .termination_condition import TerminationCondition
21+
from .termination_condition import TerminationResult
22+
from .text_mention_termination import TextMentionTermination
23+
from .timeout_termination import TimeoutTermination
24+
from .token_usage_termination import TokenUsageTermination
25+
26+
__all__ = [
27+
'AndTerminationCondition',
28+
'ExternalTermination',
29+
'FunctionCallTermination',
30+
'MaxIterationsTermination',
31+
'OrTerminationCondition',
32+
'TerminationCondition',
33+
'TerminationResult',
34+
'TextMentionTermination',
35+
'TimeoutTermination',
36+
'TokenUsageTermination',
37+
]
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""A termination condition controlled programmatically via ``set()``."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Optional
20+
from typing import Sequence
21+
22+
from ..events.event import Event
23+
from .termination_condition import TerminationCondition
24+
from .termination_condition import TerminationResult
25+
26+
27+
class ExternalTermination(TerminationCondition):
28+
"""A termination condition that is controlled externally by calling ``set()``.
29+
30+
Useful for integrating external stop signals such as a UI "Stop" button
31+
or application-level logic.
32+
33+
Example::
34+
35+
stop_button = ExternalTermination()
36+
37+
agent = LoopAgent(
38+
name='my_loop',
39+
sub_agents=[...],
40+
termination_condition=stop_button,
41+
)
42+
43+
# Elsewhere (e.g. from a UI event handler):
44+
stop_button.set()
45+
"""
46+
47+
def __init__(self) -> None:
48+
self._terminated = False
49+
50+
@property
51+
def terminated(self) -> bool:
52+
return self._terminated
53+
54+
def set(self) -> None:
55+
"""Signals that the conversation should terminate at the next check."""
56+
self._terminated = True
57+
58+
async def check(
59+
self, events: Sequence[Event]
60+
) -> Optional[TerminationResult]:
61+
if self._terminated:
62+
return TerminationResult(reason='Externally terminated')
63+
return None
64+
65+
async def reset(self) -> None:
66+
self._terminated = False
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Terminates when a specific function (tool) has been executed."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Optional
20+
from typing import Sequence
21+
22+
from ..events.event import Event
23+
from .termination_condition import TerminationCondition
24+
from .termination_condition import TerminationResult
25+
26+
27+
class FunctionCallTermination(TerminationCondition):
28+
"""Terminates when a tool with a specific name has been executed.
29+
30+
The condition checks ``FunctionResponse`` parts in events.
31+
32+
Example::
33+
34+
# Stop when the "approve" tool is called
35+
condition = FunctionCallTermination('approve')
36+
"""
37+
38+
def __init__(self, function_name: str) -> None:
39+
self._function_name = function_name
40+
self._terminated = False
41+
42+
@property
43+
def terminated(self) -> bool:
44+
return self._terminated
45+
46+
async def check(
47+
self, events: Sequence[Event]
48+
) -> Optional[TerminationResult]:
49+
if self._terminated:
50+
return None
51+
52+
for event in events:
53+
for response in event.get_function_responses():
54+
if response.name == self._function_name:
55+
self._terminated = True
56+
return TerminationResult(
57+
reason=f"Function '{self._function_name}' was executed"
58+
)
59+
return None
60+
61+
async def reset(self) -> None:
62+
self._terminated = False
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Terminates after a maximum number of events have been processed."""
16+
17+
from __future__ import annotations
18+
19+
from typing import Optional
20+
from typing import Sequence
21+
22+
from ..events.event import Event
23+
from .termination_condition import TerminationCondition
24+
from .termination_condition import TerminationResult
25+
26+
27+
class MaxIterationsTermination(TerminationCondition):
28+
"""Terminates the conversation after a maximum number of events.
29+
30+
Example::
31+
32+
# Stop after 10 events
33+
condition = MaxIterationsTermination(10)
34+
"""
35+
36+
def __init__(self, max_iterations: int) -> None:
37+
if max_iterations <= 0:
38+
raise ValueError('max_iterations must be a positive integer.')
39+
self._max_iterations = max_iterations
40+
self._count = 0
41+
self._terminated = False
42+
43+
@property
44+
def terminated(self) -> bool:
45+
return self._terminated
46+
47+
async def check(
48+
self, events: Sequence[Event]
49+
) -> Optional[TerminationResult]:
50+
if self._terminated:
51+
return None
52+
self._count += len(events)
53+
54+
if self._count >= self._max_iterations:
55+
self._terminated = True
56+
return TerminationResult(
57+
reason=(
58+
f'Maximum iterations of {self._max_iterations} reached,'
59+
f' current count: {self._count}'
60+
)
61+
)
62+
return None
63+
64+
async def reset(self) -> None:
65+
self._terminated = False
66+
self._count = 0

0 commit comments

Comments
 (0)