From e2d864dc0748aae4a39494fbe14c90a2da8113db Mon Sep 17 00:00:00 2001 From: Mustafa Esoofally Date: Tue, 21 Apr 2026 11:01:29 -0400 Subject: [PATCH 1/2] feat: Slack HITL with TaskCard pause UI for all 4 pause types Implements human-in-the-loop approval flows for the Slack AgentOS interface. Each pause attaches to the finalized streamed message via stream.stop(blocks=...), rendering as a single Slack task_card with type-specific input elements. Core - block_kit.py: TaskCard / RichText / TaskCardSource Pydantic models - blocks.py: row builders for confirmation / user_input / user_feedback / external_execution, with per-field block_id namespacing so Slack accepts multi-field pauses - router.py: interaction handler routes clicks via button_value (req_id|approval_id) and falls back to chat.postMessage on error - authorization.py: per-user scope for approval decisions Tests (47 total) - test_slack_block_kit.py: Pydantic validation for TaskCard status literals, required fields, dump shape, RichText nesting, extra=forbid - test_slack_blocks.py: classify_requirement dispatch order, row_block_id round-trip, all 4 row builder shapes, parse_submit_payload per type, per-field block_id uniqueness regression - test_os_security_key_webhooks.py: signing-secret webhook guard Cookbooks - hitl_all_types.py: demo all 4 pause types in one agent - hitl_ux_verify.py: UX verification harness - compare_resolution_flows.py: side-by-side pause resolution patterns --- .../approvals/compare_resolution_flows.py | 203 +++++ .../interfaces/slack/hitl_all_types.py | 173 ++++ .../interfaces/slack/hitl_ux_verify.py | 673 ++++++++++++++ .../agno/os/interfaces/slack/authorization.py | 89 ++ .../agno/os/interfaces/slack/block_kit.py | 218 +++++ libs/agno/agno/os/interfaces/slack/blocks.py | 835 ++++++++++++++++++ libs/agno/agno/os/interfaces/slack/events.py | 9 + libs/agno/agno/os/interfaces/slack/router.py | 346 +++++++- libs/agno/agno/os/interfaces/slack/slack.py | 14 + libs/agno/agno/os/interfaces/slack/state.py | 5 + .../unit/os/routers/test_slack_block_kit.py | 138 +++ .../unit/os/routers/test_slack_blocks.py | 384 ++++++++ .../unit/os/test_os_security_key_webhooks.py | 162 ++++ 13 files changed, 3248 insertions(+), 1 deletion(-) create mode 100644 cookbook/05_agent_os/approvals/compare_resolution_flows.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_all_types.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py create mode 100644 libs/agno/agno/os/interfaces/slack/authorization.py create mode 100644 libs/agno/agno/os/interfaces/slack/block_kit.py create mode 100644 libs/agno/agno/os/interfaces/slack/blocks.py create mode 100644 libs/agno/tests/unit/os/routers/test_slack_block_kit.py create mode 100644 libs/agno/tests/unit/os/routers/test_slack_blocks.py create mode 100644 libs/agno/tests/unit/os/test_os_security_key_webhooks.py diff --git a/cookbook/05_agent_os/approvals/compare_resolution_flows.py b/cookbook/05_agent_os/approvals/compare_resolution_flows.py new file mode 100644 index 0000000000..238f88798c --- /dev/null +++ b/cookbook/05_agent_os/approvals/compare_resolution_flows.py @@ -0,0 +1,203 @@ +""" +Compare Resolution Flows +======================== + +Side-by-side demonstration of how the two AgentOS approval UIs resolve a +paused run with multiple requirements: + + - os.agno.com web UI: one Approve/Reject per whole approval + (POST /approvals/{id}/resolve flips the aggregate status in one call). + - Slack HITL (PR #7574): one Approve/Reject per RunRequirement row + (each click writes a per-row entry inside approval.resolution_data). + +No network, no agent run, no OpenAI call — a synthetic paused approval is +inserted into SQLite and both code paths exercise the same DB function. +The third scenario shows the concurrency gap between the two models. +""" + +import asyncio +import os +import time +import uuid +from pathlib import Path + +from agno.db.sqlite import SqliteDb + +DB_FILE = "tmp/compare_resolution.db" + + +def _build_approval() -> dict: + now = int(time.time()) + return { + "id": f"appr_{uuid.uuid4().hex[:8]}", + "run_id": "run_demo", + "session_id": "sess_demo", + "source_type": "agent", + "agent_id": "demo_agent", + "source_name": "Demo Agent", + "status": "pending", + "approval_type": "required", + "pause_type": "confirmation", + "requirements": [ + { + "id": "req_delete", + "tool_execution": { + "tool_name": "delete_file", + "tool_args": {"path": "/tmp/demo.txt"}, + }, + }, + { + "id": "req_transfer", + "tool_execution": { + "tool_name": "transfer_funds", + "tool_args": {"account_id": "42", "amount_usd": 500}, + }, + }, + ], + "resolution_data": None, + "context": {"tool_names": ["delete_file", "transfer_funds"]}, + "resolved_by": None, + "resolved_at": None, + "run_status": "paused", + "created_at": now, + "updated_at": now, + } + + +def _reset(db: SqliteDb) -> dict: + approval = _build_approval() + if db.get_approval(approval["id"]): + db.delete_approval(approval["id"]) + db.create_approval(approval) + return approval + + +def _dump(label: str, approval: dict) -> None: + status = approval.get("status") if approval else "" + rd = (approval or {}).get("resolution_data") or {} + rows = rd.get("requirement_resolutions") or {} + print(f" {label}") + print(f" aggregate status : {status!r}") + if rows: + for req_id, row in rows.items(): + print(f" row {req_id:13s}: {row}") + else: + print(" per-row data : (none)") + + +def scenario_web_ui(db: SqliteDb) -> None: + print("\n" + "=" * 72) + print("Scenario A — os.agno.com web UI (single Approve/Reject)") + print("=" * 72) + approval = _reset(db) + _dump("before", db.get_approval(approval["id"])) + + # Mirrors POST /approvals/{id}/resolve in agno/os/routers/approvals/router.py + after = db.update_approval( + approval["id"], + expected_status="pending", + status="approved", + resolved_by="admin@example.com", + resolved_at=int(time.time()), + ) + _dump("after 1 call", after) + + +def scenario_slack_sequential(db: SqliteDb) -> None: + print("\n" + "=" * 72) + print("Scenario B — Slack HITL (per-row, sequential clicks)") + print("=" * 72) + approval = _reset(db) + _dump("before", db.get_approval(approval["id"])) + + # Alice clicks Approve on row 1 + current = db.get_approval(approval["id"]) + rd = dict(current.get("resolution_data") or {}) + rd["requirement_resolutions"] = { + "req_delete": {"status": "approved", "actor": "U_ALICE"} + } + db.update_approval(approval["id"], expected_status="pending", resolution_data=rd) + _dump("after Alice approves row 1", db.get_approval(approval["id"])) + + # Bob clicks Approve on row 2 (reads Alice's state first) + current = db.get_approval(approval["id"]) + rd = dict(current.get("resolution_data") or {}) + existing_rows = dict(rd.get("requirement_resolutions") or {}) + existing_rows["req_transfer"] = {"status": "approved", "actor": "U_BOB"} + rd["requirement_resolutions"] = existing_rows + db.update_approval(approval["id"], expected_status="pending", resolution_data=rd) + _dump("after Bob approves row 2", db.get_approval(approval["id"])) + + # Last-row resolver flips aggregate status (interactions.py does this + # after acontinue_run succeeds). + db.update_approval( + approval["id"], + expected_status="pending", + status="approved", + resolved_by="U_BOB", + resolved_at=int(time.time()), + ) + _dump("after aggregate flip", db.get_approval(approval["id"])) + + +async def _click(db: SqliteDb, approval_id: str, row_id: str, actor: str) -> None: + # Each "click" reads approval, merges its own row into resolution_data, + # writes back. The 10ms sleep holds both coroutines at the same snapshot + # so their writes interleave — this is the window Slack cannot serialize. + current = db.get_approval(approval_id) + rd = dict(current.get("resolution_data") or {}) + existing_rows = dict(rd.get("requirement_resolutions") or {}) + await asyncio.sleep(0.01) + existing_rows[row_id] = {"status": "approved", "actor": actor} + rd["requirement_resolutions"] = existing_rows + db.update_approval(approval_id, expected_status="pending", resolution_data=rd) + + +async def scenario_slack_race(db: SqliteDb) -> None: + print("\n" + "=" * 72) + print("Scenario C — Slack HITL (concurrent clicks on DIFFERENT rows)") + print("=" * 72) + approval = _reset(db) + _dump("before", db.get_approval(approval["id"])) + + await asyncio.gather( + _click(db, approval["id"], "req_delete", "U_ALICE"), + _click(db, approval["id"], "req_transfer", "U_BOB"), + ) + _dump("after two concurrent writes", db.get_approval(approval["id"])) + final = db.get_approval(approval["id"]) + rows = (final.get("resolution_data") or {}).get("requirement_resolutions") or {} + if len(rows) < 2: + print(" !! vote lost: one row is missing from resolution_data") + else: + print(" (both rows present — rerun to catch the narrow window)") + + +def main() -> None: + Path("tmp").mkdir(parents=True, exist_ok=True) + if os.path.exists(DB_FILE): + os.remove(DB_FILE) + db = SqliteDb(db_file=DB_FILE, approvals_table="approvals") + + scenario_web_ui(db) + scenario_slack_sequential(db) + asyncio.run(scenario_slack_race(db)) + + print("\n" + "=" * 72) + print("Takeaway") + print("=" * 72) + print(" Web UI (A): lock field == write field (both are status). Safe.") + print(" Slack (B): sequential per-row writes preserve state. Safe.") + print(" Slack (C): concurrent per-row writes both pass the aggregate") + print(" CAS; the later write clobbers the earlier row. Broken.") + print() + print( + " To bring multi-row resolution to os.agno.com, POST /approvals/{id}/resolve" + ) + print(" would need a per-requirement payload AND either (a) a JSON-path CAS") + print(" predicate on the row's status, or (b) an asyncio.Lock keyed by") + print(" approval_id wrapping the read-modify-write.") + + +if __name__ == "__main__": + main() diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py b/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py new file mode 100644 index 0000000000..d8768581a2 --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py @@ -0,0 +1,173 @@ +""" +Slack HITL — all 4 pause types +============================== + +Tests Path A Slack HITL with a single agent exposing one tool per pause type: + • delete_file(path) → confirmation (requires_confirmation=True) + • send_email(to, subject) → user_input (requires_user_input=True) + • run_shell(command) → external_execution (external_execution=True) + • ask_user(questions) → user_feedback (UserFeedbackTools) + +How to exercise each in Slack (DM the bot): + 1. "please delete /tmp/demo.txt" → confirmation card + 2. "send an email about Q1 results" → user_input form + 3. "run the shell command 'ls -la /tmp'" → external_execution card + 4. "ask me which pizza toppings I want: pepperoni, + mushroom, olives. make it multi-select" → user_feedback checkboxes + +For each, fill in / click as needed and press Submit. The agent resumes and +posts the tool's result back into the thread. + +Setup: + export SLACK_TOKEN=xoxb-... + export SLACK_SIGNING_SECRET=... + export SSL_CERT_FILE=$(python3 -c "import certifi; print(certifi.where())") + ngrok http 7777 + # In Slack App config: Event Subscriptions + Interactivity both point at: + # /slack/events + # /slack/interactions + + .venvs/demo/bin/python cookbook/05_agent_os/interfaces/slack/hitl_all_types.py +""" + +import json +from typing import List + +import httpx +from agno.agent import Agent +from agno.db.sqlite.sqlite import SqliteDb +from agno.models.openai import OpenAIChat +from agno.os.app import AgentOS +from agno.os.interfaces.slack import Slack +from agno.tools import tool +from agno.tools.calculator import CalculatorTools +from agno.tools.duckduckgo import DuckDuckGoTools +from agno.tools.hackernews import HackerNewsTools +from agno.tools.user_feedback import UserFeedbackTools +from agno.tools.wikipedia import WikipediaTools + +# --------------------------------------------------------------------------- +# Tools — one per HITL pause type +# --------------------------------------------------------------------------- + + +@tool(requires_confirmation=True) +def delete_file(path: str) -> str: + """Delete a file at the given path. Requires human approval before running. + + Args: + path: Absolute filesystem path to delete. + """ + # We don't actually delete — just report. Swap to os.remove() if you dare. + return f"(pretend) Deleted {path}" + + +@tool(requires_user_input=True, user_input_fields=["to_address", "subject"]) +def send_email(to_address: str, subject: str, body: str) -> str: + """Send an email. The `body` is provided by the agent; `to_address` and + `subject` are collected from the user at pause time. + + Args: + to_address: recipient email. + subject: email subject line. + body: email body (agent supplies this). + """ + return f"Sent email to {to_address} with subject {subject!r}: {body[:60]}…" + + +@tool(external_execution=True) +def run_shell(command: str) -> str: + """Execute a shell command on an external system. The user pastes the + output back into the HITL card. + + Args: + command: shell command to run. + """ + return f"Would run: {command}" # Unreachable — external_execution=True pauses first + + +# --------------------------------------------------------------------------- +# Optional: show real HN data works too (plain tool, no pause) +# --------------------------------------------------------------------------- + + +@tool +def top_hacker_news_stories(num_stories: int = 3) -> str: + """Fetch top stories from Hacker News (plain tool, no HITL).""" + response = httpx.get( + "https://hacker-news.firebaseio.com/v0/topstories.json", timeout=10 + ) + ids = response.json()[:num_stories] + stories: List[dict] = [] + for story_id in ids: + story = httpx.get( + f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json", timeout=10 + ).json() + story.pop("text", None) + stories.append(story) + return json.dumps(stories, indent=2) + + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_all_types.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="HITL Reference Agent", + id="hitl-reference-agent", + model=OpenAIChat(id="gpt-4o-mini"), + db=db, + tools=[ + # HITL pause types + delete_file, + send_email, + run_shell, + UserFeedbackTools(), + # Non-pause tools — exercise Slack's streaming task-card UI when + # multiple tools are invoked back-to-back in a single run. + top_hacker_news_stories, + DuckDuckGoTools(), + HackerNewsTools(), + WikipediaTools(), + CalculatorTools(), + ], + instructions=[ + "You are a HITL testing assistant. When the user asks you to do something " + "that matches one of your tools, call the tool — do not ask for confirmation " + "or clarification yourself; the framework will pause for human input.", + "For delete_file, call with the provided path.", + "For send_email, invent a short body and pass to_address + subject as placeholders " + "(the user will supply the real values via the pause form).", + "For run_shell, call with the exact command the user gave.", + "For ask_user, translate the user's description into AskUserQuestion objects " + "and pass as a list.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — all 4 pause types", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, # v0 HITL on + approval_authorization="requester_only", # only the user who triggered can resolve + reply_to_mentions_only=True, # channels require @mention; DMs always pass through + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + # Port 7778 matches the existing ngrok tunnel + # (https://paraphrastic-sang-ingenuous.ngrok-free.dev → localhost:7778). + agent_os.serve(app="hitl_all_types:app", reload=False, port=7778) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py b/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py new file mode 100644 index 0000000000..6332d14b4a --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py @@ -0,0 +1,673 @@ +""" +Slack HITL UX Verification Harness +=================================== + +Standalone FastAPI server that logs every /slack/interactions payload, +plus a CLI to post test layouts to a Slack DM. Use this to empirically +verify which Block Kit elements ship state on SUBMIT and which don't, +BEFORE committing to an HITL design. + +Replaces any running cookbook on port 7778 for the duration of the test. +Ngrok tunnel at https:///slack/interactions must be pointed at 7778. + +Usage: + # Terminal A — start the log server + python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py serve + + # Terminal B — post each test layout; click in Slack; watch Terminal A. + python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post input_only + python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post buttons_only + python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post mixed + python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post full_pause_card + +Each test proves or disproves a specific claim about Slack's behavior. +Read the docstrings on each layout function below. +""" + +import json +import os +import sys + +import httpx +from fastapi import FastAPI, HTTPException, Request +from slack_sdk.signature import SignatureVerifier + +# --------------------------------------------------------------------------- +# Config — change this to your target Slack DM/channel ID before running +# --------------------------------------------------------------------------- + +CHANNEL_ID = os.environ.get("SLACK_TEST_CHANNEL_ID", "D0AGXPEGJ8M") +SLACK_TOKEN = os.environ["SLACK_TOKEN"] +SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"] + +app = FastAPI() +verifier = SignatureVerifier(SLACK_SIGNING_SECRET) + + +# --------------------------------------------------------------------------- +# Server — logs every block_actions payload +# --------------------------------------------------------------------------- + + +@app.post("/slack/events") +async def events(request: Request): + body = await request.body() + ts = request.headers.get("X-Slack-Request-Timestamp", "") + sig = request.headers.get("X-Slack-Signature", "") + if not verifier.is_valid(body=body.decode(), timestamp=ts, signature=sig): + raise HTTPException(403, "bad signature") + payload = json.loads(body) + if payload.get("type") == "url_verification": + return {"challenge": payload.get("challenge")} + return {"ok": True} + + +@app.post("/slack/interactions") +async def interactions(request: Request): + body = await request.body() + ts = request.headers.get("X-Slack-Request-Timestamp", "") + sig = request.headers.get("X-Slack-Signature", "") + if not verifier.is_valid(body=body.decode(), timestamp=ts, signature=sig): + raise HTTPException(403, "bad signature") + form = await request.form() + payload = json.loads(form.get("payload", "{}")) + + action = (payload.get("actions") or [{}])[0] + state_values = (payload.get("state") or {}).get("values") or {} + + print("=" * 72) + print(f"TYPE: {payload.get('type')}") + print(f"ACTION_ID: {action.get('action_id')}") + print(f"BLOCK_ID: {action.get('block_id')}") + print(f"ACTION_VALUE: {action.get('value')}") + print(f"USER: {payload.get('user', {}).get('username')}") + print(f"") + print(f"STATE.VALUES (everything that ships to us):") + print(json.dumps(state_values, indent=2) if state_values else " ") + print("=" * 72) + return {"ok": True} + + +# --------------------------------------------------------------------------- +# Test layouts — each proves or disproves a specific claim +# --------------------------------------------------------------------------- + + +def layout_input_only(): + """CLAIM: Input blocks ship state.values on any button click in the same + message. This is the baseline — if this fails, the whole design is wrong. + + Expected on SUBMIT: state.values has {'b_decision': {'e': {'selected_option': ...}}} + """ + return [ + { + "type": "header", + "text": {"type": "plain_text", "text": "TEST 1: Input block state"}, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Claim:* radio_buttons in Input block ships state.values on SUBMIT.", + } + ], + }, + { + "type": "input", + "block_id": "b_decision", + "label": {"type": "plain_text", "text": "Decision"}, + "element": { + "type": "radio_buttons", + "action_id": "e", + "options": [ + { + "text": {"type": "plain_text", "text": "Confirm"}, + "value": "confirm", + }, + { + "text": {"type": "plain_text", "text": "Reject"}, + "value": "reject", + }, + ], + }, + }, + {"type": "divider"}, + { + "type": "actions", + "block_id": "submit", + "elements": [ + { + "type": "button", + "action_id": "submit", + "text": {"type": "plain_text", "text": "SUBMIT"}, + "style": "primary", + "value": "go", + }, + ], + }, + ] + + +def layout_buttons_only(): + """CLAIM: Regular buttons DO NOT carry state. Two buttons per row + SUBMIT. + Click Confirm on row 1, Reject on row 2, then SUBMIT. + + Expected on SUBMIT: state.values is empty. Button click history is lost. + This is what forces us to use chat.update OR switch to state-bearing elements. + """ + + def row(name, args): + return [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"🔧 *{name}*\n `{args}`"}, + }, + { + "type": "actions", + "block_id": f"row_{name}", + "elements": [ + { + "type": "button", + "action_id": f"confirm_{name}", + "text": {"type": "plain_text", "text": "✓ Confirm"}, + "style": "primary", + "value": f"{name}:confirm", + }, + { + "type": "button", + "action_id": f"reject_{name}", + "text": {"type": "plain_text", "text": "✗ Reject"}, + "style": "danger", + "value": f"{name}:reject", + }, + ], + }, + ] + + return [ + { + "type": "header", + "text": {"type": "plain_text", "text": "TEST 2: Button-only (stateless)"}, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Claim:* buttons do NOT ship state. SUBMIT's payload will have `state.values == {}` even after clicking buttons.", + } + ], + }, + *row("delete_file", "path: /tmp/demo.txt"), + *row("transfer_funds", "account: 42, $500"), + {"type": "divider"}, + { + "type": "actions", + "block_id": "submit", + "elements": [ + { + "type": "button", + "action_id": "submit", + "text": {"type": "plain_text", "text": "SUBMIT"}, + "style": "primary", + "value": "go", + }, + ], + }, + ] + + +def layout_mixed(): + """CLAIM: This is the UX user asked for — regular Confirm/Reject buttons + + multiline plain_text_input + SUBMIT. Multiline input DOES ship state; + buttons DO NOT. + + Expected on SUBMIT: state.values = {'b_note': {'e': {'value': ''}}} + Buttons are absent. + """ + return [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "TEST 3: Mixed — buttons + multiline input", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Claim:* multiline input ships state; buttons don't. Click Confirm, type a note, SUBMIT.", + } + ], + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "🔧 *delete_file*\n `path: /tmp/demo.txt`", + }, + }, + { + "type": "actions", + "block_id": "row_delete", + "elements": [ + { + "type": "button", + "action_id": "confirm_delete", + "text": {"type": "plain_text", "text": "✓ Confirm"}, + "style": "primary", + "value": "delete:confirm", + }, + { + "type": "button", + "action_id": "reject_delete", + "text": {"type": "plain_text", "text": "✗ Reject"}, + "style": "danger", + "value": "delete:reject", + }, + ], + }, + { + "type": "input", + "block_id": "b_note", + "label": {"type": "plain_text", "text": "Optional note"}, + "element": { + "type": "plain_text_input", + "action_id": "e", + "multiline": True, + "placeholder": {"type": "plain_text", "text": "Type a reason here…"}, + }, + "optional": True, + }, + {"type": "divider"}, + { + "type": "actions", + "block_id": "submit", + "elements": [ + { + "type": "button", + "action_id": "submit", + "text": {"type": "plain_text", "text": "SUBMIT"}, + "style": "primary", + "value": "go", + }, + ], + }, + ] + + +def layout_full_pause_card(): + """CLAIM: All 4 HITL pause types can render inline in one message using + state-bearing elements. On SUBMIT, state.values carries every field. + + Includes: confirmation (radio), user_input (plain_text_input), + user_feedback (radio), external_execution (multiline plain_text_input). + """ + return [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "TEST 4: Full pause card (all 4 pause types)", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Claim:* one message can host all 4 pause types with state.", + } + ], + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "🔧 *delete_file* (confirmation)\n `path: /tmp/demo.txt`", + }, + }, + { + "type": "input", + "block_id": "b_confirm", + "label": {"type": "plain_text", "text": "Decision"}, + "element": { + "type": "radio_buttons", + "action_id": "e", + "options": [ + { + "text": {"type": "plain_text", "text": "Confirm"}, + "value": "confirm", + }, + { + "text": {"type": "plain_text", "text": "Reject"}, + "value": "reject", + }, + ], + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "🔧 *send_email* (user_input)"}, + }, + { + "type": "input", + "block_id": "b_to_address", + "label": {"type": "plain_text", "text": "to_address"}, + "element": { + "type": "plain_text_input", + "action_id": "e", + "placeholder": {"type": "plain_text", "text": "user@example.com"}, + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "❓ *vacation_style* (user_feedback — what kind of vacation?)", + }, + }, + { + "type": "input", + "block_id": "b_feedback", + "label": {"type": "plain_text", "text": "Choose one"}, + "element": { + "type": "radio_buttons", + "action_id": "e", + "options": [ + {"text": {"type": "plain_text", "text": "Beach"}, "value": "beach"}, + {"text": {"type": "plain_text", "text": "City"}, "value": "city"}, + { + "text": {"type": "plain_text", "text": "Nature"}, + "value": "nature", + }, + ], + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "🚀 *execute_shell_command* (external_execution)\n `command: ls`", + }, + }, + { + "type": "input", + "block_id": "b_ext_result", + "label": {"type": "plain_text", "text": "Paste result"}, + "element": { + "type": "plain_text_input", + "action_id": "e", + "multiline": True, + "placeholder": { + "type": "plain_text", + "text": "e.g. file1.txt\nfile2.txt", + }, + }, + }, + {"type": "divider"}, + { + "type": "actions", + "block_id": "submit", + "elements": [ + { + "type": "button", + "action_id": "submit", + "text": {"type": "plain_text", "text": "SUBMIT"}, + "style": "primary", + "value": "go", + }, + { + "type": "button", + "action_id": "cancel", + "text": {"type": "plain_text", "text": "Cancel"}, + "style": "danger", + "value": "cancel", + }, + ], + }, + ] + + +def layout_card_minimal(): + """CLAIM: Slack accepts the new `card` block via raw chat.postMessage. + + Probes the API: does Slack's REST endpoint accept type=card today, in this + workspace? If chat.postMessage returns ok=False with invalid_blocks, the + spec's card-based design needs a fallback. + + Expected on success: a single card with title + body + Confirm/Reject. + """ + return [ + { + "type": "header", + "text": {"type": "plain_text", "text": "TEST: card block (minimal)"}, + }, + { + "type": "card", + "block_id": "card_min", + "title": {"type": "plain_text", "text": "delete_file"}, + "subtitle": {"type": "mrkdwn", "text": "_confirmation required_"}, + "body": [ + {"type": "mrkdwn", "text": "path: `/tmp/demo.txt`"}, + ], + "actions": { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "card_confirm", + "text": {"type": "plain_text", "text": "Confirm"}, + "style": "primary", + "value": "go", + }, + { + "type": "button", + "action_id": "card_reject", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "value": "no", + }, + ], + }, + }, + ] + + +def layout_card_with_input(): + """CLAIM: card and Input can coexist as siblings at message level. + + Spec's claim: card.actions can't hold inputs, but card next to a separate + Input block renders fine. Verify that a SUBMIT click on a card button OR + on a sibling Actions block ships state.values. + """ + return [ + { + "type": "header", + "text": {"type": "plain_text", "text": "TEST: card + sibling Input"}, + }, + { + "type": "card", + "block_id": "card_send", + "title": {"type": "plain_text", "text": "send_email"}, + "subtitle": {"type": "mrkdwn", "text": "_user_input required_"}, + "body": [ + {"type": "mrkdwn", "text": "to: `priti@example.com`"}, + ], + "actions": { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "card2_confirm", + "text": {"type": "plain_text", "text": "Confirm"}, + "style": "primary", + "value": "go", + }, + { + "type": "button", + "action_id": "card2_reject", + "text": {"type": "plain_text", "text": "Reject"}, + "style": "danger", + "value": "no", + }, + ], + }, + }, + { + "type": "input", + "block_id": "subj", + "label": {"type": "plain_text", "text": "subject"}, + "element": { + "type": "plain_text_input", + "action_id": "value", + "placeholder": {"type": "plain_text", "text": "Weekly update"}, + }, + }, + {"type": "divider"}, + { + "type": "actions", + "block_id": "submit_row", + "elements": [ + { + "type": "button", + "action_id": "submit", + "text": {"type": "plain_text", "text": "SUBMIT"}, + "style": "primary", + "value": "go", + }, + ], + }, + ] + + +def layout_card_no_subtitle(): + """CLAIM: card without subtitle is also accepted (flexibility check). + + If the minimal card requires every field, our builder needs to always set + them. If subtitle is optional, we can omit it for tools without args. + """ + return [ + { + "type": "header", + "text": {"type": "plain_text", "text": "TEST: card without subtitle"}, + }, + { + "type": "card", + "block_id": "card_terse", + "title": {"type": "plain_text", "text": "noop_tool"}, + "body": [ + {"type": "mrkdwn", "text": "_no arguments_"}, + ], + "actions": { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": "card3_confirm", + "text": {"type": "plain_text", "text": "Confirm"}, + "style": "primary", + "value": "go", + }, + ], + }, + }, + ] + + +LAYOUTS = { + "input_only": ( + "Baseline — radio_buttons in Input block ships state", + layout_input_only, + ), + "buttons_only": ( + "Buttons ONLY — verify they don't carry state", + layout_buttons_only, + ), + "mixed": ( + "Buttons + multiline input + SUBMIT — the UX you asked about", + layout_mixed, + ), + "full_pause_card": ( + "All 4 pause types inline in one message", + layout_full_pause_card, + ), + "card_minimal": ( + "New card block — minimal title+body+actions", + layout_card_minimal, + ), + "card_with_input": ( + "Card + sibling Input — production scenario", + layout_card_with_input, + ), + "card_no_subtitle": ( + "Card without subtitle — flexibility check", + layout_card_no_subtitle, + ), +} + + +# --------------------------------------------------------------------------- +# CLI dispatcher +# --------------------------------------------------------------------------- + + +def post_layout(layout_name: str): + if layout_name not in LAYOUTS: + print(f"Unknown layout '{layout_name}'. Available: {', '.join(LAYOUTS)}") + sys.exit(1) + description, fn = LAYOUTS[layout_name] + blocks = fn() + resp = httpx.post( + "https://slack.com/api/chat.postMessage", + headers={ + "Authorization": f"Bearer {SLACK_TOKEN}", + "Content-Type": "application/json; charset=utf-8", + }, + json={"channel": CHANNEL_ID, "text": description, "blocks": blocks}, + ) + data = resp.json() + if data.get("ok"): + print(f"Posted '{layout_name}' — {description}") + print(f" Slack message ts: {data.get('ts')}") + print(f" Channel: {CHANNEL_ID}") + print(f" Now click in Slack and watch the server console for payloads.") + else: + print(f"Failed: {data}") + sys.exit(1) + + +def serve(): + import uvicorn + + print("HITL UX verification server on http://localhost:7778") + print("Point Slack app Event Subscriptions and Interactivity URLs at:") + print(" https:///slack/events") + print(" https:///slack/interactions") + print("Post test layouts with: python this_file.py post ") + uvicorn.run(app, host="0.0.0.0", port=7778) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python hitl_ux_verify.py [serve|post ]") + print("Layouts:") + for name, (desc, _) in LAYOUTS.items(): + print(f" {name:20s} {desc}") + sys.exit(0) + + cmd = sys.argv[1] + if cmd == "serve": + serve() + elif cmd == "post" and len(sys.argv) >= 3: + post_layout(sys.argv[2]) + else: + print(f"Unknown command '{cmd}'. Use 'serve' or 'post '.") + sys.exit(1) diff --git a/libs/agno/agno/os/interfaces/slack/authorization.py b/libs/agno/agno/os/interfaces/slack/authorization.py new file mode 100644 index 0000000000..5a6c504272 --- /dev/null +++ b/libs/agno/agno/os/interfaces/slack/authorization.py @@ -0,0 +1,89 @@ +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. +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) + + +def assert_hitl_backend(db: Any) -> None: + # v0: simple null check. Deferred for later: MRO-walk base-stub detection + # so MongoDb/MySQL/Redis adapters (which inherit NotImplementedError stubs + # for update_approval) fail at startup rather than at first CAS call. + if db is None: + raise ValueError("hitl_enabled=True requires the agent/team to have a db configured (SqliteDb or PostgresDb)") + + +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 diff --git a/libs/agno/agno/os/interfaces/slack/block_kit.py b/libs/agno/agno/os/interfaces/slack/block_kit.py new file mode 100644 index 0000000000..110676d6ae --- /dev/null +++ b/libs/agno/agno/os/interfaces/slack/block_kit.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +MAX_TEXT = 3000 +MAX_ACTION_ID = 255 +MAX_BLOCK_ID = 255 +MAX_BUTTON_VALUE = 2000 +MAX_OPTION_VALUE = 150 +MAX_FALLBACK_TEXT = 500 +MAX_SECTION_FIELDS = 10 +MAX_ACTIONS_ELEMENTS = 25 +MAX_CONTEXT_ELEMENTS = 10 +MAX_MESSAGE_BLOCKS = 48 +MAX_STATIC_OPTIONS = 100 +MAX_CHOICE_OPTIONS = 10 + + +class _Block(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class PlainText(_Block): + type: Literal["plain_text"] = "plain_text" + text: str = Field(..., max_length=MAX_TEXT) + emoji: bool = True + + +class Markdown(_Block): + type: Literal["mrkdwn"] = "mrkdwn" + text: str = Field(..., max_length=MAX_TEXT) + + +Text = Annotated[Union[PlainText, Markdown], Field(discriminator="type")] + + +class Option(_Block): + text: PlainText + value: str = Field(..., max_length=MAX_OPTION_VALUE) + description: Optional[PlainText] = None + + +class ConfirmDialog(_Block): + """Native Slack confirmation dialog — appears as a modal when the + interactive element is triggered. Used for "are you sure?" prompts. + https://docs.slack.dev/reference/block-kit/composition-objects/confirmation-dialog-object/""" + + title: PlainText = Field(..., description="max 100 chars") + text: Text = Field(..., description="max 300 chars") + confirm: PlainText = Field(..., description="max 30 chars") + deny: PlainText = Field(..., description="max 30 chars") + style: Optional[Literal["primary", "danger"]] = None + + +class Button(_Block): + type: Literal["button"] = "button" + action_id: str = Field(..., max_length=MAX_ACTION_ID) + text: PlainText + value: Optional[str] = Field(None, max_length=MAX_BUTTON_VALUE) + style: Optional[Literal["primary", "danger"]] = None + confirm: Optional[ConfirmDialog] = None + + +class StaticSelect(_Block): + type: Literal["static_select"] = "static_select" + action_id: str = Field(..., max_length=MAX_ACTION_ID) + placeholder: PlainText + options: List[Option] = Field(..., min_length=1, max_length=MAX_STATIC_OPTIONS) + + +class Checkboxes(_Block): + type: Literal["checkboxes"] = "checkboxes" + action_id: str = Field(..., max_length=MAX_ACTION_ID) + options: List[Option] = Field(..., min_length=1, max_length=MAX_CHOICE_OPTIONS) + + +class PlainTextInput(_Block): + type: Literal["plain_text_input"] = "plain_text_input" + action_id: str = Field(..., max_length=MAX_ACTION_ID) + placeholder: Optional[PlainText] = None + initial_value: Optional[str] = None + multiline: Optional[bool] = None + + +InputElement = Annotated[ + Union[Button, StaticSelect, Checkboxes, PlainTextInput], + Field(discriminator="type"), +] + + +class Image(_Block): + """Slack image element — standalone block OR Section accessory.""" + + type: Literal["image"] = "image" + image_url: str + alt_text: str = Field("", max_length=MAX_TEXT) + + +class Section(_Block): + type: Literal["section"] = "section" + text: Optional[Text] = None + fields: Optional[List[Text]] = Field(None, max_length=MAX_SECTION_FIELDS) + accessory: Optional[Image] = None + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +class Divider(_Block): + type: Literal["divider"] = "divider" + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +class Actions(_Block): + type: Literal["actions"] = "actions" + elements: List[Annotated[Union[Button, StaticSelect, Checkboxes], Field(discriminator="type")]] = Field( + ..., min_length=1, max_length=MAX_ACTIONS_ELEMENTS + ) + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +class Context(_Block): + type: Literal["context"] = "context" + elements: List[Annotated[Union[PlainText, Markdown, Image], Field(discriminator="type")]] = Field( + ..., min_length=1, max_length=MAX_CONTEXT_ELEMENTS + ) + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +class InputBlock(_Block): + type: Literal["input"] = "input" + label: PlainText + element: InputElement + hint: Optional[PlainText] = None + optional: Optional[bool] = None + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +class RichTextStyle(_Block): + bold: Optional[bool] = None + italic: Optional[bool] = None + strike: Optional[bool] = None + code: Optional[bool] = None + + +class RichTextPlain(_Block): + type: Literal["text"] = "text" + text: str = Field(..., max_length=MAX_TEXT) + style: Optional[RichTextStyle] = None + + +class RichTextLink(_Block): + type: Literal["link"] = "link" + url: str + text: Optional[str] = Field(None, max_length=MAX_TEXT) + + +class RichTextSection(_Block): + type: Literal["rich_text_section"] = "rich_text_section" + elements: List[Annotated[Union[RichTextPlain, RichTextLink], Field(discriminator="type")]] + + +class RichText(_Block): + """Rich-text block used inside task_card's `details` and `output` fields. + Slack rejects mrkdwn in those slots — rich_text is the only supported + formatting primitive. We expose only sections (no lists / code blocks / + quote blocks yet — add as needed).""" + + type: Literal["rich_text"] = "rich_text" + elements: List[RichTextSection] + + +class TaskCardSource(_Block): + """Citation for a task_card — appears in the `sources` section when the + card is expanded. Slack renders these as small link chips.""" + + type: Literal["url"] = "url" + url: str + text: Optional[str] = Field(None, max_length=MAX_TEXT) + + +class TaskCard(_Block): + """Slack's native task-card block — a collapsible row with a status icon, + title, optional details/output (rich_text), and optional source chips. + Designed for agent tool-call progress and HITL checkpoints. + + Valid statuses: `in_progress` (animated spinner), `completed` (green check), + `error` (red). Slack rejects `pending` despite the obvious semantic fit — + use `in_progress` for awaiting-user states. + + https://docs.slack.dev/reference/block-kit/blocks/task-card-block/ + """ + + type: Literal["task_card"] = "task_card" + task_id: str = Field(..., description="Stable id — reuse across updates to the same card") + title: str = Field(..., max_length=MAX_TEXT) + status: Literal["in_progress", "completed", "error"] + details: Optional[RichText] = None + output: Optional[RichText] = None + sources: Optional[List[TaskCardSource]] = None + block_id: Optional[str] = Field(None, max_length=MAX_BLOCK_ID) + + +Block = Annotated[ + Union[Section, Divider, Actions, Context, InputBlock, Image, TaskCard], + Field(discriminator="type"), +] + + +class BlockKitMessage(_Block): + text: str = Field(..., max_length=MAX_FALLBACK_TEXT) + blocks: List[Block] = Field(..., min_length=1, max_length=MAX_MESSAGE_BLOCKS) + + def to_slack_payload(self) -> Dict[str, Any]: + return { + "text": self.text, + "blocks": [b.model_dump(exclude_none=True, mode="json") for b in self.blocks], + } diff --git a/libs/agno/agno/os/interfaces/slack/blocks.py b/libs/agno/agno/os/interfaces/slack/blocks.py new file mode 100644 index 0000000000..2980c4a4f0 --- /dev/null +++ b/libs/agno/agno/os/interfaces/slack/blocks.py @@ -0,0 +1,835 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional + +from agno.os.interfaces.slack.block_kit import ( + MAX_MESSAGE_BLOCKS, + MAX_SECTION_FIELDS, + Actions, + Button, + Checkboxes, + ConfirmDialog, + Context, + Divider, + InputBlock, + Markdown, + Option, + PlainText, + PlainTextInput, + RichText, + RichTextPlain, + RichTextSection, + Section, + StaticSelect, + TaskCard, +) +from agno.run.requirement import RunRequirement + +# --------------------------------------------------------------------------- +# Protocol — block_id / action_id contract +# +# block_id format: row:::pending[:decided:] +# Example: row:7f3a:confirmation:pending (unresolved row) +# row:7f3a:confirmation:decided:approve (confirmation clicked) +# +# Parser uses bounded split(":", 4) so req_ids containing ":" stay intact +# through the first 3 segments. +# --------------------------------------------------------------------------- + +PauseType = Literal["confirmation", "user_input", "user_feedback", "external_execution"] + +ROW_BLOCK_PREFIX = "row" +PAUSE_BLOCK_PREFIX = "pause" + +# Action IDs — closed set. parse_submit_payload (F5) rejects unknown values. +ACTION_SUBMIT = "submit_pause" +ACTION_ROW_APPROVE = "row_approve" +ACTION_ROW_REJECT = "row_reject" +ACTION_FEEDBACK_SELECT = "feedback_select" +ACTION_EXTERNAL_RESULT = "external_result" +# User-input field action_ids are namespaced per field: input_field: +ACTION_INPUT_FIELD_PREFIX = "input_field:" + +# Arg preview caps — keep messages scannable; full args still in logs. +_ARG_PREVIEW_MAX = 400 +_ARG_VALUE_MAX = 120 + + +# --------------------------------------------------------------------------- +# Parsed decision shapes +# --------------------------------------------------------------------------- + + +@dataclass +class ParsedDecision: + requirement_id: str + pause_type: PauseType + # Only the field matching pause_type is populated — others stay None. + approved: Optional[bool] = None + rejected_note: Optional[str] = None + input_values: Optional[Dict[str, Any]] = None + feedback_selections: Optional[Dict[str, List[str]]] = None + external_result: Optional[str] = None + + +@dataclass +class ParseError: + requirement_id: str + field: str + message: str + + +# --------------------------------------------------------------------------- +# Classification — matches os.agno.com DynamicHITLComponent getToolType order +# --------------------------------------------------------------------------- + + +def _classify_flags( + *, + user_feedback_schema: Any, + requires_user_input: bool, + external_execution_required: bool, +) -> PauseType: + # Order mirrors os.agno.com utils.ts: feedback → external → user_input → confirmation + if user_feedback_schema: + return "user_feedback" + if external_execution_required: + return "external_execution" + if requires_user_input: + return "user_input" + return "confirmation" + + +def classify_tool_execution(tool_execution: Optional[Dict[str, Any]]) -> PauseType: + """Classify a serialized tool_execution dict. Fallback to confirmation if + the DB round-trip dropped the tool_execution entirely.""" + if not tool_execution: + return "confirmation" + return _classify_flags( + user_feedback_schema=tool_execution.get("user_feedback_schema"), + requires_user_input=bool(tool_execution.get("requires_user_input")), + external_execution_required=bool(tool_execution.get("external_execution_required")), + ) + + +def classify_requirement(requirement: RunRequirement) -> PauseType: + """Classify a live RunRequirement by inspecting its tool_execution flags.""" + tool = requirement.tool_execution + if tool is None: + return "confirmation" + return _classify_flags( + user_feedback_schema=getattr(tool, "user_feedback_schema", None), + requires_user_input=bool(getattr(tool, "requires_user_input", False)), + external_execution_required=bool(getattr(tool, "external_execution_required", False)), + ) + + +# --------------------------------------------------------------------------- +# block_id / action_id helpers +# --------------------------------------------------------------------------- + + +def row_block_id(requirement_id: str, kind: PauseType, *, decided: Optional[str] = None) -> str: + """Build a row block_id. decided is 'approve' or 'reject' when a + confirmation row has been clicked.""" + base = f"{ROW_BLOCK_PREFIX}:{requirement_id}:{kind}:pending" + if decided is None: + return base + return f"{ROW_BLOCK_PREFIX}:{requirement_id}:{kind}:decided:{decided}" + + +def parse_row_block_id(block_id: str) -> Optional[Dict[str, str]]: + """Inverse of row_block_id. Returns dict with req_id/kind/status/decided + or None if not a row block_id. + + Bounded split(":", 4) so a req_id containing ":" can't corrupt the parse + (only the first 3 separators matter).""" + if not block_id.startswith(f"{ROW_BLOCK_PREFIX}:"): + return None + parts = block_id.split(":", 4) + # parts: [row, req_id, kind, status, maybe decided] + if len(parts) < 4: + return None + out: Dict[str, str] = { + "req_id": parts[1], + "kind": parts[2], + "status": parts[3], + } + if len(parts) == 5 and parts[3] == "decided": + out["decided"] = parts[4] + return out + + +def pause_block_id(approval_id: str) -> str: + """block_id for the SUBMIT actions block — lets the submit handler recover + the approval_id without hunting through message metadata.""" + return f"{PAUSE_BLOCK_PREFIX}:{approval_id}" + + +# --------------------------------------------------------------------------- +# Arg value rendering — used by the 2-column Section(fields=...) grid +# --------------------------------------------------------------------------- + + +def _truncate(text: str, limit: int) -> str: + if len(text) <= limit: + return text + return text[: limit - 1] + "…" + + +def render_arg_value(value: Any) -> str: + """Render a single tool arg value for Slack mrkdwn display.""" + try: + rendered = value if isinstance(value, str) else json.dumps(value, default=str) + except (TypeError, ValueError): + rendered = str(value) + return _truncate(rendered, _ARG_VALUE_MAX) + + +# --------------------------------------------------------------------------- +# Row builders — one per pause type. Each returns list[Block]. +# --------------------------------------------------------------------------- + + +def _build_arg_fields(args: Optional[Dict[str, Any]]) -> List[Markdown]: + """Render tool args as a 2-column mrkdwn grid for Section(fields=...). + + Matches the user's Block Kit Builder reference pattern: + *key:* + value + Capped at MAX_SECTION_FIELDS (Slack limit=10) with a byte budget so long + values don't blow up the card. + """ + if not args: + return [] + fields: List[Markdown] = [] + total_chars = 0 + for key, value in args.items(): + if len(fields) >= MAX_SECTION_FIELDS - 1: + # Reserve last slot for "… N more" overflow marker. + remaining = len(args) - len(fields) + if remaining > 0: + fields.append(Markdown(text=f"_… {remaining} more_")) + break + rendered = f"*{key}:*\n{render_arg_value(value)}" + if total_chars + len(rendered) > _ARG_PREVIEW_MAX: + remaining = len(args) - len(fields) + fields.append(Markdown(text=f"_… {remaining} more_")) + break + fields.append(Markdown(text=rendered)) + total_chars += len(rendered) + return fields + + +def _tool_name(requirement: RunRequirement) -> str: + tool = requirement.tool_execution + return getattr(tool, "tool_name", None) or "tool" + + +def _tool_args(requirement: RunRequirement) -> Dict[str, Any]: + tool = requirement.tool_execution + return getattr(tool, "tool_args", None) or {} + + +def _confirmation_buttons(button_value: str, approve_confirm, deny_confirm) -> List[Button]: + """Approve / Deny buttons carrying the req_id|approval_id routing key.""" + return [ + Button( + action_id=ACTION_ROW_APPROVE, + text=PlainText(text="Approve", emoji=True), + style="primary", + value=button_value, + confirm=approve_confirm, + ), + Button( + action_id=ACTION_ROW_REJECT, + text=PlainText(text="Deny", emoji=True), + style="danger", + value=button_value, + confirm=deny_confirm, + ), + ] + + +def _build_confirm_dialogs(tool_name: str, args: Dict[str, Any]): + """Native Slack confirm-dialog modals for Approve/Deny clicks. + Slack caps dialog text at 300 chars, title at 100.""" + bullets: List[str] = [] + running = 0 + for key, value in (args or {}).items(): + line = f"• {key}: `{render_arg_value(value)}`" + if running + len(line) > 180: + bullets.append(f"_… {len(args) - len(bullets)} more_") + break + bullets.append(line) + running += len(line) + args_block = "\n".join(bullets) if bullets else "_(no arguments)_" + approve_text = (f"{args_block}\n\n_Approving will resume the agent run._")[:299] + deny_text = (f"{args_block}\n\n_The agent will continue without running this tool._")[:299] + approve = ConfirmDialog( + title=PlainText(text=f"Approve {tool_name}?"[:100]), + text=Markdown(text=approve_text), + confirm=PlainText(text="Yes, approve"), + deny=PlainText(text="Cancel"), + style="primary", + ) + deny = ConfirmDialog( + title=PlainText(text=f"Deny {tool_name}?"[:100]), + text=Markdown(text=deny_text), + confirm=PlainText(text="Yes, deny"), + deny=PlainText(text="Cancel"), + style="danger", + ) + return approve, deny + + +def _args_as_rich_text(args: Optional[Dict[str, Any]]) -> Optional[RichText]: + """Render tool args as rich_text sections (one per arg). Used in + TaskCard.details — uses bold for keys and plain text for values. + `code` style on rich_text elements appears to be silently dropped by + Slack's task_card rendering, so we avoid it here.""" + if not args: + return None + sections: List[RichTextSection] = [] + for key, value in args.items(): + sections.append( + RichTextSection( + elements=[ + RichTextPlain(text=f"{key}: ", style={"bold": True}), + RichTextPlain(text=render_arg_value(value)), + ] + ) + ) + return RichText(elements=sections) + + +def _approval_task_id(req_id: str) -> str: + """Namespaced task_id for HITL task cards so they can\'t collide with + streaming tool-call task cards.""" + return f"approval:{req_id}" + + +def _build_confirmation_row(requirement: RunRequirement, approval_id: str = "") -> List[Any]: + """Confirmation row — TaskCard(pending) + Actions(Approve, Deny). + + Uses Slack\'s native task_card block so the approval matches the visual + language of the streamed tool-call task cards above it. The Actions + block carries its own block_id so _handle_row_click can route the click + via button_value (req_id|approval_id). + """ + req_id = requirement.id or "" + tool_name = _tool_name(requirement) + args = _tool_args(requirement) + approve_confirm, deny_confirm = _build_confirm_dialogs(tool_name, args) + return [ + TaskCard( + block_id=row_block_id(req_id, "confirmation"), + task_id=_approval_task_id(req_id), + title=f"Approval required: {tool_name}", + status="in_progress", + details=_args_as_rich_text(args), + ), + Actions( + block_id=f"rowact:{req_id}:confirmation", + elements=_confirmation_buttons(f"{req_id}|{approval_id}", approve_confirm, deny_confirm), + ), + ] + + +# Mapping of Python UserInputField.field_type → Slack input element. +# int/float use plain_text_input (Slack has no numeric input); we parse at submit. +# bool uses a StaticSelect with True/False options. +# list/dict use multiline plain_text_input (user pastes JSON); we parse at submit. +_BOOL_OPTIONS = [ + Option(text=PlainText(text="True"), value="true"), + Option(text=PlainText(text="False"), value="false"), +] + + +def _build_input_field(req_id: str, ui_field: Any) -> InputBlock: + """Turn a UserInputField into a Slack InputBlock.""" + name = getattr(ui_field, "name", "field") + description = getattr(ui_field, "description", None) + field_type = getattr(ui_field, "field_type", str) + initial_raw = getattr(ui_field, "value", None) + + type_name = field_type.__name__ if isinstance(field_type, type) else str(field_type) + + if type_name == "bool": + element: Any = StaticSelect( + action_id=f"{ACTION_INPUT_FIELD_PREFIX}{name}", + placeholder=PlainText(text="Select"), + options=_BOOL_OPTIONS, + ) + else: + multiline = type_name in ("list", "dict") + initial_value: Optional[str] = None + if initial_raw is not None: + initial_value = initial_raw if isinstance(initial_raw, str) else json.dumps(initial_raw, default=str) + element = PlainTextInput( + action_id=f"{ACTION_INPUT_FIELD_PREFIX}{name}", + placeholder=PlainText(text=f"Enter {name}"), + initial_value=initial_value, + multiline=multiline if multiline else None, + ) + + return InputBlock( + # Namespace per field — Slack rejects duplicate block_ids. Previously + # every field shared row_block_id(..., "user_input"); now we append + # the field name so each InputBlock has a unique id. + block_id=f"{row_block_id(req_id, 'user_input')}:{name}", + label=PlainText(text=name), + element=element, + hint=PlainText(text=description) if description else None, + ) + + +def _build_input_row(requirement: RunRequirement) -> List[Any]: + """user_input row — TaskCard(pending) + one InputBlock per schema field. + + TaskCard header signals the pause and keeps visual parity with the + streamed tool-call task cards. Each user_input_schema field becomes its + own InputBlock with a Slack input element chosen by field_type. + """ + req_id = requirement.id or "" + tool_name = _tool_name(requirement) + blocks: List[Any] = [ + TaskCard( + block_id=row_block_id(req_id, "user_input"), + task_id=_approval_task_id(req_id), + title=f"Input required: {tool_name}", + status="in_progress", + details=_args_as_rich_text(_tool_args(requirement)), + ), + ] + schema = requirement.user_input_schema or [] + for ui_field in schema: + blocks.append(_build_input_field(req_id, ui_field)) + return blocks + + +# --------------------------------------------------------------------------- +# user_feedback — one card per question. Multi-select → Checkboxes, +# single-select → StaticSelect (Slack SDK 3.41 lacks RadioButtons). +# --------------------------------------------------------------------------- + + +def _option_to_slack(option: Any, index: int) -> Option: + """Convert a UserFeedbackOption to a Block Kit Option. The option.label is + used as BOTH the display text AND the value — that's how we recover the + selected labels at SUBMIT time to pass back to provide_user_feedback().""" + label = getattr(option, "label", f"option-{index}") + description = getattr(option, "description", None) + return Option( + text=PlainText(text=label), + value=label, + description=PlainText(text=description) if description else None, + ) + + +def _build_feedback_question(req_id: str, question: Any, q_index: int) -> InputBlock: + """One InputBlock per UserFeedbackQuestion.""" + prompt = getattr(question, "question", f"Question {q_index + 1}") + options = getattr(question, "options", None) or [] + multi_select = bool(getattr(question, "multi_select", False)) + + slack_options = [_option_to_slack(opt, i) for i, opt in enumerate(options)] + + element: Any + if multi_select: + # Checkboxes — matches os.agno.com "full-width checkbox option card" UX. + element = Checkboxes( + action_id=f"{ACTION_FEEDBACK_SELECT}:{q_index}", + options=slack_options, + ) + else: + element = StaticSelect( + action_id=f"{ACTION_FEEDBACK_SELECT}:{q_index}", + placeholder=PlainText(text="Select one"), + options=slack_options, + ) + + return InputBlock( + # Block ID carries question index so the parser knows which schema + # entry a selection belongs to. + block_id=f"{row_block_id(req_id, 'user_feedback')}:q{q_index}", + label=PlainText(text=prompt), + element=element, + ) + + +def _build_feedback_row(requirement: RunRequirement) -> List[Any]: + """user_feedback row — TaskCard(pending) + one question input per schema entry.""" + req_id = requirement.id or "" + tool_name = _tool_name(requirement) + blocks: List[Any] = [ + TaskCard( + block_id=row_block_id(req_id, "user_feedback"), + task_id=_approval_task_id(req_id), + title=f"Feedback needed: {tool_name}", + status="in_progress", + ), + ] + schema = requirement.user_feedback_schema or [] + for i, question in enumerate(schema): + blocks.append(_build_feedback_question(req_id, question, i)) + return blocks + + +# --------------------------------------------------------------------------- +# external_execution — single multiline input for the result string. +# Framework requires non-empty; we hint that at submit parse time (F5). +# --------------------------------------------------------------------------- + + +def _build_external_row(requirement: RunRequirement) -> List[Any]: + req_id = requirement.id or "" + tool_name = _tool_name(requirement) + return [ + TaskCard( + block_id=row_block_id(req_id, "external_execution"), + task_id=_approval_task_id(req_id), + title=f"Output required: {tool_name}", + status="in_progress", + details=_args_as_rich_text(_tool_args(requirement)), + ), + InputBlock( + block_id=f"{row_block_id(req_id, 'external_execution')}:result", + label=PlainText(text="Result"), + element=PlainTextInput( + action_id=ACTION_EXTERNAL_RESULT, + placeholder=PlainText(text="Paste the execution output here"), + multiline=True, + ), + ), + ] + + +# --------------------------------------------------------------------------- +# Dispatch + assembly +# --------------------------------------------------------------------------- + + +_BUILDERS = { + "confirmation": _build_confirmation_row, + "user_input": _build_input_row, + "user_feedback": _build_feedback_row, + "external_execution": _build_external_row, +} + + +def _submit_actions_block(approval_id: str) -> Actions: + """Global SUBMIT button, rendered only when the pause has rows that + collect form state (user_input / user_feedback / external_execution). + Confirmation-only pauses use atomic Approve/Deny-with-confirm buttons + and skip the Submit entirely.""" + return Actions( + block_id=pause_block_id(approval_id), + elements=[ + Button( + action_id=ACTION_SUBMIT, + text=PlainText(text="Submit"), + style="primary", + value=approval_id, + ), + ], + ) + + +def _resolved_row_block( + requirement: RunRequirement, decision: Literal["approve", "reject"], note: Optional[str] = None +) -> Section: + """Replacement block rendered after a confirmation row is decided. Shows + a decided chip instead of Approve/Deny buttons.""" + req_id = requirement.id or "" + chip = "✅ Approved" if decision == "approve" else f"❌ Rejected{': ' + note if note else ''}" + return Section( + block_id=row_block_id(req_id, "confirmation", decided=decision), + text=Markdown(text=f"*{_tool_name(requirement)}* — {chip}"), + ) + + +def build_pause_message( + approval_id: str, + requirements: List[RunRequirement], +) -> List[Any]: + """Assemble the full pause message: rows + optional truncation notice + SUBMIT. + + Slack caps messages at 50 blocks; we target MAX_MESSAGE_BLOCKS=48 so the + final SUBMIT + an optional overflow context block fit under the cap. If the + requirement set would exceed that, we truncate and post a Context note + explaining how many were omitted — remaining ones can re-render after + SUBMIT resolves the shown batch. + """ + blocks: List[Any] = [] + processed = 0 + truncated_count = 0 + total = len(requirements) + budget = MAX_MESSAGE_BLOCKS - 2 # reserve SUBMIT + overflow context + + for i, requirement in enumerate(requirements): + kind = classify_requirement(requirement) + # Confirmation builder gets approval_id so Approve/Deny buttons can + # carry it in their `value` field (avoiding dependence on a Submit + # block that we drop for confirmation-only pauses). + if kind == "confirmation": + row_blocks = _build_confirmation_row(requirement, approval_id=approval_id) + else: + row_blocks = _BUILDERS[kind](requirement) + # Divider between rows (skip before the first). + header_size = 1 if i > 0 else 0 + if len(blocks) + header_size + len(row_blocks) > budget: + truncated_count = total - processed + break + if i > 0: + blocks.append(Divider()) + blocks.extend(row_blocks) + processed += 1 + + if truncated_count: + blocks.append( + Context( + elements=[ + Markdown( + text=f":warning: _{truncated_count} more pause row(s) omitted — " + "Slack message cap. Resolve the shown rows; remaining re-render after submit._" + ) + ], + ) + ) + + # Submit is ONLY rendered when at least one row collects form state + # (user_input / user_feedback / external_execution). For confirmation- + # only pauses, each card's Approve/Deny buttons carry their own native + # confirm-dialog and are atomic — no separate Submit click needed. + needs_submit = any(classify_requirement(r) != "confirmation" for r in requirements[:processed]) + if needs_submit: + blocks.append(_submit_actions_block(approval_id)) + return blocks + + +# --------------------------------------------------------------------------- +# SUBMIT payload parser +# --------------------------------------------------------------------------- + + +def _coerce_input_value(raw: Optional[str], field_type: Any) -> Any: + """Turn a Slack-submitted string into the declared Python type. Raises + ValueError on bad input — caller converts to ParseError.""" + if raw is None or raw == "": + return None + type_name = field_type.__name__ if isinstance(field_type, type) else str(field_type) + if type_name == "str": + return raw + if type_name == "int": + return int(raw) + if type_name == "float": + return float(raw) + if type_name == "bool": + return raw.lower() in ("true", "1", "yes", "y") + if type_name in ("list", "dict"): + parsed = json.loads(raw) + if type_name == "list" and not isinstance(parsed, list): + raise ValueError(f"expected list, got {type(parsed).__name__}") + if type_name == "dict" and not isinstance(parsed, dict): + raise ValueError(f"expected dict, got {type(parsed).__name__}") + return parsed + return raw + + +def _decided_from_blocks(message_blocks: List[Dict[str, Any]], requirement_id: str) -> Optional[str]: + """Scan message.blocks (as dicts from the inbound Slack payload) for a + confirmation row in decided state. Returns 'approve' / 'reject' or None.""" + for block in message_blocks: + bid = block.get("block_id") or "" + parsed = parse_row_block_id(bid) + if not parsed: + continue + if parsed.get("req_id") != requirement_id: + continue + if parsed.get("kind") != "confirmation": + continue + if parsed.get("status") == "decided": + return parsed.get("decided") + return None + + +def _parse_confirmation( + requirement: RunRequirement, + message_blocks: List[Dict[str, Any]], +) -> ParsedDecision: + req_id = requirement.id or "" + decided = _decided_from_blocks(message_blocks, req_id) + if decided is None: + # No click registered — treat as rejected by default to keep runs safe. + # (user can re-submit with explicit Approve to move forward.) + return ParsedDecision( + requirement_id=req_id, + pause_type="confirmation", + approved=False, + rejected_note="No decision made", + ) + return ParsedDecision( + requirement_id=req_id, + pause_type="confirmation", + approved=(decided == "approve"), + ) + + +def _parse_user_input( + requirement: RunRequirement, + state_values: Dict[str, Dict[str, Any]], + errors: List[ParseError], +) -> ParsedDecision: + req_id = requirement.id or "" + # Each field's InputBlock has a per-field block_id (row::user_input:pending:) + # so state_values is keyed by that unique id. + row_prefix = row_block_id(req_id, "user_input") + values: Dict[str, Any] = {} + + for ui_field in requirement.user_input_schema or []: + name = ui_field.name + field_block_id = f"{row_prefix}:{name}" + state_row = state_values.get(field_block_id) or {} + action_id = f"{ACTION_INPUT_FIELD_PREFIX}{name}" + action_state = state_row.get(action_id) or {} + element_type = action_state.get("type") + try: + if element_type == "static_select": + selected = action_state.get("selected_option") or {} + raw = selected.get("value") + # bool fields come through static_select with "true"/"false" values + values[name] = _coerce_input_value(raw, ui_field.field_type) + else: + raw = action_state.get("value") + values[name] = _coerce_input_value(raw, ui_field.field_type) + except (ValueError, TypeError, json.JSONDecodeError) as exc: + errors.append(ParseError(requirement_id=req_id, field=name, message=str(exc))) + values[name] = None + + return ParsedDecision( + requirement_id=req_id, + pause_type="user_input", + input_values=values, + ) + + +def _parse_user_feedback( + requirement: RunRequirement, + state_values: Dict[str, Dict[str, Any]], + errors: List[ParseError], +) -> ParsedDecision: + req_id = requirement.id or "" + base_row_bid = row_block_id(req_id, "user_feedback") + selections: Dict[str, List[str]] = {} + + schema = requirement.user_feedback_schema or [] + for q_index, question in enumerate(schema): + q_bid = f"{base_row_bid}:q{q_index}" + state_q = state_values.get(q_bid) or {} + action_state = state_q.get(f"{ACTION_FEEDBACK_SELECT}:{q_index}") or {} + element_type = action_state.get("type") + q_text = question.question + + picked: List[str] = [] + if element_type == "checkboxes": + for opt in action_state.get("selected_options") or []: + val = opt.get("value") + if val: + picked.append(val) + elif element_type == "static_select": + opt = action_state.get("selected_option") or {} + val = opt.get("value") + if val: + picked.append(val) + + if not picked: + errors.append(ParseError(requirement_id=req_id, field=q_text, message="No option selected")) + selections[q_text] = picked + + return ParsedDecision( + requirement_id=req_id, + pause_type="user_feedback", + feedback_selections=selections, + ) + + +def _parse_external( + requirement: RunRequirement, + state_values: Dict[str, Dict[str, Any]], + errors: List[ParseError], +) -> ParsedDecision: + req_id = requirement.id or "" + result_bid = f"{row_block_id(req_id, 'external_execution')}:result" + state_row = state_values.get(result_bid) or {} + action_state = state_row.get(ACTION_EXTERNAL_RESULT) or {} + raw = (action_state.get("value") or "").strip() + if not raw: + errors.append(ParseError(requirement_id=req_id, field="result", message="Result must be non-empty")) + return ParsedDecision( + requirement_id=req_id, + pause_type="external_execution", + external_result=raw or None, + ) + + +def parse_submit_payload( + payload: Dict[str, Any], + requirements: List[RunRequirement], +) -> tuple[List[ParsedDecision], List[ParseError]]: + """Consume a Slack block_actions payload from a SUBMIT click and produce + one ParsedDecision per requirement plus a list of ParseErrors.""" + message_blocks: List[Dict[str, Any]] = (payload.get("message") or {}).get("blocks") or [] + state_values: Dict[str, Dict[str, Any]] = (payload.get("state") or {}).get("values") or {} + + decisions: List[ParsedDecision] = [] + errors: List[ParseError] = [] + + for requirement in requirements: + kind = classify_requirement(requirement) + if kind == "confirmation": + decisions.append(_parse_confirmation(requirement, message_blocks)) + elif kind == "user_input": + decisions.append(_parse_user_input(requirement, state_values, errors)) + elif kind == "user_feedback": + decisions.append(_parse_user_feedback(requirement, state_values, errors)) + elif kind == "external_execution": + decisions.append(_parse_external(requirement, state_values, errors)) + + return decisions, errors + + +# --------------------------------------------------------------------------- +# Dispatch — apply ParsedDecisions onto RunRequirement instances so they're +# ready for agent.acontinue_run(requirements=[...]). +# --------------------------------------------------------------------------- + + +def apply_decisions( + decisions: List[ParsedDecision], + requirements: List[RunRequirement], +) -> None: + """Mutate each RunRequirement in place with the matching ParsedDecision. + Caller must supply hydrated RunRequirement objects (via from_dict if + they came out of the approval row).""" + by_id = {r.id: r for r in requirements if r.id} + for decision in decisions: + req = by_id.get(decision.requirement_id) + if req is None: + continue + if decision.pause_type == "confirmation": + if decision.approved: + req.confirm() + else: + req.reject(decision.rejected_note) + elif decision.pause_type == "user_input": + if decision.input_values is not None: + req.provide_user_input(decision.input_values) + elif decision.pause_type == "user_feedback": + if decision.feedback_selections is not None: + req.provide_user_feedback(decision.feedback_selections) + elif decision.pause_type == "external_execution": + if decision.external_result: + req.set_external_execution_result(decision.external_result) diff --git a/libs/agno/agno/os/interfaces/slack/events.py b/libs/agno/agno/os/interfaces/slack/events.py index fb44af199a..d51aa0ac22 100644 --- a/libs/agno/agno/os/interfaces/slack/events.py +++ b/libs/agno/agno/os/interfaces/slack/events.py @@ -263,6 +263,14 @@ async def _on_run_error(chunk: BaseRunOutputEvent, state: StreamState, stream: A return True +async def _on_run_paused(chunk: BaseRunOutputEvent, state: StreamState, stream: AsyncChatStream) -> bool: + # Stash the pause event so the router can post the Block Kit pause card + # after the stream closes (chat_stream can't carry Block Kit payloads). + state.paused_event = chunk + state.terminal_status = "complete" + return True + + # ============================================================================= # Workflow Event Handlers (require custom logic) # ============================================================================= @@ -387,6 +395,7 @@ async def _on_loop_execution_completed(chunk: BaseRunOutputEvent, state: StreamS RunEvent.run_completed.value: _on_run_completed, RunEvent.run_error.value: _on_run_error, RunEvent.run_cancelled.value: _on_run_error, # Treat cancellation as terminal error + RunEvent.run_paused.value: _on_run_paused, # HITL — router posts Block Kit after stream ends # ------------------------------------------------------------------------- # Workflow Lifecycle Events # ------------------------------------------------------------------------- diff --git a/libs/agno/agno/os/interfaces/slack/router.py b/libs/agno/agno/os/interfaces/slack/router.py index 0c946ea852..a43ccbb631 100644 --- a/libs/agno/agno/os/interfaces/slack/router.py +++ b/libs/agno/agno/os/interfaces/slack/router.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, Field from agno.agent import Agent, RemoteAgent +from agno.os.interfaces.slack.blocks import build_pause_message from agno.os.interfaces.slack.events import process_event from agno.os.interfaces.slack.helpers import ( build_run_metadata, @@ -56,6 +57,38 @@ class SlackChallengeResponse(BaseModel): challenge: str = Field(description="Challenge string to echo back to Slack") +async def _post_pause_message_safely( + async_client: Any, + paused_event: Any, + channel: str, + thread_ts: str, +) -> None: + """Post the Block Kit pause card into the thread after stream.stop(). + Runs AsyncChatStream-independent because chat_stream doesn't support + Block Kit payloads.""" + run_id = getattr(paused_event, "run_id", None) + # active_requirements filters out anything already resolved in-flight. + requirements = list(getattr(paused_event, "active_requirements", None) or []) + if not run_id or not requirements: + return + try: + # Use run_id as the approval identifier in block_ids. On SUBMIT, F11 + # recovers the run_id from the 'pause:' block_id and fetches + # the requirements via entity.aget_run_output(run_id, session_id). + blocks = build_pause_message(run_id, requirements) + # BlockKitMessage would validate here; we skip and emit raw dicts to + # keep chat.postMessage independent of the Pydantic roundtrip. + block_dicts = [b.model_dump(exclude_none=True, mode="json") for b in blocks] + await async_client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text="Run paused — please resolve below", + blocks=block_dicts, + ) + except Exception as exc: + log_error(f"Failed to post pause message (run_id={run_id}): {exc}") + + def attach_routes( router: APIRouter, agent: Optional[Union[Agent, RemoteAgent]] = None, @@ -73,6 +106,8 @@ def attach_routes( buffer_size: int = 100, max_file_size: int = 1_073_741_824, # 1GB resolve_user_identity: bool = False, + hitl_enabled: bool = False, + approval_authorization: Any = "requester_only", ) -> APIRouter: # Inner functions capture config via closure to keep each instance isolated entity = agent or team or workflow @@ -148,6 +183,280 @@ async def slack_events(request: Request, background_tasks: BackgroundTasks): return SlackEventResponse(status="ok") + @router.post( + "/interactions", + operation_id=f"slack_interactions_{op_suffix}", + name="slack_interactions", + description="Handle Slack interactive components (HITL buttons / form submit)", + response_model=SlackEventResponse, + response_model_exclude_none=True, + responses={ + 200: {"description": "Interaction accepted"}, + 400: {"description": "Malformed interaction payload"}, + 403: {"description": "Invalid Slack signature"}, + }, + ) + async def slack_interactions(request: Request, background_tasks: BackgroundTasks): + if not hitl_enabled: + # HITL not enabled — drop silently. The endpoint is always mounted + # so Slack's app-manifest configuration doesn't have to change per + # deployment, but we no-op when the feature is off. + return SlackEventResponse(status="ok") + + body = await request.body() + timestamp = request.headers.get("X-Slack-Request-Timestamp") + slack_signature = request.headers.get("X-Slack-Signature", "") + if not timestamp or not slack_signature: + raise HTTPException(status_code=400, detail="Missing Slack headers") + if not verify_slack_signature(body, timestamp, slack_signature, signing_secret=signing_secret): + raise HTTPException(status_code=403, detail="Invalid signature") + + # Pre-ack retry drop — Slack retries after ~3s if we don't ack. We ACK + # below; any retry arriving before that gets the same 200 response. + if request.headers.get("X-Slack-Retry-Num"): + return SlackEventResponse(status="ok") + + # Slack sends interactive payloads as application/x-www-form-urlencoded + # with a single form field `payload=`. + form = await request.form() + payload_raw = form.get("payload") + if not isinstance(payload_raw, str) or not payload_raw: + raise HTTPException(status_code=400, detail="Missing payload") + try: + import json as _json + + payload = _json.loads(payload_raw) + except Exception: + raise HTTPException(status_code=400, detail="Malformed payload JSON") + + # Dispatch by action_id — only block_actions payloads carry HITL clicks. + if payload.get("type") != "block_actions": + return SlackEventResponse(status="ok") + actions = payload.get("actions") or [] + if not actions: + return SlackEventResponse(status="ok") + action_id = actions[0].get("action_id", "") + + if action_id in ("row_approve", "row_reject"): + background_tasks.add_task(_handle_row_click, payload) + elif action_id == "submit_pause": + background_tasks.add_task(_handle_submit, payload) + # Silently ignore unknown action_ids — a non-HITL Slack app sharing + # the same endpoint might also post interactions here. + + return SlackEventResponse(status="ok") + + async def _handle_row_click(payload: Dict[str, Any]) -> None: + """Approve/Deny click on a confirmation card. + + If the pause is confirmation-only (no form-collecting rows), this is + the atomic commit — we update the row visually AND trigger the full + submit/resume flow inline. If the pause is mixed, we only update the + visual; the Submit button will commit everything later. + """ + from slack_sdk.web.async_client import AsyncWebClient + + from agno.os.interfaces.slack.blocks import parse_row_block_id + + actions = payload.get("actions") or [] + if not actions: + return + action_id = actions[0].get("action_id") or "" + button_value = actions[0].get("value") or "" + if action_id not in ("row_approve", "row_reject"): + return + # button_value carries "req_id|approval_id" — this is our source of + # truth. The Actions block_id is now "rowact:..." (not a row_block_id), + # so parsing req_id from block_id would fail. + if "|" not in button_value: + return + req_id, approval_id = button_value.split("|", 1) + decided = "approve" if action_id == "row_approve" else "reject" + + channel = (payload.get("channel") or {}).get("id") + message = payload.get("message") or {} + msg_ts = message.get("ts") + original_blocks = list(message.get("blocks") or []) + if not channel or not msg_ts: + return + + # Collapse the clicked row into a decided chip; preserve other blocks. + new_blocks: List[Dict[str, Any]] = [] + any_pending_confirm = False + has_submit_block = False + for block in original_blocks: + bid = block.get("block_id") or "" + block_type = block.get("type") + if block_type == "actions" and bid.startswith("pause:"): + has_submit_block = True + new_blocks.append(block) + continue + # The decided row's companion Actions block (buttons) — drop it so + # users can't click again. Matched by the rowact::... prefix + # emitted by _build_confirmation_row. + if bid == f"rowact:{req_id}:confirmation": + continue + parsed_block = parse_row_block_id(bid) + is_confirm_row = parsed_block and parsed_block.get("kind") == "confirmation" + if is_confirm_row and parsed_block.get("req_id") == req_id: + chip = "✅ Approved" if decided == "approve" else "❌ Rejected" + # task_card has title; Section has text; older Card uses body. + body_text = ( + block.get("title") + or (block.get("text") or {}).get("text") + or (block.get("body") or {}).get("text") + or "" + ) + new_blocks.append( + { + "type": "section", + "block_id": f"row:{req_id}:confirmation:decided:{decided}", + "text": {"type": "mrkdwn", "text": f"*{body_text}*\n\n{chip}"}, + } + ) + else: + if is_confirm_row and parsed_block.get("status") == "pending": + any_pending_confirm = True + new_blocks.append(block) + + client = AsyncWebClient(token=slack_tools.token, ssl=ssl) + try: + await client.chat_update(channel=channel, ts=msg_ts, blocks=new_blocks, text="Run paused") + except Exception as exc: + log_error(f"[HITL] chat_update failed for row {req_id}: {exc}") + + # Atomic commit for confirmation-only pauses: no Submit block + no + # other pending confirmation rows → trigger the submit/resume flow. + if not has_submit_block and not any_pending_confirm and approval_id: + synthetic_payload = dict(payload) + # Repurpose: make it look like a submit_pause click so _handle_submit + # reads it uniformly. We inject the approval_id via submit block_id. + synthetic_payload["actions"] = [ + { + "action_id": "submit_pause", + "block_id": f"pause:{approval_id}", + "value": approval_id, + } + ] + # Replace the message blocks in the payload with the updated + # state so parse_submit_payload sees the 'decided:' + # block_ids on confirmation rows. + synthetic_payload["message"] = {**(payload.get("message") or {}), "blocks": new_blocks} + await _handle_submit(synthetic_payload) + + async def _handle_submit(payload: Dict[str, Any]) -> None: + """SUBMIT button clicked. Parse the form state, hydrate the paused + requirements, apply user decisions, then resume the run and post the + continuation back to the same thread. + + v0 uses non-streaming continuation for simplicity — one chat.postMessage + per resumption with the final content. No CAS guard (last write wins). + """ + from slack_sdk.web.async_client import AsyncWebClient + + from agno.os.interfaces.slack.blocks import ( + apply_decisions, + parse_submit_payload, + ) + + actions = payload.get("actions") or [] + if not actions: + return + submit_block_id = actions[0].get("block_id") or "" + # block_id shape for SUBMIT: "pause:" + if not submit_block_id.startswith("pause:"): + return + run_id = submit_block_id[len("pause:") :] + channel = (payload.get("channel") or {}).get("id") + message = payload.get("message") or {} + msg_ts = message.get("ts") + if not (run_id and channel and msg_ts): + return + + # thread_ts: the pause card was posted inside an assistant thread. + # Slack includes the thread_ts in message metadata; fall back to msg_ts. + thread_ts = message.get("thread_ts") or msg_ts + session_id = f"{entity_id}:{thread_ts}" + + client = AsyncWebClient(token=slack_tools.token, ssl=ssl) + + # Fetch the paused run so we can hydrate the in-memory requirements. + # aget_run_output pulls the last RunOutput from the agent's session storage. + try: + run_output = await entity.aget_run_output(run_id=run_id, session_id=session_id) # type: ignore[union-attr] + except Exception as exc: + log_error(f"[HITL] aget_run_output failed for run={run_id}: {exc}") + run_output = None + + requirements = list(getattr(run_output, "requirements", None) or []) if run_output else [] + if not requirements: + await _post_ephemeral( + client, + channel=channel, + user=(payload.get("user") or {}).get("id", ""), + text="This approval is no longer active.", + ) + return + + decisions, errors = parse_submit_payload(payload, requirements) + if errors: + detail = "\n".join(f"• {e.field}: {e.message}" for e in errors) + await _post_ephemeral( + client, + channel=channel, + user=(payload.get("user") or {}).get("id", ""), + text=f"Please fix the following and submit again:\n{detail}", + ) + return + + apply_decisions(decisions, requirements) + + # Leave the pause card in whatever decided-chip state _handle_row_click + # already set (e.g., "✅ Approved" or "❌ Rejected"). Overwriting it + # with a "Submitted — resuming…" context block erased the chip and + # produced a confusing near-empty message bubble. The continuation + # message that posts next already gives the user resumption feedback. + + # Resume the run with hydrated requirements. v0 uses stream=False for + # simplicity; later versions can plumb continuation into chat_stream. + try: + continued = await entity.acontinue_run( # type: ignore[union-attr] + run_id=run_id, + requirements=requirements, + session_id=session_id, + stream=False, + ) + except Exception as exc: + log_error(f"[HITL] acontinue_run failed for run={run_id}: {exc}") + await send_slack_message_async( + client, + channel=channel, + message=_ERROR_MESSAGE, + thread_ts=thread_ts, + ) + return + + content = getattr(continued, "content", None) or "" + if content: + await send_slack_message_async( + client, + channel=channel, + message=str(content), + thread_ts=thread_ts, + ) + + async def _post_ephemeral( + client: Any, + *, + channel: str, + user: str, + text: str, + ) -> None: + try: + await client.chat_postEphemeral(channel=channel, user=user, text=text) + except Exception as exc: + log_error(f"[HITL] chat_postEphemeral failed: {exc}") + async def _process_slack_event(data: dict): event = data["event"] if not should_respond(event, reply_to_mentions_only): @@ -424,7 +733,42 @@ async def _rotate_stream(pending_text: str = ""): stop_kwargs["markdown_text"] = state.flush() if completion_chunks: stop_kwargs["chunks"] = completion_chunks - await stream.stop(**stop_kwargs) + + # HITL: attach the approval UI directly to the finalized streamed + # message via stream.stop(blocks=...). Keeps task cards + approval + # in one cohesive message with no visual seam. + pause_blocks: Optional[List[Dict[str, Any]]] = None + if hitl_enabled and state.paused_event is not None: + run_id = getattr(state.paused_event, "run_id", None) + requirements = list(getattr(state.paused_event, "active_requirements", None) or []) + if run_id and requirements: + try: + pydantic_blocks = build_pause_message(run_id, requirements) + pause_blocks = [b.model_dump(exclude_none=True, mode="json") for b in pydantic_blocks] + stop_kwargs["blocks"] = pause_blocks + except Exception as exc: + log_error(f"[HITL] Failed to build pause blocks: {exc}") + + try: + await stream.stop(**stop_kwargs) + except Exception as exc: + # Fall back: stop without blocks, then post separately. Covers + # the case where Slack rejects task_card blocks for this workspace. + if pause_blocks: + log_error(f"[HITL] stream.stop(blocks=) failed, falling back to post: {exc}") + stop_kwargs.pop("blocks", None) + try: + await stream.stop(**stop_kwargs) + except Exception: + pass + await _post_pause_message_safely( + async_client, + state.paused_event, + ctx["channel_id"], + ctx["thread_id"], + ) + else: + raise await upload_response_media_async(async_client, state, ctx["channel_id"], ctx["thread_id"]) diff --git a/libs/agno/agno/os/interfaces/slack/slack.py b/libs/agno/agno/os/interfaces/slack/slack.py index 15d7eb1a1c..d82022f484 100644 --- a/libs/agno/agno/os/interfaces/slack/slack.py +++ b/libs/agno/agno/os/interfaces/slack/slack.py @@ -5,6 +5,7 @@ from agno.agent import Agent, RemoteAgent from agno.os.interfaces.base import BaseInterface +from agno.os.interfaces.slack.authorization import ApprovalPolicy, assert_hitl_backend from agno.os.interfaces.slack.router import attach_routes from agno.team import RemoteTeam, Team from agno.workflow import RemoteWorkflow, Workflow @@ -34,6 +35,8 @@ def __init__( buffer_size: int = 100, max_file_size: int = 1_073_741_824, # 1GB resolve_user_identity: bool = False, + hitl_enabled: bool = False, + approval_authorization: ApprovalPolicy = "requester_only", ): self.agent = agent self.team = team @@ -52,10 +55,19 @@ def __init__( self.buffer_size = buffer_size self.max_file_size = max_file_size self.resolve_user_identity = resolve_user_identity + self.hitl_enabled = hitl_enabled + self.approval_authorization = approval_authorization if not (self.agent or self.team or self.workflow): raise ValueError("Slack requires an agent, team, or workflow") + if self.hitl_enabled: + # Fail-fast at construction rather than at first pause event. + entity = self.agent or self.team + if entity is None: + raise ValueError("hitl_enabled=True requires an Agent or Team (workflow not yet supported)") + assert_hitl_backend(getattr(entity, "db", None)) + def get_router(self) -> APIRouter: self.router = attach_routes( router=APIRouter(prefix=self.prefix, tags=self.tags), # type: ignore @@ -74,6 +86,8 @@ def get_router(self) -> APIRouter: buffer_size=self.buffer_size, max_file_size=self.max_file_size, resolve_user_identity=self.resolve_user_identity, + hitl_enabled=self.hitl_enabled, + approval_authorization=self.approval_authorization, ) return self.router diff --git a/libs/agno/agno/os/interfaces/slack/state.py b/libs/agno/agno/os/interfaces/slack/state.py index 3d87e7ff17..f914351c11 100644 --- a/libs/agno/agno/os/interfaces/slack/state.py +++ b/libs/agno/agno/os/interfaces/slack/state.py @@ -59,6 +59,11 @@ class StreamState: # Total chars sent to the current Slack stream; reset on rotation stream_chars_sent: int = 0 + # HITL: set by _on_run_paused when a RunPausedEvent fires. Router reads + # this after stream.stop() to post the pause message via chat.postMessage + # (AsyncChatStream only handles task_update chunks, not Block Kit). + paused_event: Optional["BaseRunOutputEvent"] = None + def track_task(self, key: str, title: str) -> None: self.task_cards[key] = TaskCard(title=title) diff --git a/libs/agno/tests/unit/os/routers/test_slack_block_kit.py b/libs/agno/tests/unit/os/routers/test_slack_block_kit.py new file mode 100644 index 0000000000..3e1f155d87 --- /dev/null +++ b/libs/agno/tests/unit/os/routers/test_slack_block_kit.py @@ -0,0 +1,138 @@ +import pytest +from pydantic import ValidationError + +from agno.os.interfaces.slack.block_kit import ( + Actions, + BlockKitMessage, + Button, + InputBlock, + PlainText, + PlainTextInput, + RichText, + RichTextLink, + RichTextPlain, + RichTextSection, + RichTextStyle, + Section, + TaskCard, + TaskCardSource, +) + + +class TestTaskCard: + def test_minimal_valid(self): + card = TaskCard(task_id="approval:abc", title="Approval required", status="in_progress") + assert card.type == "task_card" + assert card.details is None + assert card.output is None + + def test_rejects_pending_status(self): + # Slack rejects "pending" — task_card only accepts in_progress/completed/error. + with pytest.raises(ValidationError): + TaskCard(task_id="t1", title="x", status="pending") + + def test_allows_completed_and_error(self): + TaskCard(task_id="t1", title="x", status="completed") + TaskCard(task_id="t1", title="x", status="error") + + def test_requires_title(self): + with pytest.raises(ValidationError): + TaskCard(task_id="t1", status="in_progress") + + def test_requires_task_id(self): + with pytest.raises(ValidationError): + TaskCard(title="x", status="in_progress") + + def test_dump_excludes_none(self): + card = TaskCard(task_id="t1", title="x", status="in_progress") + dumped = card.model_dump(exclude_none=True, mode="json") + assert dumped == {"type": "task_card", "task_id": "t1", "title": "x", "status": "in_progress"} + + def test_with_details_and_sources(self): + card = TaskCard( + task_id="t1", + title="x", + status="in_progress", + details=RichText(elements=[RichTextSection(elements=[RichTextPlain(text="hello")])]), + sources=[TaskCardSource(url="https://example.com", text="ref")], + ) + dumped = card.model_dump(exclude_none=True, mode="json") + assert dumped["details"]["type"] == "rich_text" + assert dumped["sources"][0]["type"] == "url" + + +class TestRichText: + def test_section_requires_elements(self): + with pytest.raises(ValidationError): + RichTextSection() + + def test_plain_with_style(self): + plain = RichTextPlain(text="k:", style=RichTextStyle(bold=True)) + assert plain.style.bold is True + assert plain.style.italic is None + + def test_style_accepts_dict(self): + # Row builder passes {"bold": True} — verify Pydantic coerces it. + section = RichTextSection(elements=[RichTextPlain(text="x", style={"bold": True})]) + assert section.elements[0].style.bold is True + + def test_link_discriminator(self): + section = RichTextSection( + elements=[ + RichTextPlain(text="see "), + RichTextLink(url="https://example.com", text="docs"), + ] + ) + assert section.elements[0].type == "text" + assert section.elements[1].type == "link" + + def test_rich_text_roundtrip_preserves_shape(self): + rt = RichText( + elements=[ + RichTextSection( + elements=[ + RichTextPlain(text="path: ", style=RichTextStyle(bold=True)), + RichTextPlain(text="/tmp/demo.txt"), + ] + ) + ] + ) + dumped = rt.model_dump(exclude_none=True, mode="json") + assert dumped["type"] == "rich_text" + assert len(dumped["elements"]) == 1 + inner = dumped["elements"][0]["elements"] + assert inner[0]["style"] == {"bold": True} + assert "style" not in inner[1] + + +class TestBlockKitMessage: + def test_to_slack_payload_shape(self): + msg = BlockKitMessage( + text="fallback", + blocks=[ + TaskCard(task_id="t1", title="x", status="in_progress"), + Actions( + elements=[ + Button(action_id="approve", text=PlainText(text="Approve"), value="t1"), + ] + ), + ], + ) + payload = msg.to_slack_payload() + assert payload["text"] == "fallback" + assert [b["type"] for b in payload["blocks"]] == ["task_card", "actions"] + + def test_rejects_unknown_block_type(self): + # extra='forbid' — keeps payloads clean against typos. + with pytest.raises(ValidationError): + BlockKitMessage(text="x", blocks=[{"type": "bogus"}]) + + def test_input_block_in_union(self): + # InputBlock used by user_input/user_feedback/external_execution rows. + block = InputBlock( + label=PlainText(text="field"), + element=PlainTextInput(action_id="input_field:name"), + ) + msg = BlockKitMessage(text="x", blocks=[Section(text=PlainText(text="hi")), block]) + payload = msg.to_slack_payload() + assert payload["blocks"][1]["type"] == "input" diff --git a/libs/agno/tests/unit/os/routers/test_slack_blocks.py b/libs/agno/tests/unit/os/routers/test_slack_blocks.py new file mode 100644 index 0000000000..8b5ee56c9f --- /dev/null +++ b/libs/agno/tests/unit/os/routers/test_slack_blocks.py @@ -0,0 +1,384 @@ +from typing import Any, Dict + +from agno.models.response import ToolExecution +from agno.os.interfaces.slack.blocks import ( + ACTION_EXTERNAL_RESULT, + ACTION_FEEDBACK_SELECT, + ACTION_INPUT_FIELD_PREFIX, + ACTION_ROW_APPROVE, + ACTION_ROW_REJECT, + ACTION_SUBMIT, + build_pause_message, + classify_requirement, + parse_row_block_id, + parse_submit_payload, + pause_block_id, + row_block_id, +) +from agno.run.requirement import RunRequirement, UserFeedbackQuestion +from agno.tools.function import UserFeedbackOption, UserInputField + +# -- Helpers -- + + +def _make_tool_execution(**overrides) -> ToolExecution: + defaults = dict(tool_name="do_something", tool_args={"path": "/tmp/demo.txt"}) + defaults.update(overrides) + return ToolExecution(**defaults) + + +def _make_requirement(req_id: str = "r1", **te_overrides) -> RunRequirement: + return RunRequirement(tool_execution=_make_tool_execution(**te_overrides), id=req_id) + + +def _submit_payload(state_values=None, message_blocks=None) -> Dict[str, Any]: + return { + "state": {"values": state_values or {}}, + "message": {"blocks": message_blocks or []}, + } + + +# -- classify_requirement -- + + +class TestClassifyRequirement: + def test_user_feedback_wins(self): + # Order mirrors os.agno.com utils.ts: feedback > external > input > confirmation. + req = _make_requirement( + user_feedback_schema=[UserFeedbackQuestion(question="?", options=[UserFeedbackOption(label="A")])], + requires_user_input=True, + requires_confirmation=True, + ) + assert classify_requirement(req) == "user_feedback" + + def test_external_execution_wins_over_input_and_confirmation(self): + req = _make_requirement( + external_execution_required=True, + requires_user_input=True, + requires_confirmation=True, + ) + assert classify_requirement(req) == "external_execution" + + def test_user_input_wins_over_confirmation(self): + req = _make_requirement(requires_user_input=True, requires_confirmation=True) + assert classify_requirement(req) == "user_input" + + def test_defaults_to_confirmation(self): + req = _make_requirement(requires_confirmation=True) + assert classify_requirement(req) == "confirmation" + + def test_missing_tool_execution_is_confirmation(self): + # DB round-trip can drop tool_execution; fallback keeps the run safe. + req = _make_requirement() + req.tool_execution = None + assert classify_requirement(req) == "confirmation" + + +# -- row_block_id / parse_row_block_id -- + + +class TestRowBlockId: + def test_pending_round_trip(self): + assert parse_row_block_id(row_block_id("r1", "confirmation")) == { + "req_id": "r1", + "kind": "confirmation", + "status": "pending", + } + + def test_decided_round_trip(self): + assert parse_row_block_id(row_block_id("r1", "confirmation", decided="approve")) == { + "req_id": "r1", + "kind": "confirmation", + "status": "decided", + "decided": "approve", + } + + def test_non_row_prefix_returns_none(self): + assert parse_row_block_id("pause:A1") is None + assert parse_row_block_id("rowact:r1:confirmation") is None + + +# -- Confirmation row -- + + +class TestConfirmationRow: + def test_block_types(self): + req = _make_requirement(tool_name="delete_file") + blocks = build_pause_message("A1", [req]) + # No global Submit — Approve/Deny buttons carry their own confirm dialogs. + assert [b.type for b in blocks] == ["task_card", "actions"] + + def test_task_card(self): + card = build_pause_message("A1", [_make_requirement(tool_name="delete_file")])[0] + assert card.title == "Approval required: delete_file" + assert card.status == "in_progress" + assert card.block_id == row_block_id("r1", "confirmation") + + def test_button_action_ids(self): + actions = build_pause_message("A1", [_make_requirement()])[1] + assert [el.action_id for el in actions.elements] == [ACTION_ROW_APPROVE, ACTION_ROW_REJECT] + + def test_button_value_routing(self): + # _handle_row_click splits on "|" to recover (req_id, approval_id). + actions = build_pause_message("A1", [_make_requirement()])[1] + assert actions.elements[0].value == "r1|A1" + assert actions.elements[1].value == "r1|A1" + + def test_buttons_carry_confirm_dialogs(self): + actions = build_pause_message("A1", [_make_requirement()])[1] + assert actions.elements[0].confirm is not None + assert actions.elements[1].confirm is not None + + +# -- User-input row -- + + +class TestUserInputRow: + def test_block_types(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="to_address", field_type=str)], + ) + blocks = build_pause_message("A1", [req]) + assert [b.type for b in blocks] == ["task_card", "input", "actions"] + + def test_per_field_block_ids_are_unique(self): + # Regression — Slack rejects messages with duplicate block_ids. + req = _make_requirement( + requires_user_input=True, + user_input_schema=[ + UserInputField(name="to_address", field_type=str), + UserInputField(name="subject", field_type=str), + UserInputField(name="body", field_type=str), + ], + ) + input_blocks = [b for b in build_pause_message("A1", [req]) if b.type == "input"] + ids = [b.block_id for b in input_blocks] + assert len(set(ids)) == len(ids) == 3 + + def test_bool_field_uses_static_select(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="force", field_type=bool)], + ) + block = build_pause_message("A1", [req])[1] + assert block.element.type == "static_select" + assert [o.value for o in block.element.options] == ["true", "false"] + + def test_list_field_uses_multiline(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="tags", field_type=list)], + ) + block = build_pause_message("A1", [req])[1] + assert block.element.multiline is True + + +# -- User-feedback row -- + + +class TestUserFeedbackRow: + def test_multi_select_uses_checkboxes(self): + req = _make_requirement( + user_feedback_schema=[ + UserFeedbackQuestion( + question="Pick toppings", + options=[UserFeedbackOption(label="Mushroom"), UserFeedbackOption(label="Olives")], + multi_select=True, + ), + ], + ) + block = build_pause_message("A1", [req])[1] + assert block.element.type == "checkboxes" + assert block.element.action_id == f"{ACTION_FEEDBACK_SELECT}:0" + + def test_single_select_uses_static_select(self): + req = _make_requirement( + user_feedback_schema=[ + UserFeedbackQuestion( + question="Pick one", + options=[UserFeedbackOption(label="A"), UserFeedbackOption(label="B")], + ), + ], + ) + block = build_pause_message("A1", [req])[1] + assert block.element.type == "static_select" + + def test_question_index_in_block_id(self): + req = _make_requirement( + user_feedback_schema=[ + UserFeedbackQuestion(question="q0", options=[UserFeedbackOption(label="A")]), + UserFeedbackQuestion(question="q1", options=[UserFeedbackOption(label="B")]), + ], + ) + input_blocks = [b for b in build_pause_message("A1", [req]) if b.type == "input"] + prefix = row_block_id("r1", "user_feedback") + assert [b.block_id for b in input_blocks] == [f"{prefix}:q0", f"{prefix}:q1"] + + +# -- External-execution row -- + + +class TestExternalExecutionRow: + def test_block_types(self): + req = _make_requirement(tool_name="run_shell", external_execution_required=True) + blocks = build_pause_message("A1", [req]) + assert [b.type for b in blocks] == ["task_card", "input", "actions"] + + def test_multiline_plain_text_input(self): + req = _make_requirement(external_execution_required=True) + block = build_pause_message("A1", [req])[1] + assert block.element.type == "plain_text_input" + assert block.element.multiline is True + assert block.element.action_id == ACTION_EXTERNAL_RESULT + + +# -- Global Submit -- + + +class TestGlobalSubmit: + def test_confirmation_only_skips_submit(self): + blocks = build_pause_message("A1", [_make_requirement(req_id="r1"), _make_requirement(req_id="r2")]) + # Tail is per-row Approve/Deny, not a global Submit. + assert blocks[-1].block_id != pause_block_id("A1") + + def test_mixed_pause_adds_submit(self): + confirm = _make_requirement(req_id="r1", tool_name="delete_file") + input_req = _make_requirement( + req_id="r2", + requires_user_input=True, + user_input_schema=[UserInputField(name="x", field_type=str)], + ) + blocks = build_pause_message("A1", [confirm, input_req]) + assert blocks[-1].block_id == pause_block_id("A1") + assert blocks[-1].elements[0].action_id == ACTION_SUBMIT + + +# -- parse_submit_payload -- + + +class TestParseSubmitPayload: + def test_user_input_reads_per_field_block_ids(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[ + UserInputField(name="to_address", field_type=str), + UserInputField(name="subject", field_type=str), + ], + ) + prefix = row_block_id("r1", "user_input") + payload = _submit_payload( + state_values={ + f"{prefix}:to_address": { + f"{ACTION_INPUT_FIELD_PREFIX}to_address": {"type": "plain_text_input", "value": "you@example.com"}, + }, + f"{prefix}:subject": { + f"{ACTION_INPUT_FIELD_PREFIX}subject": {"type": "plain_text_input", "value": "Q1 results"}, + }, + } + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert errors == [] + assert decisions[0].input_values == {"to_address": "you@example.com", "subject": "Q1 results"} + + def test_user_input_bool_coerced(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="force", field_type=bool)], + ) + prefix = row_block_id("r1", "user_input") + payload = _submit_payload( + state_values={ + f"{prefix}:force": { + f"{ACTION_INPUT_FIELD_PREFIX}force": { + "type": "static_select", + "selected_option": {"value": "true"}, + }, + }, + } + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert errors == [] + assert decisions[0].input_values == {"force": True} + + def test_user_input_list_parsed_from_json(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="tags", field_type=list)], + ) + prefix = row_block_id("r1", "user_input") + payload = _submit_payload( + state_values={ + f"{prefix}:tags": { + f"{ACTION_INPUT_FIELD_PREFIX}tags": {"type": "plain_text_input", "value": '["a","b"]'}, + }, + } + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert errors == [] + assert decisions[0].input_values == {"tags": ["a", "b"]} + + def test_user_input_bad_json_records_error(self): + req = _make_requirement( + requires_user_input=True, + user_input_schema=[UserInputField(name="tags", field_type=list)], + ) + prefix = row_block_id("r1", "user_input") + payload = _submit_payload( + state_values={ + f"{prefix}:tags": { + f"{ACTION_INPUT_FIELD_PREFIX}tags": {"type": "plain_text_input", "value": "not json"}, + }, + } + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert len(errors) == 1 + assert errors[0].field == "tags" + assert decisions[0].input_values == {"tags": None} + + def test_confirmation_decided_approve(self): + req = _make_requirement(tool_name="delete_file") + payload = _submit_payload( + message_blocks=[ + {"block_id": row_block_id("r1", "confirmation", decided="approve"), "type": "section"}, + ] + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert errors == [] + assert decisions[0].approved is True + + def test_confirmation_without_click_defaults_to_rejected(self): + # Submit with no click — parser treats as rejected so runs stay safe. + req = _make_requirement(tool_name="delete_file") + decisions, _ = parse_submit_payload(_submit_payload(), [req]) + assert decisions[0].approved is False + assert decisions[0].rejected_note == "No decision made" + + def test_external_execution_strips_whitespace(self): + # Strip avoids accidental whitespace from pasted terminal output. + req = _make_requirement(external_execution_required=True) + prefix = row_block_id("r1", "external_execution") + payload = _submit_payload( + state_values={ + f"{prefix}:result": { + ACTION_EXTERNAL_RESULT: {"type": "plain_text_input", "value": " ok\n"}, + }, + } + ) + decisions, errors = parse_submit_payload(payload, [req]) + assert errors == [] + assert decisions[0].external_result == "ok" + + def test_external_execution_empty_value_records_error(self): + req = _make_requirement(external_execution_required=True) + prefix = row_block_id("r1", "external_execution") + payload = _submit_payload( + state_values={ + f"{prefix}:result": { + ACTION_EXTERNAL_RESULT: {"type": "plain_text_input", "value": " "}, + }, + } + ) + _, errors = parse_submit_payload(payload, [req]) + assert len(errors) == 1 + assert errors[0].requirement_id == "r1" diff --git a/libs/agno/tests/unit/os/test_os_security_key_webhooks.py b/libs/agno/tests/unit/os/test_os_security_key_webhooks.py new file mode 100644 index 0000000000..bdd0f4eac0 --- /dev/null +++ b/libs/agno/tests/unit/os/test_os_security_key_webhooks.py @@ -0,0 +1,162 @@ +import hashlib +import hmac +import json +import time +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from agno.agent import Agent +from agno.os.app import AgentOS +from agno.os.interfaces.slack import Slack +from agno.os.settings import AgnoAPISettings + +SIGNING_SECRET = "test-signing-secret" +OS_KEY = "test-os-security-key" + + +def _signed_headers(body_bytes: bytes, signing_secret: str = SIGNING_SECRET) -> dict: + timestamp = str(int(time.time())) + sig_base = f"v0:{timestamp}:{body_bytes.decode()}" + signature = "v0=" + hmac.new(signing_secret.encode(), sig_base.encode(), hashlib.sha256).hexdigest() + return { + "Content-Type": "application/json", + "X-Slack-Request-Timestamp": timestamp, + "X-Slack-Signature": signature, + } + + +@pytest.fixture +def agent_os_with_slack_and_key(): + agent = Agent(name="Test Agent", id="test-agent", telemetry=False) + agent.arun = AsyncMock( + return_value=Mock( + status="OK", content="ok", reasoning_content=None, images=None, files=None, videos=None, audio=None + ) + ) + + mock_slack_tools = Mock() + mock_slack_tools.send_message = Mock() + mock_slack_tools.upload_file = Mock() + mock_slack_tools.max_file_size = 1_073_741_824 + + mock_async_web_client = AsyncMock() + mock_async_web_client.users_info = AsyncMock(return_value={"ok": True, "user": {"id": "U123"}}) + + settings = AgnoAPISettings(os_security_key=OS_KEY) + slack = Slack(agent=agent, token="xoxb-test", signing_secret=SIGNING_SECRET, streaming=False) + + with ( + patch("agno.os.interfaces.slack.router.SlackTools", return_value=mock_slack_tools), + patch("slack_sdk.web.async_client.AsyncWebClient", return_value=mock_async_web_client), + ): + agent_os = AgentOS(agents=[agent], interfaces=[slack], settings=settings, telemetry=False) + app = agent_os.get_app() + yield app, agent.arun + + +def test_config_requires_bearer_when_os_security_key_set(agent_os_with_slack_and_key): + app, _ = agent_os_with_slack_and_key + client = TestClient(app) + + resp = client.get("/config") + assert resp.status_code == 401, f"Expected 401 on /config without Bearer, got {resp.status_code}" + + resp = client.get("/config", headers={"Authorization": f"Bearer {OS_KEY}"}) + assert resp.status_code == 200, f"Expected 200 on /config with valid Bearer, got {resp.status_code}: {resp.text}" + + +def test_slack_events_bypasses_os_security_key(agent_os_with_slack_and_key): + app, arun_mock = agent_os_with_slack_and_key + client = TestClient(app) + + body = { + "type": "event_callback", + "event": { + "type": "message", + "channel_type": "im", + "text": "hello", + "user": "U456", + "channel": "C123", + "ts": str(time.time()), + }, + } + body_bytes = json.dumps(body).encode() + + resp = client.post("/slack/events", content=body_bytes, headers=_signed_headers(body_bytes)) + + assert resp.status_code == 200, ( + f"Expected 200 on /slack/events with valid signature and NO Bearer (webhook should bypass " + f"OS_SECURITY_KEY), got {resp.status_code}: {resp.text}" + ) + + +def test_slack_events_rejects_bad_signature(agent_os_with_slack_and_key): + app, _ = agent_os_with_slack_and_key + client = TestClient(app) + + body_bytes = b'{"type": "event_callback"}' + bad_headers = _signed_headers(body_bytes, signing_secret="wrong-secret") + + resp = client.post("/slack/events", content=body_bytes, headers=bad_headers) + + assert resp.status_code == 403, f"Expected 403 from Slack signature check, got {resp.status_code}" + assert "signature" in resp.text.lower() + + +def test_slack_url_verification_bypasses_os_security_key(agent_os_with_slack_and_key): + app, _ = agent_os_with_slack_and_key + client = TestClient(app) + + body = {"type": "url_verification", "challenge": "abc123"} + body_bytes = json.dumps(body).encode() + + resp = client.post("/slack/events", content=body_bytes, headers=_signed_headers(body_bytes)) + + assert resp.status_code == 200 + assert resp.json().get("challenge") == "abc123" + + +def test_env_var_os_security_key_does_not_block_slack(monkeypatch): + """Simulate OS_SECURITY_KEY set in shell (.zshrc): should still let Slack webhooks through.""" + monkeypatch.setenv("OS_SECURITY_KEY", OS_KEY) + + agent = Agent(name="Env Test", id="env-test-agent", telemetry=False) + agent.arun = AsyncMock( + return_value=Mock( + status="OK", content="ok", reasoning_content=None, images=None, files=None, videos=None, audio=None + ) + ) + + mock_slack_tools = Mock() + mock_slack_tools.send_message = Mock() + mock_slack_tools.upload_file = Mock() + mock_slack_tools.max_file_size = 1_073_741_824 + mock_async_web_client = AsyncMock() + mock_async_web_client.users_info = AsyncMock(return_value={"ok": True, "user": {"id": "U123"}}) + + slack = Slack(agent=agent, token="xoxb-test", signing_secret=SIGNING_SECRET, streaming=False) + + with ( + patch("agno.os.interfaces.slack.router.SlackTools", return_value=mock_slack_tools), + patch("slack_sdk.web.async_client.AsyncWebClient", return_value=mock_async_web_client), + ): + agent_os = AgentOS(agents=[agent], interfaces=[slack], telemetry=False) + assert agent_os.settings.os_security_key == OS_KEY, "Env var should have been picked up by AgnoAPISettings" + + app = agent_os.get_app() + client = TestClient(app) + + resp = client.get("/config") + assert resp.status_code == 401, "/config must be protected when OS_SECURITY_KEY is in env" + + body = {"type": "url_verification", "challenge": "env-test"} + body_bytes = json.dumps(body).encode() + resp = client.post("/slack/events", content=body_bytes, headers=_signed_headers(body_bytes)) + + assert resp.status_code == 200, ( + f"With OS_SECURITY_KEY in env (simulating .zshrc), /slack/events should return 200 " + f"for a valid signed request. Got {resp.status_code}: {resp.text}" + ) + assert resp.json().get("challenge") == "env-test" From 05fbd5064f99074782082d43e0537d051899b1d8 Mon Sep 17 00:00:00 2001 From: Mustafa Esoofally Date: Tue, 21 Apr 2026 11:10:57 -0400 Subject: [PATCH 2/2] cookbook: Slack HITL scoped per pause type + incident commander MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single kitchen-sink test cookbook with four focused examples — one per pause type — and a compound incident-response cookbook that shows all four pause types working together. - hitl_confirmation.py: billing ops agent gates cancel_subscription behind Approve / Deny, with read-only lookup_customer and list_active_subscriptions for context. - hitl_user_input.py: support intake agent drafts a ticket and pauses for priority + component, with search_existing_tickets + DuckDuckGo for dup detection and error lookup. - hitl_user_feedback.py: travel concierge uses UserFeedbackTools to collect interests (multi-select) and travel style (single-select) in one pause, grounded by Wikipedia + DuckDuckGo. - hitl_external_execution.py: devops agent hands the requester a kubectl command to run, then analyses the pasted output against lookup_runbook and DuckDuckGo. - hitl_incident_commander.py: compound flow — triage via user_feedback, diagnose via external_execution, remediate via confirmation, retro via user_input — inside one Incident Commander agent. Removes hitl_all_types.py, hitl_ux_verify.py, and compare_resolution_flows.py (they were dev-time scaffolding). --- .../approvals/compare_resolution_flows.py | 203 ------ .../interfaces/slack/hitl_all_types.py | 173 ----- .../interfaces/slack/hitl_confirmation.py | 148 ++++ .../slack/hitl_external_execution.py | 138 ++++ .../slack/hitl_incident_commander.py | 218 ++++++ .../interfaces/slack/hitl_user_feedback.py | 79 ++ .../interfaces/slack/hitl_user_input.py | 148 ++++ .../interfaces/slack/hitl_ux_verify.py | 673 ------------------ 8 files changed, 731 insertions(+), 1049 deletions(-) delete mode 100644 cookbook/05_agent_os/approvals/compare_resolution_flows.py delete mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_all_types.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_confirmation.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_external_execution.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_incident_commander.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_user_feedback.py create mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_user_input.py delete mode 100644 cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py diff --git a/cookbook/05_agent_os/approvals/compare_resolution_flows.py b/cookbook/05_agent_os/approvals/compare_resolution_flows.py deleted file mode 100644 index 238f88798c..0000000000 --- a/cookbook/05_agent_os/approvals/compare_resolution_flows.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -Compare Resolution Flows -======================== - -Side-by-side demonstration of how the two AgentOS approval UIs resolve a -paused run with multiple requirements: - - - os.agno.com web UI: one Approve/Reject per whole approval - (POST /approvals/{id}/resolve flips the aggregate status in one call). - - Slack HITL (PR #7574): one Approve/Reject per RunRequirement row - (each click writes a per-row entry inside approval.resolution_data). - -No network, no agent run, no OpenAI call — a synthetic paused approval is -inserted into SQLite and both code paths exercise the same DB function. -The third scenario shows the concurrency gap between the two models. -""" - -import asyncio -import os -import time -import uuid -from pathlib import Path - -from agno.db.sqlite import SqliteDb - -DB_FILE = "tmp/compare_resolution.db" - - -def _build_approval() -> dict: - now = int(time.time()) - return { - "id": f"appr_{uuid.uuid4().hex[:8]}", - "run_id": "run_demo", - "session_id": "sess_demo", - "source_type": "agent", - "agent_id": "demo_agent", - "source_name": "Demo Agent", - "status": "pending", - "approval_type": "required", - "pause_type": "confirmation", - "requirements": [ - { - "id": "req_delete", - "tool_execution": { - "tool_name": "delete_file", - "tool_args": {"path": "/tmp/demo.txt"}, - }, - }, - { - "id": "req_transfer", - "tool_execution": { - "tool_name": "transfer_funds", - "tool_args": {"account_id": "42", "amount_usd": 500}, - }, - }, - ], - "resolution_data": None, - "context": {"tool_names": ["delete_file", "transfer_funds"]}, - "resolved_by": None, - "resolved_at": None, - "run_status": "paused", - "created_at": now, - "updated_at": now, - } - - -def _reset(db: SqliteDb) -> dict: - approval = _build_approval() - if db.get_approval(approval["id"]): - db.delete_approval(approval["id"]) - db.create_approval(approval) - return approval - - -def _dump(label: str, approval: dict) -> None: - status = approval.get("status") if approval else "" - rd = (approval or {}).get("resolution_data") or {} - rows = rd.get("requirement_resolutions") or {} - print(f" {label}") - print(f" aggregate status : {status!r}") - if rows: - for req_id, row in rows.items(): - print(f" row {req_id:13s}: {row}") - else: - print(" per-row data : (none)") - - -def scenario_web_ui(db: SqliteDb) -> None: - print("\n" + "=" * 72) - print("Scenario A — os.agno.com web UI (single Approve/Reject)") - print("=" * 72) - approval = _reset(db) - _dump("before", db.get_approval(approval["id"])) - - # Mirrors POST /approvals/{id}/resolve in agno/os/routers/approvals/router.py - after = db.update_approval( - approval["id"], - expected_status="pending", - status="approved", - resolved_by="admin@example.com", - resolved_at=int(time.time()), - ) - _dump("after 1 call", after) - - -def scenario_slack_sequential(db: SqliteDb) -> None: - print("\n" + "=" * 72) - print("Scenario B — Slack HITL (per-row, sequential clicks)") - print("=" * 72) - approval = _reset(db) - _dump("before", db.get_approval(approval["id"])) - - # Alice clicks Approve on row 1 - current = db.get_approval(approval["id"]) - rd = dict(current.get("resolution_data") or {}) - rd["requirement_resolutions"] = { - "req_delete": {"status": "approved", "actor": "U_ALICE"} - } - db.update_approval(approval["id"], expected_status="pending", resolution_data=rd) - _dump("after Alice approves row 1", db.get_approval(approval["id"])) - - # Bob clicks Approve on row 2 (reads Alice's state first) - current = db.get_approval(approval["id"]) - rd = dict(current.get("resolution_data") or {}) - existing_rows = dict(rd.get("requirement_resolutions") or {}) - existing_rows["req_transfer"] = {"status": "approved", "actor": "U_BOB"} - rd["requirement_resolutions"] = existing_rows - db.update_approval(approval["id"], expected_status="pending", resolution_data=rd) - _dump("after Bob approves row 2", db.get_approval(approval["id"])) - - # Last-row resolver flips aggregate status (interactions.py does this - # after acontinue_run succeeds). - db.update_approval( - approval["id"], - expected_status="pending", - status="approved", - resolved_by="U_BOB", - resolved_at=int(time.time()), - ) - _dump("after aggregate flip", db.get_approval(approval["id"])) - - -async def _click(db: SqliteDb, approval_id: str, row_id: str, actor: str) -> None: - # Each "click" reads approval, merges its own row into resolution_data, - # writes back. The 10ms sleep holds both coroutines at the same snapshot - # so their writes interleave — this is the window Slack cannot serialize. - current = db.get_approval(approval_id) - rd = dict(current.get("resolution_data") or {}) - existing_rows = dict(rd.get("requirement_resolutions") or {}) - await asyncio.sleep(0.01) - existing_rows[row_id] = {"status": "approved", "actor": actor} - rd["requirement_resolutions"] = existing_rows - db.update_approval(approval_id, expected_status="pending", resolution_data=rd) - - -async def scenario_slack_race(db: SqliteDb) -> None: - print("\n" + "=" * 72) - print("Scenario C — Slack HITL (concurrent clicks on DIFFERENT rows)") - print("=" * 72) - approval = _reset(db) - _dump("before", db.get_approval(approval["id"])) - - await asyncio.gather( - _click(db, approval["id"], "req_delete", "U_ALICE"), - _click(db, approval["id"], "req_transfer", "U_BOB"), - ) - _dump("after two concurrent writes", db.get_approval(approval["id"])) - final = db.get_approval(approval["id"]) - rows = (final.get("resolution_data") or {}).get("requirement_resolutions") or {} - if len(rows) < 2: - print(" !! vote lost: one row is missing from resolution_data") - else: - print(" (both rows present — rerun to catch the narrow window)") - - -def main() -> None: - Path("tmp").mkdir(parents=True, exist_ok=True) - if os.path.exists(DB_FILE): - os.remove(DB_FILE) - db = SqliteDb(db_file=DB_FILE, approvals_table="approvals") - - scenario_web_ui(db) - scenario_slack_sequential(db) - asyncio.run(scenario_slack_race(db)) - - print("\n" + "=" * 72) - print("Takeaway") - print("=" * 72) - print(" Web UI (A): lock field == write field (both are status). Safe.") - print(" Slack (B): sequential per-row writes preserve state. Safe.") - print(" Slack (C): concurrent per-row writes both pass the aggregate") - print(" CAS; the later write clobbers the earlier row. Broken.") - print() - print( - " To bring multi-row resolution to os.agno.com, POST /approvals/{id}/resolve" - ) - print(" would need a per-requirement payload AND either (a) a JSON-path CAS") - print(" predicate on the row's status, or (b) an asyncio.Lock keyed by") - print(" approval_id wrapping the read-modify-write.") - - -if __name__ == "__main__": - main() diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py b/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py deleted file mode 100644 index d8768581a2..0000000000 --- a/cookbook/05_agent_os/interfaces/slack/hitl_all_types.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Slack HITL — all 4 pause types -============================== - -Tests Path A Slack HITL with a single agent exposing one tool per pause type: - • delete_file(path) → confirmation (requires_confirmation=True) - • send_email(to, subject) → user_input (requires_user_input=True) - • run_shell(command) → external_execution (external_execution=True) - • ask_user(questions) → user_feedback (UserFeedbackTools) - -How to exercise each in Slack (DM the bot): - 1. "please delete /tmp/demo.txt" → confirmation card - 2. "send an email about Q1 results" → user_input form - 3. "run the shell command 'ls -la /tmp'" → external_execution card - 4. "ask me which pizza toppings I want: pepperoni, - mushroom, olives. make it multi-select" → user_feedback checkboxes - -For each, fill in / click as needed and press Submit. The agent resumes and -posts the tool's result back into the thread. - -Setup: - export SLACK_TOKEN=xoxb-... - export SLACK_SIGNING_SECRET=... - export SSL_CERT_FILE=$(python3 -c "import certifi; print(certifi.where())") - ngrok http 7777 - # In Slack App config: Event Subscriptions + Interactivity both point at: - # /slack/events - # /slack/interactions - - .venvs/demo/bin/python cookbook/05_agent_os/interfaces/slack/hitl_all_types.py -""" - -import json -from typing import List - -import httpx -from agno.agent import Agent -from agno.db.sqlite.sqlite import SqliteDb -from agno.models.openai import OpenAIChat -from agno.os.app import AgentOS -from agno.os.interfaces.slack import Slack -from agno.tools import tool -from agno.tools.calculator import CalculatorTools -from agno.tools.duckduckgo import DuckDuckGoTools -from agno.tools.hackernews import HackerNewsTools -from agno.tools.user_feedback import UserFeedbackTools -from agno.tools.wikipedia import WikipediaTools - -# --------------------------------------------------------------------------- -# Tools — one per HITL pause type -# --------------------------------------------------------------------------- - - -@tool(requires_confirmation=True) -def delete_file(path: str) -> str: - """Delete a file at the given path. Requires human approval before running. - - Args: - path: Absolute filesystem path to delete. - """ - # We don't actually delete — just report. Swap to os.remove() if you dare. - return f"(pretend) Deleted {path}" - - -@tool(requires_user_input=True, user_input_fields=["to_address", "subject"]) -def send_email(to_address: str, subject: str, body: str) -> str: - """Send an email. The `body` is provided by the agent; `to_address` and - `subject` are collected from the user at pause time. - - Args: - to_address: recipient email. - subject: email subject line. - body: email body (agent supplies this). - """ - return f"Sent email to {to_address} with subject {subject!r}: {body[:60]}…" - - -@tool(external_execution=True) -def run_shell(command: str) -> str: - """Execute a shell command on an external system. The user pastes the - output back into the HITL card. - - Args: - command: shell command to run. - """ - return f"Would run: {command}" # Unreachable — external_execution=True pauses first - - -# --------------------------------------------------------------------------- -# Optional: show real HN data works too (plain tool, no pause) -# --------------------------------------------------------------------------- - - -@tool -def top_hacker_news_stories(num_stories: int = 3) -> str: - """Fetch top stories from Hacker News (plain tool, no HITL).""" - response = httpx.get( - "https://hacker-news.firebaseio.com/v0/topstories.json", timeout=10 - ) - ids = response.json()[:num_stories] - stories: List[dict] = [] - for story_id in ids: - story = httpx.get( - f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json", timeout=10 - ).json() - story.pop("text", None) - stories.append(story) - return json.dumps(stories, indent=2) - - -# --------------------------------------------------------------------------- -# Agent + AgentOS + Slack interface -# --------------------------------------------------------------------------- - -db = SqliteDb( - db_file="tmp/hitl_all_types.db", - session_table="agent_sessions", - approvals_table="approvals", -) - -agent = Agent( - name="HITL Reference Agent", - id="hitl-reference-agent", - model=OpenAIChat(id="gpt-4o-mini"), - db=db, - tools=[ - # HITL pause types - delete_file, - send_email, - run_shell, - UserFeedbackTools(), - # Non-pause tools — exercise Slack's streaming task-card UI when - # multiple tools are invoked back-to-back in a single run. - top_hacker_news_stories, - DuckDuckGoTools(), - HackerNewsTools(), - WikipediaTools(), - CalculatorTools(), - ], - instructions=[ - "You are a HITL testing assistant. When the user asks you to do something " - "that matches one of your tools, call the tool — do not ask for confirmation " - "or clarification yourself; the framework will pause for human input.", - "For delete_file, call with the provided path.", - "For send_email, invent a short body and pass to_address + subject as placeholders " - "(the user will supply the real values via the pause form).", - "For run_shell, call with the exact command the user gave.", - "For ask_user, translate the user's description into AskUserQuestion objects " - "and pass as a list.", - ], - markdown=True, -) - -agent_os = AgentOS( - description="Slack HITL — all 4 pause types", - agents=[agent], - db=db, - interfaces=[ - Slack( - agent=agent, - hitl_enabled=True, # v0 HITL on - approval_authorization="requester_only", # only the user who triggered can resolve - reply_to_mentions_only=True, # channels require @mention; DMs always pass through - ), - ], -) -app = agent_os.get_app() - - -if __name__ == "__main__": - # Port 7778 matches the existing ngrok tunnel - # (https://paraphrastic-sang-ingenuous.ngrok-free.dev → localhost:7778). - agent_os.serve(app="hitl_all_types:app", reload=False, port=7778) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_confirmation.py b/cookbook/05_agent_os/interfaces/slack/hitl_confirmation.py new file mode 100644 index 0000000000..c30f43488b --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_confirmation.py @@ -0,0 +1,148 @@ +""" +Slack HITL — Confirmation +========================= + +Billing ops agent that can cancel customer subscriptions. Cancellation is +irreversible, so the destructive tool is wrapped with +`@tool(requires_confirmation=True)` — Slack pauses with Approve / Deny +buttons before the cancellation runs. The agent also has read-only +lookup tools so it can show the customer's context before asking to +confirm. + +Try in Slack: + @bot cancel C-42's subscription — they've been asking all week, churn reason pricing + +Slack scopes: app_mentions:read, assistant:write, chat:write, im:history +""" + +from dataclasses import dataclass +from typing import Dict, List + +from agno.agent import Agent +from agno.db.sqlite.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 + +# --------------------------------------------------------------------------- +# Stand-in billing data — replace with real Stripe / internal client +# --------------------------------------------------------------------------- + + +@dataclass +class Subscription: + customer_id: str + plan: str + monthly_rate: float + status: str + seat_count: int + + +_FAKE_DB: Dict[str, Subscription] = { + "C-42": Subscription("C-42", "Team", 399.0, "active", 12), + "C-77": Subscription("C-77", "Enterprise", 2499.0, "active", 120), + "C-91": Subscription("C-91", "Starter", 49.0, "past_due", 3), +} + + +# --------------------------------------------------------------------------- +# Read-only tools — no HITL needed, agent uses these to build context +# --------------------------------------------------------------------------- + + +@tool +def lookup_customer(customer_id: str) -> str: + """Return the customer's current subscription summary. + + Args: + customer_id: Customer identifier (e.g. "C-42"). + """ + sub = _FAKE_DB.get(customer_id) + if not sub: + return f"No record for {customer_id}." + return ( + f"{sub.customer_id}: plan={sub.plan}, rate=${sub.monthly_rate}/mo, " + f"status={sub.status}, seats={sub.seat_count}." + ) + + +@tool +def list_active_subscriptions() -> List[Dict[str, str]]: + """Return every active subscription. Useful when the user refers to + a customer by something other than their id.""" + return [ + {"customer_id": s.customer_id, "plan": s.plan, "status": s.status} + for s in _FAKE_DB.values() + if s.status == "active" + ] + + +# --------------------------------------------------------------------------- +# Destructive tool — pauses for human approval +# --------------------------------------------------------------------------- + + +@tool(requires_confirmation=True) +def cancel_subscription(customer_id: str, reason: str) -> str: + """Cancel a customer subscription. Irreversible — stops billing and + revokes access at the end of the current cycle. + + Args: + customer_id: Customer identifier (e.g. "C-42"). + reason: Short human-readable cancellation reason. + """ + sub = _FAKE_DB.get(customer_id) + if not sub: + return f"No record for {customer_id} — nothing to cancel." + sub.status = "cancelled" + return f"Subscription for {customer_id} cancelled. Reason logged: {reason!r}." + + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_confirmation.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="Billing Ops Agent", + id="billing-ops-agent", + model=OpenAIResponses(id="gpt-5.4"), + db=db, + tools=[lookup_customer, list_active_subscriptions, cancel_subscription], + instructions=[ + "You are a billing operations assistant embedded in Slack.", + "Before calling cancel_subscription, use lookup_customer (or " + "list_active_subscriptions if the user didn't give an id) so you can " + "show plan + rate in your summary.", + "When ready to cancel, call cancel_subscription with the customer_id " + "and a short reason drawn from the user's message. Do NOT ask the user " + "for final confirmation yourself — the Slack interface will pause the " + "run and show an Approve / Deny card.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — confirmation (subscription cancellation)", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, + approval_authorization="requester_only", + reply_to_mentions_only=True, + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + agent_os.serve(app="hitl_confirmation:app", reload=True) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_external_execution.py b/cookbook/05_agent_os/interfaces/slack/hitl_external_execution.py new file mode 100644 index 0000000000..171165fbe4 --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_external_execution.py @@ -0,0 +1,138 @@ +""" +Slack HITL — External Execution +=============================== + +DevOps assistant that inspects Kubernetes clusters the agent itself can't +reach. The tool is marked `external_execution=True`, so Slack pauses and +asks the requester to run the kubectl command on their own laptop and +paste the output back. The agent then analyses the pasted result. A local +runbook lookup + web search round out the toolbox so the agent can +correlate symptoms with known remediations. + +Try in Slack: + @bot check the api-gateway pods in the prod namespace + +Slack scopes: app_mentions:read, assistant:write, chat:write, im:history +""" + +from typing import Dict + +from agno.agent import Agent +from agno.db.sqlite.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 +from agno.tools.duckduckgo import DuckDuckGoTools + +# --------------------------------------------------------------------------- +# Stand-in runbook store — replace with Confluence / Notion client +# --------------------------------------------------------------------------- + + +_RUNBOOKS: Dict[str, str] = { + "CrashLoopBackOff": ( + "1. `kubectl describe pod -n ` — inspect lastState.reason.\n" + "2. If OOMKilled → bump memory limit in deployment.yaml.\n" + "3. If non-zero exit → pull logs with `kubectl logs --previous`.\n" + ), + "ImagePullBackOff": ( + "1. Verify the image tag exists in the registry.\n" + "2. Check imagePullSecrets are attached to the ServiceAccount.\n" + ), + "Pending": ( + "1. `kubectl describe pod` — look for Unschedulable events.\n" + "2. Common causes: insufficient CPU / memory, no matching node selector.\n" + ), +} + + +# --------------------------------------------------------------------------- +# Read-only runbook lookup +# --------------------------------------------------------------------------- + + +@tool +def lookup_runbook(symptom: str) -> str: + """Return internal runbook steps for a known pod symptom. Use after + seeing a status like CrashLoopBackOff / ImagePullBackOff / Pending. + + Args: + symptom: Exact k8s pod status / reason string. + """ + steps = _RUNBOOKS.get(symptom) + if not steps: + return f"No runbook for {symptom!r}. Try DuckDuckGo for public docs." + return f"Runbook for {symptom}:\n{steps}" + + +# --------------------------------------------------------------------------- +# External tool — user runs it and pastes output back +# --------------------------------------------------------------------------- + + +@tool(external_execution=True) +def kubectl_get_pods(namespace: str, selector: str = "") -> str: + """Describe pods matching a label selector. The agent does NOT run this — + the requester pastes the raw command output back into the Slack card + and the agent analyses it. + + Args: + namespace: Kubernetes namespace. + selector: Optional label selector, e.g. "app=api-gateway". + """ + flag = f" -l {selector}" if selector else "" + return f"kubectl get pods -n {namespace}{flag}" + + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_external_execution.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="DevOps Agent", + id="devops-agent", + model=OpenAIResponses(id="gpt-5.4"), + db=db, + tools=[ + kubectl_get_pods, + lookup_runbook, + DuckDuckGoTools(), + ], + instructions=[ + "You are a DevOps assistant embedded in Slack.", + "When the user asks about pod status, call kubectl_get_pods with the " + "right namespace and selector. Slack will pause, show them the " + "command, and ask them to paste the output back.", + "After the paused result returns: (1) summarise pod health (Running / " + "CrashLoopBackOff / Pending / Failed counts); (2) if you see a known " + "symptom, call lookup_runbook — prefer internal docs over web search; " + "(3) only fall back to DuckDuckGo if no runbook matches.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — external execution (kubectl)", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, + approval_authorization="requester_only", + reply_to_mentions_only=True, + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + agent_os.serve(app="hitl_external_execution:app", reload=True) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_incident_commander.py b/cookbook/05_agent_os/interfaces/slack/hitl_incident_commander.py new file mode 100644 index 0000000000..ed9cbd23e2 --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_incident_commander.py @@ -0,0 +1,218 @@ +""" +Slack HITL — Incident Commander +=============================== + +Compound HITL cookbook showing all four pause types inside one realistic +incident-response flow. The agent is summoned during a production +incident and walks the on-call through triage, diagnostics, remediation, +and retrospective ticket filing — pausing whenever it needs the human +judgment only the requester has. + +Pause points in this flow: + 1. user_feedback → severity + affected subsystems (up front) + 2. external_execution → engineer runs a diagnostic command and pastes output + 3. confirmation → restart a production service (destructive, gated) + 4. user_input → retrospective ticket priority + on-call owner + +Try in Slack: + @bot prod api returning 500s in eu-west, help me triage + +Slack scopes: app_mentions:read, assistant:write, chat:write, im:history +""" + +from dataclasses import dataclass +from typing import Dict, List +from uuid import uuid4 + +from agno.agent import Agent +from agno.db.sqlite.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 +from agno.tools.duckduckgo import DuckDuckGoTools +from agno.tools.user_feedback import UserFeedbackTools + +# --------------------------------------------------------------------------- +# Stand-in incident registry + service catalog +# --------------------------------------------------------------------------- + + +@dataclass +class Service: + name: str + region: str + replicas: int + runbook: str + + +_SERVICES: Dict[str, Service] = { + "api-gateway": Service("api-gateway", "eu-west", 12, "rb/api-gateway"), + "order-worker": Service("order-worker", "eu-west", 6, "rb/order-worker"), + "user-profile": Service("user-profile", "us-east", 4, "rb/user-profile"), +} + +_INCIDENTS: List[Dict[str, str]] = [] + + +# --------------------------------------------------------------------------- +# Read-only context tools +# --------------------------------------------------------------------------- + + +@tool +def lookup_service(service_name: str) -> str: + """Return replica count, region, and runbook link for a service. + + Args: + service_name: Logical service name (e.g. "api-gateway"). + """ + svc = _SERVICES.get(service_name) + if not svc: + known = ", ".join(_SERVICES) or "(none)" + return f"No service {service_name!r}. Known: {known}." + return ( + f"{svc.name}: region={svc.region}, replicas={svc.replicas}, " + f"runbook={svc.runbook}" + ) + + +@tool +def list_recent_incidents() -> List[Dict[str, str]]: + """Return the most recent incidents filed in this session (newest first).""" + return list(reversed(_INCIDENTS[-5:])) + + +# --------------------------------------------------------------------------- +# HITL tools — one per pause type +# --------------------------------------------------------------------------- + + +@tool(external_execution=True) +def run_diagnostic(command: str, note: str = "") -> str: + """Run a diagnostic command against production. The agent does NOT + execute this — the on-call engineer runs it on their jumpbox and + pastes the raw output back into the Slack card. + + Args: + command: Exact shell / kubectl command to run. + note: Optional short note about what the agent wants to see. + """ + # Unreachable — external_execution=True pauses before the body runs. + return f"[ran] {command} {note}".strip() + + +@tool(requires_confirmation=True) +def restart_service(service_name: str, reason: str) -> str: + """Roll-restart every replica of a service. Destructive — briefly + drops in-flight requests, so the Slack interface pauses for Approve + / Deny before running. + + Args: + service_name: Service to restart (matches lookup_service). + reason: One-line justification, recorded in the audit log. + """ + svc = _SERVICES.get(service_name) + if not svc: + return f"No service {service_name!r} — nothing restarted." + return ( + f"Rolled {svc.replicas} replicas of {svc.name} in {svc.region}. " + f"Reason: {reason!r}." + ) + + +@tool(requires_user_input=True, user_input_fields=["priority", "on_call_owner"]) +def file_incident_retro( + title: str, + summary: str, + priority: str, + on_call_owner: str, +) -> str: + """Open a retrospective ticket linking the incident timeline and + action items. The agent drafts title + summary; the human supplies + priority and the on-call owner who should drive the follow-up. + + Args: + title: Short incident title (agent drafts). + summary: Timeline + resolution notes (agent drafts). + priority: One of "P0" | "P1" | "P2" | "P3". + on_call_owner: Email / handle of the engineer who owns the retro. + """ + incident_id = f"INC-{uuid4().hex[:6].upper()}" + _INCIDENTS.append( + { + "id": incident_id, + "title": title, + "priority": priority, + "owner": on_call_owner, + } + ) + return ( + f"Incident {incident_id} filed: {title} " + f"(priority={priority}, owner={on_call_owner}).\nSummary: {summary}" + ) + + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_incident_commander.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="Incident Commander", + id="incident-commander-agent", + model=OpenAIResponses(id="gpt-5.4"), + db=db, + tools=[ + UserFeedbackTools(), + lookup_service, + list_recent_incidents, + run_diagnostic, + restart_service, + file_incident_retro, + DuckDuckGoTools(), + ], + instructions=[ + "You are an incident commander. Drive every incident through these " + "phases, pausing for the human when the framework does:", + " 1) Triage — call ask_user once to collect severity (single-select: " + "P0/P1/P2/P3) and affected subsystems (multi-select: api, db, cache, " + "queue, frontend). Call lookup_service for each subsystem named.", + " 2) Diagnose — call run_diagnostic with a concrete command (curl " + "against a health endpoint, kubectl describe, etc.). The engineer " + "pastes output back; use it to form a hypothesis.", + " 3) Remediate — if the fix is a restart, call restart_service. " + "Slack will gate this with Approve / Deny; do NOT ask for extra " + "confirmation yourself.", + " 4) Retro — once the incident is stable, call file_incident_retro " + "with a clean title + summary. Priority and on-call owner come from " + "the Slack pause form, not from you.", + "Use DuckDuckGo only if lookup_service + list_recent_incidents give " + "you nothing and the symptom is clearly a public library error.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — incident commander (all four pause types)", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, + approval_authorization="requester_only", + reply_to_mentions_only=True, + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + agent_os.serve(app="hitl_incident_commander:app", reload=True) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_user_feedback.py b/cookbook/05_agent_os/interfaces/slack/hitl_user_feedback.py new file mode 100644 index 0000000000..c59d8ec36d --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_user_feedback.py @@ -0,0 +1,79 @@ +""" +Slack HITL — User Feedback +========================== + +Travel concierge that needs to know a requester's preferences before it +drafts an itinerary. Uses `UserFeedbackTools` so the LLM can call +`ask_user` with structured questions. Slack renders the questions as +Checkboxes (multi-select) or a StaticSelect (single) inside a TaskCard, +and the selections flow back into the agent run when Submit is pressed. +Wikipedia + web search tools give the agent real destination grounding. + +Try in Slack: + @bot help me plan a 5-day trip to Tokyo + +Slack scopes: app_mentions:read, assistant:write, chat:write, im:history +""" + +from agno.agent import Agent +from agno.db.sqlite.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.duckduckgo import DuckDuckGoTools +from agno.tools.user_feedback import UserFeedbackTools +from agno.tools.wikipedia import WikipediaTools + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_user_feedback.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="Travel Concierge Agent", + id="travel-concierge-agent", + model=OpenAIResponses(id="gpt-5.4"), + db=db, + tools=[ + UserFeedbackTools(), + WikipediaTools(), + DuckDuckGoTools(), + ], + instructions=[ + "You are a travel concierge.", + "Workflow: (1) call ask_user ONCE to collect preferences in a single " + "Slack pause — include at least two questions: interests (multi-select: " + "museums, food, nightlife, nature, shopping) and travel style (single-" + "select: budget, mid-range, luxury); (2) after the user submits, use " + "Wikipedia for destination facts and DuckDuckGo for current-season " + "events / advisories; (3) draft a day-by-day itinerary aligned to the " + "stated interests + budget.", + "Do NOT repeat ask_user mid-plan — ask everything up front so the user " + "answers once.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — user feedback (travel preferences)", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, + approval_authorization="requester_only", + reply_to_mentions_only=True, + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + agent_os.serve(app="hitl_user_feedback:app", reload=True) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_user_input.py b/cookbook/05_agent_os/interfaces/slack/hitl_user_input.py new file mode 100644 index 0000000000..78757110a5 --- /dev/null +++ b/cookbook/05_agent_os/interfaces/slack/hitl_user_input.py @@ -0,0 +1,148 @@ +""" +Slack HITL — User Input +======================= + +Support agent that opens engineering tickets from Slack chatter. The agent +extracts `title` and `description` from the conversation, but `priority` +and `component` are fields the requester must fill in — they're listed in +`user_input_fields`, so Slack pauses with an input form before the tool +runs. Read-only tools help the agent avoid duplicates and search web +docs before filing. + +Try in Slack: + @bot open a ticket — checkout page throws 500 when the cart is empty + +Slack scopes: app_mentions:read, assistant:write, chat:write, im:history +""" + +from typing import Dict, List +from uuid import uuid4 + +from agno.agent import Agent +from agno.db.sqlite.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 +from agno.tools.duckduckgo import DuckDuckGoTools + +# --------------------------------------------------------------------------- +# Stand-in ticket store — replace with Jira / Linear client +# --------------------------------------------------------------------------- + + +_TICKETS: List[Dict[str, str]] = [ + { + "id": "SUP-A1B2C3", + "title": "Checkout 500 when cart empty", + "status": "open", + "component": "payments", + }, + { + "id": "SUP-F7E5D9", + "title": "Apple Pay button misaligned on iOS", + "status": "open", + "component": "mobile-web", + }, +] + + +# --------------------------------------------------------------------------- +# Read-only helpers +# --------------------------------------------------------------------------- + + +@tool +def search_existing_tickets(query: str) -> List[Dict[str, str]]: + """Return open tickets whose title contains the query (case-insensitive). + Use this before filing a new ticket to avoid duplicates. + + Args: + query: Free-text fragment to match in existing ticket titles. + """ + q = query.lower() + return [t for t in _TICKETS if q in t["title"].lower() and t["status"] == "open"] + + +# --------------------------------------------------------------------------- +# Ticket creation — pauses for priority + component +# --------------------------------------------------------------------------- + + +@tool(requires_user_input=True, user_input_fields=["priority", "component"]) +def create_support_ticket( + title: str, + description: str, + priority: str, + component: str, +) -> str: + """Open a support / engineering ticket. + + Args: + title: Short ticket title. The agent drafts this from the chat. + description: Longer body. The agent drafts this from the chat. + priority: One of "P0" | "P1" | "P2" | "P3". Requester picks. + component: Subsystem or team name the ticket should be routed to. + """ + ticket_id = f"SUP-{uuid4().hex[:6].upper()}" + _TICKETS.append( + {"id": ticket_id, "title": title, "status": "open", "component": component} + ) + return ( + f"Ticket {ticket_id} opened: {title} " + f"(priority={priority}, component={component}).\n" + f"Description: {description}" + ) + + +# --------------------------------------------------------------------------- +# Agent + AgentOS + Slack interface +# --------------------------------------------------------------------------- + +db = SqliteDb( + db_file="tmp/hitl_user_input.db", + session_table="agent_sessions", + approvals_table="approvals", +) + +agent = Agent( + name="Support Intake Agent", + id="support-intake-agent", + model=OpenAIResponses(id="gpt-5.4"), + db=db, + tools=[ + search_existing_tickets, + DuckDuckGoTools(), + create_support_ticket, + ], + instructions=[ + "You are a Slack support-intake assistant.", + "Workflow: (1) call search_existing_tickets with a fragment of the " + "issue description — if you find a live duplicate, surface it and ask " + "the user whether to still open a new one; (2) if the issue looks like " + "a known library error, optionally use DuckDuckGo for a link to docs; " + "(3) call create_support_ticket with a concise title and clean multi-" + "line description. Pass empty strings for priority and component — the " + "user will supply those via the Slack pause form.", + ], + markdown=True, +) + +agent_os = AgentOS( + description="Slack HITL — user input (support ticket intake)", + agents=[agent], + db=db, + interfaces=[ + Slack( + agent=agent, + hitl_enabled=True, + approval_authorization="requester_only", + reply_to_mentions_only=True, + ), + ], +) +app = agent_os.get_app() + + +if __name__ == "__main__": + agent_os.serve(app="hitl_user_input:app", reload=True) diff --git a/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py b/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py deleted file mode 100644 index 6332d14b4a..0000000000 --- a/cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py +++ /dev/null @@ -1,673 +0,0 @@ -""" -Slack HITL UX Verification Harness -=================================== - -Standalone FastAPI server that logs every /slack/interactions payload, -plus a CLI to post test layouts to a Slack DM. Use this to empirically -verify which Block Kit elements ship state on SUBMIT and which don't, -BEFORE committing to an HITL design. - -Replaces any running cookbook on port 7778 for the duration of the test. -Ngrok tunnel at https:///slack/interactions must be pointed at 7778. - -Usage: - # Terminal A — start the log server - python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py serve - - # Terminal B — post each test layout; click in Slack; watch Terminal A. - python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post input_only - python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post buttons_only - python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post mixed - python cookbook/05_agent_os/interfaces/slack/hitl_ux_verify.py post full_pause_card - -Each test proves or disproves a specific claim about Slack's behavior. -Read the docstrings on each layout function below. -""" - -import json -import os -import sys - -import httpx -from fastapi import FastAPI, HTTPException, Request -from slack_sdk.signature import SignatureVerifier - -# --------------------------------------------------------------------------- -# Config — change this to your target Slack DM/channel ID before running -# --------------------------------------------------------------------------- - -CHANNEL_ID = os.environ.get("SLACK_TEST_CHANNEL_ID", "D0AGXPEGJ8M") -SLACK_TOKEN = os.environ["SLACK_TOKEN"] -SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"] - -app = FastAPI() -verifier = SignatureVerifier(SLACK_SIGNING_SECRET) - - -# --------------------------------------------------------------------------- -# Server — logs every block_actions payload -# --------------------------------------------------------------------------- - - -@app.post("/slack/events") -async def events(request: Request): - body = await request.body() - ts = request.headers.get("X-Slack-Request-Timestamp", "") - sig = request.headers.get("X-Slack-Signature", "") - if not verifier.is_valid(body=body.decode(), timestamp=ts, signature=sig): - raise HTTPException(403, "bad signature") - payload = json.loads(body) - if payload.get("type") == "url_verification": - return {"challenge": payload.get("challenge")} - return {"ok": True} - - -@app.post("/slack/interactions") -async def interactions(request: Request): - body = await request.body() - ts = request.headers.get("X-Slack-Request-Timestamp", "") - sig = request.headers.get("X-Slack-Signature", "") - if not verifier.is_valid(body=body.decode(), timestamp=ts, signature=sig): - raise HTTPException(403, "bad signature") - form = await request.form() - payload = json.loads(form.get("payload", "{}")) - - action = (payload.get("actions") or [{}])[0] - state_values = (payload.get("state") or {}).get("values") or {} - - print("=" * 72) - print(f"TYPE: {payload.get('type')}") - print(f"ACTION_ID: {action.get('action_id')}") - print(f"BLOCK_ID: {action.get('block_id')}") - print(f"ACTION_VALUE: {action.get('value')}") - print(f"USER: {payload.get('user', {}).get('username')}") - print(f"") - print(f"STATE.VALUES (everything that ships to us):") - print(json.dumps(state_values, indent=2) if state_values else " ") - print("=" * 72) - return {"ok": True} - - -# --------------------------------------------------------------------------- -# Test layouts — each proves or disproves a specific claim -# --------------------------------------------------------------------------- - - -def layout_input_only(): - """CLAIM: Input blocks ship state.values on any button click in the same - message. This is the baseline — if this fails, the whole design is wrong. - - Expected on SUBMIT: state.values has {'b_decision': {'e': {'selected_option': ...}}} - """ - return [ - { - "type": "header", - "text": {"type": "plain_text", "text": "TEST 1: Input block state"}, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "*Claim:* radio_buttons in Input block ships state.values on SUBMIT.", - } - ], - }, - { - "type": "input", - "block_id": "b_decision", - "label": {"type": "plain_text", "text": "Decision"}, - "element": { - "type": "radio_buttons", - "action_id": "e", - "options": [ - { - "text": {"type": "plain_text", "text": "Confirm"}, - "value": "confirm", - }, - { - "text": {"type": "plain_text", "text": "Reject"}, - "value": "reject", - }, - ], - }, - }, - {"type": "divider"}, - { - "type": "actions", - "block_id": "submit", - "elements": [ - { - "type": "button", - "action_id": "submit", - "text": {"type": "plain_text", "text": "SUBMIT"}, - "style": "primary", - "value": "go", - }, - ], - }, - ] - - -def layout_buttons_only(): - """CLAIM: Regular buttons DO NOT carry state. Two buttons per row + SUBMIT. - Click Confirm on row 1, Reject on row 2, then SUBMIT. - - Expected on SUBMIT: state.values is empty. Button click history is lost. - This is what forces us to use chat.update OR switch to state-bearing elements. - """ - - def row(name, args): - return [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"🔧 *{name}*\n `{args}`"}, - }, - { - "type": "actions", - "block_id": f"row_{name}", - "elements": [ - { - "type": "button", - "action_id": f"confirm_{name}", - "text": {"type": "plain_text", "text": "✓ Confirm"}, - "style": "primary", - "value": f"{name}:confirm", - }, - { - "type": "button", - "action_id": f"reject_{name}", - "text": {"type": "plain_text", "text": "✗ Reject"}, - "style": "danger", - "value": f"{name}:reject", - }, - ], - }, - ] - - return [ - { - "type": "header", - "text": {"type": "plain_text", "text": "TEST 2: Button-only (stateless)"}, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "*Claim:* buttons do NOT ship state. SUBMIT's payload will have `state.values == {}` even after clicking buttons.", - } - ], - }, - *row("delete_file", "path: /tmp/demo.txt"), - *row("transfer_funds", "account: 42, $500"), - {"type": "divider"}, - { - "type": "actions", - "block_id": "submit", - "elements": [ - { - "type": "button", - "action_id": "submit", - "text": {"type": "plain_text", "text": "SUBMIT"}, - "style": "primary", - "value": "go", - }, - ], - }, - ] - - -def layout_mixed(): - """CLAIM: This is the UX user asked for — regular Confirm/Reject buttons - + multiline plain_text_input + SUBMIT. Multiline input DOES ship state; - buttons DO NOT. - - Expected on SUBMIT: state.values = {'b_note': {'e': {'value': ''}}} - Buttons are absent. - """ - return [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "TEST 3: Mixed — buttons + multiline input", - }, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "*Claim:* multiline input ships state; buttons don't. Click Confirm, type a note, SUBMIT.", - } - ], - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "🔧 *delete_file*\n `path: /tmp/demo.txt`", - }, - }, - { - "type": "actions", - "block_id": "row_delete", - "elements": [ - { - "type": "button", - "action_id": "confirm_delete", - "text": {"type": "plain_text", "text": "✓ Confirm"}, - "style": "primary", - "value": "delete:confirm", - }, - { - "type": "button", - "action_id": "reject_delete", - "text": {"type": "plain_text", "text": "✗ Reject"}, - "style": "danger", - "value": "delete:reject", - }, - ], - }, - { - "type": "input", - "block_id": "b_note", - "label": {"type": "plain_text", "text": "Optional note"}, - "element": { - "type": "plain_text_input", - "action_id": "e", - "multiline": True, - "placeholder": {"type": "plain_text", "text": "Type a reason here…"}, - }, - "optional": True, - }, - {"type": "divider"}, - { - "type": "actions", - "block_id": "submit", - "elements": [ - { - "type": "button", - "action_id": "submit", - "text": {"type": "plain_text", "text": "SUBMIT"}, - "style": "primary", - "value": "go", - }, - ], - }, - ] - - -def layout_full_pause_card(): - """CLAIM: All 4 HITL pause types can render inline in one message using - state-bearing elements. On SUBMIT, state.values carries every field. - - Includes: confirmation (radio), user_input (plain_text_input), - user_feedback (radio), external_execution (multiline plain_text_input). - """ - return [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": "TEST 4: Full pause card (all 4 pause types)", - }, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": "*Claim:* one message can host all 4 pause types with state.", - } - ], - }, - {"type": "divider"}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "🔧 *delete_file* (confirmation)\n `path: /tmp/demo.txt`", - }, - }, - { - "type": "input", - "block_id": "b_confirm", - "label": {"type": "plain_text", "text": "Decision"}, - "element": { - "type": "radio_buttons", - "action_id": "e", - "options": [ - { - "text": {"type": "plain_text", "text": "Confirm"}, - "value": "confirm", - }, - { - "text": {"type": "plain_text", "text": "Reject"}, - "value": "reject", - }, - ], - }, - }, - {"type": "divider"}, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "🔧 *send_email* (user_input)"}, - }, - { - "type": "input", - "block_id": "b_to_address", - "label": {"type": "plain_text", "text": "to_address"}, - "element": { - "type": "plain_text_input", - "action_id": "e", - "placeholder": {"type": "plain_text", "text": "user@example.com"}, - }, - }, - {"type": "divider"}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "❓ *vacation_style* (user_feedback — what kind of vacation?)", - }, - }, - { - "type": "input", - "block_id": "b_feedback", - "label": {"type": "plain_text", "text": "Choose one"}, - "element": { - "type": "radio_buttons", - "action_id": "e", - "options": [ - {"text": {"type": "plain_text", "text": "Beach"}, "value": "beach"}, - {"text": {"type": "plain_text", "text": "City"}, "value": "city"}, - { - "text": {"type": "plain_text", "text": "Nature"}, - "value": "nature", - }, - ], - }, - }, - {"type": "divider"}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "🚀 *execute_shell_command* (external_execution)\n `command: ls`", - }, - }, - { - "type": "input", - "block_id": "b_ext_result", - "label": {"type": "plain_text", "text": "Paste result"}, - "element": { - "type": "plain_text_input", - "action_id": "e", - "multiline": True, - "placeholder": { - "type": "plain_text", - "text": "e.g. file1.txt\nfile2.txt", - }, - }, - }, - {"type": "divider"}, - { - "type": "actions", - "block_id": "submit", - "elements": [ - { - "type": "button", - "action_id": "submit", - "text": {"type": "plain_text", "text": "SUBMIT"}, - "style": "primary", - "value": "go", - }, - { - "type": "button", - "action_id": "cancel", - "text": {"type": "plain_text", "text": "Cancel"}, - "style": "danger", - "value": "cancel", - }, - ], - }, - ] - - -def layout_card_minimal(): - """CLAIM: Slack accepts the new `card` block via raw chat.postMessage. - - Probes the API: does Slack's REST endpoint accept type=card today, in this - workspace? If chat.postMessage returns ok=False with invalid_blocks, the - spec's card-based design needs a fallback. - - Expected on success: a single card with title + body + Confirm/Reject. - """ - return [ - { - "type": "header", - "text": {"type": "plain_text", "text": "TEST: card block (minimal)"}, - }, - { - "type": "card", - "block_id": "card_min", - "title": {"type": "plain_text", "text": "delete_file"}, - "subtitle": {"type": "mrkdwn", "text": "_confirmation required_"}, - "body": [ - {"type": "mrkdwn", "text": "path: `/tmp/demo.txt`"}, - ], - "actions": { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": "card_confirm", - "text": {"type": "plain_text", "text": "Confirm"}, - "style": "primary", - "value": "go", - }, - { - "type": "button", - "action_id": "card_reject", - "text": {"type": "plain_text", "text": "Reject"}, - "style": "danger", - "value": "no", - }, - ], - }, - }, - ] - - -def layout_card_with_input(): - """CLAIM: card and Input can coexist as siblings at message level. - - Spec's claim: card.actions can't hold inputs, but card next to a separate - Input block renders fine. Verify that a SUBMIT click on a card button OR - on a sibling Actions block ships state.values. - """ - return [ - { - "type": "header", - "text": {"type": "plain_text", "text": "TEST: card + sibling Input"}, - }, - { - "type": "card", - "block_id": "card_send", - "title": {"type": "plain_text", "text": "send_email"}, - "subtitle": {"type": "mrkdwn", "text": "_user_input required_"}, - "body": [ - {"type": "mrkdwn", "text": "to: `priti@example.com`"}, - ], - "actions": { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": "card2_confirm", - "text": {"type": "plain_text", "text": "Confirm"}, - "style": "primary", - "value": "go", - }, - { - "type": "button", - "action_id": "card2_reject", - "text": {"type": "plain_text", "text": "Reject"}, - "style": "danger", - "value": "no", - }, - ], - }, - }, - { - "type": "input", - "block_id": "subj", - "label": {"type": "plain_text", "text": "subject"}, - "element": { - "type": "plain_text_input", - "action_id": "value", - "placeholder": {"type": "plain_text", "text": "Weekly update"}, - }, - }, - {"type": "divider"}, - { - "type": "actions", - "block_id": "submit_row", - "elements": [ - { - "type": "button", - "action_id": "submit", - "text": {"type": "plain_text", "text": "SUBMIT"}, - "style": "primary", - "value": "go", - }, - ], - }, - ] - - -def layout_card_no_subtitle(): - """CLAIM: card without subtitle is also accepted (flexibility check). - - If the minimal card requires every field, our builder needs to always set - them. If subtitle is optional, we can omit it for tools without args. - """ - return [ - { - "type": "header", - "text": {"type": "plain_text", "text": "TEST: card without subtitle"}, - }, - { - "type": "card", - "block_id": "card_terse", - "title": {"type": "plain_text", "text": "noop_tool"}, - "body": [ - {"type": "mrkdwn", "text": "_no arguments_"}, - ], - "actions": { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": "card3_confirm", - "text": {"type": "plain_text", "text": "Confirm"}, - "style": "primary", - "value": "go", - }, - ], - }, - }, - ] - - -LAYOUTS = { - "input_only": ( - "Baseline — radio_buttons in Input block ships state", - layout_input_only, - ), - "buttons_only": ( - "Buttons ONLY — verify they don't carry state", - layout_buttons_only, - ), - "mixed": ( - "Buttons + multiline input + SUBMIT — the UX you asked about", - layout_mixed, - ), - "full_pause_card": ( - "All 4 pause types inline in one message", - layout_full_pause_card, - ), - "card_minimal": ( - "New card block — minimal title+body+actions", - layout_card_minimal, - ), - "card_with_input": ( - "Card + sibling Input — production scenario", - layout_card_with_input, - ), - "card_no_subtitle": ( - "Card without subtitle — flexibility check", - layout_card_no_subtitle, - ), -} - - -# --------------------------------------------------------------------------- -# CLI dispatcher -# --------------------------------------------------------------------------- - - -def post_layout(layout_name: str): - if layout_name not in LAYOUTS: - print(f"Unknown layout '{layout_name}'. Available: {', '.join(LAYOUTS)}") - sys.exit(1) - description, fn = LAYOUTS[layout_name] - blocks = fn() - resp = httpx.post( - "https://slack.com/api/chat.postMessage", - headers={ - "Authorization": f"Bearer {SLACK_TOKEN}", - "Content-Type": "application/json; charset=utf-8", - }, - json={"channel": CHANNEL_ID, "text": description, "blocks": blocks}, - ) - data = resp.json() - if data.get("ok"): - print(f"Posted '{layout_name}' — {description}") - print(f" Slack message ts: {data.get('ts')}") - print(f" Channel: {CHANNEL_ID}") - print(f" Now click in Slack and watch the server console for payloads.") - else: - print(f"Failed: {data}") - sys.exit(1) - - -def serve(): - import uvicorn - - print("HITL UX verification server on http://localhost:7778") - print("Point Slack app Event Subscriptions and Interactivity URLs at:") - print(" https:///slack/events") - print(" https:///slack/interactions") - print("Post test layouts with: python this_file.py post ") - uvicorn.run(app, host="0.0.0.0", port=7778) - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python hitl_ux_verify.py [serve|post ]") - print("Layouts:") - for name, (desc, _) in LAYOUTS.items(): - print(f" {name:20s} {desc}") - sys.exit(0) - - cmd = sys.argv[1] - if cmd == "serve": - serve() - elif cmd == "post" and len(sys.argv) >= 3: - post_layout(sys.argv[2]) - else: - print(f"Unknown command '{cmd}'. Use 'serve' or 'post '.") - sys.exit(1)