Skip to content

Commit 5d976d0

Browse files
committed
🐛 fix tool-output-turn-cap(并发tool调用)
1 parent 04d3de7 commit 5d976d0

3 files changed

Lines changed: 84 additions & 3 deletions

File tree

agent/llm.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,9 @@ func StartStream(
689689

690690
// roundProgress = 本轮是否有任一工具调用成功;决定 noProgressRounds 是归零还是累加。
691691
roundProgress := false
692+
// turnToolBytes = 本轮已计入历史的工具结果合计字节;clampTurnToolOutput 据此做「本轮合计上限」,
693+
// 防止一轮并发多个 tool call、单条都 ≤96KB 但合计撑爆上下文(issue #135 遗留缺口)。每轮(此处)归零。
694+
turnToolBytes := 0
692695

693696
// 执行每个工具调用,把结果加进 convo。
694697
// 这些工具被 deepx 拦截 (不走 Executor):
@@ -924,7 +927,9 @@ func StartStream(
924927
Role: "tool",
925928
ToolCallID: tc.ID,
926929
Name: tc.Function.Name,
927-
Content: result.Output,
930+
// 本轮合计上限:单条已被 clampToolOutput 限到 96KB,这里再按「本轮所有工具结果合计」收口,
931+
// 防止一轮并发多条把上下文顶爆(issue #135)。只截入历史的内容,UI(上方 ToolCallResultMsg)仍展示完整结果。
932+
Content: clampTurnToolOutput(tc.Function.Name, result.Output, &turnToolBytes),
928933
})
929934
}
930935
ch <- HistoryUpdateMsg{History: convo}

agent/tool_clamp.go

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ const (
1818
// minBase64RunBytes:连续 base64 字符达到这个长度即判定为二进制 blob(截图 / 附件等),
1919
// 整段替换为占位符。正常文本 / 代码不会出现这么长且不含空白的连续串。
2020
minBase64RunBytes = 4096
21+
// maxToolOutputBytesPerTurn:单个 assistant turn 内,所有工具结果写入历史的「合计」字节上限。
22+
// 单条已被 clampToolOutput 限到 maxToolOutputBytes;但一轮可并发多个 tool call,合计无人约束 ——
23+
// K×96KB 仍能一轮把上下文顶爆(issue #135 的遗留缺口)。超预算后本轮后续结果整体替换为简短占位。
24+
// 256KB ≈ 容纳 2~3 条满载结果,够正常一轮多工具用,又远小于上下文窗口。
25+
maxToolOutputBytesPerTurn = 256 * 1024
2126
)
2227

2328
// clampToolOutput 把工具结果压到可安全入历史的大小:
@@ -34,11 +39,39 @@ func clampToolOutput(name, out string) string {
3439
b = b[:len(b)-1]
3540
}
3641
return string(b) + fmt.Sprintf(
37-
"\n\n[…%s 返回 %d 字节,已截断至 %d 字节,防止撑爆上下文(issue #135)。"+
42+
"\n\n[…%s 返回 %d 字节,已截断至 %d 字节,防止撑爆上下文。"+
3843
"请缩小范围重试:读文件用 offset/limit 分页、grep 收窄匹配、命令只取必要输出。]",
3944
name, len(out), len(b))
4045
}
4146

47+
// clampTurnToolOutput 在 clampToolOutput(单条上限)之上再加一道「本轮合计上限」。
48+
// spent 指向本轮已计入历史的工具结果字节数,每个 assistant turn 开始时由调用方置 0。
49+
// 预算够:截到剩余预算内(UTF-8 边界)并累加 spent;预算用尽:整条替换为简短占位,
50+
// 提示模型减少单轮并发工具调用 / 分多轮获取,避免一轮 K 条结果合计撑爆上下文(issue #135)。
51+
func clampTurnToolOutput(name, out string, spent *int) string {
52+
remaining := maxToolOutputBytesPerTurn - *spent
53+
if remaining <= 0 {
54+
return fmt.Sprintf(
55+
"[本轮工具结果合计已达 %dKB 上限,%s 的结果未计入上下文。"+
56+
"请减少单轮并发的工具调用、分多轮获取,或用更聚焦的查询缩小输出。]",
57+
maxToolOutputBytesPerTurn/1024, name)
58+
}
59+
if len(out) <= remaining {
60+
*spent += len(out)
61+
return out
62+
}
63+
b := []byte(out)[:remaining]
64+
// 回退到合法 UTF-8 边界,避免截出半个多字节字符(发给 API 会乱码 / 被拒)。
65+
for len(b) > 0 && !utf8.Valid(b) {
66+
b = b[:len(b)-1]
67+
}
68+
*spent += len(b)
69+
return string(b) + fmt.Sprintf(
70+
"\n\n[…本轮工具结果合计接近 %dKB 上限,%s 的结果被进一步截断。"+
71+
"请分多轮获取或缩小范围重试。]",
72+
maxToolOutputBytesPerTurn/1024, name)
73+
}
74+
4275
// stripBase64Blobs 把每一段足够长的连续 base64 字符串替换为简短占位符。
4376
// 用途:real-browser-mcp 的 browser_screenshot 等工具会把截图编成单行 base64 直接塞进文本结果,
4477
// 对非视觉模型纯属上下文垃圾。按字节扫描(base64 字符全是 ASCII,非 ASCII 字节天然断开,保证不破坏 UTF-8 文本)。
@@ -55,7 +88,7 @@ func stripBase64Blobs(s string) string {
5588
j++
5689
}
5790
if j-i >= minBase64RunBytes {
58-
fmt.Fprintf(&b, "[…%d 字节 base64 二进制数据已省略(截图 / 附件不入上下文,issue #135)]", j-i)
91+
fmt.Fprintf(&b, "[…%d 字节 base64 二进制数据已省略(截图 / 附件不入上下文)]", j-i)
5992
i = j
6093
continue
6194
}

agent/tool_clamp_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,46 @@ func TestClampToolOutput_UTF8Boundary(t *testing.T) {
6060
t.Fatalf("clamped multibyte output must remain valid UTF-8")
6161
}
6262
}
63+
64+
func TestClampTurnToolOutput_AggregateCap(t *testing.T) {
65+
// 模拟一轮里并发多条工具结果:单条都 ≤96KB(过了 clampToolOutput),
66+
// 但合计要被「本轮合计上限」收口。
67+
spent := 0
68+
chunk := strings.Repeat("x", maxToolOutputBytes) // 单条 96KB,符合单条上限
69+
70+
round := 0
71+
for range 10 { // 10×96KB = 960KB,远超 256KB 合计上限
72+
out := clampTurnToolOutput("Read", chunk, &spent)
73+
round++
74+
if !utf8.ValidString(out) {
75+
t.Fatalf("第 %d 条结果应为合法 UTF-8", round)
76+
}
77+
if spent > maxToolOutputBytesPerTurn {
78+
t.Fatalf("spent 不应超过本轮合计上限:%d > %d", spent, maxToolOutputBytesPerTurn)
79+
}
80+
}
81+
// 合计真正写入历史的字节(spent)必须被压在上限内。
82+
if spent > maxToolOutputBytesPerTurn {
83+
t.Fatalf("本轮合计未收口:spent=%d", spent)
84+
}
85+
// 预算用尽后,后续结果应只剩简短占位(远小于一条 96KB)。
86+
last := clampTurnToolOutput("Read", chunk, &spent)
87+
if len(last) > 1024 {
88+
t.Fatalf("预算用尽后应只返回简短占位,got %d bytes", len(last))
89+
}
90+
if !strings.Contains(last, "未计入上下文") {
91+
t.Fatalf("预算用尽占位应提示未计入上下文,got %q", last)
92+
}
93+
}
94+
95+
func TestClampTurnToolOutput_SmallPassthrough(t *testing.T) {
96+
// 预算充足时,单条小结果原样通过并正确累加 spent。
97+
spent := 0
98+
in := "ok: 3 files changed"
99+
if got := clampTurnToolOutput("Bash", in, &spent); got != in {
100+
t.Fatalf("预算充足的小结果应原样通过,got %q", got)
101+
}
102+
if spent != len(in) {
103+
t.Fatalf("spent 应累加为 %d,got %d", len(in), spent)
104+
}
105+
}

0 commit comments

Comments
 (0)