Skip to content

Commit be00b61

Browse files
authored
Merge pull request #206 from itmisx/feature/tool-output-reclaim
⚡️ tool output reclaim
2 parents a520f98 + 0543ae2 commit be00b61

2 files changed

Lines changed: 256 additions & 0 deletions

File tree

agent/llm.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,24 @@ func StartStream(
613613
return
614614
}
615615

616+
// 工具输出回收(reclaim,issue #201):比 LLM 摘要便宜的第一层,且不受 inLoopCompactOff 影响。
617+
// 按 token 预算(窗口比例)把"较旧的工具结果内容"换成引用标记 —— 不删消息、不动结构、配对不坏。
618+
// 度量按 token 而非 user 轮,因此能伸进最近 2 个 user 轮内部,回收"单个长任务轮里早几轮的大输出"
619+
// (按 user 轮切的摘要够不着的部分,正是 #201 卡 73%/400 的根)。触发用"实时估算"(含本轮刚
620+
// append 的工具结果),消除 lastPromptTokens 慢一拍导致的单轮越窗。与现有 clampToolOutput(96KB)/
621+
// clampTurnToolOutput(256KB)暂并存,验证稳后再摘旧卡口。
622+
if ctxWin := currentEntry.ContextWindow; ctxWin > 0 && len(convo) > 0 {
623+
curEst := 0
624+
for i := range convo {
625+
curEst += MsgTokens(convo[i]) // 实时估算(与 clampMaxTokens 同口径);可后续复用其结果省一遍
626+
}
627+
if max(lastPromptTokens, curEst) >= ctxWin*compactTriggerPct/100 {
628+
if reclaimToolOutputs(convo, ctxWin) {
629+
ch <- HistoryUpdateMsg{History: convo}
630+
}
631+
}
632+
}
633+
616634
// 循环内 auto-compact:上一轮 prompt 接近上下文窗口就先压缩历史腾空间再继续(不熔断停)。
617635
// 对标 Claude Code:压缩 convo[1:] 成摘要、重建 [system(新摘要)]+尾部,新摘要经 CompactedMsg
618636
// 回传 TUI 存 session(否则被剥的 system 摘要会丢失),history 截断经 HistoryUpdateMsg 同步。
@@ -1668,3 +1686,83 @@ func elideWriteContent(argsJSON string) string {
16681686
}
16691687
return strings.TrimRight(buf.String(), "\n") // Encode 会补一个换行,去掉
16701688
}
1689+
1690+
// --- 工具输出回收(reclaim,issue #201)---
1691+
//
1692+
// 问题:deepx 的历史压缩按 user 轮切,保护最近 keepRecentTurns 个 user 轮。但"跑测试 / 长任务"
1693+
// 常是一个 user 消息 + 几十轮工具调用,bloat 全在这一个被保护的 user 轮内部 —— 按 user 轮切的摘要
1694+
// 够不着,于是卡在高位"无需压缩"甚至 400(见 issue #201;RunCompression 的 keepStart≤0 分支)。
1695+
//
1696+
// reclaim 独立于 user 轮摘要,按 token 预算回收"较旧的工具结果内容":保住最近 reclaimKeepPct 预算
1697+
// + 最近 reclaimMinTailToolMsgs 条工具结果,更旧的把 Content 换成引用标记(壳留、配对不坏、可重取)。
1698+
// 因为度量按 token 而非 user 轮,它能伸进被保护的 2 轮内部,回收轮内早几轮的大输出。无 LLM,也不受
1699+
// inLoopCompactOff 影响 —— 摘要超时/拒绝时它照样能减负。
1700+
1701+
const (
1702+
// reclaimKeepPct:保住"最近工具输出"的 token 预算,取上下文窗口的这个百分比。超出的更旧工具输出被回收。
1703+
reclaimKeepPct = 20
1704+
// reclaimMinTailToolMsgs:无论预算多小,至少保护最近这么多条工具结果不动(模型正在用)。
1705+
reclaimMinTailToolMsgs = 4
1706+
// reclaimMarkerPrefix:被回收工具结果的 Content 前缀,兼作幂等判据(再次 reclaim 时跳过、不重复计数)。
1707+
reclaimMarkerPrefix = "[已回收] "
1708+
)
1709+
1710+
// reclaimToolOutputs 就地把"较旧的工具结果内容"换成引用标记以回收上下文,返回是否发生改动。
1711+
// 只改 Role=="tool" 消息的 Content;user/assistant 消息、工具调用骨架、ToolCallID 配对一律不动
1712+
// (故 sanitizeToolPairs 安全、发给 API 的配对完整)。
1713+
// 保护:最近 reclaimMinTailToolMsgs 条工具结果 + 最近 reclaimKeepPct 预算内的工具输出,留全。幂等。
1714+
func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
1715+
if ctxWin <= 0 {
1716+
ctxWin = 65536
1717+
}
1718+
keepBudget := ctxWin * reclaimKeepPct / 100
1719+
1720+
// 预建 tool_call_id → path,避免每条被回收消息各做一次 O(n) 反查配对 assistant。
1721+
callPath := map[string]string{}
1722+
for _, m := range convo {
1723+
if m.Role != "assistant" {
1724+
continue
1725+
}
1726+
for _, tc := range m.ToolCalls {
1727+
var args map[string]any
1728+
if json.Unmarshal([]byte(tc.Function.Arguments), &args) == nil {
1729+
if p, ok := args["path"].(string); ok {
1730+
callPath[tc.ID] = p
1731+
}
1732+
}
1733+
}
1734+
}
1735+
1736+
changed := false
1737+
toolSeen := 0 // 已遍历到的(未回收)工具结果条数,用于"最近 N 条"保护
1738+
keptTokens := 0 // 已保留的工具输出 token 累计,用于预算保护
1739+
for i := len(convo) - 1; i >= 0; i-- {
1740+
if convo[i].Role != "tool" {
1741+
continue
1742+
}
1743+
if strings.HasPrefix(convo[i].Content, reclaimMarkerPrefix) {
1744+
continue // 已回收:不计 tail、不计预算、不重复处理(幂等)
1745+
}
1746+
toolSeen++
1747+
if toolSeen <= reclaimMinTailToolMsgs || keptTokens < keepBudget {
1748+
keptTokens += MsgTokens(convo[i]) // 保护区:留全,并计入预算
1749+
continue
1750+
}
1751+
convo[i].Content = toolOutputReference(convo[i].Name, callPath[convo[i].ToolCallID])
1752+
changed = true
1753+
}
1754+
return changed
1755+
}
1756+
1757+
// toolOutputReference 生成被回收工具结果的引用标记:带工具名与 path(有则),
1758+
// 让模型知道"这里原是什么调用的输出、需要时重新调用取回"。
1759+
func toolOutputReference(name, path string) string {
1760+
label := name
1761+
if label == "" {
1762+
label = "工具"
1763+
}
1764+
if path != "" {
1765+
label += " " + path
1766+
}
1767+
return reclaimMarkerPrefix + label + " 的旧输出已省略以回收上下文,需要时请重新调用获取。"
1768+
}

agent/reclaim_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
package agent
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func toolMsg(id, name, content string) ChatMessage {
10+
return ChatMessage{Role: "tool", ToolCallID: id, Name: name, Content: content}
11+
}
12+
13+
func asstCall(id, name, argsJSON string) ChatMessage {
14+
return ChatMessage{Role: "assistant", ToolCalls: []ToolCall{
15+
{ID: id, Type: "function", Function: ToolCallFunc{Name: name, Arguments: argsJSON}},
16+
}}
17+
}
18+
19+
// buildConvo 造 1 system + 1 user + n 轮 (assistant call + tool result),每条工具结果内容为 content。
20+
func buildConvo(n int, name, content string) []ChatMessage {
21+
convo := []ChatMessage{{Role: "system", Content: "sys"}, {Role: "user", Content: "任务"}}
22+
for k := 0; k < n; k++ {
23+
id := fmt.Sprintf("c%d", k)
24+
convo = append(convo, asstCall(id, name, fmt.Sprintf(`{"path":"src/f%d.go"}`, k)))
25+
convo = append(convo, toolMsg(id, name, content))
26+
}
27+
return convo
28+
}
29+
30+
func countTool(convo []ChatMessage) (reclaimed, kept int) {
31+
for _, m := range convo {
32+
if m.Role != "tool" {
33+
continue
34+
}
35+
if strings.HasPrefix(m.Content, reclaimMarkerPrefix) {
36+
reclaimed++
37+
} else {
38+
kept++
39+
}
40+
}
41+
return
42+
}
43+
44+
func TestReclaim_OldReplacedRecentKept(t *testing.T) {
45+
big := strings.Repeat("这是一大段读取内容。", 500) // 每条远超小预算
46+
convo := buildConvo(10, "Read", big)
47+
before := len(convo)
48+
49+
if !reclaimToolOutputs(convo, 4096) { // 预算 = 4096*20% ≈ 819 token
50+
t.Fatal("应发生回收")
51+
}
52+
reclaimed, kept := countTool(convo)
53+
if reclaimed == 0 {
54+
t.Fatal("较旧工具结果应被回收")
55+
}
56+
if kept < reclaimMinTailToolMsgs {
57+
t.Fatalf("最近 %d 条应保留,实际保留 %d", reclaimMinTailToolMsgs, kept)
58+
}
59+
if len(convo) != before {
60+
t.Fatalf("reclaim 不应增删消息: %d -> %d", before, len(convo))
61+
}
62+
}
63+
64+
func TestReclaim_TailProtected(t *testing.T) {
65+
convo := buildConvo(reclaimMinTailToolMsgs+3, "Bash", strings.Repeat("x", 5000))
66+
reclaimToolOutputs(convo, 1024) // 预算极小
67+
68+
seen := 0
69+
for i := len(convo) - 1; i >= 0; i-- {
70+
if convo[i].Role != "tool" {
71+
continue
72+
}
73+
seen++
74+
if seen <= reclaimMinTailToolMsgs && strings.HasPrefix(convo[i].Content, reclaimMarkerPrefix) {
75+
t.Fatalf("最近第 %d 条工具结果被误回收", seen)
76+
}
77+
}
78+
}
79+
80+
func TestReclaim_ReferenceHasNameAndPath(t *testing.T) {
81+
convo := buildConvo(8, "Read", strings.Repeat("y", 8000))
82+
reclaimToolOutputs(convo, 2048)
83+
84+
found := false
85+
for _, m := range convo {
86+
if m.Role == "tool" && strings.HasPrefix(m.Content, reclaimMarkerPrefix) {
87+
if !strings.Contains(m.Content, "Read") || !strings.Contains(m.Content, "src/f") {
88+
t.Fatalf("引用应含工具名和 path, got=%q", m.Content)
89+
}
90+
found = true
91+
break
92+
}
93+
}
94+
if !found {
95+
t.Fatal("应有被回收的工具结果")
96+
}
97+
}
98+
99+
func TestReclaim_Idempotent(t *testing.T) {
100+
convo := buildConvo(8, "Read", strings.Repeat("z", 6000))
101+
if !reclaimToolOutputs(convo, 2048) {
102+
t.Fatal("首次应有改动")
103+
}
104+
if reclaimToolOutputs(convo, 2048) {
105+
t.Fatal("再次 reclaim 不应有新改动(幂等)")
106+
}
107+
}
108+
109+
func TestReclaim_NonToolUntouched(t *testing.T) {
110+
userBig := strings.Repeat("u", 9000)
111+
asstBig := strings.Repeat("a", 9000)
112+
convo := []ChatMessage{
113+
{Role: "system", Content: "s"},
114+
{Role: "user", Content: userBig},
115+
asstCall("c1", "Read", `{"path":"a.go"}`),
116+
{Role: "assistant", Content: asstBig},
117+
}
118+
before := len(convo)
119+
reclaimToolOutputs(convo, 1024)
120+
if convo[1].Content != userBig {
121+
t.Fatal("user 消息不应被改")
122+
}
123+
if convo[3].Content != asstBig {
124+
t.Fatal("assistant 文本不应被改")
125+
}
126+
if len(convo) != before {
127+
t.Fatal("消息数不应变")
128+
}
129+
}
130+
131+
func TestReclaim_UnderBudgetNoChange(t *testing.T) {
132+
convo := buildConvo(6, "Read", "tiny") // 工具输出很小
133+
if reclaimToolOutputs(convo, 1_000_000) {
134+
t.Fatal("预算充足时不应回收")
135+
}
136+
}
137+
138+
func TestReclaim_PairingPreserved(t *testing.T) {
139+
// 回收后每条 tool 消息的 ToolCallID / Name 不变,配对完整。
140+
convo := buildConvo(10, "Read", strings.Repeat("w", 6000))
141+
ids := map[int]string{}
142+
for i, m := range convo {
143+
if m.Role == "tool" {
144+
ids[i] = m.ToolCallID
145+
}
146+
}
147+
reclaimToolOutputs(convo, 2048)
148+
for i, m := range convo {
149+
if m.Role == "tool" {
150+
if m.ToolCallID != ids[i] {
151+
t.Fatalf("第 %d 条 ToolCallID 被改动", i)
152+
}
153+
if m.Name == "" {
154+
t.Fatalf("第 %d 条 Name 丢失", i)
155+
}
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)