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 条钉死这两面。
This commit is contained in:
JianFeeeee
2026-09-19 13:49:21 +08:00
parent fa61247eec
commit ee4d079c49
4 changed files with 114 additions and 5 deletions

View File

@ -2,6 +2,7 @@ package core
import (
"context"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
@ -26,7 +27,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) {
close(ch)
}()
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
@ -56,7 +57,7 @@ func TestAccumulateStreamContent(t *testing.T) {
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
close(ch)
}()
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
resp, err := accumulateStream(context.Background(), ch, nil, "cli", 4096)
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
@ -67,3 +68,69 @@ func TestAccumulateStreamContent(t *testing.T) {
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 时不应标记为截断")
}
}