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")
```(awaiting rt conv-id)会话是否在等 resume(暂停中)。返回 {:run-id :reason :pending-tool :pending-tools}
或 nil。:pending-tools 是同一批里所有被叫停的调用(单条时就是 [pending-tool])。
会话是否在等 resume(暂停中)。返回 `{:run-id :reason :pending-tool :pending-tools}`
或 nil。`:pending-tools` 是同一批里所有被叫停的调用(单条时就是 `[pending-tool]`)。(buffered-events rt conv-id)会话在事件存储里现存的事件(只读)。
缺省存储是有界的环形缓冲(:buffer-size,缺省 512 条)且不落库——它是为
断线续传服务的,不是事件日志;想要完整历史找 ChatMemory(§8.2)。插了持久化的
:event-store 时,这里返回该存储保留的部分(会话不在内存里也能读)。
/threads/:id/events 这类只读端点照实返回它。
会话在事件存储里**现存**的事件(只读)。 缺省存储是**有界**的环形缓冲(`:buffer-size`,缺省 512 条)且**不落库**——它是为 断线续传服务的,不是事件日志;想要完整历史找 ChatMemory(§8.2)。插了持久化的 `:event-store` 时,这里返回该存储保留的部分(会话不在内存里也能读)。 `/threads/:id/events` 这类只读端点照实返回它。
(forget! rt conv-id)把一个会话从注册表里摘掉(不动 ChatMemory——历史归历史)。
在跑的 run 先停掉:不停的话它收尾时还会往一个没人再读的缓冲里写。
把一个会话从注册表里摘掉(**不动 ChatMemory**——历史归历史)。 在跑的 run 先停掉:不停的话它收尾时还会往一个没人再读的缓冲里写。
(purge! rt conv-id)删除线程时用:forget! 之外再让事件存储真删(es-delete!)。
缺省环形缓冲上与 forget! 等价;持久化存储上,forget! 只是腾内存、数据还在,
purge! 才把这条线程的事件删掉。同样不动 ChatMemory(那由调用方决定)。
删除线程时用:`forget!` 之外再让事件存储**真删**(`es-delete!`)。 缺省环形缓冲上与 `forget!` 等价;持久化存储上,`forget!` 只是腾内存、数据还在, `purge!` 才把这条线程的事件删掉。同样不动 ChatMemory(那由调用方决定)。
(resume-run! rt conv-id decision)(resume-run! rt conv-id decision payload)(resume-run! rt conv-id decision payload {:keys [tools] :as opts})凭 conversation-id 恢复暂停的会话——这就是跨请求 HITL:审批按钮是另一个 HTTP 请求,它手上只有 conv-id,没有 agent 对象。
decision / payload 语义与 agent/resume 逐字相同(approved / rejected /
reply / retry / proceed,见 docs/hitl-timeline-design.md §2)。
前端工具(AG-UI client-side tool)走的也是这条路::reply + {:message 结果}
——「pending 工具不执行、载荷即其结果」正是 ask-user 语义(§7.2)。
凭 conversation-id 恢复暂停的会话——**这就是跨请求 HITL**:审批按钮是另一个
HTTP 请求,它手上只有 conv-id,没有 agent 对象。
`decision` / `payload` 语义与 `agent/resume` 逐字相同(approved / rejected /
reply / retry / proceed,见 docs/hitl-timeline-design.md §2)。
前端工具(AG-UI client-side tool)走的也是这条路:`:reply` + `{:message 结果}`
——「pending 工具不执行、载荷即其结果」正是 ask-user 语义(§7.2)。(run-detached! {:keys [agent message on-event opts run-id now]})在一个临时发射器上跑一次 agent 调用——不进注册表:没有会话条目、 没有环形缓冲、没有订阅表、没有 stop / resume / 暂停。
为什么要有这条:CopilotKit 的 /suggest(「猜用户下一句想说什么」)是一次
不留痕的 run。它刻意不走 runner——runner 会按 threadId 写线程表,于是
一次一次性的建议把一堆废线程留在那儿。这里同样:事件直接给 on-event,
跑完就没了。
agent 由调用方给(它决定这次用哪份配置、落不落 memory——策略不该埋在
库里)。返回 CompletableFuture,完成值是 agent 的结果。
:message 这一轮的用户输入:on-event (fn [中立事件]):opts 透传给 chat-async(:tool-choice / :max-iterations …):run-id 可选,缺省现生成在一个**临时发射器**上跑一次 agent 调用——**不进注册表**:没有会话条目、 没有环形缓冲、没有订阅表、没有 stop / resume / 暂停。 为什么要有这条:CopilotKit 的 `/suggest`(「猜用户下一句想说什么」)是一次 **不留痕**的 run。它刻意不走 runner——runner 会按 threadId 写线程表,于是 一次一次性的建议把一堆废线程留在那儿。这里同样:事件直接给 `on-event`, 跑完就没了。 `agent` 由调用方给(**它决定这次用哪份配置、落不落 memory**——策略不该埋在 库里)。返回 `CompletableFuture`,完成值是 agent 的结果。 - `:message` 这一轮的用户输入 - `:on-event` (fn [中立事件]) - `:opts` 透传给 `chat-async`(`:tool-choice` / `:max-iterations` …) - `:run-id` 可选,缺省现生成
(runtime {:keys [agent-fn] :as opts})构造运行时。显式值,无全局单例——CopilotKit 的 in-memory store 是进程全局 的,为此得写一段 warning 解释「你把所有会话的 bound 改了」(§8.4);这里想建 几个建几个。
:agent-fn 必填 (fn [{:keys [conversation-id tools]}] agent)。
:tools 是本 run 追加的内联工具(AG-UI 前端 action),
没有则为 []。同一 conversation-id 的 agent 会被
runtime 换上会话级 state-atom,故每次现建也不丢暂停态。:on-concurrent :reject(缺省)| :supersede(§4.4):buffer-size 会话事件环形缓冲条数(缺省 512);只在没给 :event-store 时生效:event-store 事件存储(agui.event-store/EventStore),缺省
(es/ring-store {:buffer-size ..})——即原来的进程内环形缓冲。
可替换挂点:外部库实现该协议即可持久化事件(本库不带持久化
实现,见该 ns 文档与 designs/agui-borrow.md §8):idle-ttl-ms 空闲会话驱逐(缺省 30min;有 run / 有订阅者 / 暂停中的不驱逐):supersede-wait-ms supersede 时等旧 run 收尾的上限(缺省 5s):orphan-cancel-ms 最后一个订阅者走后过这么久仍没人来,就以 :orphan 原因
停掉在跑的 run(缺省 0 = 关,与上游同:没人看的 run
照样跑完——断线 ≠ 停止,刷新正是 :since 要救的场景)。
给「烧 token 的长 run 没人看就止损」的部署方
(designs/agui-borrow.md §5):now (fn [] ms),可注入(测试):state-tools? 给模型两把写共享状态的工具(缺省 false)。开了之后
agui.tools/state-tools 的 AGUISendStateSnapshot /
AGUISendStateDelta 进每个 run 的工具表,模型于是写得动
共享状态。缺省关:它改变模型看见的工具列表,装配方
该显式点头;/info 的 capabilities.state 跟着报:subagent-events? 子 agent lane 的事件(缺省 false)。开了之后
:agent-fn 会多收到一个 :subagent-observer,把它交给
delegate-tool 的 :observer,委派期间的 token / 工具调用
就带着 subagentRunId 进事件流
(docs/subagent-event-attribution-design.md)。
缺省关不是保守:@ag-ui/client ≤ 0.0.57 在 HTTP
transport 里校验事件的 discriminated union,一条
SUBAGENT_STARTED 掐断整条流,客户端侧救不回来。:event-transform (fn [{:keys [run-id conversation-id]}] (fn [event] events)),
可选。事件流插件的挂点(见 agui.event/emitter 的
:transform):每个 run 现造一个有状态的 transform。
agui.a2ui/event-transform(本模块)与
copilotkit.genui/event-transform(examples)各是现成的一个构造运行时。**显式值,无全局单例**——CopilotKit 的 in-memory store 是进程全局
的,为此得写一段 warning 解释「你把所有会话的 bound 改了」(§8.4);这里想建
几个建几个。
- `:agent-fn` **必填** `(fn [{:keys [conversation-id tools]}] agent)`。
`:tools` 是本 run 追加的内联工具(AG-UI 前端 action),
没有则为 `[]`。同一 conversation-id 的 agent 会被
runtime 换上会话级 state-atom,故每次现建也不丢暂停态。
- `:on-concurrent` `:reject`(缺省)| `:supersede`(§4.4)
- `:buffer-size` 会话事件环形缓冲条数(缺省 512);只在没给 `:event-store` 时生效
- `:event-store` 事件存储(`agui.event-store/EventStore`),缺省
`(es/ring-store {:buffer-size ..})`——即原来的进程内环形缓冲。
**可替换挂点**:外部库实现该协议即可持久化事件(本库不带持久化
实现,见该 ns 文档与 designs/agui-borrow.md §8)
- `:idle-ttl-ms` 空闲会话驱逐(缺省 30min;有 run / 有订阅者 / 暂停中的不驱逐)
- `:supersede-wait-ms` supersede 时等旧 run 收尾的上限(缺省 5s)
- `:orphan-cancel-ms` 最后一个订阅者走后过这么久仍没人来,就以 `:orphan` 原因
停掉在跑的 run(缺省 **0 = 关**,与上游同:没人看的 run
照样跑完——断线 ≠ 停止,刷新正是 `:since` 要救的场景)。
给「烧 token 的长 run 没人看就止损」的部署方
(designs/agui-borrow.md §5)
- `:now` `(fn [] ms)`,可注入(测试)
- `:state-tools?` 给模型两把写共享状态的工具(缺省 **false**)。开了之后
`agui.tools/state-tools` 的 `AGUISendStateSnapshot` /
`AGUISendStateDelta` 进每个 run 的工具表,模型于是写得动
共享状态。**缺省关**:它改变模型看见的工具列表,装配方
该显式点头;`/info` 的 `capabilities.state` 跟着报
- `:subagent-events?` 子 agent lane 的事件(缺省 **false**)。开了之后
`:agent-fn` 会多收到一个 `:subagent-observer`,把它交给
`delegate-tool` 的 `:observer`,委派期间的 token / 工具调用
就带着 `subagentRunId` 进事件流
(docs/subagent-event-attribution-design.md)。
**缺省关不是保守**:`@ag-ui/client` ≤ 0.0.57 在 HTTP
transport 里校验事件的 discriminated union,一条
`SUBAGENT_STARTED` 掐断整条流,客户端侧救不回来。
- `:event-transform` `(fn [{:keys [run-id conversation-id]}] (fn [event] events))`,
可选。**事件流插件的挂点**(见 `agui.event/emitter` 的
`:transform`):每个 run 现造一个有状态的 transform。
`agui.a2ui/event-transform`(本模块)与
`copilotkit.genui/event-transform`(examples)各是现成的一个(shutdown! rt)停掉所有 in-flight run、等它们收尾(有上限)、关掉所有订阅者、清表。
等不到的不硬等::shutdown-wait-ms 到点就走人——事件流的终态由 finish-run!
在各自线程上照常补,只是我们不再等它。
停掉所有 in-flight run、等它们收尾(有上限)、关掉所有订阅者、清表。 等不到的不硬等:`:shutdown-wait-ms` 到点就走人——事件流的终态由 `finish-run!` 在各自线程上照常补,只是我们不再等它。
(start-run! rt conv-id message)(start-run! rt
conv-id
message
{:keys [tools discard-pause? parent-run-id state] :as opts})起一个新 run。立刻返回,run 在后台跑。
返回 {:status :started :run-id ... :since <起跑前的 seq 水位>},或:
{:status :busy :run-id <在跑的那个>}(:reject 策略下已有 in-flight run){:status :awaiting-resume :pending-tool ... :reason ...}——会话暂停中,
先 resume-run!。这里刻意不沿用 simple-agent 的 cancel-pending!
语义(新 turn 静默丢弃未 resume 的暂停):web 场景下「用户没点审批就又发
了一句」应当显式暴露,静默丢一次审批是隐蔽的语义损失(§4.4)。:since 是给 HTTP 层用的:run 立刻起跑并发出 :run/started,而你要等本
函数返回才能 subscribe。拿返回的 :since 去订阅,那一段真空就补齐了——
这正是会话级单调 :seq 的用处(§4.2 契约 1)。
:discard-pause? —— 显式丢弃会话上那个没人答复的暂停,直接起新 run。
给 AG-UI 前端工具用:客户端把工具塞在 RunAgentInput.tools 里,模型调了它,
会话就停在那儿等结果——而客户端可能永远不回(CopilotKit 的 suggestions
就是这样:它只读 TOOL_CALL_ARGS,从不回结果)。那种情况下用户下一句话到达
时,正确的做法是丢掉它继续,而不是把会话永远卡住。缺省仍是拒绝(§4.4):
人工审批被静默丢掉是隐蔽的语义损失,要丢就显式说。
:state —— RunAgentInput.state,作为本 run 共享状态的基线装上(不发事件,
客户端本来就有这份)。后续 delta 对着它算,服务端与客户端才不会各算各的。
:parent-run-id —— 调用方声明「这一轮挂在哪条 run 下面」。我们不解释它
(不建父子索引、不做级联停止),只把它挂到 :run/started 上原样发出去:
AG-UI 的 RUN_STARTED.parentRunId 就是这么个用途,客户端拿它把这条 run 接回
父链。协议里它是可选的,没有就没有。
opts 直通 agent/chat-async(:system-prompt / :max-iterations / :tool-choice …);
:tools 是本 run 追加的内联工具,会交给 :agent-fn。
起一个新 run。**立刻返回**,run 在后台跑。
返回 `{:status :started :run-id ... :since <起跑前的 seq 水位>}`,或:
- `{:status :busy :run-id <在跑的那个>}`(`:reject` 策略下已有 in-flight run)
- `{:status :awaiting-resume :pending-tool ... :reason ...}`——会话暂停中,
先 `resume-run!`。**这里刻意不沿用 `simple-agent` 的 `cancel-pending!`
语义**(新 turn 静默丢弃未 resume 的暂停):web 场景下「用户没点审批就又发
了一句」应当显式暴露,静默丢一次审批是隐蔽的语义损失(§4.4)。
**`:since` 是给 HTTP 层用的**:run 立刻起跑并发出 `:run/started`,而你要等本
函数返回才能 `subscribe`。拿返回的 `:since` 去订阅,那一段真空就补齐了——
这正是会话级单调 `:seq` 的用处(§4.2 契约 1)。
`:discard-pause?` —— **显式丢弃**会话上那个没人答复的暂停,直接起新 run。
给 AG-UI 前端工具用:客户端把工具塞在 `RunAgentInput.tools` 里,模型调了它,
会话就停在那儿等结果——**而客户端可能永远不回**(CopilotKit 的 suggestions
就是这样:它只读 `TOOL_CALL_ARGS`,从不回结果)。那种情况下用户下一句话到达
时,正确的做法是丢掉它继续,而不是把会话永远卡住。**缺省仍是拒绝**(§4.4):
人工审批被静默丢掉是隐蔽的语义损失,要丢就显式说。
`:state` —— `RunAgentInput.state`,作为本 run 共享状态的**基线**装上(不发事件,
客户端本来就有这份)。后续 delta 对着它算,服务端与客户端才不会各算各的。
`:parent-run-id` —— 调用方声明「这一轮挂在哪条 run 下面」。我们**不解释**它
(不建父子索引、不做级联停止),只把它挂到 `:run/started` 上原样发出去:
AG-UI 的 `RUN_STARTED.parentRunId` 就是这么个用途,客户端拿它把这条 run 接回
父链。协议里它是可选的,没有就没有。
`opts` 直通 `agent/chat-async`(`:system-prompt` / `:max-iterations` / `:tool-choice` …);
`:tools` 是本 run 追加的内联工具,会交给 `:agent-fn`。(stop! rt conv-id)(stop! rt conv-id run-id)(stop! rt conv-id run-id reason)请求停止。返回 true 只表示「取消已登记」,不表示「已经停了」——JVM 上没有
抢占原语,取消 = 放弃等待 + 协作式中断:provider 流式循环下次检查时退出、
react 循环在轮边界不再续跑,已经在跑的工具会跑完(§4.8,口径同
docs/tool-timeout-design.md)。停稳的信号是 :run/cancelled 事件。
传 run-id 则只停这一个 run,绝不误伤会话上更新的那个。
reason(缺省 :stop)落在 :run/cancelled 的 :reason 上,见
event/stop-reasons。第一个原因算数。
请求停止。**返回 true 只表示「取消已登记」,不表示「已经停了」**——JVM 上没有 抢占原语,取消 = 放弃等待 + 协作式中断:provider 流式循环下次检查时退出、 react 循环在轮边界不再续跑,**已经在跑的工具会跑完**(§4.8,口径同 docs/tool-timeout-design.md)。停稳的信号是 `:run/cancelled` 事件。 传 `run-id` 则只停这一个 run,绝不误伤会话上更新的那个。 `reason`(缺省 `:stop`)落在 `:run/cancelled` 的 `:reason` 上,见 `event/stop-reasons`。第一个原因算数。
(subscribe rt conv-id {:keys [since on-event on-close]})订阅一个会话的事件流。返回退订函数。
:since 上次收到的 :seq(会话级单调,跨 run 连续)。缺省 nil = 只收新事件。:on-event (fn [event])——必须立刻返回(往你的 sink 里 put 一下)。
抛异常即被摘除。:on-close (fn [reason]),可选(退订 / shutdown! 时触发)。:since 早于缓冲起点时先发一条 :run/resync(带 ChatMemory 里的完整消息
快照)再接 live tail——折叠后的真相 ChatMemory 里本来就有,不在事件侧再造
一份(§8.1:不建第二个真相店,也因此不需要 CopilotKit 的 compactEvents)。
订阅一个会话的事件流。返回**退订函数**。 - `:since` 上次收到的 `:seq`(会话级单调,跨 run 连续)。缺省 nil = 只收新事件。 - `:on-event` `(fn [event])`——**必须立刻返回**(往你的 sink 里 put 一下)。 抛异常即被摘除。 - `:on-close` `(fn [reason])`,可选(退订 / `shutdown!` 时触发)。 `:since` 早于缓冲起点时先发一条 `:run/resync`(带 ChatMemory 里的完整消息 快照)再接 live tail——**折叠后的真相 ChatMemory 里本来就有**,不在事件侧再造 一份(§8.1:不建第二个真相店,也因此不需要 CopilotKit 的 `compactEvents`)。
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 |