中立事件 ⇄ AG-UI 协议。
runtime 发中立事件,协议知识全在这一层——换个前端协议只换这个 ns, runtime / event / emit 一个字都不用改(对称于「core 收回厂商 wire 知识」, 见 docs/response-path-consolidation.md)。
本 ns 不产出 HTTP 任何东西:返回的是 Clojure map,SSE 帧、路由、CORS 在 examples/copilotkit/(design-principles §2)。
AG-UI 字段名逐个对过 CopilotKit 的 middleware-sse-parser.test.ts:
TOOL_CALL_START 是 toolCallName 不是 name,TOOL_CALL_ARGS 的
delta 是 JSON 字符串不是对象——这类地方猜错了前端不会报错,只会静默
少渲染一块。
中立事件 ⇄ AG-UI 协议。 **runtime 发中立事件,协议知识全在这一层**——换个前端协议只换这个 ns, runtime / event / emit 一个字都不用改(对称于「core 收回厂商 wire 知识」, 见 docs/response-path-consolidation.md)。 本 ns **不产出 HTTP 任何东西**:返回的是 Clojure map,SSE 帧、路由、CORS 在 examples/copilotkit/(design-principles §2)。 AG-UI 字段名逐个对过 CopilotKit 的 `middleware-sse-parser.test.ts`: `TOOL_CALL_START` 是 `toolCallName` 不是 `name`,`TOOL_CALL_ARGS` 的 `delta` 是 **JSON 字符串**不是对象——这类地方猜错了前端不会报错,只会静默 少渲染一块。
(->agui ev)中立事件 → AG-UI 事件 map(nil = 没有对应物),带 lane 归属。
中立事件 → AG-UI 事件 map(nil = 没有对应物),带 lane 归属。
(->agui-events {:keys [type message-id] :as ev})中立事件 → 零或一条 AG-UI 事件(没有对应物则空)。
曾经是一对多::run/paused 发一条 CUSTOM 说「停在这儿等人」,再补一条
RUN_FINISHED 把 run 收口——AG-UI 的 run 必须有终态,少了它 stock 前端不
报错,只是 HTTP 请求永远不结束、工具卡片永远 inProgress(联调实测)。
改用 interrupt 协议后两件事合成一条:RUN_FINISHED 自带 outcome,既是
终态又说清了停因。续跑仍是新的 run,前端 connect 接着看(§4.7)。
留着这个函数(而不是让传输层直接调 ->agui)是对的——思考块马上又用上了:
一条 :reasoning/started 要发 REASONING_START + REASONING_MESSAGE_START
两条(外层是「这一段在想」,内层才是那条 reasoning 消息),收口对称。
->agui 给的是内层那条,只认单条的调用方(如 live 脚本的假连接)因此也
看得见思考内容,只是少了外层括号。
中立事件 → **零或一条** AG-UI 事件(没有对应物则空)。 曾经是一对多:`:run/paused` 发一条 `CUSTOM` 说「停在这儿等人」,再补一条 `RUN_FINISHED` 把 run 收口——AG-UI 的 run 必须有终态,少了它 stock 前端不 报错,只是 HTTP 请求永远不结束、工具卡片永远 `inProgress`(联调实测)。 改用 interrupt 协议后两件事合成一条:`RUN_FINISHED` 自带 `outcome`,既是 终态又说清了停因。续跑仍是**新的 run**,前端 `connect` 接着看(§4.7)。 留着这个函数(而不是让传输层直接调 `->agui`)是对的——思考块马上又用上了: 一条 `:reasoning/started` 要发 `REASONING_START` + `REASONING_MESSAGE_START` 两条(外层是「这一段在想」,内层才是那条 reasoning 消息),收口对称。 `->agui` 给的是内层那条,只认单条的调用方(如 live 脚本的假连接)因此也 看得见思考内容,只是少了外层括号。
(agui->messages messages)AG-UI 消息数组 → 中立消息。
入站方向,message->agui 的反面。主路径用不着它——/run 的历史取服务端
权威(见 parse-run-input)。用得着的是无状态 run(/suggest):那条路
没有服务端线程,客户端发上来的就是全部上下文。
认不出角色的整条丢掉(客户端 SDK 各有各的扩展,宁可少喂也不喂脏数据)。
AG-UI 消息数组 → 中立消息。 **入站方向,`message->agui` 的反面**。主路径用不着它——`/run` 的历史取服务端 权威(见 `parse-run-input`)。用得着的是**无状态 run**(`/suggest`):那条路 没有服务端线程,客户端发上来的就是全部上下文。 认不出角色的整条丢掉(客户端 SDK 各有各的扩展,宁可少喂也不喂脏数据)。
(agui-content->neutral content*)AG-UI 的 InputContent 数组 → 中立多模态部件(im.ttalk.agent.model.content)。
字符串原样返回。 纯文本消息的形状一个字都不能变 —— 客户端也是这么做的
(没附件就不升成数组),两边一致,"你好" 这种消息才不会平白走部件路径。
映射(协议侧 → 中立侧):
| AG-UI | 中立 |
|---|---|
{type:"text", text} | {:type :text :text …} |
{type:"image"/"audio"/"video"/"document", source:{type:"data", value, mimeType}} | {:type :file :media-type … :data …} |
同上但 source.type = "url" | {:type :file :media-type … :url …} |
image / audio / video / document 在中立层不各立一类,统一是 :file +
media-type(与 Vercel AI SDK 的部件模型一致)—— wire 层按 media type 的顶层
类别分派,新格式不用加新类型。
⚠ 不做这一跳的话,部件数组会被 (str content) 压成一坨字符串喂给模型:
不报错,只是内容全丢,比报错难查得多。
AG-UI 的 `InputContent` 数组 → 中立多模态部件(`im.ttalk.agent.model.content`)。
**字符串原样返回。** 纯文本消息的形状一个字都不能变 —— 客户端也是这么做的
(没附件就不升成数组),两边一致,`"你好"` 这种消息才不会平白走部件路径。
映射(协议侧 → 中立侧):
| AG-UI | 中立 |
| --- | --- |
| `{type:"text", text}` | `{:type :text :text …}` |
| `{type:"image"/"audio"/"video"/"document", source:{type:"data", value, mimeType}}` | `{:type :file :media-type … :data …}` |
| 同上但 `source.type = "url"` | `{:type :file :media-type … :url …}` |
image / audio / video / document 在中立层**不各立一类**,统一是 `:file` +
media-type(与 Vercel AI SDK 的部件模型一致)—— wire 层按 media type 的顶层
类别分派,新格式不用加新类型。
⚠ 不做这一跳的话,部件数组会被 `(str content)` 压成一坨字符串喂给模型:
**不报错,只是内容全丢**,比报错难查得多。(context->prompt context)AG-UI RunAgentInput.context → 一段 system 文本。返回 nil = 这轮没有上下文。
条目是 {description, value}(ContextSchema,两个都是字符串)。前端
useAgentContext({description, value}) 注册,客户端每次 run 都重发全量
——所以它天然是 turn 级的:这一轮带就有,下一轮不带就没了。落到我们这边的
出口是 chat-async 的 :extra-system-prompts(追加,不覆盖 agent 人设),
同样不进 ChatMemory。
value 按 schema 是字符串,但 useAgentContext 收的是 JsonSerializable,
路上谁 stringify 由客户端版本决定——所以这里两种都认,非字符串按 JSON 打平。
开头那句抬头是给模型的出处说明:不写的话这几行会被当成用户指令,而它们 其实是页面状态。
AG-UI `RunAgentInput.context` → 一段 system 文本。返回 nil = 这轮没有上下文。
条目是 `{description, value}`(`ContextSchema`,两个都是字符串)。前端
`useAgentContext({description, value})` 注册,客户端**每次 run 都重发全量**
——所以它天然是 turn 级的:这一轮带就有,下一轮不带就没了。落到我们这边的
出口是 `chat-async` 的 `:extra-system-prompts`(**追加**,不覆盖 agent 人设),
同样不进 ChatMemory。
`value` 按 schema 是字符串,但 `useAgentContext` 收的是 `JsonSerializable`,
路上谁 stringify 由客户端版本决定——所以这里两种都认,非字符串按 JSON 打平。
开头那句抬头是给模型的**出处说明**:不写的话这几行会被当成用户指令,而它们
其实是页面状态。(interrupt-id {:keys [run-id pending-tool]})一次暂停的稳定标识 —— 发出去是 RUN_FINISHED.outcome.interrupts[].id,
客户端原样送回在 RunAgentInput.resume[].interruptId 里。
取 pending 工具的 tool-call id:它本来就唯一,且与同一条 interrupt 的
toolCallId 同源,前端把审批条挂回那张工具卡片不用二次关联。没有 pending
工具的暂停(纯 ask-user)退回 run-id。
入参既是暂停事件也是 runtime/awaiting 的返回值——两者 :run-id /
:pending-tool 同形,路由侧据此校验「客户端回的是不是当前这一条」。
一次暂停的稳定标识 —— 发出去是 `RUN_FINISHED.outcome.interrupts[].id`, 客户端原样送回在 `RunAgentInput.resume[].interruptId` 里。 取 pending 工具的 **tool-call id**:它本来就唯一,且与同一条 interrupt 的 `toolCallId` 同源,前端把审批条挂回那张工具卡片不用二次关联。没有 pending 工具的暂停(纯 ask-user)退回 run-id。 **入参既是暂停事件也是 `runtime/awaiting` 的返回值**——两者 `:run-id` / `:pending-tool` 同形,路由侧据此校验「客户端回的是不是当前这一条」。
(interrupts-of {:keys [pending-tools] :as ev})暂停事件 → outcome.interrupts:同一批里每条被叫停的调用一个 interrupt
(designs/agui-borrow.md §2,对照 Keelson ChatAguiRunState.Judge / Done)。
只有一条时与从前逐字相同(interrupt-of 整个事件)——单审批的客户端不受影响。
多条时每条用自己的 tool-call id 作 interrupt id、自己的 pendingTool、自己的
前端 / 审批归类;reason 取 gate 给的审批理由(:approval :reason),没有就是
「需要审批: <工具名>」——整批合在一起的那句「需要审批: a, b」挂不到单张卡片上。
暂停事件 → `outcome.interrupts`:同一批里**每条**被叫停的调用一个 interrupt (designs/agui-borrow.md §2,对照 Keelson `ChatAguiRunState.Judge` / `Done`)。 只有一条时与从前逐字相同(`interrupt-of` 整个事件)——单审批的客户端不受影响。 多条时每条用自己的 tool-call id 作 interrupt id、自己的 `pendingTool`、自己的 前端 / 审批归类;`reason` 取 gate 给的审批理由(`:approval :reason`),没有就是 「需要审批: <工具名>」——整批合在一起的那句「需要审批: a, b」挂不到单张卡片上。
(message->agui m)(message->agui m idx)中立消息 → AG-UI 消息。
:id 优先用消息自己的——ChatMemory 落库时补的那个(msg/ensure-id),
跨快照稳定。没有才按位置合成(m-<idx>):那只保证「同一次快照内部自洽」,
replace-tool-results / heal-dangling 一让位置漂,同一条消息在两次快照里
就是两个 id。走内置 store 的都有 id,退路是留给第三方 store 的。
与事件流的 message-id 不是一个 id 空间(那边是 <run-id>-mN)——合成一个
要让发射器的 id 流进 response->neutral,那是 core 依赖 agui 的方向,不做。
中立消息 → AG-UI 消息。 `:id` **优先用消息自己的**——ChatMemory 落库时补的那个(`msg/ensure-id`), 跨快照稳定。没有才按位置合成(`m-<idx>`):那只保证「同一次快照内部自洽」, `replace-tool-results` / `heal-dangling` 一让位置漂,同一条消息在两次快照里 就是两个 id。走内置 store 的都有 id,退路是留给第三方 store 的。 **与事件流的 message-id 不是一个 id 空间**(那边是 `<run-id>-mN`)——合成一个 要让发射器的 id 流进 `response->neutral`,那是 core 依赖 agui 的方向,不做。
(messages->thread-messages messages)中立消息 → /threads/:id/messages 的形状。
与事件流里的 message->agui 不是一个形状(上游也分两套):这里的
toolCalls 是扁的 {id, name, args},事件流那套是 OpenAI 风格的
{id, type, function:{name, arguments}}。照抄上游 handleGetThreadMessages
的本地分支——线程列表页读的是这一套。
中立消息 → `/threads/:id/messages` 的形状。
**与事件流里的 `message->agui` 不是一个形状**(上游也分两套):这里的
`toolCalls` 是**扁的** `{id, name, args}`,事件流那套是 OpenAI 风格的
`{id, type, function:{name, arguments}}`。照抄上游 `handleGetThreadMessages`
的本地分支——线程列表页读的是这一套。(parse-run-input {:keys [threadId runId messages tools state context
forwardedProps resume parentRunId]})AG-UI RunAgentInput → 我们的 run 参数。
历史取服务端权威(§7.3):客户端带上来的 messages 是它自己那份完整历史,
而我们的 memory filter 是服务端权威,且循环内落库 / heal-dangling / 暂停恢复 /
timeline 四样全依赖它。所以这里只取最后一条 user 消息,其余忽略;前端要
对齐历史走 connect 的 MESSAGES_SNAPSHOT。
state 作为本 run 的初始 context 注入——turn 级、不落库,与现有语义逐字
一致(跨 run 共享状态见 §7.1,路径是 fold-from-history,不建快照店)。
resume 是 interrupt 协议的答复数组({interruptId, status, payload}):
上一条 run 以 outcome:"interrupt" 收口,客户端把人的决定放在下一次 run
的请求体里带回来——协议里没有单独的审批端点,就是这一条。
context 是前端注册的本轮环境说明(useAgentContext,每次 run 都随请求
体重发)——渲染成 system 段的活在 context->prompt。
parentRunId 是可选的父 run 声明(嵌套 run)。我们不解释它,只原样回到
RUN_STARTED.parentRunId 上。⚠️ CopilotKit 的 JS 客户端在发出去之前会把这个
字段解构掉(@ag-ui/client 的 dist 里对它只有一处引用,就是丢弃那处),
所以走 CopilotKit 这条路它恒为 nil——留着是为别的 AG-UI SDK 与自建客户端。
AG-UI `RunAgentInput` → 我们的 run 参数。
**历史取服务端权威**(§7.3):客户端带上来的 `messages` 是它自己那份完整历史,
而我们的 memory filter 是服务端权威,且循环内落库 / heal-dangling / 暂停恢复 /
timeline 四样全依赖它。所以这里**只取最后一条 user 消息**,其余忽略;前端要
对齐历史走 `connect` 的 `MESSAGES_SNAPSHOT`。
`state` 作为本 run 的**初始 context** 注入——turn 级、不落库,与现有语义逐字
一致(跨 run 共享状态见 §7.1,路径是 fold-from-history,不建快照店)。
`resume` 是 interrupt 协议的答复数组(`{interruptId, status, payload}`):
上一条 run 以 `outcome:"interrupt"` 收口,客户端把人的决定放在**下一次 run
的请求体**里带回来——协议里没有单独的审批端点,就是这一条。
`context` 是前端注册的**本轮环境说明**(`useAgentContext`,每次 run 都随请求
体重发)——渲染成 system 段的活在 `context->prompt`。
`parentRunId` 是可选的父 run 声明(嵌套 run)。我们**不解释它**,只原样回到
`RUN_STARTED.parentRunId` 上。⚠️ CopilotKit 的 JS 客户端在发出去之前会把这个
字段解构掉(`@ag-ui/client` 的 dist 里对它只有一处引用,就是丢弃那处),
所以走 CopilotKit 这条路它恒为 nil——留着是为别的 AG-UI SDK 与自建客户端。(run-info agent-ids)(run-info agent-ids
{:keys [version descriptions capabilities open-generative-ui? a2ui?
suggestions? threads? thread-endpoints multimodal
parallel-tools? max-iterations state? approve-with-edits?]})GET {base}/info 的响应体。
agents 是以 agent id 为键的字典,不是数组——客户端拿它做
Object.entries(runtimeInfo.agents) 再按 id 建 proxy(agent-registry.ts)。
给数组不会报错,只会把下标当成 agent id,于是前端去请求 /agent/0/run。
这条是联调时才照出来的:单测和 live 脚本都不碰 /info。
只声明我们真支持的——不谎报能力位(intelligence / inspectorMetadata
是 CopilotKit 云产品的东西,我们没有就是没有;不写这些键等于告诉客户端
「没有」,它会走降级路径)。
className 客户端不解释,只在调试面板显示。
capabilities 是每个 agent 一份(客户端 agent-registry 从这里读,
再交给 useCapabilities)。我们报的这三位都是真的:会暂停等人
(supported)、敏感工具要审批(approvals)、走 AG-UI 的 interrupt 协议
(interrupts:RUN_FINISHED outcome=interrupt + 收 resume[])。
approveWithEdits 由装配方报(:approve-with-edits?,缺省 false):
改参数再执行要路由把 editedArgs 翻成 resume 的 {:args}——这层判不了
路由接没接,所以不替它说(examples 的路由接了,缺省报 true)。
multimodal 是装配方传进来的,本 ns 不猜:能不能收图取决于两件事——
wire 认不认部件(wire/anthropic / wire/openai 都认)且模型本身有视觉,
而后一条 codec 这层判不了。⛔ 也别按「provider 是谁」硬编码:同一个 provider
下 MiniMax-M2.7 没视觉、qwen-vl 有。不传就不报这一格——客户端据此把附件
入口收起来,比摆一颗点了才发现没用的回形针强。
`GET {base}/info` 的响应体。
**`agents` 是以 agent id 为键的字典,不是数组**——客户端拿它做
`Object.entries(runtimeInfo.agents)` 再按 id 建 proxy(`agent-registry.ts`)。
给数组不会报错,只会把下标当成 agent id,于是前端去请求 `/agent/0/run`。
这条是**联调时才照出来的**:单测和 live 脚本都不碰 `/info`。
只声明我们真支持的——**不谎报能力位**(`intelligence` / `inspectorMetadata`
是 CopilotKit 云产品的东西,我们没有就是没有;不写这些键等于告诉客户端
「没有」,它会走降级路径)。
`className` 客户端不解释,只在调试面板显示。
`capabilities` 是**每个 agent 一份**(客户端 `agent-registry` 从这里读,
再交给 `useCapabilities`)。我们报的这三位都是真的:会暂停等人
(`supported`)、敏感工具要审批(`approvals`)、走 AG-UI 的 interrupt 协议
(`interrupts`:`RUN_FINISHED outcome=interrupt` + 收 `resume[]`)。
`approveWithEdits` **由装配方报**(`:approve-with-edits?`,缺省 false):
改参数再执行要路由把 `editedArgs` 翻成 resume 的 `{:args}`——这层判不了
路由接没接,所以不替它说(examples 的路由接了,缺省报 true)。
`multimodal` 是**装配方传进来的**,本 ns 不猜:能不能收图取决于两件事——
wire 认不认部件(`wire/anthropic` / `wire/openai` 都认)**且模型本身有视觉**,
而后一条 codec 这层判不了。⛔ 也别按「provider 是谁」硬编码:同一个 provider
下 `MiniMax-M2.7` 没视觉、`qwen-vl` 有。不传就不报这一格——客户端据此把附件
入口收起来,比摆一颗点了才发现没用的回形针强。(run-input-problem body)(run-input-problem body {:keys [require-run-id?]})校验 RunAgentInput 的最小形状。返回 nil(没问题)或
{:error <机器可读的码> :message <给人看的话>}。
为什么要有:不校验时坏输入一路滑到 start-run!——缺 threadId 就拿 nil 当
会话 id 照跑(一个所有人共享的 nil 会话),坏 JSON 则在路由里炸成 500,客户端
分不清是自己错了还是服务挂了(designs/agui-borrow.md §7)。
只出数据,不出状态码:码怎么映射成 400 是路由的事(本 ns 不产出 HTTP,
见 ns docstring)。body 是已解析的请求体(解析失败的那一步在路由里,
它同样回 bad_json)。
:require-run-id? —— /run 要(AG-UI 的 RunAgentInput.runId 必填),
/connect 只认 threadId校验 `RunAgentInput` 的**最小形状**。返回 nil(没问题)或
`{:error <机器可读的码> :message <给人看的话>}`。
为什么要有:不校验时坏输入一路滑到 `start-run!`——缺 `threadId` 就拿 nil 当
会话 id 照跑(一个所有人共享的 nil 会话),坏 JSON 则在路由里炸成 500,客户端
分不清是自己错了还是服务挂了(designs/agui-borrow.md §7)。
**只出数据,不出状态码**:码怎么映射成 400 是路由的事(本 ns 不产出 HTTP,
见 ns docstring)。`body` 是**已解析**的请求体(解析失败的那一步在路由里,
它同样回 `bad_json`)。
- `:require-run-id?` —— `/run` 要(AG-UI 的 `RunAgentInput.runId` 必填),
`/connect` 只认 `threadId`(terminal? agui-event)这条 AG-UI 事件是不是终态——传输层据此关流。AG-UI 的 run 必须以终态收口, 收不到终态的客户端会一直吊着(联调时的表现:工具卡片永远停在 inProgress)。
这条 AG-UI 事件是不是**终态**——传输层据此关流。AG-UI 的 run 必须以终态收口, 收不到终态的客户端会一直吊着(联调时的表现:工具卡片永远停在 inProgress)。
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |