CopilotKit In-Chat HITL 实战:用 useHumanInTheLoop 驱动零后端工具的 .NET ChatClientAgent(ms-agent-dotnet 集成演示)
2026/9/14 15:01:31 网站建设 项目流程

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" 一节列出了三条核心技术事实,本文后续章节逐条展开并用源码印证:

  1. book_call工具完全在前端通过useHumanInTheLoop注册——后端不存在任何同名工具;
  2. .NET 侧HitlInChatAgent是一个"裸"ChatClientAgent,只有一段引导模型调用book_call的短系统提示词;
  3. 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描述文案供模型判断"何时该调用此工具"的语义依据
parametersZod schema:topic: stringattendee: 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,通过OpenAIClientAsIChatClient()适配到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_STARTEDTOOL_CALL_START/ARGS/ENDTOOL_CALL_RESULTRUN_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"])做了两件事(从源码结构看):

  1. 通过new HttpAgent({ url: \${AGENT_URL}/hitl-in-chat` })` 把 CopilotKit 请求经 AG-UI 协议代理到 .NET 后端的对应端点;
  2. 对名为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-chatChatClientAgent)→ 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); }; } }); }, [], );

工作机理可以概括为四步:

  1. 挂起:当模型发起book_call调用,core 调用handler。handler 立即返回一个尚未 resolve 的 Promise,并把 resolve 函数存入resolvePromiseRef——工具执行因此"悬停",等待用户;
  2. 渲染:内部构造的RenderComponent把注册项的render包装为工具调用渲染器,按三种ToolCallStatusInProgress/Executing/Complete)分支渲染,并统一注入namedescriptionagentIdprops;
  3. respond 仅在 Executing 时有效respond只在status === ToolCallStatus.Executing分支被传入 render props,另外两个状态下为undefined。这解释了TimePickerCardstatus !== "executing"时禁用全部按钮的设计——不是 UI 的巧合,而是契约要求;
  4. 解除:用户点选时间槽 →onSubmitrespond(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 中的属性一一对应,可直接用于自行验证。

运行与验证路径

按仓库当前结构,本演示的运行前提与入口为:

  1. 后端agent/目录下的 .NET 宿主(ProverbsAgent.csproj),默认监听 8000 端口(前端AGENT_URL的缺省值),对外暴露/hitl-in-chatAG-UI 端点与/health健康检查;需要 OpenAI 侧 API Key(由ApiKeyResolver从配置/环境变量解析);
  2. 前端:ms-agent-dotnet 的 Next.js 应用,CopilotKit连接/api/copilotkit,演示页路由为/demos/hitl-in-chat(见 E2E 测试中的page.goto("/demos/hitl-in-chat"));
  3. 验证:在聊天中发送两条建议消息之一,观察时间选择卡片内联出现;点选时间槽后应看到"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(三态卡片)前端TimePickerCardrender回调内联到聊天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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询