5  Agent Handoff

One agent can’t do everything. Let’s enable routing to specialists.

Note

Code Reference: code/v0.2/src/agentsilex/

  • agent.py
  • runner.py

5.1 The Problem

A general assistant might need to handle:

  • Weather questions → Weather specialist
  • Math problems → Calculator specialist
  • Code questions → Coding specialist

Instead of one bloated agent with all tools, we use specialized sub-agents and route between them.

5.2 The Handoff Class

First, we wrap agents as handoff targets (agent.py):

HANDOFF_TOOL_PREFIX = "transfer_to_"


def as_valid_tool_name(name: str, prefix: str | None = None) -> str:
    """Sanitize agent name for use as tool name."""
    sanitized = re.sub(r"[^a-zA-Z0-9_.:+-]", "_", name)
    if sanitized and not re.match(r"^[a-zA-Z_]", sanitized):
        sanitized = "_" + sanitized
    if prefix:
        sanitized = prefix + sanitized
    return sanitized[:64] if sanitized else "_unnamed"


class Handoff:
    def __init__(self, agent: "Agent"):
        self.agent = agent

    @property
    def name(self):
        return as_valid_tool_name(self.agent.name, prefix="transfer_to_")

    @property
    def description(self):
        return f"Handoff to the {self.agent.name} agent to handle the request. {self.agent.instructions}"

    @property
    def parameters_specification(self):
        return {}

Key points:

  • Tool name is transfer_to_{agent_name}
  • Description includes the agent’s instructions (so LLM knows when to use it)
  • No parameters needed — just the handoff action

5.3 AgentHandoffs: Managing Handoff Targets

class AgentHandoffs:
    def __init__(self, handoffs: List["Agent"]):
        self.handoffs: List[Handoff] = [Handoff(agent) for agent in handoffs]
        self.registry: Dict[str, Handoff] = {
            handoff.name: handoff for handoff in self.handoffs
        }

    def get_specification(self):
        """Generate tool specs for all handoff agents."""
        spec = []
        for handoff in self.handoffs:
            spec.append({
                "type": "function",
                "function": {
                    "name": handoff.name,
                    "description": handoff.description,
                    "parameters": handoff.parameters_specification,
                },
            })
        return spec

    def handoff_agent(self, call_spec) -> Tuple["Agent", Dict]:
        """Execute handoff and return new agent + response message."""
        handoff = self.registry[call_spec.function.name]

        handoff_response = {
            "role": "tool",
            "tool_call_id": call_spec.id,
            "content": json.dumps({"assistant": handoff.agent.name}),
        }
        return handoff.agent, handoff_response

Similar pattern to ToolsSet:

  • Registry for lookup
  • get_specification() for LLM
  • handoff_agent() returns both the new agent and a tool response

5.4 Updated Agent Class

class Agent:
    def __init__(
        self,
        name: str,
        model: Any,
        instructions: str,
        tools: List[FunctionTool] | None = None,
        handoffs: List["Agent"] | None = None,  # NEW
    ):
        self.name = name
        self.model = model
        self.instructions = instructions
        self.tools = tools or []
        self.tools_set = ToolsSet(self.tools)
        self.handoffs = AgentHandoffs(handoffs or [])  # NEW

    def get_system_prompt(self):
        return {"role": "system", "content": self.instructions}

Agents can now have handoffs — a list of other agents they can transfer to.

5.5 Updated Runner

The Runner now handles both tools and handoffs:

class Runner:
    def __init__(self, session: Session):
        self.session = session

    def run(self, agent: Agent, prompt: str) -> RunResult:
        current_agent = agent  # Track which agent is active

        msg = user_msg(prompt)
        self.session.add_new_messages([msg])

        loop_count = 0
        should_stop = False
        while loop_count < 10 and not should_stop:
            dialogs = self.session.get_dialogs()

            # Combine regular tools + handoff tools
            tools_spec = (
                current_agent.tools_set.get_specification()
                + current_agent.handoffs.get_specification()
            )

            # System prompt depends on current agent
            complete_dialogs = [current_agent.get_system_prompt()] + dialogs
            response = completion(
                model=current_agent.model,
                messages=complete_dialogs,
                tools=tools_spec if tools_spec else None,
            )

            response_message = response.choices[0].message
            self.session.add_new_messages([response_message])

            if not response_message.tool_calls:
                should_stop = True
                return RunResult(final_output=response_message.content)

            # Execute regular function calls first
            tools_response = [
                current_agent.tools_set.execute_function_call(call_spec)
                for call_spec in response_message.tool_calls
                if not call_spec.function.name.startswith(HANDOFF_TOOL_PREFIX)
            ]
            self.session.add_new_messages(tools_response)

            # Then handle agent handoffs
            handoff_responses = [
                call_spec
                for call_spec in response_message.tool_calls
                if call_spec.function.name.startswith(HANDOFF_TOOL_PREFIX)
            ]
            if handoff_responses:
                # If multiple handoffs, pick the first one
                agent_spec = handoff_responses[0]
                current_agent, handoff_response = current_agent.handoffs.handoff_agent(
                    agent_spec
                )
                self.session.add_new_messages([handoff_response])

            loop_count += 1

        return RunResult(final_output="Error: Exceeded max iterations")

Key changes:

  1. current_agent tracking — Which agent is currently active
  2. Combined tool specs — Regular tools + handoff tools
  3. Two-phase processing — Execute tools first, then handle handoffs
  4. Agent switchcurrent_agent changes when handoff occurs

5.6 Example: Multi-Agent System

from agentsilex import Agent, Runner, Session, tool

# Specialist agents
@tool
def get_weather(city: str) -> str:
    """Get weather for a city."""
    return f"Weather in {city}: 72°F, sunny"

weather_agent = Agent(
    name="weather_specialist",
    model="gpt-4o",
    instructions="You are a weather expert. Always use the get_weather tool.",
    tools=[get_weather],
)

@tool
def calculate(expression: str) -> str:
    """Evaluate a math expression."""
    return str(eval(expression))

math_agent = Agent(
    name="math_specialist",
    model="gpt-4o",
    instructions="You are a math expert. Use the calculate tool for computations.",
    tools=[calculate],
)

# Main router agent
main_agent = Agent(
    name="assistant",
    model="gpt-4o",
    instructions="""You are a helpful assistant.
    - For weather questions, transfer to weather_specialist
    - For math questions, transfer to math_specialist
    - For other questions, answer directly""",
    handoffs=[weather_agent, math_agent],
)

# Run
session = Session()
runner = Runner(session)

result = runner.run(main_agent, "What's the weather in Tokyo?")
# Flow: main_agent → transfer_to_weather_specialist → weather_agent → get_weather → response

5.7 What the LLM Sees

When main_agent runs, it sees these tools:

[
  {
    "type": "function",
    "function": {
      "name": "transfer_to_weather_specialist",
      "description": "Handoff to the weather_specialist agent... You are a weather expert...",
      "parameters": {}
    }
  },
  {
    "type": "function",
    "function": {
      "name": "transfer_to_math_specialist",
      "description": "Handoff to the math_specialist agent... You are a math expert...",
      "parameters": {}
    }
  }
]

The LLM decides which specialist to call based on the user’s request.

5.8 Handoff Flow Diagram

sequenceDiagram
    participant U as User
    participant M as Main Agent
    participant W as Weather Agent
    participant T as get_weather Tool

    U->>M: "What's the weather in Tokyo?"
    M->>M: Decides: weather question
    M->>M: Calls transfer_to_weather_specialist
    Note over M: Handoff occurs
    M->>W: Control transferred
    W->>T: Calls get_weather("Tokyo")
    T->>W: "72°F, sunny"
    W->>U: "The weather in Tokyo is 72°F and sunny!"

5.9 Key Design Decisions

Decision Why
transfer_to_ prefix Easy to identify handoffs vs regular tools
Handoffs as tools Reuse existing tool infrastructure
First handoff wins Simplicity when multiple are called
System prompt per agent Each agent has its own personality
TipCheckpoint
cd code/v0.2

You now have multi-agent handoffs! Agents can route to specialists.