Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions agent/llm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1271,16 +1306,18 @@ 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,
Stream: true,
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 笔误)。
Expand Down
190 changes: 190 additions & 0 deletions agent/truncate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package agent

import (
"encoding/json"
"strings"
"testing"
)

// makeBigArgs 返回字节长度精确为 n 的合法 JSON 字符串(模拟被内联的大文件参数)。
func makeBigArgs(n int) string {
// 用 {"x":"<pad>"} 形态,先算出固定开销,再补 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)
}
}