mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
问题(实测,三条互相印证):
· ToolResult.Success 硬编码 true(task.go 唯一赋值点)⇒ 该字段在结构上
不可能为 false,是**谎报字段**;
· 工具失败以 nil error + 错误**值**返回(files 的 errorResult、
pluginmgr 的 {"error":…}),上游无从判别;
· stepToolAfter 用 `Result.(string)` 断言,而插件返回的多是 map ⇒
断言几乎恒失败,after_toolcall 阶段对结构化结果的改写**静默失效**。
改动:
· third_party/homeagent-sdk: 新增 ToolError{Field,Reason,Detail,Hint}
与 Error()。纯新增、无签名变更,存量插件不必改动(零值语义:
内核的失败识别同时兼容既有三种约定,新类型是可选项而非迁移要求)。
· internal/sdk: 补 ToolError 别名。
· core/toolerror.go: isToolError / toolErrorText / renderToolResult。
⚠️ 判据必须同时兼容仓内**三种**既有失败约定,且**不得**把成功误判:
① {"error": msg} ② {"isError":true,content:…} ③ *ToolError
明确不判失败的:exit_code != 0(业务结果,带真实 stdout/stderr)、
stderr 非空(cmd 成功常带 warn)、字符串/数字/bool/数组/nil
(自由文本按成功处理:宁可少报失败,也不把正常结果报成失败)。
· core/toolcall.go: executeToolCallOutcome 返回 toolOutcome{Text,Raw},
**Raw 必须在成功分支也带上**——否则结构化失败在 fmt.Sprintf("%v")
那一步被抹平,Success 又退回恒真。panic 与 60s 超时统一以 ToolError
表达(可执行 Hint,避免模型原样重试工具故障)。
· core/task.go: Success=!isToolError(Raw);修恒失败的类型断言;
TaskFrame 增 CurRaw(未降级的原值)。
判据:
· toolerror_test.go 单元级:三种失败约定识别 / 成功形态不误判 /
非零退出不算工具失败 / ToolError 识别。
· TestStageCtxSuccessIsHonestEndToEnd 端到端读 f.StageCtx.ToolResults,
验证**内核产出的值本身**,而非辅助函数。
变异验证(两轮):
· Success 退回硬编码 true ⇒ 端到端判据 3 个子用例 FAIL;
· 把字符串判据改成 strings.Contains(x,"error") ⇒ 「成功文本含 error 字样」
用例 FAIL。**第二轮暴露了判据缺口**(最初没有该用例),已补。
过程中三次自伤(均由判据/编译暴露):用正则批量包装 return 时把
多行 fmt.Sprintf 截断;包装范围溢出到返回 string 的辅助函数;
测试里重复注册同名工具导致 IOManager 取到错误的 device。
遗留:全量 go test ./internal/... 在本机无法完整跑完——/tmp 是 9.8G
tmpfs 且已 98% 占用,link 阶段报 "no space left on device";
/var/tmp 另有约 29G 陈旧 release worktree。与本次改动无关(未触碰
memory/* 等失败包),已在干净基线(7a566d5)对比确认。
252 lines
9.6 KiB
Go
252 lines
9.6 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
)
|
||
|
||
// 验证流式 tool call 分片累积:模拟 llmsproxy/big-pickle 的分片序列
|
||
func TestAccumulateStreamToolCalls(t *testing.T) {
|
||
ch := make(chan agentAPI.StreamChunk, 10)
|
||
go func() {
|
||
// 分片1: name + id + arguments 开头
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "call_1", Name: "cmd_run", RawArguments: "{\""},
|
||
}}
|
||
// 分片2-3: 只有 arguments 分片
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{RawArguments: "command\""},
|
||
}}
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{RawArguments: ":\"date\"}"},
|
||
}}
|
||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"}
|
||
close(ch)
|
||
}()
|
||
|
||
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
|
||
if err != nil {
|
||
t.Fatalf("accumulateStream: %v", err)
|
||
}
|
||
if len(resp.ToolCalls) != 1 {
|
||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||
}
|
||
tc := resp.ToolCalls[0]
|
||
if tc.Name != "cmd_run" || tc.ID != "call_1" {
|
||
t.Fatalf("bad name/id: %s/%s", tc.ID, tc.Name)
|
||
}
|
||
cmd, _ := tc.Arguments["command"].(string)
|
||
if cmd != "date" {
|
||
t.Fatalf("arguments not merged, got: %v", tc.Arguments)
|
||
}
|
||
if resp.FinishReason != "tool_calls" {
|
||
t.Fatalf("finish reason: %q", resp.FinishReason)
|
||
}
|
||
}
|
||
|
||
// 验证 content/reasoning 增量累积
|
||
func TestAccumulateStreamContent(t *testing.T) {
|
||
ch := make(chan agentAPI.StreamChunk, 5)
|
||
go func() {
|
||
ch <- agentAPI.StreamChunk{ReasoningContent: "think "}
|
||
ch <- agentAPI.StreamChunk{Content: "你"}
|
||
ch <- agentAPI.StreamChunk{Content: "好"}
|
||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
|
||
close(ch)
|
||
}()
|
||
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
|
||
if err != nil {
|
||
t.Fatalf("accumulateStream: %v", err)
|
||
}
|
||
if resp.Content != "你好" {
|
||
t.Fatalf("content: %q", resp.Content)
|
||
}
|
||
if resp.ReasoningContent != "think " {
|
||
t.Fatalf("reasoning: %q", resp.ReasoningContent)
|
||
}
|
||
}
|
||
|
||
// 回归(2026-09-19 实测事故):长参数工具调用被 max_tokens 从中间截断时,
|
||
// 上游发 finish_reason="length"、参数 JSON 残缺。旧实现把残缺 JSON 静默降级成
|
||
// 空 map,工具只报 "path is required",模型看不出真因、原样重试四次。
|
||
//
|
||
// 本测试钉死:截断必须变成带指引的 __arg_error,而不是空参数。
|
||
func TestAccumulateStreamTruncatedArgsSurfaced(t *testing.T) {
|
||
ch := make(chan agentAPI.StreamChunk, 10)
|
||
go func() {
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "call_1", Name: "files_write", RawArguments: `{"path":"/tmp/a.py","content":"# -*- coding`},
|
||
}}
|
||
// 参数写到一半被切断,随后到达 length 终止块
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{RawArguments: `: utf-8 -*-\nimport openpyxl\nfor i in range(80):\n w`},
|
||
}}
|
||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "length"}
|
||
close(ch)
|
||
}()
|
||
|
||
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
|
||
if err != nil {
|
||
t.Fatalf("accumulateStream: %v", err)
|
||
}
|
||
if len(resp.ToolCalls) != 1 {
|
||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||
}
|
||
msg, ok := resp.ToolCalls[0].Arguments["__arg_error"].(string)
|
||
if !ok || msg == "" {
|
||
t.Fatalf("截断的参数必须带 __arg_error,实际 Arguments=%v", resp.ToolCalls[0].Arguments)
|
||
}
|
||
// 指引必须可执行:说出真因(截断/max_tokens)并给出拆小方案
|
||
for _, want := range []string{"截断", "max_tokens=4096", "拆成多次调用"} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Errorf("指引缺少 %q:%s", want, msg)
|
||
}
|
||
}
|
||
// 截断时绝不能把残缺 JSON 解析出的空 map 当参数交出去
|
||
if _, hasPath := resp.ToolCalls[0].Arguments["path"]; hasPath {
|
||
t.Error("截断参数不应残留任何可用字段(否则会以残缺参数执行)")
|
||
}
|
||
}
|
||
|
||
// 非截断的残缺 JSON 也要拦住工具调用(不然工具只会报 “path is required”),
|
||
// 但**必须与真截断用不同的文案** —— 否则模型会去“拆小参数”,而它其实是写坏了。
|
||
// 这里同时钉死两件事:①不丢给工具 ②两种成因可区分。
|
||
func TestAccumulateStreamMalformedArgsDistinctFromTruncated(t *testing.T) {
|
||
ch := make(chan agentAPI.StreamChunk, 10)
|
||
go func() {
|
||
ch <- agentAPI.StreamChunk{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "call_1", Name: "files_write", RawArguments: `{"path":`},
|
||
}}
|
||
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "tool_calls"}
|
||
close(ch)
|
||
}()
|
||
|
||
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
|
||
if err != nil {
|
||
t.Fatalf("accumulateStream: %v", err)
|
||
}
|
||
if len(resp.ToolCalls) != 1 {
|
||
t.Fatalf("want 1 tool call, got %d", len(resp.ToolCalls))
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 截断必须**真的拦住工具调用**,而不是只改错误文案:
|
||
// 旧实现拿着空 map 去调 files_write,工具回 "path is required",
|
||
// 模型据此原样重试(实测连续 4 次)。这里断言 executeToolCallInner 的短路。
|
||
func TestTruncatedToolCallIsShortCircuited(t *testing.T) {
|
||
tc := agentAPI.ToolCall{
|
||
ID: "call_1",
|
||
Name: "files_write",
|
||
Arguments: map[string]interface{}{
|
||
"__arg_error": truncatedArgsError("files_write", 259, 4096),
|
||
},
|
||
}
|
||
a := &Agent{}
|
||
got := a.executeToolCallInner(tc, "webui", nil).Text
|
||
|
||
if strings.Contains(got, "path is required") {
|
||
t.Errorf("截断后仍走了工具分派(模型会原样重试):%s", got)
|
||
}
|
||
for _, want := range []string{"截断", "max_tokens=4096", "拆成多次调用"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("指引缺少 %q:%s", want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 回归(2026-09-19 线上实测):真 invalid JSON 有 11/11 是同一成因 ——
|
||
// 模型把 timeout 写成 `"timeout": 20s`(值缺引号,schema 示例是 "10s, 1m, 30s"
|
||
// 而声明是 string 类型),而 command 部分一字节没错。
|
||
//
|
||
// 旧行为:解析失败 → 静默降级成空 map → 整个 command 被丢 → 工具报
|
||
// "command is required",模型只能原样重试 ⇒ 实测 cmd_run 失败率 34%(34 败/64 成)。
|
||
func TestRepairUnquotedUnitNumberInArgs(t *testing.T) {
|
||
// 全部取自日志原文(未被我自己的日志截断的那些)
|
||
real := []string{
|
||
`{"command": "ls -lt /tmp/*.xlsx /tmp/*.py 2>/dev/null | head -20; echo \"=== home ===\"; ls -lt ~ 2>/dev/null | head -20", "timeout": 20s}`,
|
||
`{"command": "sleep 45; cat /tmp/run_szce.log; ls -la /tmp/szce_run_raw.json 2>/dev/null", "timeout": 90s}`,
|
||
`{"command": "echo \"=== 上一轮 raw (471B) ===\"; cat /tmp/szce_run_raw.json; echo; echo \"=== 后台进程 ===\"; ps aux | grep -c \"[r]un_szce.py\"; echo \"=== log ===\"; cat /tmp/run_szce.log", "timeout": 30s}`,
|
||
`{"command": "sleep 60; cat /tmp/probe_out.txt; echo \"=== alive ===\"; ps aux | grep -c \"[p]robe_models.py\"", "timeout": 120s}`,
|
||
`{"command": "cat /tmp/probe_out.txt; echo \"--- alive ---\"; ps aux | grep -c \"[p]robe_models.py\"", "timeout": 30s}`,
|
||
}
|
||
for i, s := range real {
|
||
m, ok := parseToolArgsJSON(s)
|
||
if !ok {
|
||
t.Errorf("case %d 仍解析失败", i)
|
||
continue
|
||
}
|
||
if cmd, _ := m["command"].(string); cmd == "" {
|
||
t.Errorf("case %d 完好的 command 丢失", i)
|
||
}
|
||
if to, _ := m["timeout"].(string); to == "" {
|
||
t.Errorf("case %d timeout 未补成字符串: %#v", i, m["timeout"])
|
||
}
|
||
}
|
||
}
|
||
|
||
// 修复必须保守:不能碰合法 JSON,尤其不能改到字符串**正文里**的 “20s”。
|
||
func TestRepairKeepsValidArgsIntact(t *testing.T) {
|
||
m, ok := parseToolArgsJSON(`{"command": "ls", "timeout": "20s"}`)
|
||
if !ok {
|
||
t.Fatal("合法 JSON 被判非法")
|
||
}
|
||
if m["timeout"] != "20s" {
|
||
t.Errorf("合法 timeout 被改: %#v", m["timeout"])
|
||
}
|
||
m2, ok := parseToolArgsJSON(`{"content": "wait 20s then go"}`)
|
||
if !ok {
|
||
t.Fatal("含 20s 的正文被判非法")
|
||
}
|
||
if m2["content"] != "wait 20s then go" {
|
||
t.Errorf("正文里的 20s 被误改: %#v", m2["content"])
|
||
}
|
||
}
|
||
|
||
// 真截断(JSON 从中间断掉)绝不能被“修好”,否则会拿残缺参数去执行 —— 更危险。
|
||
func TestRepairDoesNotFabricateTruncatedArgs(t *testing.T) {
|
||
for _, s := range []string{
|
||
`{"command": "ls -la /tmp && echo done"`,
|
||
`{"command": "echo hi", "timeout": 30`,
|
||
`{"path": "/tmp/x", "content": "unterminated`,
|
||
} {
|
||
if _, ok := parseToolArgsJSON(s); ok {
|
||
t.Errorf("截断参数被误判为可修复(危险): %s", s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 端到端:修好 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)
|
||
}
|
||
}
|