Files
HomeAgent/internal/agent/core/stream_index_test.go
JianFeeeee e273924511 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 接受)。
2026-09-19 16:49:16 +08:00

88 lines
2.6 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 core
import (
"context"
"encoding/json"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 回归:并行多工具调用的流式分片必须按上游 index 字段分桶累积,
// 不能用 Go range 序号(每 chunk 单元素时恒为 0,导致全部污染到同一桶)。
func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mk := func(idx int, id, name, raw string) agentAPI.StreamChunk {
return agentAPI.StreamChunk{
ToolCalls: []agentAPI.ToolCall{{
ID: id, Type: "function", Name: name, RawArguments: raw, StreamIndex: idx,
}},
}
}
ch := make(chan agentAPI.StreamChunk, 32)
chunks := []agentAPI.StreamChunk{
{Content: ""},
// tool_call 0: spawn_child 参数较长,分多片
mk(0, "call_a", "spawn_child", "{\"task\":"),
mk(0, "", "", "\"调查大模型排名\"}"),
// tool_call 1: browser_render
mk(1, "call_b", "browser_render", "{\"url\":"),
mk(1, "", "", "\"https://example.com\"}"),
// tool_call 2: cmd_run
mk(2, "call_c", "cmd_run", "{\"command\":\"uname -a\"}"),
// tool_call 3: skill_list
mk(3, "call_d", "skill_list", "{}"),
{Done: true, FinishReason: "tool_calls"},
}
for _, ck := range chunks {
ch <- ck
}
close(ch)
resp, err := accumulateStream(ctx, ch, nil, "cli", 4096)
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
if len(resp.ToolCalls) != 4 {
t.Fatalf("expected 4 tool calls, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls)
}
want := map[string]string{
"spawn_child": `{"task":"调查大模型排名"}`,
"browser_render": `{"url":"https://example.com"}`,
"cmd_run": `{"command":"uname -a"}`,
"skill_list": `{}`,
}
for _, tc := range resp.ToolCalls {
raw, _ := json.Marshal(tc.Arguments)
got := string(raw)
exp, ok := want[tc.Name]
if !ok {
t.Errorf("unexpected tool %q args=%s", tc.Name, got)
continue
}
delete(want, tc.Name)
if tc.Name == "skill_list" {
// 无参工具的合法空对象 {},只需确认没被污染成乱码
continue
}
if len(tc.Arguments) == 0 {
t.Errorf("tool %q has EMPTY arguments (index pollution regression)", tc.Name)
continue
}
var wantMap map[string]interface{}
json.Unmarshal([]byte(exp), &wantMap)
gotB, _ := json.Marshal(wantMap)
if got != string(gotB) {
t.Errorf("tool %q args = %s, want %s", tc.Name, got, exp)
}
}
if len(want) > 0 {
t.Errorf("missing tool calls: %v", want)
}
if resp.FinishReason != "tool_calls" {
t.Errorf("finish reason = %q, want tool_calls", resp.FinishReason)
}
}