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
14 changes: 8 additions & 6 deletions src/google/adk/agents/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ async def _convert_tool_union_to_tools(
try:
return await tool_union.get_tools_with_prefix(ctx)
except Exception as e:
# The agent still runs, just without this toolset's tools, and the model
# will answer as though it never had them. That is a lost capability
# rather than a degraded one, so report it at error level, name which
# toolset was lost, and keep the traceback: str(e) is empty for several
# of the exceptions raised by transport clients.
# The agent still runs after a listing failure. Report at error level,
# name which toolset was lost, and keep the traceback: str(e) is empty
# for several of the exceptions raised by transport clients. Then let the
# toolset decide what to contribute (default: nothing) so apps can return
# placeholder tools for expected failures such as per-user OAuth 401s.
logger.error(
'Agent %s will run without the tools from toolset %s%s, which failed'
' to load: %s',
Expand All @@ -228,7 +228,9 @@ async def _convert_tool_union_to_tools(
e,
exc_info=True,
)
return []
return tool_union._apply_tool_name_prefix(
await tool_union.on_tools_listing_error(e, ctx)
)


# TODO: drop the explicit abc.ABC base once BaseNode surfaces ABCMeta to
Expand Down
87 changes: 62 additions & 25 deletions src/google/adk/tools/base_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,52 +99,60 @@ async def get_tools(
list[BaseTool]: A list of tools available under the specified context.
"""

@final
async def get_tools_with_prefix(
async def on_tools_listing_error(
self,
error: Exception,
readonly_context: Optional[ReadonlyContext] = None,
) -> list[BaseTool]:
"""Return all tools with optional prefix applied to tool names.
"""Decide what this toolset contributes when ``get_tools`` fails.

This method calls get_tools() and applies prefixing if tool_name_prefix is provided.
Framework call sites that load toolsets for an agent (or for skill
additional tools) catch listing failures so one broken toolset cannot take
down the rest. By default those call sites then contribute nothing from the
failed toolset. Override this hook to contribute a replacement tool list
instead — for example placeholder tools that prompt the user to complete
per-user OAuth for an MCP server that returned HTTP 401.

Direct callers of ``get_tools`` / ``get_tools_with_prefix`` still see the
original exception; this hook is only invoked by the framework's
isolate-and-continue paths.

Framework callers apply ``tool_name_prefix`` to the returned tools the same
way ``get_tools_with_prefix`` would, so overrides should return unprefixed
tool names.

Args:
readonly_context (ReadonlyContext, optional): Context used to filter tools
available to the agent. If None, all tools in the toolset are returned.
error: The exception raised by ``get_tools`` / ``get_tools_with_prefix``.
readonly_context: The same context passed to the failed listing call.

Returns:
list[BaseTool]: A list of tools with prefixed names if tool_name_prefix is provided.
Tools to use in place of the failed listing. The default returns an
Comment thread
a2105z marked this conversation as resolved.
empty list (contribute nothing), matching historical framework behavior.
"""
invocation_id = readonly_context.invocation_id if readonly_context else None
del error, readonly_context
return []

if (
self._use_invocation_cache
and self._cached_prefixed_tools is not None
and self._cached_invocation_id == invocation_id
):
return self._cached_prefixed_tools
def _apply_tool_name_prefix(self, tools: list[BaseTool]) -> list[BaseTool]:
"""Applies ``tool_name_prefix`` to tools, matching ``get_tools_with_prefix``.

tools = await self.get_tools(readonly_context)
Args:
tools: Tools with unprefixed names (as returned by ``get_tools`` or
``on_tools_listing_error``).

Returns:
The same tools when no prefix is configured; otherwise shallow copies
with prefixed ``name`` / declaration names.
"""
if not self.tool_name_prefix:
self._cached_invocation_id = invocation_id
self._cached_prefixed_tools = tools
return tools

prefix = self.tool_name_prefix

# Create copies of tools to avoid modifying original instances
prefixed_tools = []
for tool in tools:
# Create a shallow copy of the tool
tool_copy = copy.copy(tool)

# Apply prefix to the copied tool
prefixed_name = f"{prefix}_{tool.name}"
prefixed_name = f'{prefix}_{tool.name}'
tool_copy.name = prefixed_name

# Also update the function declaration name if the tool has one
# Use default parameters to capture the current values in the closure
def _create_prefixed_declaration(
original_get_declaration=tool._get_declaration,
prefixed_name=prefixed_name,
Expand All @@ -160,6 +168,35 @@ def _get_prefixed_declaration():

tool_copy._get_declaration = _create_prefixed_declaration()
prefixed_tools.append(tool_copy)
return prefixed_tools

@final
async def get_tools_with_prefix(
self,
readonly_context: Optional[ReadonlyContext] = None,
) -> list[BaseTool]:
"""Return all tools with optional prefix applied to tool names.

This method calls get_tools() and applies prefixing if tool_name_prefix is provided.

Args:
readonly_context (ReadonlyContext, optional): Context used to filter tools
available to the agent. If None, all tools in the toolset are returned.

Returns:
list[BaseTool]: A list of tools with prefixed names if tool_name_prefix is provided.
"""
invocation_id = readonly_context.invocation_id if readonly_context else None

if (
self._use_invocation_cache
and self._cached_prefixed_tools is not None
and self._cached_invocation_id == invocation_id
):
return self._cached_prefixed_tools

tools = await self.get_tools(readonly_context)
prefixed_tools = self._apply_tool_name_prefix(tools)

self._cached_invocation_id = invocation_id
self._cached_prefixed_tools = prefixed_tools
Expand Down
7 changes: 6 additions & 1 deletion src/google/adk/tools/skill_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -1314,7 +1314,12 @@ async def _resolve_additional_tools_from_state(
ts_tools,
exc_info=ts_tools,
)
continue
# Let the toolset replace a failed listing (default: contribute
# nothing) so apps can surface expected auth failures as tools.
# Apply the same prefix contract as get_tools_with_prefix.
ts_tools = toolset._apply_tool_name_prefix(
await toolset.on_tools_listing_error(ts_tools, readonly_context)
)
if isinstance(ts_tools, BaseException):
raise ts_tools
for t in ts_tools:
Expand Down
66 changes: 66 additions & 0 deletions tests/unittests/agents/test_llm_agent_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,72 @@ async def get_tools(self, readonly_context=None):
# The traceback is what identifies where inside the toolset it broke.
assert record.exc_info is not None

async def test_canonical_tools_uses_on_tools_listing_error_hook(self, caplog):
"""A toolset can contribute placeholder tools after a listing failure."""
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.base_toolset import BaseToolset

class AuthPromptToolset(BaseToolset):

async def get_tools(self, readonly_context=None):
raise ConnectionError('HTTP 401 Unauthorized')

async def on_tools_listing_error(self, error, readonly_context=None):
del readonly_context
tool = mock.MagicMock(spec=BaseTool)
tool.name = 'connect_mcp_server'
tool.description = f'Authorize access ({error})'
tool._get_declaration = mock.MagicMock(return_value=None)
return [tool]

agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[AuthPromptToolset()],
)
ctx = await _create_readonly_context(agent)

with caplog.at_level(logging.ERROR, logger='google_adk'):
tools = await agent.canonical_tools(ctx)

assert len(tools) == 1
assert tools[0].name == 'connect_mcp_server'
assert any('failed to load' in r.getMessage() for r in caplog.records)

async def test_canonical_tools_prefixes_on_tools_listing_error_fallback(
self, caplog
):
"""Listing-error fallback tools honor the toolset tool_name_prefix."""
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.base_toolset import BaseToolset

class AuthPromptToolset(BaseToolset):

async def get_tools(self, readonly_context=None):
raise ConnectionError('HTTP 401 Unauthorized')

async def on_tools_listing_error(self, error, readonly_context=None):
del error, readonly_context
tool = mock.MagicMock(spec=BaseTool)
tool.name = 'connect_mcp_server'
tool.description = 'Authorize access'
tool._get_declaration = mock.MagicMock(return_value=None)
return [tool]

agent = LlmAgent(
name='test_agent',
model='gemini-pro',
tools=[AuthPromptToolset(tool_name_prefix='books')],
)
ctx = await _create_readonly_context(agent)

with caplog.at_level(logging.ERROR, logger='google_adk'):
tools = await agent.canonical_tools(ctx)

assert len(tools) == 1
assert tools[0].name == 'books_connect_mcp_server'
assert any('failed to load' in r.getMessage() for r in caplog.records)


# Tests for multi-provider model support via string model names
@pytest.mark.parametrize(
Expand Down
70 changes: 70 additions & 0 deletions tests/unittests/tools/test_base_toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,73 @@ async def test_get_tools_with_prefix_caching():
readonly_context=readonly_context2
)
assert tools4 is not tools5


@pytest.mark.asyncio
async def test_on_tools_listing_error_default_contributes_nothing():
"""Default hook returns an empty list so failed listings contribute nothing."""
toolset = _TestingToolset()
tools = await toolset.on_tools_listing_error(
ConnectionError('MCP unauthorized'), readonly_context=None
)
assert tools == []


@pytest.mark.asyncio
async def test_on_tools_listing_error_can_be_overridden():
"""Subclasses can return placeholder tools when listing fails."""

class _AuthPromptToolset(_TestingToolset):

async def on_tools_listing_error(
self,
error: Exception,
readonly_context: Optional[ReadonlyContext] = None,
) -> list[BaseTool]:
del readonly_context
return [
_TestingTool(
name='connect_mcp',
description=f'Authorize MCP access ({error})',
)
]

toolset = _AuthPromptToolset()
tools = await toolset.on_tools_listing_error(
ConnectionError('HTTP 401'), readonly_context=None
)
assert len(tools) == 1
assert tools[0].name == 'connect_mcp'


@pytest.mark.asyncio
async def test_on_tools_listing_error_fallback_respects_tool_name_prefix():
"""Fallback tools from the listing-error hook receive tool_name_prefix."""

class _AuthPromptToolset(_TestingToolset):

async def get_tools(
self, readonly_context: Optional[ReadonlyContext] = None
) -> list[BaseTool]:
del readonly_context
raise ConnectionError('HTTP 401')

async def on_tools_listing_error(
self,
error: Exception,
readonly_context: Optional[ReadonlyContext] = None,
) -> list[BaseTool]:
del error, readonly_context
return [
_TestingTool(
name='connect_mcp',
description='authorize MCP access',
)
]

toolset = _AuthPromptToolset(tool_name_prefix='mcp')
tools = toolset._apply_tool_name_prefix(
await toolset.on_tools_listing_error(ConnectionError('HTTP 401'))
)
assert len(tools) == 1
assert tools[0].name == 'mcp_connect_mcp'
Loading