Skip to content

Human approval

Mark a tool requires_approval=True and the call will not run until someone approves it. The check lives in ToolHandler, outside the model: no prompt can talk its way past it.

"""Human-in-the-loop: a risky tool waits for an out-of-band approval event."""

import asyncio

from aaa.algos import ToolLoop
from aaa.llm import FakeLLMClient, LLMHandler
from aaa.llm.fake import text, tool_call
from aaa.runtime import Engine
from aaa.tools import ToolHandler, ToolRegistry, tool


@tool(requires_approval=True)  # enforced by ToolHandler, outside the model's control
def wire_money(amount: int, to: str) -> str:
    """Send money."""
    return f"sent ${amount} to {to}"


def model(request):
    last = request.messages[-1]
    if last.role == "user":
        return tool_call("wire_money", amount=250, to="ACME")
    return text(last.content)


async def main() -> None:
    registry = ToolRegistry([wire_money])
    async with Engine() as engine:
        LLMHandler(FakeLLMClient(model)).attach(engine)
        ToolHandler(registry).attach(engine)
        run = await engine.start(ToolLoop(registry), {"task": "Pay ACME $250"})

        async def pending():  # in a real app this is a webhook / UI / Slack button
            while True:
                for e in await engine.store.read(run.run_id):
                    if e.type == "aaa.approval.requested":
                        return e
                await asyncio.sleep(0.02)

        request = await asyncio.wait_for(pending(), 5)
        print("waiting for approval of:", request.data["name"], request.data["arguments"])
        await run.signal("aaa.approval.granted", {"call_id": request.data["call_id"]})
        # or: await run.signal("aaa.approval.denied", {"call_id": ..., "reason": "too large"})
        outcome = await run.result()
    print(outcome.value["text"])


asyncio.run(main())
waiting for approval of: wire_money {'amount': 250, 'to': 'ACME'}
sent $250 to ACME

The protocol

sequenceDiagram
    participant M as Model
    participant TH as ToolHandler
    participant L as Log
    participant H as Human / UI
    M->>L: aaa.tool.requested (wire_money)
    TH->>L: aaa.approval.requested {call_id, name, arguments}
    Note over TH,L: nothing runs. The run simply waits.
    H->>L: aaa.approval.granted {call_id}  (via run.signal)
    TH->>L: aaa.tool.started, then aaa.tool.completed
Event Payload Who emits it
aaa.approval.requested call_id, name, arguments ToolHandler, automatically
aaa.approval.granted call_id you: await run.signal("aaa.approval.granted", {"call_id": ...})
aaa.approval.denied call_id, optional reason you

A denial becomes a tool.failed with error.type == "ApprovalDenied" and your reason, which the model reads and can react to (explain, choose another route, give up).

Things worth knowing

  • Waiting is free and durable. A pending approval is just events in the log. The run can wait hours, survive restarts, and be approved from a different process or machine.
  • Approve the call, not the run. Each approval names a call_id, and the arguments you reviewed are in the request event; the tool runs with exactly those arguments.
  • Duplicates are harmless. Granting twice, or granting after the call already finished, does nothing.
  • A run deadline still applies. Use Budget(deadline_s=...) to bound how long an unattended approval may block.
  • Finding pending approvals across runs: scan for runs with an aaa.approval.requested and no matching result, or subscribe a handler to aaa.approval.requested that posts to Slack, a ticket queue or your UI.
async def notify(event):  # a handler that pings a human
    await slack.post(
        f"Approve {event.data['name']}({event.data['arguments']}) for run {event.run_id}?"
    )


engine.subscribe(
    "aaa.approval.requested", notify, group="acme.notify"
)  # one notifier per cluster, not per worker

Authorization is yours

Anything that can append an aaa.approval.granted event to a run's stream can approve a call. AAA does not authenticate the sender, because signals are plain events. Put an authorization check in whatever service exposes "approve" to people, and restrict who has write access to the event store. See Security.

Approval composes with the execution guard: an approved call is still an exclusive, numbered attempt.