From 67fdafc35579dfac3908493a42c5c36d27edb89f Mon Sep 17 00:00:00 2001 From: lordquest Date: Tue, 21 Jul 2026 00:55:40 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(agent):=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=89=8D=E6=88=AA=E6=96=AD=E8=B6=85=E5=A4=A7=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=8F=82=E6=95=B0,=E8=A7=84=E9=81=BF=20Novita=20400=20?= =?UTF-8?q?=E4=B8=8E=20llama.cpp=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型把整页约 27KB 文件内容内联进 Write/Edit 的 arguments 时,会触发 OpenRouter/Novita 的 400 (invalid_request_error) 与本地 llama.cpp 生成 超大参数时 JSON 断尾的 500。 新增 maxToolArgBytes 常量与 truncateToolArgs 函数:对超过阈值的 tool_calls[*].function.arguments 替换为合法 JSON 占位 {"_deepx_truncated":true,"original_bytes":N},作用于副本、不改原始对话。 接入 CallOnce / CallWithTools / streamAttempt 三处发请求入口。 占位保证请求体仍是合法 JSON(避免硬截断产生非法 tool_calls 被再拒), 并保留 tool_call ID 以便后续 sanitizeToolPairs 仍能配对。 新增 agent/truncate_test.go 覆盖:小参数 no-op、单条超大、边界(恰好 阈值不截/阈值+1 截)、同消息混合大小、多消息定向、截断后过 sanitize 配对、占位可序列化。 --- agent/llm.go | 43 +++++++++- agent/truncate_test.go | 190 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 agent/truncate_test.go diff --git a/agent/llm.go b/agent/llm.go index fa0c81d..bc38cfc 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -236,6 +236,39 @@ type chatRequest struct { ReasoningEffort string `json:"reasoning_effort,omitempty"` } +// maxToolArgBytes:单个工具调用的 arguments 上限。超过就截断(见 truncateToolArgs)。 +// 触发背景:模型曾把整页 ~27KB HTML 当 Write 参数内联发出,既触发 OpenRouter/Novita 的 400 +// (invalid_request_error),也会让本地 llama.cpp 在生成超大参数时 JSON 断尾报 500。 +// 16KB 是经验值:正常工具参数远小于此,只有"整篇文件内容内联"才会触顶。 +const maxToolArgBytes = 16 * 1024 + +// truncateToolArgs 返回 convo 的副本,其中超长工具参数被替换为一个带 original_bytes 的 +// 合法 JSON 占位。这样发出去的请求仍是合法 JSON(不会因硬截断产生非法 tool_calls 被 API 再拒), +// 同时把几十 KB 的内联内容(整页 HTML 等)从请求里拿掉,规避 Novita 400 与 llama.cpp 500。 +// 原始对话不被改动(走副本),且工具此前已用完整参数执行过、结果在后续 tool 消息里,截断历史无害。 +// 返回是否发生过截断。 +func truncateToolArgs(convo []ChatMessage) ([]ChatMessage, bool) { + truncated := false + out := make([]ChatMessage, len(convo)) + copy(out, convo) + for i := range out { + if len(out[i].ToolCalls) == 0 { + continue + } + tcs := make([]ToolCall, len(out[i].ToolCalls)) + copy(tcs, out[i].ToolCalls) + for j := range tcs { + if n := len(tcs[j].Function.Arguments); n > maxToolArgBytes { + tcs[j].Function.Arguments = fmt.Sprintf( + `{"_deepx_truncated":true,"original_bytes":%d}`, n) + truncated = true + } + } + out[i].ToolCalls = tcs + } + return out, truncated +} + // thinkingOption 是 DeepSeek 思考开关的请求体格式:`{"type": "enabled"}` 或 `{"type": "disabled"}`。 // DeepSeek 默认 enabled,MiMo 默认 disabled。 type thinkingOption struct { @@ -332,6 +365,7 @@ type chatResponse struct { // CallOnce 发起一次非流式 chat completion 调用,直接返回 content 文本。 // 不带 tools 参数,适用于摘要生成等一次性文本生成场景。 func CallOnce(ctx context.Context, apiKey, baseURL, modelID string, convo []ChatMessage, maxTokens int) (string, error) { + convo, _ = truncateToolArgs(convo) body, err := json.Marshal(chatRequest{ Model: modelID, MaxTokens: maxTokens, @@ -374,6 +408,7 @@ func CallOnce(ctx context.Context, apiKey, baseURL, modelID string, convo []Chat // 用于缓存友好的压缩:摘要请求复刻会话的 [system][tools][history] 前缀,只在末尾追加压缩指令, // 从而命中已缓存的前缀(tools 必须和被缓存的那次逐字节一致才命中,故由调用方传入旧 specs)。 func CallWithTools(ctx context.Context, apiKey, baseURL, modelID string, convo []ChatMessage, toolSpecs []tools.OpenAIToolSpec, maxTokens int) (string, error) { + convo, _ = truncateToolArgs(convo) body, err := json.Marshal(chatRequest{ Model: modelID, MaxTokens: maxTokens, @@ -1271,6 +1306,10 @@ func streamAttempt( ch chan<- tea.Msg, ) (string, string, []ToolCall, string, *UsageInfo, error) { + // 发送前消毒:剔除孤儿 tool 消息 / 剥掉无响应的 tool_calls,避免 API 400(见 issue #94), + // 并自愈已被写进历史的坏配对(下次请求即恢复)。正常对话是 no-op。 + truncatedConvo, _ := truncateToolArgs(convo) + sanitized := sanitizeToolPairs(truncatedConvo) body, err := json.Marshal(chatRequest{ Model: modelID, MaxTokens: maxTokens, @@ -1278,9 +1317,7 @@ func streamAttempt( StreamOptions: &streamOptions{ IncludeUsage: true, }, - // 发送前消毒:剔除孤儿 tool 消息 / 剥掉无响应的 tool_calls,避免 API 400(见 issue #94), - // 并自愈已被写进历史的坏配对(下次请求即恢复)。正常对话是 no-op。 - Messages: sanitizeToolPairs(convo), + Messages: sanitized, Tools: toolSpecs, // thinking 和 reasoning_effort 是两个独立顶层字段。各自 omitempty, // 用户设了就发、没设就不发,白名单内的值才透传(防 yaml 笔误)。 diff --git a/agent/truncate_test.go b/agent/truncate_test.go new file mode 100644 index 0000000..aa9ec5c --- /dev/null +++ b/agent/truncate_test.go @@ -0,0 +1,190 @@ +package agent + +import ( + "encoding/json" + "strings" + "testing" +) + +// makeBigArgs 返回字节长度精确为 n 的合法 JSON 字符串(模拟被内联的大文件参数)。 +func makeBigArgs(n int) string { + // 用 {"x":""} 形态,先算出固定开销,再补 pad 到精确 n 字节。 + const prefix = `{"x":"` + const suffix = `"}` + pad := strings.Repeat("a", n-len(prefix)-len(suffix)) + s := prefix + pad + suffix + if len(s) != n { + panic("makeBigArgs 长度计算错误") + } + return s +} + +func TestTruncateNoOpWhenSmall(t *testing.T) { + // 所有参数都远小于阈值 → 不应截断,原 convo 不被改动,返回 truncated=false。 + orig := []ChatMessage{ + {Role: "user", Content: "hi"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c1", Function: ToolCallFunc{Name: "Read", Arguments: `{"path":"a.txt"}`}}, + }}, + {Role: "tool", ToolCallID: "c1", Content: "ok"}, + } + snapshot := append([]ChatMessage(nil), orig...) + + out, truncated := truncateToolArgs(orig) + if truncated { + t.Fatal("小参数不应触发截断") + } + if len(out) != len(orig) { + t.Fatalf("消息条数应一致,got %d", len(out)) + } + if out[1].ToolCalls[0].Function.Arguments != `{"path":"a.txt"}` { + t.Fatalf("小参数应原样保留,got %q", out[1].ToolCalls[0].Function.Arguments) + } + // 原始 convo 必须未被改动(走副本)。 + if orig[1].ToolCalls[0].Function.Arguments != `{"path":"a.txt"}` { + t.Fatal("原始 convo 被意外改动") + } + if len(snapshot) != len(orig) { + t.Fatal("原始 convo 被意外改动(长度)") + } +} + +func TestTruncateLargeArg(t *testing.T) { + // 单个超大参数 → 替换为占位 JSON,original_bytes 正确,原始 convo 不动。 + big := makeBigArgs(maxToolArgBytes + 100) + orig := []ChatMessage{ + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c1", Function: ToolCallFunc{Name: "Write", Arguments: big}}, + }}, + } + out, truncated := truncateToolArgs(orig) + if !truncated { + t.Fatal("超大参数应触发截断") + } + got := out[0].ToolCalls[0].Function.Arguments + if got == big { + t.Fatal("超大参数未被替换") + } + // 占位必须是合法 JSON,且 original_bytes 等于原字节数。 + var ph map[string]any + if err := json.Unmarshal([]byte(got), &ph); err != nil { + t.Fatalf("占位不是合法 JSON: %v (%q)", err, got) + } + if ph["_deepx_truncated"] != true { + t.Fatalf("占位缺少 _deepx_truncated 标记: %v", ph) + } + if int(ph["original_bytes"].(float64)) != len(big) { + t.Fatalf("original_bytes 应为 %d, got %v", len(big), ph["original_bytes"]) + } + // 原始 convo 不动。 + if orig[0].ToolCalls[0].Function.Arguments != big { + t.Fatal("原始 convo 被意外改动") + } +} + +func TestTruncateBoundary(t *testing.T) { + // 边界:恰好等于阈值不截断;阈值+1 截断。这是最容易出 off-by-one 的地方。 + atThreshold := makeBigArgs(maxToolArgBytes) + overThreshold := makeBigArgs(maxToolArgBytes + 1) + + in := []ChatMessage{ + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "a", Function: ToolCallFunc{Name: "Write", Arguments: atThreshold}}, + {ID: "b", Function: ToolCallFunc{Name: "Write", Arguments: overThreshold}}, + }}, + } + out, truncated := truncateToolArgs(in) + if !truncated { + t.Fatal("超过阈值的参数应触发截断") + } + if out[0].ToolCalls[0].Function.Arguments != atThreshold { + t.Fatalf("恰好等于阈值不应被截断,got %q", out[0].ToolCalls[0].Function.Arguments) + } + if out[0].ToolCalls[1].Function.Arguments == overThreshold { + t.Fatal("超过阈值应被截断") + } +} + +func TestTruncateMixedSizesSameMessage(t *testing.T) { + // 同一条 assistant 消息里多个 tool_calls,只有大的被截,小的保留。 + small := `{"path":"a.txt"}` + big := makeBigArgs(maxToolArgBytes + 50) + in := []ChatMessage{ + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "s", Function: ToolCallFunc{Name: "Read", Arguments: small}}, + {ID: "b", Function: ToolCallFunc{Name: "Write", Arguments: big}}, + }}, + } + out, truncated := truncateToolArgs(in) + if !truncated { + t.Fatal("应触发截断") + } + if out[0].ToolCalls[0].Function.Arguments != small { + t.Fatalf("小参数应保留,got %q", out[0].ToolCalls[0].Function.Arguments) + } + if out[0].ToolCalls[1].Function.Arguments == big { + t.Fatal("大参数应被截断") + } + // ID 必须保留,否则后续 sanitize 配对会失效。 + if out[0].ToolCalls[1].ID != "b" { + t.Fatalf("截断后 tool_call ID 丢失: %q", out[0].ToolCalls[1].ID) + } +} + +func TestTruncateOnlyTargetsOversizedMessages(t *testing.T) { + // 多条消息,只有含超大参数的那条被改;其余原样。 + big := makeBigArgs(maxToolArgBytes + 10) + in := []ChatMessage{ + {Role: "user", Content: "read a.html"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c1", Function: ToolCallFunc{Name: "Read", Arguments: `{"path":"a.html"}`}}, + }}, + {Role: "tool", ToolCallID: "c1", Content: "file content"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c2", Function: ToolCallFunc{Name: "Write", Arguments: big}}, + }}, + } + out, truncated := truncateToolArgs(in) + if !truncated { + t.Fatal("应触发截断") + } + if len(out) != 4 { + t.Fatalf("消息条数应不变,got %d", len(out)) + } + if out[1].ToolCalls[0].Function.Arguments != `{"path":"a.html"}` { + t.Fatal("无超大参数的消息不应被改") + } + if out[3].ToolCalls[0].Function.Arguments == big { + t.Fatal("含超大参数的消息应被截") + } +} + +func TestTruncateSurvivesSanitize(t *testing.T) { + // 关键集成点:截断后仍要能通过 sanitizeToolPairs 配对(截断只改 Arguments,不动 ID)。 + // 否则 streamAttempt 里 truncate→sanitize 的顺序会让被截的 tool_call 因失配被剥掉。 + big := makeBigArgs(maxToolArgBytes + 200) + in := []ChatMessage{ + {Role: "user", Content: "copy a.html to b.html"}, + {Role: "assistant", ToolCalls: []ToolCall{ + {ID: "c1", Function: ToolCallFunc{Name: "Write", Arguments: big}}, + }}, + {Role: "tool", ToolCallID: "c1", Content: "written"}, + } + truncated, _ := truncateToolArgs(in) + sanitized := sanitizeToolPairs(truncated) + + // 截断后的 assistant 仍应保留其 tool_call(ID 配对成功),tool 响应也保留。 + if len(sanitized) != 3 { + t.Fatalf("截断+消毒后配对应完整,got %d 条: %v", len(sanitized), roles(sanitized)) + } + if len(sanitized[1].ToolCalls) != 1 || sanitized[1].ToolCalls[0].ID != "c1" { + t.Fatalf("截断后 tool_call 应在 sanitize 后保留,got %+v", sanitized[1]) + } + if sanitized[2].Role != "tool" || sanitized[2].ToolCallID != "c1" { + t.Fatalf("tool 响应应保留,got %+v", sanitized[2]) + } + // 占位 JSON 必须能随整条消息正常序列化(避免 400)。 + if _, err := json.Marshal(sanitized); err != nil { + t.Fatalf("截断后消息无法序列化: %v", err) + } +}