This autogen human in the loop tutorial agent approvals walks through building a guarded AutoGen system where an agent proposes an action and a human must explicitly approve before any side effect occurs. We’ll use pyautogen’s UserProxyAgent and a wrapped tool to enforce sign-off, then show how to tighten the loop with human_input_mode. The pattern is minimal, runnable, and maps directly to refund, email, or database-mutation workflows.
Prerequisites
- Python 3.10 or newer
pip install pyautogen==0.2.32(recent 0.2.x works)- An OpenAI-compatible LLM endpoint. Export
OPENAI_API_KEY, and optionallyOPENAI_BASE_URLif you use a gateway.
python -m venv .venv && source .venv/bin/activate
pip install pyautogen==0.2.32
export OPENAI_API_KEY=sk-...
# export OPENAI_BASE_URL=https://your-endpoint/v1
The approval problem
Agents that can call tools will eventually try to do something irreversible—refunds, emails, DB deletes. You need a checkpoint. AutoGen’s stable 0.2 line does not ship a built-in “approve this tool call” flag, but the primitive is simple: make the tool itself block on human input, or force the UserProxyAgent to surface the proposal to a human before execution.
Step 1: Write a guarded tool
Define the side-effecting function. Inside, prompt for confirmation synchronously. This keeps approval logic next to the risky operation.
def process_refund(amount: float, user_id: str) -> str:
"""Issue a refund. Blocks for human approval."""
confirm = input(f"Approve refund of ${amount:.2f} to {user_id}? [y/N]: ").strip().lower()
if confirm != "y":
return "Refund cancelled by human."
# Stub for actual payment API call
return f"Refunded ${amount:.2f} to {user_id}."
The function signature and docstring matter: AutoGen’s assistant uses the docstring to build the JSON schema for tool calling. Keep the docstring explicit about side effects.
Step 2: Configure the agents
Create an AssistantAgent that knows about the tool and a UserProxyAgent that maps the function name to our guarded implementation.
import os
from autogen import AssistantAgent, UserProxyAgent
llm_config = {
"model": "gpt-4o-mini",
"api_key": os.environ["OPENAI_API_KEY"],
"base_url": os.environ.get("OPENAI_BASE_URL"),
}
assistant = AssistantAgent(
name="support_agent",
llm_config=llm_config,
system_message=(
"You handle customer requests. When a refund is justified, "
"call process_refund with amount and user_id. Do not invent user ids."
),
)
user_proxy = UserProxyAgent(
name="executor",
human_input_mode="NEVER", # approval handled inside the tool
code_execution_config=False,
function_map={"process_refund": process_refund},
)
human_input_mode="NEVER" means the proxy won’t interject on every message; the only pause is inside process_refund.
Step 3: Run the chat
Kick off the conversation with a customer request.
user_proxy.initiate_chat(
assistant,
message="Customer #42 says the item arrived broken. Refund $30.",
)
Expected output (first checkpoint)
The assistant emits a function call. Your terminal blocks on the input prompt:
support_agent (to executor):
call process_refund({"amount": 30, "user_id": "42"})
Approve refund of $30.00 to 42? [y/N]:
Type y and you’ll see:
Refunded $30.00 to 42.
Type anything else and the tool returns Refund cancelled by human. The assistant receives that string and can reply to the customer accordingly.
Step 4: Strict mode with human_input_mode
If you want a human to see the raw assistant proposal before any tool mapping, set human_input_mode="ALWAYS". The proxy prints each assistant message and waits for you to press enter or type feedback.
user_proxy = UserProxyAgent(
name="executor",
human_input_mode="ALWAYS",
code_execution_config=False,
function_map={"process_refund": process_refund},
)
Now every assistant turn pauses:
support_agent (to executor):
I'll refund $30 to customer 42 via process_refund.
Please give feedback (or press enter to continue):
This is heavier but useful when the assistant might call multiple tools and you want to veto before any run.
Step 5: Logging approvals
In production, input() isn’t auditable. Replace it with a function that writes to your approval log and pulls from a secure admin console. Sketch:
import json, time
def approve_action(action: dict) -> bool:
record = {"action": action, "ts": time.time(), "decision": None}
decision = input(f"Approve {json.dumps(action)}? [y/N]: ")
record["decision"] = decision == "y"
# TODO: persist record to your audit store
return record["decision"]
Wire this into process_refund and you have a compliance trail. Fail closed: if the approval service times out, return False.
Step 6: Guarding multiple tools
Real agents call more than one action. Register a second guarded tool the same way.
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email. Blocks for human approval."""
confirm = input(f"Send email to {to}? [y/N]: ").strip().lower()
if confirm != "y":
return "Email cancelled."
# SMTP send stub
return f"Sent email to {to}"
user_proxy = UserProxyAgent(
name="executor",
human_input_mode="NEVER",
code_execution_config=False,
function_map={
"process_refund": process_refund,
"send_email": send_email,
},
)
The assistant can call either; each blocks independently. Keep the docstrings distinct so the model doesn’t confuse them.
Full transcript example
With both tools registered and human_input_mode="NEVER", a typical run looks like:
support_agent (to executor):
call process_refund({"amount": 30, "user_id": "42"})
Approve refund of $30.00 to 42? [y/N]: y
Refunded $30.00 to 42.
support_agent (to executor):
call send_email({"to": "42@example.com", "subject": "Refund processed", "body": "Your $30 refund is done."})
Send email to 42@example.com? [y/N]: n
Email cancelled.
The assistant then tells the customer the refund is complete but email notification was skipped.
Why not just use human_input_mode=“ALWAYS”?
ALWAYS mode creates a prompt per assistant turn. For a 10-step plan that’s 10 interruptions, and humans start rubber-stamping. Embedding approval in the tool confines the pause to the exact side effect, which scales better. Use ALWAYS only when you need to review reasoning before any execution.
Production routing note
If you run these loops for hours, provider flakiness will bite. Pointing AutoGen at n4n.ai’s OpenAI-compatible endpoint gives you automatic fallback when a provider is rate-limited or degraded, while per-token metering keeps the approval-chain costs visible.
Common pitfalls
- Docstring drift: If the assistant’s view of the tool diverges from code, it will call with wrong args. Keep docstrings tight.
- Blocking the event loop:
input()blocks the main thread. For web services, use an async approval queue instead. - Silent approvals: Never default to “yes” on timeout in a guarded tool. Fail closed.
- Missing function_map: If the name in
function_mapdoesn’t match the assistant’s call exactly, AutoGen raises at execution.
Where to extend
Add a list_pending_approvals tool so a supervisor agent can batch-sign. Or wrap UserProxyAgent in a GroupChat with a dedicated auditor agent that reviews the transcript. The core pattern from this autogen human in the loop tutorial agent approvals stays the same: the side effect waits for a human, every time.