Commit Graph

11 Commits

Author SHA1 Message Date
bcd75d25fd fix(provider): has empty arguments 误报 —— 零参数工具被当成参数丢失
## 现象

部署后生产日志出现 14 次:

    provider.go:414  [provider:llmsproxy] tool_call seq_list (...) has empty arguments

## 根因

原始响应里参数**完好**(从日志扒出来):

    "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]

诊断条件是 `len(tc.Arguments)==0 && tc.RawArguments==""`,而
`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map ⇒ 命中告警。

被点名的全是**零参数工具**(`seq_list` / `*_list` / `seq_help`,
它们的 `properties` 本来就是 `{}`)。

## 为什么必须修

不是"日志吵"。这条诊断的本职是抓「上游/适配器**真的**把参数丢了」,
真发生时会被这 14 次噪音淹没 —— **诊断日志失去信噪比就等于没有**。

## 修法

新增 `argsLookDropped(rawArgs)`,判 `RawArguments` **原文**而非解析后的 map:

- 空串 / 纯空白 ⇒ 上游没给 arguments 键 ⇒ 真丢
- 能解析成 JSON(哪怕是 `{}`)⇒ 上游确实回了参数 ⇒ 不报
- 解析失败(如半截 JSON)⇒ 参数本身是坏的 ⇒ 等同丢失

## 判据(3 条)

- `TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject`  4 个子用例:
  `{}` / ` { } ` / 完全缺失 / 只有空白
- `TestArgsLookDroppedIgnoresNonEmpty`  非空参数一律不报
- `TestArgsLookDroppedEndToEnd`  用**日志里出现过的真实 body** 走
  `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对
  只有端到端抓得到

写判据时我先用错了类型:拿 `apiToolCall`(**非流式**路径的结构)喂
`normalizeOpenAIToolCalls`,vet 直接报错才纠正为 `openAIToolCall`。
两套结构并存,很容易接错缝。

## 顺带记录:另一个告警不是内核缺陷

`重复申请 stage 锁`(5 次)经排查是**插件侧**问题,内核自愈机制工作正常:

- `proc_main.go.tmpl:1569` SDK 模板在每个 stage handler 入口**自动**调
  `stage.lock`;`lock.go:51` 锁**不可重入** ⇒ 同一次 `before_toolcall`
  被触发两次且首次未释放就命中
- 已排除 qq 业务代码:`beforeToolcall`(plugin.go:1303-1350)只有
  `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与纯本地调用,
  无任何再次触发 stage 的路径
- 成因在插件进程侧运行时(编译进 9月14日的 `plugin.bin`,**不随 homed 部署**)
- 内核 `stage.go:102-106` 的强制释放是**有意设计**("锁仲裁回内核"自愈,
  实验 9),避免后续插件死锁;`stages.go:258` 把错误收进 `ctx.Errors`
  不中断流程 ⇒ 那轮 212 秒正常跑完

两条结论都写进文档,避免以后有人当内核缺陷去修。

门禁:`-race` 通过,`go test ./internal/... ./cmd/...` 全绿。
2026-09-27 21:18:32 +08:00
20d357328b docs: 两份 toolcall 文档对齐实现与部署实况
## 契约文档:状态头从「尚未实现」改为「已实现并部署」

生产已注册 7 个 `seq_*` 工具,但文档仍写着"设计定稿,尚未实现" ——
实现者(和读者)会以为 seq 还不存在。

新增 §9.3「实现落点」:设计稿 §8 写的是**六个** `seq_*` 工具,实现时
多了一个 `seq_when_call`(跨序列条件调用独立成工具,否则模型要手写
"先 seq_list 再挑目标再 seq_call",多一次往返且容易挑错),并记下三项
设计之外的修正(`seq_create` O(n²)、`Store.List()` 误认任意 `.json`、
存储用 AST 而非原始文本)。

同时点明:设计条款**仍是契约**,实现与本文冲突时以本文为准并修实现。

## 修一处预先存在的失效引用

§7 末尾 `见 §4.5` —— §4 只到 4.4,该小节不存在。改为按标题名引用
(`§4「结果契约」的 ErrToolNotFound 哨兵`):将来增删小节时不会再次失效。

自查脚本第一版把 8 个**存在**的章节误报成失效引用 —— 标题格式是
`## 1. 背景`(编号后跟 `.`),而我的正则要求编号后是空格。判据自己错了,
改成 `(\d+(?:\.\d+)*)\.?\s` 后才得到真实结果。

## 并行计划文档:部署小节 + 白名单专节

- 原「⚠ 部署前置条件(未完成)」改为「✅ 部署(已完成)」,补实际验证数据
- 记下"实例自述没有编排工具"不是说谎:生产二进制构建于 06:36、seq 引入
  于 `71c894c`(更晚)⇒ `strings | grep -c internal/plugins/seq` 为 0。
  这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词
- 新增「设备命令白名单改为可配置」:起因、替换语义、daemon 路径的疏漏
- 明确写下**已知局限**:白名单只匹配命令名、不看参数,
  `find -delete` / `sed -i` / `sort -o` 仍放行 ⇒ **不要把它叫"只读白名单"**,
  那会让人以为写操作被挡住了
- 记 106 此前无 `online` 日志的成因(旧 waiter 落在未 bind 时收 ping 会断连的
  缺陷窗口),以及 `ssh` 吃掉 `read` 输入导致"喂了 yes 却说已取消"

删掉了初稿里一段"反引号内 `+=` 写进 heredoc 导致赋值落到子 shell"的说法 ——
脚本与 git 历史里都没有这种写法,属凭记忆误记,不能留。

文中数字均与现场核对:seq 工具 7、二进制 86784400 / 12691402、白名单 22 条。
2026-09-27 20:00:33 +08:00
07feecba11 docs(plan): 补记全面压测结果与部署前置条件
两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。
2026-09-27 19:00:53 +08:00
56fe10d3a4 docs(plan): 记录更新前后全面压测结果与部署前置条件
两版隔离实例实测(规模 3 = 288 条输入),核心差异是**处理数**而非耗时:
旧版 openai.lua 缺 stream_index 透传 ⇒ 多个分片并到槽 0、参数混拼 ⇒
工具一个都没真跑,却因为「少干活」而耗时更短。

    !slowbatch8   旧 0.220s / 0 个  →  新 0.409s / 8 个
    并发 vs 强制串行(新版内部)      N=8 加速 3.88×
    调度器轰炸 288 输入             两版均 100% 通过
    连续稳定性 20 轮                两版均无错误、内核无 panic

规模 1(32 条)与规模 3(288 条)结果完全一致 ⇒ 可复现。

同时记录部署前置条件:生产是 -tags=onnxruntime 构建(strip 后 75MB vs
普通构建 28MB),package-linux.sh:139 会显式拒绝非 onnxruntime 构建。
本次改动未触及任何 ONNX 路径,故压测结论对生产成立,但必须走
deploy/packaging/build.sh 才能部署。
2026-09-27 19:00:19 +08:00
4fd18a2e83 docs(plan): 遗留项收敛——D4 与端到端已完成,提权无需决策 2026-09-27 13:18:28 +08:00
532500e3c9 docs(plan): 内核主线与插件线全部完成,记录两个设计决策与三处遗留 2026-09-27 13:06:10 +08:00
ae3cdaa1a0 docs: 同步两份文档的实现进度(内核主线 0~2.5 全部完成) 2026-09-27 11:56:01 +08:00
446645d0a0 docs(plan): 阶段 2 标记完成,记录并发规则与 2c 判据闭合 2026-09-27 11:27:53 +08:00
3d12e82f65 refactor(toolcall): StageContext 拆 per-tool,为并发执行消除共享槽(阶段 2c)
问题:f.StageCtx 是**单槽**,批内每个工具都覆写它(ToolCalls=[单元素]、
ToolResults 覆写、Results[0] 回读)。串行下看不出问题,但并发下
N 个 goroutine 同写一个 ctx = 数据竞争,且 after_toolcall 插件可能读到
**别的工具**的结果。

改动(task.go):
· TaskFrame 增 toolCtxs []sdk.StageContext(每工具一份)
· buildToolContexts 在 stepLLM 设 PendingTools 时建池;
  Extra **逐份浅拷贝**——共享同一 map 即竞争(stage handler 会写它)
· toolCtxFor(i) 取第 i 份,越界/未建时回落 f.StageCtx(测试替身安全)
· stepToolBegin(before_toolcall + 参数回填)、stepToolExec(写结果)、
  stepToolAfter(读结果)三处全部切到 per-tool ctx

判据(toolbatch_test.go 追加两条):
· 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name,
  且 Extra[output_channel] 逐份带过去(stage.go:18 依赖它)
· after_toolcall 读到的 Result 必须属于当前工具,不能是批内另一个的

★ 诚实记录:这两条判据在**串行**下**测不出与单槽的差别**——串行时
每工具跑完才进下一个,不存在交错。变体验证(toolCtxFor 退回单槽)后
判据仍全绿。故 2c 记为「实现已就位、判据未闭合」,真正判据必须与 2d
(并发执行)一起写,并以 -race 确认无竞争。已在执行计划中标注。

过程中两次自伤:
· 我的 harness 没设 Extra[output_channel](那是 prepareInputTask 才写的,
  task.go:380),判据一度报「产品缺陷」——核实后是我造的场景,已对齐生产;
· 阶段 1 的 TestStageCtxSuccessIsHonestEndToEnd 读 f.StageCtx.ToolResults,
  拆分后失效——判据跟随新结构改为按批索引取 toolCtxs[i],
  断言的仍是内核产出的 Success 值本身。

顺带记录(非本次引入):TestResidualKeep/Drop 偶发失败,根因是
offload_test.go 的 SpawnResident 起了子调度器 goroutine,而测试
enqueue 后无同步就读同一队列。干净基线 3/3 全绿属运气。已在计划中
记为待修,避免后续误判为并行化引入的回归。
2026-09-27 11:17:06 +08:00
4ba72977d2 test(toolcall): 补批内路径的三条缺失判据(阶段 0.5)
同一批多个 tool_call 的循环(StepToolBegin→Exec→After)此前只被
scheduler_critical_test.go:125 一条用例覆盖「按序执行」,缺的三条正是
阶段 2(并行执行层)要改的地方:

· tool_call_id 配对完整性 —— 阶段 2 改消息落法(一个 assistant 带全部
  tool_calls + N 条 tool)时,配对断裂上游会直接报错
· ContentOnce 批内语义 —— 同一段 assistant 文本在批内重复 N 次,撑爆上下文
· denied 后继续批内 —— 改成 abort 会丢掉本可执行的后续调用

判据 toolbatch_test.go(4 条),全部确定性断言:阶段 0 已消除 map
迭代随机性,同批工具的落序与配对可稳定断言。

变异验证:令 stepToolBegin 跳过批内最后一个工具后,4 条判据同时 FAIL
(既有那条也 FAIL),报错直指 ToolsUsed=[tool_alpha]、
tool_call_id "c2" 被声明 0 次。

更正一处此前的不准确表述:我曾说「无任何测试直接驱动批内路径」——
不准确。scheduler_critical_test.go:125 已驱动「同批两工具按序执行」;
漏查是因为只 grep 了 PendingTools/ToolIdx 字段名,没查断言内容。
真正缺的是上表三条。

过程中三次自伤(均由「判据先写」暴露):臆造不存在的 helper;
stageHost 置 nil 后又使用;给 newTaskFrame 传 nil 导致 stepPrepare 于
task.go:519 nil 解引用 panic(改用仓内既有 a.stageCtxFromInput)。
顺带记录:生产两处 newTaskFrame 调用都传真实 ctx,但 stepPrepare 对
f.StageCtx 无 nil 兜底——本次不修(无生产触发路径),记为潜在缺口。

回归:internal/agent/... 与 internal/plugins/... 全绿。
2026-09-27 08:59:30 +08:00
7a566d50b7 fix(toolcall): 工具「不存在」类型化 + 修父 io 兜底吞错误 + 修并行 tool_call 落序随机
主线:工具调用并行化改造(阶段 0 与 0.2)。

① flush 顺序随机(process.go)
   flushToolCall 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
   ⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。
   对 output_send__ 这类用户可见通道,分段消息到达顺序不可复现。
   改为收集 index 后 sort.Ints 再 flush(两个调用点统一走 flushAll)。
   判据 stream_flush_order_test.go(8 工具 × 200 轮),已变异验证可检测。

② 工具「不存在」类型化(io/channel.go、core/stages.go、core/toolcall.go)
   工具是动态注册的,「不存在」是运行期常态而非异常。原先内核用
   strings.Contains(err, "not found in any plugin") 判别——约定而非契约,
   插件文案含该子串即被误判。改用哨兵 ErrToolNotFound + errors.Is
   (沿用仓内 ErrInputChannelUnknown 的先例)。

   ⚠️ 顺带修一个静默 bug:IOManager 向父兜底时吞掉父的执行失败,
   误报为「工具不存在」。后果是设备离线这类本该 retry 的失败被判为
   「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。改为只传递
   「确实不存在」,其余如实上抛。

   「不存在」的文案改为可执行指引(get_plugin_tools / output_list_channels),
   而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。

判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)、
channel_error_test.go(父失败不吞、真的不存在仍可判别)。
两者均经变异验证。回归:internal/agent/... 与 internal/plugins/... 全绿(14 包)。

设计文档:docs/zh/toolcall-contract-and-sequence-design.md
执行计划:docs/zh/toolcall-parallel-execution-plan.md
2026-09-27 08:55:25 +08:00