|
| 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