How to Implement Multi-Agent Workflows with Agent-to-Agent Delegation in openai-agents-python
Use the handoff() helper to expose one agent as a tool on another, then wire the Runner with nest_handoff_history=True to automatically transfer context and conversation state between agents.
The openai-agents-python SDK provides a first-class handoff mechanism for building multi-agent workflows. By treating agents as callable tools, you can implement complex delegation patterns such as triage → specialist → resolution while preserving conversation context across agent boundaries.
Core Handoff Concepts
The delegation system relies on several interconnected components defined in src/agents/handoffs/__init__.py and src/agents/handoffs/history.py:
Handoffdataclass (lines 42‑56): Describes the handoff tool, including the name, description, JSON schema, and the function that creates the target agent instance.handoff()helper (lines 88‑135): Validates input schemas, builds theHandoffobject, and wires the tool into the model’s tool list.HandoffInputData(lines 44‑71): A dataclass that carries the transcript, pre‑handoff items, new items, and optionalinput_filterresults to the next agent.nest_handoff_history(history.py lines 71‑104): Automatically injects a summarized conversation snippet into the next agent’s input, preventing token bloat while maintaining context.input_filter: An optional callable (lines 27‑34) that prunes or reshapes items before the receiving agent sees them, useful for privacy or deduplication.
Step 1: Define Specialized Agents
Create distinct Agent instances, each with its own tools and system instructions. In src/agents/agent.py, the Agent class accepts a tools list that can include both function tools and handoff tools.
# agents.py
from agents import Agent, function_tool
# -------------------------------------------------
# Triage agent – decides which specialist to call
# -------------------------------------------------
def triage_logic(request: str) -> str:
"""Simple rule‑based triage: keywords → specialist name."""
if "billing" in request.lower():
return "billing"
if "technical" in request.lower():
return "tech"
return "general"
triage = Agent(
name="triage",
description="Routes the user to the appropriate specialist.",
tools=[
function_tool(triage_logic, name="triage", description="Return the specialist name")
],
)
# -------------------------------------------------
# Billing specialist
# -------------------------------------------------
billing = Agent(
name="billing",
description="Handles billing‑related questions.",
tools=[],
)
# -------------------------------------------------
# Technical specialist
# -------------------------------------------------
technical = Agent(
name="technical",
description="Handles technical support queries.",
tools=[],
)
# -------------------------------------------------
# General fallback agent
# -------------------------------------------------
general = Agent(
name="general",
description="Catches all other requests.",
tools=[],
)
Step 2: Configure Agent-to-Agent Handoffs
Use the handoff() helper (defined in src/agents/handoffs/__init__.py lines 88‑135) to create delegation tools. Then attach these tools to the orchestrator agent (triage).
# handoffs.py
from agents import handoff
from agents import triage, billing, technical, general
# -------------- Billing handoff --------------
billing_handoff = handoff(
billing,
tool_name_override="transfer_to_billing",
nest_handoff_history=True, # include concise transcript summary
)
# -------------- Technical handoff --------------
tech_handoff = handoff(
technical,
tool_name_override="transfer_to_technical",
nest_handoff_history=True,
)
# -------------- General handoff (fallback) --------------
general_handoff = handoff(
general,
tool_name_override="transfer_to_general",
nest_handoff_history=True,
)
# Attach the handoffs as *tools* on the triage agent
triage.tools.extend([billing_handoff, tech_handoff, general_handoff])
When the LLM calls transfer_to_billing, the SDK executes Handoff._invoke_handoff, returns the target Agent object, and automatically prepares the next turn’s input using HandoffInputData.
Step 3: Execute the Multi-Agent Workflow
Instantiate Runner (from src/agents/run.py) with RunConfig to control history nesting and safety limits.
# run_workflow.py
from agents import Runner, RunConfig
from agents import triage # import the triage agent defined earlier
from handoffs import billing_handoff, tech_handoff, general_handoff
run_cfg = RunConfig(
nest_handoff_history=True, # Enable global history nesting
max_turns=10, # Prevent infinite loops
)
runner = Runner(agent=triage, config=run_cfg)
# Example user request that triggers the billing specialist
result = runner.run("I have a question about my last invoice.")
print("Final response:", result.output)
Execution flow:
- Turn 1:
triageanalyzes the request and generates a tool calltransfer_to_billing. - Turn 2: Runtime executes the handoff, invokes
nest_handoff_history()(fromsrc/agents/handoffs/history.pylines 71‑104) to summarize prior turns, and injects the summary into thebillingagent’s input. - Turn 3:
billingreceives the summarized transcript plus the original user request, runs its logic, and returns the final answer.
Advanced Customization Options
Conditional Handoff with is_enabled
Control visibility of handoff tools using the is_enabled parameter (handled in src/agents/handoffs/__init__.py lines 14‑23).
def billing_enabled(ctx, agent):
import datetime
now = datetime.datetime.utcnow().hour
return 9 <= now <= 17 # UTC business hours only
billing_handoff = handoff(
billing,
is_enabled=billing_enabled,
)
Input Filtering for Privacy
Use input_filter (defined in src/agents/handoffs/__init__.py lines 27‑34) to sanitize data before the next agent receives it.
def strip_sensitive_data(input_data):
filtered = tuple(
item for item in input_data.new_items
if not (hasattr(item, "content") and "ssn" in str(item.content))
)
return input_data.clone(new_items=filtered)
billing_handoff = handoff(
billing,
input_filter=strip_sensitive_data,
nest_handoff_history=True,
)
Custom History Summarization
Override the default mapper by passing a history_mapper callable to customize how conversation history is compressed.
def bullet_summary(transcript):
lines = [f"* {item['role']}: {item.get('content', '')}" for item in transcript]
summary = "\n".join(lines) or "(no previous turns)"
return [{
"role": "assistant",
"content": f"The conversation so far:\n{summary}",
}]
billing_handoff = handoff(
billing,
nest_handoff_history=True,
history_mapper=bullet_summary,
)
Summary
- Create separate agents by instantiating
Agentobjects with distinct tool sets and instructions. - Expose handoff tools using
handoff(target_agent, ...)and optionally overridetool_name_overridefor clearer LLM prompts. - Attach handoffs to the orchestrator agent (e.g., triage) by extending its
toolslist. - Preserve context by setting
nest_handoff_history=TrueinRunConfigor individual handoffs, which invokesnest_handoff_history()fromsrc/agents/handoffs/history.py. - Filter sensitive data by providing an
input_filtercallable that returns a clonedHandoffInputDatawith sanitized items. - Control availability using
is_enabledto dynamically hide handoff tools based on runtime conditions like time of day or user permissions. - Execute workflows by passing the entry agent to
RunnerwithRunConfig(max_turns=...)to prevent infinite loops.
Frequently Asked Questions
How does conversation history transfer between agents?
The SDK automatically manages history transfer through the nest_handoff_history function in src/agents/handoffs/history.py. When enabled, it summarizes prior conversation turns using default_handoff_history_mapper and injects the summary into the receiving agent's input wrapped in <CONVERSATION HISTORY> markers. This prevents token bloat while ensuring the specialist agent retains necessary context.
Can I prevent a handoff from appearing based on runtime conditions?
Yes. Pass an is_enabled parameter to the handoff() function—either a boolean or a callable receiving RunContextWrapper and the target Agent. If the callable returns False, the handoff tool is hidden from the LLM for that specific turn. This is implemented in src/agents/handoffs/__init__.py and enables time-based routing or feature-flagging specific agents.
What is the difference between input_filter and nest_handoff_history?
nest_handoff_history is a boolean flag that controls whether a summarized conversation snippet is prepended to the next agent's input using the history mapper. input_filter is an optional callable that receives the full HandoffInputData object—containing new_items, pre_handoff_items, and the transcript—and returns a modified version before the next agent receives it. Use input_filter to remove PII, deduplicate tool calls, or reshape payloads while keeping the full history intact in the session store.
How do I debug a multi-agent workflow?
Set max_turns in RunConfig to prevent infinite loops during development. Inspect the HandoffInputData objects inside your input_filter functions to verify exactly what content passes between agents. Enable detailed logging around the handoff() helper and Runner.run() calls. Finally, review the source implementation in src/agents/handoffs/__init__.py and src/agents/run.py to understand the exact orchestration logic and error handling paths.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →