Skip to content

feat: 原生函数调用 + 多工具串行批次执行(tool execution 重构) - #211

Merged
baicaizhale merged 32 commits into
masterfrom
feat/toolexecution
Aug 11, 2026
Merged

feat: 原生函数调用 + 多工具串行批次执行(tool execution 重构)#211
baicaizhale merged 32 commits into
masterfrom
feat/toolexecution

Conversation

@baicaizhale

Copy link
Copy Markdown
Owner

概述

重构工具调用体系:原生函数调用门控 + 文本协议多工具串行批次 + 文件工具 JSON 行格式。

核心改动(32 commits)

工具调用

  • 原生函数调用native_tool_calling):OpenAI 兼容 tool_calls + Responses API(gpt-oss),显式声明 parallel_tool_calls/tool_choice
  • 多工具批次:文本协议支持一次输出多个 #tool,串行执行、逐条反馈、60s 超时兜底
  • #run 安全:命令存在性校验(疑似斜杠错误拦截教育)、SMART 风险评估、YOLO 风险词检查、force
  • 工具调用文本协议 → JSON 行:#edit/#write 参数改为标准 JSON 转义,消灭 | 分隔符冲突与 \n 三层转义;原生通道 bridgeToText 统一输出 JSON 行
  • YOLO 免确认路径修复:文件工具不再残留 pendingCommand(此前后续消息全被"请确认"吞掉)

会话与稳定性

  • 会话原子写入:临时文件 + ATOMIC_MOVE,避免中断留下半截 JSON 导致 resume 扫描报错
  • 玩家名缓存:离线保存会话时定位玩家(修复断线保存跳过)
  • system 后 user 占位:兼容严格网关(reka 等)消息序列校验
  • 配置热重载:WatchService 增补 CREATE/DELETE + mtime 轮询兜底
  • 压缩输入不截断:移除 500 字符截断,逐条诊断日志
  • 主对话温度可配置fancy.temperature(null=模型默认,0=完全确定),6 处主对话请求挂载

测试

  • 418 单测全过(新增 ToolExecutorFileFormatTest 10 例、批次/提取/解析测试)
  • FancyTester 集成测试全绿:4 工具批次、含 | 的 edit/write、compact、YOLO 链路

baicaizhale and others added 30 commits August 8, 2026 18:48
- LLMClient 请求体发 tools 数组(fancy/openai/cloudflare + gpt-oss Responses 格式),400/422 自动去 tools 降级重试
- StreamingHandler 累加流式 delta.tool_calls(跨 delta 归并参数片段)
- ResponseParser 解析 OpenAI message.tool_calls 与 Cloudflare function_call 项
- 新增 ToolRegistry:按权限门控组装 tools、tool_call 桥接回 #tool 文本复用 handler、模型黑名单
- 修复 #edit_memory 前缀劫持 bug(KNOWN_TOOLS 长名在前)
- 串行批量:一次响应多个 tool_calls 逐个执行,结果合并一次回灌模型(批次安全集 + 60s 超时兜底)
- config.yml 新增 native_tool_calling 开关(默认关,保持原文本协议)
- 380 测试全绿

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLIManager 的 API 异常 catch 块此前只向玩家发送简略报错、不打印控制台日志,
导致 API 失败(如 401/429/模型不支持)无法在服务端诊断。

为全部 API 相关错误路径补 `[CLI] ...` warning 日志(含玩家名 + 完整异常堆栈):
- processAIMessage 主入口与重试的 IOException/Throwable catch
- continueGeneration 的 IOException catch
- feedbackToAI 的 IOException/Throwable catch
- processStreamingMessage 与 feedbackToAI 的流式 onErrorCallback

玩家聊天界面不变,仅服务端控制台可诊断。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
buildToolsArray 生成的 parameters 直接是 {"query": {...}},最外层缺
{"type":"object","properties":{...}}。OpenAI 兼容 API 要求 parameters 是
合法 JSON Schema,DeepSeek 返回 400: Invalid schema ... got 'type: null'。

addTool 统一把传入的 properties 内容包一层 object schema。新增测试断言
parameters 形状。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
obj("type", str("string","array")) 生成 {"type":{"type":"string","description":"array"},...},
type 字段被塞进一个对象而非字符串,不是合法 JSON Schema。DeepSeek 校验严格,
二次请求 400 直接拒绝,模型无法再给草方块。新增 arr() 辅助生成
{"type":"array","description":...,"items":{"type":...}},ask.options 与 todo.todos 走它。

同时给原生 tool_calls 分发与批次执行加诊断日志,区分"模型只返回一个调用"
与"多调用被单工具路径静默丢弃"两种情况。新增测试断言数组参数 schema 合法。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
根因:提示词 [Single Tool Call] 规定每次响应只能一个工具调用,
deepseek 严格遵守,导致"给我苹果和草方块"只返回一个 run。

native 开启时:Single Tool Call → Multiple Tool Calls(可一次返回多个
独立函数调用,串行执行、结果一次性回灌);#tool 文本降级为"仅原生不可用
才兜底";Usage Guide 的 todo 后禁止其他工具也放开。文本协议分支原样保留。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
根因:hasPreloadedSession 遍历 30 分钟内的所有会话文件,只要找到一个
非 explicitExit 的就返回 true;而 loadLatestPlayerSession 只取最新文件,
最新文件 explicitExit 时返回 null。两处判定不一致导致:
  1. 之前某次在 CLI 中重载 → shutdown 保存了无 explicitExit 的旧文件 A;
  2. 本次退出 CLI → 最新文件 B 被标记 explicitExit;
  3. 重载后 hasPreloadedSession 扫到 A → 误判 true → 进 CLI;
  4. loadLatestPlayerSession 取最新的 B → 恢复失败 → 空会话进入。

修复:hasPreloadedSession 改为与 loadLatestPlayerSession 一致——
只检查最新会话文件是否 explicitExit。显式退出后重载不再自动进入 CLI。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
问题:退出 CLI 后深度重载仍会误入 CLI。之前尝试用 explicitExit 标记修复,
但根因是恢复的会话可能无用户消息(hasUserMessage=false)导致标记逻辑被跳过,
残留旧文件也会被当作可恢复。

新方案:完全禁用自动恢复——
- 注释 FancyHelper.checkOnlinePlayersForPreloadedSessions 调用
- 注释 ChatListener.onPlayerJoin 的预加载自动进入逻辑
- 插件关闭时 sendUnloadMessage 改为"会话已保存并退出,点击恢复或输入
  /cli resume",带可点击按钮(三语言文案)
- 手动 /cli resume 不受 explicitExit 限制,列表照常显示所有会话

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sendUnloadMessage 改为接收当前会话 UUID,提示"会话已保存并退出。使用
/cli resume <uuid> 恢复",恢复命令整条蓝色下划线可点击(RUN_COMMAND),
三语言文案同步。无 UUID 时退化为通用 /cli resume。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
/cli resume <uuid> 会把 uuid 当页码解析并显示列表,不恢复会话。
显示保持 /cli resume <uuid>,点击实际执行 /cli resume_confirm <uuid>
直接恢复该会话。hover 如实显示实际执行命令。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
之前多个 run 一起返回时,非 YOLO 模式因确认按钮卡批次屏障而只执行
第一个。现放行 run 在所有模式进批次:逐个确认→执行→捕获输出暂存→
下一项→全部完成合并一次发回 AI。

- isBatchSafeTool 放行 run(NORMAL/SMART/PLAN/YOLO)
- 新增 batchInProgress 标记,让批次最后一项的异步反馈也能被屏障
  拦截合并,不再漏结果
- 取消/拒绝在批次中回灌 #error 结果并推进下一工具,避免永久卡死
- handleConfirm 重置超时起点,防止确认按钮停留过久被 60s 兜底误杀

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
原实现固定 1s/5s 延迟:spark 这类打完命令后 4-5s 才有后续输出的
命令会被提前截断,秒回命令又白白等 5s。改为捕获缓冲长度增长判定:

- 有新输出 → 重置静默计时;有输出后安静 2s 即收尾
- 从未有输出 → 等待 5s 首次输出宽限后走控制台日志兜底
- 绝对上限 15s 防止持续吐输出的命令无限等待
- 命令失败立即收尾;captureLength/stopCapture 与 netty 监听器
  共用锁,保证主线程读到最新写入

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
两个问题:
1. native 模式下 todo 工具 schema 是 {todos:[...]},模型返回该对象经
   bridgeToText 原样透传,但 updateTodos 只接受裸数组,导致报"必须数组
   格式"。现兼容 {todos:[...]} 与裸数组 [...] 两种形状。
2. todo 解析失败(多为模型格式问题)的详细错误文案此前直接 sendMessage
   展示给玩家。这是内部细节,改为仅 feedbackToAI 让 AI 自行修正,
   控制台保留 warning 日志。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cloudflare Workers AI(gemma 等)缺少 parallel_tool_calls 时会退化到
单工具调用,导致"请同时给我苹果和木头"只执行一个命令。加上该字段后
实测稳定返回多个 tool_calls;stripTools 降级时一并移除配对字段。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
gemma 在完整系统提示词下退回文本协议并输出多个 #run,原 extractToolCall
只取第一个导致'苹果和木头'只执行一个。改为 extractToolCalls 收集全部
合法 #tool,dispatchTextTools 复用原生 FC 批次屏障串行执行。
完整系统提示词的 #tool 语法说明会诱导 gemma 放弃原生 function calling,
改用文本 #run 输出。nativeTools 分支改为只声明'全部工具由函数提供,
绝不输出 #tool 文本',并清理所有 # 文本示例。同时把缺失的 #end 注册为
原生 end 工具,保证删除文本列表后任务完成仍可触发。
CF gemma 并行 tool_calls 共享同一 index 且参数分片不带 id,原按 index
归并会把两个调用的参数拼进一个 #run,导致'苹果和木棍'只执行一条
报错命令。改为:带 id 的 delta 按 id 归并并设为当前活跃调用,无 id
的参数分片顺序路由到当前活跃调用;无 id 带 name 的退化为按 index。
模型执行 #run 时若首词不在命令表,但存在 0/1/2 个斜杠变体命中
(如 /menu ↔ menu、pos1 ↔ /pos1),则拦截不执行,feedback 警告进
上下文并附带 force 示例。原生 run 调用 JSON 键 {"force": true} 跳过
此校验(不注入提示词)。命令表为空(懒注册)时放行避免误杀,
校验只查表绝不执行命令。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
问题 1:批次超时兜底复用 generationStartTimes,基准会被 setGenerating(EXECUTING_TOOL)、
handleConfirm 等反复重置,导致异步慢工具(#webfetch 45s timeout、#mcp 网络延迟)
的合法执行等待被误判为批次卡死而强制终结。改为在 DialogueSession 中维护独立的
batchLastFeedbackTime(setBatchInProgress 启动、addPendingToolResult 推进),超时检查
仅关注"批次中没有任何工具反馈超过 BATCH_TIMEOUT_MS",不再与单工具执行计时耦合。

问题 2:玩家在批次中 #ask 等待期间执行 /cli stop,pendingCommands 被清除但
isBatchInProgress 仍为 true。后续交互中 feedbackToAI 命中批次拦截器,工具反馈被
吞入已死批次的幽灵队列。在 /cli stop 分支中增加 clearBatchState() 清理。
- CLIManager.executeTool 同步异常兜底:report+反馈 AI,单路径不再卡 EXECUTING_TOOL
- executeNativeBatch 合并处删除按结果文本的启发式二次计数(含 _error 字样会被误判失败)
- handleAskTool 解析失败/缺 question 时反馈 #ask_error,不再无声等待
- handleSearchTool/handleMcpTool 异步 lambda 加 try-catch,异常反馈 AI 不丢
- parseToolCall 加 null 防御
- 注释:force 键刻意不进 schema 的设计意图(ToolRegistry x2 + handleRunTool javadoc)
clim.agree.prompt 文案宣称可发 /cli 退出,但 exitCLI 开头对非 activeCLI 玩家直接 return,
待同意玩家被困在协议提示循环。现在 exitCLI 会清除待同意状态并发送退出消息。
- LLMClient: 请求消息 system 后首条非 user 时垫 user 占位消息,修复 reka 等
  严格网关 400(进入 CLI 的欢迎语以 assistant 开头产生非法序列)
- FileWatcherManager: 补 ENTRY_CREATE/DELETE 监听 + mtime 轮询兜底,修复
  Git Bash sed -i 等替换式保存不触发热重载(Windows WatchService 不报 rename)
- PromptManager: 文本协议提示词改为多工具引导,修复"要求多工具只执行部分"漏执行
- 离线断线保存会话改用玩家名缓存(enterCLI 记录),不再依赖在线玩家对象,
  修复异步保存时 Bukkit.getPlayer 返回 null 导致会话历史不保存
- 压缩输入不再截断 500 字符,上下文完整传入压缩模型 + 新增逐条诊断日志
  (定位工具调用消息是否算进压缩/被截断)
- #edit/#write 支持 JSON 行参数(标准 JSON 转义,消灭 | 分隔符与 \n 三层转义冲突),旧格式兼容
- ToolRegistry bridgeToText 原生通道输出 JSON 行,与文本协议统一
- config fancy.temperature 主对话采样温度(null=跟随模型默认,0=完全确定),6 处主对话请求挂载
- 会话保存改原子写入(临时文件+ATOMIC_MOVE),避免中断留下半截 JSON 导致 resume 扫描报错
- 新增 ToolExecutorFileFormatTest(JSON 行+旧格式 10 例),更新 ToolRegistryTest bridge 断言
YOLO 分支 setPendingCommand 后直接执行但未清除,玩家后续所有消息被
pendingCommands 拦截走"请确认 [Y/N]"分支静默吞掉。修复:YOLO 免确认
不设置 pendingCommand,与 #run 的 YOLO 分支一致。
@baicaizhale baicaizhale self-assigned this Aug 11, 2026
@baicaizhale
baicaizhale merged commit 575a042 into master Aug 11, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in FancyHelper Aug 11, 2026
@baicaizhale
baicaizhale deleted the feat/toolexecution branch August 11, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant