Skip to content

Commit b0acff0

Browse files
committed
feat: port hitl-in-chat (book_call) to google-adk
Frontend-tool HITL via useHumanInTheLoop with an in-chat time-picker. Adds a dedicated ADK agent that prompts the model to call the frontend-defined book_call tool; the picker card is rendered inline by useHumanInTheLoop and resolves with the chosen slot or a cancellation.
1 parent 26039c5 commit b0acff0

5 files changed

Lines changed: 336 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""ADK agent backing the In-Chat HITL (book_call) demo.
2+
3+
The `book_call` tool is defined on the frontend via `useHumanInTheLoop`, so
4+
there is no backend tool implementation here — the frontend renders a time
5+
picker, the user's choice is forwarded back to the agent as the tool result.
6+
7+
This mirrors the langgraph-python reference (hitl_in_chat_agent.py) but
8+
adapted to ADK: ADK doesn't need an explicit tool stub on the backend for
9+
frontend-defined tools — the ag-ui-adk middleware injects the frontend's
10+
tool list at request time.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from google.adk.agents import LlmAgent
16+
17+
from agents.shared_chat import get_model
18+
19+
_INSTRUCTION = (
20+
"You help users book an onboarding call with the sales team. "
21+
"When they ask to book a call, call the frontend-provided "
22+
"`book_call` tool with a short topic and the user's name (or 'the team' "
23+
"if they don't specify). Keep any chat reply to one short sentence."
24+
)
25+
26+
hitl_in_chat_book_call_agent = LlmAgent(
27+
name="HitlInChatBookCallAgent",
28+
model=get_model(),
29+
instruction=_INSTRUCTION,
30+
tools=[],
31+
)

showcase/integrations/google-adk/src/agents/registry.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@
4343
)
4444
from agents.subagents_agent import subagents_root_agent
4545
from agents.hitl_in_chat_agent import hitl_in_chat_agent
46+
from agents.hitl_in_chat_book_call_agent import hitl_in_chat_book_call_agent
4647
from agents.hitl_in_app_agent import hitl_in_app_agent
48+
from agents.mcp_apps_agent import mcp_apps_agent
4749
from agents.multimodal_agent import multimodal_agent
4850
from agents.declarative_gen_ui_agent import declarative_gen_ui_agent
4951
from agents.a2ui_fixed_agent import a2ui_fixed_agent
@@ -146,8 +148,10 @@ class AgentSpec:
146148
tool_rendering_reasoning_chain_agent
147149
),
148150
# ----- HITL variants -----
149-
"hitl_in_chat": AgentSpec(hitl_in_chat_agent),
151+
"hitl_in_chat": AgentSpec(hitl_in_chat_book_call_agent),
150152
"hitl_in_app": AgentSpec(hitl_in_app_agent),
153+
# ----- MCP Apps -----
154+
"mcp_apps": AgentSpec(mcp_apps_agent),
151155
# ----- Multimodal & state-context -----
152156
"multimodal": AgentSpec(multimodal_agent),
153157
"readonly_state_agent_context": AgentSpec(readonly_state_agent_context_agent),
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# In-Chat Human in the Loop (book_call)
2+
3+
## What This Demo Shows
4+
5+
Frontend-tool HITL via `useHumanInTheLoop` — the agent calls a `book_call`
6+
tool, the frontend renders a time-picker card inside the chat, and the
7+
user's chosen slot is forwarded back to the agent as the tool result.
8+
9+
## How to Interact
10+
11+
Try asking your Copilot to:
12+
13+
- "Book an intro call with sales"
14+
- "Schedule a 1:1 with Alice next week"
15+
- "Set up a 30-minute call with the customer success team"
16+
17+
The agent calls `book_call` with a short topic and attendee. The frontend
18+
renders a card listing four candidate time slots; pick one to book, or
19+
"None of these work" to cancel. The picked slot (or cancellation) is
20+
returned to the agent, which confirms the outcome in chat.
21+
22+
## Technical Details
23+
24+
- The `book_call` tool is defined entirely on the frontend via
25+
`useHumanInTheLoop({ name, parameters, render })`. The ADK backend has
26+
NO matching tool — the ag-ui-adk middleware injects the frontend tool
27+
list at request time, the agent picks `book_call`, and the rendered
28+
card calls `respond({...})` with the user's selection.
29+
- `respond({ chosen_time, chosen_label })` returns a structured result;
30+
`respond({ cancelled: true })` signals user cancellation. The agent
31+
reads the structured response and replies in plain text.
32+
- This is the canonical "agent asks user a structured question" pattern
33+
for ADK — no `interrupt()` primitive needed because the chat-side tool
34+
lifecycle (`inProgress``executing``complete`) already encodes
35+
the pause-and-resume semantics.
36+
37+
## Building With This
38+
39+
Use inline styles inside the chat-rendered card (Tailwind v4 may purge
40+
classes used only inside CopilotKit's chat tree); see the
41+
[Styling Guide](https://github.com/CopilotKit/CopilotKit/blob/main/showcase/STYLING-GUIDE.md)
42+
for details.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use client";
2+
3+
import React from "react";
4+
import {
5+
CopilotKit,
6+
CopilotChat,
7+
useHumanInTheLoop,
8+
useConfigureSuggestions,
9+
} from "@copilotkit/react-core/v2";
10+
import { z } from "zod";
11+
import { TimePickerCard, TimeSlot } from "./time-picker-card";
12+
13+
// @region[time-slots]
14+
const DEFAULT_SLOTS: TimeSlot[] = [
15+
{ label: "Tomorrow 10:00 AM", iso: "2026-04-30T10:00:00-07:00" },
16+
{ label: "Tomorrow 2:00 PM", iso: "2026-04-30T14:00:00-07:00" },
17+
{ label: "Monday 9:00 AM", iso: "2026-05-04T09:00:00-07:00" },
18+
{ label: "Monday 3:30 PM", iso: "2026-05-04T15:30:00-07:00" },
19+
];
20+
// @endregion[time-slots]
21+
22+
export default function HitlInChatDemo() {
23+
return (
24+
<CopilotKit runtimeUrl="/api/copilotkit" agent="hitl_in_chat">
25+
<div className="flex justify-center items-center h-screen w-full">
26+
<div className="h-full w-full max-w-4xl">
27+
<Chat />
28+
</div>
29+
</div>
30+
</CopilotKit>
31+
);
32+
}
33+
34+
function Chat() {
35+
useConfigureSuggestions({
36+
suggestions: [
37+
{
38+
title: "Book a call with sales",
39+
message:
40+
"Please book an intro call with the sales team to discuss pricing.",
41+
},
42+
{
43+
title: "Schedule a 1:1 with Alice",
44+
message: "Schedule a 1:1 with Alice next week to review Q2 goals.",
45+
},
46+
],
47+
available: "always",
48+
});
49+
50+
// @region[hitl-hook]
51+
useHumanInTheLoop({
52+
agentId: "hitl_in_chat",
53+
name: "book_call",
54+
description:
55+
"Ask the user to pick a time slot for a call. The picker UI presents fixed candidate slots; the user's choice is returned to the agent.",
56+
parameters: z.object({
57+
topic: z
58+
.string()
59+
.describe("What the call is about (e.g. 'Intro with sales')"),
60+
attendee: z
61+
.string()
62+
.describe("Who the call is with (e.g. 'Alice from Sales')"),
63+
}),
64+
render: ({ args, status, respond }: any) => (
65+
<TimePickerCard
66+
topic={args?.topic ?? "a call"}
67+
attendee={args?.attendee}
68+
slots={DEFAULT_SLOTS}
69+
status={status}
70+
onSubmit={(result) => respond?.(result)}
71+
/>
72+
),
73+
});
74+
// @endregion[hitl-hook]
75+
76+
return <CopilotChat agentId="hitl_in_chat" className="h-full rounded-2xl" />;
77+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client";
2+
3+
import React, { useState } from "react";
4+
5+
export interface TimeSlot {
6+
label: string;
7+
iso: string;
8+
}
9+
10+
export type TimePickerStatus = "inProgress" | "executing" | "complete";
11+
12+
export interface TimePickerCardProps {
13+
topic: string;
14+
attendee?: string;
15+
slots: TimeSlot[];
16+
status: TimePickerStatus;
17+
onSubmit: (
18+
result: { chosen_time: string; chosen_label: string } | { cancelled: true },
19+
) => void;
20+
}
21+
22+
/**
23+
* Renders a "Book a call" card with a small grid of time slots.
24+
* The user picks one slot (or cancels); that resolution is forwarded back
25+
* to the agent via the onSubmit callback wired up by `useHumanInTheLoop`.
26+
*/
27+
export function TimePickerCard({
28+
topic,
29+
attendee,
30+
slots,
31+
status,
32+
onSubmit,
33+
}: TimePickerCardProps) {
34+
const [picked, setPicked] = useState<TimeSlot | null>(null);
35+
const [cancelled, setCancelled] = useState(false);
36+
const disabled = status !== "executing" || picked !== null || cancelled;
37+
38+
if (cancelled) {
39+
return (
40+
<div
41+
style={{
42+
borderRadius: "16px",
43+
border: "1px solid #DBDBE5",
44+
background: "#F7F7F9",
45+
padding: "16px",
46+
fontSize: "14px",
47+
color: "#57575B",
48+
maxWidth: "28rem",
49+
}}
50+
data-testid="time-picker-cancelled"
51+
>
52+
Cancelled — no time picked.
53+
</div>
54+
);
55+
}
56+
57+
if (picked) {
58+
return (
59+
<div
60+
style={{
61+
borderRadius: "16px",
62+
border: "1px solid #85ECCE4D",
63+
background: "rgba(133, 236, 206, 0.1)",
64+
padding: "16px",
65+
maxWidth: "28rem",
66+
}}
67+
data-testid="time-picker-picked"
68+
>
69+
<p style={{ fontSize: "14px", color: "#010507" }}>
70+
Booked for <span style={{ fontWeight: 600 }}>{picked.label}</span>
71+
</p>
72+
</div>
73+
);
74+
}
75+
76+
return (
77+
<div
78+
style={{
79+
borderRadius: "16px",
80+
border: "1px solid #DBDBE5",
81+
background: "#fff",
82+
padding: "20px",
83+
boxShadow: "0 1px 2px rgba(0,0,0,0.05)",
84+
maxWidth: "28rem",
85+
}}
86+
data-testid="time-picker-card"
87+
>
88+
<p
89+
style={{
90+
fontSize: "10px",
91+
textTransform: "uppercase",
92+
letterSpacing: "0.14em",
93+
color: "#57575B",
94+
fontWeight: 500,
95+
}}
96+
>
97+
Book a call
98+
</p>
99+
<h3
100+
style={{
101+
fontSize: "16px",
102+
fontWeight: 600,
103+
color: "#010507",
104+
marginTop: "6px",
105+
}}
106+
>
107+
{topic}
108+
</h3>
109+
{attendee && (
110+
<p style={{ fontSize: "14px", color: "#57575B", marginTop: "2px" }}>
111+
With {attendee}
112+
</p>
113+
)}
114+
115+
<p
116+
style={{
117+
fontSize: "14px",
118+
color: "#57575B",
119+
marginTop: "16px",
120+
marginBottom: "8px",
121+
}}
122+
>
123+
Pick a time:
124+
</p>
125+
<div
126+
style={{
127+
display: "grid",
128+
gridTemplateColumns: "1fr 1fr",
129+
gap: "8px",
130+
}}
131+
>
132+
{slots.map((s) => (
133+
<button
134+
key={s.iso}
135+
disabled={disabled}
136+
data-testid="time-picker-slot"
137+
onClick={() => {
138+
setPicked(s);
139+
onSubmit({ chosen_time: s.iso, chosen_label: s.label });
140+
}}
141+
style={{
142+
borderRadius: "12px",
143+
border: "1px solid #DBDBE5",
144+
background: "#fff",
145+
padding: "8px 12px",
146+
fontSize: "14px",
147+
fontWeight: 500,
148+
color: "#010507",
149+
opacity: disabled ? 0.5 : 1,
150+
cursor: disabled ? "not-allowed" : "pointer",
151+
transition: "border-color 0.15s, background 0.15s",
152+
}}
153+
>
154+
{s.label}
155+
</button>
156+
))}
157+
</div>
158+
<button
159+
disabled={disabled}
160+
onClick={() => {
161+
setCancelled(true);
162+
onSubmit({ cancelled: true });
163+
}}
164+
style={{
165+
marginTop: "12px",
166+
width: "100%",
167+
borderRadius: "12px",
168+
border: "1px solid #E9E9EF",
169+
padding: "6px 12px",
170+
fontSize: "12px",
171+
color: "#838389",
172+
background: "transparent",
173+
opacity: disabled ? 0.5 : 1,
174+
cursor: disabled ? "not-allowed" : "pointer",
175+
}}
176+
>
177+
None of these work
178+
</button>
179+
</div>
180+
);
181+
}

0 commit comments

Comments
 (0)