Skip to content

Commit 396da17

Browse files
feat: add HITL approval gateway for production ADK agent workflows (#110)
* feat(hitl): Implement human-in-the-loop approval gateway - Add ADK 1.x adapter and data models - Add FastAPI backend and SQLite state management routes - Add Streamlit dashboard UI for human reviewers - Add sample credit_agent showcasing the full @hitl_tool integration - Fully formatted under pyink and isort conventions * fix(hitl): inject supervisor decision context into LLM tool response payload * fix(hitl): address review feedback from PR #110 - Replace datetime.utcnow() with datetime.now(timezone.utc) throughout (deprecated in Python 3.12+) - Make API_BASE_URL configurable via ADK_HITL_API_URL env var - Make poll interval configurable via ADK_HITL_POLL_INTERVAL_S env var - Add jitter to polling loop to reduce backend traffic under concurrent load - Simplify _to_pydantic() using Pydantic v2 model_validate() with from_attributes=True and a model_validator to handle JSON strings - Update test_approved_tool_runs assertion to match enriched return dict (action_result + supervisor_decision) - Add README.md with architecture diagram, quick start, and configuration reference * chore: remove .adk local dev artifacts from tracked files * chore: add .adk and local db files to .gitignore * chore: resolve .gitignore merge conflict — keep ADK artifacts and gemini-cli entries * chore: drop CHANGELOG, .gitignore, hitl.db, and .pr_body from PR Per review feedback on #110 — these files should not be part of this PR. * fix(hitl): make hitl deps optional and skip API test when missing Adds 'hitl' optional-dependency extra (sqlalchemy, fastapi, aiosqlite) and uses pytest.importorskip in the API test so CI passes without the extra installed — mirrors the existing pattern for the s3 extra.
1 parent aa7395e commit 396da17

18 files changed

Lines changed: 1281 additions & 0 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# ADK HITL Approval Dashboard
2+
3+
A drop-in **production-ready Human-in-the-Loop (HITL) approval middleware** for Google Agent Development Kit (ADK) agents — complete with an API backend and a demo Streamlit dashboard UI.
4+
5+
## The Problem Solved
6+
7+
ADK 1.x ships with an experimental `require_confirmation=True` feature that handles pausing the LLM loop for human verification. However, it is fundamentally built for local debugging and introduces major blockers to an enterprise environment:
8+
9+
1. **Incompatible with Persistent Sessions:** Native confirmations intentionally do not serialize well and will completely fail to resume your agent if you use `DatabaseSessionService`, `SpannerSessionService`, or `VertexAiSessionService` (the mandatory session backends for production deployments).
10+
2. **Single-Agent Limitations:** They silently break across `AgentTool` nested bounds and true multi-agent (A2A) topologies, causing missing events or infinitely looping models.
11+
3. **No Resilient Audit Log:** The native confirmation tool leaves no easily queryable paper trail linking the human supervisor to a precise LLM request.
12+
13+
*This project is the production implementation of the HITL pattern covered in the [ADK Multi-Agent Patterns Guide (Advent of Agents Day 13)](https://medium.com/@garythomasgeorge/why-google-adks-human-in-the-loop-story-has-a-production-gap-and-one-way-it-could-be-fixed-66aabef33a32).*
14+
15+
## What This Library Provides
16+
17+
This project solves the production gaps by explicitly decoupling the human approval payload from ADK's internal session memory. It introduces a session-agnostic REST API layer using an Adapter pattern.
18+
19+
### The 3-Layer Architecture
20+
21+
```
22+
┌─────────────────────────────────────────┐
23+
│ Dashboard UI (Streamlit) │ Layer 3: Demo/reference UI
24+
│ Approval inbox, audit log viewer │ (Easily replaced by Zendesk/etc.)
25+
└──────────────────┬──────────────────────┘
26+
27+
┌──────────────────▼──────────────────────┐
28+
│ ApprovalRequest Model (Pydantic) │ Layer 2: Normalised Contract API
29+
│ FastAPI backend + SQLite store │ Session-agnostic persistence
30+
└────────────────┬────────────────────────┘
31+
32+
┌──────────┴───────────┐
33+
┌─────▼──────┐ ┌──────────▼──────┐
34+
│ ADK 1.x │ │ ADK 2.0 │ Layer 1: Adapters
35+
│ Adapter │ │ Adapter │ Only this changes between versions
36+
└────────────┘ └─────────────────┘
37+
```
38+
39+
By retaining HITL state inside an independent FastAPI engine and SQLite database, an active agent can pause safely. When a human supervisor hits "Approve" inside a centralized web portal hours later, the middleware simply posts the decision back into the agent's `/run_sse` stream seamlessly.
40+
41+
## Configuration
42+
43+
| Environment Variable | Default | Description |
44+
|---|---|---|
45+
| `ADK_HITL_API_URL` | `http://localhost:8000` | URL of the HITL approval FastAPI backend. Override for Cloud Run or any remote deployment. |
46+
| `ADK_HITL_POLL_INTERVAL_S` | `2.0` | Base polling interval in seconds. Up to 1s of random jitter is added automatically to reduce backend traffic under concurrent load. |
47+
48+
Set these before starting the gateway:
49+
50+
```bash
51+
export ADK_HITL_API_URL="https://your-hitl-service.run.app"
52+
export ADK_HITL_POLL_INTERVAL_S="3.0"
53+
```
54+
55+
## Quick Start (Local Sandbox)
56+
57+
We have provided a demo customer service agent (`credit_agent`) alongside a launch script to test the interaction end-to-end.
58+
59+
1. Create your Python virtual environment and sync dependencies using `uv` (requires Python 3.11+):
60+
61+
```bash
62+
uv venv --python "python3.11" ".venv"
63+
source .venv/bin/activate
64+
uv sync --all-extras
65+
```
66+
67+
2. Start the FastAPI backend, Streamlit dashboard, and ADK Live Chat agent all at once:
68+
69+
```bash
70+
./start_servers.sh
71+
```
72+
73+
3. Open `http://localhost:8080` to chat with the agent and ask for a $75 account credit.
74+
4. When the agent pauses and asks for a supervisor, open `http://localhost:8501` to approve or reject the request.
75+
76+
## How to Use in Your Own ADK Application
77+
78+
Wrapping an ADK agent with a formal enterprise HITL checkpoint takes under 5 lines of code:
79+
80+
1. Import the `hitl_tool` gateway wrapper.
81+
2. Decorate your function tool.
82+
3. Attach it to your ADK Agent initialization using a standard `FunctionTool`.
83+
84+
```python
85+
from google.adk.tools import FunctionTool
86+
from google.adk_community.tools.hitl.gateway import hitl_tool
87+
88+
# 1. Wrap your function with the decorator
89+
@hitl_tool(agent_name="my_billing_agent")
90+
async def issue_refund(user_id: str, amount: float):
91+
# This block won't execute until explicitly approved in the dashboard
92+
return {"status": "success", "amount_refunded": amount}
93+
94+
# 2. Attach to ADK Agent
95+
root_agent = Agent(
96+
name="my_billing_agent",
97+
tools=[FunctionTool(issue_refund)]
98+
)
99+
```
100+
101+
## Production Integration Strategies
102+
103+
This repository acts as the production baseline for a contact center or enterprise orchestration grid. Once deployed to staging, consider swapping out:
104+
105+
- **Storage Layer:** Replace the local `SQLite` engine in `app/api/store.py` with `PostgreSQL` or `Cloud Spanner`.
106+
- **Proactive Notification:** Hook the FastAPI `POST /approvals/` route into Slack, PagerDuty, or Microsoft Teams to actively ping channels when a high-risk request pops up.
107+
- **Remove Streamlit:** Bypass the Streamlit frontend completely and point your existing support portal interface (like Salesforce Service Cloud) directly to `GET /approvals/pending` and `POST /approvals/{id}/decide`.
108+
109+
## ADK 2.0 Compatibility
110+
111+
This project currently uses ADK 1.x conventions and event triggers. Because it strictly implements an `adapters` layer, all the Pydantic API schemas and Streamlit logic are completely forward-compatible with ADK 2.0 `RequestInput` workflow yielding. You'll simply need to switch the adapter layer translation once ADK 2.0 exits Alpha. The `ADK_HITL_API_URL` and `ADK_HITL_POLL_INTERVAL_S` environment variables remain valid across both adapter versions.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import agent
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Credit agent — external supervisor HITL demo.
16+
17+
This agent demonstrates the cross-user approval pattern:
18+
- Customer chats in ADK web (:8080)
19+
- Agent wants to apply a credit → submits request to HITL API (:8000)
20+
- Agent blocks (non-blocking async poll) waiting for a decision
21+
- Supervisor opens Streamlit dashboard (:8501), reviews and approves/rejects
22+
- Agent resumes and informs the customer of the outcome
23+
24+
Make sure all three services are running before chatting (see start_servers.sh):
25+
HITL API: uvicorn google.adk_community.services.hitl_approval.api:app --port 8000
26+
Dashboard: streamlit run dashboard/app.py --server.headless true
27+
ADK web: adk web credit_agent/ --port 8080
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import os
33+
import sys
34+
35+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
36+
37+
from google.adk.agents import Agent
38+
from google.adk.tools import FunctionTool
39+
40+
from google.adk_community.tools.hitl.gateway import hitl_tool
41+
42+
43+
@hitl_tool(agent_name="credit_agent")
44+
async def apply_account_credit(account_id: str, amount: float, reason: str) -> dict:
45+
"""Apply a credit to a customer account. Requires supervisor approval.
46+
47+
Args:
48+
account_id: The customer account ID to credit.
49+
amount: Credit amount in USD.
50+
reason: Business justification for the credit.
51+
52+
Returns:
53+
Confirmation with the updated account balance.
54+
"""
55+
# Real implementation would call your billing/CRM API here
56+
return {
57+
"status": "credited",
58+
"account_id": account_id,
59+
"amount_credited": amount,
60+
"new_balance": f"${amount:.2f} credit applied successfully.",
61+
}
62+
63+
64+
root_agent = Agent(
65+
name="credit_agent",
66+
model="gemini-2.5-flash",
67+
description=(
68+
"Customer support agent that can apply account credits. "
69+
"Every credit requires supervisor approval via the HITL dashboard."
70+
),
71+
instruction=(
72+
"You are a customer support agent. When a customer requests an account credit, "
73+
"call apply_account_credit with their account ID, the amount, and the reason. "
74+
"Let them know their request is being reviewed by a supervisor and that you will "
75+
"update them once a decision is made. "
76+
"If the credit is approved, confirm it to the customer. "
77+
"If rejected, apologise and explain that the supervisor did not approve it."
78+
),
79+
tools=[FunctionTool(apply_account_credit)],
80+
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Streamlit HITL Approval Dashboard.
16+
17+
Run:
18+
streamlit run contributing/samples/hitl_approval/dashboard/app.py
19+
"""
20+
21+
import httpx
22+
import streamlit as st
23+
24+
API_BASE = "http://localhost:8000"
25+
26+
27+
def _resolve(request_id: str, decision: str, note: str):
28+
try:
29+
r = httpx.post(
30+
f"{API_BASE}/approvals/{request_id}/decide",
31+
json={
32+
"decision": decision,
33+
"reviewer_id": "dashboard_admin",
34+
"notes": note or None,
35+
},
36+
timeout=5,
37+
)
38+
r.raise_for_status()
39+
st.success(f"Request {request_id[:8]}… marked as {decision}.")
40+
st.rerun()
41+
except Exception as e:
42+
st.error(f"Failed to resolve: {e}")
43+
44+
45+
st.set_page_config(page_title="ADK HITL Dashboard", page_icon="🔍", layout="wide")
46+
st.title("ADK HITL Approval Dashboard")
47+
48+
# ── Sidebar filters ───────────────────────────────────────────────────────────
49+
50+
status_filter = st.sidebar.selectbox(
51+
"Filter by status", ["All", "pending", "approved", "rejected", "escalated"]
52+
)
53+
54+
if st.sidebar.button("Refresh"):
55+
st.rerun()
56+
57+
# ── Fetch approvals ───────────────────────────────────────────────────────────
58+
59+
try:
60+
if status_filter == "pending":
61+
resp = httpx.get(f"{API_BASE}/approvals/pending", timeout=5)
62+
else:
63+
params = {}
64+
if status_filter != "All":
65+
params["decision"] = status_filter
66+
resp = httpx.get(f"{API_BASE}/approvals/audit", params=params, timeout=5)
67+
68+
resp.raise_for_status()
69+
requests = resp.json()
70+
except Exception as e:
71+
st.error(f"Could not connect to API: {e}")
72+
st.stop()
73+
74+
# ── Render approval cards ─────────────────────────────────────────────────────
75+
76+
if not requests:
77+
st.info("No approval requests found.")
78+
else:
79+
for req in requests:
80+
status = req["status"]
81+
color = {
82+
"pending": "🟡",
83+
"approved": "🟢",
84+
"rejected": "🔴",
85+
"escalated": "🟠",
86+
}.get(status, "⚪")
87+
88+
with st.expander(
89+
f"{color} [{status.upper()}] {req['tool_name']}{req['agent_name']} ({req['id'][:8]}…)"
90+
):
91+
col1, col2 = st.columns(2)
92+
col1.markdown(
93+
f"**App:** `{req.get('app_name', 'N/A')}` | **User:** `{req.get('user_id', 'N/A')}`"
94+
)
95+
col1.markdown(f"**Agent:** `{req['agent_name']}`")
96+
col1.markdown(f"**Tool:** `{req['tool_name']}`")
97+
col1.markdown(f"**Session:** `{req['session_id']}`")
98+
col2.markdown(f"**Created:** {req['created_at']}")
99+
if req.get("decided_at"):
100+
col2.markdown(
101+
f"**Resolved:** {req['decided_at']} by `{req.get('decided_by', 'unknown')}`"
102+
)
103+
104+
st.markdown(f"**Message / Hint:**")
105+
st.info(req.get("message", "No message provided."))
106+
107+
st.markdown("**Payload / Arguments:**")
108+
st.json(req.get("payload", {}))
109+
110+
if req.get("decision_notes"):
111+
st.markdown(f"**Reviewer note:** {req['decision_notes']}")
112+
113+
if status == "pending":
114+
note = st.text_input(
115+
"Reviewer note (optional)", key=f"note_{req['id']}"
116+
)
117+
c1, c2, c3 = st.columns(3)
118+
119+
if c1.button("Approve", key=f"approve_{req['id']}", type="primary"):
120+
_resolve(req["id"], "approved", note)
121+
122+
if c2.button("Reject", key=f"reject_{req['id']}"):
123+
_resolve(req["id"], "rejected", note)
124+
125+
if c3.button("Escalate", key=f"escalate_{req['id']}"):
126+
_resolve(req["id"], "escalated", note)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Sample-specific dependencies for the HITL Approval demo.
2+
# Install into the repo virtualenv after `uv sync --all-extras`:
3+
#
4+
# uv pip install -r contributing/samples/hitl_approval/requirements.txt
5+
#
6+
# The core package (google-adk-community) and its deps (google-adk, httpx)
7+
# are already installed by `uv sync`. Only the service and dashboard extras
8+
# are listed here.
9+
10+
fastapi>=0.111.0
11+
uvicorn[standard]>=0.30.0
12+
sqlalchemy>=2.0.0
13+
aiosqlite>=0.20.0
14+
streamlit>=1.35.0
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/bin/bash
2+
# Copyright 2026 Google LLC
3+
4+
# 1. Kill any lingering local servers from previous runs to free up ports
5+
killall python uvicorn streamlit adk 2>/dev/null || true
6+
sleep 1
7+
8+
# 2. Ensure we're running from the repo root so imports resolve correctly
9+
cd "$(git rev-parse --show-toplevel)"
10+
11+
# 3. Load GOOGLE_GENAI_API_KEY from .env if present
12+
if [ -f .env ]; then
13+
source .env
14+
fi
15+
16+
echo "Starting FastAPI HITL Backend (:8000)..."
17+
export HITL_DB_PATH="./contributing/samples/hitl_approval/hitl.db"
18+
.venv/bin/uvicorn google.adk_community.services.hitl_approval.api:app --port 8000 &
19+
API_PID=$!
20+
21+
echo "Starting Streamlit Dashboard (:8501)..."
22+
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
23+
.venv/bin/streamlit run contributing/samples/hitl_approval/dashboard/app.py \
24+
--server.headless true &
25+
STREAMLIT_PID=$!
26+
27+
echo "Starting ADK Web Chat (:8080)..."
28+
.venv/bin/adk web contributing/samples/hitl_approval --port 8080 &
29+
ADK_PID=$!
30+
31+
echo ""
32+
echo "All services launched."
33+
echo "=========================================="
34+
echo "Backend API: http://localhost:8000/docs"
35+
echo "Dashboard UI: http://localhost:8501"
36+
echo "ADK Agent Chat: http://localhost:8080"
37+
echo "=========================================="
38+
echo "Press Ctrl+C to shut down all servers."
39+
40+
trap "kill $API_PID $STREAMLIT_PID $ADK_PID 2>/dev/null; exit" EXIT
41+
42+
wait

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ sdc-agents = [
5050
"sdc-agents>=4.3.3; python_version >= '3.11'",
5151
]
5252
spraay = ["web3>=6.0.0"]
53+
hitl = [
54+
"aiosqlite>=0.20.0",
55+
"fastapi>=0.110.0",
56+
"sqlalchemy>=2.0.0",
57+
]
5358

5459

5560
[tool.pyink]

src/google/adk_community/services/hitl_approval/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)