n4nAI

Add a human approval gate to AutoGen tool calls

Step-by-step guide to adding a human approval gate to AutoGen tool calls using a Python function wrapper, with runnable code examples and verification steps for safe agents.

n4n Team3 min read720 words

Audio narration

Coming soon — every post will get a voice note here.

Adding a human approval gate to autogen human approval tool calls is the difference between a demo and a system you can trust with production data. AutoGen’s default agent loop executes assistant-proposed functions as soon as they arrive; if the model hallucinates a drop_table call, it runs. This tutorial shows how to intercept those calls and require an explicit human sign-off before any side-effecting tool fires.

Step 1: Install dependencies and define the tools

Install the framework:

pip install pyautogen==0.2.32

Define two tools with real side effects. Keep them simple for the example.

# tools.py
def delete_user_record(user_id: str) -> str:
    # Pretend this hits a production DB.
    return f"Deleted record for {user_id}"

def post_to_slack(channel: str, message: str) -> str:
    # Pretend this calls Slack API.
    return f"Posted to {channel}: {message}"

These are the functions we will gate. Any autogen human approval tool calls pattern needs a clear boundary between read-only and mutating operations; start with the mutating ones.

Step 2: Configure the AssistantAgent with tool schemas

The assistant needs JSON schemas so it knows how to emit calls. Use llm_config pointing at an OpenAI-compatible endpoint. If you route through n4n.ai, you get one endpoint for 240+ models and automatic fallback when a provider is degraded, which matters when a human takes minutes to approve a step.

import autogen

llm_config = {
    "config_list": [{
        "model": "gpt-4o",
        "api_key": "sk-...",  # or via env
        "base_url": "https://api.n4n.ai/v1",  # optional gateway
    }],
    "temperature": 0,
}

assistant = autogen.AssistantAgent(
    name="assistant",
    llm_config=llm_config,
    functions=[
        {
            "name": "delete_user_record",
            "description": "Delete a user record from the database",
            "parameters": {
                "type": "object",
                "properties": {"user_id": {"type": "string"}},
                "required": ["user_id"],
            },
        },
        {
            "name": "post_to_slack",
            "description": "Post a message to a Slack channel",
            "parameters": {
                "type": "object",
                "properties": {
                    "channel": {"type": "string"},
                    "message": {"type": "string"},
                },
                "required": ["channel", "message"],
            },
        },
    ],
)

Step 3: Wrap tools with an approval gate

The cleanest way to enforce autogen human approval tool calls is to intercept at the function_map layer. The UserProxyAgent resolves a proposed call name to a Python callable; we replace that callable with a wrapper that prompts first.

def approval_gate(func):
    def wrapped(*args, **kwargs):
        print(f"\n🔧 Tool requested: {func.__name__}")
        print(f"   args: {args}")
        print(f"   kwargs: {kwargs}")
        resp = input("Approve execution? (y/n): ").strip().lower()
        if resp == "y":
            result = func(*args, **kwargs)
            print(f"   result: {result}")
            return result
        return f"Human denied execution of {func.__name__}."
    return wrapped

function_map = {
    "delete_user_record": approval_gate(delete_user_record),
    "post_to_slack": approval_gate(post_to_slack),
}

Now create the proxy. Set human_input_mode="NEVER" because our gate handles the human interaction; set code_execution_config=False to disable AutoGen’s default code executor.

user_proxy = autogen.UserProxyAgent(
    name="user_proxy",
    human_input_mode="NEVER",
    code_execution_config=False,
    function_map=function_map,
    max_consecutive_auto_reply=10,
)

Why not just use human_input_mode=“ALWAYS”?

AutoGen’s UserProxyAgent supports human_input_mode="ALWAYS", which prompts the user on every agent turn. It does not, however, distinguish between a tool call and a normal message, and it does not block execution of a function that is already in function_map unless you type a command. In practice the prompt appears after the tool runs if code_execution_config is set, which is too late. The wrapper approach gives you a precise, code-level checkpoint exactly before side effects. For autogen human approval tool calls, precision matters more than convenience.

Step 4: Run the conversation

Kick off a task that will trigger a tool call.

user_proxy.initiate_chat(
    assistant,
    message="Delete user ID abc123 and then post to #ops that cleanup is done.",
)

Sample terminal output:

🔧 Tool requested: delete_user_record
   args: ()
   kwargs: {'user_id': 'abc123'}
Approve execution? (y/n): y
   result: Deleted record for abc123

🔧 Tool requested: post_to_slack
   args: ()
   kwargs: {'channel': '#ops', 'message': 'cleanup is done'}
Approve execution? (y/n): n

The assistant receives the string Human denied execution of post_to_slack. and can adapt—maybe it apologizes or asks for a different channel. That loop is exactly the autogen human approval tool calls behavior we want.

Step 5: Swap the terminal prompt for a real UI

input() blocks the event loop and won’t work in a web service. For production, expose an approval queue. Below is a minimal asyncio sketch:

import asyncio

approval_queue = asyncio.Queue()

async def request_approval(tool_name, args, kwargs):
    # Push a pending request to a worker that renders UI and waits.
    future = asyncio.get_event_loop().create_future()
    await approval_queue.put({
        "tool": tool_name,
        "args": args,
        "kwargs": kwargs,
        "future": future,
    })
    return await future

def approval_gate_async(func):
    def wrapped(*args, **kwargs):
        # In a sync context, run a bounded event loop.
        result = asyncio.run(request_approval(func.__name__, args, kwargs))
        if result["approved"]:
            return func(*args, **kwargs)
        return f"Human denied execution of {func.__name__}."
    return wrapped

A pragmatic pattern: store pending calls in Redis with a UUID, render a button in your admin panel, and have the wrapper poll until resolved. The key point is that the gate is just a synchronization boundary; AutoGen doesn’t care whether the human is in a terminal or a browser.

Step 6: Verify the gate works

You need three observable outcomes to call this done:

  1. Denied calls never reach the underlying function. Add a print or side-effect counter inside delete_user_record. If you answer n, the counter must not increment.
  2. The assistant sees the denial. Check the next assistant message references the denial string. If it blindly retries, tighten the prompt or return a structured error.
  3. No silent auto-execution. Temporarily set function_map to the raw functions and confirm the behavior differs—this proves the wrapper is in the path.

Log every gate event with timestamps and the user who approved. That audit trail is non-negotiable for compliance.

Production considerations

  • Timeouts: if no human responds in 5 minutes, return a timeout error so the agent doesn’t hang.
  • Parallel calls: AutoGen may emit multiple tool calls in one message. Your wrapper must handle a list; prompt for each or batch them.
  • Model routing: when the human delay is long, the LLM context may grow. Using a gateway that honors provider cache-control hints keeps prompt cost predictable across the wait.
  • Escape hatch: provide a sudo mode for trusted scripts where the gate is bypassed via config, not code changes.

The pattern above is deliberately small. You can extend the wrapper to check role-based access, require two approvers for destructive tools, or integrate with Slack itself to approve Slack posts. The core invariant stays: autogen human approval tool calls are enforced at the function boundary, not by hoping the model behaves.

That’s the whole mechanism. Wire it in, test the denial path first, and ship.

Tagsautogenhuman-in-the-looptool-callingapprovals

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All autogen human-in-the-loop workflows posts →