Files
google--adk-python/tests/unittests/agents/test_managed_agent.py
T
wehub-resource-sync ec2b666284
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:13 +08:00

1049 lines
31 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import subprocess
import sys
from typing import Any
from unittest.mock import MagicMock
from google.adk.agents._managed_agent import ManagedAgent
from google.adk.agents.run_config import RunConfig
from google.adk.agents.run_config import StreamingMode
from google.adk.events.event import Event
from google.adk.models.llm_response import LlmResponse
from google.adk.tools import google_search
from google.adk.tools._remote_mcp_server import RemoteMcpServer
from google.adk.tools.function_tool import FunctionTool
from google.genai import types
from google.genai import types as genai_types
from pydantic import ValidationError
import pytest
class _FakeClient:
vertexai = False
class _FakeApiClient:
def __init__(self, location, vertexai=None):
self.location = location
self.vertexai = vertexai
class _FakeClientWithLocation:
"""Mimics a genai Client: public vertexai, private _api_client.location."""
def __init__(self, location, vertexai=None):
self.vertexai = bool(vertexai)
self._api_client = _FakeApiClient(location, vertexai)
def test_construction_sets_fields_and_injectable_client():
client = _FakeClient()
agent = ManagedAgent(
name='mgr',
agent_id='antigravity-preview-05-2026',
environment={'type': 'remote'},
api_client=client,
)
assert agent.name == 'mgr'
assert agent.agent_id == 'antigravity-preview-05-2026'
assert agent.environment == {'type': 'remote'}
assert agent.tools == []
# Injected client is returned without constructing a real genai client.
assert agent.api_client is client
def test_injected_client_is_not_tagged():
client = _FakeClient()
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
# ADK must not attach http_options/headers onto a caller-supplied client.
assert agent.api_client is client
assert not hasattr(client, 'http_options')
def test_lazy_client_enterprise_uses_global_location(monkeypatch):
from google.adk.utils._google_client_headers import get_tracking_headers
import google.genai as genai
monkeypatch.setenv('GOOGLE_GENAI_USE_ENTERPRISE', '1')
captured = {}
def _fake_client(**kwargs):
captured.update(kwargs)
return _FakeClient()
monkeypatch.setattr(genai, 'Client', _fake_client)
agent = ManagedAgent(name='mgr', agent_id='agents/a')
_ = agent.api_client # triggers lazy construction
assert captured['enterprise'] is True
assert captured['location'] == 'global'
assert captured['http_options'].headers == get_tracking_headers()
def test_lazy_client_dev_api_omits_location(monkeypatch):
from google.adk.utils._google_client_headers import get_tracking_headers
import google.genai as genai
monkeypatch.setenv('GOOGLE_GENAI_USE_ENTERPRISE', '0')
captured = {}
def _fake_client(**kwargs):
captured.update(kwargs)
return _FakeClient()
monkeypatch.setattr(genai, 'Client', _fake_client)
agent = ManagedAgent(name='mgr', agent_id='agents/a')
_ = agent.api_client # triggers lazy construction
assert captured['enterprise'] is False
assert 'location' not in captured
assert captured['http_options'].headers == get_tracking_headers()
def test_injected_non_global_enterprise_client_raises():
client = _FakeClientWithLocation('us-central1', vertexai=True)
with pytest.raises(ValueError, match='global'):
ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
def test_injected_global_enterprise_client_is_accepted():
client = _FakeClientWithLocation('global', vertexai=True)
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
assert agent.api_client is client
def test_injected_regional_dev_api_client_is_accepted():
# Developer API clients have no meaningful location; genai still stamps
# GOOGLE_CLOUD_LOCATION onto _api_client.location, so a regional value must
# NOT be rejected for a non-enterprise client.
client = _FakeClientWithLocation('us-central1', vertexai=False)
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
assert agent.api_client is client
def test_injected_client_without_location_is_accepted():
client = _FakeClient()
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
assert agent.api_client is client
def test_validate_uses_public_vertexai_property():
# The enterprise decision must come from the PUBLIC `Client.vertexai`
# property, not the private `_api_client.vertexai`. This client reports
# enterprise via the public property while its private `_api_client.vertexai`
# is unset; a non-global location must therefore be rejected.
class _PublicVertexClient:
vertexai = True # public property says enterprise
def __init__(self):
self._api_client = _FakeApiClient('us-central1', vertexai=None)
with pytest.raises(ValueError, match='global'):
ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_PublicVertexClient()
)
def _ctx() -> Any:
# _resolve_backend_tools only needs an InvocationContext to build a
# ToolContext; a MagicMock satisfies the built-in tools used here.
return MagicMock()
def test_resolve_builtin_google_search():
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
tools=[google_search],
api_client=_FakeClient(),
)
tool_params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert {'type': 'google_search'} in tool_params
def test_resolve_raw_tool_passthrough():
raw = types.Tool(url_context=types.UrlContext())
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[raw], api_client=_FakeClient()
)
tool_params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert {'type': 'url_context'} in tool_params
def test_resolve_rejects_raw_mcp_server():
raw = types.Tool(
mcp_servers=[
types.McpServer(
name='db',
streamable_http_transport=types.StreamableHttpTransport(
url='https://x'
),
)
]
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[raw], api_client=_FakeClient()
)
with pytest.raises(NotImplementedError, match='mcp'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_rejects_function_tool():
def my_fn(x: str) -> str:
return x
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
tools=[FunctionTool(func=my_fn)],
api_client=_FakeClient(),
)
with pytest.raises(NotImplementedError, match='client-executed'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_rejects_plain_callable():
def my_fn(x: str) -> str:
return x
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[my_fn], api_client=_FakeClient()
)
with pytest.raises(NotImplementedError, match='client-executed'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_rejects_raw_tool_with_function_declarations():
raw = types.Tool(
function_declarations=[
types.FunctionDeclaration(name='my_fn', description='d')
]
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[raw], api_client=_FakeClient()
)
with pytest.raises(NotImplementedError, match='client-executed'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_rejects_unsupported_raw_tool():
raw = types.Tool(google_search_retrieval=types.GoogleSearchRetrieval())
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[raw], api_client=_FakeClient()
)
with pytest.raises(NotImplementedError, match='Unsupported raw'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_combines_multiple_tools():
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
tools=[google_search, types.Tool(url_context=types.UrlContext())],
api_client=_FakeClient(),
)
tool_params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert {'type': 'google_search'} in tool_params
assert {'type': 'url_context'} in tool_params
def test_resolve_empty_tools_returns_empty():
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
assert asyncio.run(agent._resolve_backend_tools(_ctx())) == []
def test_resolve_passes_managed_agent_flag_and_no_model():
from google.adk.tools.base_tool import BaseTool
class _RecordingTool(BaseTool):
def __init__(self):
super().__init__(name='rec', description='rec')
self.captured = {}
async def process_llm_request(self, *, tool_context, llm_request):
self.captured['is_managed_agent'] = llm_request._is_managed_agent
self.captured['model'] = llm_request.model
rec = _RecordingTool()
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[rec], api_client=_FakeClient()
)
asyncio.run(agent._resolve_backend_tools(_ctx()))
assert rec.captured['is_managed_agent'] is True
assert rec.captured['model'] is None
class _RecordingInteractions:
def __init__(self, responses_per_call):
self._responses_per_call = list(responses_per_call)
self.calls = []
async def create(self, **kwargs):
self.calls.append(kwargs)
responses = self._responses_per_call.pop(0)
class _Iter:
def __init__(self, items):
self._it = iter(items)
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self._it)
except StopIteration as exc:
raise StopAsyncIteration from exc
return _Iter(responses)
class _RecordingClient:
vertexai = False
def __init__(self, responses_per_call):
self.aio = MagicMock()
self.aio.interactions = _RecordingInteractions(responses_per_call)
def _user_ctx(text, *, session_events=None, invocation_id='inv1', branch=None):
ctx = MagicMock()
ctx.user_content = genai_types.Content(
role='user', parts=[genai_types.Part(text=text)]
)
ctx.invocation_id = invocation_id
ctx.branch = branch
ctx.session.events = session_events or []
ctx.run_config.http_options = None # no per-request headers by default
return ctx
def _make_llm_response(text, interaction_id, environment_id):
return LlmResponse(
content=genai_types.Content(
role='model', parts=[genai_types.Part(text=text)]
),
interaction_id=interaction_id,
environment_id=environment_id,
)
def _partial_text_response(text):
return LlmResponse(
content=genai_types.Content(
role='model', parts=[genai_types.Part(text=text)]
),
partial=True,
)
def _final_text_response(text):
return LlmResponse(
content=genai_types.Content(
role='model', parts=[genai_types.Part(text=text)]
),
partial=False,
turn_complete=True,
)
def test_run_async_yields_events_with_ids(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _make_llm_response('Hello!', 'int_1', 'env_1')
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
async def _collect():
out = []
async for e in agent._run_async_impl(_user_ctx('hi')):
out.append(e)
return out
events = asyncio.run(_collect())
assert len(events) == 1
assert events[0].author == 'mgr'
assert events[0].content.parts[0].text == 'Hello!'
assert events[0].interaction_id == 'int_1'
assert events[0].environment_id == 'env_1'
def test_run_async_recovers_previous_state():
prior = Event(
author='mgr', interaction_id='int_prev', environment_id='env_prev'
)
ctx = _user_ctx('again', session_events=[prior])
client = _RecordingClient([[]])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
asyncio.run(_drain(agent._run_async_impl(ctx)))
create_kwargs = client.aio.interactions.calls[0]
assert create_kwargs['previous_interaction_id'] == 'int_prev'
assert create_kwargs['environment'] == 'env_prev'
assert create_kwargs['agent'] == 'agents/a'
assert create_kwargs['stream'] is True
assert create_kwargs['background'] is True
def test_run_async_forwards_tools_and_agent_config():
from google.adk.tools import google_search
client = _RecordingClient([[]])
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
tools=[google_search],
agent_config={'type': 'dynamic'},
api_client=client,
)
asyncio.run(_drain(agent._run_async_impl(_user_ctx('hi'))))
create_kwargs = client.aio.interactions.calls[0]
assert {'type': 'google_search'} in create_kwargs['tools']
assert create_kwargs['agent_config'] == {'type': 'dynamic'}
def test_run_async_sets_background_true():
client = _RecordingClient([[]])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
asyncio.run(_drain(agent._run_async_impl(_user_ctx('hi'))))
create_kwargs = client.aio.interactions.calls[0]
assert create_kwargs['background'] is True
def test_run_async_merges_run_config_headers_into_extra_headers():
client = _RecordingClient([[]])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
ctx = _user_ctx('hi')
ctx.run_config.http_options = genai_types.HttpOptions(
headers={'x-custom': 'v'}
)
asyncio.run(_drain(agent._run_async_impl(ctx)))
extra_headers = client.aio.interactions.calls[0]['extra_headers']
assert extra_headers['x-custom'] == 'v'
assert 'google-adk/' in extra_headers['x-goog-api-client']
assert 'google-adk/' in extra_headers['user-agent']
def test_run_async_sends_tracking_headers_without_run_config_headers():
from google.adk.utils._google_client_headers import get_tracking_headers
client = _RecordingClient([[]])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
ctx = _user_ctx('hi') # http_options defaults to None
asyncio.run(_drain(agent._run_async_impl(ctx)))
assert (
client.aio.interactions.calls[0]['extra_headers']
== get_tracking_headers()
)
def test_run_async_sends_tracking_headers_when_http_options_has_no_headers():
from google.adk.utils._google_client_headers import get_tracking_headers
client = _RecordingClient([[]])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
ctx = _user_ctx('hi')
ctx.run_config.http_options = genai_types.HttpOptions() # headers is None
asyncio.run(_drain(agent._run_async_impl(ctx)))
assert (
client.aio.interactions.calls[0]['extra_headers']
== get_tracking_headers()
)
def test_run_async_yields_multiple_events_in_order(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _make_llm_response('one', 'int_1', 'env_1')
yield _make_llm_response('two', 'int_1', 'env_1')
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
events = asyncio.run(_drain_collect(agent._run_async_impl(_user_ctx('hi'))))
assert [e.content.parts[0].text for e in events] == ['one', 'two']
def test_run_async_error_yields_error_event(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _boom(api_client, *, create_kwargs, stream, extra_headers=None):
raise RuntimeError('api exploded')
yield # pragma: no cover
monkeypatch.setattr(mod, '_create_interactions', _boom)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
events = asyncio.run(_drain_collect(agent._run_async_impl(_user_ctx('hi'))))
assert len(events) == 1
assert events[0].author == 'mgr'
assert 'api exploded' in (events[0].error_message or '')
assert events[0].error_code == 'UNKNOWN_ERROR'
assert events[0].turn_complete is True
def test_run_async_api_error_surfaces_backend_status_and_message(monkeypatch):
from google.adk.agents import _managed_agent as mod
from google.genai import errors
async def _boom(api_client, *, create_kwargs, stream, extra_headers=None):
raise errors.ClientError(
429,
{
'error': {
'code': 429,
'status': 'RESOURCE_EXHAUSTED',
'message': 'Quota exceeded.',
}
},
)
yield # pragma: no cover
monkeypatch.setattr(mod, '_create_interactions', _boom)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
events = asyncio.run(_drain_collect(agent._run_async_impl(_user_ctx('hi'))))
assert len(events) == 1
assert events[0].author == 'mgr'
assert events[0].error_code == 'RESOURCE_EXHAUSTED'
assert events[0].error_message == 'Quota exceeded.'
assert events[0].turn_complete is True
def test_run_async_uses_self_environment_when_no_prior():
client = _RecordingClient([[]])
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
environment={'type': 'remote'},
api_client=client,
)
asyncio.run(_drain(agent._run_async_impl(_user_ctx('hi'))))
create_kwargs = client.aio.interactions.calls[0]
assert create_kwargs['environment'] == {'type': 'remote'}
assert 'previous_interaction_id' not in create_kwargs
def test_run_async_raises_on_unsupported_tool():
def my_fn(x: str) -> str:
return x
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[my_fn], api_client=_FakeClient()
)
with pytest.raises(NotImplementedError, match='client-executed'):
asyncio.run(_drain(agent._run_async_impl(_user_ctx('hi'))))
def test_managed_agent_exported_from_package():
import google.adk.agents as agents_pkg
assert agents_pkg.ManagedAgent is ManagedAgent
assert 'ManagedAgent' in agents_pkg.__all__
def test_run_async_non_streaming_suppresses_partials(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _partial_text_response('thinking')
yield _partial_text_response('searching')
yield _final_text_response('Final answer.')
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
ctx = _user_ctx('hi')
ctx.run_config.streaming_mode = StreamingMode.NONE
events = asyncio.run(_drain_collect(agent._run_async_impl(ctx)))
assert len(events) == 1
assert events[0].content.parts[0].text == 'Final answer.'
assert not events[0].partial
def test_run_async_sse_yields_all_partials(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _partial_text_response('thinking')
yield _partial_text_response('searching')
yield _final_text_response('Final answer.')
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
ctx = _user_ctx('hi')
ctx.run_config.streaming_mode = StreamingMode.SSE
events = asyncio.run(_drain_collect(agent._run_async_impl(ctx)))
assert [e.content.parts[0].text for e in events] == [
'thinking',
'searching',
'Final answer.',
]
def test_run_async_non_streaming_surfaces_error_event(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _partial_text_response('thinking')
yield LlmResponse(
error_code='UNKNOWN_ERROR', error_message='boom', turn_complete=True
)
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
ctx = _user_ctx('hi')
ctx.run_config.streaming_mode = StreamingMode.NONE
events = asyncio.run(_drain_collect(agent._run_async_impl(ctx)))
assert len(events) == 1
assert events[0].error_code == 'UNKNOWN_ERROR'
assert events[0].error_message == 'boom'
def test_run_async_default_run_config_suppresses_partials(monkeypatch):
from google.adk.agents import _managed_agent as mod
async def _fake_stream(
api_client, *, create_kwargs, stream, extra_headers=None
):
yield _partial_text_response('thinking')
yield _final_text_response('Final answer.')
monkeypatch.setattr(mod, '_create_interactions', _fake_stream)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', api_client=_FakeClient()
)
ctx = _user_ctx('hi')
ctx.run_config = RunConfig() # default streaming_mode == StreamingMode.NONE
events = asyncio.run(_drain_collect(agent._run_async_impl(ctx)))
assert len(events) == 1
assert events[0].content.parts[0].text == 'Final answer.'
def test_run_async_non_streaming_final_event_carries_grounding_and_usage():
from google.genai.interactions import InteractionCompletedEvent
from google.genai.interactions import InteractionSseEventInteraction
from google.genai.interactions import StepDelta
from google.genai.interactions import Usage
sse_events = [
StepDelta(
event_type='step.delta',
index=0,
delta={
'type': 'google_search_call',
'arguments': {'queries': ['q1']},
},
),
StepDelta(
event_type='step.delta',
index=0,
delta={'type': 'text', 'text': 'Final answer.'},
),
InteractionCompletedEvent(
event_type='interaction.completed',
interaction=InteractionSseEventInteraction(
id='int_e2e',
status='completed',
steps=[],
usage=Usage(total_input_tokens=12, total_output_tokens=7),
),
),
]
client = _RecordingClient([sse_events])
agent = ManagedAgent(name='mgr', agent_id='agents/a', api_client=client)
ctx = _user_ctx('hi')
ctx.run_config.streaming_mode = StreamingMode.NONE
events = asyncio.run(_drain_collect(agent._run_async_impl(ctx)))
assert len(events) == 1
final = events[0]
assert not final.partial
assert final.content.parts[-1].text == 'Final answer.'
assert final.grounding_metadata.web_search_queries == ['q1']
assert final.usage_metadata.prompt_token_count == 12
assert final.usage_metadata.candidates_token_count == 7
def test_mode_defaults_to_none():
agent = ManagedAgent(name='m', agent_id='a')
assert agent.mode is None
def test_mode_single_turn_is_accepted():
agent = ManagedAgent(name='m', agent_id='a', mode='single_turn')
assert agent.mode == 'single_turn'
def test_mode_chat_is_rejected():
from pydantic import ValidationError
with pytest.raises(ValidationError):
ManagedAgent(name='m', agent_id='a', mode='chat')
async def test_run_impl_bridges_node_input_to_user_content():
from google.adk.agents.context import Context
from google.adk.agents.invocation_context import InvocationContext
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.sessions.session import Session
from google.adk.utils.content_utils import to_user_content
captured = {}
# ManagedAgent is a Pydantic model, so `run_async` cannot be reassigned on an
# instance; subclassing to override it is the pydantic-safe equivalent of
# stubbing it. The override captures the `parent_context.user_content` that
# `_run_impl` threads in from `node_input`.
class _CapturingManagedAgent(ManagedAgent):
async def run_async(self, parent_context):
captured['user_content'] = parent_context.user_content
return
yield # make this an async generator
agent = _CapturingManagedAgent(name='m', agent_id='a', mode='single_turn')
# Build a minimal node Context (mirrors
# tests/unittests/workflow/test_agent_node.py).
session = Session(app_name='test', user_id='user', id='session')
ic = InvocationContext(
invocation_id='inv',
session=session,
session_service=InMemorySessionService(),
)
ctx = Context(ic, node_path='wf')
events = [
e async for e in agent._run_impl(ctx=ctx, node_input='compute primes')
]
assert events == []
assert captured['user_content'] == to_user_content('compute primes')
async def _drain(agen):
async for _ in agen:
pass
async def _drain_collect(agen):
out = []
async for e in agen:
out.append(e)
return out
def test_remote_mcp_server_constructs_and_is_exported():
import google.adk.tools as tools_pkg
server = RemoteMcpServer(
url='https://mcp.example.com/mcp',
name='example',
headers={'X-Static': 'v'},
allowed_tools=['a', 'b'],
header_provider=lambda ctx: {'Authorization': 'Bearer t'},
)
assert server.url == 'https://mcp.example.com/mcp'
assert server.name == 'example'
assert server.headers == {'X-Static': 'v'}
assert server.allowed_tools == ['a', 'b']
assert server.header_provider is not None
assert tools_pkg.RemoteMcpServer is RemoteMcpServer
assert 'RemoteMcpServer' in tools_pkg.__all__
def test_tools_import_first_has_no_cycle():
"""Importing google.adk.tools before google.adk.agents must not cycle.
Guards the leaf-module invariant for RemoteMcpServer: a future edit that adds
a runtime (non-TYPE_CHECKING) google.adk.agents import to
google.adk.tools._remote_mcp_server would reintroduce a circular import and
fail this test.
"""
subprocess.run(
[
sys.executable,
'-c',
(
'import google.adk.tools as t; t.RemoteMcpServer; '
'from google.adk.agents._managed_agent import ManagedAgent'
),
],
check=True,
)
def test_remote_mcp_server_defaults():
server = RemoteMcpServer(url='https://x/mcp')
assert server.name is None
assert server.headers is None
assert server.allowed_tools is None
assert server.header_provider is None
def test_remote_mcp_server_forbids_extra_fields():
with pytest.raises(ValidationError):
RemoteMcpServer(url='https://x/mcp', bogus='nope')
def _mcp_params(params):
return [p for p in params if p.get('type') == 'mcp_server']
def test_resolve_mcp_basic_mapping():
server = RemoteMcpServer(
url='https://mcp.example.com/mcp', name='example', allowed_tools=['a']
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert {
'type': 'mcp_server',
'url': 'https://mcp.example.com/mcp',
'name': 'example',
'allowed_tools': [{'tools': ['a']}],
} in params
def test_resolve_mcp_sync_header_provider():
captured = {}
def provider(ctx):
captured['called'] = True
return {'Authorization': 'Bearer tok'}
server = RemoteMcpServer(url='https://x/mcp', header_provider=provider)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert captured['called'] is True
assert _mcp_params(params)[0]['headers'] == {'Authorization': 'Bearer tok'}
def test_resolve_mcp_async_header_provider():
async def provider(ctx):
return {'Authorization': 'Bearer async'}
server = RemoteMcpServer(url='https://x/mcp', header_provider=provider)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert _mcp_params(params)[0]['headers'] == {'Authorization': 'Bearer async'}
def test_resolve_mcp_merges_static_and_dynamic_dynamic_wins():
server = RemoteMcpServer(
url='https://x/mcp',
headers={'X-Static': 's', 'Shared': 'static'},
header_provider=lambda ctx: {'Shared': 'dynamic', 'X-Dyn': 'd'},
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert _mcp_params(params)[0]['headers'] == {
'X-Static': 's',
'Shared': 'dynamic',
'X-Dyn': 'd',
}
def test_resolve_mcp_no_header_provider_static_only():
server = RemoteMcpServer(url='https://x/mcp', headers={'X-Static': 's'})
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert _mcp_params(params)[0]['headers'] == {'X-Static': 's'}
def test_resolve_mcp_header_provider_error_propagates():
def boom(ctx):
raise RuntimeError('token mint failed')
server = RemoteMcpServer(url='https://x/mcp', header_provider=boom)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
with pytest.raises(RuntimeError, match='token mint failed'):
asyncio.run(agent._resolve_backend_tools(_ctx()))
def test_resolve_mcp_mixed_with_builtin():
server = RemoteMcpServer(url='https://x/mcp')
agent = ManagedAgent(
name='mgr',
agent_id='agents/a',
tools=[google_search, server],
api_client=_FakeClient(),
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert {'type': 'google_search'} in params
assert _mcp_params(params)
def test_resolve_mcp_empty_header_provider_omits_headers():
# A header_provider returning an empty dict (or None), with no static headers,
# must not add a 'headers' key to the mcp_server param.
server = RemoteMcpServer(url='https://x/mcp', header_provider=lambda ctx: {})
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
params = asyncio.run(agent._resolve_backend_tools(_ctx()))
assert 'headers' not in _mcp_params(params)[0]
def test_resolve_mcp_does_not_mutate_spec_headers():
original_headers = {'X-Static': 's'}
server = RemoteMcpServer(
url='https://x/mcp',
headers=original_headers,
header_provider=lambda ctx: {'Authorization': 'Bearer tok'},
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=_FakeClient()
)
asyncio.run(agent._resolve_backend_tools(_ctx()))
# The spec's original headers dict must be untouched by resolution.
assert server.headers == {'X-Static': 's'}
assert original_headers == {'X-Static': 's'}
def test_run_async_forwards_mcp_server_param():
client = _RecordingClient([[]])
server = RemoteMcpServer(
url='https://mcp.example.com/mcp',
header_provider=lambda ctx: {'X-Goog-Api-Key': 'k'},
)
agent = ManagedAgent(
name='mgr', agent_id='agents/a', tools=[server], api_client=client
)
asyncio.run(_drain(agent._run_async_impl(_user_ctx('hi'))))
create_kwargs = client.aio.interactions.calls[0]
mcp = [t for t in create_kwargs['tools'] if t['type'] == 'mcp_server'][0]
assert mcp['url'] == 'https://mcp.example.com/mcp'
assert mcp['headers'] == {'X-Goog-Api-Key': 'k'}