Liking cljdoc? Tell your friends :D

Changelog

本项目版本号形如 0.x.<git-count>(各模块同步)。本文件按 Keep a Changelog 组织。

[0.5.0] - 未发布(2026-11-09~11-10:clj-http + clj-agent-server)

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(原回 *)。
  • qwen 思考写法(:thinking-style :qwen,及原生 :dashscope):开思考时非流式也写 enable_thinking: true (原为非流式强制 false,照 Keelson / 旧版百炼文档)。依据:2026-11-10 LiteGate 真机 qwen3.8-flash / max 非流式开思考 正常返回 reasoning_content、不 400。老模型若仍有该限制,用 :configure-request 按 :stream 改回。
  • Anthropic:缓存策略实际打到 "1h" 断点时,请求头自动带 anthropic-beta: extended-cache-ttl-2025-04-11 (与 :beta 合并去重)。官方端点已 GA、多发无害;全 5m 时不变。
  • OpenAI 兼容线:流式请求缺省带 stream_options: {include_usage: true}(与 Keelson.Agent 对齐),末块不原生带 usage 的端点也能拿到账;:stream-options 仍可覆盖,显式 false / {} 关闭。所有 OpenAI 兼容请求非流式一律不发 stream_options,严格端点不再被探针 / 一次性调用打 400。
  • MCP Streamable HTTP 传输改用共享的 im.ttalk.http/default-client:固定 HTTP/1.1(原先 JDK 缺省先试 HTTP/2)、虚拟线程 executor、连接池共享、建连超时 30s(原 = :timeout-ms)。要原行为传 :client。
  • web_fetch 的缺省 client 换成虚拟线程 executor(其余设置不变:不跟随重定向、建连超时 = :timeout-ms)。

🐛 修复

  • OpenAI 兼容流式答案整段丢失(qwen / 百炼兼容模式,2026-11-10 LiteGate 真机发现):百炼每块同时带 content 与 reasoning_content,空的那格是 "";stream.openai/process-chunk 用 (:reasoning_content delta) 判断,空串为真, 答案块 {"content":"15","reasoning_content":""} 被当成推理块,content 恒为空。改为判非空(not-empty), 同块两格都非空时推理累积进最终响应、实时发答案 token;空串块不再发空 token。原生 stream.dashscope 同口径。
  • DashScope 原生 provider 不读思考键::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),原先思考内容整段丢失。
  • Retry-After 的解析:取头统一走 im.ttalk.http/header,另认 RFC 850 / asctime 日期、小数秒与 retry-after-ms 头。
  • AG-UI 外部 /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 后加载再比顺序,数组条目属性表也在判据内。
  • MCP 的 SSE 响应只取第一行 data::一条 JSON 拆成多行 data 时解析失败。现按 WHATWG 分帧拼接。
  • IO 失败时 JDK 异常常没有 message,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 而不是字符串。
  • 移植 Keelson clj-net-server 时修正:ns 改名后宿主用 ::on-stopping(自动解析)读路由元数据,与 AG-UI 路由写的键对不上, 停机钩子会丢——改为显式的 :im.ttalk.agent.server/on-stopping。

[0.4.0] - 未发布(2026-11-08 借鉴 Keelson)

版本方案 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 顶部一轮)。

✨ 新增

  • provider 可靠性(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。
  • Agent 运行时(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 …}})。
  • AG-UI(designs/agui-borrow.md):审批改参数 approveWithEdits + 拒绝理由透传;一轮多条审批; SSE 心跳;多 agent 注册表 agui.registry;取消原因(stop / superseded / shutdown / orphan)与 可选孤儿自动停;线程元数据持久化 agui.thread-store(内存 + 按需加载 sqlite);入参校验 400 / 413、/stop 带 interrupt、/threads 过滤 / 分页 / 缺槽 501。
  • 可替换的持久化挂点(用户拍板:本库不做持久化,留给外部库):AG-UI 事件存储 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。

⚠️ 缺省行为变化

  • 流式在首个增量之前失败缺省重试 2 次(原 0 次);config 里的 :retry 改由 ChatModel 统一执行, 不再叠加传输层重试。空补全缺省补打 1 次,仍空原样返回(:empty-exhausted :throw 才抛)。
  • 欠费类 429 不再当限流重试,classify-exception 归 :environment;普通 429 照旧重试。
  • var 工具的参数按 schema 完整校验(原先只查必需参数)。
  • Anthropic pause_turn 现在续跑(原以 :pause 收尾)。
  • AG-UI 审批答复 {approved:false} 一律当拒绝(原被误判为批准)。

🐛 修复

  • Retry-After 在真实请求上从未生效(响应头值是 List,原代码按字符串解析失败)。
  • Anthropic 流内 error 事件被吞成「流式响应未产出结果」,现转为 canonical error。
  • openai 兼容 provider 的错误 :provider 为 nil。
  • subagent.manager/resume! 把 {:payload … :on-token …} 整个当 payload 传给 simple-agent/resume: 子 agent 审批改参数不生效、:reply 抛异常、续跑不流式。改为 payload 与 chat-opts 分开传。

[0.3.0] - 未发布(2026-07-17 定稿;2026-07-11 启动)

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,保留。

    • 重试在整个 filter 栈之下:filter 看到的是「一次逻辑调用」,重试重入 碰不到任何 filter(否则 memory-filter 会把同一轮 delta 写两遍,且无运行期症状)
    • 流式不重试:token 已投递给 sink,重跑即重复内容
    • 判据是 canonical error 的 :retryable?;Retry-After 由 provider 边界解析成 :retry-after-ms 传上来,受 :max-delay 约束
    • 三级取值:单次 opts > provider config > 框架默认(2 次);: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

    • 三个 invoke 原语。没有照 Spring 收窄到 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
    

    语义要点(全部有测试钉住):

    • 重入即记账:重入一轮 = 那一轮的 LLM 调用与工具批次真的又跑了一遍, remaining 与 records 都如实计入——记「发生过什么」,不记「逻辑上算几轮」。 max-iterations 因此对 filter 重入仍是硬上限。为此 run-tool-loop 的 remaining / records 从 loop 参数改为 volatile,扣减点在批次实际执行之后, 与改造前 (recur … (dec remaining) …) 的时机逐字相同。
    • 暂停照常出链:暂停是终端的返回值而非异常,:paused 沿链回流、around 后半段照常执行,filter 看得见暂停——单轮计时/记账在 HITL 下也能正常收尾。 (立项时的判断相反,写测试时被证伪,见 TASK.md 该节坑 1。)
    • 但 resume 的那半批不算一轮:resume 执行的是「暂停那一轮的下半截」 (批次已定、无新 LLM 调用),不经过本链;续跑的循环从下一个完整轮重新进链。
    • 硬规则同 :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 就废了)。

    • 新增 core ns 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)。
    • 图片 / 音频 / PDF 不各立一类,统一是 :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 的块…)视为厂商原生块原样透传, 可与中立部件混用。
    • 二进制一律以 base64 字符串落地,不是 byte[]——对话历史要过 EDN/SQLite 往返,字节数组过不去(有 SQLite 往返回归测试钉住)。
  • 文本向量化:IEmbeddingProvider 独立可选协议 + 9 个内置端点(2026-08-18)。 框架此前完全没有 embedding 能力。

    • core 新增 ns im.ttalk.agent.model.embedding(单方法 embed,批量即全部)。 与 ILLMProvider 并列、不加进它:embedding 是另一种模型(另一端点、 另一套参数、另一份计费)。同样不给 Object 兜底——satisfies? 必须可信, 没有 embedding 服务的厂商(Anthropic)在表里根本没有条目。
    • provider 新增 provider/embeddings(工厂 + 三个实现)与 provider/common/embeddings(OpenAI 兼容 + DashScope 原生两种线上形状): :openai / :zhipu / :siliconflow / :mistral / :gemini / :ollama / :dashscope / :openai-compat / :mock(确定性假向量,离线测试用)。
    • 超批自动切片按原序拼回、usage 逐片累加;返回向量按服务端 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 两种块。于是工具循环第二轮起,模型再也看不到自己前几轮的思考。

    • 代价是实测出来的,不是从文档推的:M3 上 A/B 对照(n=40/臂,7 步串行工具链, 答案唯一可自动判定)——完整回传 100% 正确、逐轮全对 100%、40 次零方差; 剥掉 thinking 82.5% 正确、逐轮全对 47.5%、轮数 3–17。单侧 Fisher p=0.0059。 (M2.x 不受影响:它关不掉 thinking,每轮必然思考。)
    • 机制:新增可选协议 im.ttalk.agent.model/IReplayableResponse (单方法 replay-blocks),service 归一化时 satisfies? 探测—— 不实现的 provider(含仓库外的实现)一行不改照常工作。 不往 ILLMProvider 加方法:那是 DIP 的端口,加方法 = 所有实现方破坏性变更。
    • core 只搬运不解释:载荷带 :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?。单测专钉这条。
    • 验收:+12 tests / +45 assertions(全套 331/1369/0);真机走 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。零破坏。

    • ToolSearch(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(deftool {: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): 编排层的键不下沉,其余一律透传。

    • 风险面朝向反了才安全:白名单漏一个键 = 一个能力静默失效(只在用户报「为什么 不生效」时才发现);排除法漏一个键 = 编排层的值被塞给 provider,当场炸在测试里。
    • :tools 是名单里最危险的一条(两边都有、含义不同:service config 里是已编译 schema,agent 里是 tool var 向量)。漏下去 provider 转出 {:name nil},MiniMax 报 400「function name is empty」——P0 实验第一版真撞过,故单测专钉这条。
    • 显式 nil 不下沉(否则 provider 侧 (some? temperature) 这类判据会被 nil 骗过)。
    • +3 tests / +17 assertions(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 的超时工具必须幂等——重试时上一次调用可能仍在跑)。

🔧 内部 / 测试

  • 构建/开发脚本重做:5 个 shell 脚本 → 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。
    • 顺带修掉一个真 bug:旧 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 跑不动。
  • 新语义测试:S1 批次 9 个(真并行证明、快照隔离、last-writer 按调用序而非 完成序、失败 writes 丢弃、messages/records 原序、serial 整批退化、reject、 reducer 折叠与跨轮累积)+ S2 分类/重试/环境暂停 5 组 + HITL 持久化 6 个 (EDN 往返、跨重启审批与 env-retry 恢复、context 恢复回归、清理时机)
    • Timeline 7 个(writes 落历史与 wire 剥除、fork/血缘/rollback/prune、 HITL 决策分支、编辑重试)。全套 230 tests / 971 assertions / 0 failures。
  • Advisor 对齐批次测试(2026-07-15):253/1039 → 292 tests / 1194 assertions / 0 failures(+39 tests / +155 assertions,零回归)。
    • 先补钉子后动工::chat filter 改写 :tools 抵达 provider 的契约此前 无测试覆盖,而 ToolSearch 完全建立在它之上——先补 3 个断言钉住 (含 context 驱动的动态工具集),再实现。
    • ToolSearch:索引单测 8 组(中文二元组/camelCase/snake_case/limit/权重/ 重建/非法正则)+ 端到端 3 组(真实 react 循环跑通「只见 search_tools → 检索 → 下一轮见到工具 → 调用」完整往返、两次检索并集累积、未命中不污染 context)。
    • return-direct 5 组(LLM 只调一次、transcript 完整无悬空且下个 turn 的 heal 不摘、混批仍回灌、多结果拼接、tool-calls-made 记录); eligibility-fn 3 组。
    • structured-output 10 组(必填/类型/枚举/嵌套路径/数组下标/布尔不算 integer/字符串键/开放世界/围栏剥离/驱动重试反馈带具体问题)。
    • safeguard 7 组(短路不进循环、大小写、resume 放行、多模态 content)、 re-reading 4 组、logging-chat 3 组、rag 9 组(每 turn 只检索一次、空检索 不注入、多模态不改写、只改最后一条 user 消息)。
  • 文档一致性门禁(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 运行时。已补。
  • live 验证脚本(真实 provider):五个新脚本,共 78 项行为断言,均已实跑 通过(各自反复跑 2–3 遍稳定)。断言一律钉机制(发给 provider 的消息/工具集、 LLM 调用次数、落库形状),不钉模型措辞——后者会波动,拿它当断言等于给 CI 埋雷; 模型的实际回答只打印不断言。
    • 新增 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 文档补上类型说明防复发。
  • 工具超时批次测试(2026-07-16):300/1227 → 307 tests / 1251 assertions / 0 failures(+9 tests / +29 assertions,含上条绑定修复的 2 个,零回归)。元数据回归钉 + 端到端穿 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 时就炸且报错点名。
  • 工具执行丢失调用方的动态绑定(2026-07-16,两处,均为静默给根值—— 无报错、只是悄悄读错):
    • 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。回归测试钉住批次大小与引擎 两个维度。
  • 工具超时批次终态:全套 314 tests / 1295 assertions / 0 failures (超时/绑定/Error 收敛/缺省机制/filter 删除各阶段的独立计数见 TASK P8 各节)。
  • 工具超时 live 验证(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 已加修订块记录。
  • P9 code review 第二轮(2026-07-16,10 条 CONFIRMED 全部修复):
    • R1/R3 超时包裹 filter 链而非工具本体:run-chain 把整条 :tool filter 链 (含 approval-filter 的阻塞审批)一起计时——操作员审批慢即超时,超时结果在链外 合成(日志/审计 filter 看不到)。超时下沉到 terminal(只包工具本体),filter 链 在计时区外。
    • R2 超时起 VT 与引擎线程模型冲突:声明超时的工具总是跑在 VT 上(call-with-timeout), Sequential「调用方线程」与 ThreadPool「舱壁」承诺在有超时不成立——诚实降级 docstring。
    • R4 manager :timeout 读 kernel 反向指针:instrumented wrapper / 独立传入的 manager 会读错对象。改动态 var *active-manager-timeout*,各 record 入口 binding 自身 :timeout。
    • R5 :tool-manager 的 :timeout 零校验::tool-manager {:timeout 0} 每次调用瞬时 超时。build-kernel 装配期一并过 valid-timeout?(消息串统一走 tool/check-timeout!)。
    • R6 delegate 被引擎超时打断 → kill!/drop! 跳过:子 agent 泄漏且继续烧 token。 run-sync 加 try/finally(drop! 内先 kill!,中断路径一条不漏); delegate-tool / fanout-tool 写 :timeout 进 inline map(声明恒优先,引擎缺省 砍不掉它)。fanout-tool 的 try/finally 为事后自查补漏(首轮只修了 delegate 的 run-sync,同型的 fanout await 循环漏了——第二次 code review 逮到)。
    • R7 executor 路径 OOM 被 Future.get 包成 ExecutionException:invoke-one 重抛的 OOM 被 EE 包装(普通 Exception),fatal-throwable? 认不出。run-on-executor 拆 cause 原样重抛。
    • R8-R10 docstring/测试陈述过时:run-tools 恒串行但吃引擎 :timeout(docstring 如实写明); ThreadPool :timeout 因果倒置(无超时卡死才占槽,非被超时放弃的占);tool_test「声明无强制力」 与「开箱即生效」矛盾(改为「裸 tool/invoke 不超时」)。
    • 次级 cleanup(helper 提取,零行为变化):err/contain-throwable 收口 4 处 catch-Throwable 三元组;tool/check-timeout! 统一超时校验消息串;:retry 装配期校验 (与 :timeout 对称);inline-meta 预计算消除 4 处 by-name O(n) 扫描;live 脚本 cond-> 冗余 + test sleep 减半。
    • 收尾(2026-07-17):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)。
    • 全套 316 tests / 1307 assertions / 0 failures,MiniMax live 20 项六遍稳定 (场景 4 abandon 差值六次实测 1701/1702/1700/1700/1700/1700ms,恒定 ≈ CPU 循环 2500ms − 超时 800ms——修复前后跨越了架构重排,测量纹丝不动)。

[0.2.0] - 未发布(2026-07-10 定稿)

v0.2 是一次破坏性版本:统一消息/tool-call 词汇、模块重组、删除无消费者的子系统。 从 v0.1 迁移请看文末迁移指南。

💥 破坏性变更

  • tool-call 全库统一为 {: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)。
  • 死代码清理(均无仓库内调用方):kernel 的 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)。

🐛 修复

  • DashScope 同步调用必抛 :parse-error:http-kit → java.net.http 迁移后 body 被二次 JSON 解析(ClassCastException),成功响应也被误报;现按 :success? 分派。
  • deftool 工具在 DashScope 下参数静默丢失:其 schema 转换不识别 :input_schema(deftool 宏生成的键);现复用 schema.openai(两种键名都认)。
  • PATCH 请求必然运行时崩溃:HttpRequest.Builder 没有 .PATCH 方法, 反射解析失败;改走 .method "PATCH"。
  • factory 环境变量读取从未生效(:api-key/:base-url):生成的变量名带 连字符(如 OPENAI_API-KEY),POSIX 不可设置;现连字符转下划线 (OPENAI_API_KEY)。
  • 发布构建链从未跑通(三个叠加 bug,本次全修): ① build.clj require 了不存在的 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 坐标的消费端项目可解析并运行。
  • 子 agent 状态竞态:spawn!/restart! 曾在注册表条目写入前启动 worker, 秒完成的任务状态永远卡 :running;kill! 后 await!/result 曾返回 nil, 且被中断的 worker 会把 :killed 覆盖成 :failed。现先登记后启动 + finish! 终态守卫 + kill! 写入明确 {:error :killed}。

⚡ 性能

  • 流式累积 O(n²) → O(n):stream/{openai,anthropic,dashscope} 的所有累积器 (正文/推理/块内文本/工具参数 JSON)改 StringBuilder 原地 append。
  • tools->schemas 有界缓存:ReAct 循环每轮 LLM 调用不再对不变的工具列表 重跑 wire 转换(openai/anthropic 两个 schema 命名空间)。
  • HttpClient 单例共享:流式与非流式此前各持一个虚拟线程连接池,现共用。
  • 子 agent 改虚拟线程:subagent/manager 从 clojure future(无界平台 线程池)改为 newVirtualThreadPerTaskExecutor(kill! 语义不变)。
  • 反射清零:全部 60 个 src 命名空间开启 *warn-on-reflection* 并修复 暴露的反射/装箱点;新增反射会在编译期告警。

✨ 新增

  • 流式建链重试(opt-in):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 的统一实现。

🔧 内部 / 测试

  • 测试基线 196 → 204 tests / 840 assertions / 0 failures;新增覆盖: timeout/approval filter(含后台中断不泄漏线程)、factory 配置(env 名规范/ 三级合并/validate 全分支)、SQLite 与 InMemory 并发写、子 agent 生命周期、 DashScope 同步回归、流式建链重试。
  • Process Framework(V1 同步引擎 / V2 core.async 并行引擎 / Timeline 快照, 曾以 clj-agent-process 第四模块形态完整落地并全绿)在发布前整体撤下—— 设计经评审判定需重新思考(V1/V2 快照与并行的语义撕裂,对照 SK Process / MS Agent Framework superstep 模型);未进入任何发布版本,设计文档留档。
  • client_test 的 httpbin.org 外网用例本地化(com.sun.net.httpserver), 消除 CI flake。
  • 设计文档状态与代码对齐:memory-filter-refactor(已完成)、onion-filter (全部实施,落地为 clj-agent-client)、streaming-async-design(全部落地)、 response-path-consolidation(双消息体系统一完成)。

从 v0.2 迁移到 v0.3

1. 缺省执行引擎改为串行 ⚠️ 唯一可能静默挂起的变更,先读这节

不指定 :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 兜底(见下节)——即使漏排查了一对互等工具, 也是「超时错误」而非「永久挂起」。

2. 超时: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})} ;; 引擎缺省
  • 缺省不超时(v0.2 的 filter 也是 opt-in,故仅迁移挂过 filter 的部署)
  • 优先级:工具声明 > 引擎缺省 > 不超时;坏值(0/"5s"/负数)装配期即抛
  • 语义不变:超时 = 放弃等待 ≠ 终止执行(阻塞 IO 会被真正取消;纯 CPU 循环 打不断,副作用可能在超时后落地)——声明 :retry 的超时工具必须幂等

3. invoke-tool 返回形状

;; v0.2                            ;; v0.3
{:value result :context ctx}       {:value result}            ;; 无写
                                   {:value result :writes {k v}}  ;; 有写意图

直调方自行用 context/apply-writes 折叠 writes(react 循环内已自动处理)。

4. tool filter 响应契约收窄

;; v0.2 短路分支须回传 context      ;; v0.3 响应侧无 :context
{:result "blocked" :context (:context req)}   {:result "blocked"}

不带 :writes 的响应 = 该调用写意图不生效(事务性);:error {:class ...} 参与屏障分层路由。

5. 工具 / inline handler 返回值拆包判据

;; v0.2:含 :result 的 map 会被拆包   ;; v0.3:判据改为「含 :writes」
(fn [args ctx] {:result r})           (fn [args ctx] r)                    ;; 纯结果直接返回
                                      (fn [args ctx] {:result r :writes {k v}})  ;; 要写才包

6. ToolContext 只读 + 状态槽

工具/filter 不再改写 context;写走返回值 :writes,屏障处按 build-kernel :state-slots 声明的槽级 reducer 折叠(未声明槽 last-writer)。同批工具互相 看不到对方的写(轮初快照)——此语义与选哪个引擎无关。

7. on-tool-result 时序

改为任务完成即实时触发,批内顺序不确定;需确定顺序读 :tool-calls-made / :records。

8. LineageStore → BranchStore(含 SQLite 表重建)

v0.2v0.3
lineage-add! / lineage-get / lineage-children / lineage-remove!record-branch! / get-branch / branch-children / delete-branch!
in-memory-lineage-store / sqlite-lineage-storein-memory-branch-store / sqlite-branch-store
deps key :lineage:branch
SQL 表 branch_lineagebranch_records(跨版本需重建,无自动迁移)

用户侧查询 API(lineage / ancestry / fork! / rollback! / prune!)不变。


从 v0.1 迁移到 v0.2

1. 依赖坐标(若直接引用 core 且使用 agent API)

;; 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 {...}}}

2. tool-call 形状

;; 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 现为字符串

3. 构造器

;; 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

4. 流式 on-token

;; v0.1:可以直接读全文
(fn [{:keys [token accumulated]}] (render! accumulated))
;; v0.2:自行累积
(let [acc (StringBuilder.)]
  (fn [{:keys [token]}] (when token (.append acc token)) (render! (str acc))))

5. 结构化输出(converter 已删除)

直接用 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

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close