Most agents fail in production because they execute side effects without checking. This guide shows you how to implement an autogen agent ask before acting pattern that pauses for human approval before any tool with consequences runs. You’ll use AutoGen’s function-calling with a wrapper that gates execution behind a console prompt, and you can drop this into an existing assistant in about fifty lines.
Step 1: Install dependencies and configure the model client
Install AutoGen (the classic 0.2.x line, which is stable for function-calling workflows) and set up a single LLM client. If you route through n4n.ai, the OpenAI-compatible endpoint gives you automatic fallback when a provider is rate-limited, so your autogen agent ask before acting loop stays responsive during provider degradation.
pip install pyautogen
from autogen import AssistantAgent, UserProxyAgent
llm_config = {
"config_list": [{
"model": "gpt-4o-mini",
"base_url": "https://api.n4n.ai/v1", # optional: any OpenAI-compatible gateway
"api_key": "YOUR_API_KEY",
}],
"temperature": 0,
}
Keep temperature at zero for tool-selection tasks. You want deterministic JSON argument extraction, not a creative model inventing parameters.
Step 2: Classify your tools by risk
An autogen agent ask before acting design starts with honest labeling of side effects. Read-only calls (lookup, compute, search) need no gate. Mutating calls (send money, delete record, POST to production) get a human in front of them.
Define two functions: one safe, one risky.
def get_account_balance(account_id: str) -> str:
# read-only stub
return f"Balance for {account_id}: $1,204.55"
def transfer_funds(from_id: str, to_id: str, amount: float) -> str:
# THIS writes to a ledger. Side effect lives here.
return f"Transferred ${amount:.2f} from {from_id} to {to_id}"
The risky function must not be called without a checkpoint. We enforce that in the next step. In a real system, keep the risky function in a module that never gets imported by the agent directly—only the gated wrapper is registered.
Step 3: Wrap risky tools with a console approval gate
Write a decorator that intercepts the call, prints the proposed arguments, and blocks execution unless the operator types y. This is the core of the autogen agent ask before acting pattern.
import inspect
def human_approval(func):
sig = inspect.signature(func)
def wrapper(*args, **kwargs):
bound = sig.bind(*args, **kwargs)
bound.apply_defaults()
call_desc = f"{func.__name__}({', '.join(f'{k}={v}' for k, v in bound.arguments.items())})"
answer = input(f"[HUMAN APPROVAL REQUIRED] {call_desc}\nApprove? [y/N] ")
if answer.strip().lower() != "y":
return f"Human denied: {call_desc}"
return func(*args, **kwargs)
return wrapper
gated_transfer = human_approval(transfer_funds)
Using inspect.signature makes the prompt readable instead of a raw args tuple. The wrapper returns a string on denial. AutoGen treats that string as the tool result and feeds it back to the model, so the agent can recover or explain rather than assuming success.
Step 4: Register tools with a UserProxyAgent
AutoGen executes functions through UserProxyAgent.function_map. Map the gated version, not the raw one. Set human_input_mode="NEVER" so the terminal isn’t flooded with chat prompts—the gate lives inside the tool.
assistant = AssistantAgent(
name="planner",
llm_config=llm_config,
system_message=(
"You are a banking ops assistant. Use get_account_balance for lookups. "
"Use transfer_funds only when the user explicitly requests a move of money. "
"Never guess arguments."
),
)
user_proxy = UserProxyAgent(
name="executor",
human_input_mode="NEVER",
code_execution_config=False,
max_consecutive_auto_reply=3,
function_map={
"get_account_balance": get_account_balance,
"transfer_funds": gated_transfer,
},
)
max_consecutive_auto_reply caps how many times the agent can loop on tool calls without a new human message, preventing a denied call from spiraling into retries.
Step 5: Instruct the assistant to propose, not impersonate
The system message already restricts the assistant. Reinforce it with a clear opening message so the LLM learns the boundary immediately.
user_proxy.initiate_chat(
assistant,
message="Check balance for ACC-123, then if I confirm, move $50 to ACC-999.",
)
When you later type move the money, the assistant should emit a transfer_funds call. It will not invent a success string because the function schema is the only way to satisfy the user’s request.
Step 6: Run the conversation and observe the gate
Start the script. The assistant calls get_account_balance immediately (no prompt). Then when you send a follow-up requesting the transfer, you’ll see:
[HUMAN APPROVAL REQUIRED] transfer_funds(from_id=ACC-123, to_id=ACC-999, amount=50.0)
Approve? [y/N]
Type n. The tool returns Human denied: ... and the assistant replies that the transfer was blocked. Type a new request after continuing the chat, type y, and the real function runs.
This interaction is the autogen agent ask before acting loop in its simplest form. The model proposed; the human disposed.
Step 7: Handle denial and keep the agent on track
A denied tool result is just text. The assistant may apologize or ask for clarification. To make the loop auditable, wrap the gate once more:
def gated_transfer_logged(*args, **kwargs):
result = gated_transfer(*args, **kwargs)
if result.startswith("Human denied"):
# emit metric, open ticket, or log to audit trail
print("AUDIT: transfer blocked by operator")
return result
Register gated_transfer_logged instead. The model still sees the denial string, so it won’t assume success. Never swallow the denial silently—the agent needs the feedback.
Step 8: Verify the autogen agent ask before acting behavior
Run the script and perform this end-to-end check:
- Launch the Python file.
- Confirm the balance prints without any prompt.
- Send
transfer $50 from ACC-123 to ACC-999. - At the approval prompt, type
n. Verify the assistant reports the block and no ledger change occurs. - Send
transfer $10 from ACC-123 to ACC-999and typey. Verify the success string appears.
If steps 4 and 5 behave as described, your autogen agent ask before acting implementation is correct. The agent proposed the action; the human controlled the side effect.
Why not just use human_input_mode=“ALWAYS”?
AutoGen’s human_input_mode="ALWAYS" prompts for free-text on every turn, but it does not block function execution cleanly—the user has to type “ignore the tool call” and the assistant may still have already run it depending on function_map wiring. Gating inside the tool gives you a single, typed checkpoint exactly at the mutation boundary. It also ports to non-terminal environments: replace input() with an async queue or a Slack confirm button and the rest of the agent code is unchanged.
Production considerations
The console gate is fine for a CLI, but in a service you’ll replace input() with an async approval queue. Keep the same signature: return a string on denial. AutoGen doesn’t care whether the human is a terminal or an SRE on call.
Add a timeout. If no approval arrives in 30 seconds, return a denial string. Otherwise the agent thread hangs. For multi-step plans, gate each mutating call independently; never batch approvals unless the user explicitly accepts a batch.
The autogen agent ask before acting pattern is not about slowing the model down. It’s about making the blast radius of a bad function call exactly one human decision.