Skip to content

Commit 73aba76

Browse files
caohy1988claude
andcommitted
feat(plugins): add TRANSFER_A2A classification for TransferToAgentTool
When RemoteA2aAgent is used via sub_agents, the framework creates a TransferToAgentTool that only stores agent names. The analytics plugin returned TRANSFER_AGENT unconditionally for all transfer tools, losing the A2A distinction. This preserves transfer-target origin metadata so analytics can distinguish remote A2A transfers from local ones. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f973673 commit 73aba76

File tree

6 files changed

+202
-4
lines changed

6 files changed

+202
-4
lines changed

src/google/adk/flows/llm_flows/agent_transfer.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,23 @@ async def run_async(
4848
if not transfer_targets:
4949
return
5050

51+
try:
52+
from ...agents.remote_a2a_agent import RemoteA2aAgent
53+
except ImportError:
54+
RemoteA2aAgent = None
55+
56+
target_origin_by_name = {
57+
agent.name: (
58+
'TRANSFER_A2A'
59+
if RemoteA2aAgent is not None and isinstance(agent, RemoteA2aAgent)
60+
else 'TRANSFER_AGENT'
61+
)
62+
for agent in transfer_targets
63+
}
64+
5165
transfer_to_agent_tool = TransferToAgentTool(
52-
agent_names=[agent.name for agent in transfer_targets]
66+
agent_names=[agent.name for agent in transfer_targets],
67+
target_origin_by_name=target_origin_by_name,
5368
)
5469

5570
llm_request.append_instructions([

src/google/adk/plugins/bigquery_agent_analytics_plugin.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,30 @@ def _get_tool_origin(tool: "BaseTool") -> str:
208208
return "UNKNOWN"
209209

210210

211+
def _resolve_transfer_origin(
212+
tool: "BaseTool",
213+
tool_origin: str,
214+
tool_args: Optional[dict[str, Any]],
215+
) -> str:
216+
"""Refine transfer-tool origin using the selected target name.
217+
218+
For ``TransferToAgentTool`` calls, resolves the per-call origin
219+
(e.g. ``TRANSFER_A2A``) from the tool's ``_target_origin_by_name``
220+
metadata and the ``agent_name`` in ``tool_args``.
221+
222+
Returns ``tool_origin`` unchanged for non-transfer tools or when
223+
the selected target is not in the metadata map.
224+
"""
225+
if (
226+
tool_origin == "TRANSFER_AGENT"
227+
and tool_args
228+
and "agent_name" in tool_args
229+
):
230+
origin_map = getattr(tool, "_target_origin_by_name", {})
231+
return origin_map.get(tool_args["agent_name"], tool_origin)
232+
return tool_origin
233+
234+
211235
def _recursive_smart_truncate(
212236
obj: Any, max_len: int, seen: Optional[set[int]] = None
213237
) -> tuple[Any, bool]:
@@ -3175,7 +3199,9 @@ async def before_tool_callback(
31753199
args_truncated, is_truncated = _recursive_smart_truncate(
31763200
tool_args, self.config.max_content_length
31773201
)
3178-
tool_origin = _get_tool_origin(tool)
3202+
tool_origin = _resolve_transfer_origin(
3203+
tool, _get_tool_origin(tool), tool_args
3204+
)
31793205
content_dict = {
31803206
"tool": tool.name,
31813207
"args": args_truncated,
@@ -3209,7 +3235,9 @@ async def after_tool_callback(
32093235
resp_truncated, is_truncated = _recursive_smart_truncate(
32103236
result, self.config.max_content_length
32113237
)
3212-
tool_origin = _get_tool_origin(tool)
3238+
tool_origin = _resolve_transfer_origin(
3239+
tool, _get_tool_origin(tool), tool_args
3240+
)
32133241
content_dict = {
32143242
"tool": tool.name,
32153243
"result": resp_truncated,
@@ -3254,7 +3282,9 @@ async def on_tool_error_callback(
32543282
args_truncated, is_truncated = _recursive_smart_truncate(
32553283
tool_args, self.config.max_content_length
32563284
)
3257-
tool_origin = _get_tool_origin(tool)
3285+
tool_origin = _resolve_transfer_origin(
3286+
tool, _get_tool_origin(tool), tool_args
3287+
)
32583288
content_dict = {
32593289
"tool": tool.name,
32603290
"args": args_truncated,

src/google/adk/tools/transfer_to_agent_tool.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,30 @@ class TransferToAgentTool(FunctionTool):
4949
5050
Attributes:
5151
agent_names: List of valid agent names that can be transferred to.
52+
target_origin_by_name: Optional mapping from agent name to its origin
53+
category (e.g. ``"TRANSFER_AGENT"`` or ``"TRANSFER_A2A"``).
54+
Used by analytics plugins to distinguish remote A2A transfers
55+
from local ones at call time.
5256
"""
5357

5458
def __init__(
5559
self,
5660
agent_names: list[str],
61+
*,
62+
target_origin_by_name: Optional[dict[str, str]] = None,
5763
):
5864
"""Initialize the TransferToAgentTool.
5965
6066
Args:
6167
agent_names: List of valid agent names that can be transferred to.
68+
target_origin_by_name: Optional mapping from agent name to its
69+
origin category. When provided, analytics plugins can resolve
70+
per-call origin (e.g. ``"TRANSFER_A2A"``) from the selected
71+
target name.
6272
"""
6373
super().__init__(func=transfer_to_agent)
6474
self._agent_names = agent_names
75+
self._target_origin_by_name = target_origin_by_name or {}
6576

6677
@override
6778
def _get_declaration(self) -> Optional[types.FunctionDeclaration]:

tests/unittests/flows/llm_flows/test_agent_transfer_system_instructions.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,60 @@ async def test_agent_transfer_no_instructions_when_no_transfer_targets():
296296
instructions = llm_request.config.system_instruction or ''
297297
assert '**NOTE**:' not in instructions
298298
assert 'transfer_to_agent' not in instructions
299+
300+
301+
@pytest.mark.asyncio
302+
async def test_transfer_tool_preserves_a2a_target_origin():
303+
"""Test that TransferToAgentTool metadata distinguishes A2A from local."""
304+
try:
305+
from google.adk.agents.remote_a2a_agent import RemoteA2aAgent
306+
except ImportError:
307+
pytest.skip('RemoteA2aAgent not available')
308+
309+
mockModel = testing_utils.MockModel.create(responses=[])
310+
311+
local_agent = Agent(
312+
name='local_agent',
313+
model=mockModel,
314+
description='Local agent',
315+
)
316+
317+
# Minimal subclass that satisfies isinstance() without needing
318+
# full A2A dependencies (agent_card, httpx, etc.).
319+
class _StubRemoteA2aAgent(RemoteA2aAgent):
320+
321+
model_config = {'arbitrary_types_allowed': True}
322+
323+
def __init__(self, **kwargs):
324+
# Bypass RemoteA2aAgent.__init__; call BaseAgent directly.
325+
from google.adk.agents.base_agent import BaseAgent
326+
327+
BaseAgent.__init__(self, **kwargs)
328+
329+
remote_agent = _StubRemoteA2aAgent(
330+
name='remote_agent',
331+
description='Remote A2A agent',
332+
)
333+
334+
main_agent = Agent(
335+
name='main_agent',
336+
model=mockModel,
337+
sub_agents=[local_agent, remote_agent],
338+
description='Main agent',
339+
)
340+
341+
invocation_context = await create_test_invocation_context(main_agent)
342+
llm_request = LlmRequest()
343+
344+
async for _ in agent_transfer.request_processor.run_async(
345+
invocation_context, llm_request
346+
):
347+
pass
348+
349+
# Retrieve the TransferToAgentTool that was added to the request
350+
tool = llm_request.tools_dict.get('transfer_to_agent')
351+
assert tool is not None, 'transfer_to_agent tool not found in llm_request'
352+
353+
origin_map = tool._target_origin_by_name
354+
assert origin_map['local_agent'] == 'TRANSFER_AGENT'
355+
assert origin_map['remote_agent'] == 'TRANSFER_A2A'

tests/unittests/plugins/test_bigquery_agent_analytics_plugin.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4371,6 +4371,72 @@ def test_unknown_tool_returns_unknown(self):
43714371
result = bigquery_agent_analytics_plugin._get_tool_origin(tool)
43724372
assert result == "UNKNOWN"
43734373

4374+
def test_transfer_tool_remote_target_returns_transfer_a2a(self):
4375+
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
4376+
4377+
tool = TransferToAgentTool(
4378+
agent_names=["local", "remote"],
4379+
target_origin_by_name={
4380+
"local": "TRANSFER_AGENT",
4381+
"remote": "TRANSFER_A2A",
4382+
},
4383+
)
4384+
result = bigquery_agent_analytics_plugin._resolve_transfer_origin(
4385+
tool, "TRANSFER_AGENT", {"agent_name": "remote"}
4386+
)
4387+
assert result == "TRANSFER_A2A"
4388+
4389+
def test_transfer_tool_local_target_returns_transfer_agent(self):
4390+
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
4391+
4392+
tool = TransferToAgentTool(
4393+
agent_names=["local", "remote"],
4394+
target_origin_by_name={
4395+
"local": "TRANSFER_AGENT",
4396+
"remote": "TRANSFER_A2A",
4397+
},
4398+
)
4399+
result = bigquery_agent_analytics_plugin._resolve_transfer_origin(
4400+
tool, "TRANSFER_AGENT", {"agent_name": "local"}
4401+
)
4402+
assert result == "TRANSFER_AGENT"
4403+
4404+
def test_transfer_tool_no_tool_args_returns_transfer_agent(self):
4405+
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
4406+
4407+
tool = TransferToAgentTool(
4408+
agent_names=["remote"],
4409+
target_origin_by_name={"remote": "TRANSFER_A2A"},
4410+
)
4411+
result = bigquery_agent_analytics_plugin._resolve_transfer_origin(
4412+
tool, "TRANSFER_AGENT", None
4413+
)
4414+
assert result == "TRANSFER_AGENT"
4415+
4416+
def test_transfer_tool_missing_agent_name_returns_transfer_agent(self):
4417+
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
4418+
4419+
tool = TransferToAgentTool(
4420+
agent_names=["remote"],
4421+
target_origin_by_name={"remote": "TRANSFER_A2A"},
4422+
)
4423+
result = bigquery_agent_analytics_plugin._resolve_transfer_origin(
4424+
tool, "TRANSFER_AGENT", {"other": "val"}
4425+
)
4426+
assert result == "TRANSFER_AGENT"
4427+
4428+
def test_transfer_tool_unknown_name_returns_transfer_agent(self):
4429+
from google.adk.tools.transfer_to_agent_tool import TransferToAgentTool
4430+
4431+
tool = TransferToAgentTool(
4432+
agent_names=["remote"],
4433+
target_origin_by_name={"remote": "TRANSFER_A2A"},
4434+
)
4435+
result = bigquery_agent_analytics_plugin._resolve_transfer_origin(
4436+
tool, "TRANSFER_AGENT", {"agent_name": "unknown"}
4437+
)
4438+
assert result == "TRANSFER_AGENT"
4439+
43744440

43754441
class TestHITLTracing:
43764442
"""Tests for HITL-specific event emission via on_event_callback.

tests/unittests/tools/test_transfer_to_agent_tool.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,25 @@ def test_transfer_to_agent_tool_maintains_inheritance():
122122
assert hasattr(tool, 'process_llm_request')
123123

124124

125+
def test_target_origin_by_name_default_empty():
126+
"""Test that _target_origin_by_name defaults to empty dict."""
127+
tool = TransferToAgentTool(agent_names=['agent_a'])
128+
assert tool._target_origin_by_name == {}
129+
130+
131+
def test_target_origin_by_name_stored():
132+
"""Test that _target_origin_by_name stores the provided mapping."""
133+
origin_map = {
134+
'local_agent': 'TRANSFER_AGENT',
135+
'remote_agent': 'TRANSFER_A2A',
136+
}
137+
tool = TransferToAgentTool(
138+
agent_names=['local_agent', 'remote_agent'],
139+
target_origin_by_name=origin_map,
140+
)
141+
assert tool._target_origin_by_name == origin_map
142+
143+
125144
def test_transfer_to_agent_tool_handles_parameters_json_schema():
126145
"""Test that TransferToAgentTool handles parameters_json_schema format."""
127146
agent_names = ['agent_x', 'agent_y', 'agent_z']

0 commit comments

Comments
 (0)