A2UI —— 声明式生成式 UI(可选插件)。
移植自 @ag-ui/a2ui-middleware(CopilotKit 在 agent-utils.ts 里与
Open Generative UI 挂在同一个位置)。两者的分工值得先说清楚:
copilotkit.genui(Open Generative UI,在 examples 里) | 本 ns(A2UI) | |
|---|---|---|
| 模型生成什么 | 一整块 HTML/CSS/JS | 组件树(只能用 catalog 里有的组件) |
| 谁来渲染 | 沙箱 iframe 执行模型写的代码 | 前端按自己的组件库渲染 |
| 可控性 | 任意 JS(所以要沙箱) | 白名单:模型编不出 catalog 之外的组件 |
| 交互回流 | Websandbox.connection.remote.* | forwardedProps.a2uiAction(见 input-transform) |
要「像素级自由」用 genui,要「设计系统一致 + 不执行任意代码」用 A2UI。
(rt/runtime {:agent-fn (tools/agent-fn (a2ui/with-tool spec))
:event-transform (a2ui/event-transform)})
注入工具与用法(with-tool):render_a2ui 工具 + 一段用法提示词 +
catalog(有哪些组件、各自什么属性)。catalog 是这条路的关键——它既是
给模型的词汇表,也是「模型编不出别的组件」的保证;
翻译事件(event-transform):模型调 render_a2ui,参数
{surfaceId, components, data} 翻成一条 ACTIVITY_SNAPSHOT:
{:activityType "a2ui-surface"
:messageId "a2ui-surface-<toolCallId>"
:replace true
:content {"a2ui_operations"
[{:version "v0.9" :createSurface {…}}
{:version "v0.9" :updateComponents {…}}
{:version "v0.9" :updateDataModel {…}}]}}
三条 op 的顺序是协议要求的(先建面,再给组件,最后灌数据);replace true
表示「整块换掉」——所以不需要 delta,每次都是完整的一份;
回流用户动作(input-transform):用户点了生成出来的按钮,前端把
forwardedProps.a2uiAction 发上来,翻成一句话喂回模型。
extractCompleteItems),一边生成一边往面上贴。我们的运行时不增量流式
工具参数(docs/agent-runtime-design.md §10 第 4 条),参数一次到齐,所以
一条快照发完——形状与顺序一致,少的是中间态;log_a2ui_event 的一次工具调用 + 工具结果塞进
历史。我们的历史是服务端权威(§7.3),客户端塞不进消息,所以翻成一句
用户消息(措辞与上游的 formatUserActionResult 一致)。A2UI —— **声明式**生成式 UI(可选插件)。
移植自 `@ag-ui/a2ui-middleware`(CopilotKit 在 `agent-utils.ts` 里与
Open Generative UI 挂在同一个位置)。两者的分工值得先说清楚:
| | `copilotkit.genui`(Open Generative UI,在 examples 里) | 本 ns(A2UI) |
|---|---|---|
| 模型生成什么 | 一整块 HTML/CSS/JS | **组件树**(只能用 catalog 里有的组件) |
| 谁来渲染 | 沙箱 iframe 执行模型写的代码 | 前端按自己的组件库渲染 |
| 可控性 | 任意 JS(所以要沙箱) | **白名单**:模型编不出 catalog 之外的组件 |
| 交互回流 | `Websandbox.connection.remote.*` | `forwardedProps.a2uiAction`(见 `input-transform`) |
要「像素级自由」用 genui,要「设计系统一致 + 不执行任意代码」用 A2UI。
## 装
```clojure
(rt/runtime {:agent-fn (tools/agent-fn (a2ui/with-tool spec))
:event-transform (a2ui/event-transform)})
```
## 后台干什么
1. **注入工具与用法**(`with-tool`):`render_a2ui` 工具 + 一段用法提示词 +
**catalog**(有哪些组件、各自什么属性)。catalog 是这条路的关键——它既是
给模型的词汇表,也是「模型编不出别的组件」的保证;
2. **翻译事件**(`event-transform`):模型调 `render_a2ui`,参数
`{surfaceId, components, data}` 翻成一条 `ACTIVITY_SNAPSHOT`:
{:activityType "a2ui-surface"
:messageId "a2ui-surface-<toolCallId>"
:replace true
:content {"a2ui_operations"
[{:version "v0.9" :createSurface {…}}
{:version "v0.9" :updateComponents {…}}
{:version "v0.9" :updateDataModel {…}}]}}
三条 op 的顺序是协议要求的(先建面,再给组件,最后灌数据);`replace true`
表示「整块换掉」——所以不需要 delta,每次都是完整的一份;
3. **回流用户动作**(`input-transform`):用户点了生成出来的按钮,前端把
`forwardedProps.a2uiAction` 发上来,翻成一句话喂回模型。
## 与上游的两处差异(都记在设计文档 §9.14)
1. 上游从**流式** tool-call 参数里增量抠出「已经完整的那几个组件」
(`extractCompleteItems`),一边生成一边往面上贴。我们的运行时不增量流式
工具参数(docs/agent-runtime-design.md §10 第 4 条),参数一次到齐,所以
**一条快照发完**——形状与顺序一致,少的是中间态;
2. 上游把用户动作合成成 `log_a2ui_event` 的一次**工具调用 + 工具结果**塞进
历史。我们的历史是服务端权威(§7.3),客户端塞不进消息,所以翻成一句
**用户消息**(措辞与上游的 `formatUserActionResult` 一致)。中立事件 ⇄ 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 字符串**不是对象——这类地方猜错了前端不会报错,只会静默 少渲染一块。
接线:把 agent 跑起来时已经有的观察点接到发射器上。
三个采集点,各自有不可替代的理由(docs/agent-runtime-design.md §4.9):
| 采集点 | 拿到什么 | 为什么非它不可 |
|---|---|---|
:on-token | 正文 / 思维 token 增量 | 唯一的 token 粒度出口 |
:on-llm-result callback | ChatResponse——含 tool-call :id | :on-tool-call 的签名是 (fn [tool-name args]),:tool 链的 ToolRequest 是 {:function :args :context}——两个都没有 id,而事件流要靠 id 把 start/args/result 串起来。而且 :tool 链跑在并行任务里 |
:iteration 链 | 本轮 delta(带 :tool-call-id 的结果消息)+ 折叠后的 context | 每轮一次、在循环线程上、手里有完整的本轮消息 |
不新增 callback,也不改 react——三个点全是现成的。
接线:把 agent 跑起来时**已经有的**观察点接到发射器上。
三个采集点,各自有不可替代的理由(docs/agent-runtime-design.md §4.9):
| 采集点 | 拿到什么 | 为什么非它不可 |
|---|---|---|
| `:on-token` | 正文 / 思维 token 增量 | 唯一的 token 粒度出口 |
| `:on-llm-result` callback | ChatResponse——**含 tool-call `:id`** | `:on-tool-call` 的签名是 `(fn [tool-name args])`,`:tool` 链的 ToolRequest 是 `{:function :args :context}`——**两个都没有 id**,而事件流要靠 id 把 start/args/result 串起来。而且 `:tool` 链跑在并行任务里 |
| `:iteration` 链 | 本轮 delta(带 `:tool-call-id` 的结果消息)+ 折叠后的 context | 每轮一次、在循环线程上、手里有完整的本轮消息 |
**不新增 callback**,也不改 react——三个点全是现成的。中立事件模型 + 每 run 的发射器。
零 AG-UI 概念(协议编码在 agui.codec),零 web 概念
(design-principles §2:本模块不认识 Request/Response/SSE 帧)。
三条契约(docs/agent-runtime-design.md §4.2):
:seq 在一个会话内单调递增、无洞——不是每 run 重置。HITL 下一次对话
由多个 run 组成(run → :run/paused → resume 起 run'),订阅挂在会话上跨
run 连续;seq 按 run 重置会让 :since 退化成 {run-id, seq} 二元组。
故 next-seq 由会话提供,发射器只管取号。finish! 是终态的唯一
出口且幂等——「已有终态就什么都不补」这条规则因此由构造保证,而不是靠检查。model.error),不是字符串。发射器自身永不抛(§6.3):callbacks/invoke 的吞异常语义对观察类回调是
对的,对事件流是错的——吞掉一次发射 = :seq 出洞 = 断线续传从此对不上,
而且不报错。所以这里反过来:sink 抛异常由发射器兜住并计数,
:seq 由发射器自己分配(不依赖 sink 的返回值),一次失败不影响后续编号。
中立事件模型 + 每 run 的发射器。
**零 AG-UI 概念**(协议编码在 `agui.codec`),**零 web 概念**
(design-principles §2:本模块不认识 Request/Response/SSE 帧)。
三条契约(docs/agent-runtime-design.md §4.2):
1. `:seq` 在一个**会话**内单调递增、无洞——不是每 run 重置。HITL 下一次对话
由多个 run 组成(run → `:run/paused` → resume 起 run'),订阅挂在会话上跨
run 连续;seq 按 run 重置会让 `:since` 退化成 `{run-id, seq}` 二元组。
故 `next-seq` 由会话提供,发射器只管取号。
2. **恰好一个终态事件**,且它是该 run 的最后一个事件。`finish!` 是终态的唯一
出口且幂等——「已有终态就什么都不补」这条规则因此由构造保证,而不是靠检查。
3. 错误值是 canonical error(`model.error`),不是字符串。
**发射器自身永不抛**(§6.3):`callbacks/invoke` 的吞异常语义对观察类回调是
对的,对事件流是错的——吞掉一次发射 = `:seq` 出洞 = 断线续传从此对不上,
而且不报错。所以这里反过来:sink 抛异常由**发射器**兜住并计数,
`:seq` 由发射器自己分配(不依赖 sink 的返回值),一次失败不影响后续编号。会话事件存储的可替换挂点(designs/agui-borrow.md §8;对照 Keelson
IAguiThreadEventStore / IAguiThreadSequenceRecorder 与卫星包
Keelson.Agui.Bitcask)。
本库只带一个实现:ring-store,即原来 runtime 内置的会话环形缓冲
(有界、进程内、不落库)。缺省行为与以前逐字相同。
为什么要有这个协议(2026-11-08 用户拍板):事件持久化本库不做——
docs/agent-runtime-design.md §8.1 / §8.2 的结论不变(对话真相在 ChatMemory,
暂停真相在 PauseStore,事件缓冲是传输设施)。但事件存储的逻辑要能整个换掉:
将来外部库(同 Keelson 的 Agui.Bitcask)实现本协议、经 runtime 的
:event-store 插进来,就能让 /threads/:id/events、/threads/:id/state 在
重启后仍有内容,:since / Last-Event-ID 跨进程续传。本库不需要为此改一行。
即便插了持久化实现,它仍然不是对话真相::since 补不上时 runtime 照旧以
ChatMemory 的消息快照 resync(§8.1),事件存储只是回放设施。
:seq 由 runtime 分配:会话内单调递增、跨 run 连续。存储按原样保存,
不改、不重排。es-append! 在会话锁内同步调用:要快,不许抛(抛了 runtime 记日志吞掉,
不打断 run,但这条事件就没进存储)。慢存储请自己做写缓冲。es-last-seq:会话新建条目时 runtime 用它接上序号(重启后 :seq 不从 0
重来——否则客户端拿着旧的 Last-Event-ID 会漏收)。没有记录返回 nil。es-since:返回 {:events [...] :gap? bool};since 为 nil 返回空且不报
gap;gap? 为真表示存储起点已越过 since,中间那段补不回来,runtime 会走 resync。es-release!:会话从 runtime 内存里被驱逐 / forget 时调。进程内实现借机
释放内存;持久化实现什么都不做(数据要留着给重启后的续传)。es-delete!:线程被删除时调(rt/purge!)。持久化实现在这里真删。事件是中立的 Clojure map(agui.event 的事件形状,含 :type :seq :ts 等),
EDN 可往返;持久化实现自己决定编码(EDN / Transit / JSON)。
会话事件存储的**可替换挂点**(designs/agui-borrow.md §8;对照 Keelson
`IAguiThreadEventStore` / `IAguiThreadSequenceRecorder` 与卫星包
`Keelson.Agui.Bitcask`)。
**本库只带一个实现**:`ring-store`,即原来 runtime 内置的会话环形缓冲
(有界、进程内、不落库)。缺省行为与以前逐字相同。
**为什么要有这个协议**(2026-11-08 用户拍板):事件持久化本库**不做**——
`docs/agent-runtime-design.md` §8.1 / §8.2 的结论不变(对话真相在 ChatMemory,
暂停真相在 PauseStore,事件缓冲是传输设施)。但事件存储的逻辑要**能整个换掉**:
将来外部库(同 Keelson 的 `Agui.Bitcask`)实现本协议、经 runtime 的
`:event-store` 插进来,就能让 `/threads/:id/events`、`/threads/:id/state` 在
重启后仍有内容,`:since` / Last-Event-ID 跨进程续传。本库不需要为此改一行。
即便插了持久化实现,它仍然**不是**对话真相:`:since` 补不上时 runtime 照旧以
ChatMemory 的消息快照 resync(§8.1),事件存储只是回放设施。
## 契约(实现方必须满足)
- **`:seq` 由 runtime 分配**:会话内单调递增、跨 run 连续。存储按原样保存,
不改、不重排。
- **`es-append!` 在会话锁内同步调用**:要快,**不许抛**(抛了 runtime 记日志吞掉,
不打断 run,但这条事件就没进存储)。慢存储请自己做写缓冲。
- **`es-last-seq`**:会话新建条目时 runtime 用它接上序号(重启后 `:seq` 不从 0
重来——否则客户端拿着旧的 Last-Event-ID 会漏收)。没有记录返回 nil。
- **`es-since`**:返回 `{:events [...] :gap? bool}`;`since` 为 nil 返回空且不报
gap;`gap?` 为真表示存储起点已越过 `since`,中间那段补不回来,runtime 会走 resync。
- **`es-release!`**:会话从 runtime 内存里被驱逐 / forget 时调。进程内实现借机
释放内存;持久化实现**什么都不做**(数据要留着给重启后的续传)。
- **`es-delete!`**:线程被删除时调(`rt/purge!`)。持久化实现在这里真删。
事件是中立的 Clojure map(`agui.event` 的事件形状,含 `:type :seq :ts` 等),
EDN 可往返;持久化实现自己决定编码(EDN / Transit / JSON)。MCP Apps —— 把「带 UI 资源的 MCP 工具」接进 AG-UI 的事件流与前端代理通道。
clj-agent-mcp分层与 genui 那次一致,判据也一样:协议认不认。
| 归谁 | 什么 |
|---|---|
clj-agent-mcp(独立模块) | JSON-RPC 信封、双时代握手、两种传输、tools/list、tools/call、工具接入 |
| 本 ns(agui) | 带 UI 的工具出结果时补一条 AG-UI activity 事件;前端那块 iframe 发起调用时的代理白名单 |
activity 消息、replace: true、forwardedProps.__proxiedMCPRequest 都是
CopilotKit 前端的约定,不是 MCP 协议的东西——所以它们在这儿,而不在协议模块里。
(require '[im.ttalk.agent.mcp.tools :as mcp-tools] ;; 协议侧:把工具接进来
'[im.ttalk.agent.agui.mcp :as agui-mcp]) ;; 前端侧:UI 那半边
(let [spec (mcp-tools/with-tools base-spec servers)
apps (agui-mcp/app-tools (:tools spec))]
(rt/runtime {:agent-fn (tools/agent-fn spec)
:event-transform (agui-mcp/event-transform {:apps apps :servers servers})}))
**MCP Apps** —— 把「带 UI 资源的 MCP 工具」接进 AG-UI 的事件流与前端代理通道。
## 这里**只剩前端约定**,协议栈在 `clj-agent-mcp`
分层与 genui 那次一致,判据也一样:**协议认不认**。
| 归谁 | 什么 |
|---|---|
| `clj-agent-mcp`(独立模块) | JSON-RPC 信封、双时代握手、两种传输、`tools/list`、`tools/call`、工具接入 |
| 本 ns(agui) | 带 UI 的工具出结果时补一条 **AG-UI activity 事件**;前端那块 iframe 发起调用时的**代理白名单** |
activity 消息、`replace: true`、`forwardedProps.__proxiedMCPRequest` 都是
**CopilotKit 前端**的约定,不是 MCP 协议的东西——所以它们在这儿,而不在协议模块里。
## 装
```clojure
(require '[im.ttalk.agent.mcp.tools :as mcp-tools] ;; 协议侧:把工具接进来
'[im.ttalk.agent.agui.mcp :as agui-mcp]) ;; 前端侧:UI 那半边
(let [spec (mcp-tools/with-tools base-spec servers)
apps (agui-mcp/app-tools (:tools spec))]
(rt/runtime {:agent-fn (tools/agent-fn spec)
:event-transform (agui-mcp/event-transform {:apps apps :servers servers})}))
```多 agent 注册表:agent id → runtime(designs/agui-borrow.md §4,对照 Keelson
AguiRegistry.cs)。
为什么要有:AG-UI 前端按 id 找 agent(/info 的 agents 是字典、请求路径是
/agent/:id/run),而一个服务挂多台 agent(便宜模型的「客服」+ 带敏感工具的
「运维」)是常规用法。没有这张表时路由只能写死一台,路径里的 id 被忽略——
请求 ops 跑的是 default,静默串台。
纯数据 + 函数,零 web 依赖(design-principles §2):找不到 agent 回 404 是 路由的事,这里只回 nil。
(def reg (registry [{:id "default" :runtime rt1 :description "通用助手"}
{:id "ops" :runtime rt2 :suggest? true}]))
(find-agent reg "ops") ;; => 条目 map
(stop! reg "ops" "t1") ;; => true / false
条目是开放 map:除下面这几个键,路由层自己的东西(服务端工具名、
/suggest 要的 agent 配置、入站插件…)照样放在条目里,本 ns 不解释。
:id 必填,非空字符串,全表唯一:runtime 内置驱动时必填,agui.runtime/runtime 的返回值(多台可共享一个)。
自带 run 驱动的 agent 不必塞占位 runtime::external? true 即可(run / stop / connect 由
clj-agent-server 的路由选项或宿主自己的处理器接管)。:external? true = 这台没有 runtime,只有名录与元数据;缺省 false:description 进 /info:suggest? 这台支持无状态 /suggest(/info.suggestions = 任一台支持):stop 可选 (fn [{:keys [runtime thread-id agent]}] bool)——这台自己
决定怎么停(例如拒绝停某些线程)。缺省 runtime/stop!,原因 :stop多 agent 注册表:agent id → runtime(designs/agui-borrow.md §4,对照 Keelson
`AguiRegistry.cs`)。
为什么要有:AG-UI 前端按 id 找 agent(`/info` 的 `agents` 是字典、请求路径是
`/agent/:id/run`),而一个服务挂多台 agent(便宜模型的「客服」+ 带敏感工具的
「运维」)是常规用法。没有这张表时路由只能写死一台,路径里的 id 被忽略——
请求 `ops` 跑的是 `default`,**静默串台**。
**纯数据 + 函数,零 web 依赖**(design-principles §2):找不到 agent 回 404 是
路由的事,这里只回 nil。
```clojure
(def reg (registry [{:id "default" :runtime rt1 :description "通用助手"}
{:id "ops" :runtime rt2 :suggest? true}]))
(find-agent reg "ops") ;; => 条目 map
(stop! reg "ops" "t1") ;; => true / false
```
条目是**开放 map**:除下面这几个键,路由层自己的东西(服务端工具名、
`/suggest` 要的 agent 配置、入站插件…)照样放在条目里,本 ns 不解释。
- `:id` **必填**,非空字符串,全表唯一
- `:runtime` 内置驱动时**必填**,`agui.runtime/runtime` 的返回值(多台可共享一个)。
自带 run 驱动的 agent 不必塞占位 runtime:`:external? true` 即可(run / stop / connect 由
clj-agent-server 的路由选项或宿主自己的处理器接管)。
- `:external?` true = 这台没有 runtime,只有名录与元数据;缺省 false
- `:description` 进 `/info`
- `:suggest?` 这台支持无状态 `/suggest`(`/info.suggestions` = 任一台支持)
- `:stop` 可选 `(fn [{:keys [runtime thread-id agent]}] bool)`——这台自己
决定怎么停(例如拒绝停某些线程)。缺省 `runtime/stop!`,原因 `:stop`Agent Runtime —— 会话注册表 + run 生命周期 + 事件订阅。
设计见 docs/agent-runtime-design.md。一句话:run 的寿命不再由调用方的寿命
决定。agent/chat-stream 把 token 直推调用方的 sink,sink 死了这轮的输出就
没了;这里 run 立刻起跑,事件进会话的环形缓冲再扇出给订阅者——断线重连凭
:since 补齐缺口,另一个请求线程凭 conversation-id 就能 stop / resume。
零 web 依赖(design-principles §2):订阅是回调(on-event + 返回退订
函数),与 on-token 同族。SSE 帧 / 路由 / 鉴权在 examples/copilotkit/。
本 ns 立的不变量(§3.3):
一个 conversation-id 恰好对应一个 agent 状态机,且同时至多一个 in-flight run。
这条今天靠 simple-agent docstring 里的警告维持(「同一个 agent 上不要并发多个
chat-async」),进了注册表就靠结构维持。
(def rt (runtime {:agent-fn (fn [{:keys [conversation-id tools]}]
(agent/create-agent {:provider p :memory store
:conversation-id conversation-id
:tools (into [#'my-tool] tools)}))}))
(subscribe rt "c1" {:on-event prn}) ;; => 退订函数
(start-run! rt "c1" "北京天气?") ;; => {:status :started :run-id ...}
(stop! rt "c1")
Agent Runtime —— 会话注册表 + run 生命周期 + 事件订阅。
设计见 docs/agent-runtime-design.md。一句话:**run 的寿命不再由调用方的寿命
决定**。`agent/chat-stream` 把 token 直推调用方的 sink,sink 死了这轮的输出就
没了;这里 run 立刻起跑,事件进会话的环形缓冲再扇出给订阅者——断线重连凭
`:since` 补齐缺口,另一个请求线程凭 conversation-id 就能 stop / resume。
**零 web 依赖**(design-principles §2):订阅是回调(`on-event` + 返回退订
函数),与 `on-token` 同族。SSE 帧 / 路由 / 鉴权在 examples/copilotkit/。
本 ns 立的不变量(§3.3):
> **一个 conversation-id 恰好对应一个 agent 状态机,且同时至多一个 in-flight run。**
这条今天靠 `simple-agent` docstring 里的警告维持(「同一个 agent 上不要并发多个
chat-async」),进了注册表就靠结构维持。
```clojure
(def rt (runtime {:agent-fn (fn [{:keys [conversation-id tools]}]
(agent/create-agent {:provider p :memory store
:conversation-id conversation-id
:tools (into [#'my-tool] tools)}))}))
(subscribe rt "c1" {:on-event prn}) ;; => 退订函数
(start-run! rt "c1" "北京天气?") ;; => {:status :started :run-id ...}
(stop! rt "c1")
```共享状态的 JSON Pointer / JSON Patch(RFC 6901 / 6902)与 delta 规范化。
纯函数,不认识发射器也不认识 AG-UI 事件——拿状态与 op 数组进来,算出新
状态与(可能被补过的)op 数组出去。事件那一侧在 agui.event。
为什么服务端非得自己算一遍:AG-UI 的共享状态是「一条快照 + 一串增量」,增量
打在客户端那份状态上。服务端每发一条 delta 都得同步在自己这边应用,否则
下一条 delta 就是对着一个想象中的状态算的。上游把这件事放在服务端解
(CopilotKit/packages/runtime/src/agent/state-delta.ts),我们照同一个口径。
normalize-ops 挡的是一个一个字都不报的静默失败(下游在另一台运行时上
实测到,见 feedbacks/2026-09-04-agui-no-shared-state-tools-or-delta.md):
客户端 state 还是 {} 时收到 add /todos/-——RFC 6902 要求 /todos 已存在,
必然失败,而客户端只保留旧状态 + console.warn,一个错都不报给用户。
「没发过快照就先发 delta」那条由 agui.event 在发射侧补(它才知道发过没有)。
模型写错了当作「这条 op 不适用」跳过,不抛——那是模型生成的字符串, 炸掉整条 run 比丢一条 op 糟得多。
共享状态的 JSON Pointer / JSON Patch(RFC 6901 / 6902)与 **delta 规范化**。
**纯函数,不认识发射器也不认识 AG-UI 事件**——拿状态与 op 数组进来,算出新
状态与(可能被补过的)op 数组出去。事件那一侧在 `agui.event`。
为什么服务端非得自己算一遍:AG-UI 的共享状态是「一条快照 + 一串增量」,增量
**打在客户端那份状态上**。服务端每发一条 delta 都得同步在自己这边应用,否则
下一条 delta 就是对着一个想象中的状态算的。上游把这件事放在服务端解
(`CopilotKit/packages/runtime/src/agent/state-delta.ts`),我们照同一个口径。
`normalize-ops` 挡的是一个**一个字都不报**的静默失败(下游在另一台运行时上
实测到,见 feedbacks/2026-09-04-agui-no-shared-state-tools-or-delta.md):
客户端 `state` 还是 `{}` 时收到 `add /todos/-`——RFC 6902 要求 `/todos` 已存在,
必然失败,而客户端**只保留旧状态 + `console.warn`**,一个错都不报给用户。
「没发过快照就先发 delta」那条由 `agui.event` 在发射侧补(它才知道发过没有)。
**模型写错了当作「这条 op 不适用」跳过,不抛**——那是模型生成的字符串,
炸掉整条 run 比丢一条 op 糟得多。把 subagent/manager 的协议无关观察钩子翻译成 lane 事件。
分层在这里合拢:clj-agent-client 那侧只知道「有四个可选钩子」
(manager 的 ns docstring),AG-UI 的知识一个字都没有;本 ns 负责给每次
spawn 现造一条 lane 的发射器,并把四个钩子接上去。
用法(用户在自己的 agent-fn 里):
(rt/runtime
{:subagent-events? true
:agent-fn (fn [{:keys [conversation-id tools subagent-observer]}]
(agent/create-agent
{:provider p
:tools (conj (vec tools)
(delegate/delegate-tool
{:name "deep_research"
:subagent-fn (fn [_ _] {:provider p})
:observer subagent-observer}))}))})
:subagent-observer 在开关关闭时是 nil,delegate-tool 于是走今天的老路径
——「不塞就什么都不会发生」是这条链路每一跳的性质(设计文档 §1.3)。
把 `subagent/manager` 的**协议无关观察钩子**翻译成 lane 事件。
分层在这里合拢:`clj-agent-client` 那侧只知道「有四个可选钩子」
(`manager` 的 ns docstring),AG-UI 的知识一个字都没有;本 ns 负责给每次
spawn 现造一条 lane 的发射器,并把四个钩子接上去。
用法(用户在自己的 `agent-fn` 里):
```clojure
(rt/runtime
{:subagent-events? true
:agent-fn (fn [{:keys [conversation-id tools subagent-observer]}]
(agent/create-agent
{:provider p
:tools (conj (vec tools)
(delegate/delegate-tool
{:name "deep_research"
:subagent-fn (fn [_ _] {:provider p})
:observer subagent-observer}))}))})
```
`:subagent-observer` 在开关关闭时是 **nil**,`delegate-tool` 于是走今天的老路径
——「不塞就什么都不会发生」是这条链路每一跳的性质(设计文档 §1.3)。线程元数据存储(designs/agui-borrow.md §6,对照 Keelson AguiThreadStore.cs /
InMemoryAguiThreadStore.cs):有哪些线程、叫什么、归没归档、属于哪台 agent、
什么时候建 / 动过。
为什么要有:/threads 原来直接列 runtime 的会话表——会话空闲被驱逐就从
列表里消失(历史明明还在 ChatMemory 里),名字与归档标记只活在进程内、重启
即失。ChatMemory 协议没有「列出所有会话」,用户自己补得写一张表再接进路由。
三样东西各管一件:本存储管「有哪些线程、叫什么」,ChatMemory 管「说了 什么」,runtime 管「现在在干嘛」。
⛔ 不存消息、不存事件(与 docs/agent-runtime-design.md §8.1 / §8.2 一致): 消息的真相在 ChatMemory,暂停的真相在 PauseStore,事件缓冲是传输设施。这张表 不是任何对话内容的副本,所以不构成第二真相源。Keelson 的进程内实现同时存 消息与事件——那一半我们不跟(要跟得先推翻 §8.2,待用户拍板)。
两个实现:本 ns 的 in-memory-store,以及按需加载的
im.ttalk.agent.agui.thread-store.sqlite(不 require 它就不碰 JDBC)。
记录形状(中立、毫秒时间):
{:id "t1" :agent-id "default" :name nil :archived? false
:created-at 1730000000000 :updated-at 1730000000000}
wire 形状(ISO 时间、organizationId 占位…)由路由翻译,本 ns 不出现。
线程**元数据**存储(designs/agui-borrow.md §6,对照 Keelson `AguiThreadStore.cs` /
`InMemoryAguiThreadStore.cs`):有哪些线程、叫什么、归没归档、属于哪台 agent、
什么时候建 / 动过。
为什么要有:`/threads` 原来直接列 runtime 的会话表——会话空闲被驱逐就**从
列表里消失**(历史明明还在 ChatMemory 里),名字与归档标记只活在进程内、重启
即失。`ChatMemory` 协议没有「列出所有会话」,用户自己补得写一张表再接进路由。
**三样东西各管一件**:本存储管「有哪些线程、叫什么」,ChatMemory 管「说了
什么」,runtime 管「现在在干嘛」。
⛔ **不存消息、不存事件**(与 docs/agent-runtime-design.md §8.1 / §8.2 一致):
消息的真相在 ChatMemory,暂停的真相在 PauseStore,事件缓冲是传输设施。这张表
不是任何对话内容的副本,所以不构成第二真相源。Keelson 的进程内实现同时存
消息与事件——那一半我们不跟(要跟得先推翻 §8.2,待用户拍板)。
两个实现:本 ns 的 `in-memory-store`,以及按需加载的
`im.ttalk.agent.agui.thread-store.sqlite`(不 require 它就不碰 JDBC)。
记录形状(中立、毫秒时间):
```clojure
{:id "t1" :agent-id "default" :name nil :archived? false
:created-at 1730000000000 :updated-at 1730000000000}
```
wire 形状(ISO 时间、`organizationId` 占位…)由路由翻译,本 ns 不出现。线程元数据存储 —— SQLite 后端(designs/agui-borrow.md §6)。按需加载: 不 require 本 ns 就不碰 JDBC。
零新依赖:next.jdbc / sqlite-jdbc 本来就是 clj-agent-client 的依赖
(im.ttalk.agent.memory.sqlite 在用),本模块依赖 client,传递可得。
可以与 memory.sqlite 共用一个库文件(表名不撞)——于是重启之后侧栏的线程
列表与点进去看到的历史出自同一个文件。
表结构: agui_threads(id TEXT PK, agent_id TEXT, name TEXT, archived INTEGER, created_at INTEGER, updated_at INTEGER)
⛔ 只存元数据,不存消息与事件(见 agui.thread-store 的 ns docstring)。
(require '[im.ttalk.agent.agui.thread-store.sqlite :as ts-sqlite])
(def store (ts-sqlite/sqlite-thread-store "agent.db"))
线程元数据存储 —— SQLite 后端(designs/agui-borrow.md §6)。**按需加载**:
不 require 本 ns 就不碰 JDBC。
**零新依赖**:`next.jdbc` / `sqlite-jdbc` 本来就是 `clj-agent-client` 的依赖
(`im.ttalk.agent.memory.sqlite` 在用),本模块依赖 client,传递可得。
可以与 `memory.sqlite` 共用一个库文件(表名不撞)——于是重启之后侧栏的线程
列表与点进去看到的历史出自同一个文件。
表结构:
agui_threads(id TEXT PK, agent_id TEXT, name TEXT, archived INTEGER,
created_at INTEGER, updated_at INTEGER)
⛔ 只存元数据,不存消息与事件(见 `agui.thread-store` 的 ns docstring)。
```clojure
(require '[im.ttalk.agent.agui.thread-store.sqlite :as ts-sqlite])
(def store (ts-sqlite/sqlite-thread-store "agent.db"))
```AG-UI 前端工具(client-side tool / CopilotKit useCopilotAction)。
零框架改动——三样都是现成的(docs/agent-runtime-design.md §7.2):
| AG-UI 要什么 | 我们已有的 |
|---|---|
| 前端 action 的 schema 进工具列表 | 内联工具 map::handler 之外的键原样就是发给模型的 schema(chat-client/build-chat-client) |
| 模型发出 tool call 后挂起、等前端 | gate 判 :pause → 暂停快照自动落 PauseStore |
| 前端执行完把结果送回 | resume-run! :reply {:message 结果}——「pending 工具不执行、载荷即其结果」正是 ask-user 语义 |
于是「AG-UI 前端工具」不是一套新机制,只是既有 HITL 词汇的一个用法。
前端标记走 metadata 而不是 map 里的键:那个 map 去掉 :handler 之后
整个发给模型,多一个 :agui/frontend 就是往 wire 上塞私货;metadata 不序列化。
AG-UI 前端工具(client-side tool / CopilotKit `useCopilotAction`)。
**零框架改动**——三样都是现成的(docs/agent-runtime-design.md §7.2):
| AG-UI 要什么 | 我们已有的 |
|---|---|
| 前端 action 的 schema 进工具列表 | **内联工具 map**:`:handler` 之外的键**原样就是**发给模型的 schema(`chat-client/build-chat-client`) |
| 模型发出 tool call 后挂起、等前端 | gate 判 `:pause` → 暂停快照自动落 PauseStore |
| 前端执行完把结果送回 | `resume-run! :reply {:message 结果}`——「pending 工具不执行、载荷即其结果」正是 ask-user 语义 |
于是「AG-UI 前端工具」不是一套新机制,只是**既有 HITL 词汇的一个用法**。
**前端标记走 metadata 而不是 map 里的键**:那个 map 去掉 `:handler` 之后
整个发给模型,多一个 `:agui/frontend` 就是往 wire 上塞私货;metadata 不序列化。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 |