工具调用循环(从 chat-client 下沉而来)
chat-client 只提供原语 invoke-chat / invoke-tool;'驱动 LLM↔工具直到文本或暂停' 这套策略(含 max-iterations / gate 暂停 / resume / 悬空 tool_use 自愈)属编排层, 放在 simpleagent。每轮只向 invoke-chat 传 delta,由 chat-client 的 memory filter 按 conversation-id 拼出完整历史。
store 显式传入(chat-client 不再持有 memory):用于 heal 与临时会话清理; 与 chat-client 上挂载的 memory-filter 必须是同一个 store 实例。
callbacks 独立于 chat-client filter::on-llm-call/:on-llm-result/:on-tool-result 在循环关键节点直接触发,不走 filter 链。gate 评估结果缓存,确保每个工具调用 恰好触发一次观察回调(不重复)。
工具调用循环(从 chat-client 下沉而来) chat-client 只提供原语 invoke-chat / invoke-tool;'驱动 LLM↔工具直到文本或暂停' 这套策略(含 max-iterations / gate 暂停 / resume / 悬空 tool_use 自愈)属编排层, 放在 simpleagent。每轮只向 invoke-chat 传 delta,由 chat-client 的 memory filter 按 conversation-id 拼出完整历史。 store 显式传入(chat-client 不再持有 memory):用于 heal 与临时会话清理; 与 chat-client 上挂载的 memory-filter 必须是同一个 store 实例。 callbacks 独立于 chat-client filter::on-llm-call/:on-llm-result/:on-tool-result 在循环关键节点直接触发,不走 filter 链。gate 评估结果缓存,确保每个工具调用 恰好触发一次观察回调(不重复)。
(check-decisions! loop-state payload)校验 resume payload 的逐件决定 :decisions(§15):每条 :reply 都带 :message,
且答全、答对——点名了不在待审批里的 id → ex-info :interrupt-unknown;
有待审批的调用没答 → :interrupt-incomplete。没有 :decisions 时什么都不做。
旧暂停态没有 :pending-ids 时以 [:pending-id] 为准。
公开出来,是为了让调用方(simple-agent 的 resume)在动暂停态之前先校验: 答错是调用方的输入错误,不该把暂停快照(连同整批待审批)清掉。
校验 resume payload 的逐件决定 `:decisions`(§15):每条 :reply 都带 :message, 且**答全、答对**——点名了不在待审批里的 id → ex-info `:interrupt-unknown`; 有待审批的调用没答 → `:interrupt-incomplete`。没有 `:decisions` 时什么都不做。 旧暂停态没有 :pending-ids 时以 [:pending-id] 为准。 公开出来,是为了让调用方(simple-agent 的 resume)在**动暂停态之前**先校验: 答错是调用方的输入错误,不该把暂停快照(连同整批待审批)清掉。
(emit-event! callbacks type m)发一个生命周期事件(designs/runtime-borrow.md §11)。callbacks 里没有
:on-event 就什么都不做;出口抛的异常吞掉(观察者不许打断回合)。
事件带 :type :ts,以及回调元数据里的 :conversation-id / :run-id。
发一个生命周期事件(designs/runtime-borrow.md §11)。`callbacks` 里没有 `:on-event` 就什么都不做;出口抛的异常吞掉(观察者不许打断回合)。 事件带 `:type` `:ts`,以及回调元数据里的 `:conversation-id` / `:run-id`。
(execute-batch chat-client tool-calls gate tool-context init-records)(execute-batch chat-client
tool-calls
gate
tool-context
init-records
on-tool-result)MapReduce 执行一批工具调用(设计见 docs/agent-loop-concurrency-design.md §9)。
缺省串行(无 :tool-manager 时走这里):每个调用按序在调用方线程执行。
并发要求同批工具的副作用彼此无序依赖——那是调用方才知道的性质,框架不替它
假定。要并发就注入 virtual-thread-tool-calling-manager(或 thread-pool 版)。
map:每个调用拿同一份轮初 tool-context 快照(同批工具互相看不到对方的写)。 批内任一工具声明 :serial 时,即使选了并发引擎也整批退化为按序执行。 状态语义与引擎无关:三个引擎都是快照 + 屏障折叠。
reduce:屏障收齐后,各工具的 :writes 按 tool-call 原始序经 chat-client :state-slots 的槽级 reducer 折叠进 context(未声明槽默认 last-writer; 同批多工具写同一未声明槽记 warn)。messages/records 按原始序排回。
gate: (fn [tool-call] -> 决策),nil 视为全 :proceed;批前串行预判 (审批可交互,绝不并发)。决策词汇:
on-tool-result: (fn [tool-name result-str]),每个实际执行的工具恰好触发 一次(:reject / {:reply} 不触发);并行下批内触发顺序不确定。
返回: {:messages [...] :records [...] :context 新ctx :errors [{:id :name :class :message :tc :data?} ...]} :errors 为本批失败调用及其故障类别(重试耗尽后仍失败的才出现在此), 供屏障处策略路由(环境类 → 暂停等人,见 run-tool-loop)。
MapReduce 执行一批工具调用(设计见 docs/agent-loop-concurrency-design.md §9)。
**缺省串行**(无 `:tool-manager` 时走这里):每个调用按序在调用方线程执行。
并发要求同批工具的副作用彼此无序依赖——那是调用方才知道的性质,框架不替它
假定。要并发就注入 `virtual-thread-tool-calling-manager`(或 thread-pool 版)。
map:每个调用拿同一份轮初 tool-context 快照(同批工具互相看不到对方的写)。
批内任一工具声明 :serial 时,即使选了并发引擎也整批退化为按序执行。
**状态语义与引擎无关**:三个引擎都是快照 + 屏障折叠。
reduce:屏障收齐后,各工具的 :writes 按 tool-call 原始序经 chat-client
:state-slots 的槽级 reducer 折叠进 context(未声明槽默认 last-writer;
同批多工具写同一未声明槽记 warn)。messages/records 按原始序排回。
gate: (fn [tool-call] -> 决策),nil 视为全 :proceed;批前串行预判
(审批可交互,绝不并发)。决策词汇:
- :proceed 执行
- :reject 跳过,结果「已拒绝执行」
- {:reject 理由} 跳过,结果「已拒绝执行:<理由>」(模型直接拿到原因)
- {:reply 结果} 不执行,载荷即工具结果(ask-user 语义)
on-tool-result: (fn [tool-name result-str]),每个实际执行的工具恰好触发
一次(:reject / {:reply} 不触发);并行下批内触发顺序不确定。
返回: {:messages [...] :records [...] :context 新ctx
:errors [{:id :name :class :message :tc :data?} ...]}
:errors 为本批失败调用及其故障类别(重试耗尽后仍失败的才出现在此),
供屏障处策略路由(环境类 → 暂停等人,见 run-tool-loop)。(heal-dangling-tool-calls! store conv-id)开新一轮前的自愈:为 conv-id 历史里的悬空 tool_use 补「已取消」中立结果,使会话重新配平。 无悬空则 no-op。store 即挂载于 chat-client 的同一 memory store。 store 为 nil 时(如 :memory false 的子 Agent)直接跳过,无需自愈。
开新一轮前的自愈:为 conv-id 历史里的悬空 tool_use 补「已取消」中立结果,使会话重新配平。 无悬空则 no-op。store 即挂载于 chat-client 的同一 memory store。 store 为 nil 时(如 :memory false 的子 Agent)直接跳过,无需自愈。
(invoke chat-client store messages opts)工具调用循环主入口(统一循环)。同步:阻塞到出结果。
参数:
返回: {:status :completed :response r :tool-context c :tool-calls-made [...]} {:status :paused :loop-state {...} :pending-tool {...} :tool-context c}
工具调用循环主入口(统一循环)。**同步**:阻塞到出结果。
参数:
- chat-client: ChatClient 实例(需配置 ChatModel)
- store: ChatMemory store(与 chat-client 上 memory-filter 同一实例;用于 heal/临时清理)
- messages: 本轮新消息(中立消息)
- opts: {:context :system-prompts :max-iterations :tool-choice :tool-gate :tags/:exclude-tags
:on-env-error :pause|:proceed(缺省 :proceed——环境类工具失败照常交给模型;
:pause 时在屏障处暂停等人,resume :retry/:proceed 续跑)
:callbacks 回调 map(:on-llm-call/:on-llm-result/:on-tool-result/:metadata 等)}
返回:
{:status :completed :response r :tool-context c :tool-calls-made [...]}
{:status :paused :loop-state {...} :pending-tool {...} :tool-context c}(invoke-async chat-client store messages opts)同 invoke,但立刻返回 CompletableFuture,整个 turn 跑在虚拟线程上。
给 Ring / Luminus 异步 handler 用:HTTP 工作线程不被 LLM 往返占住。
取结果用 flt/fmap 组合、async/on-complete 接回调,或 async/join 阻塞。
语义与 invoke 逐字相同(同一份 invoke*):暂停/取消/错误状态、
ephemeral 会话清理、turn filter 的递归重入都照旧;差别只在返回值被
deferred 包了一层。turn filter 在这条路径上会真的拿到 deferred——
写响应侧时必须走 flt/fmap / flt/fbind,不能 let 出来直接用(§2.6.4)。
全链路 deferred(2026-09-03,P2):循环本体走 run-tool-loop-async——
每轮 LLM 调用经 chat-client/invoke-chat-async,:chat / :iteration /
:turn 三条链都在异步形态下运行。ChatModel 未实现 IAsyncChatModel 时由
chat-model/call-async* 用虚拟线程兜底,行为一致、只是每次 LLM 调用占一根
虚拟线程。:tool 终端仍同步(工具是用户代码,阻塞是常态;批次并行本就在
executor 上),见 docs/async-chat-model-design.md §8。
同 `invoke`,但**立刻返回 `CompletableFuture`**,整个 turn 跑在虚拟线程上。 给 Ring / Luminus 异步 handler 用:HTTP 工作线程不被 LLM 往返占住。 取结果用 `flt/fmap` 组合、`async/on-complete` 接回调,或 `async/join` 阻塞。 语义与 `invoke` **逐字相同**(同一份 `invoke*`):暂停/取消/错误状态、 ephemeral 会话清理、turn filter 的递归重入都照旧;差别只在返回值被 deferred 包了一层。turn filter 在这条路径上会真的拿到 deferred—— 写响应侧时必须走 `flt/fmap` / `flt/fbind`,不能 `let` 出来直接用(§2.6.4)。 **全链路 deferred**(2026-09-03,P2):循环本体走 `run-tool-loop-async`—— 每轮 LLM 调用经 `chat-client/invoke-chat-async`,`:chat` / `:iteration` / `:turn` 三条链都在异步形态下运行。ChatModel 未实现 `IAsyncChatModel` 时由 `chat-model/call-async*` 用虚拟线程兜底,行为一致、只是每次 LLM 调用占一根 虚拟线程。`:tool` 终端仍同步(工具是用户代码,阻塞是常态;批次并行本就在 executor 上),见 docs/async-chat-model-design.md §8。
(resume chat-client loop-state decision opts)从 paused 的 loop-state 继续工具循环。同步:阻塞到出结果。
两种暂停 phase:
opts :payload(审批 phase 可选,携带用户答复):
参数:
turn 链语义:resume 同样经过 :turn 洋葱——首次进入终端执行「暂停 turn 的 延续」(消费 loop-state);turn filter 的递归重入(如校验反馈)走全新 工具循环(新 delta,上下文由 memory 拼接)。TurnRequest 带 :resume? true 标记,请求侧改写类 filter(RAG 注入等)应据此跳过首次改写。
返回: 同 invoke(:completed 或再次 :paused)
从 paused 的 loop-state 继续工具循环。**同步**:阻塞到出结果。
两种暂停 phase:
- 审批暂停(缺省,工具未执行):decision :approved(强制全部执行)
| :rejected(gate 决定,敏感→拒绝)| :reply(不执行 pending 工具,
payload :message 直接作为其结果——ask-user 语义)
- :env-retry(批已执行、环境类失败):decision :retry(环境已修复,重跑
失败调用)| :proceed(错误结果交给模型)
opts :payload(审批 phase 可选,携带用户答复):
- :approved + {:args 新参数} → pending 工具以替换后的参数执行(编辑后批准)
- :rejected + {:message 理由} → 结果「已拒绝执行:<理由>」(模型直接拿到原因)
- :reply + {:message 答复} → 答复即 pending 工具的结果(必填)
参数:
- chat-client: ChatClient 实例
- loop-state: invoke 返回的 :loop-state
- decision: 见上
- opts: 同 invoke(须含带 conversation-id 的 :context 以接续历史;
可含 :payload)
turn 链语义:resume 同样经过 :turn 洋葱——首次进入终端执行「暂停 turn 的
延续」(消费 loop-state);turn filter 的递归重入(如校验反馈)走全新
工具循环(新 delta,上下文由 memory 拼接)。TurnRequest 带 :resume? true
标记,请求侧改写类 filter(RAG 注入等)应据此跳过首次改写。
返回: 同 invoke(:completed 或再次 :paused)(resume-async chat-client loop-state decision opts)同 resume,但立刻返回 CompletableFuture,延续跑在虚拟线程上。
HITL 的第二段(人答复之后接着跑)在 web 场景与首段同样长——首段异步了、
这段还阻塞 HTTP 线程就没有意义。语义与 resume 逐字相同(同一份 resume*)。
同 `resume`,但立刻返回 `CompletableFuture`,延续跑在虚拟线程上。 HITL 的第二段(人答复之后接着跑)在 web 场景与首段同样长——首段异步了、 这段还阻塞 HTTP 线程就没有意义。语义与 `resume` 逐字相同(同一份 `resume*`)。
(run-tools chat-client tool-calls tool-context)execute-batch 的无 gate 特例:全部执行。供外部手搓工具循环使用。
恒串行(无视 :tool-manager)——不复用引擎的调度,但吃引擎的 :timeout
缺省(经 invoke-tool → effective-tool-timeout 读 :tool-manager)。
不对称是 v0.3 引入的::timeout 在 invoke-tool 强制,而调度在 react——两者
分属不同层,run-tools 只走前者。要完整引擎行为(含调度)请用 invoke/resume。
execute-batch 的无 gate 特例:全部执行。供外部手搓工具循环使用。 **恒串行**(无视 `:tool-manager`)——不复用引擎的调度,但**吃引擎的 `:timeout` 缺省**(经 invoke-tool → effective-tool-timeout 读 `:tool-manager`)。 不对称是 v0.3 引入的:`:timeout` 在 invoke-tool 强制,而调度在 react——两者 分属不同层,run-tools 只走前者。要完整引擎行为(含调度)请用 invoke/resume。
(sequential-tool-calling-manager)(sequential-tool-calling-manager {:keys [timeout]})缺省引擎:每个工具按调用序在调用方线程执行,无并发。
为什么串行是缺省:并发要求同批工具的副作用彼此无序依赖——那是调用方才
知道的性质,框架不该替它假定。串行是那个「无论工具长什么样都成立」的选择:
顺序可预期、可调试、不会因为 LLM 某轮多发一个 tool-call 就把副作用交错起来。
要并发是显式的决定:注入 virtual-thread-tool-calling-manager。
(状态语义两者相同:都是轮初快照 + 屏障折叠。)
线程模型:调用方线程,全程不构造 Future(故不背 ExecutionException 包装
与 Future.get 的中断语义——这是它与「给 VT 引擎塞一个 same-thread executor」
的本质区别)。例外:声明了 :timeout 的工具——超时在 terminal 内由
call-with-timeout 强制(R1),此时起一根 VT 跑工具本体、调用方线程等 deref,
「不构造 Future」承诺在此情形下不成立(R2 诚实降级)。未声明超时的工具(大多数)
照常在调用方线程上直接跑。
隔离边界:不持任何资源,无可争之物。
opts:
:timeout 的工具设的缺省超时(毫秒)。
缺省 nil = 不超时。工具自己的声明恒优先。**缺省引擎**:每个工具按调用序在调用方线程执行,无并发。
**为什么串行是缺省**:并发要求同批工具的副作用彼此无序依赖——那是**调用方才
知道**的性质,框架不该替它假定。串行是那个「无论工具长什么样都成立」的选择:
顺序可预期、可调试、不会因为 LLM 某轮多发一个 tool-call 就把副作用交错起来。
要并发是**显式**的决定:注入 `virtual-thread-tool-calling-manager`。
(状态语义两者相同:都是轮初快照 + 屏障折叠。)
线程模型:调用方线程,**全程不构造 Future**(故不背 ExecutionException 包装
与 Future.get 的中断语义——这是它与「给 VT 引擎塞一个 same-thread executor」
的本质区别)。**例外:声明了 `:timeout` 的工具**——超时在 terminal 内由
`call-with-timeout` 强制(R1),此时起一根 VT 跑工具本体、调用方线程等 deref,
「不构造 Future」承诺在此情形下不成立(R2 诚实降级)。未声明超时的工具(大多数)
照常在调用方线程上直接跑。
隔离边界:不持任何资源,无可争之物。
opts:
- :timeout 本引擎为**没有声明 `:timeout`** 的工具设的缺省超时(毫秒)。
缺省 nil = 不超时。工具自己的声明恒优先。(shutdown-tool-calling-manager! m)关停 manager 持有的资源。只有 ThreadPoolToolCallingManager 持有池; VT / Sequential 无资源,调用即 no-op。等价于对 Closeable 实现调 .close。
关停 manager 持有的资源。只有 ThreadPoolToolCallingManager 持有池; VT / Sequential 无资源,调用即 no-op。等价于对 Closeable 实现调 .close。
(thread-pool-tool-calling-manager)(thread-pool-tool-calling-manager {:keys [pool-size thread-name-prefix timeout]
:or {pool-size (.availableProcessors
(Runtime/getRuntime))
thread-name-prefix "clj-agent-tool-"}})有界平台线程池引擎:工具批在本实例自己的池里跑,形成舱壁隔离。
线程模型:固定大小的平台线程池(daemon 线程)。 隔离边界:每个实例一个池——工具执行的并发上限 = pool-size,不与其他 chat-client 相互挤占;池随实例关停(见下)。这是 VT 引擎给不了的:后者用进程全局无界池。
opts:
:timeout 的工具设的缺省超时(毫秒),
缺省 nil = 不超时;工具自己的声明恒优先。
本引擎尤其值得给一个:没有超时的工具若卡死(如无限
循环、socket 挂死),会永久占住一个池槽(舱壁逐格坏死);
有超时则池线程在 deref 超时后释放(但注意 R2:有超时的工具
实际跑在 VT 上、池线程只等 deref,舱壁退化为信号量)生命周期由持有者负责——池不会自己关。实现了 java.io.Closeable:
(with-open [m (thread-pool-tool-calling-manager {:pool-size 4})] (let [cc (chat-client/build-chat-client {:chat-model cm :tools [...] :tool-manager m})] ...))
关停后再执行工具批抛 ex-info(:error-class :environment)。
不变量:一个引擎属于一个 chat-client,不跨 delegate 边界。 子 agent 是独立 agent,自有引擎——这本来就是默认:delegate 的 subagent-config 全部来自用户的 :subagent-fn,父 chat-client 的 :tool-manager 没有渠道流进去。
故不要把同一个实例亲手塞进 subagent-config 复用(那是绕过默认去踩): delegate-tool 是 spawn→await→drop,父批的工具会占着池线程阻塞等子 agent 跑完, 而子 agent 的批又要同一个池的线程——互等,永久挂起。子 agent 要限流就给它 自己的实例(各自封顶,互不嵌套);不需要就留默认 VT 引擎。
同理,一个引擎的批不嵌套自己:别在本引擎某个工具的函数体里,再拿同一个 实例去跑一批(同样自锁)。框架不为此设防——不变量本身就是答案。
顺带一提:有界平台池是给真正干活的工具封顶用的。delegate 这类只是阻塞等 网络的工具,占一根平台线程停几秒到几分钟,本就不该走有界池。
有界平台线程池引擎:工具批在**本实例自己的池**里跑,形成舱壁隔离。
线程模型:固定大小的平台线程池(daemon 线程)。
隔离边界:每个实例一个池——工具执行的并发上限 = pool-size,不与其他 chat-client
相互挤占;池随实例关停(见下)。这是 VT 引擎给不了的:后者用进程全局无界池。
opts:
- :pool-size 正整数,缺省 (availableProcessors)
- :thread-name-prefix 线程名前缀,缺省 "clj-agent-tool-"
- :timeout 为**没有声明 `:timeout`** 的工具设的缺省超时(毫秒),
缺省 nil = 不超时;工具自己的声明恒优先。
**本引擎尤其值得给一个**:没有超时的工具若卡死(如无限
循环、socket 挂死),会**永久占住一个池槽**(舱壁逐格坏死);
有超时则池线程在 deref 超时后释放(但注意 R2:有超时的工具
实际跑在 VT 上、池线程只等 deref,舱壁退化为信号量)
**生命周期由持有者负责**——池不会自己关。实现了 java.io.Closeable:
(with-open [m (thread-pool-tool-calling-manager {:pool-size 4})]
(let [cc (chat-client/build-chat-client {:chat-model cm :tools [...] :tool-manager m})]
...))
关停后再执行工具批抛 ex-info(:error-class :environment)。
**不变量:一个引擎属于一个 chat-client,不跨 delegate 边界。**
子 agent 是独立 agent,自有引擎——这本来就是默认:delegate 的 subagent-config
全部来自用户的 :subagent-fn,父 chat-client 的 :tool-manager 没有渠道流进去。
故**不要把同一个实例亲手塞进 subagent-config 复用**(那是绕过默认去踩):
delegate-tool 是 spawn→await→drop,父批的工具会占着池线程阻塞等子 agent 跑完,
而子 agent 的批又要同一个池的线程——互等,永久挂起。子 agent 要限流就给它
**自己的**实例(各自封顶,互不嵌套);不需要就留默认 VT 引擎。
同理,**一个引擎的批不嵌套自己**:别在本引擎某个工具的函数体里,再拿同一个
实例去跑一批(同样自锁)。框架不为此设防——不变量本身就是答案。
顺带一提:有界平台池是给**真正干活**的工具封顶用的。delegate 这类只是阻塞等
网络的工具,占一根平台线程停几秒到几分钟,本就不该走有界池。(virtual-thread-tool-calling-manager)(virtual-thread-tool-calling-manager {:keys [timeout]})并发引擎:每调用一根虚拟线程,尊重 :serial 声明。
不是缺省——缺省是串行(见 sequential-tool-calling-manager)。同轮多个
tool-call 并发跑,要求这些工具的副作用彼此无序依赖;那是调用方才知道的事,
故须显式选择。
线程模型:虚拟线程,无界。 隔离边界:无——用的是进程全局共享 executor(见 tool-executor)。 需要舱壁 / 限流 / 可关停边界时改用 thread-pool-tool-calling-manager。
opts:
:timeout 的工具设的缺省超时(毫秒)。
缺省 nil = 不超时。工具自己的声明恒优先。并发引擎:每调用一根虚拟线程,尊重 :serial 声明。
**不是缺省**——缺省是串行(见 `sequential-tool-calling-manager`)。同轮多个
tool-call 并发跑,要求这些工具的副作用彼此无序依赖;那是**调用方才知道**的事,
故须显式选择。
线程模型:虚拟线程,无界。
隔离边界:**无**——用的是进程全局共享 executor(见 tool-executor)。
需要舱壁 / 限流 / 可关停边界时改用 thread-pool-tool-calling-manager。
opts:
- :timeout 本引擎为**没有声明 `:timeout`** 的工具设的缺省超时(毫秒)。
缺省 nil = 不超时。工具自己的声明恒优先。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 |