Skip to content

Commit 850db4d

Browse files
authored
Merge pull request #136 from itmisx/fix/tool-output-clamp
🐛 fix tool-output-clamp
2 parents 05b94f7 + 246ba1c commit 850db4d

3 files changed

Lines changed: 145 additions & 1 deletion

File tree

agent/llm.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1255,5 +1255,9 @@ func executeTool(tc ToolCall, mode AgentMode, lastFile *string) tools.ToolResult
12551255
Success: false,
12561256
}
12571257
}
1258-
return t.Executor(args)
1258+
// 唯一收口:所有真正执行的工具(Read/Bash/Grep/WebFetch/MCP 等,主 agent / 子 agent / Explore 都走这里)
1259+
// 的返回值在进会话历史前统一限幅,防止单条超大结果撑爆上下文(issue #135)。
1260+
res := t.Executor(args)
1261+
res.Output = clampToolOutput(t.Name, res.Output)
1262+
return res
12591263
}

agent/tool_clamp.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package agent
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"unicode/utf8"
7+
)
8+
9+
// 工具返回值在写入会话历史前的两道闸,根治 issue #135:
10+
// 单条超大工具结果(real-browser-mcp 的 base64 截图、大文件读取、海量 grep/Explore 结果)
11+
// 会原样进历史且只增不减,累积后超模型上下文窗口(如 1M tokens),导致 HTTP 400 且会话不可恢复。
12+
// 严重时单条消息就 ~1.3M 字符,序列化都坏掉(messages[N]: missing field `content`)。
13+
// 这里在唯一执行入口 executeTool 出口统一收口:剥 base64 二进制 + 总字节硬上限,任何单条结果都不可能独占窗口。
14+
const (
15+
// maxToolOutputBytes:单条工具结果写入历史的硬上限。超出按 UTF-8 边界截断并附说明。
16+
// 96KB(≈ 数万 token):够装正常的大文件读取 / grep 结果,又远小于上下文窗口。
17+
maxToolOutputBytes = 96 * 1024
18+
// minBase64RunBytes:连续 base64 字符达到这个长度即判定为二进制 blob(截图 / 附件等),
19+
// 整段替换为占位符。正常文本 / 代码不会出现这么长且不含空白的连续串。
20+
minBase64RunBytes = 4096
21+
)
22+
23+
// clampToolOutput 把工具结果压到可安全入历史的大小:
24+
// 先剥掉 base64 二进制 blob(替换为占位符),再对剩余文本做总字节上限截断。
25+
// name 仅用于占位 / 截断说明,方便模型理解发生了什么、如何缩小范围重试。
26+
func clampToolOutput(name, out string) string {
27+
out = stripBase64Blobs(out)
28+
if len(out) <= maxToolOutputBytes {
29+
return out
30+
}
31+
b := []byte(out)[:maxToolOutputBytes]
32+
// 回退到合法 UTF-8 边界,避免截出半个多字节字符(gob 持久化没事,但发给 API 会乱码 / 被拒)。
33+
for len(b) > 0 && !utf8.Valid(b) {
34+
b = b[:len(b)-1]
35+
}
36+
return string(b) + fmt.Sprintf(
37+
"\n\n[…%s 返回 %d 字节,已截断至 %d 字节,防止撑爆上下文(issue #135)。"+
38+
"请缩小范围重试:读文件用 offset/limit 分页、grep 收窄匹配、命令只取必要输出。]",
39+
name, len(out), len(b))
40+
}
41+
42+
// stripBase64Blobs 把每一段足够长的连续 base64 字符串替换为简短占位符。
43+
// 用途:real-browser-mcp 的 browser_screenshot 等工具会把截图编成单行 base64 直接塞进文本结果,
44+
// 对非视觉模型纯属上下文垃圾。按字节扫描(base64 字符全是 ASCII,非 ASCII 字节天然断开,保证不破坏 UTF-8 文本)。
45+
func stripBase64Blobs(s string) string {
46+
if len(s) < minBase64RunBytes {
47+
return s
48+
}
49+
var b strings.Builder
50+
i := 0
51+
for i < len(s) {
52+
if isBase64Byte(s[i]) {
53+
j := i
54+
for j < len(s) && isBase64Byte(s[j]) {
55+
j++
56+
}
57+
if j-i >= minBase64RunBytes {
58+
fmt.Fprintf(&b, "[…%d 字节 base64 二进制数据已省略(截图 / 附件不入上下文,issue #135)]", j-i)
59+
i = j
60+
continue
61+
}
62+
b.WriteString(s[i:j])
63+
i = j
64+
continue
65+
}
66+
b.WriteByte(s[i])
67+
i++
68+
}
69+
return b.String()
70+
}
71+
72+
// isBase64Byte 判断是否为 base64 字母表字符(含标准 +/ 和 URL-safe -_ 以及填充 =)。
73+
func isBase64Byte(c byte) bool {
74+
return c >= 'A' && c <= 'Z' ||
75+
c >= 'a' && c <= 'z' ||
76+
c >= '0' && c <= '9' ||
77+
c == '+' || c == '/' || c == '=' || c == '-' || c == '_'
78+
}

agent/tool_clamp_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package agent
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"unicode/utf8"
7+
)
8+
9+
func TestClampToolOutput_SmallPassthrough(t *testing.T) {
10+
in := "ok: 3 files changed"
11+
if got := clampToolOutput("Bash", in); got != in {
12+
t.Fatalf("small output should pass through unchanged, got %q", got)
13+
}
14+
}
15+
16+
func TestStripBase64Blobs_RemovesScreenshot(t *testing.T) {
17+
// 模拟 browser_screenshot:短前缀 + 一大段单行 base64。
18+
blob := strings.Repeat("A", 200000)
19+
in := "data:image/png;base64," + blob
20+
got := stripBase64Blobs(in)
21+
if strings.Contains(got, blob) {
22+
t.Fatalf("base64 blob should be stripped")
23+
}
24+
if !strings.Contains(got, "已省略") {
25+
t.Fatalf("expected placeholder, got %q", got)
26+
}
27+
if len(got) > 1024 {
28+
t.Fatalf("stripped output too large: %d bytes", len(got))
29+
}
30+
}
31+
32+
func TestStripBase64Blobs_KeepsNormalText(t *testing.T) {
33+
// 正常代码 / 文本含空白和标点,不应被当作 base64 误删。
34+
in := strings.Repeat("func foo(x int) int { return x + 1 }\n", 500)
35+
if got := stripBase64Blobs(in); got != in {
36+
t.Fatalf("normal text must be preserved")
37+
}
38+
}
39+
40+
func TestClampToolOutput_TruncatesHuge(t *testing.T) {
41+
// 巨大的纯文本(非 base64,因含空格不构成连续 base64 串)应被字节上限截断。
42+
in := strings.Repeat("word ", 500000) // ~2.5MB
43+
got := clampToolOutput("Read", in)
44+
if len(got) > maxToolOutputBytes+512 {
45+
t.Fatalf("output not clamped: %d bytes", len(got))
46+
}
47+
if !strings.Contains(got, "已截断") {
48+
t.Fatalf("expected truncation notice")
49+
}
50+
if !utf8.ValidString(got) {
51+
t.Fatalf("clamped output must be valid UTF-8")
52+
}
53+
}
54+
55+
func TestClampToolOutput_UTF8Boundary(t *testing.T) {
56+
// 多字节字符正好跨越截断点时,不能截出半个 rune。
57+
in := strings.Repeat("中", maxToolOutputBytes) // 每个 3 字节,远超上限
58+
got := clampToolOutput("Read", in)
59+
if !utf8.ValidString(got) {
60+
t.Fatalf("clamped multibyte output must remain valid UTF-8")
61+
}
62+
}

0 commit comments

Comments
 (0)