Human-in-the-loop approval gate middleware for AI agent tool calls
Project description
๐ AzureAICommunity - Agent - Approval Middleware
Add a human-in-the-loop approval gate to AI agent tool calls with a single middleware registration.
Getting Started ยท Callback Contract ยท How It Works ยท Contributing
Overview
azureaicommunity-agent-approval adds an approval gate directly into the agent-framework function-invocation pipeline. You pass only the tools that need approval โ all other tools registered on the agent execute freely without interruption. Before any gated tool executes, your callback is called with the FunctionInvocationContext; you decide whether to approve or deny using any UI: console, desktop dialog, HTTP call to a remote approver, etc. The middleware itself contains no UI code.
This mirrors the pattern established by the C# ApprovalMiddleware in the AzureAICommunity Agent Framework.
โจ Features
| Feature | |
|---|---|
| ๐ฏ | Selective gating โ only the tools you specify are intercepted; others run freely |
| ๐ | Simple callback โ receives the full FunctionInvocationContext with tool name and arguments |
| ๐ฌ | LLM-aware denial โ when denied, a descriptive message is set as the tool result so the model can reason about the refusal |
| โ๏ธ | Custom denial message โ optional denial_message_factory for per-call denial text to the LLM |
| ๐ฅ๏ธ | UI agnostic โ use any approval UI: console, GUI, HTTP, webhooks |
| ๐ | Sync & async callbacks โ both synchronous and asynchronous callbacks are supported |
| ๐ | Composable โ stacks with other agent-framework middleware in the same pipeline |
๐ฆ Installation
pip install azureaicommunity-agent-approval
Or install directly from source:
cd AgentFramework/Python/Middleware/ApprovalMiddleware
pip install -e .
๐ Quick Start
import asyncio
from agent_framework import tool, FunctionInvocationContext
from agent_framework.openai import OpenAIChatCompletionClient
from approval_middleware import ApprovalMiddleware
@tool
def get_device_status(device_name: str) -> str:
"""Get the current status of a smart-home device. Does not change device state."""
return f"{device_name} is currently OFF."
@tool
def turn_on_device(device_name: str) -> str:
"""Turn on a smart-home device."""
return f"{device_name} is now ON."
@tool
def turn_off_device(device_name: str) -> str:
"""Turn off a smart-home device."""
return f"{device_name} is now OFF."
async def console_approve(context: FunctionInvocationContext) -> bool | None:
print(f"\n[Approval Required] Tool: {context.function.name}")
answer = input(" Allow? [y/N] ").strip().lower()
return answer == "y"
async def main():
client = OpenAIChatCompletionClient(model="gpt-4o", api_key="...", base_url="...")
# Only the destructive tools are gated.
# get_device_status is intentionally omitted โ it runs freely without prompting.
def denial_factory(context: FunctionInvocationContext) -> str:
args = context.arguments or {}
device = (args.get("device_name", "device") if hasattr(args, "get")
else getattr(args, "device_name", "device"))
if context.function.name == "turn_on_device":
return f"User refused to turn ON '{device}'. Do not retry โ suggest an alternative."
if context.function.name == "turn_off_device":
return f"User refused to turn OFF '{device}'. Do not retry โ ask what else they want."
return f"User refused '{context.function.name}'. Do not retry this action."
middleware = ApprovalMiddleware(
approval_tools=["turn_on_device", "turn_off_device"],
approval_callback=console_approve,
denial_message_factory=denial_factory,
)
agent = client.as_agent(
name="HomeAssistant",
instructions="You are a helpful smart-home assistant.",
tools=[get_device_status, turn_on_device, turn_off_device],
middleware=[middleware],
)
response = await agent.run(
"Check the living room lights, then turn them on and turn off the bedroom fan."
)
print(response.text)
asyncio.run(main())
๐ Callback Contract
The approval_callback receives the full FunctionInvocationContext.
| Return value | Effect |
|---|---|
True |
Approved โ the tool executes and its real result is returned to the LLM |
False or None |
Denied โ a denial message is set as the tool result so the LLM can reason about the refusal |
The callback can be sync or async โ both are supported automatically:
# Async callback
async def my_callback(context: FunctionInvocationContext) -> bool | None:
# context.function.name โ tool name
# context.arguments โ validated arguments (dict or Pydantic model)
return True # approve
return False # deny
return None # deny
# Sync callback
def my_callback(context: FunctionInvocationContext) -> bool | None:
return True
โ๏ธ Configuration
ApprovalMiddleware
| Parameter | Type | Default | Description |
|---|---|---|---|
approval_tools |
list[str] |
required | Tool names that require approval before execution |
approval_callback |
Callable[[FunctionInvocationContext], bool | None] |
required | Sync or async callback invoked before each gated tool call |
denial_message |
str |
"User denied the call to '{tool_name}'." |
Template string for the denial result. Use {tool_name} as a placeholder |
denial_message_factory |
Callable[[FunctionInvocationContext], str] | None |
None |
Optional factory called on denial to produce per-call denial text. Takes precedence over denial_message when set |
denial_message_factory
When supplied, the factory is called with the FunctionInvocationContext of the denied call and its return value is used as the tool result sent to the LLM. This lets you return different text based on tool name or arguments:
def denial_factory(context: FunctionInvocationContext) -> str:
match context.function.name:
case "turn_on_device":
return f"User refused to turn ON. Do not retry."
case "turn_off_device":
return f"User refused to turn OFF. Do not retry."
case _:
return f"User refused '{context.function.name}'. Do not retry this action."
โ๏ธ How It Works
LLM decides to call a tool
โโโบ FunctionInvocationContext intercepted by ApprovalMiddleware
โ
โโ tool name in approval_tools?
โ โโ YES โ approval_callback(context) called
โ โ โโ returns True โ call_next() โ tool executes normally
โ โ โโ returns False/None โ denial_message_factory(ctx) or denial_message template
โ โ โโ context.result = denial text
โ โโ NO โ call_next() โ tool executes freely, no approval prompt
โ
โโ agent continues with the result
๐ค Contributing
Contributions are welcome! Please open an issue to discuss what you'd like to change before submitting a pull request.
๐ Repository: https://github.com/rvinothrajendran/AgentFramework
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -m 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Open a Pull Request
๐ค Author
Built and maintained by Vinoth Rajendran.
- ๐ GitHub: github.com/rvinothrajendran โ follow for more projects!
- ๐บ YouTube: youtube.com/@VinothRajendran โ subscribe for tutorials and demos!
- ๐ผ LinkedIn: linkedin.com/in/rvinothrajendran โ let's connect!
๐ License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file azureaicommunity_agent_approval-0.2.0.tar.gz.
File metadata
- Download URL: azureaicommunity_agent_approval-0.2.0.tar.gz
- Upload date:
- Size: 9.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
04a7ad3436bbd555b36338d9796139a9f4eb665c4a19e88328d389410507ae7d
|
|
| MD5 |
b54411de18a28fecadccfa799ea9a23f
|
|
| BLAKE2b-256 |
a8585270d639fe6a8136561c3a629978c06cb10105a34e677556f1dc711bee4f
|
File details
Details for the file azureaicommunity_agent_approval-0.2.0-py3-none-any.whl.
File metadata
- Download URL: azureaicommunity_agent_approval-0.2.0-py3-none-any.whl
- Upload date:
- Size: 7.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e6fb2f8a57d65dca5dad4070a5e6e6499062c86191d9fdef89f531644ea23d0f
|
|
| MD5 |
968c08b8099b9aef96deaea0d2d4e533
|
|
| BLAKE2b-256 |
eaf6b95146a0ac6528da1ce09b82587b816fe3e1654164967adfb0ff9713a2f2
|