Starphix
Agentic Engineering / Sources
The Lab / Sources / Agentic Engineering / MultiAgent/Delegation-Handoff.md

Delegation & Handoff

Patterns for transferring work between agents: task decomposition, context transfer, and seamless handoffs.


Delegation vs Handoff

Aspect Delegation Handoff
Initiator Manager/Orchestrator Current agent
Relationship Hierarchical Peer or hierarchical
Control Delegator retains oversight Control transfers
Return Expected (result back) May not return
Context Partial (task-specific) Full (conversation + state)

Delegation Patterns

1. Explicit Delegation (Manager-Initiated)

class DelegationManager:
    def __init__(self, agents: dict[str, Agent]):
        self.agents = agents

    async def delegate(self, task: Task, assignee: str, 
                      context: DelegationContext) -> Result:
        agent = self.agents[assignee]

        # Prepare delegation package
        delegation = DelegationPackage(
            task=task,
            context=context,
            delegator=self.manager_id,
            assignee=assignee,
            deadline=context.deadline,
            priority=context.priority,
            expected_output=context.expected_output,
            constraints=context.constraints
        )

        # Execute
        result = await agent.execute_delegation(delegation)

        # Validate
        if not self.validate_result(delegation, result):
            raise DelegationFailed("Result validation failed")

        return result

2. Capability-Based Delegation

class CapabilityBasedDelegation:
    def __init__(self, agents: dict[str, Agent]):
        self.agents = agents
        self.capability_index = self._build_capability_index()

    def _build_capability_index(self) -> dict[str, list[str]]:
        """Map capability → list of agent IDs"""
        index = defaultdict(list)
        for agent_id, agent in self.agents.items():
            for cap in agent.capabilities:
                index[cap].append(agent_id)
        return index

    def find_best_agent(self, required_capabilities: list[str]) -> str | None:
        candidates = set()
        for cap in required_capabilities:
            agents = set(self.capability_index.get(cap, []))
            if candidates:
                candidates &= agents
            else:
                candidates = agents

        if not candidates:
            return None

        # Select best (least loaded, highest skill)
        return min(candidates, key=lambda a: self.agents[a].current_load)

3. Recursive Delegation

class RecursiveDelegation:
    def __init__(self, agents: dict[str, Agent], max_depth: int = 5):
        self.agents = agents
        self.max_depth = max_depth

    async def delegate_recursive(self, task: Task, agent_id: str, 
                                 depth: int = 0) -> Result:
        if depth >= self.max_depth:
            raise MaxDelegationDepthExceeded()

        agent = self.agents[agent_id]

        # Agent decides: execute or sub-delegate
        decision = await agent.decide_delegation(task)

        if decision.action == "execute":
            return await agent.execute(task)

        elif decision.action == "delegate":
            # Sub-delegate to specialist
            subtask = decision.subtask
            specialist = decision.specialist
            return await self.delegate_recursive(subtask, specialist, depth + 1)

        elif decision.action == "decompose":
            # Break into parallel subtasks
            subtasks = decision.subtasks
            results = await asyncio.gather(*[
                self.delegate_recursive(st.task, st.assignee, depth + 1)
                for st in subtasks
            ])
            return self.merge_results(results)

Handoff Patterns

1. Context-Preserving Handoff

class HandoffPackage:
    def __init__(self, 
                 conversation_history: list[Message],
                 current_state: AgentState,
                 active_tools: list[Tool],
                 active_goal: Goal,
                 pending_actions: list[Action],
                 metadata: dict):
        self.conversation_history = conversation_history
        self.current_state = current_state
        self.active_tools = active_tools
        self.active_goal = active_goal
        self.pending_actions = pending_actions
        self.metadata = metadata
        self.handoff_id = str(uuid4())
        self.timestamp = datetime.utcnow()
        self.from_agent = None
        self.to_agent = None

class HandoffManager:
    def __init__(self, agents: dict[str, Agent]):
        self.agents = agents

    async def handoff(self, from_agent: str, to_agent: str, 
                      reason: HandoffReason) -> HandoffResult:
        from_agent_obj = self.agents[from_agent]
        to_agent_obj = self.agents[to_agent]

        # 1. Prepare handoff package
        package = await from_agent_obj.prepare_handoff(reason)
        package.from_agent = from_agent
        package.to_agent = to_agent

        # 2. Validate package completeness
        if not self.validate_package(package):
            raise IncompleteHandoffPackage()

        # 3. Execute handoff
        await to_agent_obj.receive_handoff(package)

        # 3. Confirm
        confirmation = await to_agent_obj.confirm_handoff(package.handoff_id)

        return HandoffResult(
            handoff_id=package.handoff_id,
            success=True,
            new_agent=to_agent,
            context_preserved=True
        )

2. Streaming Handoff (Live Transfer)

class StreamingHandoff:
    def __init__(self, from_agent: Agent, to_agent: Agent):
        self.from_agent = from_agent
        self.to_agent = to_agent
        self.stream_queue = asyncio.Queue()

    async def start_streaming_handoff(self):
        # 1. Start streaming state
        asyncio.create_task(self._stream_state())

        # 2. Transfer control
        await self.to_agent.prepare_for_handoff()

        # 3. Stream events
        await self.stream_queue.put(HandoffEvent.START)

        while not self.handoff_complete:
            event = await self.from_agent.get_next_event()
            await self.stream_queue.put(event)

            if event.type == "tool_result" and event.is_final:
                self.handoff_complete = True

        await self.stream_queue.put(HandoffEvent.COMPLETE)

    async def _stream_state(self):
        # Stream memory, context, tools incrementally
        for chunk in self.from_agent.stream_state(chunk_size=1000):
            await self.to_agent.receive_state_chunk(chunk)

3. Human-Initiated Handoff

class HumanHandoffManager:
    def __init__(self, agents: dict[str, Agent]):
        self.agents = agents

    async def request_human_handoff(self, agent_id: str, 
                                    reason: str, 
                                    urgency: Urgency) -> HandoffResult:
        agent = self.agents[agent_id]

        # 1. Prepare summary for human
        summary = await agent.prepare_human_summary()

        # 2. Notify human (Slack, email, dashboard)
        notification = HandoffNotification(
            agent_id=agent_id,
            reason=reason,
            urgency=urgency,
            summary=summary,
            conversation_link=f"/conversations/{agent.conversation_id}"
        )
        await self.notify_human(notification)

        # 3. Wait for human response
        response = await self.wait_for_human_response(
            timeout=300 if urgency == Urgency.NORMAL else 60
        )

        if response.action == "take_over":
            # Human takes over directly
            return await self.human_takes_over(agent, response)
        elif response.action == "reassign":
            # Reassign to another agent
            return await self.reassign_agent(agent, response.target_agent)
        else:
            # Continue with agent
            return HandoffResult(continued=True)

OpenAI Agents SDK Handoffs

Built-in Handoffs

from agents import Agent, Runner, handoff

# Define specialist agents
billing_agent = Agent(
    name="Billing Specialist",
    instructions="Handle billing inquiries, refunds, subscriptions.",
    tools=[refund_tool, subscription_tool]
)

technical_agent = Agent(
    name="Technical Support",
    instructions="Handle technical issues, bugs, integrations.",
    tools=[diagnose_tool, logs_tool]
)

# Triage agent with handoffs
triage_agent = Agent(
    name="Triage",
    instructions="Route to appropriate specialist.",
    handoffs=[billing_agent, technical_agent]
)

# Automatic handoff based on LLM decision
result = await Runner.run(triage_agent, "I was charged twice!")
# LLM decides: "This is a billing issue" → handoffs to billing_agent

Custom Handoff Configuration

from agents import handoff

# Custom handoff with input transformation
billing_handoff = handoff(
    agent=billing_agent,
    tool_name_override="transfer_to_billing",
    tool_description_override="Transfer to billing for refunds, charges, subscriptions",
    on_handoff=lambda ctx: log_handoff("billing", ctx)
)

# Input filter for handoff
def filter_billing_context(ctx):
    return {
        "issue": ctx.context.get("issue"),
        "account_id": ctx.context.get("account_id"),
        "amount": ctx.context.get("amount")
    }

billing_handoff = handoff(
    agent=billing_agent,
    input_filter=filter_billing_context
)

Handoff Patterns in SDK

# 1. Explicit handoff tool
result = await Runner.run(triage_agent, "Help with my bill")
# Agent calls: transfer_to_billing(issue="double charge", account_id="123")

# 2. Automatic handoff via instructions
# "If billing question, handoff to billing_agent"

# 3. Chained handoffs
# triage → billing → refund_specialist

# 4. Conditional handoff
# "If amount > $1000, handoff to senior_billing"

LangGraph Handoffs

Subgraph Handoffs

from langgraph.graph import StateGraph, START, END
from langgraph.graph import send

# Subgraph as specialist
research_subgraph = StateGraph(ResearchState)
research_subgraph.add_node("researcher", researcher_node)
research_subgraph.add_node("analyst", analyst_node)
research_subgraph.add_edge(START, "researcher")
research_subgraph.add_edge("researcher", "analyst")
research_subgraph.add_edge("analyst", END)
compiled_research = research_subgraph.compile()

# Main graph with handoff
main_graph = StateGraph(State)
main_graph.add_node("orchestrator", orchestrator_node)
main_graph.add_node("research_team", compiled_research)  # Handoff to subgraph
main_graph.add_node("writer", writer_node)

main_graph.add_edge(START, "orchestrator")
main_graph.add_conditional_edges("orchestrator", route_to_team)
main_graph.add_edge("research_team", "writer")
main_graph.add_edge("writer", END)

Explicit Handoff with send()

from langgraph.graph import send

def orchestrator_node(state):
    # Decide which team handles this
    if state["task_type"] == "research":
        # Handoff to research subgraph
        return send("research_team", {"topic": state["topic"], "depth": "deep"})
    elif state["task_type"] == "coding":
        return send("coding_team", {"spec": state["spec"]})
    else:
        return {"next": "writer"}

# Inter-agent communication via send()
def researcher_node(state):
    # Can send to another agent within graph
    if need_fact_check:
        return send("fact_checker", {"claim": state["claim"]})
    return {"findings": findings}

Handoff Best Practices

1. Context Preservation Checklist

  • [ ] Full conversation history (or compressed summary)
  • [ ] Current agent state (variables, memory, tools)
  • [ ] Active goal and sub-goals
  • [ ] Pending actions and tool calls
  • [ ] Tool configurations and auth state
  • [ ] User preferences and context
  • [ ] Error history and recovery state

2. Handoff Validation

def validate_handoff(package: HandoffPackage) -> ValidationResult:
    errors = []

    # Required fields
    if not package.conversation_history:
        errors.append("Missing conversation history")
    if not package.active_goal:
        errors.append("Missing active goal")
    if not package.current_state:
        errors.append("Missing agent state")

    # Context continuity
    if package.conversation_history:
        last_msg = package.conversation_history[-1]
        if last_msg.role == "assistant" and not last_msg.tool_calls:
            errors.append("Last message appears incomplete")

    # Tool continuity
    if package.active_tools:
        for tool in package.active_tools:
            if tool.requires_auth and not tool.auth_valid:
                errors.append(f"Tool {tool.name} auth expired")

    return ValidationResult(valid=len(errors) == 0, errors=errors)

3. Graceful Degradation

async def robust_handoff(from_agent: Agent, to_agent: Agent, 
                         max_retries: int = 3) -> HandoffResult:
    for attempt in range(max_retries):
        try:
            return await handoff_manager.handoff(from_agent, to_agent)
        except HandoffError as e:
            if attempt == max_retries - 1:
                raise

            # Degraded handoff: minimal context
            minimal_package = from_agent.prepare_minimal_handoff()
            await to_agent.receive_handoff(minimal_package)
            logger.warning(f"Degraded handoff on attempt {attempt + 1}")

    raise HandoffFailed("All handoff attempts failed")

Handoff Patterns in Frameworks

Framework Mechanism Features
OpenAI Agents SDK handoff() function Input filters, on_handoff callbacks, chaining
LangGraph send(), subgraphs Streaming, interrupts, human-in-loop
AutoGen initiate_chat(), GroupChat Nested chats, speaker selection
CrewAI Process delegation Sequential/hierarchical, manager agent
OpenAI Agents SDK Handoff tools Input filters, on_handoff hooks
PydanticAI Agent as tool Structured handoff with validation

Handoff Anti-Patterns

Anti-Pattern Problem Fix
Context loss New agent lacks history Preserve full context or summary
Tool mismatch New agent lacks tools Ensure tool parity or document gaps
Auth loss Tokens not transferred Include auth in handoff package
Goal drift New agent pursues different goal Explicit goal transfer
Infinite handoffs Ping-pong between agents Max handoff depth, circuit breaker
Silent failure Handoff fails silently Explicit confirmation, timeouts


Sources: OpenAI Agents SDK docs, LangGraph send() docs, AutoGen GroupChat, CrewAI processes, FIPA ACL handoff, delegation patterns, handoff patterns in microservices, human-agent interaction research.

From the Agentic Engineering research vault · CC BY 4.0 · all notes · raw markdown