Files
HomeAgent/internal/agent/api/emptyargs_diag_test.go
JianFeeeee 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

93 lines
3.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"testing"
)
// `has empty arguments` 诊断日志在部署后的生产日志里出现了 14 次,
// 全部是误报。原始响应(从日志里扒出来的)参数**完好**:
//
// "tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]
//
// 根因:`parseToolArguments("{}")` 走 string 分支 → `json.Unmarshal("{}", &m)`
// 成功且 `m != nil`(**非 nil 的空 map**)⇒ 返回空 map。而诊断条件是
// `len(args)==0 && RawArguments==""`,于是命中。
//
// 被点名的全是**零参数工具**(seq_list / *_list / seq_help,它们的
// `properties` 本来就是 `{}`)。
//
// ## 为什么要紧
//
// 不是"日志吵",是它**占用了本该报真问题的位置**:这条诊断存在的意义
// 是抓「上游/适配器真的把参数丢了」,真发生时会被这堆噪音淹没。
// 诊断日志一旦失去信噪比就等于没有。
func TestEmptyArgumentsDiagnosticIgnoresExplicitEmptyObject(t *testing.T) {
cases := []struct {
name string
rawArgs string
wantLog bool
}{
// 上游明确给了空对象 ⇒ 参数没丢,是零参数工具的正常形态
{"显式空对象 {}", "{}", false},
{"显式空对象带空格 { }", " { } ", false},
// 真正丢了参数:连 "{}" 都没有
{"完全缺失", "", true},
{"只有空白", " ", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := argsLookDropped(c.rawArgs)
if got != c.wantLog {
t.Errorf("argsLookDropped(%q) = %v,期望 %v", c.rawArgs, got, c.wantLog)
}
})
}
}
// 上游给的是**非空**参数时,当然不能报。
func TestArgsLookDroppedIgnoresNonEmpty(t *testing.T) {
for _, raw := range []string{`{"a":1}`, `{"device_id":"x"}`, `{"path":"/tmp"}`} {
if argsLookDropped(raw) {
t.Errorf("argsLookDropped(%q) = true,非空参数不应被判为丢失", raw)
}
}
}
// 端到端:从真实的 tool_call 形态走到判定,确认零参数工具不报、
// 真丢失要报。这是防止"单测过了但接缝不对"。
func TestArgsLookDroppedEndToEnd(t *testing.T) {
// 日志里出现过的真实 body
const zeroParamBody = `{"choices":[{"message":{"tool_calls":[
{"function":{"arguments":"{}","name":"seq_list"},"id":"a","type":"function"}]}}]}`
const droppedBody = `{"choices":[{"message":{"tool_calls":[
{"function":{"name":"seq_list"},"id":"a","type":"function"}]}}]}`
// 用 openAIToolCall —— normalizeOpenAIToolCalls 的真实入参类型。
// (先前误用 apiToolCall,那是**非流式**路径的结构,接缝不对。)
normalize := func(body string) []ToolCall {
var resp struct {
Choices []struct {
Message struct {
ToolCalls []openAIToolCall `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil {
t.Fatalf("解析失败: %v", err)
}
return normalizeOpenAIToolCalls(resp.Choices[0].Message.ToolCalls)
}
for _, tc := range normalize(zeroParamBody) {
if argsLookDropped(tc.RawArguments) {
t.Errorf("零参数工具 %q 被误报为参数丢失", tc.Name)
}
}
for _, tc := range normalize(droppedBody) {
if !argsLookDropped(tc.RawArguments) {
t.Errorf("真丢失参数的 %q 未被报出 —— 这条诊断会失效", tc.Name)
}
}
}