http-kit 的垫片:让它也能发 Ring SPEC 的流式正文(ring.core.protocols/StreamableResponseBody)。
http-kit 2.8 的 adapter 不认识 SPEC 的流式正文(直接 500「not understandable」),惰性 seq / 管道 InputStream 正文
它也是攒满了才发(designs/server-module.md §1 的实测)。本中间件把这样的响应改走 http-kit 自己的 as-channel:
先发状态码与头,再在一条虚拟线程上 write-body-to-stream 到一个转发给 send! 的 OutputStream,每次 write 即推送。
(require '[org.httpkit.server :as hk]
'[im.ttalk.agent.server.adapter.http-kit :as hk-shim])
(hk/run-server (hk-shim/wrap-streaming handler) {:port 3000})
StreamableResponseBody 接口的正文(reify / deftype,如 im.ttalk.agent.server.sse 的 SSE 正文);
String、byte[]、File、InputStream、seq 这些 Ring 经 extend 支持的照旧交给 http-kit。不是 http-kit 发来的请求
(没有 :async-channel)原样放过。:on-close)⇒ 之后的写抛 IOException ⇒ SSE 流记 gone?、producer 据此收尾。
⚠️ 已知局限(http-kit 2.8 实测,2026-11-10):客户端断开后 http-kit 的 send! 仍回 true、open? 仍为 true、
:on-close 不触发(关套接字后连写 40 次、每次 8 KB 都「成功」)。所以在 http-kit 上 SSE 发现不了断连:流一直写到
producer 自己结束——AG-UI 的 run 照常跑完、/stop 照常可用,但 :orphan-cancel-ms(最后一个订阅者走了就按停)不会生效。
要断连检测请用 Jetty(缺省)或 Undertow。requiring-resolve 取 org.httpkit.server,用 http-kit 的人自己的 classpath 上有它。http-kit 的垫片:让它也能发 **Ring SPEC 的流式正文**(`ring.core.protocols/StreamableResponseBody`)。
http-kit 2.8 的 adapter 不认识 SPEC 的流式正文(直接 500「not understandable」),惰性 seq / 管道 InputStream 正文
它也是攒满了才发(designs/server-module.md §1 的实测)。本中间件把这样的响应改走 http-kit 自己的 `as-channel`:
先发状态码与头,再在一条虚拟线程上 `write-body-to-stream` 到一个转发给 `send!` 的 OutputStream,每次 write 即推送。
```clojure
(require '[org.httpkit.server :as hk]
'[im.ttalk.agent.server.adapter.http-kit :as hk-shim])
(hk/run-server (hk-shim/wrap-streaming handler) {:port 3000})
```
- 只改**直接实现** `StreamableResponseBody` 接口的正文(reify / deftype,如 `im.ttalk.agent.server.sse` 的 SSE 正文);
String、byte[]、File、InputStream、seq 这些 Ring 经 `extend` 支持的照旧交给 http-kit。不是 http-kit 发来的请求
(没有 `:async-channel`)原样放过。
- 对端断开(http-kit 的 `:on-close`)⇒ 之后的写抛 IOException ⇒ SSE 流记 `gone?`、producer 据此收尾。
⚠️ **已知局限**(http-kit 2.8 实测,2026-11-10):客户端断开后 http-kit 的 `send!` 仍回 true、`open?` 仍为 true、
`:on-close` 不触发(关套接字后连写 40 次、每次 8 KB 都「成功」)。所以在 http-kit 上 **SSE 发现不了断连**:流一直写到
producer 自己结束——AG-UI 的 run 照常跑完、`/stop` 照常可用,但 `:orphan-cancel-ms`(最后一个订阅者走了就按停)不会生效。
要断连检测请用 Jetty(缺省)或 Undertow。
- 不引 http-kit 的硬依赖:`requiring-resolve` 取 `org.httpkit.server`,用 http-kit 的人自己的 classpath 上有它。Undertow(luminus/ring-undertow-adapter)的垫片:让它也能发 Ring SPEC 的流式正文
(ring.core.protocols/StreamableResponseBody)。
luminus 的 adapter 不认识 SPEC 的流式正文(直接 500「Body class not supported」),InputStream / seq 正文又是
攒满了才发(designs/server-module.md §1 的实测)。它的正文分派是一个开放协议
ring.adapter.undertow.response/RespondBody——install! 给它补上 StreamableResponseBody 接口的实现:
离开 IO 线程、startBlocking、把 exchange 的 OutputStream 交给 write-body-to-stream(每次 flush 即推送)、收尾。
(require '[ring.adapter.undertow :refer [run-undertow]]
'[im.ttalk.agent.server.adapter.undertow :as ut-shim])
(ut-shim/install!) ;; 进程里调一次(幂等)
(run-undertow handler {:port 3000})
只影响直接实现该接口的正文(reify / deftype);String、InputStream 等照旧走 adapter 自己的实现。
不引 Undertow 的硬依赖:requiring-resolve 取协议,用 Undertow 的人自己的 classpath 上有它。
Undertow(`luminus/ring-undertow-adapter`)的垫片:让它也能发 **Ring SPEC 的流式正文**
(`ring.core.protocols/StreamableResponseBody`)。
luminus 的 adapter 不认识 SPEC 的流式正文(直接 500「Body class not supported」),InputStream / seq 正文又是
攒满了才发(designs/server-module.md §1 的实测)。它的正文分派是一个**开放协议**
`ring.adapter.undertow.response/RespondBody`——`install!` 给它补上 `StreamableResponseBody` 接口的实现:
离开 IO 线程、`startBlocking`、把 exchange 的 OutputStream 交给 `write-body-to-stream`(每次 flush 即推送)、收尾。
```clojure
(require '[ring.adapter.undertow :refer [run-undertow]]
'[im.ttalk.agent.server.adapter.undertow :as ut-shim])
(ut-shim/install!) ;; 进程里调一次(幂等)
(run-undertow handler {:port 3000})
```
只影响**直接实现**该接口的正文(reify / deftype);String、InputStream 等照旧走 adapter 自己的实现。
不引 Undertow 的硬依赖:`requiring-resolve` 取协议,用 Undertow 的人自己的 classpath 上有它。AG-UI 的 HTTP 端(C# Keelson.Agui.Server 的 AguiEndpoints;keel agui/http.ss + server-http.ss):CopilotKit runtime 顶替那一层
要的几条路,挂在任意前缀下。全是 Ring:routes 是 clj-net-server 的路由表(handler 直接给一个 Ring 处理器),
SSE 是流式正文(StreamableResponseBody)—— 不认识 Jetty;clj-net-server 的宿主(Jetty 12)或任何 Ring 服务端都能挂。
run / 注册表 / 编解码 / 线程存储用上游 clj-agent-agui —— C# 的 Keelson.Agui(AguiRegistry、AguiRun、RunAgentInput …)
由它顶替,不重移植。
GET {prefix}/info 名录与能力位
POST {prefix}/agent/:id/run 纯 AG-UI 的 SSE
POST {prefix}/agent/:id/connect 只订阅、不起 run:重放在飞 / 刚跑完那一轮,跟到底;没有 ⇒ 200 空流
POST {prefix}/agent/:id/suggest 「猜你想问」,无状态(条目 :suggest? + :agent-spec 才有,不然 404)
POST {prefix}/agent/:id/stop/:threadId 按停(那条 RUN_ERROR code=STOPPED 在响应体里,⛔ 不发进流)
POST {prefix} 单路由退路:{"method": …, "params": {…}, "body": …}
OPTIONS 任意 预检(永远 204;真正的闸在真请求那一次)
—— 下面七条只在给了 :thread-store 时存在 ——
GET {prefix}/threads {"threads":[…],"nextCursor":…}(查询串原样交给存储)
GET {prefix}/threads/:id/messages {"messages":[…]}
GET {prefix}/threads/:id/events {"events":[…]}
GET {prefix}/threads/:id/state {"state":…}
PATCH {prefix}/threads/:id 那条线程记录(⛔ 不套信封)
POST {prefix}/threads/:id/archive {"threadId":…,"archived":true}
DELETE {prefix}/threads/:id {"threadId":…,"deleted":true}(⚠️ 带 JSON 体)
认不出的路径:任何方法都 404 not_found(⛔ 不是 405)。CORS 缺省拒(见 im.ttalk.agent.server.agui.http)。
线程那几条:存储缺那个槽 ⇒ 501 not_implemented;没这条线程 ⇒ 404 thread_not_found;方法不对 ⇒ 405 带 Allow;
改动类的体不是对象 / 缺 agentId ⇒ 400;存储抛了 ⇒ 500 thread_store_error。…/threads/subscribe 与 …/threads/clear 照旧 404。
(def h (host/host reg {:version "1.0.0" :origin-allowed (ahttp/origin-allow "http://localhost:3000")}))
(def s (net/start (agui/routes "/api/copilotkit" h) {:port 3000})) ; im.ttalk.agent.server.jetty(Jetty)
;; 或别的 Ring 服务端:(agui/handler "/api/copilotkit" h)
routes 返回 clj-net-server 的路由表,元数据里带停机钩子(停机时按停所有在飞的 run,原因 :shutdown)。
注册表条目(clj-agent-agui registry/registry)在 :id :runtime :description :suggest? :stop 之外还认:
:agent-spec /suggest 要现建一次性 agent 的那份 create-agent 配置(无状态:临时 memory、不进注册表)
:memory 这台的 ChatMemory(线程存储的适配器读 /threads/:id/messages)
:server-tool-names 服务端工具名(前端同名声明 = 渲染 / 审批意图,见 im.ttalk.agent.server.agui.resume)
:input-transform 入站插件:(fn [body] 输入),把 RunAgentInput 解析结果改一笔再起跑(如 clj-agent-agui 的 a2ui/input-transform)
:mcp-proxy MCP Apps 的前端代理:(fn [proxied-request] result)。/run 的 forwardedProps.__proxiedMCPRequest
不是一次 run(前端 iframe 里的 MCP App 要调 server)——不起 agent,回一对 RUN_STARTED /
RUN_FINISHED{result}(上游 CopilotKit 的做法;如 clj-agent-agui 的 mcp/proxy-request)
这两格也可以放在 host 选项里当缺省值,条目里给了就以条目为准。
自带 run 驱动的 agent:注册表条目只要 {:id "x" :external? true :description …}(⛔ 不塞占位 runtime);routes 的
host 选项收 :agents {"x" {:run ring-handler :stop ring-handler :connect-source source}}。run / stop 是普通 Ring 处理器
(Origin / CORS 已在外层处理,单路由退路也认它们;体超限由库先答 413,不交驱动);source 照 (thread-id after-seq deliver) 调,deliver 吃 AG-UI 事件 map
或已编帧字节,先写帧环再送本连接,交付终端事件后流自动收口。source 可回一个清理函数。
与 C# 的差别(详见 tasks/J-03.md):被按停的 run 在流里以 RUN_FINISHED result.status=cancelled(带 reason)收口
——clj-agent-agui 的契约是「恰好一个终态」,C# 是什么都不再发;/connect 的 lastSeq 续读(IAguiThreadEventStore /
序号记录器)不做;对端断开要靠心跳的那一次写才发现(Ring 的流式正文 / Jetty 的阻塞输出不报告空闲时的断连)。
AG-UI 的 HTTP 端(C# Keelson.Agui.Server 的 AguiEndpoints;keel agui/http.ss + server-http.ss):CopilotKit runtime 顶替那一层
要的几条路,挂在任意前缀下。**全是 Ring**:`routes` 是 clj-net-server 的路由表(`handler` 直接给一个 Ring 处理器),
SSE 是流式正文(StreamableResponseBody)—— 不认识 Jetty;clj-net-server 的宿主(Jetty 12)或任何 Ring 服务端都能挂。
run / 注册表 / 编解码 / 线程存储用上游 clj-agent-agui —— C# 的 Keelson.Agui(AguiRegistry、AguiRun、RunAgentInput …)
由它顶替,不重移植。
```
GET {prefix}/info 名录与能力位
POST {prefix}/agent/:id/run 纯 AG-UI 的 SSE
POST {prefix}/agent/:id/connect 只订阅、不起 run:重放在飞 / 刚跑完那一轮,跟到底;没有 ⇒ 200 空流
POST {prefix}/agent/:id/suggest 「猜你想问」,无状态(条目 :suggest? + :agent-spec 才有,不然 404)
POST {prefix}/agent/:id/stop/:threadId 按停(那条 RUN_ERROR code=STOPPED 在响应体里,⛔ 不发进流)
POST {prefix} 单路由退路:{"method": …, "params": {…}, "body": …}
OPTIONS 任意 预检(永远 204;真正的闸在真请求那一次)
—— 下面七条只在给了 :thread-store 时存在 ——
GET {prefix}/threads {"threads":[…],"nextCursor":…}(查询串原样交给存储)
GET {prefix}/threads/:id/messages {"messages":[…]}
GET {prefix}/threads/:id/events {"events":[…]}
GET {prefix}/threads/:id/state {"state":…}
PATCH {prefix}/threads/:id 那条线程记录(⛔ 不套信封)
POST {prefix}/threads/:id/archive {"threadId":…,"archived":true}
DELETE {prefix}/threads/:id {"threadId":…,"deleted":true}(⚠️ 带 JSON 体)
```
认不出的路径:任何方法都 404 `not_found`(⛔ 不是 405)。CORS 缺省**拒**(见 im.ttalk.agent.server.agui.http)。
线程那几条:存储缺那个槽 ⇒ 501 `not_implemented`;没这条线程 ⇒ 404 `thread_not_found`;方法不对 ⇒ 405 带 `Allow`;
改动类的体不是对象 / 缺 agentId ⇒ 400;存储抛了 ⇒ 500 `thread_store_error`。…/threads/subscribe 与 …/threads/clear 照旧 404。
```clojure
(def h (host/host reg {:version "1.0.0" :origin-allowed (ahttp/origin-allow "http://localhost:3000")}))
(def s (net/start (agui/routes "/api/copilotkit" h) {:port 3000})) ; im.ttalk.agent.server.jetty(Jetty)
;; 或别的 Ring 服务端:(agui/handler "/api/copilotkit" h)
```
`routes` 返回 clj-net-server 的路由表,元数据里带停机钩子(停机时按停所有在飞的 run,原因 :shutdown)。
注册表条目(clj-agent-agui `registry/registry`)在 :id :runtime :description :suggest? :stop 之外还认:
:agent-spec `/suggest` 要现建一次性 agent 的那份 create-agent 配置(无状态:临时 memory、不进注册表)
:memory 这台的 ChatMemory(线程存储的适配器读 /threads/:id/messages)
:server-tool-names 服务端工具名(前端同名声明 = 渲染 / 审批意图,见 im.ttalk.agent.server.agui.resume)
:input-transform 入站插件:(fn [body] 输入),把 RunAgentInput 解析结果改一笔再起跑(如 clj-agent-agui 的 `a2ui/input-transform`)
:mcp-proxy MCP Apps 的前端代理:(fn [proxied-request] result)。`/run` 的 `forwardedProps.__proxiedMCPRequest`
不是一次 run(前端 iframe 里的 MCP App 要调 server)——不起 agent,回一对 RUN_STARTED /
RUN_FINISHED{result}(上游 CopilotKit 的做法;如 clj-agent-agui 的 `mcp/proxy-request`)
这两格也可以放在 host 选项里当缺省值,条目里给了就以条目为准。
自带 run 驱动的 agent:注册表条目只要 `{:id "x" :external? true :description …}`(⛔ 不塞占位 runtime);`routes` 的
host 选项收 `:agents {"x" {:run ring-handler :stop ring-handler :connect-source source}}`。run / stop 是普通 Ring 处理器
(Origin / CORS 已在外层处理,单路由退路也认它们;体超限由库先答 413,不交驱动);source 照 `(thread-id after-seq deliver)` 调,deliver 吃 AG-UI 事件 map
或已编帧字节,先写帧环再送本连接,交付终端事件后流自动收口。source 可回一个清理函数。
**与 C# 的差别**(详见 tasks/J-03.md):被按停的 run 在流里以 `RUN_FINISHED result.status=cancelled`(带 reason)收口
——clj-agent-agui 的契约是「恰好一个终态」,C# 是什么都不再发;`/connect` 的 lastSeq 续读(IAguiThreadEventStore /
序号记录器)不做;对端断开要靠心跳的那一次写才发现(Ring 的流式正文 / Jetty 的阻塞输出不报告空闲时的断连)。成品服务端的运行期(C# AguiHost;keel server-http.ss N3):run 归服务端驱动,连接只是订阅者。
run 本身由 clj-agent-agui 的 runtime 跑(会话注册表、取消、HITL 都在那边);这里给每台经 /run 起的 run 挂一只泵
(一个 runtime 订阅):把中立事件编成 AG-UI 帧,进帧环(给 /connect,见 im.ttalk.agent.server.agui.replay)与直连队列
(给起 run 的那条连接),喂线程存储的 :record 钩子。⚠️ 断连 ≠ 按停(刷新也是断连,而刷新正是 /connect 要救的场景);
按停的正门是 /stop/:threadId。
选项(C# AguiRegistryOptions 里服务端用得上的那几格 + AguiServerOptions):
:version 进 /info(缺省 "0.0.0")
:origin-allowed Origin 白名单谓词;缺省 nil = 拒(带 Origin 的请求 403)。im.ttalk.agent.server.agui.http/origin-allow
:credentials? 发 Access-Control-Allow-Credentials: true
:allow-headers 宿主自己的请求头,并进预检的 Allow-Headers
:a2ui? :open-generative-ui? /info 的两格布尔
:thread-store 线程存储:六槽 map 或 clj-agent-agui 的 ThreadStore(见 im.ttalk.agent.server.agui.thread-store);给了才有 /threads*
:heartbeat-ms SSE 心跳(缺省 15000;0 = 关)。⚠️ 不是可有可无:空闲超时的代理会把不出声的流掐掉;JVM 上它还是发现断连的唯一途径
:heartbeat-frame (fn [] 字节);缺省注释帧 : clj-agent-alive。/run、/connect、/suggest 的流都用它
:replay-max-bytes 帧环一轮的字节上限(缺省 4 MiB;超了 ⇒ 这一轮 /connect 回空流)
:replay-keep-ms 跑完之后留多久给迟到的 /connect(缺省 5 分钟)
:replay-max-threads 帧环最多几条线程(缺省 256)
:replay? 帧环攒不攒(缺省 true;false ⇒ /connect 对任何线程 200 空流,/run 照旧)
:orphan-cancel-ms 最后一个订阅者走了之后过这么久没人来就按停(原因 :orphan;缺省 0 = 关)
:max-body-bytes 请求体上限(缺省 16 MiB;超了 413)
:now (fn [] ms),可注入
:info 其余交给 clj-agent-agui codec/run-info 的能力位(:multimodal :parallel-tools? :state? …)
:input-transform 入站插件的缺省值(条目里给了就以条目为准;见 im.ttalk.agent.server.agui 的注册表条目键)
:mcp-proxy MCP Apps 前端代理的缺省值(同上)
:agents 按 agent 注入外部驱动的缺省值(routes 也能再并一份;键同下面的注册表条目键)
同线程上一轮还在飞时再来 /run(C# AguiRegistryOptions.ConcurrentRun)由那台 agent 的 runtime 决定:
rt/runtime 的 :on-concurrent :reject(缺省,⇒ 409 thread_running)或 :supersede(旧的以 :superseded 取消)。
成品服务端的运行期(C# AguiHost;keel server-http.ss N3):**run 归服务端驱动,连接只是订阅者**。 run 本身由 clj-agent-agui 的 runtime 跑(会话注册表、取消、HITL 都在那边);这里给每台经 `/run` 起的 run 挂一只**泵** (一个 runtime 订阅):把中立事件编成 AG-UI 帧,进帧环(给 `/connect`,见 im.ttalk.agent.server.agui.replay)与直连队列 (给起 run 的那条连接),喂线程存储的 :record 钩子。⚠️ 断连 ≠ 按停(刷新也是断连,而刷新正是 `/connect` 要救的场景); 按停的正门是 `/stop/:threadId`。 选项(C# AguiRegistryOptions 里服务端用得上的那几格 + AguiServerOptions): :version 进 /info(缺省 "0.0.0") :origin-allowed Origin 白名单谓词;缺省 nil = **拒**(带 Origin 的请求 403)。im.ttalk.agent.server.agui.http/origin-allow :credentials? 发 Access-Control-Allow-Credentials: true :allow-headers 宿主自己的请求头,并进预检的 Allow-Headers :a2ui? :open-generative-ui? /info 的两格布尔 :thread-store 线程存储:六槽 map 或 clj-agent-agui 的 ThreadStore(见 im.ttalk.agent.server.agui.thread-store);给了才有 /threads* :heartbeat-ms SSE 心跳(缺省 15000;0 = 关)。⚠️ 不是可有可无:空闲超时的代理会把不出声的流掐掉;JVM 上它还是发现断连的唯一途径 :heartbeat-frame (fn [] 字节);缺省注释帧 `: clj-agent-alive`。/run、/connect、/suggest 的流都用它 :replay-max-bytes 帧环一轮的字节上限(缺省 4 MiB;超了 ⇒ 这一轮 /connect 回空流) :replay-keep-ms 跑完之后留多久给迟到的 /connect(缺省 5 分钟) :replay-max-threads 帧环最多几条线程(缺省 256) :replay? 帧环攒不攒(缺省 true;false ⇒ /connect 对任何线程 200 空流,/run 照旧) :orphan-cancel-ms 最后一个订阅者走了之后过这么久没人来就按停(原因 :orphan;缺省 0 = 关) :max-body-bytes 请求体上限(缺省 16 MiB;超了 413) :now (fn [] ms),可注入 :info 其余交给 clj-agent-agui `codec/run-info` 的能力位(:multimodal :parallel-tools? :state? …) :input-transform 入站插件的缺省值(条目里给了就以条目为准;见 im.ttalk.agent.server.agui 的注册表条目键) :mcp-proxy MCP Apps 前端代理的缺省值(同上) :agents 按 agent 注入外部驱动的缺省值(routes 也能再并一份;键同下面的注册表条目键) 同线程上一轮还在飞时再来 `/run`(C# AguiRegistryOptions.ConcurrentRun)由那台 agent 的 runtime 决定: `rt/runtime` 的 `:on-concurrent :reject`(缺省,⇒ 409 thread_running)或 `:supersede`(旧的以 :superseded 取消)。
routes 每条路外面那一层(C# AguiHttp,A-16 公开):预检、Origin 闸、CORS 回声,外加 JSON / 错误出口 —— 全是 Ring
(请求 map 进、响应 map 出)。宿主在自己补的路上用它,⛔ 不用照抄 CORS:
(conj (agui/routes "/agui" host)
["POST" "/agui/agent/mine/stop/:threadId"
(fn [req] (ahttp/guard req host (fn [req] (ahttp/json-response 200 {:stopped true}))))])
与 clj-net-server 的访问守卫(im.ttalk.agent.server.guard)是两层:守卫在路由之前(Host → 预检豁免 → token); 这里在路由之后,只管浏览器那一侧的 Origin / CORS。预检到这里永远 204(403 的预检在浏览器里只报一句 CORS error)。
CORS 缺省拒:带 Origin 的请求一律 403(host 选项 :origin-allowed;用 origin-allow 列白名单,开发时 origin-any)。
`routes` 每条路外面那一层(C# AguiHttp,A-16 公开):预检、Origin 闸、CORS 回声,外加 JSON / 错误出口 —— 全是 Ring
(请求 map 进、响应 map 出)。宿主在自己补的路上用它,⛔ 不用照抄 CORS:
```clojure
(conj (agui/routes "/agui" host)
["POST" "/agui/agent/mine/stop/:threadId"
(fn [req] (ahttp/guard req host (fn [req] (ahttp/json-response 200 {:stopped true}))))])
```
与 clj-net-server 的访问守卫(im.ttalk.agent.server.guard)是两层:守卫在路由之前(Host → 预检豁免 → token);
这里在路由之后,只管浏览器那一侧的 Origin / CORS。预检到这里永远 204(403 的预检在浏览器里只报一句 CORS error)。
CORS 缺省**拒**:带 Origin 的请求一律 403(host 选项 :origin-allowed;用 `origin-allow` 列白名单,开发时 `origin-any`)。AG-UI 的 run 输入(C# Keelson.Agui 的 RunAgentInput.Decode):只做形状校验,码与文案同 C#;解析成什么交给
clj-agent-agui 的 codec/parse-run-input(历史取服务端权威、只取最后一条 user 消息 …)。
错误是 ex-info::im.ttalk.agent.server/code = C# 的资源键(agui-input/…),:agui/wire-code = 线上的种类码
(bad_json / bad_input),路由据此回 400 {"error": wire-code, "message": …}。
AG-UI 的 run 输入(C# Keelson.Agui 的 RunAgentInput.Decode):只做**形状**校验,码与文案同 C#;解析成什么交给
clj-agent-agui 的 `codec/parse-run-input`(历史取服务端权威、只取最后一条 user 消息 …)。
错误是 ex-info:`:im.ttalk.agent.server/code` = C# 的资源键(`agui-input/…`),`:agui/wire-code` = 线上的种类码
(`bad_json` / `bad_input`),路由据此回 400 `{"error": wire-code, "message": …}`。/connect 的帧环(C# Keelson.Agui 的 AguiReplay;keel agui/replay.ss,designs/agui-connect.md N2):按线程一只有界缓冲,
只装这一轮从 RUN_STARTED 起编好的帧(字节)。是传输缓冲不是会话存储:跑完留 keep-ms 给迟到的 /connect,
新一轮开始就覆盖。
为什么不直接用 clj-agent-agui 的事件存储(:event-store / subscribe :since):那是按条数有界、跨 run 连续的会话环,
没有 C# 契约要的三样 —— 按字节封顶且超了整轮作废(truncated ⇒ /connect 回空流,⛔ 不发半截)、跑完后按时长保留、
线程数 LRU 驱逐。那三样是小东西,就在这里补上。
超 max-bytes ⇒ 这一轮标 :truncated、清空、不再攒。线程数超 max-threads ⇒ 驱逐没在跑的里最久没碰的(全在跑时宁可多一条)。
时间由调用方传进来(毫秒)。线程安全(一把锁)。:enabled? false ⇒ 空转:不建条目、不进帧、open-cursor 恒 nil
(⇒ /connect 对任何线程回 200 空流)。
`/connect` 的帧环(C# Keelson.Agui 的 AguiReplay;keel agui/replay.ss,designs/agui-connect.md N2):按线程一只有界缓冲, 只装**这一轮**从 RUN_STARTED 起编好的帧(字节)。是**传输**缓冲不是会话存储:跑完留 keep-ms 给迟到的 `/connect`, 新一轮开始就覆盖。 为什么不直接用 clj-agent-agui 的事件存储(`:event-store` / `subscribe :since`):那是按**条数**有界、跨 run 连续的会话环, 没有 C# 契约要的三样 —— 按**字节**封顶且超了整轮作废(truncated ⇒ `/connect` 回空流,⛔ 不发半截)、跑完后按时长保留、 线程数 LRU 驱逐。那三样是小东西,就在这里补上。 超 max-bytes ⇒ 这一轮标 :truncated、清空、不再攒。线程数超 max-threads ⇒ 驱逐**没在跑的**里最久没碰的(全在跑时宁可多一条)。 时间由调用方传进来(毫秒)。线程安全(一把锁)。`:enabled? false` ⇒ 空转:不建条目、不进帧、`open-cursor` 恒 nil (⇒ `/connect` 对任何线程回 200 空流)。
/run 起跑前的那一段:新 run 还是续跑(interrupt 协议的 resume[]、前端工具的结果、审批答复)。
逻辑最初出自 clj-agent 的 examples/copilotkit/http_kit_routes.clj(handle-run* / resume-plan / resume-decision …);2026-11-10 起
那份示例改为调用本模块,这里是唯一的一份。
文案走本模块的 i18n。C# 那边这一段在 ChatAguiAgent 里(Keelson.Agui),协议层只管路由。
start! 返回 runtime 的结果(rt/start-run! / rt/resume-run!:{:status :started :run-id :since}、:busy、
:awaiting-resume、:not-paused),或 {:status :resume-error :error [status code extra]}(在起 run 之前判掉的坏答复)。
`/run` 起跑前的那一段:新 run 还是续跑(interrupt 协议的 `resume[]`、前端工具的结果、审批答复)。
逻辑最初出自 clj-agent 的 examples/copilotkit/http_kit_routes.clj(handle-run* / resume-plan / resume-decision …);2026-11-10 起
那份示例改为调用本模块,这里是唯一的一份。
文案走本模块的 i18n。C# 那边这一段在 ChatAguiAgent 里(Keelson.Agui),协议层只管路由。
`start!` 返回 runtime 的结果(`rt/start-run!` / `rt/resume-run!`:`{:status :started :run-id :since}`、`:busy`、
`:awaiting-resume`、`:not-paused`),或 `{:status :resume-error :error [status code extra]}`(在起 run 之前判掉的坏答复)。…/threads* 那七条路背后的存储(C# Keelson.Agui 的 IAguiThreadStore / AguiThreadStore 委托版)。
契约是 C# 的六个可选槽,在这里写成一个 fn 的 map —— 给了哪个键就亮哪个槽,没给的那几条路回 501:
:list (fn [query] …) GET /threads。query = 查询串原样 {"agentId" "echo" "limit" "2"}(值全是串,同名取第一个)。 回线程记录的序列 ⇒ 补成 {threads, nextCursor: null};回带 :threads 的 map ⇒ 原样出(缺 nextCursor 补 null) :messages (fn [thread-id] …) GET /threads/:id/messages ⇒ {messages};nil = 没这条线程(404) :events (fn [thread-id] …) GET /threads/:id/events ⇒ {events};nil = 没这条线程 :state (fn [thread-id] …) GET /threads/:id/state。回 nil = 没这条线程(404);回 {:state v} = 有(v 可以是 nil ⇒ {"state":null}) :update (fn [thread-id agent-id updates] …) PATCH /threads/:id(改名 / 取消归档)与 POST /threads/:id/archive(updates = {:archived true})。 updates 已去掉 agentId / userId。回改过的那条线程记录(PATCH ⇒ 裸的出);nil = 没这条线程 :delete (fn [thread-id agent-id] …) DELETE /threads/:id。false = 没这条线程
外加一个可选钩子(C# IAguiThreadRecorder):
:record (fn [agent-id input agui-event]) 每台 run 的每条 AG-UI 事件(RUN_STARTED 是第一条,input 是那台的 RunAgentInput)。
在泵上同步调:要快;抛了吞掉(⛔ 不打断 run)。/suggest 不喂
抛异常 ⇒ 500 thread_store_error,带着异常的话(⛔ 不吞)。
接 clj-agent-agui:它的 thread-store/ThreadStore 只管元数据(三个槽:list / update / delete),消息在 ChatMemory、事件在
runtime 的事件存储 —— from-clj-agent 把这三样拼成上面的六槽 map(host 收到一个 ThreadStore 时自动套上)。
`…/threads*` 那七条路背后的存储(C# Keelson.Agui 的 IAguiThreadStore / AguiThreadStore 委托版)。
**契约是 C# 的六个可选槽**,在这里写成一个 fn 的 map —— 给了哪个键就亮哪个槽,没给的那几条路回 501:
:list (fn [query] …) GET /threads。query = 查询串原样 {"agentId" "echo" "limit" "2"}(值全是串,同名取第一个)。
回线程记录的序列 ⇒ 补成 {threads, nextCursor: null};回带 :threads 的 map ⇒ 原样出(缺 nextCursor 补 null)
:messages (fn [thread-id] …) GET /threads/:id/messages ⇒ {messages};nil = 没这条线程(404)
:events (fn [thread-id] …) GET /threads/:id/events ⇒ {events};nil = 没这条线程
:state (fn [thread-id] …) GET /threads/:id/state。回 nil = 没这条线程(404);回 {:state v} = 有(v 可以是 nil ⇒ {"state":null})
:update (fn [thread-id agent-id updates] …) PATCH /threads/:id(改名 / 取消归档)与 POST /threads/:id/archive(updates = {:archived true})。
updates 已去掉 agentId / userId。回改过的那条线程记录(PATCH ⇒ **裸的**出);nil = 没这条线程
:delete (fn [thread-id agent-id] …) DELETE /threads/:id。false = 没这条线程
外加一个可选钩子(C# IAguiThreadRecorder):
:record (fn [agent-id input agui-event]) 每台 run 的每条 AG-UI 事件(RUN_STARTED 是第一条,input 是那台的 RunAgentInput)。
在泵上同步调:要快;抛了吞掉(⛔ 不打断 run)。`/suggest` 不喂
抛异常 ⇒ 500 `thread_store_error`,带着异常的话(⛔ 不吞)。
**接 clj-agent-agui**:它的 `thread-store/ThreadStore` 只管元数据(三个槽:list / update / delete),消息在 ChatMemory、事件在
runtime 的事件存储 —— `from-clj-agent` 把这三样拼成上面的六槽 map(host 收到一个 ThreadStore 时自动套上)。本机访问守卫:token + Host 头(C# AccessGuard,N-05;语义同锦书 AccessGuard ≙ jinshu2 (jinshu httpd guard))。纯判定 + 中间件。
wrap-access-guard 是 Ring 中间件(不认识具体服务端)。选项(map;经 im.ttalk.agent.server.jetty 的 :guard 挂到最前面,或自己包):
:token 要求的 token;nil / 空串 = 不鉴权(只做 Host 校验,若开了的话)
:loopback-host-only? Host 头只认回环(localhost、127.0.0.0/8、::1,带不带端口、IPv6 带方括号都认)—— 挡 DNS rebinding。
⚠️ 要经局域网 IP / 域名访问的部署别开
:protected (fn [path] bool),哪些路径要 token(参数是解码后的路径,如 "/rpc")。缺省全部
:preflight-exempt (fn [req] bool),免 token 的请求(在 token 之前、Host 之后判)。缺省:方法是 OPTIONS(CORS 预检)
:query-name query 里带 token 的参数名(EventSource 不能自定义头)。缺省 "t";显式 nil = 不认这条带法
:header-name 带 token 的请求头名。缺省 "X-Token";显式 nil = 不认
:accept-bearer? 认 Authorization: Bearer <token>(scheme 大小写不敏感)。缺省 true
判定次序(evaluate):① :loopback-host-only? ⇒ Host 不是回环 ⇒ 403(所有请求,含静态资源与预检 —— DNS rebinding 的请求
带着对的 token 也拒);② :preflight-exempt ⇒ 放行;③ token 非空且 :protected ⇒ 三种带法里有一种对得上才放行,否则 401。
本机访问守卫:token + Host 头(C# AccessGuard,N-05;语义同锦书 AccessGuard ≙ jinshu2 (jinshu httpd guard))。纯判定 + 中间件。
`wrap-access-guard` 是 Ring 中间件(不认识具体服务端)。选项(map;经 im.ttalk.agent.server.jetty 的 `:guard` 挂到最前面,或自己包):
:token 要求的 token;nil / 空串 = 不鉴权(只做 Host 校验,若开了的话)
:loopback-host-only? Host 头只认回环(localhost、127.0.0.0/8、::1,带不带端口、IPv6 带方括号都认)—— 挡 DNS rebinding。
⚠️ 要经局域网 IP / 域名访问的部署别开
:protected (fn [path] bool),哪些路径要 token(参数是解码后的路径,如 "/rpc")。缺省全部
:preflight-exempt (fn [req] bool),免 token 的请求(在 token 之前、Host 之后判)。缺省:方法是 OPTIONS(CORS 预检)
:query-name query 里带 token 的参数名(EventSource 不能自定义头)。缺省 "t";显式 nil = 不认这条带法
:header-name 带 token 的请求头名。缺省 "X-Token";显式 nil = 不认
:accept-bearer? 认 `Authorization: Bearer <token>`(scheme 大小写不敏感)。缺省 true
判定次序(`evaluate`):① :loopback-host-only? ⇒ Host 不是回环 ⇒ 403(**所有**请求,含静态资源与预检 —— DNS rebinding 的请求
带着对的 token 也拒);② :preflight-exempt ⇒ 放行;③ token 非空且 :protected ⇒ 三种带法里有一种对得上才放行,否则 401。Ring 请求 / 响应的小工具(C# 里 ASP.NET 的 HttpContext 那一侧)。只认 Ring 规范(请求 map / 响应 map),不认识任何具体的 服务端 —— 同一个处理器挂在 Jetty(im.ttalk.agent.server.jetty)或别的 Ring 服务端(http-kit …)上都行。
请求就是 Ring 的请求 map(:request-method :uri :query-string :headers(小写名):body)。本模块另认几个键:
:path-params 路由参数 {:id "…"}(已解码;im.ttalk.agent.server.routing 填)
:im.ttalk.agent.server/body-limit 请求体上限(wrap-body-limit 填;read-body 守它)
:im.ttalk.agent.server/stopping 服务端停机时完成的 CompletableFuture(宿主填;没有 = 宿主不报停机)
:im.ttalk.agent.server/open-streams 宿主的「开着的长连接」表(SSE / WebSocket 登记进去,停机时逐个中止)
响应就是 Ring 的响应 map;response / json-response / text-response 造它。
Ring 请求 / 响应的小工具(C# 里 ASP.NET 的 HttpContext 那一侧)。**只认 Ring 规范**(请求 map / 响应 map),不认识任何具体的
服务端 —— 同一个处理器挂在 Jetty(im.ttalk.agent.server.jetty)或别的 Ring 服务端(http-kit …)上都行。
请求就是 Ring 的请求 map(:request-method :uri :query-string :headers(小写名):body)。本模块另认几个键:
:path-params 路由参数 {:id "…"}(已解码;im.ttalk.agent.server.routing 填)
:im.ttalk.agent.server/body-limit 请求体上限(`wrap-body-limit` 填;`read-body` 守它)
:im.ttalk.agent.server/stopping 服务端停机时完成的 CompletableFuture(宿主填;没有 = 宿主不报停机)
:im.ttalk.agent.server/open-streams 宿主的「开着的长连接」表(SSE / WebSocket 登记进去,停机时逐个中止)
响应就是 Ring 的响应 map;`response` / `json-response` / `text-response` 造它。MCP 的 Streamable HTTP 端点——Ring 处理器(designs/server-module.md §4)。协议在 clj-agent-mcp:
im.ttalk.agent.mcp.server/handle-message(纯函数,一条进一条出)与 check-headers(HTTP 头与信封一致性);
这里只做 HTTP 那一层。转正自 examples/mcp/server_example.clj 的 http-kit 接线。
(require '[im.ttalk.agent.mcp.server :as srv]
'[im.ttalk.agent.server.mcp :as mcp-http])
(def s (srv/server {:name "demo" :version "1.0" :tools [...]}))
(mcp-http/handler s {:origin-allowed nil}) ;; 一个 Ring 处理器,交给任何 Ring 服务端
(mcp-http/routes "/mcp" s opts) ;; 或 im.ttalk.agent.server.routing 的路由表
POST:读体(受 :max-body-bytes,超了 413)→ 解析 JSON(坏 ⇒ 400 -32700;JSON 数组 ⇒ 400 -32600,批量自
2025-06-18 起已从协议删除)→ check-headers(头与信封不一致 ⇒ 400 -32020)→ handle-message → 有响应 ⇒ JSON,
校验类错误(-32602 -32020 -32021 -32022)400、其余 200(客户端的双时代探测看状态码);通知 ⇒ 202 空体(协议禁止给
通知回响应)。handle-message 意外抛出 ⇒ 500 -32603(不把异常文本吐给客户端)。GET / DELETE / 其他 ⇒ 405 带 Allow: POST, OPTIONS:本端点不开服务端推送流、也没有会话(规范允许)。OPTIONS ⇒ 预检 204。:origin-allowed 里 ⇒ 403(规范要求服务端校验 Origin 防 DNS rebinding);
不带 Origin(非浏览器客户端)放行。白名单用 im.ttalk.agent.server.agui.http/origin-allow。选项::origin-allowed(Origin 谓词;缺省 nil = 拒所有带 Origin 的):credentials?(回 Allow-Credentials)
:allow-headers(预检额外放行的请求头):max-body-bytes(缺省 4 MiB)。
MCP 的 Streamable HTTP 端点——**Ring 处理器**(designs/server-module.md §4)。协议在 clj-agent-mcp:
`im.ttalk.agent.mcp.server/handle-message`(纯函数,一条进一条出)与 `check-headers`(HTTP 头与信封一致性);
这里只做 HTTP 那一层。转正自 examples/mcp/server_example.clj 的 http-kit 接线。
```clojure
(require '[im.ttalk.agent.mcp.server :as srv]
'[im.ttalk.agent.server.mcp :as mcp-http])
(def s (srv/server {:name "demo" :version "1.0" :tools [...]}))
(mcp-http/handler s {:origin-allowed nil}) ;; 一个 Ring 处理器,交给任何 Ring 服务端
(mcp-http/routes "/mcp" s opts) ;; 或 im.ttalk.agent.server.routing 的路由表
```
- `POST`:读体(受 `:max-body-bytes`,超了 413)→ 解析 JSON(坏 ⇒ 400 `-32700`;JSON 数组 ⇒ 400 `-32600`,批量自
2025-06-18 起已从协议删除)→ `check-headers`(头与信封不一致 ⇒ 400 `-32020`)→ `handle-message` → 有响应 ⇒ JSON,
校验类错误(`-32602 -32020 -32021 -32022`)400、其余 200(客户端的双时代探测看状态码);通知 ⇒ 202 空体(协议禁止给
通知回响应)。`handle-message` 意外抛出 ⇒ 500 `-32603`(不把异常文本吐给客户端)。
- `GET` / `DELETE` / 其他 ⇒ 405 带 `Allow: POST, OPTIONS`:本端点不开服务端推送流、也没有会话(规范允许)。
- `OPTIONS` ⇒ 预检 204。
- **Origin 缺省拒**:带 Origin 且不在 `:origin-allowed` 里 ⇒ 403(规范要求服务端校验 Origin 防 DNS rebinding);
不带 Origin(非浏览器客户端)放行。白名单用 `im.ttalk.agent.server.agui.http/origin-allow`。
- 只回 JSON、不回 SSE:一条请求一条响应,没有要推的中间消息(规范允许服务端选 JSON)。
选项:`:origin-allowed`(Origin 谓词;缺省 nil = 拒所有带 Origin 的)`:credentials?`(回 Allow-Credentials)
`:allow-headers`(预检额外放行的请求头)`:max-body-bytes`(缺省 4 MiB)。路由(C# 里 ASP.NET 的 MapGet / MapPost / Map / MapGroup 那一层,够 Agui.Server 用的大小):路由表 ⇒ 一个 Ring 处理器。
为什么不用 reitit:它对「字面量段 vs 参数段」的冲突要么装配期报错、要么退成按登记顺序的线性匹配,而 C# 的契约是按具体度选
(宿主后登记的 agent/mine/stop/:id 要胜过先登记的 agent/:id/stop/:id),还要「路径中、方法不中 ⇒ 405 + Allow,
兜底段(任何方法)在时 ⇒ 不是 405」。这一百来行比把 reitit 的冲突解析与缺省处理器拧成这个样子更直接。
一条路 = [方法 模式 处理器]:
方法 :any | "GET" | #{"GET" "HEAD"}(大写)
模式 "/agent/:id/run":字面量段、:名 参数段(匹配一段,值已解码)、末尾 *名 兜底段(匹配零或多段,值是余下的
路径,段间以 / 连接)。字面量段大小写不敏感(ASP.NET 同);末尾的 / 不计("/a/" 与 "/a" 是同一条)。
处理器 Ring 处理器 (fn [req] 响应),req 带 :path-params。返回 nil ⇒ 200 空正文(ASP.NET 同:处理器什么都没写)。
选路(ASP.NET 的口径):路径匹配的那些里挑最具体的 —— 逐段比,字面量 < 参数 < 兜底,同等时先登记的胜;方法不中的跳过。
路径中了而方法一条都不中 ⇒ 405 + Allow;路径都不中 ⇒ 404(正文为空)。
产出的处理器两种元数都有(Ring 异步的 3 元:respond / raise 在同一线程上调)。
路由(C# 里 ASP.NET 的 MapGet / MapPost / Map / MapGroup 那一层,够 Agui.Server 用的大小):路由表 ⇒ 一个 **Ring 处理器**。
为什么不用 reitit:它对「字面量段 vs 参数段」的冲突要么装配期报错、要么退成按登记顺序的线性匹配,而 C# 的契约是**按具体度**选
(宿主后登记的 `agent/mine/stop/:id` 要胜过先登记的 `agent/:id/stop/:id`),还要「路径中、方法不中 ⇒ 405 + Allow,
兜底段(任何方法)在时 ⇒ 不是 405」。这一百来行比把 reitit 的冲突解析与缺省处理器拧成这个样子更直接。
一条路 = `[方法 模式 处理器]`:
方法 :any | "GET" | #{"GET" "HEAD"}(大写)
模式 "/agent/:id/run":字面量段、`:名` 参数段(匹配一段,值已解码)、末尾 `*名` 兜底段(匹配零或多段,值是余下的
路径,段间以 / 连接)。字面量段大小写不敏感(ASP.NET 同);末尾的 / 不计("/a/" 与 "/a" 是同一条)。
处理器 Ring 处理器 (fn [req] 响应),req 带 :path-params。返回 nil ⇒ 200 空正文(ASP.NET 同:处理器什么都没写)。
选路(ASP.NET 的口径):路径匹配的那些里挑**最具体**的 —— 逐段比,字面量 < 参数 < 兜底,同等时先登记的胜;方法不中的跳过。
路径中了而方法一条都不中 ⇒ 405 + `Allow`;路径都不中 ⇒ 404(正文为空)。
产出的处理器两种元数都有(Ring 异步的 3 元:respond / raise 在同一线程上调)。SSE 响应(C# SseResponse / SseStream),Ring 的写法:处理器返回 (response req opts producer) —— 一个 Ring 响应 map,
正文是 ring.core.protocols/StreamableResponseBody;服务端写正文时调 (producer s),producer 在那条线程上往流里写帧
(每帧冲一次),返回即收尾。帧由 im.ttalk.net.sse(event-frame / comment-frame)或调用方自己拼好。
流(producer 收到的 s):写入串行化(心跳与业务写入互斥);写失败(对端走了)不抛,记为 gone?,后续写入直接丢弃 ——
调用方据此退订。aborted 是一个 CompletableFuture:对端断开(在写的那一刻发现)、服务端停机(宿主经请求里的
:im.ttalk.agent.server/open-streams 通知)或 close! 时完成。
⚠️ 对端断开只在写的时候发现:Jetty 的阻塞式 servlet 输出在不写的时候不报告连接已断(Ring 的正文协议也没有这个通道), 所以闲着的流靠心跳(:heartbeat-ms)发现断连。C# 的 RequestAborted 还盯着套接字的读端。
SSE 响应(C# SseResponse / SseStream),**Ring 的写法**:处理器返回 `(response req opts producer)` —— 一个 Ring 响应 map, 正文是 `ring.core.protocols/StreamableResponseBody`;服务端写正文时调 `(producer s)`,producer 在那条线程上往流里写帧 (每帧冲一次),返回即收尾。帧由 im.ttalk.net.sse(`event-frame` / `comment-frame`)或调用方自己拼好。 流(producer 收到的 `s`):写入串行化(心跳与业务写入互斥);写失败(对端走了)不抛,记为 `gone?`,后续写入直接丢弃 —— 调用方据此退订。`aborted` 是一个 CompletableFuture:对端断开(在写的那一刻发现)、服务端停机(宿主经请求里的 :im.ttalk.agent.server/open-streams 通知)或 `close!` 时完成。 ⚠️ 对端断开**只在写的时候**发现:Jetty 的阻塞式 servlet 输出在不写的时候不报告连接已断(Ring 的正文协议也没有这个通道), 所以闲着的流靠心跳(:heartbeat-ms)发现断连。C# 的 RequestAborted 还盯着套接字的读端。
clj-agent-server 的文案与错误。
(msg k & args) ⇒ {:code "区/事件" :text "渲染后的话"}({0} {1} … 占位);(error k args data) ⇒ ex-info,
ex-data 带 :im.ttalk.agent.server/code(= :code)。码与文案移植自 Keelson(clj-net-server / clj-agui-server 由 C# resx
生成的中文文案包,提交 5899776c);线上的错误种类码(bad_json、thread_not_found …)不在这里,它们是 AG-UI 的线协议。
clj-agent-server 的文案与错误。
`(msg k & args)` ⇒ `{:code "区/事件" :text "渲染后的话"}`(`{0}` `{1}` … 占位);`(error k args data)` ⇒ ex-info,
ex-data 带 `:im.ttalk.agent.server/code`(= :code)。码与文案移植自 Keelson(`clj-net-server` / `clj-agui-server` 由 C# resx
生成的中文文案包,提交 5899776c);线上的错误种类码(`bad_json`、`thread_not_found` …)不在这里,它们是 AG-UI 的线协议。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 |