本项目版本号形如 0.x.<git-count>(各模块同步)。本文件按 Keep a Changelog 组织。
minor 走到 0.5:新增模块 clj-http(原 clj-agent-http,通用 HTTP 客户端)、clj-agent-server、clj-agent-server-jetty,
clj-agent-mcp 首次进发布表;另有缺省行为变化(见下)。各模块同步 0.5.<git-count>。
0.4.435 原样留在本地仓库(Keelson 的 clj-agui-server 依赖它),不被覆盖。
总览:designs/http-module.md、designs/server-module.md;任务 tasks/H-*、S-*。
新模块 clj-http(designs/http-module.md,任务 tasks/H-*,2026-11-09):零依赖的 HTTP 传输,
provider / mcp / tools 原先各自的 4 套 java.net.http 用法收成一处——http/client、request /
request-async、按行真流式 stream-lines(首字节 / 空闲 watchdog)、大小写不敏感的头工具。
从 Keelson clj-net 移植:SSE 分帧(WHATWG)、环境变量代理(:proxy :env,opt-in)、网络错分类、
HTTP 日期三格式与 retry-after-ms 头。provider 的 provider.http.* 保留为门面,公开 API 不变;
MCP http-transport 与 web_fetch 新增 :client / :proxy 选项。
2026-11-10 改名为 im.ttalk/clj-http(目录 modules/clj-http,ns 前缀 im.ttalk.http),定位为与 agent 无关的通用 HTTP
客户端;并补上 SSE 拼帧(event-frame / comment-frame / retry-frame)。
新模块 clj-agent-server + clj-agent-server-jetty(designs/server-module.md,任务 tasks/S-*,2026-11-10):AG-UI 与
MCP 的 HTTP 服务端,服务端协议就是 Ring SPEC。AG-UI 那组路由(/info、/run、/connect、/suggest、/stop、/threads*)移植自
Keelson clj-agui-server,路由 / 访问守卫 / SSE / Jetty 宿主移植自 clj-net-server;MCP Streamable HTTP 端点转正自
examples/mcp/server_example.clj。Jetty 12 是缺省 adapter;http-kit / Undertow 经 im.ttalk.agent.server.adapter.* 垫片
可直接替换(它们不实现 SPEC 的流式正文)。示例 examples/server/demo.clj(clojure -M:server)。
server.agui 另支持注册表条目(或 host 选项缺省)的 :input-transform 与 :mcp-proxy(MCP Apps 前端代理:不起 run)。
examples/copilotkit/http_kit_routes.clj 改为调用 clj-agent-server(1140 → 192 行):只剩装配、换 http-kit 与两条 demo
自己的应用端点(/pending、/approve)。start! / start-registry! / stop! 签名不变。
provider:宿主请求体钩子 :configure-request + Anthropic 按断点给缓存 TTL(2026-11-10,回
feedbacks/2026-11-10-provider-request-hook-and-two-tier-cache-ttl.md,锦书报):
(fn [payload] payload') 在思考字段 / :extra-body / :stream true 之后、发出之前最后改一次请求体
(common.request-hook;OpenAI 兼容 / Responses / Anthropic,同步 / 流式 / deferred 同一处);
:cache-ttl 可给 map {:system "1h" :conversation nil},校验「长 TTL 在前」与未知键,违者抛 :invalid-cache-ttl。
AG-UI:自带 run 驱动的 agent(2026-11-11,回 feedbacks/2026-11-11-agui-server-external-run-driver.md,锦书报):
注册表条目允许 {:id … :external? true :description … :capabilities …},不再要求占位 runtime;routes 的 host 选项
:agents 可按 agent 注入 :run / :stop Ring 处理器(字面量路由与单路由退路同源),/connect 可交给
(thread-id after-seq deliver) 的外部事件源。deliver 写帧环 + 当前 SSE,终态自动收流;外部条目缺省不继承内置
runtime 的能力位,避免 /info 谎报。
DashScope 原生 provider 新增 :extra-parameters(map,并入 parameters;thinking_budget 等私有字段的逃生通道)。
examples/copilotkit 的路由改走 clj-agent-server 后,线协议以它为准(上游 / C# 口径):输入形状错误统一 bad_input
(原 missing_thread_id / missing_run_id,非对象体原 bad_json);线程改动类请求(PATCH / archive / DELETE)体里要带
agentId,缺了 400;GET /threads/:id 与「POST 同义 PATCH」不再提供(405);CORS 名单外带 Origin 的请求 403(原回 *)。:thinking-style :qwen,及原生 :dashscope):开思考时非流式也写 enable_thinking: true
(原为非流式强制 false,照 Keelson / 旧版百炼文档)。依据:2026-11-10 LiteGate 真机 qwen3.8-flash / max 非流式开思考
正常返回 reasoning_content、不 400。老模型若仍有该限制,用 :configure-request 按 :stream 改回。"1h" 断点时,请求头自动带 anthropic-beta: extended-cache-ttl-2025-04-11
(与 :beta 合并去重)。官方端点已 GA、多发无害;全 5m 时不变。stream_options: {include_usage: true}(与 Keelson.Agent 对齐),末块不原生带
usage 的端点也能拿到账;:stream-options 仍可覆盖,显式 false / {} 关闭。所有 OpenAI 兼容请求非流式一律不发
stream_options,严格端点不再被探针 / 一次性调用打 400。im.ttalk.http/default-client:固定 HTTP/1.1(原先 JDK 缺省先试
HTTP/2)、虚拟线程 executor、连接池共享、建连超时 30s(原 = :timeout-ms)。要原行为传 :client。:timeout-ms)。content 与
reasoning_content,空的那格是 "";stream.openai/process-chunk 用 (:reasoning_content delta) 判断,空串为真,
答案块 {"content":"15","reasoning_content":""} 被当成推理块,content 恒为空。改为判非空(not-empty),
同块两格都非空时推理累积进最终响应、实时发答案 token;空串块不再发空 token。原生 stream.dashscope 同口径。:thinking-effort / :thinking-enabled 被静默丢掉(style-presets 里的
:dashscope :qwen 从未生效——原生路径不走 OpenAI 兼容线)。现写 parameters.enable_thinking(开 true / 关 false,
显式 :enable-thinking 优先、:thinking-style :none 不写);原生流式解析器补上 reasoning_content
(单独发 :reasoning-token,最终响应带 reasoning_content),原先思考内容整段丢失。im.ttalk.http/header,另认 RFC 850 / asctime 日期、小数秒与 retry-after-ms 头。/run 处理器不再在请求体超限时收到 nil:字面量路由与单路由退路都由库先答 413 body_too_large,
不调驱动(回 feedbacks/2026-11-11-agui-server-external-run-driver.md 的复测小缺口)。deftool 对象参数支持嵌套 schema::properties / :required / :additional-properties
(→ additionalProperties)原样进 input_schema,嵌套 required 也参与调用前校验。
input_schema.properties 按参数声明顺序写出,超过 8 个参数不再因 hash map 退化丢序。deftool 的参数保序在 AOT 下失效(0.5.442 的修法只在 eval 下成立):schema 过去是常量,
AOT 编译会把常量 map 重建(>8 键即 PersistentHashMap),发出去的包里参数顺序全乱、单测却是绿的。
现改为命名空间加载期构造(tool/tool-meta + alter-meta!),eval 与 AOT 走同一条路径;
嵌套属性表另给保序写法 [[:字段 {…}] …](键值对 vector),(array-map …) / #ordered/map
的坑写进 docstring 与 README;该写法递归进 :items 与更深嵌套对象(回锦书 0.5.445 复测小缺口)。
判据钉在 tool_test:子进程编译成 class 后加载再比顺序,数组条目属性表也在判据内。data::一条 JSON 拆成多行 data 时解析失败。现按 WHATWG 分帧拼接。provider.http.client 回 {:status 0 :error nil},response->error
认不出连接失败、误当 HTTP 0;现 :error 用异常类名兜底。流式网络错另带细分 :code
(:name-resolution-error / :connection-error / :secure-connection-error)。clj-agent-mcp 从未进 bb test / CI matrix / build.clj 模块表——agui 依赖它,agui 的 pom 因此缺 mcp 这条依赖。provider.http.client/request-id 回的是 List 而不是字符串。::on-stopping(自动解析)读路由元数据,与 AG-UI 路由写的键对不上,
停机钩子会丢——改为显式的 :im.ttalk.agent.server/on-stopping。版本方案 0.x.<git-count>:本轮起 minor 走到 0.4(新增模块 clj-agent-tools +
provider/运行时/AG-UI 三面能力齐涨),各模块同步 0.4.<git-count>。
总览:designs/keelson-borrow.md;任务 tasks/P-*、R-*、G-*、T-*(索引见 TASK.md 顶部一轮)。
designs/provider-reliability.md):错误细分 :quota-exhausted-error /
:context-overflow-error / :empty-completion-error + 厂商业务码表(provider.common.business-codes);
重试增强(空补全独立预算、流式首增量前可重试、:attempts 记录);主备切换
im.ttalk.agent.chat-model.fallback;流式 :first-byte-ms / :idle-ms 死线与回包模型名核对;
并发准入 im.ttalk.agent.admission(chat-model/with-admission);统一限流快照
provider.common.rate-limit;thinking 档位 provider.common.thinking;ModelSpec
im.ttalk.agent.model.spec;response/pause-turn-resumable?;provider.anthropic-server-tools。designs/runtime-borrow.md):上下文压缩 filter.compaction;工具结果截断 / 另存
filter.tool-output;同参失败保护 / 失败摘除 filter.tool-guard;死循环提醒 filter.dead-loop;
声明式审批策略、会话审批缓存、审批链、拒绝熔断、超时 im.ttalk.agent.approval,模型审阅
approval.model;工具参数按 schema 完整校验 tool-args;收尾闸门 completion-gate-filter;
分层指令文件 filter.instructions;生命周期事件 :on-event + events;消息规范化
filter.normalize;子代理异步工具组 subagent.tools;暂停可含多条待审批(:pending-tools,
resume {:decisions {id …}})。designs/agui-borrow.md):审批改参数 approveWithEdits + 拒绝理由透传;一轮多条审批;
SSE 心跳;多 agent 注册表 agui.registry;取消原因(stop / superseded / shutdown / orphan)与
可选孤儿自动停;线程元数据持久化 agui.thread-store(内存 + 按需加载 sqlite);入参校验
400 / 413、/stop 带 interrupt、/threads 过滤 / 分页 / 缺槽 501。agui.event-store/EventStore(runtime :event-store,缺省环形缓冲;新 rt/purge!);
压缩状态 filter.compaction/CompactionStore(:store)与写回策略 :write-back。clj-agent-tools(designs/tools-module.md):工作区路径策略、read / write / list /
glob / grep / delete、edit_file + 读写新鲜度闸、apply_patch(Codex 文法,署名见模块
LICENSE-codex.txt)、exec 与有状态会话、web_fetch / skill / notes、写后语法闸。agui runtime_test/stop-semantics-test 曾间歇失败一次(stop! 返回 false),压测约 45 次未复现:
加诊断(闸门状态 / 实际等待、时间线、run 状态、事件序列随断言打印),并改为放行测试自己持有的闸门,
不再回读全局 atom(原写法会 NPE 吞掉诊断)。只改测试。见 tasks/G-09.md。:retry 改由 ChatModel 统一执行,
不再叠加传输层重试。空补全缺省补打 1 次,仍空原样返回(:empty-exhausted :throw 才抛)。classify-exception 归 :environment;普通 429 照旧重试。pause_turn 现在续跑(原以 :pause 收尾)。{approved:false} 一律当拒绝(原被误判为批准)。:provider 为 nil。subagent.manager/resume! 把 {:payload … :on-token …} 整个当 payload 传给 simple-agent/resume:
子 agent 审批改参数不生效、:reply 抛异常、续跑不流式。改为 payload 与 chat-opts 分开传。v0.3 追加整合(2026-10-22):provider 层补齐第三个主流协议栈
OpenAI Responses(:openai-responses)。上层仍走同一个 ILLMProvider /
中立消息 / ChatModel 边界;wire、流式累积器、工具定义扁平化、
function_call 回放与异步路径都已内建。共享 HTTP 客户端固定 HTTP/1.1,
以兼容 LiteGate 等本地网关(JDK 默认 HTTP/2 upgrade 会被网关直接断连)。
同日修掉两处既有的顺序缺陷(agui / client,与 provider 整合无关,但卡着
提交门禁):子 agent 的 lane 终态事件(SUBAGENT_FINISHED)会间歇性丢失——
① worker 的 deliver 早于 settle!,父 run 可能先收口,lane 终态被「父 run
终态后丢弃」的守卫吞掉;② agui.runtime 的 launch! 在会话锁内发起
chat-async,provider 同步时整轮连工具调用都在锁内跑完,子 agent 的事件全被
挡到门外(实测迟到 10 秒、委派工具先超时)。修法与验收见
docs/subagent-event-attribution-design.md(§3.5 补注 + S5)。
同日另修一个 :memory false 的工具循环残账:无 memory filter 时,工具循环
下一轮只回传了 tool result,没有把上一轮的 assistant(tool_calls) 带上。
OpenAI Chat / Anthropic Messages / OpenAI Responses 三条协议栈在网关侧都会以
「tool id not found」拒绝。现在无记忆循环会自己补上这条 assistant 消息;
有 memory 的历史形状不变。
v0.3 的主线:工具阶段 MapReduce 化(S1/S2)、HITL 持久化与 Timeline、
Spring AI 2.0 Advisor 全面对齐、ToolCallingManager 执行引擎、工具超时内建化、
缺省引擎串行化。设计与动机见 docs/agent-loop-concurrency-design.md(§9 为实施设计)。
从 v0.2 迁移请看文末迁移指南——尤其第 1 节的升级陷阱
(依赖批内并行的工具对在新的串行缺省下会无错误、无日志地永久挂起)。
分层对齐 Spring AI / beamai:类型化 + ChatModel 体系 + ChatClient 拆分 (2026-08-25,紧随改名那轮)。四件事一起做,因为它们是同一条分层线的四段。
1) 请求 / 响应类型化(两层)
ChatClientRequest{:request ChatRequest :context Context :on-token f} ← filter 链
ChatRequest{:messages [...] :options {...}} ← ChatModel
ChatClientResponse{:response ChatResponse :context ctx}
LLMResponse record → ChatResponse(协议名 ILLMResponse 不变)
多出来的那层装 :context 与 :on-token——两者都不下发给 provider。合成
一个类型就得靠白名单去筛「哪些能发出去」,而那张白名单正是
provider-variant-design.md §1.2「递不到底」那个 bug 的成因。
迁移::chat filter 读写请求改走存取器——(:messages req) →
(flt/req-messages req)、(assoc req :tools ts) → (flt/with-option req :tools ts)。
构造仍收 map(as-chat-client-request 归一化),但读字段必须改:两层结构
上 (:messages req) 返回 nil,不会静默给你旧语义。:turn / :iteration 两条
链有各自的请求形状,不受影响。
2) ChatModel 体系 + 重试上移
im.ttalk.agent.chat-model IChatModel 协议(call / stream-call / model-options)
DefaultChatModel(包 ILLMProvider)
FnChatModel(包 {:chat-fn :stream-fn})
im.ttalk.agent.retry 通用重试,零依赖
Provider 只管「怎么发这一个请求」,选项合并 / 重试 / 归一化归 ChatModel。
provider/http/retry.clj 的 chat 路径重试已拆除(anthropic /
openai_compat 两处)——embeddings 与 stream_client 不走 ChatModel,保留。
:retryable?;Retry-After 由 provider 边界解析成
:retry-after-ms 传上来,受 :max-delay 约束:retry false 关闭
迁移:{:chat-fn :stream-fn} 裸 map 照旧可用(as-chat-model 归一化成
FnChatModel);但 (:chat-fn cm) 这种直接取字段的写法要改成
(chat-model/call cm request)。给 ChatModel 埋探针/包一层的老写法
(assoc cm :chat-fn (fn ...)) 同样要改——包一层 IChatModel(reify 或
FnChatModel)在实现里转调。as-chat-model 对这种情况装配期即抛:
record 上 assoc 只是往 ext-map 塞键,协议分派完全看不见,注入会被静默丢弃
且没有任何运行期症状(探针不涨、替换不生效,程序照跑)——与
build-chat-client 拒绝同名工具同一类处置。3) ChatClient 拆分 + Facade
im.ttalk.agent 新增 Facade 入口(对照 beamai 的 beamai.erl)
im.ttalk.agent.tool-registry 新增:ToolMeta + 装配期建表/校验 + 8 个查询
im.ttalk.agent.filter 接收 filter-hooks / with-filters
chat_client.clj 565 → 324 行,公开面只剩 ChatClient record + build-chat-client
prompt/call/stream——beamai 的
beamai_chat_client 同样保留 invoke_tool 与 Query API,它靠的是 Facade + 按
职责拆模块,不是砍 API。
迁移:chat-client/tool-meta / serial-tool? / return-direct-tool? /
retry-policy / tool-timeout / effective-tool-timeout / find-function /
list-functions → tool-registry/*;chat-client/filter-hooks /
with-filters → filter/*。调用形状不变,只是 ns 换了(查询函数仍吃
ChatClient,只读它三个字段)。4) ChatClient 的四个工具字段收成 ToolRegistry
ChatClient [chat-model filters hooks tool-registry settings tool-manager]
ToolRegistry[tools tool-vars inline-handlers tool-meta]
四者总是一起产生、一起使用、一起被子 agent 整体替换——收成一个值之后
「一个 ChatClient 有一个工具注册表」在类型上成立,而不是靠约定。
查询函数经 registry-of 归一,吃 ChatClient 或裸 ToolRegistry 都行,
故 (registry/tool-meta cc :x) 一类调用形状不变。
迁移:(:tools cc) → (registry/tool-schemas cc);
(:tool-vars cc) / (:inline-handlers cc) / (:tool-meta cc) →
(:tool-vars (registry/registry-of cc)) 等,或直接用查询函数。
->ChatClient 位置参数从 9 个减到 6 个(build-chat-client 不受影响)。
未改的:ILLMProvider / ILLMResponse 协议名;三个 Maven 模块名。
kernel → chat-client,service → chat-model(2026-08-25),对齐
Spring AI 的两级命名:ChatModel 是单次 LLM 调用的抽象,ChatClient 是
在它之上带 filter / tool / memory 的编排器。我们此前的 Kernel(Semantic
Kernel 血统)与 service map 恰好就是这两层,名字却各自另说一套。同时
im.ttalk.agent.client 改名 im.ttalk.agent.simple-agent——否则它与新的
chat-client 只差一个词,require 时极易看错。
im.ttalk.agent.kernel → im.ttalk.agent.chat-client
im.ttalk.agent.model.service → im.ttalk.agent.chat-model ;; 提升出 model/
im.ttalk.agent.client → im.ttalk.agent.simple-agent
build-kernel → build-chat-client
create-service → create-chat-model
common/service-config → common/chat-model-config
Kernel record → ChatClient record
->Kernel → ->ChatClient
build-kernel 的 :service → :chat-model
create-agent 的 :kernel → :chat-client
Kernel 的 :service 字段 → :chat-model
ex-info 的 :kernel-keys/:service-keys → :chat-client-keys/:chat-model-keys
迁移:改 require 的 ns 名 + 上表逐条替换。invoke-chat / invoke-tool
/ invoke-chat-stream / find-function / list-functions / tool-meta /
with-filters / filter-hooks 等函数名一律不变,只有它们所在的 ns 变了。
⚠️ 别做无差别文本替换:provider 侧的 :service-tier(Anthropic 容量路由,真实
wire 字段)与 :service 无关,不能跟着改。
未改的:ILLMProvider 协议与 im.ttalk.agent.model.*(message / response /
error / content / embedding)原样;三个 Maven 模块名(clj-agent-core /
clj-agent-client / clj-agent-provider)原样。
docs/ 下的历史设计文档保留旧名(它们是当时的记录),只有仍在描述当前机制的
design-principles.md / filter-chain-design.md / tool-timeout-design.md /
token-stream-filter-design.md 与六个 README 同步改名。
im.ttalk.agent.advisor → im.ttalk.agent.filter(2026-08-25)。ns 名是
最后一处还叫 advisor 的地方——docstring、配置键 :filters、全部函数名
(create-filter / safeguard-turn-filter / …)早已统一在 filter 上,只有
require 路径还留着历史名,引用方普遍靠 :as filters / :as flt 别名绕开它。
改名涉及:
im.ttalk.agent.advisor → im.ttalk.agent.filter
im.ttalk.agent.advisor.rag → im.ttalk.agent.filter.rag
im.ttalk.agent.advisor.structured-output→ im.ttalk.agent.filter.structured-output
im.ttalk.agent.advisor.tool-search → im.ttalk.agent.filter.tool-search
im.ttalk.agent.advisor.memory (client) → im.ttalk.agent.filter.memory
迁移:只改 require 里的 ns 名,别名与调用点全部不动。
「对标 Spring AI Advisor」的说法保留——那是外部专有名词,不是我们的类型名。
filter 归一化为 Filter record:create-filter 与全部内置 filter 现在
返回 record,build-kernel 对 :filters 逐个 as-filter。用户侧写 map
字面量照常工作(归一化在装配期做掉),四个钩子之外的键(memory filter 的
:store)进 ext-map 照常可读。受影响的只有拿 filter 与裸 map 做 = 比较的
代码。
Kernel record 字段变动:新增 hooks 与 tool-meta,移除 inline-meta
(被 tool-meta 取代)。位置参数构造 ->Kernel 的 arity 变了;build-kernel
与 map->Kernel 不受影响。
:on-pause 与 :sensitive 自动暂停恢复(2026-08-25)。这两个功能在
callbacks 体系(2026-06)落地时被静默弄丢了::on-tool-call 版 gate
替换而非补充了 :on-pause 版,:on-pause 一并从 create-agent 的
:settings select-keys 里掉了。于是三处文档同时变成幽灵——README「方式二」
整节、deftool {:sensitive true} 的说明、docs/unified-invoke-agent.md
(状态 ✅ 已实施,明写「gate 仅靠 :on-pause」)。
为什么一直没人发现:没有任何运行期症状。不暂停的 agent 照跑,只是敏感
工具直接执行了——chat 返回 :completed,日志干净。而既有单测全部走
:on-tool-call 那条 gate,一条都照不到这里;只有 examples/ 里那个不进
门禁的 callbacks_integration_test 在失败,被当成既有噪音。
现在两条启用路径并存::on-pause(声明式,:sensitive 工具一律暂停)与
:callbacks :on-tool-call(命令式,返回 {:interrupt reason})。两条都配时
先问回调;回调放行的,若工具是敏感工具仍暂停——:sensitive 是工具作者
立的下限,不该被一个泛泛的回调放行掉。缺省两条都不配 = 永不暂停(不变)。
新增 on-pause-gate-test / on-pause-resume-test 钉住。
链结果组合子 + 异步入口:invoke-async / chat-async(2026-09-03)。
起点是一个设计提问:chat 走异步之后,:turn 的 around 钩子要不要拆成
turn-before / turn-after?结论是不拆——async 改的是链的返回类型,
不是 around 的形状;而 before/after 换不来异步兼容,却换掉了递归重入
((chain req) 调 N 次),那是 :turn 链存在的一半理由。决策记录见
docs/filter-chain-design.md §2.6。
改为把契约放宽成「返回 resp 或 deferred<resp>」,并给 filter 作者
一组组合子:
im.ttalk.agent.filter IChainResult 协议:fmap / fbind / on-error
fcatch 宏(同步 try/catch + 异步 error channel 二合一)
im.ttalk.agent.async CompletionStage 适配层 + vthread / inline
on-complete(Ring 的 respond/raise)/ join / unwrap-cause
同步值上组合子是恒等展开(零开销、语义逐字不变);deferred 上是
thenApply / thenCompose / exceptionally。契约 C1–C6(形态保持、永不阻塞、
异常语义不变含解包 CompletionException、组合律、不绑定异步库、
:token-xform 不参与)见 §2.6.4,逐条有测试。
异步入口:react/invoke-async、react/resume-async、
simple-agent/chat-async、simple-agent/resume-async 立刻返回
CompletableFuture,整个 turn 跑在虚拟线程上——给 Ring / Luminus 的三参数
异步 handler 用,HTTP 工作线程不被 LLM 往返占住。同步与异步是同一份实现
(invoke* / resume* / run-loop* 只差一个 run 参数),语义逐字相同。
内置 filter 的响应侧已全部改写成组合子,故它们在两条路径上都对。
可跑示例:examples/async_luminus_handler_example.clj(离线,无需 API Key;
含 8 会话并发墙钟实测、raise 路径、HITL 202 → resume)。
异步到哪一层(如实声明):只有 :turn 终端。:chat / :tool 仍是同步的
(provider HTTP 仍阻塞),它们只是被整体搬到虚拟线程上——Loom 下这就是正确
姿势。turn filter 作者注意:走异步入口时 (chain req) 真的返回 deferred,
响应侧必须 flt/fmap / flt/fbind,(let [r (chain req)] …) 不报错但静默错。
异步全链路:IAsyncChatModel / IAsyncLLMProvider + invoke-chat-async
(2026-09-03,紧随上一条组合子)。上一条把 :turn 终端搬上了虚拟线程;这一条
往下打通到传输层::turn / :iteration / :chat 三条链全链路 deferred。
设计与落地记录见 docs/async-chat-model-design.md。
im.ttalk.agent.model IAsyncLLMProvider(可选协议:call-llm-async / call-llm-stream-async)
im.ttalk.agent.chat-model IAsyncChatModel + call-async* / stream-call-async*(探测 + 虚拟线程兜底)
im.ttalk.agent.retry run-async(fbind 自递归;退避走 async/delayed,不 sleep)
im.ttalk.agent.async delayed(CompletableFuture/delayedExecutor,不占线程)
im.ttalk.agent.chat-client invoke-chat-async / invoke-chat-stream-async
provider/http/client request-deferred / post-deferred(响应 map 形状与同步版逐字相同)
provider/http/stream-client post-stream-deferred(不 @future 的 post-stream-sync)
任何 provider 都能异步:探测不到 IAsyncLLMProvider 就用虚拟线程包同步调用,
调用方永远拿得到 deferred;实现协议只是省掉那根线程。Anthropic 系(含 MiniMax /
zhipu 的 Anthropic 端点)与 OpenAI 兼容系两个 record 已实现原生异步。
两个可选协议都不给 Object 兜底——兜了 satisfies? 恒真,探测机制当场失效。
同步与异步是同一份实现:react/run-tool-loop* 的迭代体只有一份,两条路径
只差 chat-fn(invoke-chat / invoke-chat-async)与 drive(loop/recur /
fbind 自递归)两个注入项;DefaultChatModel 的异步分支复用同一套
build-call-config / 重试判据 / normalize-response。重试次数、退避曲线、
:on-retry 观测、ChatResponse(含 replay blocks)在两条路径上逐字相同,有测试钉住。
💥 可观察语义变化——流式 sink 的线程不再保证:异步入口下 on-token 在工作
线程(虚拟线程或传输层 executor)上派发,同步入口仍是调用线程。单次流内仍串行,
:token-xform 的有状态 transducer 不必加锁;但往 UI / ThreadLocal 写的 sink
要自己切回去。契约写在 docs/token-stream-filter-design.md §2.1。
清理:xxx-call-async / xxx-call-stream-async 那套 callback 式旁路
(零调用点、绕过 wire 转换与归一化、docstring 说返回 nil 实际返回 CompletableFuture)
改成 deferred 直通,同名函数仍在但语义换成「返回 deferred」,参数去掉 callback。
第四条 around 链 :iteration(2026-08-25)——包单轮迭代(LLM 调用 +
该轮工具批次),在 react/run-tool-loop 每轮外面。补的是四层中间件模型里
「内层 advisor」那一层::chat 与它同频(每轮一次),但只包 LLM 调用那一半,
看不到本轮工具跑出了什么——工具那一半被 ToolCallingManager 与 :tool 链
接管,两半之间此前没有把它们合起来的 around。
能做的事:单轮墙钟预算(LLM + 工具一起计时)、单轮重试/回滚(这一轮整个重来,
:turn 只能重来全部)、本轮收尾时基于工具结果决策。
此前的绕法及其洞:下一轮 :chat 的 delta 就是上一轮的工具结果消息,
所以「回头看」能做——但末轮没有下一次 :chat(return-direct 收尾、或
工具执行后循环结束),那批结果永远不经过 :chat 链,靠这条路做「每轮必然
一次」的逻辑会在末轮静默漏掉。
IterationRequest {:messages 本轮 delta :context :index 轮序 :remaining 剩余预算}
IterationResult {:status :continue :messages 下一轮 delta :context}
或既有终态 :completed / :paused / :cancelled
语义要点(全部有测试钉住):
remaining 与 records 都如实计入——记「发生过什么」,不记「逻辑上算几轮」。
max-iterations 因此对 filter 重入仍是硬上限。为此 run-tool-loop 的
remaining / records 从 loop 参数改为 volatile,扣减点在批次实际执行之后,
与改造前 (recur … (dec remaining) …) 的时机逐字相同。:paused 沿链回流、around
后半段照常执行,filter 看得见暂停——单轮计时/记账在 HITL 下也能正常收尾。
(立项时的判断相反,写测试时被证伪,见 TASK.md 该节坑 1。):turn::paused / :cancelled 结果必须透传、不得重入。:iteration filter 时链是 identity,循环行为与加这层之前一致。Filter record 随之增加 iteration 字段(位置构造 ->Filter 的 arity 变了),
CompiledHooks 增加第四条链。契约见 docs/filter-chain-design.md §2.3。
filter 链装配期预编译:compile-hooks 在 build-kernel 时把
:chat / :tool / :turn 三条链各折成一个 (fn [terminal] -> chain),
:token-xform 预 comp 成 (fn [sink] -> rf),一并存进 Kernel 的 hooks
字段。此前每次 invoke-tool / invoke-chat 都要 keep 全量 filters +
reverse + reduce 重建整条链——工具循环里每轮每个 tool call 一次。
空链走 identity:没挂 tool filter 的 kernel 一层包装都不加。
链的结构是装配期声明,只有 terminal 必须每次现做——拆开的正是这两者。
改 :filters 请走 kernel/with-filters(同步重编 hooks)。直接
(assoc kernel :filters ...) 仍然正确——kernel/filter-hooks 检测到 hooks
与 :filters 不同源会现场重编译兜底(宁可慢也不能静默用旧链,filter 悄悄
失效是这套机制最难查的一类 bug),但那是每次 invoke 都重编。
client/with-memory-filter 已改走 with-filters。
工具声明合成一张 ToolMeta 表:build-kernel 把 var 与内联工具的全部
装配期声明(:func-def / :serial / :retry / :timeout / :return-direct)
汇进 tool-meta,serial-tool? / return-direct-tool? / retry-policy /
tool-timeout 四个查询退化成一次 map 查找 + 一次字段读(签名不变)。
此前是同一段 (if-let [v (get tool-vars k)] 读var元数据 查inline-meta)
双分支手抄四遍——:timeout 正是在这种重复里漏掉 inline 分支、对内联工具
静默失效的。:retry 的默认值 merge 也从每次查询挪到装配期。
inline-meta 与 func-defs 两个字段被它取代。
💥 同名工具在 build-kernel 装配期直接拒绝(var 之间、内联之间、var 与
内联之间都算,抛 ex-info 带 :duplicates)。合表时暴露出此前有两套相反的
优先级:四个声明查询 var 优先、invoke-tool 的执行分派内联优先——同名时
「按 var 的超时/重试策略执行内联的 handler」。
处置不是选一个赢家,而是不许重名:同名工具没有合理用例,只有坏结果——
:tools schema 列表里两份定义都发给 LLM(模型看见两个同名工具),而
tool-meta / inline-handlers 只留得下一个,「模型看到的」与「实际执行的」
就此对不上,且没有任何运行期症状可查。「选一个赢家」是在给配置错误编造语义;
要替换某个工具,调用方该在传 :tools 之前处理自己的列表。
重名校验排在其它装配期校验之前——名字都不唯一时,别的校验是在错误的地基上做的。
迁移:此前靠重名做覆盖的(若有)会在 build-kernel 当场抛,按报错点名的
名字去重即可。
ToolRequest 的 :function 段预建:build-func-def 从每次 invoke-tool
挪到 build-kernel(存进 ToolMeta 的 :func-def)。invoke-tool 的
inline / var 双分支随之合并——此前两支除工具本体外逐字重复(ToolRequest 组装、
filter 链、返回值拆包),且现在一次查表就同时拿到 :function 与超时声明。
请求对象刻意不做 record:filter 会 assoc/update/dissoc ChatRequest
与 TurnRequest(validation-turn-filter 就 dissoc 掉 :resume?),而 record
一旦 dissoc 已声明字段就降级成普通 map。record 用在声明上(Filter /
CompiledHooks / Kernel),不用在流动的数据上。详见
docs/filter-chain-design.md §1.1。
缺省的 ToolCallingManager 改为串行(2026-07-16 用户拍板)。不指定
:tool-manager 时,同一轮的多个 tool-call 按调用序依次执行。
要并发须显式注入 (virtual-thread-tool-calling-manager)(或 thread-pool 版)。
(这一条修正了本版早先的默认——见下条:并行曾是缺省。)
理由:并发要求同批工具的副作用彼此无序依赖——那是调用方才知道的性质,
框架不该替它假定。串行是「无论工具长什么样都成立」的那个选择:顺序可预期、
可调试、不会因为 LLM 某轮多发一个 tool-call 就把副作用交错起来。
状态语义不受影响:三个引擎都是轮初快照 + :writes 屏障折叠。
工具超时缺省为「不超时」,两个显式来源(2026-07-16 用户拍板):
工具声明 deftool {:timeout ms} > 引擎缺省 (…-tool-calling-manager
{:timeout ms}) > 不超时
时间上限属于执行策略,故随引擎构造(三个引擎均接受 :timeout),
而不是散落在 filter 里——与 beamai manager_opts.tool_timeout 同一立场。
两者都由 kernel/invoke-tool 强制,开箱即生效;都没给则不起线程、
零开销。框架不替调用方决定何时放弃。
advisor/timeout-filter 删除(2026-07-16 用户拍板,紧随上条):它的两个
职责已分别被接管——强制力在 kernel/invoke-tool、整体缺省在引擎 {:timeout ms};
剩下的唯一辩护「按工具名/标签动态决定时限」按 §1 四问全落假想列(真要时自己写
5 行 :tool filter 包 tool/call-with-timeout 即等价),且第三层优先级是纯粹的
复杂度税(beamai 同样只有 tool_spec + manager opts 两个来源)。
build-func-def 的 :timeout 字段随之删除(唯一读者就是该 filter——没有读者的
字段就是下一个死选项)。机制本体 tool/call-with-timeout 保留并有直接单测。
已入 check_docs 墓碑(经变异验证:README 里复活它会被 CI 逮住)。
迁移:(timeout-filter 5000) → 引擎 (…-tool-calling-manager {:timeout 5000})。
同一轮的多个 tool-call 并行执行(虚拟线程;批内任一工具声明
{:serial true} 时整批退化为按序执行)。工具的执行环境从「批内穿线」
改为「轮初快照」:同批工具互相看不到对方的写(此前语义全仓库零真实使用者)。
⚠️ 本条的「并行」部分已被上面第一条改回串行缺省——并行机制仍在
(virtual-thread-tool-calling-manager),只是不再是缺省;「轮初快照 + 屏障折叠」
的状态语义保持不变,与选哪个引擎无关。
ToolContext 对工具/filter 只读。工具写共享状态改走返回值
{:result r :writes {k v}}(任意工具均可,不再要求 {:context true});
屏障处按 tool-call 原始序经 build-kernel 新增的 :state-slots 槽级
reducer 折叠(未声明槽默认 last-writer,冲突记 warn)。失败/超时/被拒的
调用 :writes 不生效(单工具事务性)。
kernel/invoke-tool 返回 {:value (:writes)}(原 {:value :context})。
跨轮折叠由调用方(react 循环)负责;直调方自行用 context/apply-writes。
tool filter 响应契约收窄为 {:result (:writes)}:响应侧 :context
移除——filter 短路分支不再需要手工回传 (:context req)(原易错点)。
工具/inline handler 返回值不再按「含 :result 的 map」拆包,判据改为
「含 :writes」;返回 {:result ...} 包装的旧 inline handler 需改为直接
返回值(subagent delegate 已随迁)。
on-tool-result 回调改为任务完成时实时触发,批内顺序不确定
(确定顺序请读 :tool-calls-made)。
LineageStore 协议更名为 BranchStore:与 beamai(Erlang 姐妹项目)命名对齐。方法名同步更新:
lineage-add! → record-branch! / lineage-get → get-branch / lineage-children → branch-children / lineage-remove! → delete-branch!。
实现类 InMemoryLineageStore/SqliteLineageStore → InMemoryBranchStore/SqliteBranchStore。
工厂函数 in-memory-lineage-store/sqlite-lineage-store → in-memory-branch-store/sqlite-branch-store。
deps map key :lineage → :branch(timeline/fork!/rollback!/lineage/ancestry/prune!)。
SQL 表 branch_lineage → branch_records(跨版本需重建);索引 idx_lineage_parent → idx_branch_parent。
用户-facing API 不变:lineage(单记录查询)、ancestry、fork!、rollback!、prune!。
中文描述词「血缘」/「分支血缘」不变。
多模态输入:中立内容部件 + 两家 wire 翻译(2026-08-18,对照 Vercel AI SDK
的 prompt 部件模型)。此前 :content 只能是字符串——图片 / PDF / 音频根本
发不出去,除非用户自己按各家 wire 手写块(而那份历史换个 provider 就废了)。
im.ttalk.agent.model.content:text-part / image-part /
file-part / audio-part,来源吃 http(s) URL、data URI、base64 字符串、
byte[]、java.io.File(自动 base64 + 从扩展名猜 media type)。:file + :media-type,wire 层按
media type 的顶层类别分派——新格式不需要新部件类型(AI SDK 同一取法)。wire/openai:image → image_url(URL 或 data URI)、audio → input_audio
(仅 wav/mp3)、PDF → file.file_data;wire/anthropic:image → image 块
(base64 / url source)、PDF → document 块(:filename → title)。:validation-error(不可重试),不静默丢内容——丢了内容
模型只会答非所问,排查成本远高于当场报错。Anthropic 不收音频、OpenAI 不收
URL 音频/URL PDF、Anthropic 不收通配 media-type 的内联图片,各有单测。:type 为字符串的元素(Anthropic citations
的 document、带 cache_control 的块…)视为厂商原生块原样透传,
可与中立部件混用。byte[]——对话历史要过 EDN/SQLite
往返,字节数组过不去(有 SQLite 往返回归测试钉住)。文本向量化:IEmbeddingProvider 独立可选协议 + 9 个内置端点(2026-08-18)。
框架此前完全没有 embedding 能力。
im.ttalk.agent.model.embedding(单方法 embed,批量即全部)。
与 ILLMProvider 并列、不加进它:embedding 是另一种模型(另一端点、
另一套参数、另一份计费)。同样不给 Object 兜底——satisfies? 必须可信,
没有 embedding 服务的厂商(Anthropic)在表里根本没有条目。provider/embeddings(工厂 + 三个实现)与
provider/common/embeddings(OpenAI 兼容 + DashScope 原生两种线上形状):
:openai / :zhipu / :siliconflow / :mistral / :gemini / :ollama /
:dashscope / :openai-compat / :mock(确定性假向量,离线测试用)。index /
text_index 重排(不假定顺序),条数对不上宁可抛 :parse-error 也不给
错位向量;失败与 chat 路径同一套规范错误词汇。provider/api 加 create-embedding-provider / embed / embed-one /
supports-embedding?,以及内容部件构造器的 re-export。新增四个 provider::xai(Grok)、:moonshot(Kimi,默认国内站)、
:openrouter(多厂商聚合网关)、:siliconflow(硅基流动)。均由
defprovider 生成,已进 factory 注册表、env 前缀映射与默认配置表
(两处默认值一致性有测试钉住——历史上 anthropic 多一个 /v1、mistral 少一个
/v1 就是这么漏的)。OpenRouter 的署名头走既有 :extra-headers、路由参数走
既有 :extra-body,不为它新增字段。
thinking 回传契约:可选协议 IReplayableResponse + 中立消息 :blocks
(2026-07-28,设计与三轮实验记录见 docs/provider-variant-design.md)。
推理模型的 thinking 块(含 signature)此前在中立层被抹平——response->neutral
只用 :text + :tool-calls 重建 assistant 消息,wire/anthropic 也只吐
text/tool_use 两种块。于是工具循环第二轮起,模型再也看不到自己前几轮的思考。
im.ttalk.agent.model/IReplayableResponse
(单方法 replay-blocks),service 归一化时 satisfies? 探测——
不实现的 provider(含仓库外的实现)一行不改照常工作。
不往 ILLMProvider 加方法:那是 DIP 的端口,加方法 = 所有实现方破坏性变更。:format 标签,core 全程不碰 :data,
厂商 wire 知识仍归 provider(不违反 response-path-consolidation.md)。:blocks / :format 认不出(跨 provider 的历史)/
:data 为空,一律走原来的 text+tool_use 重建,各有单测。extend-type Object 兜底——ILLMProvider 就因为有
兜底,satisfies? 对任意非 nil 对象恒为 true(见 model/provider? 注释),
而本机制全靠 satisfies?。单测专钉这条。create-agent
全链 20/20 = 100%,20 次全部恰好 7 次工具调用,与 A 臂零方差形态一致。context/apply-writes:批次写折叠纯函数(槽级 reducer + conflict 上报)。
build-kernel :state-slots:状态槽合并语义声明。
deftool {:serial true} / inline 工具 :serial 键 + kernel/serial-tool?。
工具失败分层路由(S2,纯增量):model.error/classify-exception
(显式 ex-data :error-class > canonical :retryable?/:auth-error >
常见网络异常 > 缺省 :semantic);invoke-tool/execute-batch 透出
:error {:class :message} / :errors。
瞬态类自动重试:deftool {:retry true|{:max-retries n :initial-delay-ms ms}}
(幂等工具 opt-in),仅 :transient 类错误指数退避重试,对模型透明;
timeout-filter 的超时结果标 :transient。
环境类屏障暂停(HITL):react :on-env-error :pause|:proceed(缺省
:proceed)——环境类失败在屏障处带一致快照暂停;resume 决策 :retry
(重跑失败调用,结果按 tool-call-id 替换进原批次)| :proceed(错误交给
模型)。client 层配置了 :on-tool-call 的 HITL agent 自动 :pause,
resume 接受 "retry"/"approved" 表示环境已修复。
HITL 暂停态持久化(im.ttalk.agent.pause):PauseStore 协议 +
in-memory / SQLite(EDN)实现;create-agent :pause-store 暂停快照自动
落库、终态/reset!/新 chat 自动清除、paused?/resume 透明回落 store
——进程重启后同 conversation-id + 同 store 重建 agent 即可 resume
(审批与 :env-retry 两种暂停均支持;对话历史配 SQLite ChatMemory)。
create-agent 同时补透传 :state-slots。
Timeline 与多分支(im.ttalk.agent.timeline):对话日志即时间线,
分支 = fork-as-new-conversation(前缀复制 + BranchStore 血缘记录,
现有 ChatMemory 协议零改动)。fork!(暂停点全量 fork 连带复制暂停快照
→ HITL 决策分支:两支各自 resume 不同决策)/ rollback!(破坏性截断,
"重新生成")/ lineage/ancestry / prune!(有子分支拒绝)。
合法 fork/rollback 点 = turn 边界 / 暂停点(一致性不变量)。
:writes 进历史(event-sourcing 伏笔):tool-result 中立消息可携带
:writes 元数据(该工具对状态槽的写意图)——只进历史存储、不发给 LLM
(wire 层剥除有测试钉住);失败/被拒调用无 writes(与事务性同真同假)。
立即价值为审计,为将来跨 turn 状态的 fold-from-history 留路。
Turn 级 filter 链(:turn 钩子):filter 第三个钩子,包整个工具循环
(每 turn 一次),与 :chat(每轮 LLM 调用)/:tool(每次工具执行)并存。
可改写入口 :messages/:context、可多次 (chain req) 递归重入
(闭包洋葱天然"仅下游");:paused/:cancelled/:error 结果须透传。
解锁每 turn 一次的 RAG 注入、最终答案 guardrail、turn 级预算。
内置 advisor/validation-turn-filter(校验失败带反馈重入重试,
对标 Spring AI 2.0 StructuredOutputValidationAdvisor)。resume 同样经过
turn 链(TurnRequest 带 :resume? true;首调延续暂停 turn、递归重入走
全新循环)——resume 完成的最终答案同样被校验/guardrail。
resume 带 payload(审批 phase,3-arity):
(resume agent "rejected" {:message 理由}) 结果「已拒绝执行:<理由>」;
(resume agent "approved" {:args 新参数}) pending 工具改参执行;
(resume agent "reply" {:message 答复}) 工具不执行、答复即结果——
ask-user 模式解锁(提问工具 + gate 拦截 + reply 送回)。
gate 决策词汇同步扩展({:reject 理由}/{:reply 结果});loop-state
新增 :pending-id;env 暂停显式拒收 reply。零破坏。
Token 流变换链(:token-xform 钩子,2026-07-14):filter 第四个钩子,
值为 transducer,变换流式出站 token 流(1→N / 跨 chunk 状态 / 流末
flush——completion arity 即 end-of-stream 信号)。组装在
invoke-chat-stream terminal 内:provider 原始 token → xform 链(注册顺序)
→ on-token;正常完流 flush、stream-fn 异常不 flush(缓冲丢弃)、下游
reduced 早停、无 xform 零开销退化。硬边界:只变换交付流,不改最终
:response(memory/turn 用原文)。内置 advisor/token-redact-filter
(无状态正则脱敏)与 advisor/hold-release-filter(先审后放:缓冲整流、
完流 check-fn 全文,通过原序放行 / 不通过 emit 单个替换 token)。
对标 Spring AI StreamAdvisor(Flux 一等流)——吸收算子思想,不引
Reactor、不拆 Call/Stream 双接口。零破坏。
设计见 docs/token-stream-filter-design.md。
Spring AI 2.0 Advisor 全面对齐(2026-07-15)——逐个对完之后,真正的
缺口只有一个半。设计与吸收记录见 docs/advisor-alignment-design.md。零破坏。
advisor/tool-search,对标 ToolSearchToolCallingAdvisor):
渐进式工具披露——初始只暴露 search_tools,模型检索到的工具下一轮才进
工具列表(Spring 实测省 34–64% token)。零新增钩子:search_tools 是
普通内联工具,返回 {:writes {::discovered #{名字}}} → 屏障按槽 reducer
折叠(into = 集合并,跨轮累积)→ 每轮进 ChatRequest :context →
:chat filter 据此重写 :tools。发现集合住在 tool-context 里,故
暂停/resume/持久化全自动正确。索引零依赖可插拔:内置
keyword-tool-index(名称/描述分词 × IDF 打分,中文二元组切分,拆
snake_case/camelCase)与 regex-tool-index(名称模式,非法正则退化字面
匹配不抛异常);向量检索经 IToolIndex 注入。with-tool-search 一次装好
工具/filter/状态槽三处。
live 实测(MiniMax-M2.7,50 工具目录)省 78% prompt token,且任务质量
与基线一致(可复现:examples/toolsearch_live_test.clj,11 项行为断言)。
赚不赚看工具定义总量而非工具个数——固定成本约 600–1000 token(多一轮
LLM 往返),沿用 Spring 的度量「工具定义 >5K token 才用」。
⚠️ 省 token 未必省钱:基线的静态工具前缀天然适合 prompt cache,本
filter 每轮改写 :tools 会打碎可缓存前缀。按缓存读 10% 计价折算,实测
冷缓存下 ToolSearch 省 65%、热缓存下反贵 66%——差别全在基线前缀的冷热。
工具集静态 + 高频会话 + 廉价缓存时,省的是上下文窗口而非钱。适用判断见
docs/advisor-alignment-design.md §2.4。{:return-direct true}):工具结果即最终答案,
不再回灌 LLM(转人工/升级工单/guardrail)。整批全声明才生效(对齐 Spring
allMatch);多个结果按原序拼接。补落库:正常路径下工具结果靠下一次
invoke-chat 经 memory filter 落库,return-direct 没有下一次——不补则历史
留下悬空 tool_use 并被下个 turn 的 heal 整条摘掉。build-kernel :eligibility-fn,对标
ToolExecutionEligibilityChecker):(fn [response context] -> boolean),
响应带 tool-call 时是否真的执行并续跑;返回 false 则按最终答案收尾。
缺省恒真(行为不变)。advisor/structured-output(补 StructuredOutputValidationAdvisor 的
判据侧):validation-turn-filter 早有机制,缺的是判据——按 JSON Schema
子集校验并把失败写成模型能据以自我修正的人话(「缺少必填字段 films」/
「字段 films[1] 期望 string,实为 integer」)。validate-value 纯函数零
依赖;validate-fn 的 JSON 解析经 :parse-fn 注入(core 无依赖,不内置
解析器)。safeguard-turn-filter(对标 SafeGuardAdvisor):敏感词命中即不进
循环直接拒答。推翻旧决定(原记「用户一个 chat filter 即可」)——那句
写在 turn 链之前且挂点有误::chat 会每轮重查累积历史。大小写不敏感。advisor/rag qa-turn-filter(对标 QuestionAnswerAdvisor):每 turn
检索一次并拼进用户问题。推翻旧决定(原记「不跟本体」)——推翻的是
「不做本体」而非「不引 vector store」:仍零检索依赖,向量库经 IRetriever
注入;本体价值在提示词编排不在检索。检索为空时不注入(刻意偏离 Spring 的
空上下文 + 拒答指令,可用 :inject-when-empty? 恢复)。logging-chat-filter(对标 SimpleLoggerAdvisor):既有
logging-filter 只覆盖工具侧,补 LLM 侧请求/响应日志。re-reading-filter(对标 ReReadingAdvisor,RE2):入口用户问题重复
一遍附在其后。ToolCallingManager 协议(core/tool-calling-manager.clj,2026-07-15):
工具执行入口升格为可注入协议。单方法 execute-tool-calls [this kernel response opts]
(Spring AI 2.0 ToolCallingManager 对齐签名,内部抽 tool_calls)。多 impl 是
核心特性——不同 record 选不同执行策略:
VirtualThreadToolCallingManager(默认,委托现有 5-arity execute-batch,尊重
:serial 声明)+ SequentialToolCallingManager(独立顺序路径,全串行无并发)
ThreadPoolToolCallingManager(有界平台线程池,见下)。
build-kernel 新增可选 :tool-manager 字段(Kernel record 第 7 字段,缺省 nil
走原路径,零行为变化)。execute-batch / execute-single 退回成每个 record 的
内部 helper(不是协议方法)——怎么实现完全是 record 自己的选择。
边界契约(实证测试):manager 不夺 :serial / :tool filter / :writes 屏障
的权——注入 mock manager 仍走 kernel 原语,三条不变量各有测试钉住。
定位:manager 是 clj-agent 的工具执行引擎——拿到一批已批准的 tool-call,
决定怎么把它们跑完:线程模型 + 隔离边界 + 调度策略。换 manager = 换引擎。
修订 advisor-alignment-design.md §1.3(《ToolCallingManager —— 我们不长
这个抽象》旧立场推翻):§1.3 的落点建议(kernel :settings 注入 executor)
低估了要换的东西——单键换的是「线程从哪来」,manager 换的是引擎。
三条能力级差异:(a) 调度决策(react.clj:167 的 (if (or serial? (<= count 1)) ...))
硬编码在框架里,Executor 接口够不着;(b) SequentialToolCallingManager
全程不构造 Future(react.clj:207 独立路径),same-thread executor 模拟不出
(仍会背上 .submit/.get 的包装与中断语义);(c) 池的生命周期须与策略打包
(Sequential 无资源、ThreadPool 持可关停的池),单键收下别人造的 executor 后
生命周期无人认领。隔离缺口:react.clj:66 的虚拟线程 executor 是
进程全局 def + delay,同 JVM 所有 kernel / agent / 子 agent 共享——
无舱壁、无限流、无可关停边界;ThreadPoolToolCallingManager(每实例一个有界池)
正是为此留的位置。
设计见 docs/tool-calling-manager-design.md(v5,含 Oracle review 16 项处置 + 23 条
决策记录 + §0.5 设计原则)。文档体系整理:原则提取 + 目录合并 + 索引(无代码变更)。
docs/design-principles.md——项目级设计原则的唯一出处(硬约束):
原则散在个案里的下场是下一份设计文档再把它重新推导一遍,故抽出集中收录,
各设计文档改为回指不重述。首批两条——§1《无真实需求不建》(新提):
抽象要由真实需求触发,不由对齐别人 / 对称性 / 「以后可能要」触发;用户几行
代码就能等价做到的事,框架不长字段、不开 seam、不加协议。本是
advisor-alignment-design.md §1.3 的「立项判据」,因在
tool-calling-manager-design.md 里被反复重新发现(毙 deftool :backend、
毙 PR2/PR3、拆掉一个防御性保护)而提为原则;含四问判据、落地约束、案例法
(含 ToolCallingManager 自己险些踩中:立项两条腿里「多 transport」那条是
假想的,且当时看起来比真实那条更有说服力)。§2《框架无关》:收录自
streaming-async-design.md §0.5,内容不变。两条分工——§1 管纵向(抽象该不该
存在),§2 管横向(依赖该不该跨进来)。design/ 并入 docs/,不再分两处:7 份文档经 git mv 迁移(rename 历史
保留,内容不变),design/ 删除。那条界线是历史形成的(design/ 早期放专题/
重构笔记,docs/ 放主设计文档),但两边都在长,且「这份算专题还是主文档」没有
可判定的标准;跨目录相对链接(../docs/…)也是纯粹的税。全仓 13 处路径引用
同步更新(含 provider/anthropic.clj、provider/http/client.clj 两个 docstring
与 examples/streaming/README.md)。老路径 design/xxx.md → docs/xxx.md
一一对应,无重命名。docs/README.md 索引:按「权威参考 / 已实施专题 / 进行中 / 已废弃留档」
分组,16 份全部收录;沿用各文档头部既有的 状态: 行作为「当下是否算数」的判据
——对 process V1/V2/Timeline 那三份废弃留档尤其要紧(讲得头头是道但不代表现状)。新增 ThreadPoolToolCallingManager——有界池引擎(舱壁隔离):
(react/thread-pool-tool-calling-manager {:pool-size 8 :thread-name-prefix "..."}),
缺省 pool-size = availableProcessors。工具批在本实例自己的池里跑,
并发上限 = pool-size,不与其他 kernel 互挤——这是默认 VT 引擎给不了的
(后者用进程全局无界 executor)。线程为 daemon(用户忘记关停也不吊住 JVM 退出)。
池的生命周期归持有者:record 实现 java.io.Closeable,可直接 with-open,
或调 react/shutdown-tool-calling-manager!(对无资源的引擎是 no-op);
关停后再执行工具批抛 ex-info(:error-class :environment),不静默失败。
不变量:一个引擎属于一个 kernel,不跨 delegate 边界——子 agent 自有引擎,
这本来就是默认,框架里没有共享路径(delegate 的 subagent-config 全部来自用户的
:subagent-fn,父 kernel 的 :tool-manager 流不进去)。⚠️ 别绕过默认去踩:
亲手把同一个有界实例塞进 subagent-config 会死锁(delegate-tool 是
spawn→await→drop,父批工具占着池线程等子 agent,子 agent 的批又要同一个池的线程,
互等);同理别在某工具函数体里再拿同一实例跑一批。框架不为此设防——跨 delegate
的情形线程局部标记根本测不到,同线程的情形无真实需求,不变量本身就是答案。
子 agent 要限流请给它自己的实例;不需要就留默认 VT 引擎。
重构(内部,行为不变):三个引擎的 map + 屏障骨架抽成 execute-batch-via——
gate 预判、:serial 整批退化、ctx/apply-writes 折叠、messages/records/errors
按原序排回,一处实现;引擎只负责挑 executor(nil = 全程内联,不构造 Future)。
此前 VT 与 Sequential 各持一份拷贝,契约靠两边同步维持。公开 react/execute-batch
的 5/6-arity 签名与行为不变。
deftool :backend(HTTP / MCP transport 声明)❌ 否决,不会实施(2026-07-16
用户拍板):工具走 HTTP 还是 MCP 是函数体内部实现——既不影响 LLM 怎么用这个
工具,也不影响框架怎么调度它(gate / 并行 / 重试 / writes 折叠待遇完全相同),
框架不该知道。Clojure 里函数是一等值,Spring ToolCallback 用接口承担的 transport
多态,在这里由函数体本身承担;再加一层 :backend defmulti 是把宿主语言已经
免费提供的能力重新实现一遍。代价(deftool 宏多 5 个字段 + 2 条编译期检查、
invoke-backend defmulti 成永久公开 API 面、core/client 跨模块耦合、kernel 加
:mcp-clients 字段、inline tools 路径天生接不上、声明式配置表达力反而弱于手写)
远超收益(省 5-10 行 HTTP 包装)。此前(2026-07-15)标记为「⏸️ 搁置待真实需求」,
现改为否决——即使出现 HTTP/MCP 需求也不按此设计做,deftool 内直接写
(http/post ...) / (mcp/call-tool ...) 即可。deftool / tool/invoke /
Kernel 均无改动。否决理由与唯一可能重开的场景(远端动态工具发现,且届时
应重新设计)见设计文档 §5。对 ToolCallingManager 零影响——manager 管
「怎么执行」(线程模型 / 隔离 / 调度),:backend 想管「执行的是什么」
(transport),两件事不相干。
DashScope 把中立消息原样发了出去(2026-08-18,随多模态改造暴露):
DashScopeProvider/call-llm 未做 wire 转换,:tool-calls / :args / keyword
role 直接进请求体——DashScope 一个都不认,于是多轮工具调用的历史等于没发
(首轮正常,第二轮起模型看不到自己调过什么、拿到过什么结果)。原生
text-generation 的 messages 与 OpenAI 同构,故复用 wire/openai;
回归测试断言出站 body 里 tool_calls[].function.arguments 是 JSON 字符串、
tool 结果是 role=tool + tool_call_id,且中立键不得泄漏。
该端点不收多模态部件(qwen-vl 在另一个端点、形状也不同),边界处直接报错并
指路(用 :openai-compat 指向兼容模式),而不是发出去等一个看不懂的 400。
create-agent 递不到 provider 专属能力(2026-07-28,见
docs/provider-variant-design.md §6.1):common/build-kernel 用白名单
{:model :max-tokens :temperature} 组装 provider 调用 config,于是 provider 侧
明明实现了的能力——Anthropic/MiniMax 的 :thinking、:cache-strategy、
:service-tier、:top-k、:beta、:retry 等——走 agent 门面全部静默丢弃,
只能自建 kernel/service 绕开。anthropic/build-params 早就认 :thinking,
是门面把它挡在外面:说了能用却用不了。
改为排除法(新增公开的 common/service-config + orchestration-keys):
编排层的键不下沉,其余一律透传。
:tools 是名单里最危险的一条(两边都有、含义不同:service config 里是已编译
schema,agent 里是 tool var 向量)。漏下去 provider 转出 {:name nil},MiniMax
报 400「function name is empty」——P0 实验第一版真撞过,故单测专钉这条。nil 不下沉(否则 provider 侧 (some? temperature) 这类判据会被 nil 骗过)。service_config_test.clj);真机 M2.7 冒烟通过。resume 丢弃暂停前累积的 context 状态槽:client/resume 此前用仅含
conversation-id 的裸 context 续跑,S1 的 :writes 折叠结果跨 resume 即失
(S1 之前 context 无内容故无感)。现 resume context 恢复自暂停态的
:tool-context(本进程与跨重启两路径同修)。
deftool :timeout 曾是死选项(2026-07-16,设计见
docs/tool-timeout-design.md)::timeout 在 opts 白名单里却从不生成
:tool/timeout 元数据、全库零读取——声明 {:timeout 500} 编译通过、无警告、
零效果(实测睡 3s 的工具跑满全程)。现经 tool/timeout-spec →
kernel/build-func-def 透传,由 timeout-filter 消费,优先级:
工具声明 > filter 构造缺省(对齐 beamai resolve_timeout 的策略层)。
仍为 opt-in:不挂 timeout-filter 即无超时。
timeout-filter 把工具搬去平台线程(同上设计文档 §4):曾用
clojure.core/future(send-off 无界平台线程池)包裹下游——挂了该 filter,
VirtualThread 引擎的工具实际跑在平台线程上,且超时被弃的任务在 send-off 池
无界堆积、ThreadPool 引擎的舱壁 bound 不住。现改 Thread/startVirtualThread;
顺带下游异常原样重抛(不再包 ExecutionException)。
docstring 同步如实声明语义:JVM 上超时 = 放弃等待 ≠ 终止执行(无 kill
原语;CPU 循环/普通 socket read 打不断,副作用可能在超时结果返回后落地;
声明 :retry 的超时工具必须幂等——重试时上一次调用可能仍在跑)。
bb.edn,3 份 build.clj → 根 build.clj
(2026-07-28)。scripts/{build,clean,install,test,repl}-all.sh 与三个模块各自的
build.clj(含各自的 :build alias)全部删除。
bb.edn:bb test [module] / check-docs / check / jar / install / release / deploy / version / repl [example]。CI 与本地从此跑同一条命令
(workflow 改为 bb test <module> 与 bb check-docs)。build.clj:三份 95% 雷同的 build.clj 合成一张模块表 + 一套函数
(同一 pom-data、同一 0.3.<git-count> 版本、同一段 override-deps 注释,
连「b/install 必填 :class-dir」这条踩坑记录都曾抄三遍)。切模块靠
b/set-project-root!——tools.build 全部路径相对 *project-root* 解析;
deps-deploy 不吃它,故 deploy 的路径显式 b/resolve-path。
三模块一个 JVM 跑完,不再 cd + 起 6 次 clojure。build-all.sh 只 clean+jar,从不 install,而
client/provider 的 basis 把 core 覆盖成同版本 :mvn/version——fresh clone 上
它必然解析失败(且列表里根本没有 client,与 test-all.sh 早先漏测 client 同款
毛病)。现在 release 逐模块 clean→jar→install,单独 jar client 也会自动补一次
core;pom 里 core 依赖坐标已实测正确。scripts/check_docs.clj 留在 JVM Clojure 上(bb check-docs 只是入口):
门禁要 require 全部源码 ns 再 ns-resolve,而源码带 next.jdbc / sqlite,bb 跑不动。:chat filter 改写 :tools 抵达 provider 的契约此前
无测试覆盖,而 ToolSearch 完全建立在它之上——先补 3 个断言钉住
(含 context 驱动的动态工具集),再实现。scripts/check_docs.clj)+ 六个 README 的幽灵 API 清理:
一轮排查发现文档里积了一批幽灵 API——功能早被删/改,源码里甚至留了
「已移除」的注释,文档却没跟:
:build-result-msgs(service map 的历史键)——model/service.clj 明写它已
移除,四个 README 却还在头部特性 bullet 里教人用;真实契约是
:chat-fn + :stream-fn,而 :stream-fn 一处没提;proto/call-with-tools——协议里根本没这个方法(真实为 call-llm +
extract-tool-calls/extract-text);find-function → {:plugin p :tool-var v}(:plugin 不存在)、
invoke-tool → {:value r :context ctx}(:context v0.3 已删)、
chat-fn → :assistant-msg(早已归一化为 ILLMResponse)、
im.ttalk.agent.model.types(从未存在)、filter 的 :order/:phase/
:before/:after(早已移除);pause / timeline 整块功能不在 client README 里;
DashScope 在 provider README 出现 0 次(却已注册并有专属 SSE 解析器);
modules/README.md 的依赖图把 core 标成「协议 + Agent 运行时」且
整个 client 模块不在图里(2026-07 拆分前的图)。
全部修正,并加门禁挡住复发:ns 存在 / ns 覆盖 / 符号 resolve / 墓碑四项,
接入 CI(新增 docs job)与 scripts/test-all.sh。设计取舍是宁可漏报不可
误报(alias 未由同文件 require 绑定即跳过;注释与字符串字面量先剥掉)——
会误报的门禁很快就会被 || true 绕过。四项均已用变异测试验证真的会失败。scripts/test-all.sh 漏测整个 client 模块:MODULES 列表只有 core 与
provider(CI matrix 有 client,本脚本没有)——本地 test-all 长期静默跳过
Agent 运行时。已补。examples/safeguard_live_test.clj(18 项)——拦截逻辑本身有单测,live
验的是单测证明不了的:拦下时真的一次 LLM 都没调(短路在 :turn 层,连接
都没建)、不落库的语义后果在真实多轮里长什么样(第 2 轮模型答「我没有
之前的记录,这是对话的开始」——刻意取舍的代价看得见)。并跑出了语义边界:
工具结果里含敏感词照样通过——SafeGuard 是入口守卫,不是输出守卫。examples/return_direct_live_test.clj(19 项)——对照组是重点:
同一句合规话术,return-direct → 逐字送达(LLM 只调 1 次);普通工具 →
被模型回灌改写(【工单已创建】… 变成 已为您创建转接工单…)。两边一比
才知道 return-direct 在防什么。场景 3 用真实第二轮对话验证补落库的修复:
模型答得出上一轮的工单号 T-88123,证明 transcript 没被 heal 摘掉。
另含 :eligibility-fn 的放行/拦停对照。examples/rag_live_test.clj(18 项)——语料全为虚构事实(模型训练
数据里不可能有「每 42 天除垢」),故对照组答不出、RAG 组答得出,grounding
真的来自检索这件事才算被证明。并实跑印证了对 Spring 的刻意偏离:同一个
无关问题,默认(空检索不注入)→ 模型正常作诗;:inject-when-empty? true
(Spring 行为)→ 模型拒答「没有足够的上下文信息」。examples/structured_output_live_test.clj(12 项)——校验器本身有单测,
live 唯一值得验的是单测证明不了的:把「缺少必填字段 internal_review_code」
丢回给真实模型,它会不会真把字段补上。用「schema 要求 prompt 里没提过的
字段」触发真实失败(不作假),实测第 1 轮漏、反馈点名、第 2 轮补上、
校验通过。另钉死「合格只调 1 次 LLM」与「耗尽恰好 2 次、原样返回」。examples/toolsearch_live_test.clj(11 项)——ToolSearch × MiniMax 端到端:
11 项行为断言(渐进式披露成立 / 发现集合跨轮累积 / 任务质量不掉 / 大目录
真省 token)+ 冷热缓存对照报告。跑真机推翻了三个基于单测的判断:
检索工具描述缺「多能力须各检索一次」会静默掉召回、关键词索引缺 IDF、
prompt cache 会让 token 对照得出反向结论。examples/minimax_agent_live_test.clj 的两处静默失效(该脚本因
环境变量改名 MINIMAX_AUTH_TOKEN → MINIMAX_API_KEY 早已启动即 exit 1,
无人跑到,assertion 悄悄烂掉):① 环境变量两个都接受;② on-tool-call
的 tool-name 契约是字符串,脚本却拿 keyword 比较——scenario 1 断言
直接失败,scenario 2 更严重:gate 的 (= :send-email n) 永不相等 → 永不
中断 → 中断/恢复整条链路静默失效。callbacks.clj 文档补上类型说明防复发。build-func-def(修复前该用例睡满 60s)+「裸 invoke 不超时」语义钉;
优先级三分支;虚拟线程 .isVirtual 断言 + 异常原样重抛;超时→:transient
→:retry 重试整链;同批部分结果(慢者超时不殃及快者);诚实测试——
CPU 忙循环在超时结果返回后仍在跳(钉住「放弃等待 ≠ 终止执行」的真实语义,
防后人误以为有 kill)。Error 会打死整个工具循环(2026-07-16):tool/invoke、
kernel/invoke-tool 的两个 terminal、react/invoke-one 四处一律 catch Exception,
于是 Error 全部逃逸——一个工具的深递归 StackOverflowError 整轮死,而分层错误
路由(:semantic/:transient/:environment)的全部意义就是「一个工具坏了不牵连
别人」。现四处改收 Throwable:非致命 Error 收敛为该工具的错误结果(经
classify-exception 归类,缺省 :semantic),致命的仍原样上抛——判据见新增的
model.error/fatal-throwable?:致命 = VirtualMachineError 中除
StackOverflowError 外的那些(OutOfMemoryError/InternalError/UnknownError)
ThreadDeath。吞掉 OOM 只会掩盖真因,且收敛动作本身还要分配内存。
与 Scala NonFatal 的有意分歧:它把整个 VirtualMachineError 划为致命,
但这里的 Throwable 来自用户工具函数体——栈溢出是它最常见的自伤方式,
栈一退就恢复,正是最该收敛成「这一个工具失败」的那类。deftool :timeout 只有挂 filter 才生效 / 内联工具完全无效 / 值零校验
(2026-07-16 code review)——三条都是上一条 :timeout 修复没做完的部分:
:timeout 仍被静默忽略:根因是两个 func-def 构造点(var 走
build-func-def、inline 另行硬编码),而 inline 的 :serial/:retry 一直生效。
现新增 kernel/tool-timeout(var + inline 双分支,与 serial-tool?/retry-policy/
return-direct-tool? 同款),两个构造点合并为一(新增字段只加一处)。
delegate-tool 恰恰是内联且跑整个子 agent——最需要超时的正是拿不到超时的。:timeout 是唯一不「开箱即生效」的 deftool 选项(:serial/:retry/
:return-direct 都由 kernel/react 直接消费):用户写下 {:timeout 5000} 不挂
filter → 编译通过、无警告、零效果,与死选项 bug 的症状逐字相同。现强制力落到
kernel/invoke-tool(单次调用的时间上限是它的职责;:retry/:serial 那些是
循环/批次策略故属 react),仅在声明时起线程(未声明零开销)。
timeout-filter 相应退为纯缺省:只管未声明的工具,见到声明即让位——
优先级「声明 > filter 缺省」由让位实现,不比大小,同一 deadline 不套两层线程。:timeout 值零校验:"5s" 曾每次调用抛 ClassCastException、-1 曾让工具
每次静默立刻超时、2.7 被静默截断。现 build-kernel 装配期校验
(var 与 inline 汇合、尚未执行的最早时点),坏值在造 kernel 时就炸且报错点名。run-on-executor 从不传导绑定帧(既有 bug):于是同一个工具会因
「这批里有几个 tool-call」而看到不同的 binding 值——批内 1 个走 run-inline
(调用方线程,绑定可见)、≥2 个走 executor(绑定丢失),而发几个 tool-call
是 LLM 临场决定的;换引擎(Sequential 全程内联 → 可见 / VirtualThread →
丢失)同样会变。这违反 react.clj 明写的引擎契约「引擎只决定『怎么把这批
跑完』,不决定『跑的是什么』」。timeout-filter 改虚拟线程时丢了传导(本次引入的回归):原
clojure.core/future 自带绑定传导,Thread/startVirtualThread 没有。
两处均改用 bound-fn* 包装任务,与 future / pmap 的传导语义一致。修复后
四条路径(内联 / executor / Sequential / VirtualThread)行为一致,挂不挂
timeout-filter 也不再改变工具看到的 binding。回归测试钉住批次大小与引擎
两个维度。examples/tool_timeout_live_test.clj,MiniMax 真实
provider):20 项行为断言,四遍稳定(含 timeout-filter 删除后以
「引擎缺省 + 声明」新机制的复验;场景 4 abandon 差值四次实测
1701/1702/1700/1700ms,恒定 ≈ CPU 循环 2500ms − 超时 800ms)。慢后端是本地裸 TCP(零依赖不联网),
只有 LLM 是真的。四场景:超时→模型理解错误→循环存活作答 / 对照组(同为 3s 的
活儿,声明 6s 救活 vs 未声明被 800ms 缺省杀死)/ :retry 透明重试(实跑 2 次、
模型只见成功结果)/ abandon 残余风险(CPU 忙循环副作用晚 1.7s 落地)。
跑真机推翻了一个基于常识的判断:设计文档初稿断言「socket read 打不断」是
残余风险主体——实测 JDK 25 上虚拟线程 + socket read 会被 interrupt 打断
(SocketException: Closed by interrupt;JEP 353 把 Socket 重实现在 NIO 上),
平台线程才无视 interrupt。故上面那条 timeout-filter 修复比初判更有价值:
改虚拟线程不只修了线程模型,还把最常见的工具形态(阻塞 IO / HTTP 调用)从
「打不断」变成了「真能取消」——超时后 socket 立刻关闭,副作用不落地。
残余风险收窄为不检查中断标志的 CPU 密集代码、native 调用等。设计文档 §2.2
已加修订块记录。run-chain 把整条 :tool filter 链
(含 approval-filter 的阻塞审批)一起计时——操作员审批慢即超时,超时结果在链外
合成(日志/审计 filter 看不到)。超时下沉到 terminal(只包工具本体),filter 链
在计时区外。call-with-timeout),
Sequential「调用方线程」与 ThreadPool「舱壁」承诺在有超时不成立——诚实降级 docstring。*active-manager-timeout*,各 record 入口 binding 自身 :timeout。:tool-manager 的 :timeout 零校验::tool-manager {:timeout 0} 每次调用瞬时
超时。build-kernel 装配期一并过 valid-timeout?(消息串统一走 tool/check-timeout!)。run-sync 加 try/finally(drop! 内先 kill!,中断路径一条不漏);
delegate-tool / fanout-tool 写 :timeout 进 inline map(声明恒优先,引擎缺省
砍不掉它)。fanout-tool 的 try/finally 为事后自查补漏(首轮只修了
delegate 的 run-sync,同型的 fanout await 循环漏了——第二次 code review 逮到)。invoke-one 重抛的
OOM 被 EE 包装(普通 Exception),fatal-throwable? 认不出。run-on-executor 拆 cause
原样重抛。err/contain-throwable 收口 4 处
catch-Throwable 三元组;tool/check-timeout! 统一超时校验消息串;:retry 装配期校验
(与 :timeout 对称);inline-meta 预计算消除 4 处 by-name O(n) 扫描;live 脚本
cond-> 冗余 + test sleep 减半。tool-timeout-design.md 补 §5.6 后记记录 P9 的两个
架构事实变更(超时包裹点 链→terminal、引擎缺省读取 字段回读→动态 var),
§3.5 旧修订块加指针防读旧停留;advisor_test 的 5 处裸 map 引擎桩
{:timeout ms} 换 StubManager defrecord(协议 + 字段双契约;裸 map 进真实
react 循环会抛 No implementation of method 的陷阱先例清除;注意 reify 不可用
——关键字查找拿不到 reify 的值,必须 defrecord)。v0.2 是一次破坏性版本:统一消息/tool-call 词汇、模块重组、删除无消费者的子系统。 从 v0.1 迁移请看文末迁移指南。
{:id, :name(字符串), :args}(原响应层为
{:id, :name(keyword), :input},与中立历史层两套词汇并存靠桥接互转)。
影响:on-tool-call 回调收到的工具名从 keyword 变为字符串;读取响应
:tool-calls 的代码须把 :input 改为 :args。im.ttalk.agent.model.types:make-tool-call → 用
im.ttalk.agent.model.message/tool-call;4 个字符串-role 消息构造器
(user-message 等)→ 用 model.message 的中立构造器(keyword role)。
im.ttalk.agent.provider.api 的同名 re-export 已重指向中立构造器(返回形状变化)。on-token 回调不再携带 :accumulated / :reasoning-accumulated,
只发增量 {:token :index} / {:reasoning-token}。需要全文请自行累积
(每 token 物化全文是 O(n²) 的根源)。im.ttalk/clj-agent-client。agent 运行时
(client/react/memory/memory.sqlite/advisor.memory/callbacks/
subagent.*/common 共 8 个 ns)自 core 迁入;命名空间不变,仅依赖坐标变化。
core 现为零依赖纯 Clojure(协议 + kernel 原语),timbre/next.jdbc/sqlite-jdbc
依赖随迁 client。converter.* 与 prompt.* 两个子系统(结构化输出解析 / LangChain 式
提示词模板,共 10 个 ns,约 2900 行):无任何 runtime 消费者。如需结构化输出,
用 provider 侧原生 :response-format(json_object / json_schema+strict)。get-tool-var/
list-functions-by-tag(-s)/list-functions-with-all-tags;tool 的
get-params/sensitive?/get-category/has-tag?;error 的
throw-if-error!/safe-execute;common 的 ensure-kernel;
http/client.clj 已废弃的 with-retry(此前已标 deprecated)。:parse-error:http-kit → java.net.http 迁移后
body 被二次 JSON 解析(ClassCastException),成功响应也被误报;现按
:success? 分派。:input_schema(deftool 宏生成的键);现复用 schema.openai(两种键名都认)。HttpRequest.Builder 没有 .PATCH 方法,
反射解析失败;改走 .method "PATCH"。:api-key/:base-url):生成的变量名带
连字符(如 OPENAI_API-KEY),POSIX 不可设置;现连字符转下划线
(OPENAI_API_KEY)。deps-deploy.deploy-deps(实为 deps-deploy.deps-deploy)
→ 加载即失败;② b/install 误传 :src-dirs 缺必填 :class-dir → install 必错;
③ b/create-basis 无顶层 :override-deps 参数(被静默忽略)→ client/provider 的
pom 一直缺失 core 依赖,消费方解析即断;现经 alias 启用 override。
已验证:三模块 jar + pom 正确产出并 install,纯 mvn 坐标的消费端项目可解析并运行。spawn!/restart! 曾在注册表条目写入前启动 worker,
秒完成的任务状态永远卡 :running;kill! 后 await!/result 曾返回 nil,
且被中断的 worker 会把 :killed 覆盖成 :failed。现先登记后启动 +
finish! 终态守卫 + kill! 写入明确 {:error :killed}。tools->schemas 有界缓存:ReAct 循环每轮 LLM 调用不再对不变的工具列表
重跑 wire 转换(openai/anthropic 两个 schema 命名空间)。subagent/manager 从 clojure future(无界平台
线程池)改为 newVirtualThreadPerTaskExecutor(kill! 语义不变)。*warn-on-reflection* 并修复
暴露的反射/装箱点;新增反射会在编译期告警。post-stream-sync 支持 :retry(约定同
http.retry/maybe-with-retry:true=默认配置 / map=合并),仅当错误可重试
且尚未流出任何 token 时指数退避重试;provider config 传 :retry 即启用。stream_client/post-stream-sync:流式同步编排(promise 对 / cancel 登记 /
结果分派)统一入口,openai-compat / anthropic / dashscope 三路共用。http/client.clj/response->error:HTTP 失败 → canonical error 的统一实现。clj-agent-process 第四模块形态完整落地并全绿)在发布前整体撤下——
设计经评审判定需重新思考(V1/V2 快照与并行的语义撕裂,对照 SK Process /
MS Agent Framework superstep 模型);未进入任何发布版本,设计文档留档。client_test 的 httpbin.org 外网用例本地化(com.sun.net.httpserver),
消除 CI flake。不指定 :tool-manager 时,同一轮的多个 tool-call 从并行(v0.2 缺省,虚拟线程)
改为按调用序依次执行。绝大多数工具无感知;但有一类升级后会永久挂起:
;; v0.2 下能工作的「批内互等」工具对——A 等 B 的信号,B 等 A 的信号,
;; 并行时双双就绪;串行缺省下 A 先跑、永远等不到 B,而缺省又无超时:
;; 无错误、无日志、循环永不返回。
排查判据:工具体内有 deref promise / 等锁 / 等队列,且对端是同批另一个
工具 → 中招。迁移:显式注入并发引擎(行为与 v0.2 逐字相同):
(kernel/build-kernel {:service svc :tools [...]
:tool-manager (react/virtual-thread-tool-calling-manager)})
顺带建议给引擎配 :timeout 兜底(见下节)——即使漏排查了一对互等工具,
也是「超时错误」而非「永久挂起」。
timeout-filter 已删除,超时是内建机制;; v0.2
{:filters [(filters/timeout-filter 5000)]}
;; v0.3 —— 两个来源,开箱即生效,无需任何 filter
(deftool slow-api "..." [...] {:timeout 5000} ...) ;; 工具声明(优先)
{:tool-manager (react/sequential-tool-calling-manager {:timeout 30000})} ;; 引擎缺省
0/"5s"/负数)装配期即抛:retry 的超时工具必须幂等invoke-tool 返回形状;; v0.2 ;; v0.3
{:value result :context ctx} {:value result} ;; 无写
{:value result :writes {k v}} ;; 有写意图
直调方自行用 context/apply-writes 折叠 writes(react 循环内已自动处理)。
;; v0.2 短路分支须回传 context ;; v0.3 响应侧无 :context
{:result "blocked" :context (:context req)} {:result "blocked"}
不带 :writes 的响应 = 该调用写意图不生效(事务性);:error {:class ...}
参与屏障分层路由。
;; v0.2:含 :result 的 map 会被拆包 ;; v0.3:判据改为「含 :writes」
(fn [args ctx] {:result r}) (fn [args ctx] r) ;; 纯结果直接返回
(fn [args ctx] {:result r :writes {k v}}) ;; 要写才包
工具/filter 不再改写 context;写走返回值 :writes,屏障处按 build-kernel
:state-slots 声明的槽级 reducer 折叠(未声明槽 last-writer)。同批工具互相
看不到对方的写(轮初快照)——此语义与选哪个引擎无关。
on-tool-result 时序改为任务完成即实时触发,批内顺序不确定;需确定顺序读 :tool-calls-made /
:records。
LineageStore → BranchStore(含 SQLite 表重建)| v0.2 | v0.3 |
|---|---|
lineage-add! / lineage-get / lineage-children / lineage-remove! | record-branch! / get-branch / branch-children / delete-branch! |
in-memory-lineage-store / sqlite-lineage-store | in-memory-branch-store / sqlite-branch-store |
deps key :lineage | :branch |
SQL 表 branch_lineage | branch_records(跨版本需重建,无自动迁移) |
用户侧查询 API(lineage / ancestry / fork! / rollback! / prune!)不变。
;; v0.1
{:deps {im.ttalk/clj-agent-core {...}
im.ttalk/clj-agent-provider {...}}}
;; v0.2 —— agent 运行时在新模块(命名空间不变,require 无需改动)
{:deps {im.ttalk/clj-agent-client {...} ;; 传递引入 core
im.ttalk/clj-agent-provider {...}}}
;; v0.1:响应层 tool-call
{:id "c1" :name :get-weather :input {:city "北京"}}
;; v0.2:全库统一
{:id "c1" :name "get-weather" :args {:city "北京"}}
(:input tc) → 改 (:args tc)(= :get-weather (:name tc)) → 改 (= "get-weather" (:name tc))on-tool-call 回调:(fn [name args] ...) 中 name 现为字符串;; v0.1 ;; v0.2
(types/make-tool-call id name input) (msg/tool-call id name args)
(types/user-message "hi") (msg/user "hi") ; {:role :user ...}
(types/assistant-message "ok") (msg/assistant "ok")
(types/system-message "sys") (msg/system "sys")
(types/tool-message id content) (msg/tool-result id name content)
;; msg = im.ttalk.agent.model.message;注意中立消息 role 为 keyword
;; v0.1:可以直接读全文
(fn [{:keys [token accumulated]}] (render! accumulated))
;; v0.2:自行累积
(let [acc (StringBuilder.)]
(fn [{:keys [token]}] (when token (.append acc token)) (render! (str acc))))
直接用 provider 原生能力(OpenAI 兼容系列):
{:model "..." :response-format {:type "json_schema"
:json_schema {:name "Person" :strict true
:schema {...}}}}
Can you improve this documentation?Edit on GitHub
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 |