From 5dc6658b241cb3714e18568cff7ea0c6537ed84a Mon Sep 17 00:00:00 2001 From: Gary Thomas George Date: Mon, 23 Mar 2026 21:53:37 -0400 Subject: [PATCH 1/3] 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 --- CHANGELOG.md | 6 + contributing/samples/hitl_approval/README.md | 91 ++++++++++ .../hitl_approval/credit_agent/__init__.py | 1 + .../hitl_approval/credit_agent/agent.py | 80 +++++++++ .../samples/hitl_approval/dashboard/app.py | 126 +++++++++++++ .../samples/hitl_approval/requirements.txt | 14 ++ .../samples/hitl_approval/start_servers.sh | 42 +++++ .../services/hitl_approval/__init__.py | 0 .../services/hitl_approval/api.py | 45 +++++ .../services/hitl_approval/routes.py | 162 +++++++++++++++++ .../services/hitl_approval/store.py | 80 +++++++++ .../adk_community/tools/hitl/__init__.py | 0 .../tools/hitl/adapters/__init__.py | 0 .../adk_community/tools/hitl/adapters/adk1.py | 86 +++++++++ .../adk_community/tools/hitl/gateway.py | 138 +++++++++++++++ src/google/adk_community/tools/hitl/models.py | 75 ++++++++ .../services/test_hitl_approval_api.py | 166 ++++++++++++++++++ tests/unittests/tools/test_hitl_gateway.py | 113 ++++++++++++ 18 files changed, 1225 insertions(+) create mode 100644 contributing/samples/hitl_approval/README.md create mode 100644 contributing/samples/hitl_approval/credit_agent/__init__.py create mode 100644 contributing/samples/hitl_approval/credit_agent/agent.py create mode 100644 contributing/samples/hitl_approval/dashboard/app.py create mode 100644 contributing/samples/hitl_approval/requirements.txt create mode 100755 contributing/samples/hitl_approval/start_servers.sh create mode 100644 src/google/adk_community/services/hitl_approval/__init__.py create mode 100644 src/google/adk_community/services/hitl_approval/api.py create mode 100644 src/google/adk_community/services/hitl_approval/routes.py create mode 100644 src/google/adk_community/services/hitl_approval/store.py create mode 100644 src/google/adk_community/tools/hitl/__init__.py create mode 100644 src/google/adk_community/tools/hitl/adapters/__init__.py create mode 100644 src/google/adk_community/tools/hitl/adapters/adk1.py create mode 100644 src/google/adk_community/tools/hitl/gateway.py create mode 100644 src/google/adk_community/tools/hitl/models.py create mode 100644 tests/unittests/services/test_hitl_approval_api.py create mode 100644 tests/unittests/tools/test_hitl_gateway.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 904f09be..74f08f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Features + +* **hitl:** add production-ready Human-in-the-Loop approval gateway for ADK agents — includes `@hitl_tool` decorator, FastAPI approval service with SQLite persistence, ADK 1.x adapter, and reference Streamlit dashboard (`contributing/samples/hitl_approval`) + ## [0.4.1](https://github.com/google/adk-python-community/compare/v0.4.0...v0.4.1) (2026-02-18) diff --git a/contributing/samples/hitl_approval/README.md b/contributing/samples/hitl_approval/README.md new file mode 100644 index 00000000..e1a6c289 --- /dev/null +++ b/contributing/samples/hitl_approval/README.md @@ -0,0 +1,91 @@ +# ADK HITL Approval Dashboard + +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. + +## The Problem Solved + +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: +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). +2. **Single-Agent Limitations:** They silently break across `AgentTool` nested bounds and true multi-agent (A2A) topologies, causing missing events or infinitely looping models. +3. **No Resilient Audit Log:** The Native confirmation tool leaves no easily queryable paper trail linking the human supervisor to a precise LLM request. + +*This project is the production implementation of the HITL pattern covered in the [ADK Multi-Agent Patterns Guide (Advent of Agents Day 13)](#).* + +## What This Library Provides + +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. + +### The 3-Layer Architecture + +```text +┌─────────────────────────────────────────┐ +│ Dashboard UI (Streamlit) │ Layer 3: Demo/reference UI +│ Approval inbox, audit log viewer │ (Easily replaced by Zendesk/etc.) +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ ApprovalRequest Model (Pydantic) │ Layer 2: Normalised Contract API +│ FastAPI backend + SQLite store │ Session-agnostic persistence +└────────────────┬────────────────────────┘ + │ + ┌──────────┴───────────┐ +┌─────▼──────┐ ┌──────────▼──────┐ +│ ADK 1.x │ │ ADK 2.0 │ Layer 1: Adapters +│ Adapter │ │ Adapter │ Only this changes between versions +└────────────┘ └─────────────────┘ +``` + +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. + +## Quick Start (Local Sandbox) + +We have provided a demo customer service agent (`credit_agent`) alongside a launch script to test the interaction end-to-end. + +1. Create your python virtual environment and sync dependencies using `uv` (requires Python 3.11+): + ```bash + uv venv --python "python3.11" ".venv" + source .venv/bin/activate + uv sync --all-extras + ``` +2. Start the FastAPI backend, Streamlit dashboard, and ADK Live Chat agent all at once: + ```bash + ./start_servers.sh + ``` +3. Open `http://localhost:8080` to chat with the agent and ask for a $75 account credit. +4. When the agent pauses and asks for a supervisor, open `http://localhost:8501` to approve or reject the request. + +## How to use in your own ADK application + +Wrapping an ADK agent with a formal enterprise HITL checkpoint takes under 5 lines of code: + +1. Import the `hitl_tool` gateway wrapper. +2. Decorate your function tool. +3. Attach it to your ADK Agent initialization using a standard `FunctionTool`. + +```python +from google.adk.tools import FunctionTool +from google.adk_community.tools.hitl.gateway import hitl_tool + +# 1. Wrap your function with the decorator +@hitl_tool(agent_name="my_billing_agent") +async def issue_refund(user_id: str, amount: float): + # This block won't execute until explicitly approved inside the FastAPI dashboard + return {"status": "success", "amount_refunded": amount} + +# 2. Attach to ADK Agent +root_agent = Agent( + name="my_billing_agent", + tools=[FunctionTool(issue_refund)] +) +``` + +## Production Integration Strategies + +This repository acts as the production baseline for a contact center or enterprise orchestration grid. Once deployed to staging, consider swapping out: +* **Storage Layer:** Replace the local `SQLite` engine in `app/api/store.py` with `PostgreSQL` or `Cloud Spanner`. +* **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. +* **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`. + +## ADK 2.0 Compatibility + +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. diff --git a/contributing/samples/hitl_approval/credit_agent/__init__.py b/contributing/samples/hitl_approval/credit_agent/__init__.py new file mode 100644 index 00000000..02c597e1 --- /dev/null +++ b/contributing/samples/hitl_approval/credit_agent/__init__.py @@ -0,0 +1 @@ +from . import agent diff --git a/contributing/samples/hitl_approval/credit_agent/agent.py b/contributing/samples/hitl_approval/credit_agent/agent.py new file mode 100644 index 00000000..a7a2230a --- /dev/null +++ b/contributing/samples/hitl_approval/credit_agent/agent.py @@ -0,0 +1,80 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Credit agent — external supervisor HITL demo. + +This agent demonstrates the cross-user approval pattern: + - Customer chats in ADK web (:8080) + - Agent wants to apply a credit → submits request to HITL API (:8000) + - Agent blocks (non-blocking async poll) waiting for a decision + - Supervisor opens Streamlit dashboard (:8501), reviews and approves/rejects + - Agent resumes and informs the customer of the outcome + +Make sure all three services are running before chatting (see start_servers.sh): + HITL API: uvicorn google.adk_community.services.hitl_approval.api:app --port 8000 + Dashboard: streamlit run dashboard/app.py --server.headless true + ADK web: adk web credit_agent/ --port 8080 +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) + +from google.adk.agents import Agent +from google.adk.tools import FunctionTool + +from google.adk_community.tools.hitl.gateway import hitl_tool + + +@hitl_tool(agent_name="credit_agent") +async def apply_account_credit(account_id: str, amount: float, reason: str) -> dict: + """Apply a credit to a customer account. Requires supervisor approval. + + Args: + account_id: The customer account ID to credit. + amount: Credit amount in USD. + reason: Business justification for the credit. + + Returns: + Confirmation with the updated account balance. + """ + # Real implementation would call your billing/CRM API here + return { + "status": "credited", + "account_id": account_id, + "amount_credited": amount, + "new_balance": f"${amount:.2f} credit applied successfully.", + } + + +root_agent = Agent( + name="credit_agent", + model="gemini-2.5-flash", + description=( + "Customer support agent that can apply account credits. " + "Every credit requires supervisor approval via the HITL dashboard." + ), + instruction=( + "You are a customer support agent. When a customer requests an account credit, " + "call apply_account_credit with their account ID, the amount, and the reason. " + "Let them know their request is being reviewed by a supervisor and that you will " + "update them once a decision is made. " + "If the credit is approved, confirm it to the customer. " + "If rejected, apologise and explain that the supervisor did not approve it." + ), + tools=[FunctionTool(apply_account_credit)], +) diff --git a/contributing/samples/hitl_approval/dashboard/app.py b/contributing/samples/hitl_approval/dashboard/app.py new file mode 100644 index 00000000..c576198e --- /dev/null +++ b/contributing/samples/hitl_approval/dashboard/app.py @@ -0,0 +1,126 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Streamlit HITL Approval Dashboard. + +Run: + streamlit run contributing/samples/hitl_approval/dashboard/app.py +""" + +import httpx +import streamlit as st + +API_BASE = "http://localhost:8000" + + +def _resolve(request_id: str, decision: str, note: str): + try: + r = httpx.post( + f"{API_BASE}/approvals/{request_id}/decide", + json={ + "decision": decision, + "reviewer_id": "dashboard_admin", + "notes": note or None, + }, + timeout=5, + ) + r.raise_for_status() + st.success(f"Request {request_id[:8]}… marked as {decision}.") + st.rerun() + except Exception as e: + st.error(f"Failed to resolve: {e}") + + +st.set_page_config(page_title="ADK HITL Dashboard", page_icon="🔍", layout="wide") +st.title("ADK HITL Approval Dashboard") + +# ── Sidebar filters ─────────────────────────────────────────────────────────── + +status_filter = st.sidebar.selectbox( + "Filter by status", ["All", "pending", "approved", "rejected", "escalated"] +) + +if st.sidebar.button("Refresh"): + st.rerun() + +# ── Fetch approvals ─────────────────────────────────────────────────────────── + +try: + if status_filter == "pending": + resp = httpx.get(f"{API_BASE}/approvals/pending", timeout=5) + else: + params = {} + if status_filter != "All": + params["decision"] = status_filter + resp = httpx.get(f"{API_BASE}/approvals/audit", params=params, timeout=5) + + resp.raise_for_status() + requests = resp.json() +except Exception as e: + st.error(f"Could not connect to API: {e}") + st.stop() + +# ── Render approval cards ───────────────────────────────────────────────────── + +if not requests: + st.info("No approval requests found.") +else: + for req in requests: + status = req["status"] + color = { + "pending": "🟡", + "approved": "🟢", + "rejected": "🔴", + "escalated": "🟠", + }.get(status, "⚪") + + with st.expander( + f"{color} [{status.upper()}] {req['tool_name']} — {req['agent_name']} ({req['id'][:8]}…)" + ): + col1, col2 = st.columns(2) + col1.markdown( + f"**App:** `{req.get('app_name', 'N/A')}` | **User:** `{req.get('user_id', 'N/A')}`" + ) + col1.markdown(f"**Agent:** `{req['agent_name']}`") + col1.markdown(f"**Tool:** `{req['tool_name']}`") + col1.markdown(f"**Session:** `{req['session_id']}`") + col2.markdown(f"**Created:** {req['created_at']}") + if req.get("decided_at"): + col2.markdown( + f"**Resolved:** {req['decided_at']} by `{req.get('decided_by', 'unknown')}`" + ) + + st.markdown(f"**Message / Hint:**") + st.info(req.get("message", "No message provided.")) + + st.markdown("**Payload / Arguments:**") + st.json(req.get("payload", {})) + + if req.get("decision_notes"): + st.markdown(f"**Reviewer note:** {req['decision_notes']}") + + if status == "pending": + note = st.text_input( + "Reviewer note (optional)", key=f"note_{req['id']}" + ) + c1, c2, c3 = st.columns(3) + + if c1.button("Approve", key=f"approve_{req['id']}", type="primary"): + _resolve(req["id"], "approved", note) + + if c2.button("Reject", key=f"reject_{req['id']}"): + _resolve(req["id"], "rejected", note) + + if c3.button("Escalate", key=f"escalate_{req['id']}"): + _resolve(req["id"], "escalated", note) diff --git a/contributing/samples/hitl_approval/requirements.txt b/contributing/samples/hitl_approval/requirements.txt new file mode 100644 index 00000000..06b8b483 --- /dev/null +++ b/contributing/samples/hitl_approval/requirements.txt @@ -0,0 +1,14 @@ +# Sample-specific dependencies for the HITL Approval demo. +# Install into the repo virtualenv after `uv sync --all-extras`: +# +# uv pip install -r contributing/samples/hitl_approval/requirements.txt +# +# The core package (google-adk-community) and its deps (google-adk, httpx) +# are already installed by `uv sync`. Only the service and dashboard extras +# are listed here. + +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +sqlalchemy>=2.0.0 +aiosqlite>=0.20.0 +streamlit>=1.35.0 diff --git a/contributing/samples/hitl_approval/start_servers.sh b/contributing/samples/hitl_approval/start_servers.sh new file mode 100755 index 00000000..085ce29d --- /dev/null +++ b/contributing/samples/hitl_approval/start_servers.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Copyright 2026 Google LLC + +# 1. Kill any lingering local servers from previous runs to free up ports +killall python uvicorn streamlit adk 2>/dev/null || true +sleep 1 + +# 2. Ensure we're running from the repo root so imports resolve correctly +cd "$(git rev-parse --show-toplevel)" + +# 3. Load GOOGLE_GENAI_API_KEY from .env if present +if [ -f .env ]; then + source .env +fi + +echo "Starting FastAPI HITL Backend (:8000)..." +export HITL_DB_PATH="./contributing/samples/hitl_approval/hitl.db" +.venv/bin/uvicorn google.adk_community.services.hitl_approval.api:app --port 8000 & +API_PID=$! + +echo "Starting Streamlit Dashboard (:8501)..." +STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ + .venv/bin/streamlit run contributing/samples/hitl_approval/dashboard/app.py \ + --server.headless true & +STREAMLIT_PID=$! + +echo "Starting ADK Web Chat (:8080)..." +.venv/bin/adk web contributing/samples/hitl_approval --port 8080 --enable_features=TOOL_CONFIRMATION & +ADK_PID=$! + +echo "" +echo "All services launched." +echo "==========================================" +echo "Backend API: http://localhost:8000/docs" +echo "Dashboard UI: http://localhost:8501" +echo "ADK Agent Chat: http://localhost:8080" +echo "==========================================" +echo "Press Ctrl+C to shut down all servers." + +trap "kill $API_PID $STREAMLIT_PID $ADK_PID 2>/dev/null; exit" EXIT + +wait diff --git a/src/google/adk_community/services/hitl_approval/__init__.py b/src/google/adk_community/services/hitl_approval/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/google/adk_community/services/hitl_approval/api.py b/src/google/adk_community/services/hitl_approval/api.py new file mode 100644 index 00000000..0c729da7 --- /dev/null +++ b/src/google/adk_community/services/hitl_approval/api.py @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""FastAPI application entry point.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from . import routes +from .store import init_db + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI( + title="ADK HITL Approval API", + description="Human-in-the-Loop approval layer for Google ADK agents.", + version="0.1.0", + lifespan=lifespan, +) + +app.include_router(routes.router) + + +@app.get("/health") +async def health(): + return {"status": "ok"} diff --git a/src/google/adk_community/services/hitl_approval/routes.py b/src/google/adk_community/services/hitl_approval/routes.py new file mode 100644 index 00000000..d0fe487b --- /dev/null +++ b/src/google/adk_community/services/hitl_approval/routes.py @@ -0,0 +1,162 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Approval request CRUD endpoints.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from .store import ApprovalRequestDB, get_db +from ...tools.hitl.models import (ApprovalDecision, ApprovalRequest, ApprovalStatus) + +router = APIRouter(prefix="/approvals", tags=["approvals"]) + +# ── Routes ──────────────────────────────────────────────────────────────────── + + +@router.post("/", response_model=ApprovalRequest, status_code=201) +async def create_approval(payload: ApprovalRequest, db: AsyncSession = Depends(get_db)): + """Agent submits a new approval request before executing a tool.""" + db_item = ApprovalRequestDB( + id=payload.id, + session_id=payload.session_id, + invocation_id=payload.invocation_id, + function_call_id=payload.function_call_id, + app_name=payload.app_name, + user_id=payload.user_id, + agent_name=payload.agent_name, + tool_name=payload.tool_name, + message=payload.message, + payload=json.dumps(payload.payload), + response_schema=json.dumps(payload.response_schema), + risk_level=payload.risk_level, + status=payload.status, + created_at=payload.created_at, + decided_at=payload.decided_at, + decided_by=payload.decided_by, + decision_notes=payload.decision_notes, + escalated_to=payload.escalated_to, + ) + db.add(db_item) + await db.commit() + await db.refresh(db_item) + return _to_pydantic(db_item) + + +@router.get("/pending", response_model=List[ApprovalRequest]) +async def list_pending_approvals(db: AsyncSession = Depends(get_db)): + """List all pending approvals.""" + q = ( + select(ApprovalRequestDB) + .where(ApprovalRequestDB.status == ApprovalStatus.PENDING) + .order_by(ApprovalRequestDB.created_at.desc()) + ) + result = await db.execute(q) + return [_to_pydantic(r) for r in result.scalars()] + + +@router.get("/audit", response_model=List[ApprovalRequest]) +async def get_audit_log( + agent_name: Optional[str] = None, + decision: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + """Audit log — queryable by agent, date, decision.""" + q = select(ApprovalRequestDB).order_by(ApprovalRequestDB.created_at.desc()) + if agent_name: + q = q.where(ApprovalRequestDB.agent_name == agent_name) + if decision: + q = q.where(ApprovalRequestDB.status == decision) + + result = await db.execute(q) + return [_to_pydantic(r) for r in result.scalars()] + + +@router.get("/{request_id}", response_model=ApprovalRequest) +async def get_approval(request_id: str, db: AsyncSession = Depends(get_db)): + """Get single approval with full context.""" + db_item = await _get_or_404(request_id, db) + return _to_pydantic(db_item) + + +@router.post("/{request_id}/decide", response_model=ApprovalRequest) +async def resolve_approval( + request_id: str, + decision: ApprovalDecision, + db: AsyncSession = Depends(get_db), +): + """Submit approve/reject/escalate decision.""" + db_item = await _get_or_404(request_id, db) + if db_item.status != ApprovalStatus.PENDING: + raise HTTPException(status_code=409, detail="Request already resolved.") + + db_item.status = decision.decision + db_item.decided_by = decision.reviewer_id + db_item.decision_notes = decision.notes + db_item.escalated_to = decision.escalate_to + db_item.decided_at = datetime.now(timezone.utc) + + # Optionally update payload if modified by reviewer + if decision.payload: + db_item.payload = json.dumps(decision.payload) + + await db.commit() + await db.refresh(db_item) + + return _to_pydantic(db_item) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +async def _get_or_404(request_id: str, db: AsyncSession) -> ApprovalRequestDB: + result = await db.execute( + select(ApprovalRequestDB).where(ApprovalRequestDB.id == request_id) + ) + db_item = result.scalar_one_or_none() + if db_item is None: + raise HTTPException(status_code=404, detail="Approval request not found.") + return db_item + + +def _to_pydantic(db_item: ApprovalRequestDB) -> ApprovalRequest: + return ApprovalRequest( + id=db_item.id, + session_id=db_item.session_id, + invocation_id=db_item.invocation_id, + function_call_id=db_item.function_call_id, + app_name=db_item.app_name, + user_id=db_item.user_id, + agent_name=db_item.agent_name, + tool_name=db_item.tool_name, + message=db_item.message, + payload=json.loads(db_item.payload) if db_item.payload else {}, + response_schema=json.loads(db_item.response_schema) + if db_item.response_schema + else {}, + risk_level=db_item.risk_level, + status=db_item.status, + created_at=db_item.created_at, + decided_at=db_item.decided_at, + decided_by=db_item.decided_by, + decision_notes=db_item.decision_notes, + escalated_to=db_item.escalated_to, + ) diff --git a/src/google/adk_community/services/hitl_approval/store.py b/src/google/adk_community/services/hitl_approval/store.py new file mode 100644 index 00000000..34f6e965 --- /dev/null +++ b/src/google/adk_community/services/hitl_approval/store.py @@ -0,0 +1,80 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async SQLite database setup via SQLAlchemy.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, String, Text +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +class ApprovalRequestDB(Base): + __tablename__ = "approval_requests" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + session_id = Column(String, nullable=False) + invocation_id = Column(String, nullable=True) + function_call_id = Column(String, nullable=True) + app_name = Column(String, nullable=False) + user_id = Column(String, nullable=False) + agent_name = Column(String, nullable=False) + tool_name = Column(String, nullable=False) + message = Column(Text, nullable=False) + payload = Column(Text, nullable=False) # JSON-serialised + response_schema = Column(Text, nullable=True) # JSON-serialised + risk_level = Column(String, nullable=False) + status = Column(String, nullable=False) + created_at = Column( + DateTime, default=lambda: datetime.now(timezone.utc), nullable=False + ) + decided_at = Column(DateTime, nullable=True) + decided_by = Column(String, nullable=True) + decision_notes = Column(Text, nullable=True) + escalated_to = Column(String, nullable=True) + + +import os + +db_path = os.getenv("HITL_DB_PATH", "./hitl.db") +DATABASE_URL = f"sqlite+aiosqlite:///{db_path}" + +engine = create_async_engine(DATABASE_URL, echo=False) +AsyncSessionLocal = async_sessionmaker( + engine, expire_on_commit=False, class_=AsyncSession +) + + +async def init_db() -> None: + """Create tables on startup.""" + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +async def get_db(): + """FastAPI dependency that yields a database session.""" + async with AsyncSessionLocal() as session: + yield session diff --git a/src/google/adk_community/tools/hitl/__init__.py b/src/google/adk_community/tools/hitl/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/google/adk_community/tools/hitl/adapters/__init__.py b/src/google/adk_community/tools/hitl/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/google/adk_community/tools/hitl/adapters/adk1.py b/src/google/adk_community/tools/hitl/adapters/adk1.py new file mode 100644 index 00000000..94e925fb --- /dev/null +++ b/src/google/adk_community/tools/hitl/adapters/adk1.py @@ -0,0 +1,86 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Adapter for Google ADK 1.x Human-in-the-Loop feature. + +Converts ADK `adk_request_confirmation` events to normalized ApprovalRequests, +and formats Streamlit dashboard decisions back into ADK FunctionResponses. +""" + +from __future__ import annotations + +from typing import Any, Dict + +import httpx + +from ..models import (ApprovalDecision, ApprovalRequest, ApprovalStatus) + + +def parse_confirmation_event(payload: Dict[str, Any]) -> ApprovalRequest: + """Parse an incoming ADK 1.x Tool Confirmation event to a normalized ApprovalRequest.""" + + call_id = payload.get("function_call_id") + args = payload.get("arguments", {}) + hint = args.get("hint", "Please review this action.") + tool_payload = args.get("payload", {}) + + return ApprovalRequest( + session_id=payload.get("session_id", "unknown_session"), + invocation_id=payload.get("invocation_id"), + function_call_id=call_id, + app_name=payload.get("app_name", "unknown_app"), + user_id=payload.get("user_id", "unknown_user"), + agent_name=payload.get("agent_name", "unknown_agent"), + tool_name=args.get("tool_name", "unknown_tool"), + message=hint, + payload=tool_payload, + response_schema={}, # Native tool confirmation in ADK 1.x doesn't expose a schema + ) + + +async def submit_decision_to_adk( + adk_base_url: str, request: ApprovalRequest, decision: ApprovalDecision +): + """Resume the ADK 1.x agent by sending the human's decision back as a FunctionResponse.""" + + confirmed = decision.decision == ApprovalStatus.APPROVED + + adk_payload = { + "app_name": request.app_name, + "user_id": request.user_id, + "session_id": request.session_id, + "invocation_id": request.invocation_id, + "new_message": { + "role": "user", + "parts": [ + { + "function_response": { + "id": request.function_call_id, + "name": "adk_request_confirmation", + "response": { + "confirmed": confirmed, + "payload": decision.payload or {}, + }, + } + } + ], + }, + } + + async with httpx.AsyncClient() as client: + # Assumes the ADK FastAPI server is running with the /run_sse endpoint + url = f"{adk_base_url.rstrip('/')}/run_sse" + resp = await client.post(url, json=adk_payload) + resp.raise_for_status() + return resp.json() diff --git a/src/google/adk_community/tools/hitl/gateway.py b/src/google/adk_community/tools/hitl/gateway.py new file mode 100644 index 00000000..66d5aa67 --- /dev/null +++ b/src/google/adk_community/tools/hitl/gateway.py @@ -0,0 +1,138 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""HITL tool wrapper — submits an approval request to the FastAPI API and +waits asynchronously for a supervisor to approve/reject via the Streamlit +dashboard before executing the wrapped tool. + +Usage: + from google.adk_community.tools.hitl.gateway import hitl_tool + from google.adk.tools import FunctionTool + + @hitl_tool(agent_name="credit_agent") + async def apply_credit(account_id: str, amount: float) -> str: + ... # only runs after a supervisor approves in the dashboard + + tool = FunctionTool(apply_credit) +""" + +from __future__ import annotations + +import asyncio +import functools +import inspect +import json +import uuid +from typing import Any, Callable, Optional + +import httpx + +API_BASE_URL = "http://localhost:8000" +POLL_INTERVAL_S = 2.0 +POLL_TIMEOUT_S = 300.0 # 5 minutes + + +def hitl_tool( + agent_name: str, + api_base: str = API_BASE_URL, + poll_interval: float = POLL_INTERVAL_S, + timeout: float = POLL_TIMEOUT_S, +): + """Decorator — wraps any async or sync function with a supervisor approval gate. + + Flow: + 1. Agent calls the wrapped function. + 2. Wrapper POSTs an approval request to the HITL API (status: pending). + 3. Wrapper polls GET /approvals/{id} with asyncio.sleep — non-blocking. + 4. Supervisor opens the Streamlit dashboard and clicks Approve/Reject. + 5. On approval the original function runs; on rejection a PermissionError + is raised so the agent can relay the outcome to the user. + """ + + def decorator(fn: Callable) -> Callable: + @functools.wraps(fn) + async def wrapper(*args, **kwargs) -> Any: + session_id = kwargs.pop("_session_id", str(uuid.uuid4())) + invocation_id = kwargs.pop("_invocation_id", None) + + payload = { + "session_id": session_id, + "invocation_id": invocation_id, + "app_name": "adk_chatbot", + "user_id": "current_user", + "agent_name": agent_name, + "tool_name": fn.__name__, + "message": f"Approval requested for {fn.__name__}", + "payload": _serialise_args(fn, args, kwargs), + } + + async with httpx.AsyncClient(base_url=api_base) as client: + resp = await client.post("/approvals/", json=payload) + resp.raise_for_status() + request_id = resp.json()["id"] + + status = await _poll_for_decision( + api_base, request_id, poll_interval, timeout + ) + + if status == "approved": + if inspect.iscoroutinefunction(fn): + return await fn(*args, **kwargs) + else: + return fn(*args, **kwargs) + elif status == "rejected": + raise PermissionError( + f"Tool '{fn.__name__}' was rejected by a supervisor." + ) + elif status == "escalated": + raise PermissionError( + f"Tool '{fn.__name__}' was escalated — awaiting further review." + ) + else: + raise TimeoutError( + f"No decision received for '{fn.__name__}' within {timeout}s." + ) + + return wrapper + + return decorator + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +async def _poll_for_decision( + api_base: str, + request_id: str, + interval: float, + timeout: float, +) -> Optional[str]: + deadline = asyncio.get_event_loop().time() + timeout + async with httpx.AsyncClient(base_url=api_base) as client: + while asyncio.get_event_loop().time() < deadline: + resp = await client.get(f"/approvals/{request_id}") + resp.raise_for_status() + data = resp.json() + if data["status"] != "pending": + return data["status"] + await asyncio.sleep(interval) + return None + + +def _serialise_args(fn: Callable, args: tuple, kwargs: dict) -> dict: + sig = inspect.signature(fn) + params = list(sig.parameters.keys()) + named = {params[i]: args[i] for i in range(len(args)) if i < len(params)} + named.update(kwargs) + return json.loads(json.dumps(named, default=str)) diff --git a/src/google/adk_community/tools/hitl/models.py b/src/google/adk_community/tools/hitl/models.py new file mode 100644 index 00000000..0d5ecf9e --- /dev/null +++ b/src/google/adk_community/tools/hitl/models.py @@ -0,0 +1,75 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class ApprovalStatus: + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + ESCALATED = "escalated" + EXPIRED = "expired" + + +class RiskLevel: + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class ApprovalRequest(BaseModel): + # Identity + id: str = Field(default_factory=lambda: str(uuid.uuid4())) + + # ADK context — needed to resume the agent correctly + session_id: str + invocation_id: Optional[str] = None # Required for ADK Resume feature + function_call_id: Optional[str] = None # Must match in FunctionResponse + app_name: str + user_id: str + + # Agent context — what the human needs to decide + agent_name: str + tool_name: str + message: str # Maps from ADK 1.x 'hint' OR ADK 2.0 'message' + payload: dict # The structured data awaiting approval + response_schema: dict = Field( + default_factory=dict + ) # Empty in 1.x, populated in ADK 2.0 + risk_level: str = RiskLevel.MEDIUM + + # Status tracking + status: str = ApprovalStatus.PENDING + created_at: datetime = Field(default_factory=datetime.utcnow) + decided_at: Optional[datetime] = None + decided_by: Optional[str] = None + decision_notes: Optional[str] = None + + # Escalation + escalated_to: Optional[str] = None + + +class ApprovalDecision(BaseModel): + decision: str # approved / rejected / escalated + reviewer_id: str + notes: Optional[str] = None + payload: dict = Field(default_factory=dict) # Response data back to the agent + escalate_to: Optional[str] = None diff --git a/tests/unittests/services/test_hitl_approval_api.py b/tests/unittests/services/test_hitl_approval_api.py new file mode 100644 index 00000000..e48552e8 --- /dev/null +++ b/tests/unittests/services/test_hitl_approval_api.py @@ -0,0 +1,166 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Integration tests for the FastAPI approval endpoints.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import ( + AsyncSession, + async_sessionmaker, + create_async_engine, +) + +from google.adk_community.services.hitl_approval.api import app +from google.adk_community.services.hitl_approval.store import Base, get_db + +# Use an in-memory SQLite database for tests +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine(TEST_DATABASE_URL) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + session_factory = async_sessionmaker( + engine, expire_on_commit=False, class_=AsyncSession + ) + async with session_factory() as session: + yield session + await engine.dispose() + + +@pytest_asyncio.fixture +async def client(db_session): + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + app.dependency_overrides.clear() + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +@pytest.mark.asyncio +async def test_create_approval(client): + payload = { + "session_id": "sess-1", + "app_name": "test_app", + "user_id": "u-123", + "agent_name": "email_agent", + "tool_name": "send_email", + "message": "Please approve sending email.", + "payload": {"to": "alice@example.com"}, + } + resp = await client.post("/approvals/", json=payload) + assert resp.status_code == 201 + data = resp.json() + assert data["status"] == "pending" + assert data["tool_name"] == "send_email" + return data["id"] + + +@pytest.mark.asyncio +async def test_resolve_approval(client): + # Create first + create_resp = await client.post( + "/approvals/", + json={ + "session_id": "sess-2", + "app_name": "test_app", + "user_id": "u-123", + "agent_name": "file_agent", + "tool_name": "delete_file", + "message": "Approve delete?", + "payload": {"path": "/tmp/test.txt"}, + }, + ) + assert create_resp.status_code == 201 + request_id = create_resp.json()["id"] + + # Resolve + resolve_resp = await client.post( + f"/approvals/{request_id}/decide", + json={"decision": "approved", "reviewer_id": "rev-99", "notes": "Looks safe."}, + ) + assert resolve_resp.status_code == 200 + data = resolve_resp.json() + assert data["status"] == "approved" + assert data["decision_notes"] == "Looks safe." + assert data["decided_at"] is not None + + +@pytest.mark.asyncio +async def test_double_resolve_returns_409(client): + create_resp = await client.post( + "/approvals/", + json={ + "session_id": "sess-3", + "app_name": "test_app", + "user_id": "u-123", + "agent_name": "researcher", + "tool_name": "web_search", + "message": "Search the web?", + "payload": {"query": "latest news"}, + }, + ) + request_id = create_resp.json()["id"] + + await client.post( + f"/approvals/{request_id}/decide", + json={"decision": "rejected", "reviewer_id": "rev-1"}, + ) + resp2 = await client.post( + f"/approvals/{request_id}/decide", + json={"decision": "approved", "reviewer_id": "rev-1"}, + ) + assert resp2.status_code == 409 + + +@pytest.mark.asyncio +async def test_list_pending(client): + # Create two requests + for tool in ["tool_a", "tool_b"]: + await client.post( + "/approvals/", + json={ + "session_id": "s", + "app_name": "app", + "user_id": "u", + "agent_name": "ag", + "tool_name": tool, + "message": "msg", + "payload": {}, + }, + ) + + resp = await client.get("/approvals/pending") + assert resp.status_code == 200 + assert len(resp.json()) == 2 + assert all(r["status"] == "pending" for r in resp.json()) diff --git a/tests/unittests/tools/test_hitl_gateway.py b/tests/unittests/tools/test_hitl_gateway.py new file mode 100644 index 00000000..69ea7f04 --- /dev/null +++ b/tests/unittests/tools/test_hitl_gateway.py @@ -0,0 +1,113 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the HITL tool wrapper (mocking the API calls).""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from google.adk_community.tools.hitl.gateway import _serialise_args, hitl_tool + +# ── _serialise_args ─────────────────────────────────────────────────────────── + + +def test_serialise_args_positional(): + def fn(a, b, c): + ... + + result = _serialise_args(fn, (1, 2), {"c": 3}) + assert result == {"a": 1, "b": 2, "c": 3} + + +def test_serialise_args_kwargs_only(): + def fn(x, y): + ... + + result = _serialise_args(fn, (), {"x": "hello", "y": 42}) + assert result == {"x": "hello", "y": 42} + + +def test_serialise_args_non_serialisable_falls_back_to_str(): + class Foo: + pass + + def fn(obj): + ... + + result = _serialise_args(fn, (Foo(),), {}) + assert isinstance(result["obj"], str) + + +# ── hitl_tool — approved ────────────────────────────────────────────────────── + + +def _make_mock_client(status: str, request_id: str = "abc-123"): + mock_client = AsyncMock() + + # Setup context manager correctly + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = False + + post_resp = MagicMock() + post_resp.json.return_value = {"id": request_id} + mock_client.post.return_value = post_resp + + get_resp = MagicMock() + get_resp.json.return_value = {"id": request_id, "status": status} + mock_client.get.return_value = get_resp + + return mock_client + + +@pytest.mark.asyncio +@patch("google.adk_community.tools.hitl.gateway.httpx.AsyncClient") +async def test_approved_tool_runs(mock_client_cls): + mock_client_cls.return_value = _make_mock_client("approved") + + @hitl_tool(agent_name="test_agent") + def add(a: int, b: int) -> int: + return a + b + + result = await add(2, 3) + assert result == 5 + + +@pytest.mark.asyncio +@patch("google.adk_community.tools.hitl.gateway.httpx.AsyncClient") +async def test_rejected_tool_raises(mock_client_cls): + mock_client_cls.return_value = _make_mock_client("rejected") + + @hitl_tool(agent_name="test_agent") + def delete_file(path: str) -> str: + return "deleted" + + with pytest.raises(PermissionError, match="rejected"): + await delete_file("/important/file.txt") + + +@pytest.mark.asyncio +@patch("google.adk_community.tools.hitl.gateway.httpx.AsyncClient") +async def test_escalated_tool_raises(mock_client_cls): + mock_client_cls.return_value = _make_mock_client("escalated") + + @hitl_tool(agent_name="test_agent") + def wire_transfer(amount: float) -> str: + return "done" + + with pytest.raises(PermissionError, match="escalated"): + await wire_transfer(10000.0) From 1ddc3884cc3a05202464f57886d0b1b406ba84ea Mon Sep 17 00:00:00 2001 From: Gary Thomas George Date: Mon, 23 Mar 2026 22:31:43 -0400 Subject: [PATCH 2/3] fix(hitl): inject supervisor decision context into LLM tool response payload --- .../samples/hitl_approval/start_servers.sh | 2 +- .../adk_community/tools/hitl/gateway.py | 34 +++++++++++++------ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/contributing/samples/hitl_approval/start_servers.sh b/contributing/samples/hitl_approval/start_servers.sh index 085ce29d..afe4b0ae 100755 --- a/contributing/samples/hitl_approval/start_servers.sh +++ b/contributing/samples/hitl_approval/start_servers.sh @@ -25,7 +25,7 @@ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \ STREAMLIT_PID=$! echo "Starting ADK Web Chat (:8080)..." -.venv/bin/adk web contributing/samples/hitl_approval --port 8080 --enable_features=TOOL_CONFIRMATION & +.venv/bin/adk web contributing/samples/hitl_approval --port 8080 & ADK_PID=$! echo "" diff --git a/src/google/adk_community/tools/hitl/gateway.py b/src/google/adk_community/tools/hitl/gateway.py index 66d5aa67..e279e310 100644 --- a/src/google/adk_community/tools/hitl/gateway.py +++ b/src/google/adk_community/tools/hitl/gateway.py @@ -82,26 +82,38 @@ async def wrapper(*args, **kwargs) -> Any: resp.raise_for_status() request_id = resp.json()["id"] - status = await _poll_for_decision( + decision_data = await _poll_for_decision( api_base, request_id, poll_interval, timeout ) + + if not decision_data: + raise TimeoutError( + f"No decision received for '{fn.__name__}' within {timeout}s." + ) + + status = decision_data["status"] + notes = decision_data.get("decision_notes", "No notes provided.") if status == "approved": if inspect.iscoroutinefunction(fn): - return await fn(*args, **kwargs) + result = await fn(*args, **kwargs) else: - return fn(*args, **kwargs) + result = fn(*args, **kwargs) + + # We inject the supervisor's decision into the return payload + # so the LLM explicitly sees and references the supervisor's approval! + return { + "supervisor_decision": "APPROVED", + "supervisor_notes": notes, + "action_result": result + } elif status == "rejected": raise PermissionError( - f"Tool '{fn.__name__}' was rejected by a supervisor." + f"Tool '{fn.__name__}' was rejected by a supervisor. Notes: {notes}" ) elif status == "escalated": raise PermissionError( - f"Tool '{fn.__name__}' was escalated — awaiting further review." - ) - else: - raise TimeoutError( - f"No decision received for '{fn.__name__}' within {timeout}s." + f"Tool '{fn.__name__}' was escalated — awaiting further review. Notes: {notes}" ) return wrapper @@ -117,7 +129,7 @@ async def _poll_for_decision( request_id: str, interval: float, timeout: float, -) -> Optional[str]: +) -> Optional[dict]: deadline = asyncio.get_event_loop().time() + timeout async with httpx.AsyncClient(base_url=api_base) as client: while asyncio.get_event_loop().time() < deadline: @@ -125,7 +137,7 @@ async def _poll_for_decision( resp.raise_for_status() data = resp.json() if data["status"] != "pending": - return data["status"] + return data await asyncio.sleep(interval) return None From 0bf358e4a46f6eab98f26ef72a08a1f3f675e2ba Mon Sep 17 00:00:00 2001 From: Gary Thomas George Date: Tue, 5 May 2026 18:38:40 -0400 Subject: [PATCH 3/3] 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 --- .pr_body.md | 103 ++++++++++++++++++ contributing/samples/hitl_approval/README.md | 56 +++++++--- .../credit_agent/.adk/session.db | Bin 0 -> 114688 bytes contributing/samples/hitl_approval/hitl.db | Bin 0 -> 12288 bytes hitl.db | Bin 0 -> 12288 bytes .../services/hitl_approval/routes.py | 23 +--- .../adk_community/tools/hitl/gateway.py | 11 +- src/google/adk_community/tools/hitl/models.py | 46 +++++++- tests/unittests/tools/test_hitl_gateway.py | 4 +- 9 files changed, 193 insertions(+), 50 deletions(-) create mode 100644 .pr_body.md create mode 100644 contributing/samples/hitl_approval/credit_agent/.adk/session.db create mode 100644 contributing/samples/hitl_approval/hitl.db create mode 100644 hitl.db diff --git a/.pr_body.md b/.pr_body.md new file mode 100644 index 00000000..23894478 --- /dev/null +++ b/.pr_body.md @@ -0,0 +1,103 @@ +## Summary +Closes #[ISSUE NUMBER] +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. + +## Problem +ADK's Tool Confirmation (v1.14.0+) is experimental and has three blockers for production use: +1. Does not support `DatabaseSessionService` or `VertexAiSessionService` +2. Does not trigger inside `AgentTool` or across A2A boundaries +3. No structured approval UI, audit trail, or persistence layer + +Validated by community issues: #1797, #1851, #2645, #3276, #3567 on `google/adk-python`. + +## Solution +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. + +### What's included + +**Core module** (`src/google/adk_community/tools/hitl/`) +- `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. +- `models.py` — `ApprovalRequest` Pydantic model, normalised data contract capturing agent context, payload, risk level, and audit metadata +- `adapters/adk1.py` — ADK 1.x adapter translating `request_confirmation()` events into `ApprovalRequest` objects + +**Service** (`src/google/adk_community/services/hitl_approval/`) +- `api.py` — FastAPI application +- `routes.py` — REST endpoints for approval queue management +- `store.py` — SQLite persistence with full audit log + +**Sample** (`contributing/samples/hitl_approval/`) +- `credit_agent/agent.py` — Credit approval agent demonstrating end-to-end integration +- `dashboard/app.py` — Reference Streamlit approval inbox UI +- `start_servers.sh` — One-command startup for all three services +- `requirements.txt` — Sample-only dependencies + +### Architecture +``` +ADK Agent Pipeline + ↓ +@hitl_tool decorator (wraps async function → FunctionTool) + ↓ POST /approvals/ — creates ApprovalRequest +FastAPI + SQLite (approval state) + ↓ serves pending approvals +Streamlit Dashboard (reviewer decides) + ↓ POST /approvals/{id}/decide +FastAPI updates status in SQLite + ↓ decorator polls GET /approvals/{id} every 2 s +Agent resumes execution (wrapper unblocks; runs tool if approved) +``` + +### Forward compatibility +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. + +## Testing +### Unit tests +All 11 tests passing: +```text +============================= test session starts ============================= +platform darwin -- Python 3.11.15, pytest-9.0.2, pluggy-1.6.0 +rootdir: /Users/garythomasgeorge/Desktop/Work/AI Dev/adk-python-community +configfile: pyproject.toml +plugins: anyio-4.12.1, asyncio-1.3.0 +asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=function, asyncio_default_test_loop_scope=function +collected 11 items + +tests/unittests/tools/test_hitl_gateway.py ...... [ 54%] +tests/unittests/services/test_hitl_approval_api.py ..... [100%] + +================================ 11 passed in 1.76s ================================= +Exit code: 0 +``` + +### Manual E2E +Full end-to-end flow verified: +- Agent triggers approval request → appears in Streamlit dashboard ✓ +- Reviewer approves in dashboard → agent resumes correctly ✓ +- Uvicorn restart → SQLite persists previous approvals ✓ + +> 🎥 *Please drag-and-drop your `hitl_demo_video_1774318429041.webp` file here before publishing* + +## Testing plan +For reviewers wanting to reproduce locally: +```bash +cd contributing/samples/hitl_approval +uv pip install -r requirements.txt +./start_servers.sh +``` + +Then open: +- ADK Dev UI: `http://localhost:8080` +- Streamlit dashboard: `http://localhost:8501` +- FastAPI docs: `http://localhost:8000/docs` + +Trigger an approval by asking the credit agent to process an amount over $500. + +## Notes for reviewers +- Opening as **Draft** — happy to address structural feedback before requesting full review +- ADK 2.0 adapter (`adapters/adk2.py`) is planned as a follow-up PR once 2.0 moves toward stable +- 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 + +## Related +- Proposal issue: #[ISSUE NUMBER] +- ADK Tool Confirmation docs (known limitations): https://google.github.io/adk-docs/tools-custom/confirmation/ +- ADK multi-agent HITL pattern reference: https://developers.googleblog.com/developers-guide-to-multi-agent-patterns-in-adk/ +- Existing community example this extends: https://github.com/jackwotherspoon/adk-human-in-the-loop diff --git a/contributing/samples/hitl_approval/README.md b/contributing/samples/hitl_approval/README.md index e1a6c289..4f600724 100644 --- a/contributing/samples/hitl_approval/README.md +++ b/contributing/samples/hitl_approval/README.md @@ -5,11 +5,12 @@ A drop-in **production-ready Human-in-the-Loop (HITL) approval middleware** for ## The Problem Solved 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: + 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). 2. **Single-Agent Limitations:** They silently break across `AgentTool` nested bounds and true multi-agent (A2A) topologies, causing missing events or infinitely looping models. -3. **No Resilient Audit Log:** The Native confirmation tool leaves no easily queryable paper trail linking the human supervisor to a precise LLM request. +3. **No Resilient Audit Log:** The native confirmation tool leaves no easily queryable paper trail linking the human supervisor to a precise LLM request. -*This project is the production implementation of the HITL pattern covered in the [ADK Multi-Agent Patterns Guide (Advent of Agents Day 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).* ## What This Library Provides @@ -17,7 +18,7 @@ This project solves the production gaps by explicitly decoupling the human appro ### The 3-Layer Architecture -```text +``` ┌─────────────────────────────────────────┐ │ Dashboard UI (Streamlit) │ Layer 3: Demo/reference UI │ 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 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. +## Configuration + +| Environment Variable | Default | Description | +|---|---|---| +| `ADK_HITL_API_URL` | `http://localhost:8000` | URL of the HITL approval FastAPI backend. Override for Cloud Run or any remote deployment. | +| `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. | + +Set these before starting the gateway: + +```bash +export ADK_HITL_API_URL="https://your-hitl-service.run.app" +export ADK_HITL_POLL_INTERVAL_S="3.0" +``` + ## Quick Start (Local Sandbox) We have provided a demo customer service agent (`credit_agent`) alongside a launch script to test the interaction end-to-end. -1. Create your python virtual environment and sync dependencies using `uv` (requires Python 3.11+): - ```bash - uv venv --python "python3.11" ".venv" - source .venv/bin/activate - uv sync --all-extras - ``` +1. Create your Python virtual environment and sync dependencies using `uv` (requires Python 3.11+): + +```bash +uv venv --python "python3.11" ".venv" +source .venv/bin/activate +uv sync --all-extras +``` + 2. Start the FastAPI backend, Streamlit dashboard, and ADK Live Chat agent all at once: - ```bash - ./start_servers.sh - ``` + +```bash +./start_servers.sh +``` + 3. Open `http://localhost:8080` to chat with the agent and ask for a $75 account credit. 4. When the agent pauses and asks for a supervisor, open `http://localhost:8501` to approve or reject the request. -## How to use in your own ADK application +## How to Use in Your Own ADK Application Wrapping an ADK agent with a formal enterprise HITL checkpoint takes under 5 lines of code: @@ -69,7 +88,7 @@ from google.adk_community.tools.hitl.gateway import hitl_tool # 1. Wrap your function with the decorator @hitl_tool(agent_name="my_billing_agent") async def issue_refund(user_id: str, amount: float): - # This block won't execute until explicitly approved inside the FastAPI dashboard + # This block won't execute until explicitly approved in the dashboard return {"status": "success", "amount_refunded": amount} # 2. Attach to ADK Agent @@ -82,10 +101,11 @@ root_agent = Agent( ## Production Integration Strategies This repository acts as the production baseline for a contact center or enterprise orchestration grid. Once deployed to staging, consider swapping out: -* **Storage Layer:** Replace the local `SQLite` engine in `app/api/store.py` with `PostgreSQL` or `Cloud Spanner`. -* **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. -* **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`. + +- **Storage Layer:** Replace the local `SQLite` engine in `app/api/store.py` with `PostgreSQL` or `Cloud Spanner`. +- **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. +- **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`. ## ADK 2.0 Compatibility -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. +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. \ No newline at end of file diff --git a/contributing/samples/hitl_approval/credit_agent/.adk/session.db b/contributing/samples/hitl_approval/credit_agent/.adk/session.db new file mode 100644 index 0000000000000000000000000000000000000000..de1f32f6db174b149e0f10de67eadbbd486f6e6f GIT binary patch literal 114688 zcmeFa39uyTaUS+&XXn^CaS&L50LZtq00EF&gSwBUmO$V4eK$om{<^xW@2;*ss=*Pl z+`~9%giP~L$h3@gIBY4*plpi{ld@!DZ3balVTFTqNER%I0WxHsRs_Q~DbaGsU%g|t z_iguV&GZar0d>brzkc1Xva9mX%FI9W%gjmxc#N?xqkCs99+Ko?7_|3H~+gEw{HCTwLiK3r&pi7 z_UrdJZvWAq1A7kaIk4xzo&$Rh>^ZRKz@7t-ivurx@Y1EHKls6OuU>bE+Bb~3F?6QK z&zD2RNT3`!EC+(=$l>vOho87|h=-*gmLv7@VK#^Vm2~>Kqj$-8+!~UBvFWqf)UaEd z51Sg(sWlpI`P3TDM;dXq|AAu-jHyEg<3pTo`phXEopnnm4%u{4`C=jyC^imLk;dUC zPS5_i!;^D;?(mH>dk&ZU$>Xbx=8BO-EbHT-PvVTCkzynpij+=iO*efHXdmXXhv7&% z0)B@ArBEOox$?;$x_t5JPk->-fi={P<#f`=UZzE6&gkg(*71V0nB$*M{nQf|E0J3Ou~>$xmIp_;lza+c7s&~Y-ql<+wImUGD<&$sX z-P#AcvG3@6HZf`^PJj!-lNX;(JiCnxYvG~;)N#^$Ex#2fE#WW?Hv9p5?kf5BHi$vtuL>DV*duH}t0XfbcRJ`ZJj*;A<3y{C71Py5H7 z1A7kaIk4xzo&$Rh>^ZRKz@7tp4(vIw=fIu=dk%cJa^Q-0Vf+36ckA-*uWQeNJqPw2 z*mGddfjtNI9N2SU&w)J$_8i!AV9$Xg4(#*)dqLQ9V9$X)2lgD;b70SbJqPw2*mGdd zfjtNI9N2T1Zv4RYe|-H5*OhC3bnV}}mcIJ`;lur7&w)J$_8i!AV9$X) z2lgD;b70SbJqI2G2QoJzf_^EAbCtVqeS z!B8}>@vKVYNl`v`C-9B&U-^q){8be{fAXoxf5>O9Zy_g`nrgC~Ov$P#Q#>mOltObn z#Ti5uR6`ayo<1h`FY15qIgjkW{AcgZWUg%?r!kyPM1iI_LDeXp5QS1$-k@k=r~<8U zCWs%AyYt)sZS{4KyYr1dICqe_x`iCe5t>m%jbez&Q#?-?iiosA5h4-RG-cXg$T7LE zy!W5xJ#xSB6W{!snJZh!8M3a@w4_p^M!-86G*wL`l*}@;#u|#OD)KS8<=5|uEVcuX#G_@l3Q#G;#47dqmFo z`imd^>mXx+qRw!TJCUapQ&xbeyB z%WGf1`M&E<+|1wn&FlaC#(!}Ag&W_v`Q__>=Z5|m6y5$S_Z--BV9$X)2lgD;b70Sb zJqPw2*mGddfo&Xk{sQ76=N=>~a%1zMej*^(Hy`RJ+Hr03p?)G8S2rK(CyH@p^Pzqs z7Ef(H)K7Hc^5#SRL?SM2KGaXt;o_DqiNggMI)~)&_(Z%3qK8h$IAjF+qleyy(?hL8 z?6#pFeMAl&L+c#Qt*PbQJ}h^v=^_4&W{!;<#%MOZb?3*PyJe6121t7ejazq~yY;5I zMtI-f>EC+p7A637M)oNs{H}S!k#PE-yLB{RrKKBvhu~Ly39idB@dxYg*~Vmsfk}q$ z%@XRgzy{N5dxzui-}uZKjrzx*d9zbbzTl2`-a5Y8&l=)X?~Z1=^*kdL_gZtq_9jx{Lfgi`8d?;48wd2sw1bPf zm)yjSet-1LVSKbW)W{H$(lPquLw7VgbVfRHKmC`7>!NVO6(h51O+qi-#h zb9sEzYzpyd4Gf2P1FXCS22tJe#w1N!&ZuV$u|hLXgq7!RjqTB3?7aDHR$$)z!4!hv z5UYPI?D&3yp;9ic(;L_1E!a9y9PP=igKEH8+n$zmCga&`A!#(W>@!Z^g(tG)|M^-)PUwN}pPkZ4Dm;gDfDUN}?@jbL>= z6NW#vanNBkk@qJBeopuuA_aa{ZIcvWeU;lB%gd4kDR|7EpLDb{y{ z6!j@nE(|CICb9j2sC-C&{XYur|@59s0@5R%N_v7jMd+>Da z2A-~Zng1&npTP6Eo4<(XgZJJ1#hZaAzIFb!^MNPNJ@L#FFJ6A;@{5*V_DH(tE<+Kp$fpF4N%;I(tFpCcDuycoFf z%!O}xBpf#Ab_fBA$&*qQkQs($0kfc)PwqgURX9nd zMR?0R4HM`=rBv7~iUk@=6C_^H6zLN?&^L&p=)B~?Ul`L9ehmN-L#9jyFd~&xSfVf= z-+?|cO&%B&oe}|)!8Q{(Dn*l6N+pV}%B(>&nf`$-^!>yPHBOOqRtK7g;lLm4PbeaB z0+xd@@rmYH@{jF6-;h{IG*lP{gAoDm@%RJP1lWrvfwayjviQ**=(B`ppmD$zn)d)Y z1_vY;TpW!DOv>P7_^ZmZJJ44-Mxqr(gK~pm1I$Z-BuOIpBXa_4vMfjQw|1b<3%V%_ zGN5B7zJ!-d@Q2|=iq=e4p(R5SRP~u1=&Q1%%CrFY40}d6Y8vh@h&?4BuAmCQ{Nei# zx6tMUYlh7AQgu?`t$+1%>_oUFh?QK!D|WbF z*~R%nQt?eP!?PybCINTfhj*ZF%Dka5qC)AKw_T%k3911gL{3+L9t8Z2r{xdrK%doU z4(t&qorP%fqD+JTBKF9JjEIEkAq(~I-9q0_3IGdgii!$=9kMylZ{GR>35#Ht&a)yw zb~Md>a0mLjD(Q+!3l!jba8a?SltKU;H5Awbjgg=sln?AcUzS;Hl|f&Hrok3V;VJB_ zun4Nj^D2XXCj0ab^c9r@vR9=&{^R}yP8gO(fqc>i5B1E^EMvTX2l^aKYqTVJ`T-G4 zUO?DNrg@!$;$d~fl>oIR@7smGrt*?-q?%3GcUi*uvsi9X0=$#PUQvXfQRU}JF$gT5dDR0{ioEw9Q$QlUj@o?!)Gq~EiR zzK=W>jIHirs{zRc@|9;0_Jw?ESZ-b7b(7H~ntk^U^mPKAV|ZIWBEvp1ASO=UKOAc(@n$Qn`d{f@ukHGtpG+Md21APL_ttki`#lfQB{#8Bs z)DR5U0q2D~1n<$fu>*ZLAP_`dqzs;h)G!k6WJEG3on{0R8zdlp>FYbthx=;a0u+Q` z2&6`kfcQ1BLo#U*aA|~z;ox4|fj-M1ct=_Clu)?SayC3eLq32w+q8y_q^47 z`KYLoh=wW~1mje=J`hiAPi2Wi5={{aUZCbi(qHiE|1X~Z;=v8&8h3@dY+m>q_+|gt zb70SbZ!ZV-`Tu?Xf1m%~=l_AVSWhpkr%k-~_xXP>61%BX(jT@0$s*ub_WA#fu!~JO zKY=|$aqRQ|+fg9<{Qo}x?+>uBuGt?W7U=CZo*rsrlP=)*`Trxt!8>Fb{lG3ZFC(Yi z76g&z#5>;p|H*UzNH|ZEazeMbcE0mnAa)f7j!`ZYynAIxLk`L|5x(lZSXlhA(wu>@f~u1`eFN9?@yo{ zWmdY49HPPtcfRO8b+U~A4)GJue#1+e3 zH__`^BYKN!AEEGXk-;&h8?^@Bmgoq_**YS21m+Ya&wAa3iieKC#K#2+cWV$%iW59& z`H&2}s)HkxXzP^PF(18;QA#jtcbwMLY7dDsv%S9!jRK*73OK@iUaKsF*{+-F7qhJe z-8S5EAUtqPT}+ngLPVcUv`Nh=*X2$_FSz{{pN;Etv9u6}X}UMJ6a8F;2%&k14y4M# zY-O;D42hbr3~Q#$MY4UGO9tCLs+U?YBYQj-`%SJm3DeUaXD8-_WfD`XS*l50wA#@M ziZ+^ui}6W!T3#g0h^1uP#ThqQW|d-bG;6kvg4is{a%tHure_A-3(nJ_s@c!XJ6%S% z^K83PYfIJUs9+^+ijO8E$?hr|Od5JQX>$R)S4ou?wN#_;bR%MZAy-qg@~~p{8vU9u zszw{B2qVsHYnbR{dNG;fIX5#~HpE(_KAdPwy&%q&Kq5+s!%(;BjOQ{_&Nm{fNpRG} zSH}x3)5|XxFTAi0(|dUEf1Z6a%>T430DX(z{K0+U|8evY{{L7+&Qu(T{Km=vp5r%G z2Kc!L*A?6Po$kH=?TrldN5gi@o(+cfc?kXQ&wM_TB-j8v=;D`D5SWH3Y^No+pYV*0Od`v=hnH)MB9;JgZ^yY)*8%juwgfS|suhG(z74P{OC-9m#z_|slt zoYn6iPG{=C@`~q9s>x1DA)zf4z-b;6<2K7=}j_eO&x}nf|bd|mhqNC z+38ZSyDB$Vr4BdlwK8>G%(TX_1gS^pB;iI>gU(6ah8V6D8iXqkXRVr4ryG2kFEw!+~@};Nr|7Z5kjTdk}~Z~=lN;Ap=V?*SkTpBqdu4?G66A0*IN{oStjRm zZam0NQl{ogf)Jl{mThSm%B34-E}bium8xU3nnAWUXr)MK94*%!O^YwPty(g$C{Rha z-5yn%#m1zN&d;)XA<7j>t!RSjRjFEbG;!nYXtb`9UT`7La}mXstF@vY7)&LhHLYeQ zrHC1CWs0+@o3r9Qw^&QoyUkdr(wN!Z=491yCeiw;mM0aG2(>44W~EA^u7v1@%`#45 zW{rcamKmlaBG>Mvq5>)ABXd;7h34Z#vfdQtoz^g8H=EV2*>Z}mzDjZ8V%)B{Vb*1pA zj4!vv_@Zav4#pQpZ#TZce{HtDjIz=C`cWL7l|8L2T~|-u``=#cd*ti-(W0BRvRB0+ zp<+$+QC$4KD|3Mp$qfHtOLMh4g z+VP_3-z%yDI!KVQ>qk6CAzo3{V(=&bJo_*`UO(Tvu45mg$LojrZ`G|{=th=q16GR? z{X1XIUO!pPpS$V@3i@D+DGFX;kO7dU*9VzLcV^VTpi2NCp{NBwyINF0d5hTz{#xP@ zdc2|0tgj!H@sE#-Zc%JMw{+tzF0jY7jS-bhe$yG2C=;_NzY(T@ktM(_NQ5h-ArjXW@@UXD#21tO6yQC3e ztc6wy7(%6UavoDw;EGN8|zX)UUjub zuraBpmsFve9dSxJTb*{fVQ;A^<&K;%3T;Qr%o0{iDbspUo=5aDTBh{$qEP51+oGs7 zrU`w}6hb;jT68AdEOv{rU`=2<#ys8#a+AWUErw@WUaC>!bVYTdEEVSG{l=th6tq-F zv-2&UtlDYO825VhXqz_bL9wXMH2D!ls+coA@oA(A&0ltMmXAACY>ZT))L^r-%rYCD zWj{J+U6HSAHt+q%;4J%5z?&7iDBPyK4*1HQmoHyCwa!232YbJM+5WAwALbq_^tspW zf1$w-fCQST2`HlFQQ1sGx2^usN8bMr?``J%Hf!?pjq7*# zV>D5I*uLq`MoqbR+bgtXq&u%N?>^eLg5Z&++Fl$M^hnJ@2&KSK@=bxv=9h`q0y~P^xh*NB8 zQ|D({*YA~e;ql&o3?9B88Q*lzunHsGMy0nvvv*$k#Jf)2x=;J5ZeG7^{~o>{<{sZO zpZ?t~s_!n8#x|y7w6+8&WerY)+EdYs|6uMBx})$6gBCRmx}z8vFC-cadW&idSaC1l zi-(_igR<8(6FoYgzjb`UhJLihn{hYC;GEXozkL1(r86`ZEtT{M?kbS+5}mi{ySYve zdSl_XoX!x*noiAGzwh3@b?467Ew+8<;fI^v>Ar(9H-1EGyQpH&6ngRfbol0psQ&!I zPoeyy71hE2`NH3OaP@vyn%@Ep^C`)9fnjfl1Xud##Gp8@ zj*^Nts0PbHD%(_($xfZCC3CT6uN~D=H3Y*7Ww)m+6QNjziZ2M;h#5*&w_+i?NVjFR zrG*j_VtrJqABY;EOud@T@^gB^rOJGH+#9@0%y39HM=e$}e>~bIqC!f`meo*>riSHgVj3OP zz7%?Za%egy z$mlohg%khi=SIesJ@pBy_x@1ae!h2o_xTuuihh{C$z2e^z1tjz9-sW3S6c5oS7z(U6Jz zfoN}x>v_{pEP|RPlk}&?LzqUvaBmLhoyPfC`Ah)t{<2>3an@_IudDz+u*p}37q}?| z=w}huC3}6Tz4sqO2+)s2ZkBMKzm2ZVtc>pV;`f|L_^Ut4`Die?e%VJMKws+04|6?r z^?&@z{8!#i!WT!-tG?Akyn5&jI{@;E^iN6(1We;OKEgtErxB|3w|H2-y_=tbAGOIU4R~=YV@nz>?>!NmFI4<=4t+4e@sq^z1m_=RfBMV!7Musn_dj%1ct+vf)r8;X z{J$T@u1o9s@_Fw+26g90$~HaT0Fl;YbdNle5`IjH_(UHy1iLc)_k5S3{S-nlSi;T|gw-vNl zhyVNP%@3YP!6Ig}#4shII}m-Hqvxtj-n=thE-Hv7whc*c>wFMk~=BUiI?I5zD9<269y_A`d zXEiHrq)Mr(feLMllqbzqt~^YeQi=A8D|16C&C{inlohmDGwm9| zRfeKQlTJc3SCM>VX3U1+*id5@LyO5NPCBILB{v$LAQxX}hTTM}TZkO;^*@)N~ zYdjIhwTv82)s0mIMPi+1b7Cd(6~VMJR;XW&<{K%sXofIpCTkmVN;*A%jDa+g?nrdP zwcLs>*rjeG+88_4rKq`sW-6S^$JuUuB)AJRpoeqyK%}KgDYVe3uk2 zWP_c78JL;9P%)8>F|4%en$^HS(I;(^oYtg7VpJQq#Z`ryc%-wa4`lqiMDbhfUTF{Kt(hvn{AMcaX45=B&Xv(00KjoTo_(u8V8 z$8^8YNAv$-I74+}QK2*Lkpju(Lh)uRm92)mRXGq<2jf(>JO~^0X05%sQ0bxjX#5#h z>~trOs>nAxZ};V+;plt#Xu{_Fzd%dh1wPu_<^TN{v5i)d7nIu~gARZv{r{ej4xaj- zF8$#7e~lOW$DRXk&w-aB-*@6mz3iga=55EQo%N;sjDIsQXivaYEkmFLQAeRVCNNR5 zDR_Ax3?$P5a8+r7eej*aZ+-=B>F!ku=eI_{&o_Dw(kYnqafUN|uzl0rFDn@3>y5|4 zP_8?#e&Qpi@r+OV8CQ399UpFV>yMr3uRb`6^dx_~DdcgF!OuR518T-`*CyZ90qdc& zyD^cSC)ji7C%0*YJtyqc!VdLtKejQ@fdQi%jp+S`{MKd0yLTQ#pu>-7Z@gCoTJiEb zUZCieXFq%z=)m;px1YSYe%TK{5d6o}pF%r_jVkZnfesAR^tyjwR3YO5C3s9%R|qDz zp=O8{HCpEx^HJ0Wcng2Dt6VD#kWh7gCdwh9*!tm=1$+lesUNA@E~S`J%Sv~Cn)=X* zl>U6e&-~6uE2aPI!SBBQ;0pb&AipIJ;!}uE;~*#b?6nrLVB_83NHvkaoBp^frnjD# zyvnEt33ALECq)3WoHNM}oBd|XD9X!1D4dz6>J?TVMnv9WREHI2G}W71@?b@+>hoS8 z-E#x{*wuxcHWIn4oo&SQdZNTg<$T60&_ddcOu1)Oc z;uw9tGt3&Pz&xIF2YS!RB-w_$oDb+ma>Ay}YGE)h#;ByI&I7Hwt4R@ImgmEIbP{W$ z|16OG?3kUU>87@{l~JiXjD@6bB{*8N0)3Yn%;LegNVl6qjA#}LfpXm}Cc>R?s3Xkk zjM8F)YB5r@Ds*sSEh|aEN|~vJ$Ons+R>`WgCQOitq}XEJs*WVC)>&ldZg?`Z3zKDW z7GvC0!tG^3q8xAb<*HB+ow6`6-BFwBRq~ByYF6pn~ck#hm>M?gLpAMf(6QqmPE}nHK+Z|x0J1| z$KmgqtMwALlP0la)K$I_!GF-6=C*d)I(A%Jwo`enm|s@J83`MKN_QORdQ-l?8tPOg zNIU3n+N$s|F4@mcHLNNVT{h?W?x@wM z4IhvMWNfEpsDI$f`YsloB0D;uiz1D(ACBF_Fy# zr_-g9uB|$3Nr?72I?(JaD7TeN=IqR@BgaZJ$yS|2+6`OzT5VZR=BrLGPRzwLR7vNF zJ6_J!MY%JXcVZUey$iKEs|dY;+DkE#WH4=I6tpK{T`FpgYLx<{v72dfDqP0lI#4+VAZ|sY0BHi!iGxRlKNBfUl>xvtS(c;ub(VNr=ZCHI-}k94Tj#rty~zM5 zsJoab>MkO7I2)({N!>-i9JR|DdY!yK`Ub2TmaGQ5 zrT^dkX8zV!)YDVnUbpQ?ANdiy&4TP@02x_Ch||OWUpW6e2iL1te)zI;;XlR8{bSF8 zJqNyXIq*{D6DPLml@GECC#kfE@E3w$6t4n7p(+fjq&WfZ2FlhnMOxQno>?|zU8QLh zP>UMDF|x^1swSerJj>7;Ybdg+$Oa{AM~&wd%H+Hd7fTfUm1OwZcXwM0a%Ky)oZ4n%X)p{lR z@w+~UK@>&z0*o?dY*O9?07{VznKBtu6;)*J398|b<{q|4+ZdvGo8NV1RbG>Hl>^8I zo+&RAZzv>O8p0r?smP|N3WB$;?z5d6d!J)BpTiHY*9CZeMZNbQgU{hd@HPuFI+F?l zhg}f%KtFII$S*I5Y4RA;r=tQ614(!TGb|Jh2yu-6)K!@^2xiZ%U-lyqbGjZ-8YLu4s~;b1dF!%KZ|0-}11iBB{@ zO&@S8>nG6V>7#D?1M{8r(I)KJ8*x1!P_dKwe!h`nkE)L>d!}9N>Ancdv_q5!M{n6x z@$BX%5`I?i9j4*@i1OxJgOP4C7+i{e8n1l(qo=0&1w&#b(LiZ0F&GiyO3ze_qJ$|u z=-(!q7)&9HXEofz4S07`KlRVHXt=u&n~g;Iov9bEapp8DjIU&f35W6yza3kP1l@!W}yeD$iy8iL58*AxB&uOZUd*hqv>oS<^5 zAOYyFDra@1zileRnT&#sDMe(oQ{d718vf-m5<@~6vx>1$M=71p#~Addf4gn!^l)u3 z4sv*ycHTZ3(R-BGJL;jMxmq8Sdyh)Wkpb)w{S9P>?se4h@_qjHw|>rj-FQAmf6~ta zZq!ALS4ocU>lj$@#Si_^i9r0-kD*jkS5y>X>lg(=q4yUA!XOf*3#=jXu%;r)wI67I z3qe9M5BO)57}(#k*mbBfuTiE>SXLmK3E$T9CqMW4v#;#zPoBpGSy3SjdVY4br;mUJ{a&(F~G4* zsUZ5fPU)5*1R5e!;!=q|< zkc`Q#9@kA37p)n=e7*T-76>lfVYf1{Qk8PB9i6#+NoV`^vPE&tObZO^a`RGvjd#Sf z9k<8TNHG+aD|AOOlXjp;7rQB@;*3o#9&xM!6EdfrY`K$48iPcpUs)!Gi$T|AtG1ro zQdPNgdiG2$$Eo9v0MZVQJC5Ftp_De)P;$~{m)_3}w62xbZ&UC6#}KseqfItyCmPc{ zX+XQczL#P@c%q$Nz5~z66f^(>bhPJzQQ?6&G!cDM5PMTZNri7EuV1$RpoJgi9%-kq z{n_Z==IRKd;?K_b`O@_jeT;U-59jZ(lE9KMcfNGxlP4?rGv_&u=opZSu|x1M5Jx3Q z!JBXtRE857mSqWs#XZvW> z&;47Z+4RIB&4-O-Z=q|eRcfS$gM4_L=$giuZ>7SGvYIF^+o7=A9V^{HnW_oKaID8H zH_=N6oQWK4FlpPBf&w*5JGtDlUToB=fq*$%Mi+<;3Qaa96*9%3RgTroz``}#qpF6< zu1+=>ECw^ov@s}%6354*Tnxr69ch$Dn%=C;RE_EDrm8c&ft;L4d>1tYWlbAfm1HM7 z=mZwha2ODbYQe1vfGx6-z_=HRcgWnGOd5u$G5o4bmrEEuQf>&aB>hRV(Wl0Sa zb7V~_p_V>K1==v^FYLx#c3GB^O0`(dZT0qBJW}y)YeK@Z;oiRi2gPj%g&sl^vM( zoLodJ7JE`jD3CH$yvj<;2xN!L7 z*DieX%Ga)Z_QK~ce)a0-FMj6gHxIsg<>2b!xv!l&IQQ9`pLyc&+E;IW_55d^JUIW= zCqMhd=b!lI<{c@A`@mX6tJT_tI~K<0J>2LjYb)iE_;DDMinVlHL!<( zMWH)VxI!xM&L3~#66 zHMb^ERM@i!L^Uv^MOFF04)kH{I2!$2F&0YqkhrYiWfQ?46|7Q;ATa{-nJx4M6S-b* z!!CO%Wp6w#rO+Htaj3Vz2y$8EkZ)EQLKp)6c#XJuRWK-(lQ~Mz857xO&d@dE(_85K z34r-|!N(JJ2U8Euc3);MIhMe!~@0 zst8TNLh)&Wj8G3W`VRC32}lT?(==3reLr0Z{d7v-P#lyLMl`6buIb?|^nK|!P8VeZ4iyb72=tGLC<_O7 zMbZUD=57W#hpLqy#$X_coK zl+pp(A^?Ran^p7;+!Cg#5$!MSKwp4}6B##@%E5Kuv0K3e zZg?9EWD`3Mn!{9tA3iuZcldz(F?mTcRhV8~0ap+yMWG9d`zRWY1yU|5AqMyS4(AV; zsU$Mk5a}GYA6Q<{S5SAM@w&)jh&au{h1!9>iXpX%fz~)g_Vf>eIF!l)4k=-GhY~*l zmnX|x=<^KVY&5H20e$c5pFp;aeA8yztDEuRlqiJa_)Jn_s>8nH%4{@%bCiTz~yK zxqj~2Yu5r-zjgJ+tIu2_S6;tDE}gsl%$0MOUcdO-Q?ET0xcsflFFvFV|DMdA1A7j< z!GSFbz>k4dIrL8S_K%#5C=9P7NJdOrrZ^Rqt(dO`P(OWT2l{>{uD^R1`e;*P0y9j( z5Qf+M{NKfwS(|ZHn4usKad?mL%U0ghR=o# ziWdN2FiTEWRTyuU;o;_gYK#2%p|3JTc-QIz| zs7th{^E?HI8%`*vpaw@$-~~z=r!y*zbL6NU==%ZfJhuaVKWX5?%JJ9z7CiuY}==;gfeR2o- zeu~OIv4uXyp<*tEge|>J;ta;}u4dQAToM2l@ngC_LagXkLI^G_k=l zy&yUx@Mtw7u{tX~wF7+_Sw`d;;aYKC5)A1qY@nf}MO3{IU8D&j0Z;BgU&KT()DvLG z&%M0%5hO%mcow-oBra%l{(<003XM|IZTSN~?k%7>2K++>`)304 zC?Nht;L}njEh5MSnF4s{+BWw1h)?MR!5zcfH50GG)kGvfW)c6=;n#`CHuIt&ylWTw z045_vBVx!KEqNo01t3uoBt(8%@G?BOC+X`u&<78ZxIz35cHWCYAP|8>k?EzAIf4pN zz^av-NAv%lc=h1QwM*ucS-jXk_8j=ObKs>2eG0C0{iN;AY9l}4a*Wl}Rnq`&6A?(= z3ra&Dp{0}V?S~beHGyA{O%IaymB0VDA5vEwKi~KcL00-<`$jMkHbJ*xieL;ePT+l? zI$6grebCSD>+CxE@;c#;_^au7<>^0serIUqx4k6v(E#wa6@>EiE^WVX^tOUf&TF4- z4d)|`IM!%*+`3gxF12@d}pxyZJ_8y3`u~WY&?fZFtvw&OFZ1-Y zFSFgJ_=*!YlyFfhPV(hSq_)(CbFnut9EX~kL3i5cY&~f#(xu>}6q+^4+Nj@Oq@wdg ztW(HZTDcsTL-lDb9TwX0P&+2I!$Ea`VR3fAa#D>_wwyAeQ=?}D>lJ%EjIB}&j!V+* z@??~&BzQAn=g`@K9R((NfiJPaoIJ8f)^P&q_Sl?G(pJLmv{|EAPifprU3IH^x>zsv zvq~<5E_qCw62o=86{&YgxDg5`_?j%nvXh|Q39NEjq0AVOUfmuCJK1_{F3%IorM9qE z0V$RiQ>j^(Em0+=tw*wHyVZ*flPimInZ>wS=mpdvvrIJ`jd_JmjN2h@6r9aMB&_v= z%TaGtW@;J+?kz)lIZgKp!D^+!5?PH&*<52#BJoUnz%j{iu5ad}PIsbEkq9$o<$1Q4 zY{><)G|~!_N!ncqxsfr>r?QF0qBARGRlQ7&n)*SkcB zC1Rvn>jVasZZ|w07fGbD?35xd+iWt?UcT(ELfx{6@xp*J=%f z#Aw9yhmDXzD^A1cB^LF0dRa76^Ij^niX~f0Pzginm$;N2J zHKSdrThzOy;Hp_08WBmYLbGL-{N{mxJD09;XR!RIwQJm2^#6T<{BiX5+BIc!?Ha%y z-v!>s+x2GlqoX(Le6Q#h$R0FpJ=*_2d3bPLy!t~I{unRzk39$W9N2T)dRe?JD#&`nh<@n>?qn%{#`04;@L)?RX}W~W zIA(%)(l5!8AHK$CBgKoMUr?!gLsE zv}!4#l;F#hmRkgLs+sF8b+Kzu1DSFAop?pURHq=}%O$Iv?hb;Jcq`n^grL?}Rx5DHOg==k8yGCLQUCnB_W^_3ivkGsDOD>kn zkH5XDj z%)3I3DGMd8M<+&uX(SiTr-q?$M`w}_hPx-&Wr~?QQ#CgT*HTKXQO;ECiOvhFf@F2- zv$(pNiRlD4>cnS4ChlM}6Us!1M6cp%Kdnu8vl!3lR;#WZvF-Nx7gy>`<;_VGH zO01#x2X`A9txNfl1$-zX@fdBXpQYO_3IJ#;XvG_2PDJ4gcl?yGJ(wu?$sQf&tG@Xs zJDbvf!^e^M;q^OAr}+`Q&4PTCu@hy1V~+O!Ph9%NgDW9C?H_v%>^ZRKz@7tp4(vJb z*g5dZN5xYwQ}R=c`(R$CpD>u`W&XlXXuq*#!*Ms+y0MG24p@~e0D_1J5b8`d+Jk|j zPTAlDOzFj7J%*Lxr+mkw2K+Gc9p*y%k*duuq$1r`ypn&6wEOb&!ikvvtj7DO*?+WR z`m?|EPyfM#Z-(wl@>^g7KBf2+Hn0&DfUWf#`)Wjz?+q=$M*Cj(1U}x_Id)2b5#pqgk`_mNwsZ~7!S2VrKGW`T0d3i#AvfR&1Upb zJ=a#U6EV)llVXmYr2`4d>CUQp$y1o*?7Y&Fs20Kh19DR?-<+k)c zM{kT$eP?Otw@d%~(Y2eEHUPz0TJbOre|NfSK}?RWm|pSkwnrf)x% z`1xD@c>NA@;`|8SMm@lD@@>?>)0}eWrI36g$S=?Q^gVghg512kWvg*2rPjI)+n$&3 zbL*@x=VQ!E_+k6@Wu#@1z4NoQbh3*7an4V7sI#l+FGJ_4AMyg^uSCp%6WMNTstx@j>vXR=t21n5! z?gwLfXx8CtBFTo>_Fx(!T1aS$>2__H%w}TKK&aYIk5{bCwy3JRaGf{?ENGcgWhKv7 z89u{HNm5x*DPbg=!#W-6Q`MX(q$5lunoh(!RbHNz-6mVhF7muE6)Kv<6Rt}c^(LR_ zF6jUhs%OT5Sb&aH7Adt=DOOWVo@U~mc)Jo=w8Tn<6DDptRxa7q+Ehj>?By&l47shK zMT)h_s)1tB>5SwtIDxOqsam7o3FP`oP0NOAy47eqJyLZ833?uxww8KdX!aZOcm~J0 z+7d<9UR7zcztp4iRA~~64u$D7yc&w6olHADaZ#TbcHFQ^**TR=@c}E`se*A%vYSjc znS80$X3~*-Ak&e%N=5BQJ4MS*sL6CbkuQe?H(nd+MP)cMBTf`;mE2`bi?qv5wiB2v z?V6rUbyYnPE0yd?t1U!<;SiHUE>>QVKz!tMhP@%LM$1fm+HC0PI=-}u3o9L>oHE^v zj}tDMB~_B9I>bTMG0Nb=J&}mFi+TwOu(uh`kW&lPnp;3*w^6Eu>&0m?9$&MpeiRhr?D zQI+fA2D;ZI-9|3x6w?!y^u>HKz39i{WhCq7Ip9cG+FVhe=22IP|d1^;`2`f-9%; zgpV&*J-Od&t`xzl(Q3I~=T$f8vQ%%Z$k~jvP#j$>4MHiSRv(L|(;GOoWiCH+gKT41 zYR8mmx#9F9^1R(y7TH!L+)0On(sW)G=B?Hwiv4dO-K|Bl)S#YM>_kWwJCN@xZSWt+ORY(QlVm~gK)3ftZMsNkrzm~C~#g-l2*qp{jD z$d^|wK?sK`fo!xp$=5TvO21x>D%wn_)!4$kZ{(VEI?MOOVvty?QKFJ}+!EET*g;bp z1XhJsIv8+`D67}9(Q>HLNtl-Goheh*|Ubjh#h0P zwWhY@b2h56XG+tI5HlzW+PtE-AvP8rces_J>cx0HT~3D^hHlvT8oOwR;_-aF!N+Em z-lX24Y&}cQT5>~IjCd&$whKnRqPq)TD7LXPcg>O;osf`dhcoEjIB1V^wL+`R1%~O_ zJgP)X6FC={(6MF4QFSpN?3!sQF$tDR=o@M2W7ka0`j`S_)tG^?tn|~f)^x!&wAre& zv|=+`NsZczdQ^y|7^AzGh@oSk+Xrt=+I>czHQ8Am9TjOlN_$9uf)%;N)E2A8Tu%n=*vrOBE$#N)`g({*R&6pC zZOUIHT}Ga_N2_HYHlmm!;%7@hFxHnie7aiev$U;F6FMkFf2ZbIu^6;`L2G)*Q?ii2 zRsu5M?ULIwn4hb7O5amF#j&FX5 zu5M@Fx-?85$yEUNZa#KdO?bkF>94yF@2HGix2}#hVQGNFRRMk%csPKu1Xxn2l1v1v znI-@|0gVZ25I7vgeGR8uRKC|nO4bd{j$y|iTvC(c=2NlTfsgvkiphSE7%{v%-CkR| z(t2HpgbaUg>LzahR0C`#Cu#YO7l&w+%jB09^8!EMV!Pw2b0%MZ+nbNQ4qgTjQQ(%G zi+5L%ox1C7L6A$;>AkheGDvVn_(ah#AboeU|p(5OwvWX=?ES9%t7} zTD=7x|8bj%7pS#ALiwnE@gOj3fv=s{2hXl`-87 zk!~Y<7i-zc;Jvuhgs=((!< zXY@UX6o`xtMp*6K3Kn!R1z1h9Ic zqhgeYCaZSc9BXkSNr|lCs`0Eo*XAv&u^C3~t-b6isvwY0iS-R7jt4ccPVt{_Jkmlq z{Pf*vA5gj+k7^L?e;}%PSWCEyl|F9uG3p!z1W@kJ-gDl(H2WX4t3Vh(ZL@Fu_>PF@ zvKMr<=W=w-B}o0`BHOwdw?8>o{|p;ORqp1V%fg(Whq*r-CqdD~4@p>Q&48j7hX@Ie6wfeEuW2#`>zfE-x^ zWA5}sTO=N3%l^;$Z1>y1YXA_xTLt?JCE9_dNpo2}PfM(0lvJAiZ0KANlq*R^h!XJ(NZh4M5T{aRh+x5x~5s z6u=k{5rHups~W*S~o(_UKWs^BLNsD#w1$H3H`cMP1DQf9chi zzWmcyK7oH;eEnaZzz<*PUTX8lfUfj#+S=RNTrIk&l6;i8)NA3-vCr+6?jIa}a2CTZ zM={F0Dl+!Aad>bR;~Xo;cHNV7rz?qZ9&!i1R_1b74bT(#5KqyK(|7!L{64GzCMUje zh+jWB|6?u=?ZKgs)AH?;7)CiDJB*E6$>E*TA2&LePcF*G@2J>g^;`1^j}+#^Pu~&d z11s#&vV0X$`GR@XDtdF=9FycJNdg}-e)i$3ol8CZ=-n#x+D};zcmK5S@T%AC)T6@& z2&DkCElSmJ3<2UFpymM%U7#>h>Nt%9TQ!OsBpk)l9ya*qiDntv7F79ijOu*G>hz=( zob#~jIT$^74i%w_;NGnW1P&7jXP=nuOGWtETfoPz)FIfZ0f0CR?KMg+an&G8o+Pra z8Wj@*J>xooJrwq;w_c2=akpptt%lQ4REdn1?m(6_cE09z+ju88r(EHo49tNS*tqD_ znz0jhh8E}T>z-*Wnu3M-L6FUBeaY@+;V`msNy}UDA|5isENOLmGEdL9+s2`ost0jI zldX*+PYjUkP88zRdKhaKk0X`S1r zyfPj{3NMe-G4(w) zacpI_Q>OrDX!$F-oEcD)bGYajYgvP0(iP#RC0H8eQstt>b_RdCF z$nA8(qL$fOO*b)4F`QVZaXpC_`++_SIXW$f1MKi9W>*G99=VbujxaNA>dCe*brT}j zB$G{YuRZNJ|vyZL8w!I$p4KlAq&w>H^6DnPNasKWbtFIYIOlj-iq)b`f#! zvY~tsMq{k89nSzQm><|!%Rz_3CBJKUt0}56<1K122EM6lld$GBINRE1nIQXCu-wF+ z*OZppIyAfHe9Cw&P7N2ZJkqd3tS<3nbIUfz6kF@DHEg=??`C9RO;;g}c1)aEW22~> z7VDP0W@|pjcO0eIP07x>k@rzivq(Dh37^>TSvK#syttFq@<}{pNTv|oni>!+J{%)1 zd$1WdNYoXtn**@5D0}W5)$eG$qI@JDznUQkGGd@AyQHK5z+*eL%SN!jDwY3JFLBF z5dzexLRJ5no_g!w&~wlH6}?pI^CnBYY|;d&N=0QWR^oYYY|rOCf8WgHs|Ru^twxngS!@4);rkRhmpVy z{MDteVR)4xZtADKOxtP0z~!4+n!9dp9=pCCIh5}|Nb-$t&uzKhz9YyBT@3SmO=jS9 z;cOSy$hROKtGDkiO}vVACb5(D@`2;#=;`>bra>8)Hyik zPX6wlfAPUzXJ5@A2ZOSvmsKB%qfLtLsK`U5={@agW3nMwcPyRVX^y`;&2hHeZ!TvuQ?Abz#$6M; z(DoMyWn3&=_ZM`(ydg9R3|CN%^DHeL?r*{KAM3$7+|&*SNxJTD_H^po?7MV*psibY z{_J@l-f!67DT2;YOeMk$QSM{f7Nm_yOG=`In6i+dg;gLGvpRnqWqJTJ0NYQ}`y1DN zm|B<(c&bj}9F5S~mcN~xszhLMCjyHe53#{87Ni|#h|^G`2s4FqL0GtgDHfq<&B5g&!ro-oJvH$~FyJD#XBf5;a(uY%!vU z45mC3 zi+NzNBF6!EQaAR?(gWp0#|5PRyQv8kZ7f?fPzt}5(_o>zMF|fmnFd|VRzQC`Rt;hz z8i#R!;)nxn8Cn!N1zGAqBB+zh#X%H11x^}4ZbLA5WFV)(n?)=lmKr4}I~LHpl3&qn z8K~*(-BXup#@nIuVb$}Fuvwdm7A9DR>aDD1WmX3P5BW6gih#VBsbD|5gFW%$fM6?K zh=dAhiJc-PDM1YMLq#E|vkjq2#xVr}2?+}TyC6toOA(QbfM|tS8z`;+S-IS!{P6a& zZ+3NjA{v1>M4t&SRhTOl0Ne?ECXnT-C1fZ-MJFrco074+n1=G4#7S8uVPSoHx#bg# z7k!gKJCw`qmyJpQ9*pxI^f#oTqe#nA!DAti2INR;Ary0#X{H?EFfUOUi8um*iBtf! z6}ngwTjmUfFyZ3}z#i%4f!j(l*gz;{+qbas0_%H!S8_%(o;Q<{)gVft2#?r-dU0`oAxojd>9Yrbmo<{!tqIfwY63NZnm|pUCQuWo3Dg8?0yTk}01&u3yVf|$KS1V6$D{1ipDG=XvdMh1 fbUeyt>50ZIr(OE%;d^ literal 0 HcmV?d00001 diff --git a/hitl.db b/hitl.db new file mode 100644 index 0000000000000000000000000000000000000000..64eb9a22ba1537f47ed314614ca09967faa91cde GIT binary patch literal 12288 zcmeI1-EPw`6vv$n0jpF7(xizCB&%p*6U&+*#ZJXE z?E=IF;thBZ-i1fu5+`d>t8N-sNT6dSO@02yKK9S)#W{WZrX2`|`$-xRferK!DGGXl zF+yl@e9K4C6#02){5^l<{oC3i+WNXuUjB&|$}uX(%inJs06Gu=0zd!=00AHX1b_e# z00KbZ{~+*rSGl)Zttwv{f_Nb#!(p0?Na&{QEMr{oIm5zEr{#28xa+)ZxA2^d*Go$Q z#qXTX&MT*b50AR|u-9&H;?fdloCitl-XI6@DDjDy^~lHdv)C`pJ_*CA(YYq$yD^E_ zwE;8E(kcI$#gG#gi<^gvBnfX;Be@ef0q(Zm%k52VaY)X?gwS~*Wqg>#oVnZ|u!s~6 z`TEmcC-;lhZ0RbQY1b_e#00KY&2mk>f00e*l5cvB9K2c?**F)&( z+M4BmtUh}D#BrWKLPp=$46|>ml$v$bw5g%mb*iZrF+8oQ+a9&OMx8Z1li4ckQ(cwL zM74EIb~R5UP1~|qT_g0=^#??FiSV;D&C3&c0Ym2bG!Sl4uaHS|cqTWUG@zAjT&d}XY1JFe$|kPJY64H<+5)X>#Am`x`{Py+pKNt%`LlW8 ApprovalRequestDB: def _to_pydantic(db_item: ApprovalRequestDB) -> ApprovalRequest: - return ApprovalRequest( - id=db_item.id, - session_id=db_item.session_id, - invocation_id=db_item.invocation_id, - function_call_id=db_item.function_call_id, - app_name=db_item.app_name, - user_id=db_item.user_id, - agent_name=db_item.agent_name, - tool_name=db_item.tool_name, - message=db_item.message, - payload=json.loads(db_item.payload) if db_item.payload else {}, - response_schema=json.loads(db_item.response_schema) - if db_item.response_schema - else {}, - risk_level=db_item.risk_level, - status=db_item.status, - created_at=db_item.created_at, - decided_at=db_item.decided_at, - decided_by=db_item.decided_by, - decision_notes=db_item.decision_notes, - escalated_to=db_item.escalated_to, - ) + return ApprovalRequest.model_validate(db_item) diff --git a/src/google/adk_community/tools/hitl/gateway.py b/src/google/adk_community/tools/hitl/gateway.py index e279e310..bf4e2517 100644 --- a/src/google/adk_community/tools/hitl/gateway.py +++ b/src/google/adk_community/tools/hitl/gateway.py @@ -37,10 +37,13 @@ async def apply_credit(account_id: str, amount: float) -> str: from typing import Any, Callable, Optional import httpx +import os +import random -API_BASE_URL = "http://localhost:8000" -POLL_INTERVAL_S = 2.0 -POLL_TIMEOUT_S = 300.0 # 5 minutes +API_BASE_URL = os.getenv("ADK_HITL_API_URL", "http://localhost:8000") +POLL_INTERVAL_S = float(os.getenv("ADK_HITL_POLL_INTERVAL_S", "2.0")) +POLL_JITTER_S = 1.0 +POLL_TIMEOUT_S = 300.0 # ← this one was likely removed accidentally def hitl_tool( @@ -138,7 +141,7 @@ async def _poll_for_decision( data = resp.json() if data["status"] != "pending": return data - await asyncio.sleep(interval) + await asyncio.sleep(POLL_INTERVAL_S + random.uniform(0, POLL_JITTER_S)) return None diff --git a/src/google/adk_community/tools/hitl/models.py b/src/google/adk_community/tools/hitl/models.py index 0d5ecf9e..64d6aaca 100644 --- a/src/google/adk_community/tools/hitl/models.py +++ b/src/google/adk_community/tools/hitl/models.py @@ -14,11 +14,12 @@ from __future__ import annotations -import uuid -from datetime import datetime +import uuid, json +from datetime import datetime, timezone from typing import Any, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator +import json class ApprovalStatus: @@ -36,6 +37,7 @@ class RiskLevel: class ApprovalRequest(BaseModel): + model_config = ConfigDict(from_attributes=True) # Identity id: str = Field(default_factory=lambda: str(uuid.uuid4())) @@ -58,14 +60,48 @@ class ApprovalRequest(BaseModel): # Status tracking status: str = ApprovalStatus.PENDING - created_at: datetime = Field(default_factory=datetime.utcnow) + created_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) decided_at: Optional[datetime] = None decided_by: Optional[str] = None decision_notes: Optional[str] = None - # Escalation escalated_to: Optional[str] = None + + @model_validator(mode="before") + @classmethod + def _parse_json_strings(cls, values): + """ + When constructing from an ORM object, SQLite stores payload + and response_schema as JSON strings. Parse them to dicts + for Pydantic without mutating the original ORM object. + """ + # Handle dict input (normal Pydantic construction) + if isinstance(values, dict): + for field in ("payload", "response_schema"): + val = values.get(field) + if isinstance(val, str): + try: + values[field] = json.loads(val) + except (ValueError, TypeError): + values[field] = {} + return values + # Handle ORM object input (from_attributes path) + # Build a plain dict from the ORM object attributes + # so we never mutate the SQLAlchemy-tracked object + data = {} + for column in values.__table__.columns: + val = getattr(values, column.name, None) + if column.name in ("payload", "response_schema") and isinstance(val, str): + try: + data[column.name] = json.loads(val) + except (ValueError, TypeError): + data[column.name] = {} + else: + data[column.name] = val + return data class ApprovalDecision(BaseModel): decision: str # approved / rejected / escalated diff --git a/tests/unittests/tools/test_hitl_gateway.py b/tests/unittests/tools/test_hitl_gateway.py index 69ea7f04..beae907c 100644 --- a/tests/unittests/tools/test_hitl_gateway.py +++ b/tests/unittests/tools/test_hitl_gateway.py @@ -84,7 +84,9 @@ def add(a: int, b: int) -> int: return a + b result = await add(2, 3) - assert result == 5 + assert result["action_result"] == 5 + assert result["supervisor_decision"] == "APPROVED" + assert result["supervisor_notes"] == "No notes provided." @pytest.mark.asyncio