Skip to content

Commit 0bf358e

Browse files
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
1 parent 1ddc388 commit 0bf358e

9 files changed

Lines changed: 193 additions & 50 deletions

File tree

.pr_body.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
## Summary
2+
Closes #[ISSUE NUMBER]
3+
Adds a production-ready Human-in-the-Loop approval gateway for Google ADK agents. This addresses a documented gap where ADK's built-in Tool Confirmation feature explicitly does not support `DatabaseSessionService` or `VertexAiSessionService` — the two session backends required for production deployments — making structured human oversight unavailable in any persistent production environment.
4+
5+
## Problem
6+
ADK's Tool Confirmation (v1.14.0+) is experimental and has three blockers for production use:
7+
1. Does not support `DatabaseSessionService` or `VertexAiSessionService`
8+
2. Does not trigger inside `AgentTool` or across A2A boundaries
9+
3. No structured approval UI, audit trail, or persistence layer
10+
11+
Validated by community issues: #1797, #1851, #2645, #3276, #3567 on `google/adk-python`.
12+
13+
## Solution
14+
A session-agnostic HITL approval gateway that manages approval state in its own persistence layer (SQLite, with a documented path to Postgres), independent of ADK's session service. The agent resumes via ADK's standard REST API after a human decision is submitted.
15+
16+
### What's included
17+
18+
**Core module** (`src/google/adk_community/tools/hitl/`)
19+
- `gateway.py``hitl_tool` decorator that wraps any async function before it is passed to `FunctionTool`. Adding HITL to an existing tool takes ~5 lines.
20+
- `models.py``ApprovalRequest` Pydantic model, normalised data contract capturing agent context, payload, risk level, and audit metadata
21+
- `adapters/adk1.py` — ADK 1.x adapter translating `request_confirmation()` events into `ApprovalRequest` objects
22+
23+
**Service** (`src/google/adk_community/services/hitl_approval/`)
24+
- `api.py` — FastAPI application
25+
- `routes.py` — REST endpoints for approval queue management
26+
- `store.py` — SQLite persistence with full audit log
27+
28+
**Sample** (`contributing/samples/hitl_approval/`)
29+
- `credit_agent/agent.py` — Credit approval agent demonstrating end-to-end integration
30+
- `dashboard/app.py` — Reference Streamlit approval inbox UI
31+
- `start_servers.sh` — One-command startup for all three services
32+
- `requirements.txt` — Sample-only dependencies
33+
34+
### Architecture
35+
```
36+
ADK Agent Pipeline
37+
38+
@hitl_tool decorator (wraps async function → FunctionTool)
39+
↓ POST /approvals/ — creates ApprovalRequest
40+
FastAPI + SQLite (approval state)
41+
↓ serves pending approvals
42+
Streamlit Dashboard (reviewer decides)
43+
↓ POST /approvals/{id}/decide
44+
FastAPI updates status in SQLite
45+
↓ decorator polls GET /approvals/{id} every 2 s
46+
Agent resumes execution (wrapper unblocks; runs tool if approved)
47+
```
48+
49+
### Forward compatibility
50+
Built with an adapter pattern so the same approval backend and dashboard work with ADK 1.x today and ADK 2.0's `RequestInput` pattern when it reaches stable — without teams needing to rebuild their approval layer on upgrade.
51+
52+
## Testing
53+
### Unit tests
54+
All 11 tests passing:
55+
```text
56+
============================= test session starts =============================
57+
platform darwin -- Python 3.11.15, pytest-9.0.2, pluggy-1.6.0
58+
rootdir: /Users/garythomasgeorge/Desktop/Work/AI Dev/adk-python-community
59+
configfile: pyproject.toml
60+
plugins: anyio-4.12.1, asyncio-1.3.0
61+
asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function
62+
collected 11 items
63+
64+
tests/unittests/tools/test_hitl_gateway.py ...... [ 54%]
65+
tests/unittests/services/test_hitl_approval_api.py ..... [100%]
66+
67+
================================ 11 passed in 1.76s =================================
68+
Exit code: 0
69+
```
70+
71+
### Manual E2E
72+
Full end-to-end flow verified:
73+
- Agent triggers approval request → appears in Streamlit dashboard ✓
74+
- Reviewer approves in dashboard → agent resumes correctly ✓
75+
- Uvicorn restart → SQLite persists previous approvals ✓
76+
77+
> 🎥 *Please drag-and-drop your `hitl_demo_video_1774318429041.webp` file here before publishing*
78+
79+
## Testing plan
80+
For reviewers wanting to reproduce locally:
81+
```bash
82+
cd contributing/samples/hitl_approval
83+
uv pip install -r requirements.txt
84+
./start_servers.sh
85+
```
86+
87+
Then open:
88+
- ADK Dev UI: `http://localhost:8080`
89+
- Streamlit dashboard: `http://localhost:8501`
90+
- FastAPI docs: `http://localhost:8000/docs`
91+
92+
Trigger an approval by asking the credit agent to process an amount over $500.
93+
94+
## Notes for reviewers
95+
- Opening as **Draft** — happy to address structural feedback before requesting full review
96+
- ADK 2.0 adapter (`adapters/adk2.py`) is planned as a follow-up PR once 2.0 moves toward stable
97+
- Confirmed structure placement from proposal issue: `tools/hitl` for the gateway and models, `services/hitl_approval` for the FastAPI backend — let me know if you'd prefer a different organisation
98+
99+
## Related
100+
- Proposal issue: #[ISSUE NUMBER]
101+
- ADK Tool Confirmation docs (known limitations): https://google.github.io/adk-docs/tools-custom/confirmation/
102+
- ADK multi-agent HITL pattern reference: https://developers.googleblog.com/developers-guide-to-multi-agent-patterns-in-adk/
103+
- Existing community example this extends: https://github.com/jackwotherspoon/adk-human-in-the-loop

contributing/samples/hitl_approval/README.md

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@ A drop-in **production-ready Human-in-the-Loop (HITL) approval middleware** for
55
## The Problem Solved
66

77
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+
89
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).
910
2. **Single-Agent Limitations:** They silently break across `AgentTool` nested bounds and true multi-agent (A2A) topologies, causing missing events or infinitely looping models.
10-
3. **No Resilient Audit Log:** The Native confirmation tool leaves no easily queryable paper trail linking the human supervisor to a precise LLM request.
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.
1112

12-
*This project is the production implementation of the HITL pattern covered in the [ADK Multi-Agent Patterns Guide (Advent of Agents Day 13)](#).*
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).*
1314

1415
## What This Library Provides
1516

1617
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.
1718

1819
### The 3-Layer Architecture
1920

20-
```text
21+
```
2122
┌─────────────────────────────────────────┐
2223
│ Dashboard UI (Streamlit) │ Layer 3: Demo/reference UI
2324
│ Approval inbox, audit log viewer │ (Easily replaced by Zendesk/etc.)
@@ -37,24 +38,42 @@ This project solves the production gaps by explicitly decoupling the human appro
3738

3839
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.
3940

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+
4055
## Quick Start (Local Sandbox)
4156

4257
We have provided a demo customer service agent (`credit_agent`) alongside a launch script to test the interaction end-to-end.
4358

44-
1. Create your python virtual environment and sync dependencies using `uv` (requires Python 3.11+):
45-
```bash
46-
uv venv --python "python3.11" ".venv"
47-
source .venv/bin/activate
48-
uv sync --all-extras
49-
```
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+
5067
2. Start the FastAPI backend, Streamlit dashboard, and ADK Live Chat agent all at once:
51-
```bash
52-
./start_servers.sh
53-
```
68+
69+
```bash
70+
./start_servers.sh
71+
```
72+
5473
3. Open `http://localhost:8080` to chat with the agent and ask for a $75 account credit.
5574
4. When the agent pauses and asks for a supervisor, open `http://localhost:8501` to approve or reject the request.
5675

57-
## How to use in your own ADK application
76+
## How to Use in Your Own ADK Application
5877

5978
Wrapping an ADK agent with a formal enterprise HITL checkpoint takes under 5 lines of code:
6079

@@ -69,7 +88,7 @@ from google.adk_community.tools.hitl.gateway import hitl_tool
6988
# 1. Wrap your function with the decorator
7089
@hitl_tool(agent_name="my_billing_agent")
7190
async def issue_refund(user_id: str, amount: float):
72-
# This block won't execute until explicitly approved inside the FastAPI dashboard
91+
# This block won't execute until explicitly approved in the dashboard
7392
return {"status": "success", "amount_refunded": amount}
7493

7594
# 2. Attach to ADK Agent
@@ -82,10 +101,11 @@ root_agent = Agent(
82101
## Production Integration Strategies
83102

84103
This repository acts as the production baseline for a contact center or enterprise orchestration grid. Once deployed to staging, consider swapping out:
85-
* **Storage Layer:** Replace the local `SQLite` engine in `app/api/store.py` with `PostgreSQL` or `Cloud Spanner`.
86-
* **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.
87-
* **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`.
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`.
88108

89109
## ADK 2.0 Compatibility
90110

91-
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.
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.
112 KB
Binary file not shown.
12 KB
Binary file not shown.

hitl.db

12 KB
Binary file not shown.

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

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -138,25 +138,4 @@ async def _get_or_404(request_id: str, db: AsyncSession) -> ApprovalRequestDB:
138138

139139

140140
def _to_pydantic(db_item: ApprovalRequestDB) -> ApprovalRequest:
141-
return ApprovalRequest(
142-
id=db_item.id,
143-
session_id=db_item.session_id,
144-
invocation_id=db_item.invocation_id,
145-
function_call_id=db_item.function_call_id,
146-
app_name=db_item.app_name,
147-
user_id=db_item.user_id,
148-
agent_name=db_item.agent_name,
149-
tool_name=db_item.tool_name,
150-
message=db_item.message,
151-
payload=json.loads(db_item.payload) if db_item.payload else {},
152-
response_schema=json.loads(db_item.response_schema)
153-
if db_item.response_schema
154-
else {},
155-
risk_level=db_item.risk_level,
156-
status=db_item.status,
157-
created_at=db_item.created_at,
158-
decided_at=db_item.decided_at,
159-
decided_by=db_item.decided_by,
160-
decision_notes=db_item.decision_notes,
161-
escalated_to=db_item.escalated_to,
162-
)
141+
return ApprovalRequest.model_validate(db_item)

src/google/adk_community/tools/hitl/gateway.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ async def apply_credit(account_id: str, amount: float) -> str:
3737
from typing import Any, Callable, Optional
3838

3939
import httpx
40+
import os
41+
import random
4042

41-
API_BASE_URL = "http://localhost:8000"
42-
POLL_INTERVAL_S = 2.0
43-
POLL_TIMEOUT_S = 300.0 # 5 minutes
43+
API_BASE_URL = os.getenv("ADK_HITL_API_URL", "http://localhost:8000")
44+
POLL_INTERVAL_S = float(os.getenv("ADK_HITL_POLL_INTERVAL_S", "2.0"))
45+
POLL_JITTER_S = 1.0
46+
POLL_TIMEOUT_S = 300.0 # ← this one was likely removed accidentally
4447

4548

4649
def hitl_tool(
@@ -138,7 +141,7 @@ async def _poll_for_decision(
138141
data = resp.json()
139142
if data["status"] != "pending":
140143
return data
141-
await asyncio.sleep(interval)
144+
await asyncio.sleep(POLL_INTERVAL_S + random.uniform(0, POLL_JITTER_S))
142145
return None
143146

144147

src/google/adk_community/tools/hitl/models.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@
1414

1515
from __future__ import annotations
1616

17-
import uuid
18-
from datetime import datetime
17+
import uuid, json
18+
from datetime import datetime, timezone
1919
from typing import Any, Optional
2020

21-
from pydantic import BaseModel, Field
21+
from pydantic import BaseModel, ConfigDict, Field, model_validator
22+
import json
2223

2324

2425
class ApprovalStatus:
@@ -36,6 +37,7 @@ class RiskLevel:
3637

3738

3839
class ApprovalRequest(BaseModel):
40+
model_config = ConfigDict(from_attributes=True)
3941
# Identity
4042
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
4143

@@ -58,14 +60,48 @@ class ApprovalRequest(BaseModel):
5860

5961
# Status tracking
6062
status: str = ApprovalStatus.PENDING
61-
created_at: datetime = Field(default_factory=datetime.utcnow)
63+
created_at: datetime = Field(
64+
default_factory=lambda: datetime.now(timezone.utc)
65+
)
6266
decided_at: Optional[datetime] = None
6367
decided_by: Optional[str] = None
6468
decision_notes: Optional[str] = None
65-
6669
# Escalation
6770
escalated_to: Optional[str] = None
71+
72+
@model_validator(mode="before")
73+
@classmethod
74+
def _parse_json_strings(cls, values):
75+
"""
76+
When constructing from an ORM object, SQLite stores payload
77+
and response_schema as JSON strings. Parse them to dicts
78+
for Pydantic without mutating the original ORM object.
79+
"""
80+
# Handle dict input (normal Pydantic construction)
81+
if isinstance(values, dict):
82+
for field in ("payload", "response_schema"):
83+
val = values.get(field)
84+
if isinstance(val, str):
85+
try:
86+
values[field] = json.loads(val)
87+
except (ValueError, TypeError):
88+
values[field] = {}
89+
return values
6890

91+
# Handle ORM object input (from_attributes path)
92+
# Build a plain dict from the ORM object attributes
93+
# so we never mutate the SQLAlchemy-tracked object
94+
data = {}
95+
for column in values.__table__.columns:
96+
val = getattr(values, column.name, None)
97+
if column.name in ("payload", "response_schema") and isinstance(val, str):
98+
try:
99+
data[column.name] = json.loads(val)
100+
except (ValueError, TypeError):
101+
data[column.name] = {}
102+
else:
103+
data[column.name] = val
104+
return data
69105

70106
class ApprovalDecision(BaseModel):
71107
decision: str # approved / rejected / escalated

tests/unittests/tools/test_hitl_gateway.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ def add(a: int, b: int) -> int:
8484
return a + b
8585

8686
result = await add(2, 3)
87-
assert result == 5
87+
assert result["action_result"] == 5
88+
assert result["supervisor_decision"] == "APPROVED"
89+
assert result["supervisor_notes"] == "No notes provided."
8890

8991

9092
@pytest.mark.asyncio

0 commit comments

Comments
 (0)