AutoGen’s strength is letting agents collaborate autonomously, but production systems need clear boundaries where human judgment takes over. Designing autogen agent escalation path design means deciding what triggers escalation, how context transfers to a human, and how the workflow resumes afterward. This guide walks through concrete patterns you can implement today.
Define your escalation triggers first
Before writing code, enumerate the conditions that should pause automation. Common triggers include:
- Confidence thresholds: An agent’s output falls below a calibrated score
- Policy violations: Content moderation flags, PII detection, or compliance rules
- Stalled progress: Round limits exceeded without resolution
- High-stakes actions: Financial transactions, data deletion, external API calls with side effects
- Explicit user request: The user types “talk to a human” or similar
Each trigger needs a corresponding escalation type so your routing logic knows which human or team receives the handoff. A billing dispute routes differently than a content moderation appeal.
from enum import Enum
from dataclasses import dataclass
from typing import Optional
class EscalationType(Enum):
LOW_CONFIDENCE = "low_confidence"
POLICY_VIOLATION = "policy_violation"
STALLED = "stalled"
HIGH_STAKES = "high_stakes"
USER_REQUESTED = "user_requested"
@dataclass
class EscalationContext:
type: EscalationType
trigger_details: str
conversation_history: list[dict]
agent_state: dict
suggested_next_action: Optional[str] = None
Build a dedicated escalation agent
Don’t scatter escalation logic across your worker agents. Create a specialized agent that monitors the conversation and decides when to escalate. This keeps your domain agents focused and makes the escalation policy auditable.
from autogen import AssistantAgent, UserProxyAgent
from autogen.agentchat import GroupChat, GroupChatManager
escalation_agent = AssistantAgent(
name="escalation_monitor",
system_message="""You monitor the conversation for escalation triggers.
Respond with a JSON object only:
{
"should_escalate": boolean,
"escalation_type": "low_confidence|policy_violation|stalled|high_stakes|user_requested",
"reason": "specific reason",
"suggested_action": "what the human should do"
}
Triggers:
- Any agent expresses uncertainty below 70% confidence
- Content flagged by moderation tools
- More than 10 rounds without resolution
- User explicitly requests human
- Financial/compliance actions pending""",
llm_config={"config_list": [{"model": "gpt-4", "api_key": "..."}]}
)
The monitor observes but doesn’t participate in the primary task. Wire it into a GroupChat with a custom speaker selection function:
def select_speaker(last_speaker: Agent, groupchat: GroupChat) -> Agent:
agents = [a for a in groupchat.agents if a != escalation_agent]
# Let escalation agent check every 3rd turn
if len(groupchat.messages) % 3 == 0:
return escalation_agent
# Default round-robin among workers
idx = agents.index(last_speaker) if last_speaker in agents else 0
return agents[(idx + 1) % len(agents)]
groupchat = GroupChat(
agents=[user_proxy, researcher, writer, reviewer, escalation_agent],
messages=[],
max_round=20,
speaker_selection_func=select_speaker
)
manager = GroupChatManager(groupchat=groupchat, llm_config=llm_config)
Structure the handoff payload
When escalation triggers, the human needs full context — not just the last message. Package everything required for a cold start: the original request, all agent reasoning, tool outputs, and the specific decision point.
import json
from datetime import datetime
def build_handoff_payload(escalation: EscalationContext, groupchat: GroupChat) -> dict:
return {
"escalation_id": f"esc_{datetime.utcnow().timestamp()}",
"timestamp": datetime.utcnow().isoformat(),
"type": escalation.type.value,
"trigger": escalation.trigger_details,
"original_request": groupchat.messages[0]["content"] if groupchat.messages else "",
"conversation_summary": summarize_for_human(groupchat.messages),
"full_history": [
{
"agent": msg.get("name", "unknown"),
"role": msg.get("role", "assistant"),
"content": msg.get("content", ""),
"tool_calls": msg.get("tool_calls", []),
"tool_results": msg.get("tool_results", [])
}
for msg in groupchat.messages
],
"current_state": {
"pending_decisions": extract_pending_decisions(groupchat.messages),
"collected_facts": extract_key_facts(groupchat.messages),
"conflicts": detect_conflicts(groupchat.messages)
},
"suggested_action": escalation.suggested_next_action
}
def summarize_for_human(messages: list[dict]) -> str:
# Use a cheap model to generate a 3-4 sentence summary
summary_prompt = f"Summarize this agent conversation in 3 sentences for a human taking over:\n{messages[-10:]}"
# ... call lightweight LLM
return summary_result
Implement the human interface
The handoff mechanism depends on your stack. Three common patterns:
Synchronous blocking (simple, low volume)
async def handle_escalation_sync(payload: dict) -> dict:
# Send to internal dashboard, Slack, PagerDuty, etc.
notification_id = await send_to_human_queue(payload)
# Block until human responds
response = await wait_for_human_response(notification_id, timeout=3600)
return response
Asynchronous with callback (scales better)
from typing import Callable, Awaitable
class EscalationManager:
def __init__(self):
self.pending: dict[str, asyncio.Future] = {}
async def escalate(self, payload: dict, callback: Callable[[dict], Awaitable[None]]) -> str:
esc_id = payload["escalation_id"]
future = asyncio.get_event_loop().create_future()
self.pending[esc_id] = future
await send_to_human_queue(payload) # Fire and forget
try:
human_response = await asyncio.wait_for(future, timeout=3600)
await callback(human_response)
return human_response
except asyncio.TimeoutError:
await callback({"action": "timeout", "resolution": "auto_closed"})
return {"action": "timeout"}
def resolve(self, esc_id: str, response: dict):
if esc_id in self.pending:
self.pending[esc_id].set_result(response)
del self.pending[esc_id]
Human-in-the-loop as an agent (AutoGen native)
AutoGen’s UserProxyAgent can serve as the human interface directly:
human_proxy = UserProxyAgent(
name="human_reviewer",
human_input_mode="ALWAYS", # or "TERMINATE" for escalation-only
code_execution_config=False,
system_message="You are a human reviewer. Review the escalation payload and respond with your decision."
)
# In your escalation handler:
async def escalate_to_human(payload: dict, groupchat: GroupChat):
# Inject payload as a message to the human agent
human_message = {
"role": "user",
"name": "system",
"content": f"ESCALATION REQUIRED:\n{json.dumps(payload, indent=2)}"
}
groupchat.messages.append(human_message)
# Resume group chat with human as next speaker
await manager.a_run_chat(
messages=groupchat.messages,
sender=human_proxy
)
Resume workflows cleanly
After human input, the workflow must continue without losing context. The key is treating the human response as just another message in the conversation history.
def resume_after_escalation(groupchat: GroupChat, human_response: dict, next_agent: Agent):
# Record human decision
groupchat.messages.append({
"role": "user",
"name": "human_reviewer",
"content": f"HUMAN DECISION: {human_response.get('decision', 'proceed')}\n"
f"REASONING: {human_response.get('reasoning', '')}\n"
f"INSTRUCTIONS: {human_response.get('instructions', 'Continue as planned')}"
})
# Optionally inject a system reminder
groupchat.messages.append({
"role": "system",
"name": "system",
"content": "The human has reviewed the escalation. Continue the workflow incorporating their guidance."
})
# Resume with the appropriate agent
return manager.resume(messages=groupchat.messages, sender=next_agent)
Common pitfalls
Pitfall: Escalation loops Agents escalate, human responds, agents immediately re-escalate on the same issue. Fix by adding a “human has ruled” marker to the context that agents must respect:
def add_human_ruling_marker(messages: list[dict], ruling: str) -> list[dict]:
return messages + [{
"role": "system",
"name": "system",
"content": f"HUMAN_RULING: {ruling}. Do not re-escalate this specific issue."
}]
Pitfall: Context loss on resume If you truncate history for token limits, you may drop the human’s decision. Always preserve escalation-related messages in any summarization or truncation logic.
Pitfall: No timeout handling A human never responds. The workflow hangs indefinitely. Implement escalation timeouts with sensible defaults — auto-approve low-risk items, auto-reject high-risk ones, or route to a backup reviewer.
ESCALATION_TIMEOUTS = {
EscalationType.LOW_CONFIDENCE: 3600, # 1 hour
EscalationType.POLICY_VIOLATION: 1800, # 30 min
EscalationType.HIGH_STAKES: 900, # 15 min
EscalationType.USER_REQUESTED: 7200, # 2 hours
EscalationType.STALLED: 1800,
}
Pitfall: Over-escalation If your confidence thresholds are too aggressive, humans drown in noise. Calibrate thresholds using historical data. Start conservative (escalate less) and measure false negative rate — cases where human review would have changed the outcome.
Observability you’ll need
Instrument every escalation with structured logs:
import structlog
logger = structlog.get_logger()
def log_escalation(payload: dict, outcome: dict, duration_seconds: float):
logger.info(
"escalation_completed",
escalation_id=payload["escalation_id"],
type=payload["type"],
trigger=payload["trigger"],
outcome=outcome.get("action"),
duration_seconds=duration_seconds,
human_reviewer=outcome.get("reviewer_id"),
auto_resolved=outcome.get("action") == "timeout"
)
Build a dashboard tracking:
- Escalation rate by type
- Mean time to human response
- Human override rate (how often humans change the agent’s path)
- False escalation rate (human says “proceed as planned”)
- Workflow completion rate post-escalation
Routing to the right human
Not all escalations go to the same queue. Route by type and context:
def route_escalation(payload: dict) -> str:
esc_type = payload["type"]
metadata = payload.get("current_state", {})
if esc_type == "policy_violation":
return "trust-safety-team"
elif esc_type == "high_stakes":
amount = metadata.get("transaction_amount", 0)
if amount > 10000:
return "finance-leads"
return "support-senior"
elif esc_type == "user_requested":
return "support-general"
elif esc_type == "low_confidence":
domain = metadata.get("domain", "general")
return f"subject-matter-{domain}"
else:
return "support-general"
Testing your escalation paths
Write integration tests that simulate the full escalation cycle:
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_escalation_on_low_confidence():
# Setup agents with mocked LLM that returns low confidence
with patch("autogen.AssistantAgent.generate_reply") as mock_reply:
mock_reply.return_value = json.dumps({
"confidence": 0.45,
"content": "I'm not sure about this recommendation"
})
result = await run_workflow("Analyze this complex contract")
assert result.escalated is True
assert result.escalation_type == EscalationType.LOW_CONFIDENCE
assert "human_reviewer" in result.participants
@pytest.mark.asyncio
async def test_human_override_respected():
# Simulate human rejecting an agent's plan
human_response = {
"decision": "reject",
"reasoning": "Legal risk too high",
"instructions": "Escalate to legal team instead"
}
result = await run_workflow_with_escalation(
trigger=EscalationType.HIGH_STAKES,
human_response=human_response
)
# Verify the workflow incorporates human guidance
final_messages = result.messages
legal_escalation = any(
"legal" in msg.get("content", "").lower()
for msg in final_messages
)
assert legal_escalation
Tradeoffs to acknowledge
| Approach | Pros | Cons |
|---|---|---|
| Synchronous blocking | Simple mental model, immediate consistency | Doesn’t scale, ties up resources |
| Async with callbacks | Scales, decouples human latency | Complex state management, eventual consistency |
| Human as UserProxyAgent | Native AutoGen, full conversation context | Human must be available in-band, harder to route |
| External queue (Slack/PagerDuty) | Familiar tools, mobile alerts, on-call rotation | Context switching, payload size limits |
For most teams, start with the UserProxyAgent approach for internal tools, and an external queue for customer-facing workflows. The external queue lets you integrate with existing on-call rotations and SLAs.
When to escalate vs. when to retry
Not every failure warrants human attention. Distinguish between:
- Transient failures: Rate limits, temporary network issues → retry with backoff
- Systematic failures: Model hallucination, tool misconfiguration → escalate
- Ambiguity: Genuine uncertainty requiring judgment → escalate
- Policy boundaries: Hard lines no agent should cross → escalate immediately
Build a retry policy into your agents before escalation enters the picture:
from tenacity import retry, stop_after_attempt, wait_exponential
@retry(
wait=wait_exponential(multiplier=1, min=2, max=30),
stop=stop_after_attempt(3),
retry=retry_if_exception_type(TransientError)
)
async def call_external_api(payload: dict) -> dict:
# ...
pass
Only escalate after retries exhaust. This keeps your human reviewers focused on judgment calls on flaky infrastructure.
The escalation path is a contract between your automation and your operators. Design it like an API: version it, document it, test it, and monitor it. When the pager goes off at 2 AM, the human on the other end should have everything they need to decide and move on — not a puzzle to reconstruct.