Skip to main content

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.

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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

azureaicommunity_agent_approval-0.2.0.tar.gz (9.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

File details

Details for the file azureaicommunity_agent_approval-0.2.0.tar.gz.

File metadata

File hashes

Hashes for azureaicommunity_agent_approval-0.2.0.tar.gz
Algorithm Hash digest
SHA256 04a7ad3436bbd555b36338d9796139a9f4eb665c4a19e88328d389410507ae7d
MD5 b54411de18a28fecadccfa799ea9a23f
BLAKE2b-256 a8585270d639fe6a8136561c3a629978c06cb10105a34e677556f1dc711bee4f

See more details on using hashes here.

File details

Details for the file azureaicommunity_agent_approval-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for azureaicommunity_agent_approval-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e6fb2f8a57d65dca5dad4070a5e6e6499062c86191d9fdef89f531644ea23d0f
MD5 968c08b8099b9aef96deaea0d2d4e533
BLAKE2b-256 eaf6b95146a0ac6528da1ce09b82587b816fe3e1654164967adfb0ff9713a2f2

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page