open-agents 实践:用 InferAgentUIMessage 构建端到端类型安全的 useChat Agent 界面
open-agents 实践用 InferAgentUIMessage 构建端到端类型安全的 useChat Agent 界面【免费下载链接】open-agentsAn open source template for building cloud agents.项目地址: https://gitcode.com/GitHub_Trending/op/open-agents本指南围绕 open-agents 仓库中.agents/skills/ai-sdk/references/type-safe-agents.md的核心方案展开从 Agent 定义、工具ToolSchema 中推断出UIMessage类型并把它接入useChat让服务端工具调用的input/output与前端渲染天然共享同一套 TypeScript 类型。读完本文你将掌握「定义工具 → 定义 Agent → 导出 UI 消息类型 → 类型安全渲染」的完整链路以及如何用UIToolInvocation把工具渲染拆分成独立组件而不丢失任何类型信息。文中所有证据均来自 open-agents 仓库的真实源码与配置。为什么需要「端到端」类型安全在典型的 AI Agent 应用中服务端负责执行工具如查询天气、运行 Bash、读写文件客户端负责把工具结果渲染成 UI。如果两端各自维护一份「手写」的消息类型工具 Schema 一旦调整比如把location: string改成结构化对象前端很容易漏改出现运行时才能暴露的字段缺失或类型错配。open-agents 的方案是把类型推导源头放在 Agent 定义本身使用ToolLoopAgent声明工具集合与模型再用InferAgentUIMessagetypeof myAgent一次性推导出可供useChat消费的消息类型。工具的结果output、参数input都来自对应工具的inputSchema实现「改一处 Schema全链路类型同步」。这个模式在仓库中有直接的工程化落地。主 Agent 定义在 packages/agent/open-agent.ts它从ai包导入ToolLoopAgent并注册了 11 个工具todo_write、read、write、edit、grep、glob、bash、task、ask_user_question、skill、web_fetch随后export type OpenAgent typeof openAgent。而 Web 前端通过 apps/web/app/types.ts 中的WebAgentUIMessage UIMessageWebAgentMessageMetadata, WebAgentDataParts, WebAgentUITools把这些类型继续向客户端传递——整个仓库本身就是本文所讲模式的参考实现。推荐的文件结构原文档建议把 Agent 定义与工具定义分层存放lib/ agents/ my-agent.ts # Agent 定义 类型导出 tools/ weather-tool.ts # 单个工具定义 calculator-tool.ts这样做的两个好处工具可复用weather-tool.ts只导出工具本身与可选的调用类型不依赖任何 Agent类型导出集中agents/下每个 Agent 文件同时导出 Agent 实例与UIMessage类型UI 层只 import 类型不 import 实现。open-agents 仓库遵循同一思路子 Agent 定义集中在 packages/agent/subagents/explorer.ts、executor.ts、design.ts并在 packages/agent/subagents/types.ts 中统一导出SubagentUIMessage工具定义则在 packages/agent/tools/ 下按文件拆分如 bash.ts、read.ts、write.ts。AI SDK 技能文档 .agents/skills/ai-sdk/SKILL.md 也明确要求「Always use theToolLoopAgentpattern」并指向本文所在的参考文档获取文件组织规范。定义工具工具是类型的唯一来源// lib/tools/weather-tool.ts import { tool } from ai; import { z } from zod; export const weatherTool tool({ description: Get current weather for a location, inputSchema: z.object({ location: z.string().describe(City name), }), execute: async ({ location }) { return { temperature: 72, condition: sunny, location }; }, });要点说明description供模型决定何时调用该工具应尽量具体inputSchema使用 zod 声明入参结构它同时服务于「模型结构化调用」与「前端类型推导」两条链路execute真正的执行函数入参由inputSchema校验后的结果提供返回值即该工具的输出类型。在 open-agents 中工具还演示了更复杂的用法。以 packages/agent/tools/todo.ts 这类工具为例除了inputSchema之外Agent 层还会通过addCacheControl见 packages/agent/context-management/cache-control.ts为工具描述注入缓存控制指令这是生产环境对「类型安全」之上的另一层优化——类型与性能可以同时管理。定义 Agent 并导出类型// lib/agents/my-agent.ts import { ToolLoopAgent, InferAgentUIMessage } from ai; import { weatherTool } from ../tools/weather-tool; import { calculatorTool } from ../tools/calculator-tool; export const myAgent new ToolLoopAgent({ model: anthropic/claude-sonnet-4, instructions: You are a helpful assistant., tools: { weather: weatherTool, calculator: calculatorTool, }, }); // 从 Agent 定义推断 UIMessage 类型 export type MyAgentUIMessage InferAgentUIMessagetypeof myAgent;关键一步是同时导出实例与类型myAgent供服务端调用MyAgentUIMessage供客户端useChat与渲染组件消费。InferAgentUIMessage会遍历tools对象为每个工具生成形如tool-weather、tool-calculator的判别联合类型。open-agents 中的完整示例packages/agent/open-agent.ts展示了ToolLoopAgent的生产级配置方式export const openAgent new ToolLoopAgent({ model: defaultModel, // gateway(anthropic/claude-opus-4.6) instructions: buildSystemPrompt({}), tools, stopWhen: stepCountIs(1), callOptionsSchema, // zod schema校验每次调用的 options prepareStep: ({ messages, model, steps }) { return { messages: addCacheControl({ messages, model }) }; }, prepareCall: ({ options, ...settings }) { // 在这里合并 sandbox、skills、subagentModel 等上下文 return { ...settings, model: callModel, tools, instructions, experimental_context: { sandbox, skills, model, subagentModel } }; }, });可以看到除了文档中的最小配置model/instructions/toolsToolLoopAgent还支持stopWhen如stepCountIs(1)限制步数、callOptionsSchemazod 校验调用参数、prepareStep/prepareCall在每一步或每次调用前改写消息、模型、工具与上下文等扩展点。它们不会改变类型推导机制但决定了 Agent 在真实沙箱环境中的行为。带自定义元数据Custom Metadata如果你的 UI 需要展示额外信息如创建时间、使用的模型可以把元数据 Schema 传给InferAgentUIMessage的第二个泛型参数// lib/agents/my-agent.ts import { z } from zod; const metadataSchema z.object({ createdAt: z.number(), model: z.string().optional(), }); type MyMetadata z.infertypeof metadataSchema; export type MyAgentUIMessage InferAgentUIMessagetypeof myAgent, MyMetadata;open-agents 对此有清晰的实战先例。packages/agent/subagents/types.ts 定义了SubagentMessageMetadataexport type SubagentMessageMetadata { lastStepUsage?: LanguageModelUsage; totalMessageUsage?: LanguageModelUsage; modelId?: string; }; // 三个子 Agent 的 UI 消息联合类型全部携带元数据 export type SubagentUIMessage | InferAgentUIMessagetypeof explorerSubagent, SubagentMessageMetadata | InferAgentUIMessagetypeof executorSubagent, SubagentMessageMetadata | InferAgentUIMessagetypeof designSubagent, SubagentMessageMetadata;同理Web 端 apps/web/app/types.ts 中的WebAgentMessageMetadata承载lastStepFinishReason、stepFinishReasons等步骤级信息并继续传递给UIMessageWebAgentMessageMetadata, WebAgentDataParts, WebAgentUITools。这说明自定义元数据可以描述「用量统计」「完成原因」这类对渲染很关键、却与工具无关的数据。与 useChat 集成// app/chat.tsx import { useChat } from ai-sdk/react; import type { MyAgentUIMessage } from /lib/agents/my-agent; export function Chat() { const { messages } useChatMyAgentUIMessage(); return ( div {messages.map(message ( Message key{message.id} message{message} / ))} /div ); }useChatMyAgentUIMessage()的泛型参数让messages数组中的每条消息都具备完整的parts联合类型。在 open-agents 的 Web 应用中apps/web/app/sessions/[sessionId]/chats/[chatId]/hooks/use-session-chat-runtime.ts 即采用useChatWebAgentUIMessage见该文件第 192 行并配合chat、resume、experimental_throttle等选项实现断流恢复与节流。与之配套服务端流式接口同样复用了这一类型apps/web/app/api/chat/[chatId]/stream/route.ts 中定义了type WebAgentUIMessageChunk InferUIMessageChunkWebAgentUIMessage随后通过run.getReadableWebAgentUIMessageChunk(...)与createUIMessageStreamResponse把 Agent 运行产生的流式消息直接吐给前端。这意味着同一个消息类型贯穿了服务端运行、HTTP 流式传输与客户端渲染三段。按类型安全的方式渲染 Parts工具在消息中的位置被称为 tool part其类型是基于 Agent 的 tools 键名动态生成的判别联合tool-{toolName}。TypeScript 会根据part.type收窄类型为每个工具给出对应的input/output自动补全与类型检查function Message({ message }: { message: MyAgentUIMessage }) { return ( div {message.parts.map((part, i) { switch (part.type) { case text: return p key{i}{part.text}/p; case tool-weather: // part.input 与 part.output 已完全类型化 if (part.state output-available) { return ( div key{i} Weather in {part.input.location}: {part.output.temperature}F /div ); } return div key{i}Loading weather.../div; case tool-calculator: // TypeScript 知道这是 calculator 工具 return div key{i}Calculating.../div; default: return null; } })} /div ); }几点实践说明state判别tool part 存在多个生命周期状态典型的是output-available已有输出与执行中状态无输出。渲染时必须先判断state再访问output否则类型无法通过default分支联合类型会随 Agent 新增工具而扩展保留default分支能保证「新增工具但忘记写渲染」时仍能编译通过同时不会渲染出空白内容文本 parttextpart 同样被类型化适合流式逐字渲染。open-agents 的 Web 端把这一模式扩展到了「数据 part」上。apps/web/app/types.ts 定义了WebAgentDataParts { commit, pr, snippet, workspace-status }并通过ExtractWebAgentUIMessagePart, { type: data-commit }提取出WebAgentCommitDataPart、WebAgentPrDataPart等专用类型供 git-panel.tsx 等组件消费——当 Agent 自动创建 PR、提交代码时UI 可以直接按字段渲染prNumber、commitSha、url等信息。用 UIToolInvocation 拆分工具渲染组件当工具数量变多把所有渲染逻辑塞进一个switch会让消息组件迅速膨胀。UIToolInvocationTOOL可以从单个工具推导出「该工具的调用类型」把它和工具定义放在一起导出UI 侧只 import 类型// lib/tools/weather-tool.ts import { tool, UIToolInvocation } from ai; import { z } from zod; export const weatherTool tool({ description: Get current weather for a location, inputSchema: z.object({ location: z.string().describe(City name), }), execute: async ({ location }) { return { temperature: 72, condition: sunny, location }; }, }); // 导出供 UI 组件使用的调用类型 export type WeatherToolInvocation UIToolInvocationtypeof weatherTool;组件侧只需引入类型纯类型导入不引入工具实现避免把服务端代码打进客户端 bundle// components/weather-tool.tsx import type { WeatherToolInvocation } from /lib/tools/weather-tool; export function WeatherToolComponent({ invocation, }: { invocation: WeatherToolInvocation; }) { // invocation.input 与 invocation.output 已完全类型化 if (invocation.state output-available) { return ( div Weather in {invocation.input.location}: {invocation.output.temperature}F /div ); } return divLoading weather for {invocation.input?.location}.../div; }最后在消息渲染器里按part.type分发到对应组件function Message({ message }: { message: MyAgentUIMessage }) { return ( div {message.parts.map((part, i) { switch (part.type) { case text: return p key{i}{part.text}/p; case tool-weather: return WeatherToolComponent key{i} invocation{part} /; case tool-calculator: return CalculatorToolComponent key{i} invocation{part} /; default: return null; } })} /div ); }这种组织方式的收益渲染逻辑就近管理每个工具一个组件文件与工具定义一一对应类型不丢失invocation就是消息中的 tool partinput/output依旧跟随工具 Schema不引入实现UI 组件只import type工具的执行逻辑永远不会进入客户端代码。open-agents 的 Web 端也采用了相同的「按 part 类型拆分渲染器」架构apps/web/components/tool-call/renderers/ 下按工具拆分如 bash-renderer.tsx、read-renderer.tsx、grep-renderer.tsx、write-renderer.tsx、edit-renderer.tsx 等并在 apps/web/components/tool-call/tool-call.tsx 中统一分发apps/web/components/ui 下则沉淀了按钮、对话框、输入框等基础组件供这些渲染器复用。完整链路回顾结合 open-agents 仓库整个「类型安全 Agent 界面」的数据流可以归纳为定义工具如 packages/agent/tools/ 下的各工具tool({ description, inputSchema, execute })zod Schema 成为唯一事实来源定义 Agentpackages/agent/open-agent.ts、packages/agent/subagents/ToolLoopAgent聚合模型、指令与工具必要时通过callOptionsSchema、prepareStep、prepareCall注入沙箱上下文导出类型InferAgentUIMessagetypeof agent, Metadata如 packages/agent/subagents/types.ts、apps/web/app/types.ts或用UIToolInvocationtypeof tool导出单工具类型服务端流式apps/web/app/api/chat/[chatId]/stream/route.ts 用InferUIMessageChunkWebAgentUIMessage泛型化可读流保证跨进程类型一致客户端渲染useChatWebAgentUIMessageuse-session-chat-runtime.ts配合按part.type分发的渲染器apps/web/components/tool-call/从消息到工具结果全程零any。实践中最容易踩的三个坑一是忘记传第二个泛型参数导致元数据字段在 UI 侧不可见二是在渲染output前没有先判别stateoutput-available之外的阶段访问output会直接类型报错三是在新工具注册后未处理新的tool-{toolName}分支——好在default分支会兜底让你在编译期不报错但渲染留白的风险中及时补全。在动手写代码前也可以参考技能入口文档 .agents/skills/ai-sdk/SKILL.md其中明确要求「不要依赖训练数据里的旧 API先查node_modules/ai/docs/与node_modules/ai/src/确认当前版本签名」因为useChat等接口在近期版本中变化较大参数重命名等细节见 .agents/skills/ai-sdk/references/common-errors.md。【免费下载链接】open-agentsAn open source template for building cloud agents.项目地址: https://gitcode.com/GitHub_Trending/op/open-agents创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考