Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions cookbook/05_agent_os/approvals/approval_multi_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""
Approval Multi-Tool (AgentOS web UI)
=====================================

Two @approval tools on a single agent. When the user asks for both actions in
the same message, OpenAI's Responses API batches them into one model turn and
the run pauses with **two** RunRequirements in a single pause event — agno
records this as one approval row whose `context.tool_names` lists both tools
and whose `requirements[]` carries each tool_execution.

Run this alongside os.agno.com to inspect the web UI's handling of the
multi-tool case. The cookbook serves AgentOS on port 7779; point os.agno.com
at a tunnel to this port (e.g. ngrok) and trigger:

"please delete /tmp/demo.txt and transfer $500 to account 42"

Expected web UI behaviour (per agno-os/src/pages/Approvals/utils/approvalHelpers.ts):
- card title : "delete_file, transfer_funds" (from context.tool_names)
- args section : one row per tool (from requirements[])
- buttons : single Approve / Reject pair (row-level resolution)

One click approves the whole row — both tools run. This is by design: agno
stamps a single approval_id on every paused tool and the web UI surfaces all
of them so the user can see exactly what they are authorizing.
"""

import os

from agno.agent import Agent
from agno.approval import approval
from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
from agno.os.app import AgentOS
from agno.tools import tool

DB_FILE = "tmp/approval_multi_tool.db"


@approval
@tool(requires_confirmation=True)
def delete_file(path: str) -> str:
"""Delete a file at the given path.

Args:
path (str): Absolute path to the file that should be deleted.

Returns:
str: Confirmation message.
"""
return f"Deleted {path}"


@approval
@tool(requires_confirmation=True)
def transfer_funds(account_id: str, amount_usd: float) -> str:
"""Transfer funds from the company account to another account.

Args:
account_id (str): Destination account identifier.
amount_usd (float): Amount to transfer in USD.

Returns:
str: Confirmation of the transfer.
"""
return f"Transferred ${amount_usd:.2f} to account {account_id}"


os.makedirs("tmp", exist_ok=True)

db = SqliteDb(
db_file=DB_FILE,
session_table="agent_sessions",
approvals_table="approvals",
)

agent = Agent(
name="Approval Multi-Tool Agent",
model=OpenAIResponses(id="gpt-5.4"),
tools=[delete_file, transfer_funds],
db=db,
markdown=True,
instructions=(
"When the user asks you to delete a file, call delete_file. When the "
"user asks you to transfer funds, call transfer_funds. Do not ask the "
"user to confirm in chat — the framework pauses the run for human "
"approval automatically. Just make the tool calls."
),
)

# db= is passed to AgentOS so /approvals router is enabled (without it the
# endpoint returns 503 "Approvals not available: pass a db to AgentOS").
agent_os = AgentOS(
description="Multi-tool approval demo for the AgentOS web UI",
agents=[agent],
db=db,
)
app = agent_os.get_app()


if __name__ == "__main__":
"""Run AgentOS.

After starting: open os.agno.com and connect it to this server
(via ngrok if you need a public URL for the web frontend to reach).
Trigger a run such as:

"please delete /tmp/demo.txt and transfer $500 to account 42"

The run will pause with both tools listed in context.tool_names and
both tool_executions in requirements[]. Inspect how the web UI
renders this vs the Slack HITL interface.
"""
agent_os.serve(app="approval_multi_tool:app", port=7779, reload=True)
144 changes: 144 additions & 0 deletions cookbook/05_agent_os/interfaces/slack/approval_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Slack Approval Flow (HITL)
==========================

Human-in-the-loop tool approval via Slack Block Kit buttons. When the agent
calls a tool decorated with @approval + @tool(requires_confirmation=True),
the run pauses and an approval message appears in the Slack thread.

Clicking Approve resumes the run; clicking Reject stops the tool and lets
the agent respond around the denial. 'Reject with reason' opens a modal
that collects an optional note passed to the LLM as the rejection reason.

Key concepts:
- ``hitl_enabled=True`` mounts POST /slack/interactions alongside /slack/events.
- ``approval_authorization="requester_only"`` (default) — only the user
who triggered the run can resolve the approval.
- ``rejection_note_mode="optional"`` (default) — two reject buttons: quick
reject (default note) and reject-with-reason (opens modal).

Slack app requirements:
- Event Subscriptions URL: ``<tunnel>/slack/events`` (existing)
- Interactivity URL: ``<tunnel>/slack/interactions`` (NEW — required)
- Bot scopes: chat:write, app_mentions:read, assistant:write, im:history

Env:
- SLACK_TOKEN, SLACK_SIGNING_SECRET, OPENAI_API_KEY
"""

import os

from agno.agent import Agent
from agno.approval import approval
from agno.db.sqlite import SqliteDb
from agno.models.openai import OpenAIResponses
from agno.os.app import AgentOS
from agno.os.interfaces.slack import Slack
from agno.tools import tool

DB_FILE = "tmp/slack_approval.db"


@approval
@tool(requires_confirmation=True)
def delete_file(path: str) -> str:
"""Delete a file at the given path.

Args:
path (str): Absolute path to the file that should be deleted.

Returns:
str: Confirmation message.
"""
# Demo stub — no real deletion happens. In production this would call
# Path(path).unlink(), hit an S3 API, or similar irreversible action.
return f"Deleted {path}"


@approval
@tool(requires_confirmation=True)
def transfer_funds(account_id: str, amount_usd: float) -> str:
"""Transfer funds from the company account to another account.

Args:
account_id (str): Destination account identifier.
amount_usd (float): Amount to transfer in USD.

Returns:
str: Confirmation of the transfer.
"""
# Demo stub — no real transfer happens.
return f"Transferred ${amount_usd:.2f} to account {account_id}"


os.makedirs("tmp", exist_ok=True)

agent_db = SqliteDb(
db_file=DB_FILE,
session_table="agent_sessions",
approvals_table="approvals",
)

approval_agent = Agent(
name="Approval Demo Agent",
model=OpenAIResponses(id="gpt-5.4"),
tools=[delete_file, transfer_funds],
db=agent_db,
add_history_to_context=True,
num_history_runs=3,
add_datetime_to_context=True,
markdown=True,
instructions=(
"You are a safety-conscious assistant. When asked to delete files or "
"transfer funds, call the appropriate tool. Every such call pauses "
"the run for human approval — wait for the decision and respond "
"naturally to both approvals and rejections. When the user requests "
"multiple sensitive actions in one message, call all of them in the "
"same turn — Slack renders each as its own approvable row, and the "
"run continues only after every row is resolved."
),
)

# Passing ``db=agent_db`` to AgentOS enables the /approvals router so the
# os.agno.com dashboard can observe and resolve approvals alongside Slack.
# Without this, /approvals returns 503 even though the agent writes approval
# rows into agent_db.
agent_os = AgentOS(
agents=[approval_agent],
db=agent_db,
interfaces=[
Slack(
agent=approval_agent,
reply_to_mentions_only=False,
hitl_enabled=True,
)
],
)

app = agent_os.get_app()


if __name__ == "__main__":
"""Run AgentOS.

Mount point summary:
- Slack events: POST /slack/events
- Slack interactions: POST /slack/interactions
- AgentOS config: GET /config

Test flow after tunneling and configuring the Slack app:
1. @mention the bot: "please delete /tmp/demo.txt"
2. Bot streams a tool call task card, then a separate approval message
appears in the same thread with Approve / Reject / Reject with reason.
3. Click Approve — the row updates to "APPROVED by @you"; once every
row in the pause is resolved, the agent posts the follow-up response.
4. For the reject-with-reason path, a modal opens immediately on click;
the note entered there is passed to the LLM as the rejection reason.

Multi-tool test:
@mention "please delete /tmp/demo.txt and transfer $500 to account 42"
→ the pause message shows two rows with per-tool Approve/Reject buttons
→ resolve each row independently (or use "Approve all confirmations")
→ the run continues only after every row is decided.
"""
agent_os.serve(app="approval_flow:app", port=7778, reload=True)
82 changes: 82 additions & 0 deletions libs/agno/agno/os/interfaces/slack/authorization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Literal, Union

if TYPE_CHECKING:
from slack_sdk.web.async_client import AsyncWebClient

ApprovalPolicy = Union[
Literal["requester_only", "channel_members", "any_authenticated"],
Callable[[str, Dict[str, Any]], Awaitable[bool]],
]


async def check_approval_authorization(
policy: ApprovalPolicy,
clicker_user_id: str,
approval: Dict[str, Any],
slack_client: "AsyncWebClient",
) -> bool:
if callable(policy):
return await policy(clicker_user_id, approval)

if policy == "any_authenticated":
return True

if policy == "requester_only":
slack_meta = get_slack_meta(approval)
return clicker_user_id == slack_meta.get("requester_slack_user_id")

if policy == "channel_members":
slack_meta = get_slack_meta(approval)
channel = slack_meta.get("channel_id")
if not channel:
return False
return await _is_channel_member(slack_client, channel, clicker_user_id)

return False


def get_slack_meta(approval: Dict[str, Any]) -> Dict[str, Any]:
return (approval.get("resolution_data") or {}).get("interface", {}).get("slack", {})


# Mirrors the closure-scoped _db_call in libs/agno/agno/os/routers/approvals/router.py:37.
# TODO: when agno.os.approval_service lands, delegate both to that.
async def call_db(db: Any, method_name: str, *args: Any, **kwargs: Any) -> Any:
fn = getattr(db, method_name, None)
if fn is None:
return None
if asyncio.iscoroutinefunction(fn):
return await fn(*args, **kwargs)
return fn(*args, **kwargs)


async def _is_channel_member(client: "AsyncWebClient", channel: str, user: str) -> bool:
# conversations.members paginates; walk until we find the user or exhaust.
# For very large channels, a caller-supplied callback policy is the escape hatch.
from agno.utils.log import log_error, log_warning

cursor = None
try:
while True:
resp = await client.conversations_members(channel=channel, limit=200, cursor=cursor)
if user in (resp.get("members") or []):
return True
cursor = (resp.get("response_metadata") or {}).get("next_cursor")
if not cursor:
return False
except Exception as exc:
# Distinguish rate limits from genuine "not a member". 429 should log
# loudly — fail-closed would otherwise silently deny legitimate approvers.
slack_error = getattr(getattr(exc, "response", None), "data", None) or {}
err_code = slack_error.get("error") if isinstance(slack_error, dict) else None
if err_code == "ratelimited":
log_error(
f"channel_members check rate-limited for channel={channel}; user={user} denied. "
"Consider a callback policy for high-volume channels."
)
else:
log_warning(f"channel_members check failed for channel={channel} user={user}: {err_code or exc}")
return False
Loading
Loading