Introduction & Overview

Welcome to another lesson about agentic patterns! In the previous lesson, you mastered orchestrating agents as tools, where a central planner agent could dynamically delegate tasks to specialist agents and receive their results back. Today, we're exploring a fundamentally different approach called the handoff pattern, where agents can completely transfer control to other specialized agents rather than just calling them as tools.

In this lesson, you'll extend the Agent class constructor to support handoff targets, create a handoff tool schema that enables control transfers, and implement the core handoff logic that cleanly passes conversation context between agents. We'll build a practical example with a general assistant that can hand off mathematical problems to a specialized calculator assistant, demonstrating how agents make intelligent decisions about when to transfer control versus handling tasks themselves.

Understanding the Handoff Pattern

The handoff pattern represents a different philosophy of agent collaboration compared to the tool delegation approach you learned previously. When an agent uses another agent as a tool, it's essentially asking for help while maintaining responsibility for the final response. When an agent performs a handoff, it's saying, "this other agent is better equipped to handle this entire conversation from here on."

Consider the difference in conversation flow. In tool delegation, the user interacts with the orchestrator throughout: the user asks a question, the orchestrator calls a specialist tool, receives the result, and then provides its own response incorporating that information. The user never directly interacts with the specialist agent.

In the handoff pattern, the conversation flow changes completely. The user starts by talking to one agent, but that agent recognizes that another agent should take over. The first agent transfers not just the task, but the entire conversation context to the specialist. From that point forward, the specialist agent is directly responding to the user, and the original agent is no longer involved. This pattern is particularly powerful when you have agents with very different capabilities or when the nature of a request clearly falls into one agent's domain of expertise.

Extending the Agent Class Constructor

To implement handoffs, we need to extend our existing Agent class with the ability to transfer control to other agents. This requires adding a new parameter to track available handoff targets and creating a special handoff tool that agents can use to transfer control.

class Agent:
    BASE_SYSTEM_PROMPT = (
        "You are an autonomous agent that can take multiple function-calling steps when helpful. "
        "The user only sees your response when you stop calling functions. "
        "Provide a complete, standalone answer when not calling functions.\n"
        "Additional instructions:\n"
    )

    def __init__(
        self,
        name,
        system_prompt="You are a helpful assistant.",
        model=DEFAULT_MODEL,
        tools=None,
        tool_schemas=None,
        handoffs=None,  # New parameter for handoff targets
        max_turns=15
    ):
        self.name = name
        self.model_name = model
        self.system_prompt = self.BASE_SYSTEM_PROMPT + system_prompt
        self.max_turns = max_turns

        # Avoid shared mutable defaults and protect against external mutation
        self.tools = {} if tools is None else dict(tools)
        self.tool_schemas = [] if tool_schemas is None else list(tool_schemas)
        self.handoffs = [] if handoffs is None else list(handoffs)  # List of agents for handoffs

The handoffs parameter accepts a list of other Agent instances to which this agent can transfer control. We store this as a list rather than a dictionary because agents are identified by their name attribute, and we want to maintain the flexibility to search through available agents dynamically.

Configure the API once at module level with genai.Client(), optionally passing http_options=types.HttpOptions(base_url=BASE_URL) for custom endpoints. Each request uses client.models.generate_content() with a types.GenerateContentConfig that includes tools and the system instruction.

With the constructor updated, we need to create the handoff tool schema that will enable agents to request control transfers.

Creating the Handoff Tool Schema

Next, we need to create a tool schema that allows the agent to request handoffs. This schema will be automatically added to the agent's available tools when handoff targets are provided.

# Define handoff tool schema
if self.handoffs:
    # Build description with available agents and when to use handoff
    available_agents = [agent.name for agent in self.handoffs]
    handoff_description = (
        "Transfer control to another specialized agent. "
        "ONLY use this for requests that require specialized tools or capabilities that the target agent has. "
        "Do NOT use handoff for general knowledge questions (geography, history, science facts, etc.) - answer those directly. "
        f"Available agents: {available_agents}"
    )
    
    handoff_schema_raw = {
        "name": "handoff",
        "description": handoff_description,
        "parameters": {
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": f"Name of the agent to handoff to. Must be one of: {available_agents}"
                },
                "reason": {
                    "type": "string",
                    "description": "Brief explanation of why this handoff is needed (e.g., 'requires mathematical calculation', 'needs specialized tool')"
                }
            },
            "required": ["name", "reason"]
        }
    }
    self.handoff_schema = handoff_schema_raw

The handoff schema includes two required parameters: the name of the target agent and a reason for the handoff. The reason parameter serves both as documentation for debugging and as a way to help the agent think through whether a handoff is truly necessary. Notice how we dynamically include the list of available agents in the description, helping the model understand which handoff options are available.

An important detail: use parameters (not input_schema) in function declarations. The Google Gen AI Python SDK accepts standard lowercase JSON Schema types (object, string, etc.).

Now we need to make this handoff schema available to the agent alongside its other tools.

Building and Converting Tool Schemas

To make handoffs work seamlessly, we need to convert all tool schemas to Gemini's format and include the handoff schema when handoff targets are available. Tool declarations are passed on each request through types.GenerateContentConfig(tools=...).

# Convert schemas: handle both Claude format (input_schema) and Gemini format (parameters)
# Map input_schema -> parameters when needed
gemini_schemas = []
for schema in self.tool_schemas:
    if "input_schema" in schema:
        gemini_schemas.append({
            "name": schema["name"],
            "description": schema["description"],
            "parameters": schema["input_schema"],
        })
    elif "parameters" in schema:
        gemini_schemas.append({
            "name": schema["name"],
            "description": schema["description"],
            "parameters": schema["parameters"],
        })

# Add handoff schema if it was created
if self.handoffs:
    gemini_schemas.append(self.handoff_schema)

# Store converted schemas
self.gemini_schemas = gemini_schemas

This conversion process happens once during initialization, transforming all schemas into Gemini's required format. The code handles both formats gracefully: schemas that use input_schema (common in other frameworks) are converted to use parameters, while schemas already using parameters are used as-is.

The converted schemas are stored in self.gemini_schemas and are passed on each request through _build_config(), which returns a types.GenerateContentConfig with the current tools and system instruction:

def _build_config(self):
    """Build GenerateContentConfig with current tools and system prompt"""
    tools_cfg = None
    if self.gemini_schemas:
        tools_cfg = [types.Tool(function_declarations=self.gemini_schemas)]
    return types.GenerateContentConfig(
        system_instruction=self.system_prompt,
        tools=tools_cfg,
    )

This approach ensures that every turn in the conversation uses a model instance configured with the correct tools and system instructions. With the handoff tool now available to agents, we need to implement the logic that actually performs the control transfer when this tool is called.

Implementing the Handoff Logic

The core of the handoff pattern lies in the call_handoff method, which handles the actual transfer of control from one agent to another. This method performs several critical operations:

def call_handoff(self, args, messages):
    """Execute a handoff to another agent"""
    # Extract the agent name from the handoff arguments
    agent_name = args.get("name")
    reason = args.get("reason", "No reason provided")
    
    print(f"🔄 Handoff to: {agent_name}")
    print(f"📝 Reason: {reason}")
    
    try:
        # Find the agent with the given name (raises StopIteration if not found)
        target_agent = next(agent for agent in self.handoffs if agent.name == agent_name)
        
        # Remove the last model message that contains the handoff function call
        # Convert messages back to input format for target agent
        clean_messages = []
        for msg in messages:
            if msg["role"] == "user":
                if isinstance(msg.get("content"), str):
                    clean_messages.append({"role": "user", "content": msg["content"]})
                else:
                    # Convert function response parts back to string format for simplicity
                    clean_messages.append({"role": "user", "content": "Tool execution results received"})
            elif msg["role"] == "model" or msg["role"] == "assistant":
                # Extract text from model parts
                text_content = self._extract_text_from_parts(msg.get("content", []))
                if text_content:
                    clean_messages.append({"role": "assistant", "content": text_content})
        
        # Remove the last message (the handoff call)
        if clean_messages and clean_messages[-1]["role"] == "assistant":
            clean_messages = clean_messages[:-1]
        
        # Handoff the control to the other agent
        return True, target_agent.run(clean_messages)
        
    except StopIteration:
        # Agent not found
        result = f"Handoff failed: Agent '{agent_name}' not found. Available agents: {[agent.name for agent in self.handoffs]}"
        print(f"❌ {result}")
        return False, result
    except Exception as e:
        # Any other error during handoff execution
        result = f"Handoff failed: Error during handoff to '{agent_name}': {str(e)}"
        print(f"❌ {result}")
        return False, result

The method executes the following steps in sequence:

  1. Parameter extraction: Gets the target agent's name and handoff reason from the function call arguments (received as a dictionary) for logging and agent lookup.

  2. Agent lookup: Uses next() with a generator expression to find the first agent in the handoffs list matching the requested name. The next() function raises StopIteration if no matching agent is found, which we catch to handle the "agent not found" scenario gracefully.

  3. Context cleaning: This is more complex with Gemini's parts-based message format. We iterate through messages, extracting text content from model responses using the _extract_text_from_parts helper method, and converting the conversation back to a simple format. We then remove the last assistant message containing the handoff call so the target agent receives a clean conversation history without seeing the internal handoff mechanics.

  4. Control transfer: Calls the target agent's run method with the cleaned messages, effectively transferring complete control of the conversation.

  5. Success return: Returns (True, target_agent_response) — the tuple return format (success_boolean, result_data) is crucial because it allows the main execution loop to distinguish between two fundamentally different outcomes: successful handoffs that should end the current agent's processing versus failed handoffs that should continue as normal tool interactions.

  6. Error handling: Catches StopIteration when no matching agent exists and returns (False, error_string) — unlike some frameworks that return structured tool result dictionaries, Gemini handoff failures return simple error strings that can be wrapped in function response parts by the calling code.

The _extract_text_from_parts helper method handles the complexity of Gemini's parts-based message format:

def _extract_text_from_parts(self, parts):
    """Helper to extract text from parts list"""
    if not isinstance(parts, list):
        return str(parts)
    return "".join(
        part.get("text", "") if isinstance(part, dict) else (part.text if hasattr(part, "text") else "")
        for part in parts
    )

Now we need to modify the main execution loop to handle handoff function calls differently from regular tools.

Integrating Handoffs into the Execution Flow

The main execution loop in the run method needs to detect handoff function calls and handle them differently from regular tools. When a handoff succeeds, it should immediately return the target agent's response rather than continuing the current agent's execution.

def run(self, input_messages):
    """Run the agent with input messages, handling tool calls and handoffs"""
    # Convert input messages to internal format
    messages = []
    for m in input_messages:
        if m["role"] == "user":
            if isinstance(m["content"], str):
                messages.append({
                    "role": "user",
                    "content": m["content"]
                })
            else:
                # Already in parts format
                messages.append({
                    "role": "user",
                    "content": m["content"]
                })
        elif m["role"] == "assistant" or m["role"] == "model":
            # Convert to parts format
            if isinstance(m["content"], str):
                messages.append({
                    "role": "model",
                    "content": [{"text": m["content"]}]
                })
            else:
                messages.append({
                    "role": "model",
                    "content": m["content"]
                })

    turn = 0
    while turn < self.max_turns:
        turn += 1
        
        response = client.models.generate_content(
            model=self.model_name,
            contents=self._build_contents(messages),
            config=self._build_config(),
        )
        
        # Add response to messages in parts format
        messages.append({
            "role": "model",
            "content": response.candidates[0].content.parts
        })

        # Check for function calls in the response using helper
        function_calls = list(self._iter_function_calls(response))
        
        # If there are function calls, process them
        if function_calls:
            tool_results = []
            
            # Iterate through function calls in the response
            for name, args in function_calls:
                # Check if this is a handoff function call
                if name == "handoff":
                    # Call handoff method
                    handoff_success, handoff_result = self.call_handoff(args, messages)
                    
                    # If handoff was successful, immediately return the result from the other agent
                    if handoff_success:
                        return handoff_result
                    
                    # If handoff failed, treat it as a regular function result
                    tool_results.append(self._function_response_part(name, handoff_result))
                else:
                    # Execute regular tools
                    result = self._call_tool(name, args)
                    tool_results.append(self._function_response_part(name, result))
            
            # Add all tool results to messages
            messages.append({
                "role": "user",
                "content": tool_results
            })
        else:
            # No function calls - extract and return text response
            response_text = self._extract_text(response)
            if response_text.strip():
                return messages, response_text
            else:
                raise Exception("Max turns reached - no final response")

    raise Exception("Max turns reached")

The key differences from other frameworks:

  • Function call detection: Instead of checking response.stop_reason == "tool_use", we use the _iter_function_calls helper method that extracts function calls from the response parts.
  • Iteration mechanism: We iterate over (name, args) tuples from the helper rather than iterating over content items and checking their type.
  • Core logic remains the same: Detect handoff → execute if successful → return immediately, or continue with tool results if failed.

The key insight here is that successful handoffs immediately return the target agent's response, bypassing the normal tool result processing. This is what makes handoffs different from tool calls: instead of collecting the result and continuing the conversation, a successful handoff ends the current agent's involvement and returns the target agent's complete response.

With all the handoff mechanics in place, let's create a complete example to test the system.

Setting Up the Agent System

Let's create a complete example that demonstrates how agents make intelligent handoff decisions. We'll set up a general assistant that can hand off mathematical problems to a specialized calculator assistant.

import json
from agent import Agent
from functions import (
    sum_numbers,
    multiply_numbers,
    subtract_numbers,
    divide_numbers,
    power,
    square_root
)

# Load tool schemas
with open('schemas.json', 'r') as f:
    tool_schemas = json.load(f)

# Math tools
math_tools = {
    "sum_numbers": sum_numbers,
    "multiply_numbers": multiply_numbers,
    "subtract_numbers": subtract_numbers,
    "divide_numbers": divide_numbers,
    "power": power,
    "square_root": square_root
}

# Create a calculator assistant
calculator_assistant = Agent(
    name="calculator_assistant",
    system_prompt=(
        "You are a calculator assistant specializing in mathematical calculations and solving equations. "
        "When solving equations:\n"
        "1. Show your work step-by-step\n"
        "2. Use the available math tools to perform calculations\n"
        "3. Verify your solution by substituting back into the original equation\n"
        "4. Provide a clear, detailed explanation of the solution process\n"
        "Always provide a complete answer with verification."
    ),
    tools=math_tools,
    tool_schemas=tool_schemas,
    handoffs=[]
)

# Create a general assistant (can handoff to calculator)
helpful_assistant = Agent(
    name="helpful_assistant",
    system_prompt="You are a helpful assistant. You can assist with various tasks and handoff to the calculator assistant for math problems.",
    tools={},
    tool_schemas=[],
    handoffs=[calculator_assistant]
)

Notice how we create the calculator assistant first without any handoffs, then create the general assistant with the calculator in its handoffs list. This creates a clear hierarchy where the general assistant can transfer control to the specialist, but not vice versa. Now let's test the system with different types of questions to see how it makes handoff decisions.

Testing General Knowledge Questions

Let's test the system with a general knowledge question to see how the agent decides whether to handle the task itself or perform a handoff.

# Example test messages
messages1 = [
    {
        'role': 'user', 
        'content': 'What is the capital of France?'
    }
]

print("\n=== Test 1: General Question ===")
# Call helpful_assistant.run with messages1 and print the response
result_messages1, response1 = helpful_assistant.run(messages1)
print(response1)

When we run this test, the general assistant recognizes that this is a straightforward factual question that doesn't require mathematical expertise:

Paris.

The agent handled this question directly without any handoffs or tool calls, demonstrating that it can distinguish between tasks it should handle itself and those requiring specialist expertise. Now let's test with a mathematical problem that should trigger a handoff to see the complete control transfer process in action.

Testing Mathematical Problem Handoffs

Now let's test with a mathematical problem that should trigger a handoff to demonstrate the complete control transfer process.

messages2 = [
    {
        'role': 'user', 
        'content': 'What is the solution to the equation x² - 5x + 6 = 0?'
    }
]

print("\n=== Test 2: Math Question (Should Handoff) ===")
# Call helpful_assistant.run with messages2 and print the response
result_messages2, response2 = helpful_assistant.run(messages2)
print(response2)

This test demonstrates the complete handoff process in action:

🔄 Handoff to: calculator_assistant
📝 Reason: The user asked to solve a quadratic equation, which involves equation solving.
🔧 Tool called: power({'base': -5, 'exponent': 2})
🔧 Tool called: subtract_numbers({'a': 25, 'b': 24})
🔧 Tool called: square_root({'number': 1})
🔧 Tool called: sum_numbers({'a': 5, 'b': 1})
🔧 Tool called: subtract_numbers({'a': 5, 'b': 1})
🔧 Tool called: divide_numbers({'a': 6, 'b': 2})

=== Final Response ===

Answer:
x = 2 or x = 3

The execution trace shows the complete handoff process: the general assistant recognized that this was a mathematical problem requiring specialist expertise, initiated a handoff to the calculator assistant with a clear reason, and then the calculator assistant took complete control of the conversation. The calculator assistant used its mathematical tools to solve the equation step by step and provided the final response directly to the user.

Note that the exact wording and tool usage patterns may vary between runs, as the model's responses can differ while maintaining similar mathematical reasoning. The key pattern to observe is the handoff decision, the tool usage for calculation, and the final answer format.

When to Use Agents as Tools vs Handoffs

Understanding when to apply each pattern is crucial for building effective agent systems.

Use agents as tools when you need an orchestrating agent to maintain control and synthesize multiple specialist inputs into a unified response. This works well for complex tasks requiring coordination across different domains, like planning a trip that involves flights, hotels, and restaurants.

Use handoffs when a specialist is clearly better equipped to handle the entire conversation from a certain point forward. This is ideal when the task falls entirely within one domain of expertise and the specialist can provide more value through direct interaction than filtered through an orchestrator.

The key question: Does the task require orchestration and synthesis, or does it need deep specialization with direct user interaction? Choose accordingly.

Best Practices and Common Pitfalls

When implementing handoffs, success depends heavily on designing clear decision boundaries and robust error handling. The most effective handoff systems define explicit criteria in agent prompts, helping agents make confident transfer decisions rather than hesitating between options. For example, your general assistant should know precisely when mathematical problems warrant a calculator handoff versus when they can provide basic arithmetic directly.

Key practices for reliable handoffs include:

  • Define clear handoff criteria in agent prompts so agents know exactly when to transfer control
  • Always clean conversation context by removing handoff function calls before transferring
  • Implement robust error handling for failed handoffs with graceful fallbacks
  • Use descriptive handoff reasons for debugging and system transparency
  • Design handoff chains with clear direction to avoid circular transfers

The biggest pitfall to avoid is creating circular handoffs where agents pass control back and forth indefinitely. Design your handoff chains with clear directionality and avoid giving agents too many transfer options, which can lead to decision paralysis. Remember that handoffs should feel like natural conversation flows, similar to being transferred to the right department in a well-organized company rather than bouncing between confused representatives.

Summary & Preparation for Practice

You've now mastered the handoff pattern, a powerful approach for building agent systems where specialists can take complete control of conversations when their expertise is needed. This pattern differs fundamentally from agent-as-tool delegation because it transfers not just the task, but the entire conversation ownership to the most appropriate agent.

In your upcoming practice exercises, you'll build multi-agent systems with complex handoff chains, where agents can intelligently route conversations through multiple specialists based on the evolving needs of each interaction. This foundation will enable you to create sophisticated agent ecosystems that can handle diverse, complex tasks while maintaining clear specialization and efficient resource utilization.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal