Skip to content

Commit a8c024a

Browse files
authored
Merge branch 'main' into test/plugin-error-fallback-assertions
2 parents 12ed44f + cf91b84 commit a8c024a

31 files changed

Lines changed: 2158 additions & 296 deletions

contributing/samples/context_management/session_state_agent/agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ async def after_agent_callback(callback_context: CallbackContext):
168168
name='root_agent',
169169
description='a verification agent.',
170170
instruction=(
171-
'Log all users query with `log_query` tool. Must always remind user you'
172-
' cannot answer second query because your setup.'
171+
'Reply to the user. Must always remind user you cannot answer a second'
172+
' query because your setup.'
173173
),
174174
model='gemini-3.5-flash',
175175
before_agent_callback=before_agent_callback,

src/google/adk/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing import Any
1717
from typing import TYPE_CHECKING
1818

19+
from ._managed_agent import ManagedAgent
1920
from .base_agent import BaseAgent
2021
from .base_agent_config import BaseAgentConfig
2122
from .context import Context
@@ -42,6 +43,7 @@
4243
'Context',
4344
'LlmAgent',
4445
'LoopAgent',
46+
'ManagedAgent',
4547
'McpInstructionProvider',
4648
'ParallelAgent',
4749
'SequentialAgent',
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
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 __future__ import annotations
16+
17+
import logging
18+
from typing import Any
19+
from typing import AsyncGenerator
20+
from typing import Callable
21+
from typing import Optional
22+
from typing import TYPE_CHECKING
23+
from typing import Union
24+
25+
from google.genai import types
26+
from google.genai.interactions import CreateAgentInteractionAgentConfigParam
27+
from google.genai.interactions import CreateAgentInteractionEnvironmentParam
28+
from google.genai.interactions import ToolParam
29+
from pydantic import ConfigDict
30+
from pydantic import Field
31+
from pydantic import PrivateAttr
32+
33+
from ..events.event import Event
34+
from ..flows.llm_flows.interactions_processor import _find_previous_interaction_state
35+
from ..models.interactions_utils import _convert_content_to_step
36+
from ..models.interactions_utils import _create_interactions
37+
from ..models.interactions_utils import build_interactions_request_log
38+
from ..models.interactions_utils import convert_tools_config_to_interactions_format
39+
from ..models.llm_request import LlmRequest
40+
from ..models.llm_response import LlmResponse
41+
from ..telemetry import tracer
42+
from ..tools.base_tool import BaseTool
43+
from ..tools.tool_context import ToolContext
44+
from ..utils.context_utils import Aclosing
45+
from ..utils.env_utils import is_enterprise_mode_enabled
46+
from .base_agent import BaseAgent
47+
from .invocation_context import InvocationContext
48+
from .run_config import StreamingMode
49+
50+
if TYPE_CHECKING:
51+
from google.genai import Client
52+
53+
logger = logging.getLogger('google_adk.' + __name__)
54+
55+
# The Managed Agents / Interactions API is only served from the `global`
56+
# location; regional endpoints reject these calls (e.g. "Resource setup has
57+
# just started"). We pin it here so the agent works regardless of
58+
# GOOGLE_CLOUD_LOCATION in the caller's environment. The project is still
59+
# resolved from the environment / ADC as usual.
60+
_MANAGED_AGENT_LOCATION = 'global'
61+
62+
63+
def _resolve_client_location(api_client: Client) -> Optional[str]:
64+
"""Return the client's resolved location, or ``None`` if unavailable.
65+
66+
google-genai 2.9.0 exposes no public accessor for a ``Client``'s location, so
67+
we read the genai-internal ``client._api_client.location``. This is the single
68+
remaining private dependency; the enterprise backend flag uses the public
69+
``Client.vertexai`` property. A missing value (e.g. test doubles) yields
70+
``None`` and is treated as acceptable.
71+
"""
72+
try:
73+
# google-genai 2.9.0 has no public accessor for a Client's location.
74+
return api_client._api_client.location # pylint: disable=protected-access
75+
except AttributeError:
76+
return None
77+
78+
79+
def _validate_client_location(api_client: Client) -> None:
80+
"""Reject an injected enterprise client not targeting the `global` location.
81+
82+
The Managed Agents API is only served from `global`. This check applies only
83+
to enterprise (Vertex) clients: the Gemini Developer API has no location
84+
concept, yet google-genai still stamps `GOOGLE_CLOUD_LOCATION` onto every
85+
client's `_api_client.location`, so a Developer-API client must not be
86+
rejected for it. We do not override a caller-supplied client, but a
87+
non-`global` enterprise client cannot work, so we reject it loudly. The
88+
backend is read from the public `Client.vertexai` property; the resolved
89+
location has no public accessor in google-genai 2.9.0, so it is read from the
90+
genai-internal `client._api_client.location` via `_resolve_client_location`
91+
(an unresolvable location is treated as acceptable).
92+
"""
93+
# `Client.vertexai` is the public accessor (it returns False for the Gemini
94+
# Developer API, which has no location concept); only enterprise (Vertex)
95+
# clients have a meaningful location.
96+
if not api_client.vertexai:
97+
return
98+
location = _resolve_client_location(api_client)
99+
if isinstance(location, str) and location != _MANAGED_AGENT_LOCATION:
100+
raise ValueError(
101+
'ManagedAgent requires an enterprise client configured for the'
102+
f" '{_MANAGED_AGENT_LOCATION}' location; got location='{location}'."
103+
' The Managed Agents API is only served from'
104+
f" '{_MANAGED_AGENT_LOCATION}'."
105+
)
106+
107+
108+
class ManagedAgent(BaseAgent):
109+
"""An agent backed by the Managed Agents API (interactions.create).
110+
111+
This agent calls the Managed Agents API directly from its execution loop.
112+
In this version only server-side tools are supported: ADK built-in tools and
113+
raw ``google.genai.types.Tool`` configs (the kinds the interactions converter
114+
understands). Client-executed tools (FunctionTool/callables) and MCP are not
115+
yet supported.
116+
117+
ManagedAgent supports streaming interactions only. Interactions are always
118+
created with ``background=True`` (required by the Managed Agents workflow) and
119+
consumed over the streaming connection; non-streaming / background-polling
120+
execution is not yet supported.
121+
"""
122+
123+
model_config = ConfigDict(arbitrary_types_allowed=True, extra='forbid')
124+
125+
agent_id: str
126+
"""The Managed Agent id (e.g. 'antigravity-preview-05-2026' or 'agents/ID')."""
127+
128+
environment: Optional[CreateAgentInteractionEnvironmentParam] = None
129+
"""A sandbox environment spec (e.g. ``{'type': 'remote'}``) or an existing
130+
environment id string to reuse across turns."""
131+
132+
agent_config: Optional[CreateAgentInteractionAgentConfigParam] = None
133+
"""Runtime configuration passed to interactions.create."""
134+
135+
tools: list[Union[types.Tool, BaseTool, Callable[..., Any]]] = Field(
136+
default_factory=list
137+
)
138+
"""Server-side tools: ADK built-in tools or raw types.Tool configs."""
139+
140+
_api_client: Optional[Client] = PrivateAttr(default=None)
141+
142+
def __init__(
143+
self, *, api_client: Optional[Client] = None, **kwargs: Any
144+
) -> None:
145+
super().__init__(**kwargs)
146+
if api_client is not None:
147+
_validate_client_location(api_client)
148+
self._api_client = api_client
149+
150+
@property
151+
def api_client(self) -> Client:
152+
"""The genai client, lazily created if none was injected.
153+
154+
The backend is resolved from the environment
155+
(``GOOGLE_GENAI_USE_ENTERPRISE`` or the legacy
156+
``GOOGLE_GENAI_USE_VERTEXAI``), matching google-genai semantics; the
157+
no-env default is the Gemini Developer API. The enterprise backend is
158+
pinned to the ``global`` location (the Managed Agents API is only served
159+
from ``global``); the Developer API takes no ``location`` (it is
160+
meaningless there).
161+
"""
162+
if self._api_client is None:
163+
from google.genai import Client
164+
165+
if is_enterprise_mode_enabled():
166+
self._api_client = Client(
167+
enterprise=True, location=_MANAGED_AGENT_LOCATION
168+
)
169+
else:
170+
self._api_client = Client(enterprise=False)
171+
return self._api_client
172+
173+
async def _resolve_backend_tools(
174+
self, ctx: InvocationContext
175+
) -> list[ToolParam]:
176+
"""Resolve self.tools into interaction ToolParams (server-side only).
177+
178+
Raw types.Tool configs are passed through; ADK built-in tools are processed
179+
into native tool configs. Client-executed tools (FunctionTool/callables) and
180+
MCP tools are rejected.
181+
"""
182+
# Built-in tools are resolved in "managed agent" mode: the request carries
183+
# the internal _is_managed_agent flag (and no model), so tools that normally
184+
# gate on a Gemini model still resolve. Nothing here is sent to the API; the
185+
# real call uses ``agent=self.agent_id``.
186+
llm_request = LlmRequest(config=types.GenerateContentConfig())
187+
llm_request._is_managed_agent = True
188+
tool_context = ToolContext(ctx)
189+
190+
for tool in self.tools:
191+
if isinstance(tool, types.Tool):
192+
if tool.mcp_servers:
193+
raise NotImplementedError(
194+
'Raw mcp_servers tools are not yet supported by ManagedAgent '
195+
'(MCP is deferred).'
196+
)
197+
if tool.function_declarations:
198+
raise NotImplementedError(
199+
'client-executed tools are not yet supported by ManagedAgent: '
200+
f'{tool!r}'
201+
)
202+
if not (
203+
tool.google_search
204+
or tool.code_execution
205+
or tool.url_context
206+
or tool.computer_use
207+
):
208+
raise NotImplementedError(
209+
'Unsupported raw types.Tool for ManagedAgent; supported '
210+
'server-side fields are google_search, code_execution, '
211+
f'url_context, computer_use: {tool!r}'
212+
)
213+
llm_request.config.tools = (llm_request.config.tools or []) + [tool]
214+
continue
215+
216+
if not isinstance(tool, BaseTool):
217+
raise NotImplementedError(
218+
'client-executed tools are not yet supported by ManagedAgent: '
219+
f'{tool!r}'
220+
)
221+
222+
# Built-in (server-side) tools mutate config.tools directly; tools that
223+
# register a function declaration via append_tools grow tools_dict and are
224+
# therefore client-executed.
225+
before = len(llm_request.tools_dict)
226+
await tool.process_llm_request(
227+
tool_context=tool_context, llm_request=llm_request
228+
)
229+
if len(llm_request.tools_dict) > before:
230+
# The tool registered a function declaration -> client-executed.
231+
raise NotImplementedError(
232+
'client-executed tools are not yet supported by ManagedAgent: '
233+
f'{tool.name}'
234+
)
235+
236+
return convert_tools_config_to_interactions_format(llm_request.config)
237+
238+
def _response_to_event(
239+
self, ctx: InvocationContext, llm_response: LlmResponse
240+
) -> Event:
241+
"""Map a streamed LlmResponse to an ADK Event authored by this agent."""
242+
base_event = Event(
243+
invocation_id=ctx.invocation_id,
244+
author=self.name,
245+
branch=ctx.branch,
246+
)
247+
return Event.model_validate({
248+
**base_event.model_dump(exclude_none=True),
249+
**llm_response.model_dump(exclude_none=True),
250+
})
251+
252+
def _error_event(
253+
self,
254+
ctx: InvocationContext,
255+
*,
256+
error_code: str,
257+
error_message: str,
258+
) -> Event:
259+
"""Build a terminal error event authored by this agent.
260+
261+
Always sets ``turn_complete=True`` so the Runner receives a terminal event
262+
even when the interactions call/stream fails.
263+
"""
264+
return Event(
265+
invocation_id=ctx.invocation_id,
266+
author=self.name,
267+
branch=ctx.branch,
268+
error_code=error_code,
269+
error_message=error_message,
270+
turn_complete=True,
271+
)
272+
273+
async def _run_async_impl(
274+
self, ctx: InvocationContext
275+
) -> AsyncGenerator[Event, None]:
276+
# Lazy import: google.genai is heavy, so only `types` is imported at module
277+
# level (see CheckGoogleGenaiLazyImport / base_llm_flow.run_live).
278+
from google.genai import errors
279+
280+
# Recovery and tool resolution run outside the try so config errors (e.g.
281+
# unsupported tools) surface loudly rather than becoming an error event.
282+
prev_interaction_id, prev_environment_id = _find_previous_interaction_state(
283+
ctx.session.events,
284+
agent_name=self.name,
285+
current_branch=ctx.branch,
286+
)
287+
288+
environment = prev_environment_id or self.environment
289+
290+
input_steps = (
291+
_convert_content_to_step(ctx.user_content) if ctx.user_content else []
292+
)
293+
interaction_tools = await self._resolve_backend_tools(ctx)
294+
295+
create_kwargs: dict[str, Any] = {
296+
'agent': self.agent_id,
297+
'input': input_steps,
298+
# The Managed Agents interactions workflow (server-side tools + remote
299+
# environment) requires background execution. ManagedAgent supports
300+
# streaming only, so the background result is consumed via the open SSE
301+
# stream (stream=True at the _create_interactions call site below).
302+
'background': True,
303+
}
304+
if interaction_tools:
305+
create_kwargs['tools'] = interaction_tools
306+
if environment is not None:
307+
create_kwargs['environment'] = environment
308+
if self.agent_config is not None:
309+
create_kwargs['agent_config'] = self.agent_config
310+
if prev_interaction_id:
311+
create_kwargs['previous_interaction_id'] = prev_interaction_id
312+
313+
logger.info(
314+
'Sending request via interactions API, agent: %s, stream: %s, '
315+
'previous_interaction_id: %s, environment: %s',
316+
self.agent_id,
317+
True,
318+
prev_interaction_id,
319+
environment,
320+
)
321+
logger.debug(
322+
build_interactions_request_log(
323+
model=self.agent_id,
324+
input_steps=input_steps,
325+
system_instruction=None,
326+
tools=interaction_tools if interaction_tools else None,
327+
generation_config=None,
328+
previous_interaction_id=prev_interaction_id,
329+
stream=True,
330+
)
331+
)
332+
333+
try:
334+
with tracer.start_as_current_span('managed_agent_interaction'):
335+
async with Aclosing(
336+
_create_interactions(
337+
self.api_client, create_kwargs=create_kwargs, stream=True
338+
)
339+
) as agen:
340+
async for llm_response in agen:
341+
# ManagedAgent always streams from the server, but only surface
342+
# intermediate partials to the caller in SSE mode. In non-streaming
343+
# mode (the default) emit just the non-partial events (the
344+
# aggregated final event, plus any error event), mirroring
345+
# base_llm_flow's behavior for LlmAgent.
346+
if (
347+
ctx.run_config is not None
348+
and ctx.run_config.streaming_mode == StreamingMode.SSE
349+
) or not llm_response.partial:
350+
yield self._response_to_event(ctx, llm_response)
351+
except errors.APIError as e:
352+
# Surface the backend's real status/code (e.g. RESOURCE_EXHAUSTED) instead
353+
# of a blanket UNKNOWN_ERROR, mirroring the status=='failed' interaction
354+
# path and base_llm_flow's APIError handling.
355+
logger.exception('ManagedAgent interaction failed with backend API error')
356+
yield self._error_event(
357+
ctx,
358+
error_code=e.status or 'UNKNOWN_ERROR',
359+
error_message=e.message or str(e),
360+
)
361+
except Exception as e: # pylint: disable=broad-except
362+
# Top-level safety net: any other failure still becomes a terminal error
363+
# event so the Runner never hangs.
364+
logger.exception('ManagedAgent interaction failed')
365+
yield self._error_event(
366+
ctx, error_code='UNKNOWN_ERROR', error_message=str(e)
367+
)

0 commit comments

Comments
 (0)