Repository navigation
Description
BigQueryAgentAnalyticsPlugin: tool_origin not set to "A2A" when RemoteA2aAgent is used via sub_agents
Summary
When a RemoteA2aAgent is registered via sub_agents=[RemoteA2aAgent(...)] on an LlmAgent,
the BigQueryAgentAnalyticsPlugin logs tool_origin = "TRANSFER_AGENT" instead of "A2A".
This makes it impossible to distinguish A2A remote calls from regular local agent transfers in
observability data.
Workaround: Wrap the RemoteA2aAgent in AgentTool and pass it via tools=[]:
from google.adk.tools.agent_tool import AgentTool
root_agent = Agent(
tools=[AgentTool(agent=agent2_server), ...] # tool_origin = "A2A" ✅
)AgentTool extends BaseTool, so it passes Pydantic validation. _get_tool_origin then
hits the isinstance(tool, AgentTool) and isinstance(tool.agent, RemoteA2aAgent) branch
and returns "A2A" correctly.
Steps to Reproduce
agent2_server = RemoteA2aAgent(
name="agent2_server",
agent_card="http://localhost:8001/a2a/app/.well-known/agent-card.json"
)
# Path A — sub_agents (idiomatic, broken observability)
root_agent = Agent(
sub_agents=[agent2_server], # tool_origin = "TRANSFER_AGENT" ❌
plugins=[BigQueryAgentAnalyticsPlugin(...)]
)
# Path B — tools=[AgentTool(agent=...)] (workaround, works)
from google.adk.tools.agent_tool import AgentTool
root_agent = Agent(
tools=[AgentTool(agent=agent2_server), ...], # tool_origin = "A2A" ✅
plugins=[BigQueryAgentAnalyticsPlugin(...)]
)Query to confirm (run against the raw events table):
SELECT
event_type,
agent,
JSON_VALUE(content, '$.tool') AS tool_name,
JSON_VALUE(content, '$.tool_origin') AS tool_origin,
FORMAT_TIMESTAMP("%H:%M:%S", timestamp) AS time
FROM `<project_id>.<dataset_id>.<table_id>`
WHERE event_type IN ('TOOL_STARTING', 'TOOL_COMPLETED')
ORDER BY timestamp DESC
LIMIT 20| Configuration | Tool class seen by plugin | tool_origin |
Works? |
|---|---|---|---|
sub_agents=[RemoteA2aAgent(...)] |
TransferToAgentTool(agent_names=["name"]) |
"TRANSFER_AGENT" |
✅ runs, ❌ wrong origin |
tools=[AgentTool(agent=RemoteA2aAgent(...))] |
AgentTool(agent=RemoteA2aAgent) |
"A2A" |
✅ runs, ✅ correct origin |
Root Cause
Two-layer issue:
1. TransferToAgentTool discards agent object references.
In flows/llm_flows/agent_transfer.py:51, when building the tool for sub-agent transfers,
only the agent names (strings) are passed — not the agent objects:
transfer_to_agent_tool = TransferToAgentTool(
agent_names=[agent.name for agent in transfer_targets] # objects discarded here
)2. _get_tool_origin cannot introspect TransferToAgentTool for A2A targets.
In plugins/bigquery_agent_analytics_plugin.py:197-204, the check for TransferToAgentTool
fires unconditionally before the A2A check, with no way to know the target is a
RemoteA2aAgent:
def _get_tool_origin(tool):
if isinstance(tool, TransferToAgentTool):
return "TRANSFER_AGENT" # always fires for sub_agents, even for A2A targets
if isinstance(tool, AgentTool):
if isinstance(tool.agent, RemoteA2aAgent):
return "A2A" # only reachable via AgentTool(agent=RemoteA2aAgent(...))Proposed Fix (for the underlying bug)
Option A (minimal — 3 files):
tools/transfer_to_agent_tool.py— store agent objects alongside names:
def __init__(self, agent_names: list[str], agents: list[BaseAgent] | None = None):
super().__init__(func=transfer_to_agent)
self._agent_names = agent_names
self._agents = agents or []flows/llm_flows/agent_transfer.py— pass agent objects when constructing the tool:
transfer_to_agent_tool = TransferToAgentTool(
agent_names=[agent.name for agent in transfer_targets],
agents=transfer_targets,
)plugins/bigquery_agent_analytics_plugin.py— check for A2A targets in_get_tool_origin:
if isinstance(tool, TransferToAgentTool):
if (RemoteA2aAgent is not None
and hasattr(tool, '_agents')
and any(isinstance(a, RemoteA2aAgent) for a in tool._agents)):
return "A2A"
return "TRANSFER_AGENT"Option B (cleaner): Introduce a TransferToA2aAgentTool subclass for A2A transfers,
handled as a dedicated case in _get_tool_origin.
Expected Behavior
tool_origin = "A2A" should be logged whenever a RemoteA2aAgent is invoked, regardless
of whether it was registered via sub_agents or tools=[AgentTool(agent=...)].
Environment
google-adkversion: 1.28.0BigQueryAgentAnalyticsPluginversion: same
Activity
caohy1988 commentedon Mar 31, 2026
RFC: Preserve A2A vs local transfer origin in
TransferToAgentToolRelates to: analytics classification gap in
BigQueryAgentAnalyticsPluginProblem Statement
BigQueryAgentAnalyticsPluginclassifies tool origin using_get_tool_origin()(bigquery_agent_analytics_plugin.py:171).For direct
AgentToolusage, it distinguishes:SUB_AGENTA2ABut for the
sub_agentstransfer path, all transfers are classified asTRANSFER_AGENTregardless of the actual target type. This means A2A transfers initiated through the transfer tool path are misclassified in analytics.Key Design Decision
This RFC targets call-level classification, not tool-level classification.
Reason:
TransferToAgentToolcan contain a mix of local and A2A targetsA2AThe required question is: for this transfer call, was
tool_args["agent_name"]a remote A2A target?This cannot be answered from the tool object alone today.
Root Cause
The root cause is metadata loss, not
isinstance()ordering.TransferToAgentToolis constructed from names only (agent_transfer.py:51-52):The tool stores only
self._agent_names(transfer_to_agent_tool.py:64). By the time analytics sees the tool object, it has target names but no agent references and no type metadata._get_tool_origin(tool)(bigquery_agent_analytics_plugin.py:201) matchesTransferToAgentTooland returns"TRANSFER_AGENT"unconditionally — no way to know whether the selected target was aRemoteA2aAgent.Note: the existing code comment about isinstance ordering (
# Order matters: TransferToAgentTool is a subclass of FunctionTool) is about FunctionTool ordering, which is correct and unrelated. Reordering does not solve this bug becauseTransferToAgentToolandAgentToolare different types.Why This Matters
Observability accuracy: A2A is a materially different execution mode from local sub-agent transfer — different latency profile, failure surface, network boundary, and operational ownership. Collapsing both into
TRANSFER_AGENTmakes analytics less useful.Inconsistent classification:
AgentTool(RemoteA2aAgent(...))→A2A, butTransferToAgentTooltransferring to the sameRemoteA2aAgent→TRANSFER_AGENT. The same conceptual remote delegation is classified differently depending on which tooling path created it.BigQuery analytics impact: A2A transfer volume is undercounted, transfer metrics mix local and remote delegation, and A2A-specific latency/error analysis is incomplete.
Concrete Scenario
An agent has
sub_agents=[local_agent, remote_a2a_agent]. The framework creates aTransferToAgentToolfor those transfer targets (agent_transfer.py:51). The model chooses to transfer to the remote A2A target._get_tool_origin()seesTransferToAgentTool, returns"TRANSFER_AGENT"— remote A2A transfer is indistinguishable from local transferProposal
Preserve transfer-target origin metadata on
TransferToAgentTool, and classify transfer origin at call time using both:tool_args["agent_name"](for the selected target)Recommended Design
When building
TransferToAgentToolfromtransfer_targets(agent_transfer.py:47-52), store origin metadata:This is the simplest shape —
_get_transfer_targets()already returnslist[BaseAgent](actual agent objects, line 156), so the metadata is available at construction time. No new dataclass needed.Then, in the BigQuery analytics callback path (
before_tool_callbackat line 3186), resolve transfer origin using:tool(to check if it's aTransferToAgentToolwithtarget_origin_by_name)tool_args["agent_name"](to look up the selected target's origin)This can be done by either:
_get_tool_origin()to accept an optionaltool_argsparameter for transfer-tool casestool_argsis already availableBoth approaches work; the second is more localized to the BigQuery plugin.
Unchanged Behavior
These paths are not changing:
AgentTool(local agent)→SUB_AGENTAgentTool(RemoteA2aAgent)→A2AFunctionTool→LOCALMcpTool→MCPNew Behavior
This RFC only changes transfer-tool classification for
TransferToAgentToolcalls.Classification Output
After this change, the full
tool_origintaxonomy is:AgentTool(local agent)SUB_AGENT(unchanged)AgentTool(RemoteA2aAgent)A2A(unchanged)FunctionToolLOCAL(unchanged)McpToolMCP(unchanged)TransferToAgentToolTRANSFER_AGENT(unchanged)TransferToAgentToolRemoteA2aAgentTRANSFER_A2A(new)Rationale for
TRANSFER_A2A(not reusingA2A):A2Aalready means "directAgentToolwrapping aRemoteA2aAgent"TRANSFER_A2Apreserves the transfer-vs-direct distinction, which is operationally meaningful (different execution paths, different framework orchestration)tool_origin LIKE '%A2A%'ortool_origin IN ('A2A', 'TRANSFER_A2A')when the distinction is not neededBackward Compatibility
Additive only:
sub_agentscontinue to workagent_transfer.pyand stored as a private field without changing the constructor): no public API changeTransferToAgentToolconstructor grows an optionaltarget_originsparameter: backward-compatible additive API change (TransferToAgentToolis public surface)Testing Strategy
RemoteA2aAgenttarget_origin_by_nameRemoteA2aAgenttarget asTRANSFER_A2A(call-level)TRANSFER_AGENT(call-level)AgentToolclassification remains unchangedOut of Scope
AgentToolclassificationtransfer_to_agentfunction is used directly as a genericFunctionTool(pre-existing classification limitation unrelated to this RFC)haiyuan-eng-google commentedon Apr 13, 2026
To reflect the final decision made in the comment thread, you should add a section titled "Implementation Refinement" or "Architectural Decision." This clarifies why the final implementation differs from the initial proposal in the RFC.
Here is a draft of the section you can add, typically placed just before the Backward Compatibility or Testing Strategy sections:
Implementation Refinement
Following a review of the initial proposal, an architectural decision was made to move the origin-resolution logic from the ADK core (
agent_transfer.py) directly into theBigQueryAgentAnalyticsPlugin.Revised Approach
Instead of modifying the public interface of
TransferToAgentToolto store metadata, the analytics plugin will infer the transfer origin at runtime. This maintains the "lean" nature of the ADK core by avoiding the introduction of fields used exclusively for logging and observability.Resolution Logic
The plugin will utilize the
before_tool_callbackto distinguish between local and remote transfers:TransferToAgentTool.agent_namefrom thetool_args.tool_context._invocation_context.agent.RemoteA2aAgent. If yes, label asTRANSFER_A2A; otherwise, default toTRANSFER_AGENT.Rationale
TransferToAgentToolis constructed or stored, preventing potential side effects in other workstreams.fix: classify TransferToAgentTool transfers to RemoteA2aAgent as TRAN…
fix: Resolve BigQuery plugin issues with A2A transfers, spans, and me…
fix: Resolve BigQuery plugin issues with A2A transfers, spans, and me…
haiyuan-eng-google commentedon Apr 24, 2026
get two new comments: 1. The resolution logic states: 'Iterate through the sub-agents to find the matching name'. Is it guaranteed that all potential transfer targets are listed in the current agent's direct sub-agents? If an agent can transfer to a peer or a non-direct sub-agent (if that's supported by the framework), this lookup might fail to find the agent or incorrectly default to
TRANSFER_AGENT. It would be good to clarify the scope of sub-agents checked here and whether a recursive lookup or a different registry check is needed.tool_context._invocation_context.agent. The use of a leading underscore suggests_invocation_contextis an internal/private implementation detail of the ADK. Relying on private attributes in theBigQueryAgentAnalyticsPluginmight make the plugin fragile to future ADK internal refactors. Is there a public API ontool_contextto access the current agent or its registered sub-agents? If not, should we consider exposing one to make this integration more robust?caohy1988 commentedon Apr 24, 2026
Validated both questions against current main (
a69f8612). The implementation (shipped in PR #5325, merged as9ca8c384) already addresses both.1. Transfer target lookup scope — covers all framework-advertised targets
_find_transfer_target()(bigquery_agent_analytics_plugin.py:171-194) searches three levels:This exactly matches the framework's
_get_transfer_targets()(agent_transfer.py:156-175), which builds theTransferToAgentToolfrom:agent.sub_agents(line 158)agent.parent_agent(line 166, whendisallow_transfer_to_parentis False)agent.parent_agent.sub_agentsexcluding self (lines 169-173, whendisallow_transfer_to_peersis False)No recursive lookup is needed for classifying calls produced by the framework-created
TransferToAgentTool, because its advertised targets are constrained to the one-hop scope from_get_transfer_targets().Note: execution-time resolution is broader —
__get_agent_to_run(llm_agent.py:772) callsself.root_agent.find_agent(agent_name)(base_agent.py:373), which is recursive over the full tree. But theTransferToAgentTool'sagent_namesenum only contains targets from the one-hop scope, so the LLM cannot select a target outside that set through the standard transfer path.2. Private
_invocation_contextaccess — valid concern, current best optionThe code accesses
tool_context._invocation_context.agent(line 247)._invocation_contextis indeed a private attribute onReadonlyContext(readonly_context.py:37). The public API only exposesagent_name(a string, line 50), not the agent object or its sub-agents.There is a public
BaseAgent.find_agent(name)(base_agent.py:373) that does recursive tree lookup, butReadonlyContextdoes not expose the agent object needed to call it. So there is no fully public path fromToolContextto the agent tree today.Options for hardening:
agentproperty toReadonlyContext— exposes the agent object publicly. Simple but broadens the public surface.find_agent(name)method toReadonlyContext— more scoped, delegates to_invocation_context.agent.root_agent.find_agent(name).The shipped implementation uses (c). If ADK refactors
_invocation_context, the plugin would need updating — but as a first-party plugin shipped in the same package, this is manageable. A public API (option a or b) would be cleaner but is a separate scope decision.fix: Resolve BigQuery plugin issues with A2A transfers, spans, and me…