CopilotKit In-Chat HITL 实战:用 useHumanInTheLoop 驱动零后端工具的 .NET ChatClientAgent(ms-agent-dotnet 集成演示)
【免费下载链接】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
本文基于 CopilotKit 仓库中 ms-agent-dotnet 集成的 In-Chat HITL 演示文档,讲解"聊天内人审(Human-in-the-Loop)"这一交互模式的完整落地方式:前端通过useHumanInTheLoopHook 注册一个纯前端的book_call工具并内联渲染时间选择卡片,后端 .NETHitlInChatAgent不持有任何工具,仅靠系统提示词引导模型调用前端工具。读完本文,你可以掌握该模式的前后端分工、render/respond回调契约、AG-UI 协议下的运行时代理链路,以及端到端测试如何验证整个暂停—选择—回填闭环。
演示目标:让 Agent 在聊天里"停下来等人选"
演示文档(README.md)描述的交互场景是:用户表达"帮我约一个电话",Agent 不会自说自话地编造时间,而是暂停执行,在聊天流内部联线渲染一个时间槽选择器(TimePickerCard);用户点选某个时间后,该选择作为工具结果(tool result)回填给 Agent,Agent 再继续生成确认语句。
文档给出的两条可直接尝试的提示语是:
- "Book an intro call with the sales team."(与销团队约一次介绍会)
- "Schedule a 1:1 with Alice next week."(下周约 Alice 做 1:1)
文档的 "Technical Details" 一节列出了三条核心技术事实,本文后续章节逐条展开并用源码印证:
book_call工具完全在前端通过useHumanInTheLoop注册——后端不存在任何同名工具;- .NET 侧
HitlInChatAgent是一个"裸"ChatClientAgent,只有一段引导模型调用book_call的短系统提示词; - Hook 的
render回调返回TimePickerCard;调用其中的respond即把挂起的工具调用以用户选择为结果解除。
前端:useHumanInTheLoop 注册 book_call 工具
演示页面位于 page.tsx。整体结构分两层:
export default function HitlInChatDemo() { return ( <CopilotKit runtimeUrl="/api/copilotkit" agent="hitl-in-chat"> <div className="flex justify-center items-center h-screen w-full"> <div className="h-full w-full max-w-4xl"> <Chat /> </div> </div> </CopilotKit> ); }CopilotKit组件通过runtimeUrl="/api/copilotkit"指向本应用内的运行时路由,并用agent="hitl-in-chat"选中运行时中注册的同名 Agent(注册逻辑见后文"运行时代理"一节)。
Chat组件内有两处关键 Hook:
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", }); 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={DEFAULT_SLOTS} status={status} onSubmit={(result) => respond?.(result)} /> ), });useHumanInTheLoop的注册项可以逐项拆解:
| 字段 | 取值 | 作用 |
|---|---|---|
agentId | "hitl-in-chat" | 限定该工具只对名为hitl-in-chat的 Agent 暴露,避免被其他演示 Agent 误调用 |
name | "book_call" | 工具名,即模型在 function calling 中看到并调用的名字 |
description | 描述文案 | 供模型判断"何时该调用此工具"的语义依据 |
parameters | Zod schema:topic: string、attendee: string | 模型调用时必传两个字符串参数;z.string().describe(...)的描述会进入工具 schema,帮助模型填对字段 |
render | 返回<TimePickerCard/>的渲染函数 | 工具调用事件出现在聊天流中时,由该回调把"工具调用气泡"替换为交互式卡片 |
render回调收到的 props 中,args是模型调用book_call时给出的参数(即topic/attendee),status是工具调用状态,respond是解除挂起的回调。注意onSubmit={(result) => respond?.(result)}这行:用户的选择就是通过它"变回"工具结果的。
时间槽数据是页面内写死的四个候选:
const DEFAULT_SLOTS: TimeSlot[] = [ { label: "Tomorrow 10:00 AM", iso: "2026-04-19T10:00:00-07:00" }, { label: "Tomorrow 2:00 PM", iso: "2026-04-19T14:00:00-07:00" }, { label: "Monday 9:00 AM", iso: "2026-04-21T09:00:00-07:00" }, { label: "Monday 3:30 PM", iso: "2026-04-21T15:30:00-07:00" }, ];TimePickerCard:三态卡片与两种结果形状
卡片的完整实现见 time-picker-card.tsx。它定义了一个三态状态机,与useHumanInTheLoop传入的status联动:
export interface TimeSlot { label: string; iso: string; } export type TimePickerStatus = "inProgress" | "executing" | "complete"; export interface TimePickerCardProps { topic: string; attendee?: string; slots: TimeSlot[]; status: TimePickerStatus; onSubmit: ( result: { chosen_time: string; chosen_label: string } | { cancelled: true }, ) => void; }三种呈现状态:
- 可选态(默认分支):渲染
topic标题、With {attendee}副标题、2 列时间槽按钮网格(data-testid="time-picker-slot")以及一个"None of these work"取消按钮(data-testid卡片级为time-picker-card); - 已选定态:用户点击某个时间槽后,本地
picked状态置位,卡片切换为绿底确认块Booked for {picked.label}(data-testid="time-picker-picked"); - 已取消态:点击"None of these work"后显示灰底
Cancelled — no time picked.(data-testid="time-picker-cancelled")。
按钮的启用条件由一行逻辑控制:
const disabled = status !== "executing" || picked !== null || cancelled;即只有当工具调用处于executing状态(意味着 Agent 侧的 run 正在等待这个工具结果)且尚未做出选择时,按钮才可点击。这与 Hook 侧的行为一致:respond只在executing窗口内是"活的"(见下文 Hook 原理)。
onSubmit接受两种互斥的结果形状,体现了 HITL 交互必须处理的两种用户意图:
- 选定:
{ chosen_time: s.iso, chosen_label: s.label }; - 拒绝:
{ cancelled: true }。
两种形状都会原样作为工具结果送回 Agent,模型据此分别生成"已预约"或"已取消"的后续回复——取消并不是静默失败,而是一次显式的工具结果。
.NET 侧:零工具 ChatClientAgent 与系统提示词引导
与 README 的"后端不存在同名工具"对应的,是 HitlInChatAgent.cs。该文件顶部注释与演示文档互相印证:book_call完全由前端useHumanInTheLoop定义,.NET Agent 不拥有任何工具,系统提示词只负责"推"模型去调用前端提供的工具:
private const string SystemPrompt = "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. " + "Keep any chat reply to one short sentence.";工厂类的核心方法是CreateHitlInChatAgent:
public AIAgent CreateHitlInChatAgent() { var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(); return new ChatClientAgent( chatClient, name: "HitlInChatAgent", instructions: SystemPrompt, tools: []); }要点:
- 模型为
gpt-4o-mini,通过OpenAIClient的AsIChatClient()适配到Microsoft.Extensions.AI抽象; tools: []显式传空——后端工具列表为空,book_call不可能由后端执行;- 构造函数中通过
ApiKeyResolver.ResolveApiKey/ResolveEndpoint解析凭据与端点(见同目录 ApiKeyResolver.cs),并用AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint)注入请求选项。
该 Agent 在 ASP.NET Core 宿主中挂载到 AG-UI 端点,见 Program.cs:
var hitlInChatFactory = new HitlInChatAgentFactory(builder.Configuration, loggerFactory); app.MapAGUI("/hitl-in-chat", hitlInChatFactory.CreateHitlInChatAgent());MapAGUI是 Microsoft.Agents.AI 的 AG-UI 宿主扩展:它让 .NET 后端以 AG-UI 事件流协议(RUN_STARTED、TOOL_CALL_START/ARGS/END、TOOL_CALL_RESULT、RUN_FINISHED等)对外提供/hitl-in-chat服务。文件头注释还标明此实现与 langgraph-python 集成中的hitl_in_chat_agent.py保持行为对齐(parity),即同一演示在不同 Agent 框架下的等价移植。
运行时代理:前端请求如何到达 .NET 后端
前端CopilotKit组件连接的是本 Next.js 应用内的/api/copilotkit,而不是直连 .NET 进程。该路由在 route.ts 中构建:
// The agent backend runs as a separate process on port 8000. // This runtime proxies CopilotKit requests to it via AG-UI protocol. const AGENT_URL = process.env.AGENT_URL || "http://localhost:8000";Agent 注册表中与本演示直接相关的两行:
// In-Chat HITL -- frontend-defined `book_call` tool rendered inline in the // chat via `useHumanInTheLoop`. Backend agent has tools=[] and routes to // /hitl-in-chat on the FastAPI backend. agents["hitl-in-chat"] = createReplaySafeAgent("/hitl-in-chat", ["book_call"]);createReplaySafeAgent("/hitl-in-chat", ["book_call"])做了两件事(从源码结构看):
- 通过
new HttpAgent({ url: \${AGENT_URL}/hitl-in-chat` })` 把 CopilotKit 请求经 AG-UI 协议代理到 .NET 后端的对应端点; - 对名为
book_call的工具调用做"replay-safe"改写:运行时把toolCallId改写为追加__ck_run_<uuid>后缀的形式,并在后续回合把该后缀从消息中剥离,目的是让同一个对话线程在多次 run/重放时仍能按稳定的工具调用 ID 对齐 fixture 与历史消息。对 HITL 场景而言,这保证了用户选完时间、结果回填后,第二轮对话中的toolCallId依然可追踪、可重放。
运行时以 single-route 模式挂载:
const copilotHandler = createCopilotRuntimeHandler({ runtime: new CopilotRuntime({ agents }), basePath: "/api/copilotkit", mode: "single-route", });因此整条链路是:CopilotChat→/api/copilotkit(CopilotRuntime,single-route)→ AG-UIHttpAgent→ .NET/hitl-in-chat(ChatClientAgent)→ OpenAI(gpt-4o-mini)。模型决定调用book_call后,AG-UI 事件流把TOOL_CALL_*事件传回前端,前端的useHumanInTheLoop拦截该工具调用并渲染卡片;用户选择经respond成为TOOL_CALL_RESULT,再随下一次 run 回到后端。
Hook 原理:useHumanInTheLoop 如何"挂起"与"解除"
useHumanInTheLoop的 v2 实现位于 use-human-in-the-loop.tsx。它的本质是:把一个"等待用户输入"的 Promise 包装成前端工具(frontend tool)的 handler,并把用户交互与 Promise 的 resolve 绑定。
export function useHumanInTheLoop< T extends Record<string, unknown> = Record<string, unknown>, >(tool: ReactHumanInTheLoop<T>, deps?: ReadonlyArray<unknown>) { const { copilotkit } = useCopilotKit(); const resolvePromiseRef = useRef<((result: unknown) => void) | null>(null); const respond = useCallback(async (result: unknown) => { if (resolvePromiseRef.current) { cleanupAbortRef.current?.(); cleanupAbortRef.current = null; resolvePromiseRef.current(result); resolvePromiseRef.current = null; } }, []); const handler = useCallback( async (_args: T, context?: { signal?: AbortSignal }) => { const signal = context?.signal; return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Human-in-the-loop interaction aborted")); return; } resolvePromiseRef.current = resolve; if (signal) { const onAbort = () => { cleanupAbortRef.current = null; resolvePromiseRef.current = null; reject(new Error("Human-in-the-loop interaction aborted")); }; signal.addEventListener("abort", onAbort, { once: true }); cleanupAbortRef.current = () => { signal.removeEventListener("abort", onAbort); }; } }); }, [], );工作机理可以概括为四步:
- 挂起:当模型发起
book_call调用,core 调用handler。handler 立即返回一个尚未 resolve 的 Promise,并把 resolve 函数存入resolvePromiseRef——工具执行因此"悬停",等待用户; - 渲染:内部构造的
RenderComponent把注册项的render包装为工具调用渲染器,按三种ToolCallStatus(InProgress/Executing/Complete)分支渲染,并统一注入name、description、agentIdprops; - respond 仅在 Executing 时有效:
respond只在status === ToolCallStatus.Executing分支被传入 render props,另外两个状态下为undefined。这解释了TimePickerCard中status !== "executing"时禁用全部按钮的设计——不是 UI 的巧合,而是契约要求; - 解除:用户点选时间槽 →
onSubmit→respond(result)→resolvePromiseRef.current(result)被调用 → 挂起的 Promise 以{ chosen_time, chosen_label }解除 → core 把它记为工具结果;若 run 被中止(AbortSignal触发),handler 侧改为 reject 并抛出 "Human-in-the-loop interaction aborted",让 core 记录一个显式的错误工具结果,而不是静默 resolve 成空字符串。
文件末尾的注册逻辑把整个对象组装为前端工具挂接到 CopilotKit 上下文,因此该工具在请求进入后端时会被注入到 Agent 的可用工具集——这正是"后端tools: []却仍能调用book_call"的原因:工具定义与执行都发生在前端,后端只负责发起调用和消费结果。
端到端测试如何验证这个闭环
该演示配有 Playwright E2E 用例 hitl-in-chat.spec.ts,它把 README 中"试着让 Copilot 做这两件事"变成了可回归的断言链:
- 建议项触发卡片:输入 "Schedule a 1:1 with Alice next week to review Q2 goals." 后,断言
[data-testid="time-picker-card"]在 60 秒内出现,卡片展示With Alice副标题,且至少存在一个time-picker-slot按钮; - 选择即确认:点击第一个时间槽后,断言
[data-testid="time-picker-picked"]出现,随后断言出现匹配/Booked.*Alice/i的助手消息——即"已选定态卡片"与"Agent 回填后的确认语"都作为结果出现; - 第二条建议端到端:输入 sales 场景消息,断言卡片含
Sales team文案,选槽后同样等到/Booked.*sales team/i确认; - 同一会话背靠背跑两次流程:这是针对一个真实回归的用例——历史上第二条预约流程会跳过选择器直接输出 "Booked ..." 文本,原因是回放 fixture 以
hasToolResult: true为键,第一条流程结束后历史里已有 tool 消息,确认 fixture 抢先匹配。修复后,测试显式走完两个流程(中间waitForTimeout(1000)等运行时状态沉降),断言第二张time-picker-card必须出现(第一张已转为time-picker-picked,因此toHaveCount(1))。
这些断言共同覆盖了 HITL 的完整状态链:工具调用事件 → 卡片渲染(executing)→ 用户选择 → 卡片切换为已选态 → 工具结果回填 → Agent 确认回复。测试中大量出现的data-testid锚点与 time-picker-card.tsx 中的属性一一对应,可直接用于自行验证。
运行与验证路径
按仓库当前结构,本演示的运行前提与入口为:
- 后端:
agent/目录下的 .NET 宿主(ProverbsAgent.csproj),默认监听 8000 端口(前端AGENT_URL的缺省值),对外暴露/hitl-in-chatAG-UI 端点与/health健康检查;需要 OpenAI 侧 API Key(由ApiKeyResolver从配置/环境变量解析); - 前端:ms-agent-dotnet 的 Next.js 应用,
CopilotKit连接/api/copilotkit,演示页路由为/demos/hitl-in-chat(见 E2E 测试中的page.goto("/demos/hitl-in-chat")); - 验证:在聊天中发送两条建议消息之一,观察时间选择卡片内联出现;点选时间槽后应看到"Booked for ..."卡片与 Agent 确认语;点"None of these work"则得到取消结果。也可以用
tests/e2e/hitl-in-chat.spec.ts中的用例作为验收清单。
仓库根目录的 playwright.config.ts 与该集成的 manifest.yaml 定义了演示的部署与测试约定;部署相关脚本可参考 entrypoint.sh 与 Dockerfile。
小结:In-Chat HITL 的分工模型
把 README 的三条技术细节与源码对照后,这个模式的分工可以总结为:
| 关注点 | 承担方 | 证据位置 |
|---|---|---|
| 工具定义(名称、描述、Zod 参数) | 前端useHumanInTheLoop注册项 | page.tsx |
| 交互式 UI(三态卡片) | 前端TimePickerCard,render回调内联到聊天 | time-picker-card.tsx |
| 挂起/解除/中止语义 | useHumanInTheLoop内部 Promise + AbortSignal 处理 | use-human-in-the-loop.tsx |
| 何时调用工具的决策 | .NETChatClientAgent的系统提示词(tools: [],无后端工具) | HitlInChatAgent.cs |
| 协议桥接(CopilotKit ↔ AG-UI ↔ .NET) | Next.js 路由CopilotRuntime+HttpAgent代理、replay-safe 工具 ID 改写 | route.ts |
| 行为回归保障 | Playwright E2E:卡片渲染、选择回填、背靠背双流程 | hitl-in-chat.spec.ts |
这种"前端拥有工具、后端只发调用"的分工,使 HITL 交互无需在后端维护任何执行逻辑:Agent 框架(此处是 Microsoft Agents.AI 的 .NET 实现)只需支持 AG-UI 事件流与前端工具注入,就能获得与 langgraph-python 等参考实现行为一致的聊天内人审体验。
【免费下载链接】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),仅供参考