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())
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 theargumentsyou 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.requestedand no matching result, or subscribe a handler toaaa.approval.requestedthat 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.