diff --git a/src/google/adk/agents/base_agent.py b/src/google/adk/agents/base_agent.py index bdc10ac3a..573fcedf5 100644 --- a/src/google/adk/agents/base_agent.py +++ b/src/google/adk/agents/base_agent.py @@ -14,6 +14,7 @@ from __future__ import annotations +import copy import inspect from typing import Any from typing import AsyncGenerator @@ -121,6 +122,33 @@ class BaseAgent(BaseModel): response and appended to event history as agent response. """ + def clone(self, name: Optional[str] = None) -> BaseAgent: + """Creates a deep copy of this agent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new instance of the same agent class with identical configuration but with + no parent and cloned sub-agents. + """ + cloned_agent = copy.deepcopy(self) + + # Reset parent and clone sub-agents to avoid having the same agent object in + # the tree twice + cloned_agent.parent_agent = None + cloned_agent.sub_agents = [ + sub_agent.clone() for sub_agent in self.sub_agents + ] + + cloned_agent.name = name or f'{self.name}_clone' + + return cloned_agent + @final async def run_async( self, diff --git a/src/google/adk/agents/langgraph_agent.py b/src/google/adk/agents/langgraph_agent.py index f07b203fa..cf0c6a9b6 100644 --- a/src/google/adk/agents/langgraph_agent.py +++ b/src/google/adk/agents/langgraph_agent.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from __future__ import annotations + from typing import AsyncGenerator +from typing import cast +from typing import Optional from typing import Union from google.genai import types @@ -59,6 +63,23 @@ class LangGraphAgent(BaseAgent): instruction: str = '' + @override + def clone(self, name: Optional[str] = None) -> 'LangGraphAgent': + """Creates a deep copy of this LangGraphAgent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new LangGraphAgent instance with identical configuration but with + no parent and cloned sub-agents. + """ + return cast(LangGraphAgent, super().clone(name)) + @override async def _run_async_impl( self, diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index a5c859e26..4ad0f4c4f 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -20,6 +20,7 @@ from typing import AsyncGenerator from typing import Awaitable from typing import Callable +from typing import cast from typing import Literal from typing import Optional from typing import Union @@ -516,5 +517,21 @@ def __validate_generate_content_config( ) return generate_content_config + @override + def clone(self, name: Optional[str] = None) -> LlmAgent: + """Creates a deep copy of this LlmAgent instance. + + The cloned agent will have no parent and no sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new LlmAgent instance with identical configuration but no parent or sub-agents. + """ + return cast(LlmAgent, super().clone(name)) + Agent: TypeAlias = LlmAgent diff --git a/src/google/adk/agents/loop_agent.py b/src/google/adk/agents/loop_agent.py index 219e0c22f..0d66f2f48 100644 --- a/src/google/adk/agents/loop_agent.py +++ b/src/google/adk/agents/loop_agent.py @@ -17,6 +17,7 @@ from __future__ import annotations from typing import AsyncGenerator +from typing import cast from typing import Optional from typing_extensions import override @@ -40,6 +41,23 @@ class LoopAgent(BaseAgent): escalates. """ + @override + def clone(self, name: Optional[str] = None) -> LoopAgent: + """Creates a deep copy of this LoopAgent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new LoopAgent instance with identical configuration but with + no parent and cloned sub-agents. + """ + return cast(LoopAgent, super().clone(name)) + @override async def _run_async_impl( self, ctx: InvocationContext diff --git a/src/google/adk/agents/parallel_agent.py b/src/google/adk/agents/parallel_agent.py index 427128cec..1bb802da1 100644 --- a/src/google/adk/agents/parallel_agent.py +++ b/src/google/adk/agents/parallel_agent.py @@ -18,6 +18,8 @@ import asyncio from typing import AsyncGenerator +from typing import cast +from typing import Optional from typing_extensions import override @@ -92,6 +94,23 @@ class ParallelAgent(BaseAgent): - Generating multiple responses for review by a subsequent evaluation agent. """ + @override + def clone(self, name: Optional[str] = None) -> ParallelAgent: + """Creates a deep copy of this ParallelAgent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new ParallelAgent instance with identical configuration but with + no parent and cloned sub-agents. + """ + return cast(ParallelAgent, super().clone(name)) + @override async def _run_async_impl( self, ctx: InvocationContext diff --git a/src/google/adk/agents/remote_a2a_agent.py b/src/google/adk/agents/remote_a2a_agent.py index b9f765576..b18d67744 100644 --- a/src/google/adk/agents/remote_a2a_agent.py +++ b/src/google/adk/agents/remote_a2a_agent.py @@ -14,12 +14,20 @@ from __future__ import annotations +import asyncio +import copy import json import logging +import os from pathlib import Path +import time from typing import Any from typing import AsyncGenerator +from typing import Awaitable +from typing import Callable +from typing import cast from typing import Optional +from typing import TYPE_CHECKING from typing import Union from urllib.parse import urlparse import uuid @@ -48,6 +56,7 @@ from google.genai import types as genai_types import httpx +from typing_extensions import override from ..a2a.converters.event_converter import convert_a2a_message_to_event from ..a2a.converters.event_converter import convert_a2a_task_to_event @@ -151,6 +160,23 @@ def __init__( f"got {type(agent_card)}" ) + @override + def clone(self, name: Optional[str] = None) -> "RemoteA2aAgent": + """Creates a deep copy of this RemoteA2aAgent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new RemoteA2aAgent instance with identical configuration but with + no parent and cloned sub-agents. + """ + return cast(RemoteA2aAgent, super().clone(name)) + async def _ensure_httpx_client(self) -> httpx.AsyncClient: """Ensure HTTP client is available and properly configured.""" if not self._httpx_client: diff --git a/src/google/adk/agents/sequential_agent.py b/src/google/adk/agents/sequential_agent.py index 845dd5ac1..04daefda5 100644 --- a/src/google/adk/agents/sequential_agent.py +++ b/src/google/adk/agents/sequential_agent.py @@ -17,6 +17,8 @@ from __future__ import annotations from typing import AsyncGenerator +from typing import cast +from typing import Optional from typing_extensions import override @@ -29,6 +31,23 @@ class SequentialAgent(BaseAgent): """A shell agent that runs its sub-agents in sequence.""" + @override + def clone(self, name: Optional[str] = None) -> SequentialAgent: + """Creates a deep copy of this SequentialAgent instance. + + The cloned agent will have no parent and cloned sub-agents to avoid the restriction + where an agent can only be a sub-agent once. + + Args: + name: Optional new name for the cloned agent. If not provided, the original + name will be used with a suffix to ensure uniqueness. + + Returns: + A new SequentialAgent instance with identical configuration but with + no parent and cloned sub-agents. + """ + return cast(SequentialAgent, super().clone(name)) + @override async def _run_async_impl( self, ctx: InvocationContext diff --git a/tests/unittests/agents/test_agent_clone.py b/tests/unittests/agents/test_agent_clone.py new file mode 100644 index 000000000..7fc15d917 --- /dev/null +++ b/tests/unittests/agents/test_agent_clone.py @@ -0,0 +1,156 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Testings for the clone functionality of agents.""" + +from google.adk.agents.llm_agent import LlmAgent +from google.adk.agents.loop_agent import LoopAgent +from google.adk.agents.parallel_agent import ParallelAgent +from google.adk.agents.sequential_agent import SequentialAgent + + +def test_llm_agent_clone(): + """Test cloning an LLM agent.""" + # Create an LLM agent + original = LlmAgent( + name="llm_agent", + description="An LLM agent", + instruction="You are a helpful assistant.", + ) + + # Clone it + cloned = original.clone("cloned_llm_agent") + + # Verify the clone + assert cloned.name == "cloned_llm_agent" + assert cloned.description == "An LLM agent" + assert cloned.instruction == "You are a helpful assistant." + assert cloned.parent_agent is None + assert len(cloned.sub_agents) == 0 + assert isinstance(cloned, LlmAgent) + + # Verify the original is unchanged + assert original.name == "llm_agent" + assert original.instruction == "You are a helpful assistant." + + +def test_agent_with_sub_agents(): + """Test cloning an agent that has sub-agents.""" + # Create sub-agents + sub_agent1 = LlmAgent(name="sub_agent1", description="First sub-agent") + + sub_agent2 = LlmAgent(name="sub_agent2", description="Second sub-agent") + + # Create a parent agent with sub-agents + original = SequentialAgent( + name="parent_agent", + description="Parent agent with sub-agents", + sub_agents=[sub_agent1, sub_agent2], + ) + + # Clone it + cloned = original.clone("cloned_parent") + + # Verify the clone has no sub-agents + assert cloned.name == "cloned_parent" + assert cloned.description == "Parent agent with sub-agents" + assert cloned.parent_agent is None + assert len(cloned.sub_agents) == 2 + assert cloned.sub_agents[0].name == "sub_agent1_clone" + assert cloned.sub_agents[1].name == "sub_agent2_clone" + + # Verify the original still has sub-agents + assert original.name == "parent_agent" + assert len(original.sub_agents) == 2 + assert original.sub_agents[0].name == "sub_agent1" + assert original.sub_agents[1].name == "sub_agent2" + + +def test_multiple_clones(): + """Test creating multiple clones with automatic naming.""" + # Create multiple agents and clone each one + original = LlmAgent( + name="original_agent", description="Agent for multiple cloning" + ) + + # Test multiple clones from the same original + clone1 = original.clone() + clone2 = original.clone() + + assert clone1.name == "original_agent_clone" + assert clone2.name == "original_agent_clone" + assert clone1 is not clone2 + + +def test_clone_with_complex_configuration(): + """Test cloning an agent with complex configuration.""" + # Create an LLM agent with various configurations + original = LlmAgent( + name="complex_agent", + description="A complex agent with many settings", + instruction="You are a specialized assistant.", + global_instruction="Always be helpful and accurate.", + disallow_transfer_to_parent=True, + disallow_transfer_to_peers=True, + include_contents="none", + ) + + # Clone it + cloned = original.clone("complex_clone") + + # Verify all configurations are preserved + assert cloned.name == "complex_clone" + assert cloned.description == "A complex agent with many settings" + assert cloned.instruction == "You are a specialized assistant." + assert cloned.global_instruction == "Always be helpful and accurate." + assert cloned.disallow_transfer_to_parent is True + assert cloned.disallow_transfer_to_peers is True + assert cloned.include_contents == "none" + + # Verify parent and sub-agents are set + assert cloned.parent_agent is None + assert len(cloned.sub_agents) == 0 + + +def test_clone_without_name(): + """Test cloning without providing a name (should use default naming).""" + original = LlmAgent(name="test_agent", description="Test agent") + + cloned = original.clone() + + assert cloned.name == "test_agent_clone" + assert cloned.description == "Test agent" + + +def test_clone_preserves_agent_type(): + """Test that cloning preserves the specific agent type.""" + # Test LlmAgent + llm_original = LlmAgent(name="llm_test") + llm_cloned = llm_original.clone() + assert isinstance(llm_cloned, LlmAgent) + + # Test SequentialAgent + seq_original = SequentialAgent(name="seq_test") + seq_cloned = seq_original.clone() + assert isinstance(seq_cloned, SequentialAgent) + + # Test ParallelAgent + par_original = ParallelAgent(name="par_test") + par_cloned = par_original.clone() + assert isinstance(par_cloned, ParallelAgent) + + # Test LoopAgent + loop_original = LoopAgent(name="loop_test") + loop_cloned = loop_original.clone() + assert isinstance(loop_cloned, LoopAgent)