mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
fix(llm): 参数无法解析时给出真因,不再静默丢弃整条调用
★ 上次修复误判了成因。真实根因(本次运行日志 34/34 同形):
{"command": "…完好的长命令…", "timeout": 20s}
command 一字节没错,只是 timeout 值少了引号 —— cmd_run 的 schema 把 timeout
声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
finish_reason=length 出现 0 次 ⇒ 上次那条"截断"分支从不生效。
旧行为把**整个参数**丢掉,模型只看到 "command is required",看不出坏在 timeout,
只能原样重试。实测本次运行 cmd_run 失败率 35%(34 败 / 71 成),
12 分钟的任务里更是 48% 时间耗在这上面 —— 每次失败都付一次完整 LLM 往返。
三处改动:
1. repairToolArgsJSON:解析失败时先试窄修复 —— 只给"值位置上未加引号的带单位
数字"补引号,且修完必须真能解析成功才接受。不碰合法 JSON、不动正文里的 20s、
不会把真截断"修好"。
2. 修复仍失败时不再静默降级成空 map,改为带 __arg_error 交给模型,并按成因
分流文案:截断→拆小参数;JSON 写坏→提醒带单位的值要加引号。
3. 统一键名 __arg_error(原 __truncated_error 只覆盖截断,语义过窄)。
同一缺陷面不止 cmd:agentcli/healthcheck/timer 都有 string 类型却以
"5m, 1h" 作示例的参数,此修复一并覆盖。
回归测试:真实日志样本修复、保守性(不碰合法/正文/截断)、
端到端(修复后 timeout 仍能被 time.ParseDuration 接受)。
This commit is contained in:
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
@ -73,7 +74,7 @@ func TestAccumulateStreamContent(t *testing.T) {
|
||||
// 上游发 finish_reason="length"、参数 JSON 残缺。旧实现把残缺 JSON 静默降级成
|
||||
// 空 map,工具只报 "path is required",模型看不出真因、原样重试四次。
|
||||
//
|
||||
// 本测试钉死:截断必须变成带指引的 __truncated_error,而不是空参数。
|
||||
// 本测试钉死:截断必须变成带指引的 __arg_error,而不是空参数。
|
||||
func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) {
|
||||
ch := make(chan agentAPI.StreamChunk, 10)
|
||||
go func() {
|
||||
@ -95,9 +96,9 @@ func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) {
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
msg, ok := resp.ToolCalls[0].Arguments["__truncated_error"].(string)
|
||||
msg, ok := resp.ToolCalls[0].Arguments["__arg_error"].(string)
|
||||
if !ok || msg == "" {
|
||||
t.Fatalf("截断的参数必须带 __truncated_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments)
|
||||
t.Fatalf("截断的参数必须带 __arg_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments)
|
||||
}
|
||||
// 指引必须可执行:说出真因(截断/max_tokens)并给出拆小方案
|
||||
for _, want := range []string{"截断", "max_tokens=4096", "拆成多次调用"} {
|
||||
@ -111,9 +112,10 @@ func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 非截断的残缺 JSON 保持旧行为(静默降级成空 map,由工具自己的必填校验报错):
|
||||
// 这样不会把「厂商不回 finish_reason」的流也误判成截断。
|
||||
func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(t *testing.T) {
|
||||
// 非截断的残缺 JSON 也要拦住工具调用(不然工具只会报 “path is required”),
|
||||
// 但**必须与真截断用不同的文案** —— 否则模型会去“拆小参数”,而它其实是写坏了。
|
||||
// 这里同时钉死两件事:①不丢给工具 ②两种成因可区分。
|
||||
func TestAccumulateStreamMalformedArgsDistinctFromTruncated(t *testing.T) {
|
||||
ch := make(chan agentAPI.StreamChunk, 10)
|
||||
go func() {
|
||||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||||
@ -130,8 +132,16 @@ func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(t *testing.T) {
|
||||
if len(resp.ToolCalls) != 1 {
|
||||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||||
}
|
||||
if _, ok := resp.ToolCalls[0].Arguments["__truncated_error"]; ok {
|
||||
t.Error("finish_reason=tool_calls 时不应标记为截断")
|
||||
msg, ok := resp.ToolCalls[0].Arguments["__arg_error"].(string)
|
||||
if !ok || msg == "" {
|
||||
t.Fatalf("残缺参数必须被拦住,实际 Arguments=%v", resp.ToolCalls[0].Arguments)
|
||||
}
|
||||
// 不能被说成“截断”:真因是 JSON 写坏,两者对模型要求的动作完全不同。
|
||||
if strings.Contains(msg, "max_tokens") || strings.Contains(msg, "截断") {
|
||||
t.Errorf("非截断的残缺参数被误报为截断:%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "合法 JSON") {
|
||||
t.Errorf("应指出 JSON 格式问题:%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,7 +153,7 @@ func TestTruncatedToolCallIsShortCircuited(t *testing.T) {
|
||||
ID: "call_1",
|
||||
Name: "files_write",
|
||||
Arguments: map[string]interface{}{
|
||||
"__truncated_error": truncatedArgsError("files_write", 259, 4096),
|
||||
"__arg_error": truncatedArgsError("files_write", 259, 4096),
|
||||
},
|
||||
}
|
||||
a := &Agent{}
|
||||
@ -219,3 +229,23 @@ func TestRepairDoesNotFabricateTruncatedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:修好 JSON 之后,字段必须**真的能被插件用上**。
|
||||
// cmd 插件走 args["timeout"].(string) 再 time.ParseDuration ——
|
||||
// 若修复把 20s 变成数字或丢了引号,插件会静默忽略 timeout,等于换个姿势失败。
|
||||
func TestRepairedTimeoutUsableByPlugin(t *testing.T) {
|
||||
m, ok := parseToolArgsJSON(`{"command": "ls -lt /tmp | head -20", "timeout": 20s}`)
|
||||
if !ok {
|
||||
t.Fatal("解析失败")
|
||||
}
|
||||
to, isStr := m["timeout"].(string)
|
||||
if !isStr {
|
||||
t.Fatalf("timeout 必须是 string,否则 cmd 插件读不到: %#v", m["timeout"])
|
||||
}
|
||||
if to != "20s" {
|
||||
t.Errorf("timeout 值不对: %q", to)
|
||||
}
|
||||
if d, err := time.ParseDuration(to); err != nil || d.Seconds() != 20 {
|
||||
t.Errorf("cmd 插件下一步 ParseDuration(%q) 会失败: %v", to, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user