Skip to content

BigQueryAgentAnalyticsPlugin: tool_origin not set to "A2A" when RemoteA2aAgent is used via sub_agents #5073

Closed

Description

@evekhm

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

  1. 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 []
  1. 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,
)
  1. 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-adk version: 1.28.0
  • BigQueryAgentAnalyticsPlugin version: same

Activity

  1. added theissue type on Mar 30, 2026
  2. added
    core[Component] This issue is related to the core interface and implementation
    on Mar 30, 2026
  3. caohy1988 commented on Mar 31, 2026

    @caohy1988

    RFC: Preserve A2A vs local transfer origin in TransferToAgentTool

    Relates to: analytics classification gap in BigQueryAgentAnalyticsPlugin

    Problem Statement

    BigQueryAgentAnalyticsPlugin classifies tool origin using _get_tool_origin() (bigquery_agent_analytics_plugin.py:171).

    For direct AgentTool usage, it distinguishes:

    • local sub-agent tool → SUB_AGENT
    • remote A2A agent tool → A2A

    But for the sub_agents transfer path, all transfers are classified as TRANSFER_AGENT regardless 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:

    • A single TransferToAgentTool can contain a mix of local and A2A targets
    • Analytics should reflect the target selected for the specific invocation
    • A tool with 5 local targets and 1 A2A target should not always be classified as A2A

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

    TransferToAgentTool is constructed from names only (agent_transfer.py:51-52):

    transfer_to_agent_tool = TransferToAgentTool(
        agent_names=[agent.name for agent in transfer_targets]
    )

    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) matches TransferToAgentTool and returns "TRANSFER_AGENT" unconditionally — no way to know whether the selected target was a RemoteA2aAgent.

    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 because TransferToAgentTool and AgentTool are 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_AGENT makes analytics less useful.

    Inconsistent classification: AgentTool(RemoteA2aAgent(...)) → A2A, but TransferToAgentTool transferring to the same RemoteA2aAgent → 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 a TransferToAgentTool for those transfer targets (agent_transfer.py:51). The model chooses to transfer to the remote A2A target.

    • Expected: analytics classifies this specific call as A2A
    • Actual: _get_tool_origin() sees TransferToAgentTool, returns "TRANSFER_AGENT" — remote A2A transfer is indistinguishable from local transfer

    Proposal

    Preserve transfer-target origin metadata on TransferToAgentTool, and classify transfer origin at call time using both:

    • the tool (for target metadata)
    • tool_args["agent_name"] (for the selected target)

    Recommended Design

    When building TransferToAgentTool from transfer_targets (agent_transfer.py:47-52), store origin metadata:

    target_origin_by_name = {
        agent.name: "TRANSFER_A2A" if isinstance(agent, RemoteA2aAgent) else "TRANSFER_AGENT"
        for agent in transfer_targets
    }

    This is the simplest shape — _get_transfer_targets() already returns list[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_callback at line 3186), resolve transfer origin using:

    • tool (to check if it's a TransferToAgentTool with target_origin_by_name)
    • tool_args["agent_name"] (to look up the selected target's origin)

    This can be done by either:

    • Changing _get_tool_origin() to accept an optional tool_args parameter for transfer-tool cases
    • Moving transfer origin resolution into the callback body where tool_args is already available

    Both approaches work; the second is more localized to the BigQuery plugin.

    Unchanged Behavior

    These paths are not changing:

    • AgentTool(local agent) → SUB_AGENT
    • AgentTool(RemoteA2aAgent) → A2A
    • FunctionTool → LOCAL
    • McpTool → MCP

    New Behavior

    This RFC only changes transfer-tool classification for TransferToAgentTool calls.

    Classification Output

    After this change, the full tool_origin taxonomy is:

    Tool path Selected target Label
    AgentTool(local agent) — SUB_AGENT (unchanged)
    AgentTool(RemoteA2aAgent) — A2A (unchanged)
    FunctionTool — LOCAL (unchanged)
    McpTool — MCP (unchanged)
    TransferToAgentTool local agent TRANSFER_AGENT (unchanged)
    TransferToAgentTool RemoteA2aAgent TRANSFER_A2A (new)

    Rationale for TRANSFER_A2A (not reusing A2A):

    • A2A already means "direct AgentTool wrapping a RemoteA2aAgent"
    • TRANSFER_A2A preserves the transfer-vs-direct distinction, which is operationally meaningful (different execution paths, different framework orchestration)
    • Analytics queries can still aggregate both with tool_origin LIKE '%A2A%' or tool_origin IN ('A2A', 'TRANSFER_A2A') when the distinction is not needed

    Backward Compatibility

    Additive only:

    • Existing transfer behavior does not change
    • Only tool metadata and analytics classification change
    • Existing users of sub_agents continue to work
    • If metadata is derived internally (e.g., computed inside agent_transfer.py and stored as a private field without changing the constructor): no public API change
    • If the TransferToAgentTool constructor grows an optional target_origins parameter: backward-compatible additive API change (TransferToAgentTool is public surface)

    Testing Strategy

    1. Construct transfer targets including at least one RemoteA2aAgent
    2. Verify transfer tool creation preserves target_origin_by_name
    3. Verify analytics classifies a transfer call to RemoteA2aAgent target as TRANSFER_A2A (call-level)
    4. Verify analytics classifies a transfer call to local target as TRANSFER_AGENT (call-level)
    5. Verify existing non-A2A sub-agent transfer behavior is unchanged
    6. Verify direct AgentTool classification remains unchanged

    Out of Scope

    • Changing execution semantics of transfer
    • Redesigning the full analytics taxonomy
    • Changing direct AgentTool classification
    • Handling the separate edge case where bare transfer_to_agent function is used directly as a generic FunctionTool (pre-existing classification limitation unrelated to this RFC)
  4. haiyuan-eng-google commented on Apr 13, 2026

    @haiyuan-eng-google
    Collaborator

    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 the BigQueryAgentAnalyticsPlugin.

    Revised Approach

    Instead of modifying the public interface of TransferToAgentTool to 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_callback to distinguish between local and remote transfers:

    1. Identify the Tool: Detect if the current tool is an instance of TransferToAgentTool.
    2. Extract Target: Extract the agent_name from the tool_args.
    3. Lookup Origin: Access the current agent’s sub-agents via the tool_context._invocation_context.agent.
    4. Classify: Iterate through the sub-agents to find the matching name and check if it is an instance of RemoteA2aAgent. If yes, label as TRANSFER_A2A; otherwise, default to TRANSFER_AGENT.

    Rationale

    • API Integrity: Keeps the ADK public API surface area clean and focused on execution rather than metadata.
    • Separation of Concerns: Observability logic remains within the observability layers (Plugins/OpenTelemetry) rather than the core tool logic.
    • Zero Impact on Core: This approach requires no changes to how TransferToAgentTool is constructed or stored, preventing potential side effects in other workstreams.
  5. added a commit that references this issue on Apr 14, 2026
    70dfef8
  6. added a commit that references this issue on Apr 15, 2026
    9ca8c38
  7. added a commit that references this issue on Apr 21, 2026
    dd69ec4
  8. haiyuan-eng-google commented on Apr 24, 2026

    @haiyuan-eng-google
    Collaborator

    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.

    1. The revised approach mentions accessing the current agent via tool_context._invocation_context.agent. The use of a leading underscore suggests _invocation_context is an internal/private implementation detail of the ADK. Relying on private attributes in the BigQueryAgentAnalyticsPlugin might make the plugin fragile to future ADK internal refactors. Is there a public API on tool_context to access the current agent or its registered sub-agents? If not, should we consider exposing one to make this integration more robust?
  9. caohy1988 commented on Apr 24, 2026

    @caohy1988

    Validated both questions against current main (a69f8612). The implementation (shipped in PR #5325, merged as 9ca8c384) 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:

    # 1. Direct sub-agents
    for sub in getattr(agent, "sub_agents", []):
    # 2. Parent agent
    parent = getattr(agent, "parent_agent", None)
    if parent is not None and parent.name == agent_name:
    # 3. Peer agents (parent's sub-agents)
    if parent is not None:
        for peer in getattr(parent, "sub_agents", []):

    This exactly matches the framework's _get_transfer_targets() (agent_transfer.py:156-175), which builds the TransferToAgentTool from:

    • agent.sub_agents (line 158)
    • agent.parent_agent (line 166, when disallow_transfer_to_parent is False)
    • agent.parent_agent.sub_agents excluding self (lines 169-173, when disallow_transfer_to_peers is 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) calls self.root_agent.find_agent(agent_name) (base_agent.py:373), which is recursive over the full tree. But the TransferToAgentTool's agent_names enum 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_context access — valid concern, current best option

    The code accesses tool_context._invocation_context.agent (line 247). _invocation_context is indeed a private attribute on ReadonlyContext (readonly_context.py:37). The public API only exposes agent_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, but ReadonlyContext does not expose the agent object needed to call it. So there is no fully public path from ToolContext to the agent tree today.

    Options for hardening:

    • (a) Add agent property to ReadonlyContext — exposes the agent object publicly. Simple but broadens the public surface.
    • (b) Add find_agent(name) method to ReadonlyContext — more scoped, delegates to _invocation_context.agent.root_agent.find_agent(name).
    • (c) Accept the private access for now — the plugin is a first-party ADK component, so it can reasonably depend on internals. Document the dependency.

    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.

  10. added a commit that references this issue on Aug 11, 2026
    fd57198
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

core[Component] This issue is related to the core interface and implementation

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions