Skip to main content

🔐 AzureAICommunity - Agent - Approval Middleware

Add a human-in-the-loop approval gate to AI agent tool calls with a single middleware registration.

License Python GitHub Repo GitHub Follow YouTube Channel LinkedIn

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

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Commit your changes (git commit -m 'Add my feature')
  4. Push to the branch (git push origin feature/my-feature)
  5. Open a Pull Request

👤 Author

Built and maintained by Vinoth Rajendran.


📄 License

MIT

Metadata

Release files for azureaicommunity-agent-approval 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for azureaicommunity-agent-approval 0.2.0
File Size Uploaded Provenance Actions
azureaicommunity_agent_approval-0.2.0.tar.gz 9.0 kB Without provenance Details for azureaicommunity_agent_approval-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for azureaicommunity-agent-approval 0.2.0
File Interpreter ABI Platform Actions
azureaicommunity_agent_approval-0.2.0-py3-none-any.whl Python 3 none any Details for azureaicommunity_agent_approval-0.2.0-py3-none-any.whl

Total release size: 16.7 kB

Release files / azureaicommunity_agent_approval-0.2.0.tar.gz

Download URL azureaicommunity_agent_approval-0.2.0.tar.gz
Size 9.0 kB
Tags Source
SHA-256 checksum
How to use checksums
04a7ad3436bbd555b36338d9796139a9f4eb665c4a19e88328d389410507ae7d
BLAKE2b-256 checksum
How to use checksums
a8585270d639fe6a8136561c3a629978c06cb10105a34e677556f1dc711bee4f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.13

Release files / azureaicommunity_agent_approval-0.2.0-py3-none-any.whl

Download URL azureaicommunity_agent_approval-0.2.0-py3-none-any.whl
Size 7.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e6fb2f8a57d65dca5dad4070a5e6e6499062c86191d9fdef89f531644ea23d0f
BLAKE2b-256 checksum
How to use checksums
eaf6b95146a0ac6528da1ce09b82587b816fe3e1654164967adfb0ff9713a2f2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.13.13

Security for azureaicommunity-agent-approval 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page