在聊天流内实现人工审批:CopilotKit 的 In-Chat HITL(Booking)前端定义工具实战
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
聊天式 AI 应用常常需要在对话中途插入"人"的决策点:确认预约时间、审批操作、选择分支等。CopilotKit 的 In-Chat HITL(Human-in-the-Loop)提供了一种极简做法——把决策 UI 直接渲染进聊天流,由前端用useHumanInTheLoop定义工具,后端 agent 完全不需要实现中断逻辑。本文以showcase/integrations/claude-sdk-python集成中的 Booking 示例(book_call预约工具)为主线,逐步拆解前端工具定义、交互卡片渲染、Claude Agent SDK 后端转发,以及底层useHumanInTheLoop的原理实现,帮助你在自己的 Agent 聊天应用中快速落地同类交互。
一、In-Chat HITL 的核心思路:前端定义工具,后端零中断
传统 HITL(如 LangGraph 的interrupt())依赖后端把 agent 执行挂起、由外部恢复。而 In-Chat HITL 把"等待人工决策"这件事完全放在前端:
book_call工具由前端通过useHumanInTheLoop定义,后端没有对应的工具实现;- 后端只是一个"基本 Claude Agent SDK 聊天循环",负责把 AG-UI(Agent UI 协议)携带的前端工具定义原样转发给 Claude,并让标准 tool-call 生命周期解析用户的最终选择;
- 没有任何后端中断:工具"执行中"时前端渲染
TimePickerCard(时间段选择卡片),用户点选后respond(...)把结果像普通工具返回值一样送回 agent。
这段描述直接来自示例文档(README.md),而示例的后端代码注释也印证了这一点:
"""The `book_call` tool is defined on the FRONTEND via `useHumanInTheLoop`, so there is no backend tool here. The agent simply responds in chat and relies on the standard frontend-tool / tool-call lifecycle to invoke `book_call` when the user asks to book."""引用位置:hitl_in_chat_agent.py
该 demo 同时提供了 LangGraph 参考实现(hitl_in_chat_agent.py),后端同样使用tools=[],仅挂载CopilotKitMiddleware即可接收前端建议与渲染 hook——两个后端框架殊途同归,都证明"前端工具"是跨框架的统一机制。
二、前端第一步:用useHumanInTheLoop声明一个聊天内工具
示例前端代码位于 page.tsx,核心注册代码如下:
useHumanInTheLoop({ agentId: "hitl-in-chat", name: "book_call", description: "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.", parameters: z.object({ topic: z .string() .describe("What the call is about (e.g. 'Intro with sales')"), attendee: z .string() .describe("Who the call is with (e.g. 'Alice from Sales')"), }), render: ({ args, status, respond }: any) => ( <TimePickerCard topic={args?.topic ?? "a call"} attendee={args?.attendee} slots={slots} status={status} onSubmit={(result) => respond?.(result)} /> ), });各字段的作用与约束如下:
| 字段 | 含义 | 示例值 / 说明 |
|---|---|---|
agentId | 注册到哪个 agent 名下 | 必须与<CopilotKit agent="hitl-in-chat">、后端路由 agent 名一致 |
name | 工具名,是 LLM 调用的标识 | book_call,前后端不实现同名工具 |
description | 发给 LLM 的工具说明,决定模型何时调用 | 要写明"UI 会展示候选时间段,用户选择会返回给 agent" |
parameters | zod schema,定义工具入参 | topic(通话主题)、attendee(通话对象) |
render | 渲染函数,接收args / status / respond | 渲染TimePickerCard并回传结果 |
2.1 组件挂载与 agent 绑定
整个 demo 由CopilotKitProvider 包住,runtimeUrl指向/api/copilotkit,agent指定后端路由名,聊天组件用CopilotChat:
<CopilotKit runtimeUrl="/api/copilotkit" agent="hitl-in-chat"> ... <CopilotChat agentId="hitl-in-chat" className="h-full rounded-2xl" /> </CopilotKit>从源码结构看,useHumanInTheLoop依赖useCopilotKit()拿到全局copilotkit实例,并通过useFrontendTool在布局阶段完成注册,因此它必须在 Provider 内部使用。
2.2 预置建议(suggestions),降低用户输入门槛
Chat组件内还调用useConfigureSuggestions提供两条可点击的示例提示,方便测试与演示:
useConfigureSuggestions({ suggestions: [ { title: "Book a call with sales", message: "Please book an intro call with the sales team to discuss pricing.", }, { title: "Schedule a 1:1 with Alice", message: "Schedule a 1:1 with Alice next week to review Q2 goals.", }, ], available: "always", });候选时间段由buildDefaultSlots()动态生成:默认给出"明天 10:00、明天 14:00、下周一 9:00、下周一 15:30"四个槽位,每个槽位包含label(人类可读文案)与iso(ISO 时间戳),后续作为工具返回值回传 agent。
三、交互卡片:TimePickerCard 的状态机
决策 UI 是 HITL 的"人机接口",实现在 time-picker-card.tsx。
3.1 状态定义
export type TimePickerStatus = "inProgress" | "executing" | "complete";inProgress:模型正在生成 tool call(卡片可先展示占位信息);executing:工具正在"执行",此时respond可用,用户可点选;complete:已提交结果,卡片只读。
这与 react-core 中ToolCallStatus.InProgress / Executing / Complete三种状态一一对应(见 use-human-in-the-loop.tsx),组件内部用disabled判断做防重复提交:
const disabled = status !== "executing" || picked !== null || cancelled;3.2 三种渲染分支
组件根据内部状态渲染三种形态,并带有data-testid供端到端测试断言:
- 已取消:显示 "Cancelled — no time picked."(
data-testid="time-picker-cancelled"); - 已选择:显示 "Booked for {label}" 绿色确认条(
data-testid="time-picker-picked"); - 待选择:展示主题、对象与 2×2 时间段按钮网格,以及"None of these work"取消按钮(
data-testid="time-picker-card")。
onClick={() => { setPicked(s); onSubmit({ chosen_time: s.iso, chosen_label: s.label }); }}onClick={() => { setCancelled(true); onSubmit({ cancelled: true }); }}可以看到:用户无论"选择"还是"取消",都通过同一个onSubmit回调把结果返回给 agent。返回值有两种合法形状——{ chosen_time, chosen_label }或{ cancelled: true }(由TimePickerCardProps.onSubmit类型定义约束),这为后端/LLM 判断后续流程提供了依据。
四、后端零中断:Claude Agent SDK 如何转发前端工具
后端实现位于 hitl_in_chat_agent.py,是典型的 AG-UI 服务端流式接口:接收RunAgentInput,产出编码后的 AG-UI 事件流。
4.1 系统提示与模型
SYSTEM_PROMPT = dedent(""" You help users book an onboarding call with the sales team. When they ask to book a call, call the frontend-provided `book_call` tool with a short topic and the user's name (use a sensible placeholder like 'Alice from Sales' if no attendee was specified). Keep any chat reply to one short sentence. """).strip()调用时模型从环境变量读取:normalize_claude_model(os.getenv("ANTHROPIC_MODEL", "claude-opus-4-8")),max_tokens=1024,且仅在tools非空时才把工具定义传给 Claude——正常运行时 AG-UI 会带来前端定义的book_call,因此实际都会带上。
4.2 关键点一:把 AG-UI 消息转换为 Anthropic 格式
AG-UI 提供三类消息角色,后端必须做映射:
user→ Anthropic 用户消息;assistant→ Anthropic assistant 消息,若带tool_calls(AG-UI 的AssistantMessage把工具调用存在tool_calls字段而非content),需转成content里的tool_use块;tool→ 已解析的前端工具结果,必须转成 Anthropic 的"role": "user"+content[].type == "tool_result"结构,并用tool_use_id与之前的tool_use配对。
这正是"用户点选时间段后,runtime 重新调用 agent,agent 必须看到完整 tool 调用历史"的底层机制。源码注释明确说明:CopilotKit runtime 在用户解析前端工具后(例如在book_callHITL UI 里选了一个时间段)会重新调用本 agent,此时消息数组包含 ①带tool_use的 assistant 消息 ②携带解析结果的 tool 消息。
4.3 关键点二:把 AG-UI 的前端工具定义转发给 Claude
tools: list[dict[str, Any]] = [] for t in input_data.tools or []: name = getattr(t, "name", None) or (t.get("name") if isinstance(t, dict) else None) description = getattr(t, "description", None) or ( t.get("description", "") if isinstance(t, dict) else "" ) parameters = getattr(t, "parameters", None) or ( t.get("parameters", {}) if isinstance(t, dict) else {} ) if not name: continue tools.append({ "name": name, "description": description or "", "input_schema": parameters or {"type": "object", "properties": {}}, })AG-UI 的 Tool schema 是{ name, description, parameters }(JSON-Schema),Claude API 期望input_schema,两者形状一致,直接映射即可。这个循环是"前端定义工具、后端自动感知"的关键——agent 代码里没有任何book_call的硬编码,全靠input_data.tools透传。
4.4 关键点三:流式事件转发
run_hitl_in_chat_agent是异步生成器,用EventEncoder编码 AG-UI 事件:
- 先发
RunStartedEvent,再发TextMessageStartEvent; - 用
client.messages.stream(...)消费 Anthropic 流式响应:- 文本增量 →
TextMessageContentEvent; - 工具块开始 →
ToolCallStartEvent; - 参数 JSON 增量 →
ToolCallArgsEvent; - 工具块结束 →
ToolCallEndEvent;
- 文本增量 →
- 收尾发
TextMessageEndEvent与RunFinishedEvent。
工具调用事件的父消息 ID 固定为msg-{run_id}-0,从而把工具调用挂到同一条 assistant 消息上,前端据此把卡片渲染在正确的气泡上下文里。
4.5 为什么不会死循环
文档注释说明了这里的"回合"语义:前端useHumanInTheLoop解析book_call后,runtime 会把解析结果注入下一轮对话,而每一轮对话都是独立的 POST 请求,因此本 agent 发完RunFinishedEvent即交还控制权,不会在单个请求内自循环。
五、底层原理:useHumanInTheLoop 在 react-core 中如何工作
要理解 In-Chat HITL,值得看一眼它的实现(use-human-in-the-loop.tsx)。
5.1 respond 本质是一个可被 resolve 的 Promise
useHumanInTheLoop内部把respond与一个 promise 的 resolve 函数绑定:
const respond = useCallback(async (result: unknown) => { if (resolvePromiseRef.current) { cleanupAbortRef.current?.(); cleanupAbortRef.current = null; resolvePromiseRef.current(result); resolvePromiseRef.current = null; } }, []);同时它构造了一个handler,返回一个在人工响应前一直挂起的 Promise。这个handler就是前端工具的"执行体"——工具被调用时挂起等待,respond(result)被调用时 Promise 被 resolve,结果继续走标准 tool-call 生命周期回到 agent。这就是"前端在工具执行时渲染卡片、点选后返回结果"的底层等价物。
5.2 abort 支持:取消/中断时不静默丢结果
handler接收context.signal(AbortSignal):若信号已中止则立即 reject;否则注册一次性abort监听,中止时清理引用并 reject("Human-in-the-loop interaction aborted")。cleanupAbortRef确保 promise 一旦 settle(respond 或 abort),监听器立刻移除、不会二次触发或泄漏。useLayoutEffect清理函数还会在卸载时调用copilotkit.removeHookRenderToolCall(tool.name, tool.agentId),避免组件卸载后仍残留渲染器。
5.3 渲染器按状态注入 respond
RenderComponent根据props.status分支:
InProgress/Complete:注入respond: undefined(此时不可交互);Executing:注入respond(此时可交互)。
也就是说respond只在工具"执行中"这一生命周期窗口内是活函数,与前端组件用status !== "executing"禁用按钮的逻辑完全一致。
5.4 注册机制:useFrontendTool
useHumanInTheLoop最终把{ ...tool, handler, render }交给useFrontendTool(use-frontend-tool.tsx),后者在useLayoutEffect中:
- 同名同 agent 已注册时先
removeTool再addTool(覆盖式注册,并给出 warning); - 通过
addHookRenderToolCall注册渲染器(注意注释:即使parameters未定义也要注册 render,HITL 确认对话框正是这种"无参数但有 UI"的工具); - 卸载时
removeTool但故意不移除 render,让历史消息中的卡片仍可渲染。
这解释了为什么聊天历史里已经完成的 HITL 卡片依然可见——它们是渲染 hook 而非活动工具。
六、运行方式与扩展建议
6.1 运行该 demo
示例属于 Claude SDK Python 集成示例应用(showcase/integrations/claude-sdk-python):
- 配置环境变量:
ANTHROPIC_API_KEY(必填)、可选ANTHROPIC_MODEL(默认claude-opus-4-8); - 启动集成示例的前端(
src/app,Next.js)与后端 agent 服务,将前端runtimeUrl指向/api/copilotkit路由; - 在聊天中输入"Book a call with sales"(或直接点击预置 suggestion),模型将调用
book_call,聊天流内出现时间选择卡片; - 点选一个时间段或取消,结果回传 agent,agent 输出简短确认语。
注意:agent 的系统提示要求"任何聊天回复保持一句话",目的是让演示聚焦工具交互本身。
6.2 扩展思路
- 更多决策形态:替换
render里的卡片组件即可实现确认弹窗、下拉选择、多选审批等;返回值形状由onSubmit类型决定,保持与 LLM 可读性即可。 - 多工具 HITL:每个决策点声明一个
useHumanInTheLoop工具即可;若希望一个渲染器覆盖多个工具,react-core 支持通配工具名"*"(WILDCARD_TOOL_NAME,见 use-human-in-the-loop.tsx),此时渲染器收到的props.name是真正被调用的工具名。 - 对比后端中断:本方案适合"决策点简单、UI 内嵌聊天"的场景;若需要服务端状态持久化、审批流恢复等能力,则考虑基于中断的 HITL 方案。
七、小结
In-Chat HITL 的精髓在于把"人工决策"抽象成前端定义的工具:前端负责注册工具、渲染交互卡片、回传结果;后端只需遵循 AG-UI 协议,把input_data.tools里的前端工具定义透传给 LLM,再按标准 tool-call 生命周期处理。CopilotKit 的useHumanInTheLoop用 Promise 挂起 +respondresolve + 渲染器状态注入这套机制,在 react-core 中完整实现了这一模式,且对 Claude Agent SDK 与 LangGraph 两类后端同样适用。掌握它,你就能在任何 CopilotKit 聊天应用中低成本加入"人机协作"环节。
【免费下载链接】CopilotKitThe Frontend Stack for Agents & Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考