Files
HomeAgent/internal/agent/core/stream_accumulate_test.go
JianFeeeee 2722d76095 fix(llm): max_tokens 截断不再静默降级成空参数
长参数工具调用(整段脚本/大 JSON)被 core.llm.max_tokens 从中间切断时,
上游回 finish_reason=length,而旧实现把这个信号整个丢掉:残缺 JSON 解析失败
后静默降级成空 map,工具只看到参数为空并报 'path is required'。模型因此完全
看不出真因,原样重试四遍、次次撞同一堵墙(2026-09-19 实测 4 次 files_write 失败)。

注:files_read 并未失败——是写挂之后模型反复重写把读卷进同一轮,看起来像两者都报错。

改法:
- finish_reason=length 时不再静默降级,改为塞入 __truncated_error 指引,
  告诉模型「参数被截断 + 请拆成多次调用/追加写 + 勿原样重试」;
- executeToolCallInner 见到该标记即短路,不拿空参数去调工具;
- 非截断的残缺 JSON 保持旧行为(避免把「厂商不回 finish_reason」误判成截断)。

回归测试 2 条钉死这两面。
2026-09-19 13:49:21 +08:00

137 lines
4.7 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"
"strings"
"testing"
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",模型看不出真因、原样重试四次。
//
// 本测试钉死:截断必须变成带指引的 __truncated_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["__truncated_error"].(string)
if !ok || msg == "" {
t.Fatalf("截断的参数必须带 __truncated_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 保持旧行为(静默降级成空 map,由工具自己的必填校验报错):
// 这样不会把「厂商不回 finish_reason」的流也误判成截断。
func TestAccumulateStreamInvalidArgsNotFlaggedAsTruncated(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))
}
if _, ok := resp.ToolCalls[0].Arguments["__truncated_error"]; ok {
t.Error("finish_reason=tool_calls 时不应标记为截断")
}
}