Skip to content

disallow_transfer_to_peers=True can be bypassed #3850

@AriYusa

Description

@AriYusa

When using LlmAgent with disallow_transfer_to_peers=True, sub-agents are still able to transfer tasks directly to sibling agents using transfer_to_agent, even though such transfers should be disallowed.

The disallow_transfer_to_peers=True flag only influences the prompt given to the agent. But in the deafult mode each sub-agent has access to the full conversation history, including all previous transfers and agent names mentioned. So a sub-agent can still infer the names of sibling agents from the history and explicitly call transfer_to_agent with a sibling's name, bypassing the intended restriction. Since a sibling's name exists in the agent tree no error is raised.

To Reproduce

git clone https://github.com/google/adk-python.git
cd adk-python
uv sync --all-extras
from __future__ import annotations

from unittest.mock import AsyncMock

from google.adk.agents import Agent
from google.adk.apps import App
from google.adk.models.base_llm import BaseLlm
from google.adk.models.llm_response import LlmResponse
from google.genai import types  
from google.adk.runners import Runner
from google.adk.sessions.in_memory_session_service import InMemorySessionService
from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactService
from google.adk.memory.in_memory_memory_service import InMemoryMemoryService


def create_mock_llm_with_transfer(agent_name: str) -> BaseLlm:
  """Create a mock LLM that returns a transfer_to_agent call."""
  mock_llm = AsyncMock(spec=BaseLlm)
  mock_llm.model = 'mock-model'
  
  # This simulates an LLM that has inferred sibling names from history
  async def mock_generate(*args, **kwargs):
    yield LlmResponse(
        content=types.Content(
            role='model',
            parts=[
                types.Part.from_function_call(
                    name='transfer_to_agent',
                    args={'agent_name': agent_name}
                )
            ],
        ),
        usage_metadata=None,
    )
  
  mock_llm.generate_content_async.side_effect = mock_generate
  
  return mock_llm


async def test_disallow_transfer_to_peers_bypass():
  """Test that demonstrates the bug.
  
  Expected: sub_agent_1 cannot transfer to sub_agent_2 (sibling)
  Actual: sub_agent_1 can transfer to sub_agent_2 by calling transfer_to_agent
  """
  
  # Create mock LLMs
  # root_agent's LLM transfers to sub_agent_1
  root_mock_llm = create_mock_llm_with_transfer('sub_agent_1')
  
  # sub_agent_1's LLM transfers to sub_agent_2 (its sibling) - THIS SHOULD FAIL
  sub_agent_1_mock_llm = create_mock_llm_with_transfer('sub_agent_2')
  
  # sub_agent_2 just responds normally
  sub_agent_2_mock_llm = AsyncMock(spec=BaseLlm)
  sub_agent_2_mock_llm.model = 'mock-model'

  async def mock_sub_agent_2_generate(*args, **kwargs):
    yield LlmResponse(
        content=types.Content(
            role='model',
            parts=[types.Part(text='Hello from sub_agent_2')],
        ),
        usage_metadata=None,
    )
  sub_agent_2_mock_llm.generate_content_async.side_effect = mock_sub_agent_2_generate
  
  # root_agent
  #   ├── sub_agent_1 (disallow_transfer_to_peers=True)
  #   └── sub_agent_2 (disallow_transfer_to_peers=True)
  
  sub_agent_1 = Agent(
      name='sub_agent_1',
      model=sub_agent_1_mock_llm,
      description='Sub agent 1',
      disallow_transfer_to_peers=True,  # Should prevent transfer to sub_agent_2
  )
  
  sub_agent_2 = Agent(
      name='sub_agent_2',
      model=sub_agent_2_mock_llm,
      description='Sub agent 2',
      disallow_transfer_to_peers=True,  # Should prevent transfer to sub_agent_1
  )
  
  root_agent = Agent(
      name='root_agent',
      model=root_mock_llm,
      sub_agents=[sub_agent_1, sub_agent_2],
  )
  
  app = App(name='test_app', root_agent=root_agent)
  
  session_service = InMemorySessionService()
  
  runner = Runner(
      app=app,
      session_service=session_service,
      artifact_service=InMemoryArtifactService(),
      memory_service=InMemoryMemoryService(),
  )
  
  # Create session
  session = await session_service.create_session(
      app_name='test_app',
      user_id='test_user'
  )
  
  events = []
  async for event in runner.run_async(
      user_id=session.user_id,
      session_id=session.id,
      new_message=types.Content(
          role='user',
          parts=[types.Part.from_text(text='test')]
      )
  ):
    events.append(event)
  
  for i, event in enumerate(events):
    print(f"Event {i + 1}:")
    print(f"  Author: {event.author}")
    print(f"  Content: {event.content}")
    if event.actions and event.actions.transfer_to_agent:
      print(f"  Transfer to: {event.actions.transfer_to_agent}")
    print()
  
  # Check if transfer to sibling happened
  transfer_to_sibling_events = [
      e for e in events 
      if e.actions and e.actions.transfer_to_agent == 'sub_agent_2'
      and e.author == 'sub_agent_1'
  ]
  
  assert not transfer_to_sibling_events, "BUG: Transfer to sibling agent should be disallowed but occurred."


if __name__ == '__main__':
  import asyncio
  
  asyncio.run(test_disallow_transfer_to_peers_bypass())

Expected behavior
When disallow_transfer_to_peers=True is set on a sub-agent, the agent should not be able to transfer to sibling agents. The restriction should be enforced at runtime, not just in the prompt/enum constraint

There are a few alternatives of handling:

  • An error is raised when attempting to transfer to a disallowed agent (the same now happens if agent tries to transfer to non-existing agent)
  • No error is raised, the transfer target is explicitly changed from peer agent to parent agent

Desktop

  • OS: Windows 11 (WSL Ubuntu)
  • Python version: Python 3.11.10
  • ADK version: 1.20.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    a2a[Component] This issue is related a2a support inside ADK.

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions