Liking cljdoc? Tell your friends :D

im.ttalk.agent

Facade 入口:core 的统一门面

对照 beamai 的 beamai.erl——真实模块按职责拆开,门面只负责「常用的那些 东西在一个地方找得到」。它不实现任何逻辑,每个函数都是一行转发;要用到 门面没暴露的东西,直接 require 背后的 ns 即可,两条路等价。

(require '[im.ttalk.agent :as ai])

(ai/deftool get-weather "查天气" [[city :string "城市"]]
  (str city ": 晴 25°C"))

(def cc (ai/chat-client {:chat-model (ai/chat-model provider {:model "gpt-4"})
                         :tools   [#'get-weather]
                         :filters [(ai/filter :log :chat my-around)]}))

(ai/chat cc [{:role :user :content "北京天气?"}] {})

工具调用循环不在这里——LLM ↔ Tool 的多轮编排属于 Agent 层,见 im.ttalk.agent.simple-agent(clj-agent-client 模块)。core 只给两个原语: 单次 LLM 调用与单次工具调用,各自经过 filter 洋葱链。

背后的分工:

ns管什么
im.ttalk.agent.chat-model一次 LLM 调用:选项合并 → 重试 → 响应归一化
im.ttalk.agent.chat-clientfilter 洋葱链 + 工具装配;invoke-chat / invoke-tool
im.ttalk.agent.tool-registry工具声明表:装配期建表 + 运行期查询
im.ttalk.agent.filterfilter 契约、链合成、内置 filter 集
im.ttalk.agent.model.*中立边界类型:消息 / 请求 / 响应 / 错误 / 内容
im.ttalk.agent.retry通用重试(判据取自 canonical error)
Facade 入口:core 的统一门面

对照 beamai 的 `beamai.erl`——真实模块按职责拆开,**门面只负责「常用的那些
东西在一个地方找得到」**。它不实现任何逻辑,每个函数都是一行转发;要用到
门面没暴露的东西,直接 require 背后的 ns 即可,两条路等价。

```clojure
(require '[im.ttalk.agent :as ai])

(ai/deftool get-weather "查天气" [[city :string "城市"]]
  (str city ": 晴 25°C"))

(def cc (ai/chat-client {:chat-model (ai/chat-model provider {:model "gpt-4"})
                         :tools   [#'get-weather]
                         :filters [(ai/filter :log :chat my-around)]}))

(ai/chat cc [{:role :user :content "北京天气?"}] {})
```

**工具调用循环不在这里**——LLM ↔ Tool 的多轮编排属于 Agent 层,见
`im.ttalk.agent.simple-agent`(clj-agent-client 模块)。core 只给两个原语:
单次 LLM 调用与单次工具调用,各自经过 filter 洋葱链。

背后的分工:

| ns | 管什么 |
|---|---|
| `im.ttalk.agent.chat-model`    | 一次 LLM 调用:选项合并 → **重试** → 响应归一化 |
| `im.ttalk.agent.chat-client`   | filter 洋葱链 + 工具装配;`invoke-chat` / `invoke-tool` |
| `im.ttalk.agent.tool-registry` | 工具声明表:装配期建表 + 运行期查询 |
| `im.ttalk.agent.filter`        | filter 契约、链合成、内置 filter 集 |
| `im.ttalk.agent.model.*`       | 中立边界类型:消息 / 请求 / 响应 / 错误 / 内容 |
| `im.ttalk.agent.retry`         | 通用重试(判据取自 canonical error) |
raw docstring

im.ttalk.agent.admission

在途票据池:把「同时压在某个 provider/模型上的请求数」关进一个计数 (designs/provider-reliability.md §5,Keelson ChatAdmission.cs / AdmittingChatClient.cs)。

  • 按键分池:每个键 permits 张票(缺省 8),键通常是 "<provider>/<model>"。
  • 退避不占票:票只跟「一次尝试」走——chat-model/with-admission 把取票放在 DefaultChatModel 重试循环的里面,退避等待发生在票外。
  • 异步不占线程:acquire-async 返回 CompletableFuture,排队期间不阻塞任何线程; 还票时直接把票移交给队首等待者(不经 available,防插队),移交在虚拟线程上完成, 免得把等待者的后续阶段跑在还票线程上。
  • 排队不设死线(限流是分钟尺度);要放弃就取消:同步路径中断线程,异步路径 cancel future。
  • 无全局单例(design-principles §3):要共享就把同一个池显式传给多个 ChatModel。
(def pool (admission/admission {:permits 4}))
(admission/with-permit pool "anthropic/claude" #(call-llm ...))
(admission/with-permit-async pool "anthropic/claude" #(call-llm-async ...))
在途票据池:把「同时压在某个 provider/模型上的请求数」关进一个计数
(designs/provider-reliability.md §5,Keelson `ChatAdmission.cs` / `AdmittingChatClient.cs`)。

- **按键分池**:每个键 `permits` 张票(缺省 8),键通常是 `"<provider>/<model>"`。
- **退避不占票**:票只跟「一次尝试」走——`chat-model/with-admission` 把取票放在
  DefaultChatModel 重试循环的**里面**,退避等待发生在票外。
- **异步不占线程**:`acquire-async` 返回 CompletableFuture,排队期间不阻塞任何线程;
  还票时直接把票**移交**给队首等待者(不经 available,防插队),移交在虚拟线程上完成,
  免得把等待者的后续阶段跑在还票线程上。
- 排队不设死线(限流是分钟尺度);要放弃就取消:同步路径中断线程,异步路径 cancel future。
- **无全局单例**(design-principles §3):要共享就把同一个池显式传给多个 ChatModel。

```clojure
(def pool (admission/admission {:permits 4}))
(admission/with-permit pool "anthropic/claude" #(call-llm ...))
(admission/with-permit-async pool "anthropic/claude" #(call-llm-async ...))
```
raw docstring

im.ttalk.agent.async

异步适配层:CompletionStage ⇄ 链结果组合子 + 虚拟线程入口 + 两回调 sink。

filter.clj 只定义协议 IChainResult(契约见 docs/filter-chain-design.md §2.6.4,C5「协议进 core,实现不进」);本 ns 是它的第一个 deferred 实现 ——CompletionStage 是 JDK 自带、零依赖,故可以住在 core,require 即生效。 manifold / core.async 的适配层照抄本 ns 的形状自写即可,两者都不必进 core。

为什么是 CompletableFuture 而不是 Flux

turn 结果是单值(一个 TurnResult),不是流——Flux 那一套控制反转在这里 买不到东西。真正的流是 token 流,而 token 流走 :token-xform transducer (token-stream-filter-design.md),与本 ns 无关。

为什么是虚拟线程

LLM 调用与工具执行本身是阻塞 IO。Loom 下把它们跑在虚拟线程上,调用线程 立刻拿到 CompletableFuture 返回,而阻塞代码一行不用改写成回调——这正是 §2.6.2 说的「around 形状在 async 下不用变」。用 Thread/startVirtualThread 而非 clojure.core/future(send-off 平台线程池)的理由同 tool.clj 的 call-with-timeout:几 KB 栈、阻塞 IO 可被 interrupt 真正打断。

典型用法(Ring / Luminus 异步 handler)

(defn chat-handler [request respond raise]
  (-> (agent/chat-async my-agent (get-in request [:body-params :message]))
      (flt/fmap (fn [r] {:status 200 :body {:text (:text r)}}))
      (async/on-complete respond raise)))

respond / raise 正是 Ring 3 异步 handler 的两个回调;on-complete 只是 把链结果接到这一对回调上——同步值也接(此时 respond 在当前线程直接调用), 所以同一个 handler 对同步与异步入口都成立。

异步适配层:CompletionStage ⇄ 链结果组合子 + 虚拟线程入口 + 两回调 sink。

`filter.clj` 只定义协议 `IChainResult`(契约见 docs/filter-chain-design.md
§2.6.4,C5「协议进 core,实现不进」);本 ns 是它的**第一个 deferred 实现**
——CompletionStage 是 JDK 自带、零依赖,故可以住在 core,`require` 即生效。
manifold / core.async 的适配层照抄本 ns 的形状自写即可,两者都不必进 core。

## 为什么是 CompletableFuture 而不是 Flux

turn 结果是**单值**(一个 TurnResult),不是流——Flux 那一套控制反转在这里
买不到东西。真正的流是 token 流,而 token 流走 `:token-xform` transducer
(`token-stream-filter-design.md`),与本 ns 无关。

## 为什么是虚拟线程

LLM 调用与工具执行本身是**阻塞 IO**。Loom 下把它们跑在虚拟线程上,调用线程
立刻拿到 CompletableFuture 返回,而阻塞代码一行不用改写成回调——这正是
§2.6.2 说的「around 形状在 async 下不用变」。用 `Thread/startVirtualThread`
而非 `clojure.core/future`(send-off 平台线程池)的理由同 `tool.clj`
的 `call-with-timeout`:几 KB 栈、阻塞 IO 可被 interrupt 真正打断。

## 典型用法(Ring / Luminus 异步 handler)

```clojure
(defn chat-handler [request respond raise]
  (-> (agent/chat-async my-agent (get-in request [:body-params :message]))
      (flt/fmap (fn [r] {:status 200 :body {:text (:text r)}}))
      (async/on-complete respond raise)))
```

`respond` / `raise` 正是 Ring 3 异步 handler 的两个回调;`on-complete` 只是
把链结果接到这一对回调上——**同步值也接**(此时 respond 在当前线程直接调用),
所以同一个 handler 对同步与异步入口都成立。
raw docstring

im.ttalk.agent.chat-client

ChatClient 核心 - 中央编排器

ChatClient 构建使用声明式 map:

(build-chat-client {:chat-model my-chat-model :tools [#'get-weather #'get-time] :filters [memory-filter logging-filter] :settings {:max-tool-iterations 10}})

Tool 定义(支持 tags): (deftool get-weather "获取天气" [[city :string "城市"]] {:tags [:weather :read-only]} (str city ": 25°C"))

Invoke API - 调用函数/LLM: (invoke-tool chat-client :get-weather {:city "北京"} context) (invoke-chat chat-client messages opts) (invoke chat-client messages opts) (invoke chat-client messages {:tags [:weather]}) ;; 只用带 :weather tag 的工具 (invoke chat-client messages {:exclude-tags [:dangerous]}) ;; 排除危险工具

Query API - 查询 ChatClient 状态: (tool-registry/tool-schemas chat-client) ;; 所有 tool schemas (list-functions chat-client) ;; 所有函数名 (find-function chat-client :get-weather)

ChatModel 格式: ChatModel 是一个 map,定义 LLM 调用接口: {:chat-fn (fn [messages opts] -> {:text "..." :tool-calls [...]})}

返回值格式: invoke-tool: {:value v (:writes {k v})} ;; context 只读,写意图经 :writes invoke-chat: {:response r :context ctx} invoke: {:response r :context ctx :tool-calls-made [...]}

ChatClient 核心 - 中央编排器

ChatClient 构建使用声明式 map:

  (build-chat-client {:chat-model my-chat-model
                      :tools [#'get-weather #'get-time]
                      :filters [memory-filter logging-filter]
                      :settings {:max-tool-iterations 10}})

Tool 定义(支持 tags):
  (deftool get-weather
    "获取天气"
    [[city :string "城市"]]
    {:tags [:weather :read-only]}
    (str city ": 25°C"))

Invoke API - 调用函数/LLM:
  (invoke-tool chat-client :get-weather {:city "北京"} context)
  (invoke-chat chat-client messages opts)
  (invoke chat-client messages opts)
  (invoke chat-client messages {:tags [:weather]})           ;; 只用带 :weather tag 的工具
  (invoke chat-client messages {:exclude-tags [:dangerous]}) ;; 排除危险工具

Query API - 查询 ChatClient 状态:
  (tool-registry/tool-schemas chat-client)     ;; 所有 tool schemas
  (list-functions chat-client)                 ;; 所有函数名
  (find-function chat-client :get-weather)

ChatModel 格式:
ChatModel 是一个 map,定义 LLM 调用接口:
  {:chat-fn (fn [messages opts] -> {:text "..." :tool-calls [...]})}

返回值格式:
  invoke-tool: {:value v (:writes {k v})}   ;; context 只读,写意图经 :writes
  invoke-chat: {:response r :context ctx}
  invoke:      {:response r :context ctx :tool-calls-made [...]}
raw docstring

im.ttalk.agent.chat-model

ChatModel —— 一次 LLM 调用的抽象

对标 Spring AI 的 ChatModel:Provider 只管「底层怎么发这一个请求」, ChatModel 管「这一次逻辑调用」——选项解析、响应归一化、重试,都在这里。 分工的判据是「换一家 provider 要不要重写」:wire 格式要,退避策略不要。

ILLMProvider   一次 HTTP 往返,认识厂商 wire 格式
     ↑
 ChatModel     一次逻辑调用:选项合并 → 重试 → 响应归一化
     ↑
ChatClient     filter 洋葱链 + 工具
     ↑
SimpleAgent    工具循环 + 记忆 + HITL

重试在这里,位于整个 filter 栈之下(与 beamai beamai_chat_model 同一 取舍,理由见 im.ttalk.agent.retry 的 ns 文档):filter 看到的是「一次逻辑 调用」,重试重入碰不到任何 filter,memory/计时的副作用每轮只发生一次。 call 重试、stream 只在首个 token 之前重试——token 已经投递给 sink 后重跑会让下游看到 重复内容;流式要容错请在 turn 层重跑整轮。

两个实现:

  • DefaultChatModel:包一个 ILLMProvider,日常用的就是它
  • FnChatModel:包 {:chat-fn :stream-fn} 两个闭包——历史写法与测试 stub 照旧可用(as-chat-model 在装配期归一化),也是接入任意 LLM 的最短路径

使用示例:

(require '[im.ttalk.agent.chat-model :as chat-model])

(def cm (chat-model/create-chat-model my-provider {:model "gpt-4" :max-tokens 4096 :retry {:max-retries 3}})) ;; 缺省 2 次;:retry false 关闭

(chat-model/call cm (req/chat-request messages {:tools [...]}))

ChatModel —— 一次 LLM 调用的抽象

对标 Spring AI 的 `ChatModel`:**Provider 只管「底层怎么发这一个请求」,
ChatModel 管「这一次逻辑调用」**——选项解析、响应归一化、重试,都在这里。
分工的判据是「换一家 provider 要不要重写」:wire 格式要,退避策略不要。

    ILLMProvider   一次 HTTP 往返,认识厂商 wire 格式
         ↑
     ChatModel     一次逻辑调用:选项合并 → 重试 → 响应归一化
         ↑
    ChatClient     filter 洋葱链 + 工具
         ↑
    SimpleAgent    工具循环 + 记忆 + HITL

**重试在这里,位于整个 filter 栈之下**(与 beamai `beamai_chat_model` 同一
取舍,理由见 `im.ttalk.agent.retry` 的 ns 文档):filter 看到的是「一次逻辑
调用」,重试重入碰不到任何 filter,memory/计时的副作用每轮只发生一次。
**`call` 重试、`stream` 只在首个 token 之前重试**——token 已经投递给 sink 后重跑会让下游看到
重复内容;流式要容错请在 turn 层重跑整轮。

两个实现:
- `DefaultChatModel`:包一个 ILLMProvider,日常用的就是它
- `FnChatModel`:包 `{:chat-fn :stream-fn}` 两个闭包——历史写法与测试 stub
  照旧可用(`as-chat-model` 在装配期归一化),也是接入任意 LLM 的最短路径

使用示例:

(require '[im.ttalk.agent.chat-model :as chat-model])

(def cm (chat-model/create-chat-model my-provider
          {:model "gpt-4" :max-tokens 4096
           :retry {:max-retries 3}}))       ;; 缺省 2 次;:retry false 关闭

(chat-model/call cm (req/chat-request messages {:tools [...]}))
raw docstring

im.ttalk.agent.chat-model.fallback

主备模型切换(designs/provider-reliability.md §3;Keelson Providers/FallbackChatClient.cs)。

主模型连它自己的重试都耗尽之后,按备用链依次换模型。它是一个 ChatModel 装饰器, 挂在每站 DefaultChatModel(内含重试)的外面、filter 栈的下面:

FallbackChatModel ──┬─ 站 0:主模型(重试 → 取票 → provider)
                    ├─ 站 1:备用(惰性构造)
                    └─ …
  • 判据走错误类型(switchable?):切 5xx 类 :provider-error / :network-error / :timeout-error / :rate-limit-error(重试已耗尽)/ :auth-error / :quota-exhausted-error (额度是逐家的)/ :empty-completion-error / :parse-error;不切 :validation-error (请求本身有错)、:context-overflow-error(换一家照样超长,交压缩恢复)、取消 / 中断。 这一站返回空补全(response/empty-completion?)且还有下一站 → 也切(:switch-on-empty?,缺省真)。
  • 流式:只在本站第一个 token 之前切;吐过 token 之后的失败原样抛(同「token 出门不重发」)。
  • 跨 provider 剪 thinking:上一站与本站 ref 的 provider 段不同 → 所有消息 dissoc :blocks (Anthropic thinking 签名、Responses reasoning item 等不透明回放载荷,别家原样收到只会 400)。 同家换模型不动。
  • 换站去掉 :options :model:备用站用它自己的 model-options,不拿主模型的名字问备胎。
  • 惰性构造::create 轮到才调、每站只调一次;构造抛错 = 这一站自己的配置问题,不看判据、 有下一站就硬切。
  • 报账:成功响应 assoc :model-ref ref :fallback report;全链失败原样抛最后一个错误, ex-data 追加 :fallback report。读法 report。
(fallback/fallback-chat-model
  primary-cm
  [{:ref "openai/gpt-4o" :create (fn [] (cm/create-chat-model openai-p {:model "gpt-4o"}))}
   {:ref "deepseek/deepseek-chat" :chat-model ds-cm}]
  {:primary-ref "anthropic/claude-sonnet-4"
   :on-switch (fn [{:keys [from to error]}] (log/warn from "→" to (:message error)))})
主备模型切换(designs/provider-reliability.md §3;Keelson `Providers/FallbackChatClient.cs`)。

主模型**连它自己的重试都耗尽**之后,按备用链依次换模型。它是一个 ChatModel 装饰器,
挂在每站 DefaultChatModel(内含重试)的**外面**、filter 栈的下面:

    FallbackChatModel ──┬─ 站 0:主模型(重试 → 取票 → provider)
                        ├─ 站 1:备用(惰性构造)
                        └─ …

- **判据走错误类型**(`switchable?`):切 5xx 类 :provider-error / :network-error /
  :timeout-error / :rate-limit-error(重试已耗尽)/ :auth-error / :quota-exhausted-error
  (额度是逐家的)/ :empty-completion-error / :parse-error;**不切** :validation-error
  (请求本身有错)、:context-overflow-error(换一家照样超长,交压缩恢复)、取消 / 中断。
  这一站返回空补全(`response/empty-completion?`)且还有下一站 → 也切(`:switch-on-empty?`,缺省真)。
- **流式**:只在本站第一个 token 之前切;吐过 token 之后的失败原样抛(同「token 出门不重发」)。
- **跨 provider 剪 thinking**:上一站与本站 ref 的 provider 段不同 → 所有消息 `dissoc :blocks`
  (Anthropic thinking 签名、Responses reasoning item 等不透明回放载荷,别家原样收到只会 400)。
  同家换模型不动。
- **换站去掉 `:options :model`**:备用站用它自己的 model-options,不拿主模型的名字问备胎。
- **惰性构造**:`:create` 轮到才调、每站只调一次;构造抛错 = 这一站自己的配置问题,**不看判据**、
  有下一站就硬切。
- **报账**:成功响应 `assoc :model-ref ref :fallback report`;全链失败原样抛最后一个错误,
  ex-data 追加 `:fallback report`。读法 `report`。

```clojure
(fallback/fallback-chat-model
  primary-cm
  [{:ref "openai/gpt-4o" :create (fn [] (cm/create-chat-model openai-p {:model "gpt-4o"}))}
   {:ref "deepseek/deepseek-chat" :chat-model ds-cm}]
  {:primary-ref "anthropic/claude-sonnet-4"
   :on-switch (fn [{:keys [from to error]}] (log/warn from "→" to (:message error)))})
```
raw docstring

im.ttalk.agent.context

ToolContext - 请求/会话级的扁平状态 map

重构后(Memory Filter 模式):对话消息不再放在 Context,而是由 记忆由 clj-agent-client 模块的 memory filter 按 conversation-id 自管(core 对其零感知)。 Context 退化为一个单层扁平 map,承载:

  • :conversation-id 会话 ID(Memory Filter 读取)
  • 任意工具/filter 的请求级共享 k/v(对标 Spring AI ToolContext)

{:context true} 的工具拿到这个 map,可用 get-var/set-var 读写 (请求级有效;跨轮累积请走 store)。

使用示例: (def ctx (create {:conversation-id "s1" :user-id "u1"})) (get-var ctx :user-id) ; => "u1" (set-var ctx :cart [{:item "book"}])

ToolContext - 请求/会话级的扁平状态 map

重构后(Memory Filter 模式):对话消息不再放在 Context,而是由
记忆由 clj-agent-client 模块的 memory filter 按 conversation-id 自管(core 对其零感知)。
Context 退化为一个**单层扁平 map**,承载:
- :conversation-id  会话 ID(Memory Filter 读取)
- 任意工具/filter 的请求级共享 k/v(对标 Spring AI ToolContext)

{:context true} 的工具拿到这个 map,可用 get-var/set-var 读写
(请求级有效;跨轮累积请走 store)。

使用示例:
(def ctx (create {:conversation-id "s1" :user-id "u1"}))
(get-var ctx :user-id)            ; => "u1"
(set-var ctx :cart [{:item "book"}])
raw docstring

im.ttalk.agent.filter

Filter 系统 - 扁平 vector,注册顺序即执行顺序

所有 filter 都是 around:(fn [req chain] -> resp)。 filter 通过 :chat / :tool / :turn / :iteration 四个键挂到对应的链上, 可任意并存。四条链从外到内:

:turn        每 turn 一次        包整个工具循环
 └ :iteration 每轮一次           包 LLM 调用 + 本轮工具批次
    ├ :chat    每轮一次          只包 LLM 调用
    │  └ :token-xform 每 token
    └ :tool    每 tool call 一次 单个工具执行
  • :chat 包单次 LLM 调用(工具循环内每轮执行;memory/日志/重试)
  • :tool 包单次工具执行(并行任务内各自生效;超时/审批/限流)
  • :iteration 包单轮迭代(LLM 调用 + 该轮工具批次;单轮预算/单轮重试/ 基于本轮工具结果的收尾决策)。IterationRequest {:messages :context :index :remaining},前两者可改写; IterationResult 为 {:status :continue :messages :context} (本轮跑完工具、还要接着转)或既有终态(:completed/:paused/ :cancelled)。filter 可改写下一轮 delta、递归重入重跑这一轮、 或不调 chain 短路成 :completed。 硬规则同 :turn::paused/:cancelled 结果必须透传、不得重入。 暂停照常出链:暂停是终端的返回值而非异常,:paused 结果沿链 回流,around 后半段照常执行、filter 看得见暂停——单轮计时/预算 记账在 HITL 下也能正常收尾。 但 resume 的那半批不算一轮:resume 执行的是「暂停那一轮的 下半截」(批次已定、无新 LLM 调用),不经过本链;续跑的循环从下 一个完整轮重新进链,:index 也从 0 重新计。 重入即记账:重入一轮 = 那一轮的 LLM 调用与工具批次真的又跑了 一遍,remaining 由循环本体在实际执行工具批次后扣减,故 filter 重入自然计入,max-iterations 仍是硬上限。
  • :turn 包整个工具循环(每 turn 一次;RAG 注入/最终答案校验/ guardrail/turn 级预算)。TurnRequest {:messages :context (:resume?)} 可改写;TurnResult 为循环结果 {:status :response :tool-context ...}。 闭包链天然"仅下游",turn filter 可多次 (chain req) 递归重入 (evaluator-optimizer;重入的 :messages 应为新 delta,完整上下文 由 memory filter 拼接)。硬规则::paused/:cancelled/:error 结果必须透传、不得重入(暂停态上重试会破坏 HITL 语义)。 resume 同样经过 turn 链(TurnRequest 带 :resume? true,首次进入 延续暂停的 turn、:messages 为 nil)——请求侧改写类 filter(RAG 注入等)应在 :resume? 时跳过首次改写;响应侧(校验/guardrail) 无需感知,递归重入照常。

第五钩子 :token-xform(流式专用,非 around 形状):值为一个 transducer, 作用于出站 token-data 流({:token ...} / {:reasoning-token ...})。 组装在 invoke-chat-stream 的 terminal 内(chat 链之后): provider 原始 token → :token-xform 链(注册顺序,靠前者先见原始 token)→ 最终 on-token。正常完流调 completion arity(缓冲 flush),异常不 flush; 状态作用域 = 单次 LLM 流。硬边界:只变换交付给 on-token 的流, 不改 stream-fn 返回的最终 :response(memory/turn 用原文)。 设计见 docs/token-stream-filter-design.md。

Filter 定义(Filter record,五个钩子字段固定;写普通 map 也行, build-chat-client 会经 as-filter 归一化): {:name :my-filter :chat (fn [req chain] ...) ;; 可选,挂到 chat 链 :tool (fn [req chain] ...) ;; 可选,挂到 tool 链 :turn (fn [req chain] ...) ;; 可选,挂到 turn 链 :iteration (fn [req chain] ...);; 可选,挂到 iteration 链 :token-xform xform} ;; 可选,流式 token 变换(transducer)

五个钩子之外的键(如 memory filter 的 :store)照常可读——record 的 ext-map 收着,(:store f) 不变。

装配期预编译:compile-hooks 在 build-chat-client 时把五条链各折一次, 产出 CompiledHooks;运行期只做 (chain-builder terminal)。此前每次 invoke-tool / invoke-chat 都要 keep 全量 filters + reverse + reduce 重建一遍链——工具循环里每轮每个 tool call 一次。

filter 可以通过闭包携带自己的上下文: (defn caching-filter [] (let [cache (atom {})] {:name :cache :chat (fn [req chain] (if-let [hit (@cache (:k req))] {:resp hit} (let [resp (chain req)] (swap! cache assoc (:k req) (:resp resp)) resp)))}))

响应侧改写走组合子:(flt/fmap (chain req) f) 而非 (let [r (chain req)] (f r))——同步值上就是恒等展开(零开销、语义逐字 相同),将来链的终端返回 deferred 时同一份源码不用改。配套 fbind (f 自身返回链结果/递归重入)与 fcatch 宏(错误侧)。契约见 docs/filter-chain-design.md §2.6.4。

使用示例: (build-chat-client {:chat-model cm :tools [#'t1 #'t2] :filters [memory-filter retry-filter logging-filter]})

Filter 系统 - 扁平 vector,注册顺序即执行顺序

所有 filter 都是 around:(fn [req chain] -> resp)。
filter 通过 :chat / :tool / :turn / :iteration 四个键挂到对应的链上,
可任意并存。四条链从外到内:

    :turn        每 turn 一次        包整个工具循环
     └ :iteration 每轮一次           包 LLM 调用 + 本轮工具批次
        ├ :chat    每轮一次          只包 LLM 调用
        │  └ :token-xform 每 token
        └ :tool    每 tool call 一次 单个工具执行

- :chat  包单次 LLM 调用(工具循环内每轮执行;memory/日志/重试)
- :tool  包单次工具执行(并行任务内各自生效;超时/审批/限流)
- :iteration 包**单轮迭代**(LLM 调用 + 该轮工具批次;单轮预算/单轮重试/
         基于本轮工具结果的收尾决策)。IterationRequest
         {:messages :context :index :remaining},前两者可改写;
         IterationResult 为 {:status :continue :messages :context}
         (本轮跑完工具、还要接着转)或既有终态(:completed/:paused/
         :cancelled)。filter 可改写下一轮 delta、递归重入重跑这一轮、
         或不调 chain 短路成 :completed。
         **硬规则同 :turn**::paused/:cancelled 结果必须透传、不得重入。
         **暂停照常出链**:暂停是终端的返回值而非异常,:paused 结果沿链
         回流,around 后半段照常执行、filter 看得见暂停——单轮计时/预算
         记账在 HITL 下也能正常收尾。
         **但 resume 的那半批不算一轮**:resume 执行的是「暂停那一轮的
         下半截」(批次已定、无新 LLM 调用),不经过本链;续跑的循环从下
         一个完整轮重新进链,:index 也从 0 重新计。
         **重入即记账**:重入一轮 = 那一轮的 LLM 调用与工具批次真的又跑了
         一遍,`remaining` 由循环本体在实际执行工具批次后扣减,故 filter
         重入自然计入,max-iterations 仍是硬上限。
- :turn  包**整个工具循环**(每 turn 一次;RAG 注入/最终答案校验/
         guardrail/turn 级预算)。TurnRequest {:messages :context (:resume?)}
         可改写;TurnResult 为循环结果 {:status :response :tool-context ...}。
         闭包链天然"仅下游",turn filter 可多次 (chain req) 递归重入
         (evaluator-optimizer;重入的 :messages 应为新 delta,完整上下文
         由 memory filter 拼接)。**硬规则**::paused/:cancelled/:error
         结果必须透传、不得重入(暂停态上重试会破坏 HITL 语义)。
         resume 同样经过 turn 链(TurnRequest 带 :resume? true,首次进入
         延续暂停的 turn、:messages 为 nil)——请求侧改写类 filter(RAG
         注入等)应在 :resume? 时跳过首次改写;响应侧(校验/guardrail)
         无需感知,递归重入照常。

第五钩子 :token-xform(流式专用,非 around 形状):值为一个 **transducer**,
作用于出站 token-data 流({:token ...} / {:reasoning-token ...})。
组装在 invoke-chat-stream 的 terminal 内(chat 链之后):
provider 原始 token → :token-xform 链(注册顺序,靠前者先见原始 token)→
最终 on-token。正常完流调 completion arity(缓冲 flush),异常不 flush;
状态作用域 = 单次 LLM 流。**硬边界**:只变换交付给 on-token 的流,
不改 stream-fn 返回的最终 :response(memory/turn 用原文)。
设计见 docs/token-stream-filter-design.md。

Filter 定义(`Filter` record,五个钩子字段固定;写普通 map 也行,
`build-chat-client` 会经 `as-filter` 归一化):
  {:name :my-filter
   :chat (fn [req chain] ...)     ;; 可选,挂到 chat 链
   :tool (fn [req chain] ...)     ;; 可选,挂到 tool 链
   :turn (fn [req chain] ...)     ;; 可选,挂到 turn 链
   :iteration (fn [req chain] ...);; 可选,挂到 iteration 链
   :token-xform xform}            ;; 可选,流式 token 变换(transducer)

五个钩子之外的键(如 memory filter 的 `:store`)照常可读——record 的
ext-map 收着,`(:store f)` 不变。

**装配期预编译**:`compile-hooks` 在 `build-chat-client` 时把五条链各折一次,
产出 `CompiledHooks`;运行期只做 `(chain-builder terminal)`。此前每次
`invoke-tool` / `invoke-chat` 都要 `keep` 全量 filters + `reverse` + `reduce`
重建一遍链——工具循环里每轮每个 tool call 一次。

filter 可以通过闭包携带自己的上下文:
  (defn caching-filter []
    (let [cache (atom {})]
      {:name :cache
       :chat (fn [req chain]
               (if-let [hit (@cache (:k req))]
                 {:resp hit}
                 (let [resp (chain req)]
                   (swap! cache assoc (:k req) (:resp resp))
                   resp)))}))

**响应侧改写走组合子**:`(flt/fmap (chain req) f)` 而非
`(let [r (chain req)] (f r))`——同步值上就是恒等展开(零开销、语义逐字
相同),将来链的终端返回 deferred 时同一份源码不用改。配套 `fbind`
(f 自身返回链结果/递归重入)与 `fcatch` 宏(错误侧)。契约见
docs/filter-chain-design.md §2.6.4。

使用示例:
(build-chat-client {:chat-model cm
                    :tools [#'t1 #'t2]
                    :filters [memory-filter retry-filter logging-filter]})
raw docstring

im.ttalk.agent.filter.instructions

分层指令文件(designs/runtime-borrow.md §10;借 Keelson Instructions/HierarchicalFileInstructionSource.cs 与 Advisors/InstructionsAdvisor.cs)。

项目里放一份 AGENTS.md(子目录可以再放一份更具体的),agent 每次问模型时把 「根 → … → cwd」路径上每层第一个存在的候选文件拼起来,接在 system prompt 后面。

  • 指令不进消息历史:只改本次请求的 :system-prompt 选项,ChatMemory 一个字都不留;
  • 候选文件名由调用方给(库内不写死;缺省 ["AGENTS.md"] 只是 opts 缺省值);
  • 字节预算截断,不劈 UTF-8 多字节字符;读不到的层跳过,不打断回合;
  • 每次 LLM 调用现读(文件小,改了立刻生效)。
分层指令文件(designs/runtime-borrow.md §10;借 Keelson
`Instructions/HierarchicalFileInstructionSource.cs` 与 `Advisors/InstructionsAdvisor.cs`)。

项目里放一份 `AGENTS.md`(子目录可以再放一份更具体的),agent 每次问模型时把
「根 → … → cwd」路径上每层**第一个存在的候选文件**拼起来,接在 system prompt 后面。

- 指令**不进消息历史**:只改本次请求的 `:system-prompt` 选项,ChatMemory 一个字都不留;
- 候选文件名由调用方给(库内不写死;缺省 `["AGENTS.md"]` 只是 opts 缺省值);
- 字节预算截断,不劈 UTF-8 多字节字符;读不到的层跳过,不打断回合;
- 每次 LLM 调用现读(文件小,改了立刻生效)。
raw docstring

im.ttalk.agent.filter.normalize

消息规范化补全(designs/runtime-borrow.md §12;借 Keelson MessageNormalizer / MessagePruner,二者又借 Codex normalize_history 与 keel prune-messages)。

发给模型之前把历史修成 provider 一定收的形状——只改请求视图,store 不动:

  1. 删孤儿结果:找不到对应 assistant 调用的 tool 消息(窗口裁剪 / 手拼历史会产生, OpenAI / Anthropic 一律拒);
  2. 补悬空调用:非末条 assistant 的 tool-call 没有结果 → 紧跟在其结果串之后补一条 固定文本的结果(固定文本 → 同一份历史规范化多少次都字节相同,提示缓存照中)。 react 的 heal-dangling-tool-calls! 在 turn 开始时给 store 补,这里管的是请求视图;
  3. 模型不收的媒体换占位文字(:images? false / :files? false);
  4. :thinking :before-current-turn 时去掉本轮之前的 assistant 思考块(:blocks)—— 本轮工具往返里的思考 Anthropic / DeepSeek 要求原样回传,更早的它们本就忽略。

normalize-filter 应注册在 :filters 的最后(memory / compaction 之后,紧贴模型)。

消息规范化补全(designs/runtime-borrow.md §12;借 Keelson `MessageNormalizer` /
`MessagePruner`,二者又借 Codex `normalize_history` 与 keel `prune-messages`)。

发给模型之前把历史修成 provider 一定收的形状——**只改请求视图,store 不动**:

1. 删**孤儿结果**:找不到对应 assistant 调用的 tool 消息(窗口裁剪 / 手拼历史会产生,
   OpenAI / Anthropic 一律拒);
2. 补**悬空调用**:非末条 assistant 的 tool-call 没有结果 → 紧跟在其结果串之后补一条
   固定文本的结果(固定文本 → 同一份历史规范化多少次都字节相同,提示缓存照中)。
   react 的 `heal-dangling-tool-calls!` 在 turn 开始时给 store 补,这里管的是请求视图;
3. 模型不收的**媒体**换占位文字(`:images? false` / `:files? false`);
4. `:thinking :before-current-turn` 时去掉**本轮之前**的 assistant 思考块(`:blocks`)——
   本轮工具往返里的思考 Anthropic / DeepSeek 要求原样回传,更早的它们本就忽略。

`normalize-filter` 应注册在 `:filters` 的**最后**(memory / compaction 之后,紧贴模型)。
raw docstring

im.ttalk.agent.filter.rag

检索增强(对标 Spring AI QuestionAnswerAdvisor)

决策变更(2026-07-15):此前记录为「不跟本体(需 vector store,超出 定位),留 :turn 挂点」(filter-chain-design.md §4 / agent-loop-concurrency §14.3)。现予推翻——但推翻的只是「不做本体」,不是「不引 vector store」: 本 ns 仍不引任何检索依赖,只定义 IRetriever 协议 + 注入 filter, 向量库/embedding 由用户注入。与 ToolSearch 的 IToolIndex 同一取舍。

之所以值得做:QuestionAnswerAdvisor 的实质是提示词编排(把问题、检索 结果、grounding 指令拼成一条增强消息)而非检索本身。这块编排每个用户都要 重写一遍,且容易写错——尤其「每 turn 只注入一次」这个点。

为什么挂 :turn

§14.3 早已点明这正是 turn 链解锁的场景:挂 :chat 会让工具循环每轮重复 检索(浪费且污染 transcript——第 2 轮起 :messages 是 memory 拼出的完整 历史,拿它当 query 检索毫无意义)。挂 :turn 则每 turn 恰好注入一次。

:resume? 时跳过:延续暂停的 turn 没有入口消息可改写(filter 作者指引见 filter-chain-design.md §2.4)。

用法

(chat-client/build-chat-client
  {:chat-model cm
   :filters [(ma/memory-filter store)
             (rag/qa-turn-filter my-retriever :top-k 4)]})

my-retriever 实现 IRetriever——包一层你的向量库即可:

(reify rag/IRetriever
  (retrieve [_ query top-k]
    (map (fn [hit] {:text (:content hit) :metadata {:source (:id hit)}})
         (my-vector-store/search query top-k))))

不跟的

RetrievalAugmentationAdvisor(模块化 RAG:查询改写/扩展/压缩/重排)—— 那是一整套 org.springframework.ai.rag 构件的门面。需要的话,这些环节 在 IRetriever 实现内部做,或自己写 turn filter;机制都在。

检索增强(对标 Spring AI `QuestionAnswerAdvisor`)

> **决策变更(2026-07-15)**:此前记录为「不跟本体(需 vector store,超出
> 定位),留 :turn 挂点」(filter-chain-design.md §4 / agent-loop-concurrency
> §14.3)。现予推翻——但推翻的只是「不做本体」,不是「不引 vector store」:
> 本 ns **仍不引任何检索依赖**,只定义 `IRetriever` 协议 + 注入 filter,
> 向量库/embedding 由用户注入。与 ToolSearch 的 `IToolIndex` 同一取舍。
>
> 之所以值得做:QuestionAnswerAdvisor 的实质是**提示词编排**(把问题、检索
> 结果、grounding 指令拼成一条增强消息)而非检索本身。这块编排每个用户都要
> 重写一遍,且容易写错——尤其「每 turn 只注入一次」这个点。

## 为什么挂 :turn

§14.3 早已点明这正是 turn 链解锁的场景:挂 :chat 会让工具循环**每轮**重复
检索(浪费且污染 transcript——第 2 轮起 :messages 是 memory 拼出的完整
历史,拿它当 query 检索毫无意义)。挂 :turn 则每 turn 恰好注入一次。

`:resume?` 时跳过:延续暂停的 turn 没有入口消息可改写(filter 作者指引见
filter-chain-design.md §2.4)。

## 用法

```clojure
(chat-client/build-chat-client
  {:chat-model cm
   :filters [(ma/memory-filter store)
             (rag/qa-turn-filter my-retriever :top-k 4)]})
```

`my-retriever` 实现 `IRetriever`——包一层你的向量库即可:

```clojure
(reify rag/IRetriever
  (retrieve [_ query top-k]
    (map (fn [hit] {:text (:content hit) :metadata {:source (:id hit)}})
         (my-vector-store/search query top-k))))
```

## 不跟的

`RetrievalAugmentationAdvisor`(模块化 RAG:查询改写/扩展/压缩/重排)——
那是一整套 `org.springframework.ai.rag` 构件的门面。需要的话,这些环节
在 `IRetriever` 实现内部做,或自己写 turn filter;机制都在。
raw docstring

im.ttalk.agent.filter.structured-output

结构化输出校验(对标 Spring AI 2.0 StructuredOutputValidationAdvisor)

filter/validation-turn-filter 提供的是机制(不合格 → 反馈重入循环 → 重试上限);它的 validate-fn 一直得用户自己写。Spring 的价值恰恰在 validate-fn 本身:按 JSON Schema 校验,并把失败原因写成模型能据以自我修正的 人话("missing required field 'actor'" / "expected 'array', got 'string'") ——而不是干巴巴的「重试」。本 ns 补的就是这块。

零依赖的代价与切法

core 无任何依赖(见 modules/clj-agent-core/deps.edn),JSON 解析器不在 手边。故切成两半:

  • validate-value —— 纯函数,校验已解析的值(Clojure map/vector), 零依赖;
  • validate-fn —— 生成喂给 validation-turn-filter 的 validate-fn, JSON 解析由调用方经 :parse-fn 注入 (#(cheshire.core/parse-string % true) 即可)。

用法

(def schema
  {:type "object"
   :properties {:actor {:type "string"}
                :films {:type "array" :items {:type "string"}}}
   :required ["actor" "films"]})

(chat-client/build-chat-client
  {:chat-model cm
   :filters [(ma/memory-filter store)                      ;; 递归重入依赖 memory 在位
             (flt/validation-turn-filter
               (so/validate-fn schema :parse-fn #(json/parse-string % true))
               :max-retries 2)]})

配 provider 原生 json_schema 使用:原生约束负责「大体是对的」,本校验负责 「真的对」——原生结构化输出并不保证语义完整(Spring 2.0 给 StructuredOutputValidationAdvisor 自动注册,理由同此)。

支持的 JSON Schema 子集

:type(object/array/string/number/integer/boolean/null)、:properties、 :required、:items、:enum。够覆盖 deftool 生成的 schema 与常见结构化 输出;不做 $ref/allOf/oneOf/pattern 等——真要全量 JSON Schema, validate-fn 换成自己的实现即可(机制不变)。

结构化输出校验(对标 Spring AI 2.0 `StructuredOutputValidationAdvisor`)

`filter/validation-turn-filter` 提供的是**机制**(不合格 → 反馈重入循环 →
重试上限);它的 validate-fn 一直得用户自己写。Spring 的价值恰恰在
validate-fn 本身:按 JSON Schema 校验,并把失败原因写成模型能据以自我修正的
人话("missing required field 'actor'" / "expected 'array', got 'string'")
——而不是干巴巴的「重试」。本 ns 补的就是这块。

## 零依赖的代价与切法

core **无任何依赖**(见 modules/clj-agent-core/deps.edn),JSON 解析器不在
手边。故切成两半:

- `validate-value` —— 纯函数,校验**已解析**的值(Clojure map/vector),
  零依赖;
- `validate-fn` —— 生成喂给 `validation-turn-filter` 的 validate-fn,
  JSON 解析由调用方经 `:parse-fn` 注入
  (`#(cheshire.core/parse-string % true)` 即可)。

## 用法

```clojure
(def schema
  {:type "object"
   :properties {:actor {:type "string"}
                :films {:type "array" :items {:type "string"}}}
   :required ["actor" "films"]})

(chat-client/build-chat-client
  {:chat-model cm
   :filters [(ma/memory-filter store)                      ;; 递归重入依赖 memory 在位
             (flt/validation-turn-filter
               (so/validate-fn schema :parse-fn #(json/parse-string % true))
               :max-retries 2)]})
```

配 provider 原生 json_schema 使用:原生约束负责「大体是对的」,本校验负责
「真的对」——原生结构化输出并不保证语义完整(Spring 2.0 给
StructuredOutputValidationAdvisor 自动注册,理由同此)。

## 支持的 JSON Schema 子集

`:type`(object/array/string/number/integer/boolean/null)、`:properties`、
`:required`、`:items`、`:enum`。够覆盖 deftool 生成的 schema 与常见结构化
输出;不做 $ref/allOf/oneOf/pattern 等——真要全量 JSON Schema,
`validate-fn` 换成自己的实现即可(机制不变)。
raw docstring

im.ttalk.agent.filter.tool-guard

同参失败保护 + 失败摘除(designs/runtime-borrow.md §3;借 Keelson ToolCallingAdvisor.RepeatLimit/Account 与 FailureDisableAdvisor 的 Gate 模式)。

两种病:

  1. 原样重试:模型拿同一组错参数反复调用,每次同样失败,烧光迭代预算。 同指纹(工具名 + 规范化参数)第 :repeat-limit 次失败 → 结果换成「别再原样重试」
    • 原报错;第 :repeat-stop-limit 次 → 「到此为止」并停回合(经 :loop/stop)。 同指纹成功一次清零。
  2. 后端挂了:某工具连续失败 :disable-threshold 次 → 本回合门禁拒绝: 工具仍在工具列表里(请求前缀字节不变,提示缓存照中),调用直接回 「已拒绝执行」而不执行。成功一次清零。

计数放在 context 的框架槽 :tool-guard/state,经本 filter 返回的 :writes 写入(ctx/framework-slots 内建两层 merge:同批不同指纹各写各的格)。context 本就进暂停快照,所以计数随 HITL 暂停保存、resume 后接着数;simple-agent 每个 turn 新建 context,作用域天然是一个回合。该键在 ctx/framework-keys 里,AG-UI 状态快照不会带出去。

缺省不挂 = 行为不变。

同参失败保护 + 失败摘除(designs/runtime-borrow.md §3;借 Keelson
`ToolCallingAdvisor.RepeatLimit`/`Account` 与 `FailureDisableAdvisor` 的 Gate 模式)。

两种病:
1. **原样重试**:模型拿同一组错参数反复调用,每次同样失败,烧光迭代预算。
   同指纹(工具名 + 规范化参数)第 `:repeat-limit` 次失败 → 结果换成「别再原样重试」
   + 原报错;第 `:repeat-stop-limit` 次 → 「到此为止」并**停回合**(经 `:loop/stop`)。
   同指纹成功一次清零。
2. **后端挂了**:某工具连续失败 `:disable-threshold` 次 → 本回合**门禁拒绝**:
   工具**仍在工具列表里**(请求前缀字节不变,提示缓存照中),调用直接回
   「已拒绝执行」而不执行。成功一次清零。

**计数放在 context 的框架槽 `:tool-guard/state`**,经本 filter 返回的 `:writes`
写入(`ctx/framework-slots` 内建两层 merge:同批不同指纹各写各的格)。context
本就进暂停快照,所以计数随 HITL 暂停保存、resume 后接着数;simple-agent 每个 turn
新建 context,作用域天然是**一个回合**。该键在 `ctx/framework-keys` 里,AG-UI
状态快照不会带出去。

缺省不挂 = 行为不变。
raw docstring

im.ttalk.agent.filter.tool-output

工具结果截断 + 全文另存(designs/runtime-borrow.md §2,借 Keelson Tools/ToolOutputTruncation.cs / ToolOutputSpill.cs,二者又借 Codex)。

一次 cat 大文件、一次大 JSON 查询,几十 KB 进历史,之后每一轮都要为它付费, 而且很快撞窗。这里做两件事:

  • 纯函数 truncate-middle / truncate-middle-tokens:头尾保留、中段截断 (报错信息常在尾部,只留头会把它截掉),中间放「…N chars truncated…」标记, 结果连同标记不超预算,所以再截一次不变(幂等——压缩 filter 每轮都会再过一遍);
  • tool-output-filter::tool filter,超预算的结果截断并加警告头;可注入 :spill 把全文另存,并在结果末尾告诉模型存在哪(模型要细节可以再去读)。

缺省不挂 = 行为不变。

工具结果截断 + 全文另存(designs/runtime-borrow.md §2,借 Keelson
`Tools/ToolOutputTruncation.cs` / `ToolOutputSpill.cs`,二者又借 Codex)。

一次 `cat` 大文件、一次大 JSON 查询,几十 KB 进历史,之后**每一轮**都要为它付费,
而且很快撞窗。这里做两件事:

- 纯函数 `truncate-middle` / `truncate-middle-tokens`:**头尾保留、中段截断**
  (报错信息常在尾部,只留头会把它截掉),中间放「…N chars truncated…」标记,
  **结果连同标记不超预算**,所以再截一次不变(幂等——压缩 filter 每轮都会再过一遍);
- `tool-output-filter`:`:tool` filter,超预算的结果截断并加警告头;可注入
  `:spill` 把全文另存,并在结果末尾告诉模型存在哪(模型要细节可以再去读)。

缺省不挂 = 行为不变。
raw docstring

im.ttalk.agent.filter.tool-search

ToolSearch —— 渐进式工具披露(对标 Spring AI 2.0 ToolSearchToolCallingAdvisor)

动机:工具一多,全量 schema 每轮都进 prompt。Spring 的实测是 28 个工具 ≈ 5K–17K token,且模型在 30+ 同名工具间的选择准确率下降。渐进式披露把 「一次性全塞」换成「按需检索」:初始只暴露一个检索工具,模型自己搜出需要 的能力,检索到的工具在后续回合才进工具列表。

机制:零新增钩子

我们不需要 Spring 那样的新 advisor 类型——现有三条契约天然拼出全部能力:

  1. run-tool-loop 每轮都把当轮 tool-context 塞进 ChatRequest 的 :context(react.clj);
  2. invoke-chat 的 terminal 由 request 当前字段重建 chat-opts (chat_client.clj),故 :chat filter 改写 :tools 会抵达 provider;
  3. v0.3 的 :writes + :state-slots 槽级 reducer 让工具的写意图在屏障处 按序折叠进 context。

于是:search_tools 是一个普通内联工具,返回 {:writes {::discovered #{名字}}};槽 reducer 为集合并(into), 跨轮累积;:chat filter 从 (:context req) 读出已发现集合,把 :tools 重写为 [search_tools] + 已发现。

白拿的性质:发现集合住在 tool-context 里 → 暂停/resume/持久化全都 自动正确(tool-context 本就随快照走),无需任何额外状态。

索引(零依赖 + 可插拔)

Spring 提供 vector / Lucene / regex 三种 ToolIndex。我们只内置零依赖的 两种,语义检索留给协议注入(与本仓一贯取舍一致:拒 Reactor、拒 vector store 本体、只留挂点):

  • (keyword-tool-index) —— 名称/描述分词重叠打分。中文按二元组切分 (本仓工具描述以中文为主,空白分词对中文无效——同 Lucene CJKBigramFilter 的做法);同时拆 snake_case 与 camelCase;
  • (regex-tool-index) —— query 当正则匹配工具名(get_.*_data 之类); 非法正则退化为字面子串匹配(不抛异常)。

自带向量库的用户 (reify IToolIndex ...) 注入即可。

用法

(chat-client/build-chat-client
  (ts/with-tool-search
    {:chat-model cm :tools [#'t1 #'t2 ... #'t80]
     :filters [(ma/memory-filter store)]}
    {:index (ts/keyword-tool-index) :limit 5}))

with-tool-search 一次装好三处(工具 / filter / 状态槽);要手工接线用 (tool-search opts) 拿 {:tool :filter :state-slots}。

边界(含 live 实测结论,详见 docs/advisor-alignment-design.md §2.3–2.5,

可复现脚本 examples/toolsearch_live_test.clj)

  • 工具定义太少会亏:实测 50 工具目录省 78% prompt token。但固定成本 (多一轮 LLM 往返 + 检索结果进历史,约 600–1000 token)要先赚回来—— 看 schema 总量,不是工具个数(实测两个 12 工具目录结论相反:描述短的 多花 13%,描述长的反而省)。沿用 Spring 的度量:工具定义 >5K token 才用;
  • ⚠️ 省 token 未必省钱:基线的静态工具前缀天然适合 prompt cache,本 filter 每轮改写 :tools 会把可缓存前缀打碎。按缓存读 10% 计价折算,实测 冷缓存下 ToolSearch 省 65%,热缓存下反贵 66%——差别全在基线前缀的 冷热。工具集静态 + 高频会话 + 廉价 prompt cache 时,省的是上下文窗口 而非钱;
  • 发现集合的作用域 = tool-context 的作用域。调用方把上一轮的 :tool-context 回传给下一轮 :context,发现即跨轮累积(≈ Spring 的 per-conversation);不回传则每轮从零开始检索;
  • 召回优先于精确:索引按 IDF 加权,但不设相对分数截断——实测的真实失败 是召回(模型只检索一种能力就作答,另一半问题静默丢失),不是精确 (多召回两个无关工具,模型直接忽略,成本 ~100 token)。:limit 是控制 暴露量的旋钮。
ToolSearch —— 渐进式工具披露(对标 Spring AI 2.0 `ToolSearchToolCallingAdvisor`)

**动机**:工具一多,全量 schema 每轮都进 prompt。Spring 的实测是 28 个工具
≈ 5K–17K token,且模型在 30+ 同名工具间的选择准确率下降。渐进式披露把
「一次性全塞」换成「按需检索」:初始只暴露一个检索工具,模型自己搜出需要
的能力,检索到的工具在后续回合才进工具列表。

## 机制:零新增钩子

我们不需要 Spring 那样的新 advisor 类型——现有三条契约天然拼出全部能力:

1. `run-tool-loop` 每轮都把**当轮 tool-context** 塞进 ChatRequest 的
   `:context`(react.clj);
2. `invoke-chat` 的 terminal 由 **request 当前字段重建** chat-opts
   (chat_client.clj),故 `:chat` filter 改写 `:tools` 会抵达 provider;
3. v0.3 的 `:writes` + `:state-slots` 槽级 reducer 让工具的写意图在屏障处
   按序折叠进 context。

于是:`search_tools` 是一个**普通内联工具**,返回
`{:writes {::discovered #{名字}}}`;槽 reducer 为集合并(`into`),
跨轮累积;`:chat` filter 从 `(:context req)` 读出已发现集合,把 `:tools`
重写为 `[search_tools] + 已发现`。

**白拿的性质**:发现集合住在 tool-context 里 → 暂停/resume/持久化全都
自动正确(tool-context 本就随快照走),无需任何额外状态。

## 索引(零依赖 + 可插拔)

Spring 提供 vector / Lucene / regex 三种 ToolIndex。我们只内置**零依赖**的
两种,语义检索留给协议注入(与本仓一贯取舍一致:拒 Reactor、拒 vector
store 本体、只留挂点):

- `(keyword-tool-index)` —— 名称/描述分词重叠打分。**中文按二元组切分**
  (本仓工具描述以中文为主,空白分词对中文无效——同 Lucene CJKBigramFilter
  的做法);同时拆 snake_case 与 camelCase;
- `(regex-tool-index)` —— query 当正则匹配工具名(`get_.*_data` 之类);
  非法正则退化为字面子串匹配(不抛异常)。

自带向量库的用户 `(reify IToolIndex ...)` 注入即可。

## 用法

```clojure
(chat-client/build-chat-client
  (ts/with-tool-search
    {:chat-model cm :tools [#'t1 #'t2 ... #'t80]
     :filters [(ma/memory-filter store)]}
    {:index (ts/keyword-tool-index) :limit 5}))
```

`with-tool-search` 一次装好三处(工具 / filter / 状态槽);要手工接线用
`(tool-search opts)` 拿 `{:tool :filter :state-slots}`。

## 边界(含 live 实测结论,详见 docs/advisor-alignment-design.md §2.3–2.5,
##       可复现脚本 examples/toolsearch_live_test.clj)

- **工具定义太少会亏**:实测 50 工具目录省 78% prompt token。但固定成本
  (多一轮 LLM 往返 + 检索结果进历史,约 600–1000 token)要先赚回来——
  **看 schema 总量,不是工具个数**(实测两个 12 工具目录结论相反:描述短的
  多花 13%,描述长的反而省)。沿用 Spring 的度量:工具定义 >5K token 才用;
- **⚠️ 省 token 未必省钱**:基线的静态工具前缀天然适合 prompt cache,本
  filter 每轮改写 `:tools` 会把可缓存前缀打碎。按缓存读 10% 计价折算,实测
  **冷缓存下 ToolSearch 省 65%,热缓存下反贵 66%**——差别全在基线前缀的
  冷热。工具集静态 + 高频会话 + 廉价 prompt cache 时,省的是上下文窗口
  而非钱;
- **发现集合的作用域 = tool-context 的作用域**。调用方把上一轮的
  `:tool-context` 回传给下一轮 `:context`,发现即跨轮累积(≈ Spring 的
  per-conversation);不回传则每轮从零开始检索;
- **召回优先于精确**:索引按 IDF 加权,但不设相对分数截断——实测的真实失败
  是召回(模型只检索一种能力就作答,另一半问题静默丢失),不是精确
  (多召回两个无关工具,模型直接忽略,成本 ~100 token)。`:limit` 是控制
  暴露量的旋钮。
raw docstring

im.ttalk.agent.model

LLM Provider 协议定义

定义所有 LLM 提供商必须实现的统一接口。 第三方只需依赖 clj-agent-core 即可实现自定义 provider。

使用示例:

(defrecord MyProvider [api-key] ILLMProvider (provider-name [_] :my-provider) (call-llm [this config messages tools] ...) (extract-tool-calls [_ response] ...) (extract-text [_ response] ...) (build-tool-result [_ tool-id content] ...))

LLM Provider 协议定义

定义所有 LLM 提供商必须实现的统一接口。
第三方只需依赖 clj-agent-core 即可实现自定义 provider。

使用示例:

(defrecord MyProvider [api-key]
  ILLMProvider
  (provider-name [_] :my-provider)
  (call-llm [this config messages tools] ...)
  (extract-tool-calls [_ response] ...)
  (extract-text [_ response] ...)
  (build-tool-result [_ tool-id content] ...))
raw docstring

im.ttalk.agent.model.content

中立多模态内容部件(content parts)—— provider 无关的图片 / 文件 / 音频表示。

消息的 :content 有两种合法形态:

  1. 字符串(既有形态,纯文本,最常用)
  2. 部件向量(多模态):
{:role :user
 :content [(content/text-part "这张图里有什么?")
           (content/image-part "https://example.com/cat.png")]}

部件形状(对齐 Vercel AI SDK 的 LanguageModelPrompt 部件模型—— 图片 / 音频 / PDF 不各立一类,统一是 :file + :media-type, wire 层按 media type 的顶层类别分派;这样新格式不需要新部件类型):

{:type :text :text "..."}
{:type :file :media-type "image/png"       :data "<base64>"}   ; 内联数据
{:type :file :media-type "image/png"       :url  "https://..."} ; 远端 URL
{:type :file :media-type "application/pdf" :data "<base64>" :filename "a.pdf"}

约束与边界:

  • 中立层只搬运,不解释:能不能发(Anthropic 不收音频、OpenAI 的 PDF 只收内联) 由各 provider 的 wire 转换器判定,不支持时抛规范错误(:validation-error, 不可重试),不静默丢内容。
  • 与厂商原生块共存::content 向量里 :type 为字符串的元素(如 Anthropic 的 {:type "text"} / {:type "document"})被视为厂商原生块, wire 层原样透传——既有那条逃生通道不受影响,两者可混用。
  • 部件里的二进制一律以 base64 字符串落地:对话历史要经 EDN / JSON 往返 (SQLite store),字节数组过不去。

(require '[im.ttalk.agent.model.content :as content] '[im.ttalk.agent.model.message :as msg])

(msg/user [(content/text-part "描述这张图") (content/image-part (io/file "cat.png"))])

中立多模态内容部件(content parts)—— provider 无关的图片 / 文件 / 音频表示。

消息的 `:content` 有两种合法形态:

1. **字符串**(既有形态,纯文本,最常用)
2. **部件向量**(多模态):

```clojure
{:role :user
 :content [(content/text-part "这张图里有什么?")
           (content/image-part "https://example.com/cat.png")]}
```

部件形状(对齐 Vercel AI SDK 的 `LanguageModelPrompt` 部件模型——
图片 / 音频 / PDF **不各立一类**,统一是 `:file` + `:media-type`,
wire 层按 media type 的顶层类别分派;这样新格式不需要新部件类型):

```clojure
{:type :text :text "..."}
{:type :file :media-type "image/png"       :data "<base64>"}   ; 内联数据
{:type :file :media-type "image/png"       :url  "https://..."} ; 远端 URL
{:type :file :media-type "application/pdf" :data "<base64>" :filename "a.pdf"}
```

约束与边界:

- **中立层只搬运,不解释**:能不能发(Anthropic 不收音频、OpenAI 的 PDF 只收内联)
  由各 provider 的 wire 转换器判定,不支持时抛规范错误(`:validation-error`,
  不可重试),**不静默丢内容**。
- **与厂商原生块共存**:`:content` 向量里 `:type` 为**字符串**的元素(如
  Anthropic 的 `{:type "text"}` / `{:type "document"}`)被视为厂商原生块,
  wire 层**原样透传**——既有那条逃生通道不受影响,两者可混用。
- 部件里的二进制一律以 **base64 字符串**落地:对话历史要经 EDN / JSON 往返
  (SQLite store),字节数组过不去。

(require '[im.ttalk.agent.model.content :as content]
         '[im.ttalk.agent.model.message :as msg])

(msg/user [(content/text-part "描述这张图")
           (content/image-part (io/file "cat.png"))])
raw docstring

im.ttalk.agent.model.embedding

Embedding Provider 协议(端口)

与 ILLMProvider 并列而非从属:embedding 模型和对话模型是两种不同的模型, 端点、参数、计费都不同,一个 provider 实例支持哪一种是它自己的事(对齐 Vercel AI SDK 把 LanguageModel 与 EmbeddingModel 分成两个规格)。

所以这里不给 extend-type Object 默认实现——satisfies? 必须是可信的能力 探测(ILLMProvider 就栽在有兜底上,见 model/provider? 注释)。 不支持 embedding 的厂商(如 Anthropic)不实现本协议,调用方 satisfies? 一问便知,不需要「调了才报不支持」。

实现示例:

(defrecord MyEmbeddings [config] IEmbeddingProvider (embed [_ call-config texts] {:embeddings [[0.1 0.2]] :model "m"}))

Embedding Provider 协议(端口)

与 `ILLMProvider` **并列而非从属**:embedding 模型和对话模型是两种不同的模型,
端点、参数、计费都不同,一个 provider 实例支持哪一种是它自己的事(对齐 Vercel
AI SDK 把 `LanguageModel` 与 `EmbeddingModel` 分成两个规格)。

所以这里**不给 `extend-type Object` 默认实现**——`satisfies?` 必须是可信的能力
探测(`ILLMProvider` 就栽在有兜底上,见 `model/provider?` 注释)。
不支持 embedding 的厂商(如 Anthropic)不实现本协议,调用方 `satisfies?`
一问便知,不需要「调了才报不支持」。

实现示例:

(defrecord MyEmbeddings [config]
  IEmbeddingProvider
  (embed [_ call-config texts] {:embeddings [[0.1 0.2]] :model "m"}))
raw docstring

im.ttalk.agent.model.error

LLM 错误处理模块

提供统一的错误类型、分类和处理机制。

错误类型:

  • :network-error 网络连接错误(可重试)
  • :timeout-error 请求超时(可重试)
  • :rate-limit-error 速率限制(可重试)
  • :auth-error 认证失败(不可重试)
  • :validation-error 参数验证失败(不可重试)
  • :parse-error 响应解析失败(不可重试)
  • :provider-error Provider 特定错误
  • :quota-exhausted-error 欠费 / 额度用尽 / 402(不可重试;不是限流,可切备用模型)
  • :context-overflow-error 请求超出上下文窗口(不可重试、不切备用,留给压缩恢复)
  • :empty-completion-error HTTP 200 但无有效内容(可重试,但只走 ChatModel 的独立预算)

厂商业务码(智谱 1309、OpenAI insufficient_quota@429 …)的细分在 provider 边界做 (im.ttalk.agent.provider.common.business-codes),core 只认状态码能下的结论(402)。

使用示例:

(require '[im.ttalk.agent.model.error :as errors])

;; 创建错误 (errors/error :timeout-error "请求超时")

;; 检查是否可重试(直接关键字访问) (:retryable? err) ; => true

;; 安全执行 (errors/with-error-handling #(api-call))

LLM 错误处理模块

提供统一的错误类型、分类和处理机制。

错误类型:
- :network-error    网络连接错误(可重试)
- :timeout-error    请求超时(可重试)
- :rate-limit-error 速率限制(可重试)
- :auth-error       认证失败(不可重试)
- :validation-error 参数验证失败(不可重试)
- :parse-error      响应解析失败(不可重试)
- :provider-error   Provider 特定错误
- :quota-exhausted-error  欠费 / 额度用尽 / 402(不可重试;**不是限流**,可切备用模型)
- :context-overflow-error 请求超出上下文窗口(不可重试、不切备用,留给压缩恢复)
- :empty-completion-error HTTP 200 但无有效内容(可重试,但只走 ChatModel 的独立预算)

厂商业务码(智谱 1309、OpenAI insufficient_quota@429 …)的细分在 provider 边界做
(`im.ttalk.agent.provider.common.business-codes`),core 只认状态码能下的结论(402)。

使用示例:

(require '[im.ttalk.agent.model.error :as errors])

;; 创建错误
(errors/error :timeout-error "请求超时")

;; 检查是否可重试(直接关键字访问)
(:retryable? err) ; => true

;; 安全执行
(errors/with-error-handling #(api-call))
raw docstring

im.ttalk.agent.model.message

中立消息格式 - provider 无关的对话消息表示

这是对话历史的唯一真相形态。Memory store 只存这种形状, 发送给 LLM 时由各 provider 的 wire 转换器转成自家格式 (见 llm/wire/openai、llm/wire/anthropic)。

消息形状:

{:role :system :content "..."} {:role :user :content "..."} {:role :assistant :content "..."} ; 纯文本 {:role :assistant :content nil :tool-calls [{:id "c1" :name "get_weather" :args {:city "北京"}}]} {:role :tool :tool-call-id "c1" :name "get_weather" :content "晴 22°C"}

消息可带 :id(可选):进 ChatMemory 时由 store 补上(ensure-id), 一旦落库就不再变。它解决的是跨快照稳定:AG-UI 的 MESSAGES_SNAPSHOT 与 /threads/:id/messages 以前只能按下标合成 id(m-0/m-1…),而 heal-dangling / replace-tool-results 会让位置漂——同一条消息在两次快照里 拿到不同的 id,前端据此做的增量更新就错位。

它与事件流里的 message-id 不是一个 id 空间(那边是 <run-id>-mN,由发射器 在流式过程中现给)。要合成一个是另一件事:得让发射器的 id 流进 response->neutral, 而那是 core 依赖 agui 的方向,不做。

约束:

  • role 用 keyword(:system/:user/:assistant/:tool)
  • tool-call: {:id 字符串 :name 字符串 :args map}
  • tool 消息的 :tool-call-id 关联到对应调用的 :id

assistant 消息可携带 :blocks(可选):

{:role :assistant :content "..." :tool-calls [...] :blocks {:provider :minimax :format :anthropic-content :data [...]}}

这是「必须原样带回下一轮」的不透明载荷(Anthropic thinking 块 + signature、 Gemini thought_signature 之类)。性质与 :tool 消息的 :writes 一样—— 中立层只搬运、不解释;区别是 wire 层会消费它,但只由认得 :format 的 那一方消费,认不出就当它不存在(降级路径见 wire/anthropic)。

来源:chat-model 归一化时经可选协议 model/IReplayableResponse 抽取, 由 filter/memory 的 response->neutral 挂上来。 动机与实测代价见 docs/provider-variant-design.md(不回传:正确率 100%→82.5%)。

中立消息格式 - provider 无关的对话消息表示

这是对话历史的唯一真相形态。Memory store 只存这种形状,
发送给 LLM 时由各 provider 的 wire 转换器转成自家格式
(见 llm/wire/openai、llm/wire/anthropic)。

消息形状:

{:role :system    :content "..."}
{:role :user      :content "..."}
{:role :assistant :content "..."}                       ; 纯文本
{:role :assistant :content nil
 :tool-calls [{:id "c1" :name "get_weather" :args {:city "北京"}}]}
{:role :tool :tool-call-id "c1" :name "get_weather" :content "晴 22°C"}

消息可带 **:id**(可选):**进 ChatMemory 时由 store 补上**(`ensure-id`),
一旦落库就不再变。它解决的是**跨快照稳定**:AG-UI 的 `MESSAGES_SNAPSHOT` 与
`/threads/:id/messages` 以前只能按下标合成 id(`m-0`/`m-1`…),而
`heal-dangling` / `replace-tool-results` 会让位置漂——同一条消息在两次快照里
拿到不同的 id,前端据此做的增量更新就错位。

**它与事件流里的 message-id 不是一个 id 空间**(那边是 `<run-id>-mN`,由发射器
在流式过程中现给)。要合成一个是另一件事:得让发射器的 id 流进 `response->neutral`,
而那是 core 依赖 agui 的方向,不做。

约束:
- role 用 keyword(:system/:user/:assistant/:tool)
- tool-call: {:id 字符串 :name 字符串 :args map}
- tool 消息的 :tool-call-id 关联到对应调用的 :id

assistant 消息可携带 **:blocks**(可选):

{:role :assistant :content "..." :tool-calls [...]
 :blocks {:provider :minimax :format :anthropic-content :data [...]}}

这是「必须原样带回下一轮」的**不透明载荷**(Anthropic thinking 块 + signature、
Gemini thought_signature 之类)。性质与 :tool 消息的 :writes 一样——
**中立层只搬运、不解释**;区别是 wire 层**会**消费它,但只由认得 :format 的
那一方消费,认不出就当它不存在(降级路径见 wire/anthropic)。

来源:chat-model 归一化时经可选协议 model/IReplayableResponse 抽取,
由 filter/memory 的 response->neutral 挂上来。
动机与实测代价见 docs/provider-variant-design.md(不回传:正确率 100%→82.5%)。
raw docstring

im.ttalk.agent.model.request

ChatRequest —— 发往 ChatModel 的一次调用请求

对标 Spring AI 的 Prompt(List<Message>, ChatOptions):消息 + 选项两段, 没有第三样东西。wire 级参数(:tools / :tool-choice / :system-prompt / :model / :max-tokens / provider 私有键…)一律进 :options——它们的共同点 是「会影响这一次 HTTP 请求长什么样」,而 :messages 是内容本身。

为什么是叶子 ns:chat-model 要它(作为入参)、filter 要它(ChatClientRequest 裹着它)、chat-client 要它(在 terminal 组装)。放在任何一个里面都会给另外两个 造出反向依赖,故单独成 ns,只依赖 clojure.core。

与 ChatClientRequest 的分工(两层,别混):

ChatClientRequest{:request ChatRequest, :context Context} ← filter 洋葱链看到的 ChatRequest{:messages [...], :options {...}} ← ChatModel 看到的

filter 链层多出来的那一样是 :context(请求级共享状态,只读)——它不下发给 provider,所以不能混进 ChatRequest。这正是 Spring 把 ChatClientRequest 与 Prompt 分成两个类的理由。

构造走 chat-request / as-chat-request;as-chat-request 收 map 字面量并 归一化成 record(与 filter/as-filter 同一套手法——用户侧照旧写 map,装配期 / 入口处转一次)。

ChatRequest —— 发往 ChatModel 的一次调用请求

对标 Spring AI 的 `Prompt(List<Message>, ChatOptions)`:**消息 + 选项**两段,
没有第三样东西。wire 级参数(`:tools` / `:tool-choice` / `:system-prompt` /
`:model` / `:max-tokens` / provider 私有键…)一律进 `:options`——它们的共同点
是「会影响这一次 HTTP 请求长什么样」,而 `:messages` 是内容本身。

**为什么是叶子 ns**:`chat-model` 要它(作为入参)、`filter` 要它(`ChatClientRequest`
裹着它)、`chat-client` 要它(在 terminal 组装)。放在任何一个里面都会给另外两个
造出反向依赖,故单独成 ns,只依赖 `clojure.core`。

**与 `ChatClientRequest` 的分工**(两层,别混):

  ChatClientRequest{:request ChatRequest, :context Context}   ← filter 洋葱链看到的
  ChatRequest{:messages [...], :options {...}}                ← ChatModel 看到的

filter 链层多出来的那一样是 `:context`(请求级共享状态,只读)——它**不下发给
provider**,所以不能混进 ChatRequest。这正是 Spring 把 `ChatClientRequest` 与
`Prompt` 分成两个类的理由。

构造走 `chat-request` / `as-chat-request`;`as-chat-request` 收 map 字面量并
归一化成 record(与 `filter/as-filter` 同一套手法——用户侧照旧写 map,装配期
/ 入口处转一次)。
raw docstring

im.ttalk.agent.model.response

LLM 响应协议和类型定义

定义 Core 工具调用循环所需的响应接口契约。 第三方只需依赖 clj-agent-core 即可实现自定义响应类型。

核心概念:

  • ILLMResponse: 响应协议,定义 Core 所需的方法
  • ChatResponse: 默认实现,使用 record
  • make-response: 工厂函数,创建规范化的响应

使用示例:

(require '[im.ttalk.agent.model.response :as resp])

;; 创建响应 (def r (resp/make-response :text "你好" :tool-calls [{:id "1" :name "calc" :args {:x 1}}] :usage {:input_tokens 100 :output_tokens 50}))

;; 使用协议方法 (resp/response-text r) ; => "你好" (resp/has-tool-calls? r) ; => true (resp/response-tool-calls r) ; => [{:id "1" ...}]

LLM 响应协议和类型定义

定义 Core 工具调用循环所需的响应接口契约。
第三方只需依赖 clj-agent-core 即可实现自定义响应类型。

核心概念:
- ILLMResponse: 响应协议,定义 Core 所需的方法
- ChatResponse: 默认实现,使用 record
- make-response: 工厂函数,创建规范化的响应

使用示例:

(require '[im.ttalk.agent.model.response :as resp])

;; 创建响应
(def r (resp/make-response
         :text "你好"
         :tool-calls [{:id "1" :name "calc" :args {:x 1}}]
         :usage {:input_tokens 100 :output_tokens 50}))

;; 使用协议方法
(resp/response-text r)        ; => "你好"
(resp/has-tool-calls? r)      ; => true
(resp/response-tool-calls r)  ; => [{:id "1" ...}]
raw docstring

im.ttalk.agent.model.spec

模型元数据(ModelSpec)与注册表(designs/provider-reliability.md §8; Keelson Models/ModelSpec.cs、Models/ModelRegistry.cs)。

压缩(触发线 = 上下文窗口 × 百分比)、工具结果截断、thinking 预算上限、主备链 (chat-model.fallback/from-registry)都要问「这个模型的窗口 / 上限 / 能力 / 备选是什么」。 本 ns 只放结构与查询 API:不内置模型表(会过期,且是假想需求)、不按 spec 造 ChatModel (造法由宿主给)。

  • ref = "<provider>/<线上的模型 id>",按第一个斜杠拆("openrouter/anthropic/claude" → ["openrouter" "anthropic/claude"])。
  • 注册表是实例(atom),没有全局单例(design-principles §3)。
(def reg (spec/registry))
(spec/register! reg "anthropic/claude-sonnet-4"
  {:context-limit 200000 :output-limit 64000 :fallbacks ["openai/gpt-4o"]})
(spec/context-limit reg "anthropic/claude-sonnet-4")   ;=> 200000
(spec/fallback-chain reg "anthropic/claude-sonnet-4")  ;=> ["openai/gpt-4o"]
模型元数据(ModelSpec)与注册表(designs/provider-reliability.md §8;
Keelson `Models/ModelSpec.cs`、`Models/ModelRegistry.cs`)。

压缩(触发线 = 上下文窗口 × 百分比)、工具结果截断、thinking 预算上限、主备链
(`chat-model.fallback/from-registry`)都要问「这个模型的窗口 / 上限 / 能力 / 备选是什么」。
本 ns **只放结构与查询 API**:不内置模型表(会过期,且是假想需求)、不按 spec 造 ChatModel
(造法由宿主给)。

- ref = `"<provider>/<线上的模型 id>"`,按**第一个**斜杠拆(`"openrouter/anthropic/claude"` →
  `["openrouter" "anthropic/claude"]`)。
- 注册表是**实例**(atom),没有全局单例(design-principles §3)。

```clojure
(def reg (spec/registry))
(spec/register! reg "anthropic/claude-sonnet-4"
  {:context-limit 200000 :output-limit 64000 :fallbacks ["openai/gpt-4o"]})
(spec/context-limit reg "anthropic/claude-sonnet-4")   ;=> 200000
(spec/fallback-chain reg "anthropic/claude-sonnet-4")  ;=> ["openai/gpt-4o"]
```
raw docstring

im.ttalk.agent.retry

通用重试:指数退避 + 满抖动,判据取自 canonical error

落点在 ChatModel,位于整个 filter 栈之下(与 beamai beamai_chat_model 同一取舍)。这不是随手放的——filter 看到的必须是「一次逻辑调用」:

:chat filter(memory / 计时 / 记账) └ ChatModel.call ──┐ ├ 尝试 1 ✗ → 退避 → 尝试 2 ✓ ← 重试在这里面 └ 返回一个响应

若重试在 filter 链之上(或做成一个 filter),一次网络抖动就会让 memory-filter 把同一轮 delta 写两遍、让计时 filter 记出两条记录。放在链下, 重试重入碰不到任何 filter,around_chat 的副作用每轮只发生一次。 要观测每次真实尝试,用 :on-retry 回调——它是为此存在的,不是日志糖。

流式只在首个 token 之前重试:token 已经投递给 sink 之后重跑会让下游看到 重复内容,那之后的失败原样抛;要容错请在 turn 层重跑整轮。首个 token 之前 (建连失败、503、首字节超时)没有任何东西出门,重发安全——stream-call 用 :retry-gate + 独立预算 :stream-retries 表达这一点(designs/provider-reliability.md §2)。

空补全(HTTP 200 但无有效内容)走独立预算 :empty-retries,不占 :max-retries。

判据是 canonical error 的 :retryable?,不是 HTTP 状态码——那是 provider 边界的事(见 provider.http.client/response->error,它顺带把 Retry-After 解析成 :retry-after-ms 塞进错误 map,core 只读那个数)。 core 因此对 HTTP 一无所知;本 ns 无外部依赖,只读 canonical error 里的两个键 (:retryable? / :retry-after-ms)。(异步版 run-async 用到 core 内的链结果 组合子 im.ttalk.agent.filter,仍无第三方依赖。)

同步 / 异步两个入口共用一套判据:run 与 run-async 的重试次数、 retryable-ex?、退避曲线(含 Retry-After 与满抖动)都走同一批函数, 只差「等待」的实现——同步 Thread/sleep,异步 CompletableFuture/delayedExecutor (绝不能在异步路径上 sleep:那会睡掉传输层 executor 的线程)。

参数三级取值:单次 opts > provider config > 框架默认;:max-retries 0 即关闭(等价于直接调用,零开销)。

通用重试:指数退避 + 满抖动,判据取自 canonical error

**落点在 ChatModel,位于整个 filter 栈之下**(与 beamai `beamai_chat_model`
同一取舍)。这不是随手放的——filter 看到的必须是「一次逻辑调用」:

  :chat filter(memory / 计时 / 记账)
    └ ChatModel.call ──┐
                       ├ 尝试 1 ✗ → 退避 → 尝试 2 ✓   ← 重试在这里面
                       └ 返回一个响应

若重试在 filter 链**之上**(或做成一个 filter),一次网络抖动就会让
memory-filter 把同一轮 delta 写两遍、让计时 filter 记出两条记录。放在链下,
重试重入碰不到任何 filter,`around_chat` 的副作用每轮只发生一次。
要观测每次**真实尝试**,用 `:on-retry` 回调——它是为此存在的,不是日志糖。

**流式只在首个 token 之前重试**:token 已经投递给 sink 之后重跑会让下游看到
重复内容,那之后的失败原样抛;要容错请在 turn 层重跑整轮。首个 token 之前
(建连失败、503、首字节超时)没有任何东西出门,重发安全——`stream-call` 用
`:retry-gate` + 独立预算 `:stream-retries` 表达这一点(designs/provider-reliability.md §2)。

**空补全**(HTTP 200 但无有效内容)走独立预算 `:empty-retries`,不占 `:max-retries`。

**判据是 canonical error 的 `:retryable?`**,不是 HTTP 状态码——那是
provider 边界的事(见 `provider.http.client/response->error`,它顺带把
`Retry-After` 解析成 `:retry-after-ms` 塞进错误 map,core 只读那个数)。
core 因此对 HTTP 一无所知;本 ns **无外部依赖**,只读 canonical error 里的两个键
(`:retryable?` / `:retry-after-ms`)。(异步版 `run-async` 用到 core 内的链结果
组合子 `im.ttalk.agent.filter`,仍无第三方依赖。)

**同步 / 异步两个入口共用一套判据**:`run` 与 `run-async` 的重试次数、
`retryable-ex?`、退避曲线(含 `Retry-After` 与满抖动)都走同一批函数,
只差「等待」的实现——同步 `Thread/sleep`,异步 `CompletableFuture/delayedExecutor`
(绝不能在异步路径上 sleep:那会睡掉传输层 executor 的线程)。

参数三级取值:**单次 opts > provider config > 框架默认**;`:max-retries 0`
即关闭(等价于直接调用,零开销)。
raw docstring

im.ttalk.agent.streaming

流式取消令牌(cancel token)。

用途:让应用层在需要时(如 web handler 客户端断连)中止正在进行的 chat-stream—— 取消上游 HTTP(停止烧 token)并让 ReAct 循环停在当前回合。

用法(应用层): (require '[im.ttalk.agent.streaming :as st] '[im.ttalk.agent.simple-agent :as agent]) (def token (st/make-cancel-token)) (future (agent/chat-stream a "消息" on-token {:cancel-token token})) ;; 客户端断连时: (st/request-cancel! token)

设计:令牌穿过 chat-stream → react 循环(回合间据它停止);react 在调 provider 流式前 把动态 var register-cancel 绑成「登记当前令牌的在途 cancel-fn」。provider 拿到 stream_client 的 cancel 后登记之——无需改协议 / config 签名。整条同步调用链同线程, 动态 var 可见;request-cancel! 直接操作令牌(可跨线程)。

流式取消令牌(cancel token)。

用途:让应用层在需要时(如 web handler 客户端断连)中止正在进行的 chat-stream——
取消上游 HTTP(停止烧 token)并让 ReAct 循环停在当前回合。

用法(应用层):
  (require '[im.ttalk.agent.streaming :as st]
           '[im.ttalk.agent.simple-agent :as agent])
  (def token (st/make-cancel-token))
  (future (agent/chat-stream a "消息" on-token {:cancel-token token}))
  ;; 客户端断连时:
  (st/request-cancel! token)

设计:令牌穿过 chat-stream → react 循环(回合间据它停止);react 在调 provider 流式前
把动态 var *register-cancel* 绑成「登记当前令牌的在途 cancel-fn」。provider 拿到
stream_client 的 cancel 后登记之——**无需改协议 / config 签名**。整条同步调用链同线程,
动态 var 可见;request-cancel! 直接操作令牌(可跨线程)。
raw docstring

im.ttalk.agent.tokens

token 粗估(零依赖)——上下文压缩(filter.compaction)、工具结果截断 (filter.tool-output)与模型审阅者的证据预算共用一份口径。

口径(宁高勿低:低估会让压缩来不及触发,高估只是早压一点):

  • ASCII 每 4 个字符 1 token(英文/代码的经验值);
  • 非 ASCII 每字符 1 token(CJK 实测约 0.6–1.2,取保守端);
  • 每条消息 +4(角色/分隔开销);
  • 工具调用 = 名字 + 参数 pr-str;
  • 图片 / 文件部件按固定值(image-tokens / document-tokens,同 Keelson CompactionBudget:⛔ 按 base64 长度算——那会把一张图算成几万 token)。

设计见 designs/runtime-borrow.md §1。

token 粗估(零依赖)——上下文压缩(`filter.compaction`)、工具结果截断
(`filter.tool-output`)与模型审阅者的证据预算共用一份口径。

口径(宁高勿低:低估会让压缩来不及触发,高估只是早压一点):
- ASCII 每 4 个字符 1 token(英文/代码的经验值);
- 非 ASCII 每字符 1 token(CJK 实测约 0.6–1.2,取保守端);
- 每条消息 +4(角色/分隔开销);
- 工具调用 = 名字 + 参数 `pr-str`;
- 图片 / 文件部件按固定值(`image-tokens` / `document-tokens`,同 Keelson
  `CompactionBudget`:⛔ 按 base64 长度算——那会把一张图算成几万 token)。

设计见 designs/runtime-borrow.md §1。
raw docstring

im.ttalk.agent.tool

工具函数定义宏

deftool 宏同时生成:

  1. 普通 Clojure 函数(可直接调用)
  2. 完整的 tool schema 元数据(用于 LLM Function Calling)

参数声明使用向量格式: [名称 类型 描述] 支持可选参数: [名称 类型 描述 :default 默认值] 支持参数约束: [名称 类型 描述 :enum [..] :pattern ".." :minimum 1 :maximum 9 :min-length n :max-length n :items {:type "string"} :min-items n :max-items n :properties {…} :required [..] :additional-properties false] (进 input_schema,模型看得到;调用前按 schema 完整校验,见 tool-args) 对象参数的属性表可写成 [[名称 schema] …] 键值对 vector——保序,字段多于 8 个时用这个写法 (map 字面量多于 8 键会被 Clojure reader 变成 hash map,键序不再跟源码文本一致)。 数组条目的对象(:items {:type "object" :properties …})与更深的嵌套对象都吃同一套写法。

schema 在命名空间加载期构造(不是常量):AOT 编译会把常量 map 重建(>8 键即 PersistentHashMap),参数声明顺序会丢;加载期构造则 eval 与 AOT 完全一致。 支持敏感标记: {:sensitive true} 支持只读 context: {:context true} 支持串行标记: {:serial true}(副作用工具,批内并行时整批退化为按序执行) 支持直接返回: {:return-direct true}(结果即最终答案,不再回灌 LLM)

状态读写(Tool 阶段 MapReduce 契约,见 docs/agent-loop-concurrency-design.md §9):

  • 读:{:context true} 工具收到只读的轮初 context 快照(ctx 变量自动绑定)
  • 写:任意工具(无论是否声明 :context)返回 {:result r :writes {k v}} 声明写意图;批次屏障处按 tool-call 原始序经槽级 reducer 折叠进 context。 同批工具互相看不到对方的写(快照隔离);失败/超时/拒绝的调用 writes 不生效。

使用示例:

;; 基本用法 (deftool get-weather "获取指定城市的天气信息" [[city :string "城市名称"]] (fetch-weather city))

;; 带可选参数 (deftool calculate "执行数学计算" [[expression :string "数学表达式"] [precision :int "小数精度" :default 2]] (compute expression precision))

;; 标记敏感工具 (deftool delete-file "删除文件" [[path :string "文件路径"]] {:sensitive true} (io/delete-file path))

;; 带 tags 的工具 (deftool get-weather "获取天气" [[city :string "城市名称"]] {:tags [:weather :external-api :read-only]} (fetch-weather city))

;; 读 context(只读快照)+ 写状态(:writes 声明写意图) (deftool save-note "保存笔记" [[content :string "内容"]] {:context true} ;; ctx 是自动绑定的只读 context 变量 (let [user (context/get-var ctx :user-id)] {:result (str "已保存(" user ")") :writes {:notes content}})) ;; 槽 :notes 的 reducer 决定如何合并

;; 无参数函数 (deftool get-time "获取当前时间" [] (str (java.time.LocalDateTime/now)))

工具函数定义宏

deftool 宏同时生成:
1. 普通 Clojure 函数(可直接调用)
2. 完整的 tool schema 元数据(用于 LLM Function Calling)

参数声明使用向量格式: [名称 类型 描述]
支持可选参数: [名称 类型 描述 :default 默认值]
支持参数约束: [名称 类型 描述 :enum [..] :pattern ".." :minimum 1 :maximum 9
              :min-length n :max-length n :items {:type "string"} :min-items n :max-items n
              :properties {…} :required [..] :additional-properties false]
              (进 input_schema,模型看得到;调用前按 schema 完整校验,见 tool-args)
对象参数的属性表可写成 [[名称 schema] …] 键值对 vector——**保序**,字段多于 8 个时用这个写法
(map 字面量多于 8 键会被 Clojure reader 变成 hash map,键序不再跟源码文本一致)。
数组条目的对象(`:items {:type "object" :properties …}`)与更深的嵌套对象都吃同一套写法。

**schema 在命名空间加载期构造**(不是常量):AOT 编译会把常量 map 重建(>8 键即
PersistentHashMap),参数声明顺序会丢;加载期构造则 eval 与 AOT 完全一致。
支持敏感标记: {:sensitive true}
支持只读 context: {:context true}
支持串行标记: {:serial true}(副作用工具,批内并行时整批退化为按序执行)
支持直接返回: {:return-direct true}(结果即最终答案,不再回灌 LLM)

状态读写(Tool 阶段 MapReduce 契约,见 docs/agent-loop-concurrency-design.md §9):
- 读:{:context true} 工具收到只读的轮初 context 快照(ctx 变量自动绑定)
- 写:任意工具(无论是否声明 :context)返回 {:result r :writes {k v}}
  声明写意图;批次屏障处按 tool-call 原始序经槽级 reducer 折叠进 context。
  同批工具互相看不到对方的写(快照隔离);失败/超时/拒绝的调用 writes 不生效。

使用示例:

;; 基本用法
(deftool get-weather
  "获取指定城市的天气信息"
  [[city :string "城市名称"]]
  (fetch-weather city))

;; 带可选参数
(deftool calculate
  "执行数学计算"
  [[expression :string "数学表达式"]
   [precision :int "小数精度" :default 2]]
  (compute expression precision))

;; 标记敏感工具
(deftool delete-file
  "删除文件"
  [[path :string "文件路径"]]
  {:sensitive true}
  (io/delete-file path))

;; 带 tags 的工具
(deftool get-weather
  "获取天气"
  [[city :string "城市名称"]]
  {:tags [:weather :external-api :read-only]}
  (fetch-weather city))

;; 读 context(只读快照)+ 写状态(:writes 声明写意图)
(deftool save-note
  "保存笔记"
  [[content :string "内容"]]
  {:context true}
  ;; ctx 是自动绑定的只读 context 变量
  (let [user (context/get-var ctx :user-id)]
    {:result (str "已保存(" user ")")
     :writes {:notes content}}))   ;; 槽 :notes 的 reducer 决定如何合并

;; 无参数函数
(deftool get-time
  "获取当前时间"
  []
  (str (java.time.LocalDateTime/now)))
raw docstring

im.ttalk.agent.tool-args

按 JSON Schema 完整校验工具参数(designs/runtime-borrow.md §8,借 Keelson Tools/ToolArgsValidator.cs)。

模型给了枚举外的值、负数、错类型时,与其让工具函数体炸成一句看不懂的异常, 不如一次报全每处错误、带 JSON 路径、告诉模型期望什么——它多半下一轮就改对了。

认的关键字:type required properties items enum pattern minimum maximum exclusiveMinimum exclusiveMaximum minLength maxLength minItems maxItems。$ref / anyOf / oneOf / allOf / format 不深入。

宽容的地方(向后兼容,刻意):

  • integer / number 接受数字字符串(模型常给数字加引号,函数体以前也照样拿到); boolean 接受 "true"/"false";
  • 可选参数给 null 视同没给;
  • 未知键不报错。

schema 与参数的键既可以是 keyword 也可以是字符串(deftool 产 keyword、MCP 给字符串)。

按 JSON Schema 完整校验工具参数(designs/runtime-borrow.md §8,借 Keelson
`Tools/ToolArgsValidator.cs`)。

模型给了枚举外的值、负数、错类型时,与其让工具函数体炸成一句看不懂的异常,
不如**一次报全**每处错误、带 JSON 路径、告诉模型期望什么——它多半下一轮就改对了。

认的关键字:`type` `required` `properties` `items` `enum` `pattern`
`minimum` `maximum` `exclusiveMinimum` `exclusiveMaximum` `minLength` `maxLength`
`minItems` `maxItems`。`$ref` / `anyOf` / `oneOf` / `allOf` / `format` 不深入。

**宽容的地方(向后兼容,刻意)**:
- integer / number 接受数字字符串(模型常给数字加引号,函数体以前也照样拿到);
  boolean 接受 "true"/"false";
- 可选参数给 `null` 视同没给;
- 未知键不报错。

schema 与参数的键既可以是 keyword 也可以是字符串(deftool 产 keyword、MCP 给字符串)。
raw docstring

im.ttalk.agent.tool-registry

工具注册表:装配期把工具声明汇成一张表,运行期一次查表答完

从 chat-client 拆出来的一块。判据是「这些函数认识什么」——它们全部只认识 工具声明(:serial / :retry / :timeout / :return-direct / schema), 一个都不认识 ChatModel、filter 链或消息。chat-client 因此瘦回它的本分: record + 装配 + 三个 invoke 原语。

对照 beamai 的 beamai_tool / beamai_tool_index——同样是把工具那一摊从 ChatClient 里分出去;Spring AI 对应的是 ToolCallback 注册表那一层。

装配期做掉,运行期只查表:build-tool-meta 在 build-chat-client 时把 var 工具与内联工具两个来源汇成 {keyword -> ToolMeta};此前四个查询函数 各自写一遍 (if-let [v (get tool-vars k)] (读 var 元数据) (查 inline-meta)) ——同一段双分支手抄四遍,:timeout 就是在那种重复里漏掉 inline 分支、对内联 工具静默失效的。

本 ns 的查询函数吃 ChatClient(只读 :tool-meta / :tool-vars / :tool-manager 三个键,不 require chat-client,故无循环依赖)。

工具注册表:装配期把工具声明汇成一张表,运行期一次查表答完

从 `chat-client` 拆出来的一块。判据是「这些函数认识什么」——它们全部只认识
**工具声明**(`:serial` / `:retry` / `:timeout` / `:return-direct` / schema),
一个都不认识 ChatModel、filter 链或消息。`chat-client` 因此瘦回它的本分:
record + 装配 + 三个 invoke 原语。

对照 beamai 的 `beamai_tool` / `beamai_tool_index`——同样是把工具那一摊从
ChatClient 里分出去;Spring AI 对应的是 `ToolCallback` 注册表那一层。

**装配期做掉,运行期只查表**:`build-tool-meta` 在 `build-chat-client` 时把
var 工具与内联工具两个来源汇成 `{keyword -> ToolMeta}`;此前四个查询函数
各自写一遍 `(if-let [v (get tool-vars k)] (读 var 元数据) (查 inline-meta))`
——同一段双分支手抄四遍,`:timeout` 就是在那种重复里漏掉 inline 分支、对内联
工具静默失效的。

本 ns 的查询函数**吃 ChatClient**(只读 `:tool-meta` / `:tool-vars` /
`:tool-manager` 三个键,不 require chat-client,故无循环依赖)。
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