Liking cljdoc? Tell your friends :D

im.ttalk.agent.agui.a2ui

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)})

后台干什么

  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 一致)。
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` 一致)。
raw docstring

im.ttalk.agent.agui.codec

中立事件 ⇄ 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 字符串**不是对象——这类地方猜错了前端不会报错,只会静默
少渲染一块。
raw docstring

im.ttalk.agent.agui.emit

接线:把 agent 跑起来时已经有的观察点接到发射器上。

三个采集点,各自有不可替代的理由(docs/agent-runtime-design.md §4.9):

采集点拿到什么为什么非它不可
:on-token正文 / 思维 token 增量唯一的 token 粒度出口
:on-llm-result callbackChatResponse——含 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——三个点全是现成的。
raw docstring

im.ttalk.agent.agui.event

中立事件模型 + 每 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 的返回值),一次失败不影响后续编号。

中立事件模型 + 每 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 的返回值),一次失败不影响后续编号。
raw docstring

im.ttalk.agent.agui.event-store

会话事件存储的可替换挂点(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)。
raw docstring

im.ttalk.agent.agui.mcp

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})}))
```
raw docstring

im.ttalk.agent.agui.registry

多 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`
raw docstring

im.ttalk.agent.agui.runtime

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")
```
raw docstring

im.ttalk.agent.agui.state

共享状态的 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 糟得多。
raw docstring

im.ttalk.agent.agui.subagent

把 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)。
raw docstring

im.ttalk.agent.agui.thread-store

线程元数据存储(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 不出现。
raw docstring

im.ttalk.agent.agui.thread-store.sqlite

线程元数据存储 —— 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"))
```
raw docstring

im.ttalk.agent.agui.tools

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 不序列化。
raw docstring

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close