Skip to content

Commit b38b99c

Browse files
authored
Merge pull request #208 from itmisx/fix/compaction-visibility
🐛 fix: compaction-visibility
2 parents 4b13f64 + 303f3d4 commit b38b99c

7 files changed

Lines changed: 510 additions & 33 deletions

File tree

agent/llm.go

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,51 @@ type HistoryUpdateMsg struct {
108108
History []ChatMessage
109109
}
110110

111-
// CompactedMsg 在「单个长 turn 内」自动压缩后发给 UI:摘要存在 session(每轮注入 system 尾部),
112-
// 而 system 不入 history(HistoryUpdateMsg 会剥掉)——必须用这条把新摘要带出来,否则下轮上下文丢失。
113-
// history 截断由随后的 HistoryUpdateMsg 同步。Turns = 本次被压掉的 user 轮数,仅供 UI 提示。
111+
// CompactedMsg 在「单个长 turn 内」自动压缩后发给 UI。
112+
//
113+
// 摘要 + 截断后的历史**必须装在同一条消息里**:两者曾经分两条发(CompactedMsg 带摘要、随后的
114+
// HistoryUpdateMsg 带截断历史),而 UI 是一条一条消费的,用户若恰好在两条之间按 ESC,中断逻辑会
115+
// drainAndDiscard 掉后一条 —— 结果是「摘要存下了、历史没截断」,下一轮把摘要和完整历史一起发出去,
116+
// 内容重复、上下文反而比压缩前更大(issue #201 里"莫名其妙涨到 87%"的机制之一)。
117+
// 装成一条后要么全生效、要么全不生效:中断时 agent 直接 return,它压好的 convo 一并丢弃,
118+
// UI 保留完整历史,下一轮重来,只是白压一次 —— 状态始终自洽。
114119
type CompactedMsg struct {
115120
Summary string
116121
Turns int
122+
// History = 压缩后的完整 convo(含首条 system)。UI 用它替换本地副本,剥掉 system 后存盘。
123+
History []ChatMessage
124+
// EstPromptTokens = 压缩后「下一次请求」的本地估算(system + tools + 保留的历史)。
125+
// TUI 状态栏显示的是上一次请求的真实 prompt_tokens,压缩截断历史后它仍是压缩前的大数,
126+
// 要等下一次请求才更新 —— 本轮若被 ESC 中断就永远不更新,看起来像"压了没动"(issue #201)。
127+
// 由 agent 捎出来而不是让 TUI 自己估:CompactedMsg 到达时 TUI 的 history 还没被截断(截断靠
128+
// 随后的 HistoryUpdateMsg),自己估只会估出压缩前的数;而这个值 agent 防死循环时本就要算。
129+
EstPromptTokens int
130+
}
131+
132+
// CompactingMsg 轮内自动压缩「开始」时发给 UI:RunCompression 是一次同步的 LLM 调用,
133+
// 最长会卡 compactionTimeout(2 分钟),期间 agent 停在那儿、一个 token 也不吐。不发这条的话
134+
// 界面上只有普通的流式 spinner,用户看不出在压缩,只觉得卡死了(issue #201)。
135+
// 收到后 UI 把活动状态行切成「压缩中…」,由 CompactedMsg(成功)或 CompactFailedMsg(失败)复位。
136+
type CompactingMsg struct{}
137+
138+
// CompactFailedMsg 轮内自动压缩失败(摘要请求超时/被拒、历史压不动)。
139+
// 失败后本轮不再尝试压缩,退回「撞窗口优雅报错」保护 —— 这件事此前完全静默,
140+
// 用户只看到上下文一路涨上去却不知道中间试过一次(issue #201)。
141+
type CompactFailedMsg struct {
142+
Reason string
143+
}
144+
145+
// ContextReclaimedMsg 工具输出回收(reclaim)落地后发给 UI。reclaim 是长任务(user 轮≤2、压缩
146+
// 结构上不触发)里**唯一**在减负的一层,可它此前只发 HistoryUpdateMsg —— 不刷状态栏、不留痕迹、
147+
// 不广播,把上下文从 100% 压到 20% 用户却什么都看不到,以为"没压成功"(issue #201)。
148+
// 这条把 reclaim 对齐到自动压缩的待遇:痕迹 + web 提示 + 状态栏刷新。
149+
type ContextReclaimedMsg struct {
150+
Count int // 被回收的工具结果条数
151+
Tokens int // 净回收掉的 token(原文 - 占位标记)
152+
// EstPromptTokens = 回收后「下一次请求」的本地估算。状态栏读的是上一次请求的真实 prompt_tokens,
153+
// 回收后它仍是回收前的大数,要等下一次请求才更新 —— 本轮被 ESC 中断就永远不更新。用它把已失效的
154+
// 真实值降下来(TUI 侧只降不升,见 CompactedMsg.EstPromptTokens 同款逻辑)。
155+
EstPromptTokens int
117156
}
118157

119158
// VisionUnsupportedMsg:本以为支持视觉的模型,实际发图被端点拒(如 404 "no image input")。
@@ -579,6 +618,9 @@ func StartStream(
579618
ch <- ModelSwitchMsg{Role: role, ModelID: currentEntry.Model}
580619

581620
toolSpecs := buildToolSpecs(mode)
621+
// 工具定义 JSON 的 token 数,本轮不变(toolSpecs 只在这里建一次)。算一次复用:reclaim
622+
// 给 UI 的估算要含它,口径才与真实 prompt_tokens / 压缩的 EstimatePromptTokens 对齐。
623+
toolSpecsTokens := EstTokens(MarshalToolSpecs(toolSpecs))
582624

583625
// 发出本轮"实际发送"的前缀快照(system 文本 + tool specs JSON),供 TUI 持久化:
584626
// 重启变化检测 + 缓存友好压缩复刻旧前缀。tool specs 随 mode/role 变,故必须存实际值。
@@ -625,7 +667,10 @@ func StartStream(
625667
curEst += MsgTokens(convo[i]) // 实时估算(与 clampMaxTokens 同口径);可后续复用其结果省一遍
626668
}
627669
if max(lastPromptTokens, curEst) >= ctxWin*compactTriggerPct/100 {
628-
if reclaimToolOutputs(convo, ctxWin) {
670+
if n, freed := reclaimToolOutputs(convo, ctxWin); n > 0 {
671+
// 回收后 prompt 估算 = system+messages(curEst 已含,减去净回收量)+ tools schema。
672+
estAfter := curEst - freed + toolSpecsTokens
673+
ch <- ContextReclaimedMsg{Count: n, Tokens: freed, EstPromptTokens: estAfter}
629674
ch <- HistoryUpdateMsg{History: convo}
630675
}
631676
}
@@ -638,21 +683,26 @@ func StartStream(
638683
lastPromptTokens >= ctxWin*compactTriggerPct/100 &&
639684
len(convo) > 0 && convo[0].Role == "system" {
640685
hist := convo[1:]
686+
ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 2 分钟,期间不吐任何 token
641687
sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin)
642688
if cerr != nil {
643689
inLoopCompactOff = true // 压不动(历史太短/摘要失败)→ 本轮不再尝试,退回撞窗口报错
690+
ch <- CompactFailedMsg{Reason: cerr.Error()}
644691
} else {
645692
summary = sum
646693
kept := append([]ChatMessage(nil), hist[cutIdx:]...)
647694
convo = append([]ChatMessage{{Role: "system", Content: BuildSystemPrompt(workspace, skillCatalog, summary)}}, kept...)
648695
lastPromptTokens = 0 // 压完归零,等下一轮真实 usage 再判
696+
// 压缩后 prompt 的本地估算:一份两用 —— ① 防死循环判断(下面);② 随 CompactedMsg
697+
// 带给 TUI 刷新状态栏(见 CompactedMsg.EstPromptTokens 注释)。
698+
estAfter := EstimatePromptTokens(workspace, skillCatalog, summary, kept)
649699
// 防死循环:若压缩后历史仍超阈值(最近 5 轮本身就超窗口,RunCompression 切点缩不动),
650700
// 再压也无效 → 本轮关掉循环内压缩,退回撞窗口优雅报错,避免反复压缩刷请求。
651-
if EstimatePromptTokens(workspace, skillCatalog, summary, kept) >= ctxWin*compactTriggerPct/100 {
701+
if estAfter >= ctxWin*compactTriggerPct/100 {
652702
inLoopCompactOff = true
653703
}
654-
ch <- CompactedMsg{Summary: summary, Turns: turns}
655-
ch <- HistoryUpdateMsg{History: convo}
704+
// 摘要 + 截断历史 + 新量级一条送达,原子生效(见 CompactedMsg 注释)。
705+
ch <- CompactedMsg{Summary: summary, Turns: turns, History: convo, EstPromptTokens: estAfter}
656706
}
657707
}
658708
// 按本轮模型支不支持视觉,即时把带图消息渲染成 base64 或 路径+OCR(见 renderConvoImages)。
@@ -1714,12 +1764,14 @@ const (
17141764
reclaimMarkerPrefix = "[已回收] "
17151765
)
17161766

1717-
// reclaimToolOutputs 就地把"较旧的工具结果内容"换成引用标记以回收上下文,返回是否发生改动。
1767+
// reclaimToolOutputs 就地把"较旧的工具结果内容"换成引用标记以回收上下文,
1768+
// 返回(被回收条数, 净回收 token = 原文-占位);未发生改动返回 (0, 0)。计数供 UI 提示 ——
1769+
// 不出声的话用户看不出上下文被动过,只当"没压成功"(issue #201)。
17181770
// 只改 Role=="tool" 消息的 Content;user/assistant 消息、工具调用骨架、ToolCallID 配对一律不动
17191771
// (故 sanitizeToolPairs 安全、发给 API 的配对完整)。
17201772
// 保护:最近 reclaimMinTailToolMsgs 条工具结果 + 最近 reclaimKeepPct 预算内的工具输出,留全。幂等。
17211773
// 聚合下限:整趟能回收的总量不足 reclaimMinTotalPct 窗口就整趟不动 —— 避免为一点点收益击穿前缀缓存。
1722-
func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
1774+
func reclaimToolOutputs(convo []ChatMessage, ctxWin int) (count, freed int) {
17231775
if ctxWin <= 0 {
17241776
ctxWin = 65536
17251777
}
@@ -1768,14 +1820,19 @@ func reclaimToolOutputs(convo []ChatMessage, ctxWin int) bool {
17681820

17691821
// 聚合下限:总回收量不够就整趟不动,不为小收益击穿缓存。
17701822
if reclaimable < ctxWin*reclaimMinTotalPct/100 {
1771-
return false
1823+
return 0, 0
17721824
}
17731825

1774-
// 第二趟:落地回收。
1826+
// 第二趟:落地回收。净回收量 = 原文 - 占位标记(标记本身也占几十 token),按净值报给 UI。
1827+
freed = reclaimable
17751828
for _, i := range victims {
17761829
convo[i].Content = toolOutputReference(convo[i].Name, callPath[convo[i].ToolCallID])
1830+
freed -= MsgTokens(convo[i])
1831+
}
1832+
if freed < 0 {
1833+
freed = 0
17771834
}
1778-
return len(victims) > 0
1835+
return len(victims), freed
17791836
}
17801837

17811838
// toolOutputReference 生成被回收工具结果的引用标记:带工具名与 path(有则),

agent/reclaim_emit_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package agent
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// reclaimPlainSSE 一个只回一句文本、不带工具调用的假端点(收到即结束本轮)。
12+
func reclaimPlainSSE(t *testing.T, text string) string {
13+
return sseServer(t, func(w http.ResponseWriter, flush func(), _ <-chan struct{}) {
14+
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%q}}]}\n\n", text)
15+
flush()
16+
fmt.Fprint(w, "data: [DONE]\n\n")
17+
flush()
18+
})
19+
}
20+
21+
// reclaimBloatedHistory 造"1 个 user 轮 + 一堆大工具输出" —— reclaim 的目标形态(#201)。
22+
func reclaimBloatedHistory(rounds, perTool int) []ChatMessage {
23+
h := []ChatMessage{{Role: "user", Content: "跑单元测试并生成覆盖率报告"}}
24+
body := strings.Repeat("覆盖率数据 line 42 hit; ", perTool)
25+
for i := range rounds {
26+
id := fmt.Sprintf("c%d", i)
27+
h = append(h,
28+
asstCall(id, "Bash", fmt.Sprintf(`{"command":"go test ./pkg%d/"}`, i)),
29+
toolMsg(id, "Bash", body),
30+
)
31+
}
32+
return h
33+
}
34+
35+
// 回收落地后 agent 必须发 ContextReclaimedMsg(带条数、净回收 token、回收后估算),
36+
// 否则 TUI 无从刷新状态栏 / 留痕迹 —— reclaim 又会回到"默默干活、零反馈"(issue #201)。
37+
func TestStartStream_EmitsContextReclaimed(t *testing.T) {
38+
const ctxWin = 20000
39+
hist := reclaimBloatedHistory(30, 120) // 够大,过 70% 触发线,且 reclaim 有得回收
40+
41+
// 本地先跑一遍拿期望值(agent 内部走同一个函数)。
42+
local := append([]ChatMessage(nil), hist...)
43+
wantCount, wantFreed := reclaimToolOutputs(local, ctxWin)
44+
if wantCount == 0 {
45+
t.Fatal("测试数据没能触发回收,调大 reclaimBloatedHistory")
46+
}
47+
48+
url := reclaimPlainSSE(t, "好的")
49+
cfg := ModelConfig{Flash: ModelEntry{
50+
BaseURL: url, Model: "m", APIKey: "k",
51+
ContextWindow: ctxWin, MaxTokens: 256,
52+
}}
53+
_, ch := StartStream(context.Background(), cfg, hist,
54+
AgentMode_Auto, t.TempDir(), "", "", "flash", WorkingModeDefault)
55+
56+
var got []ContextReclaimedMsg
57+
for msg := range ch {
58+
if m, ok := msg.(ContextReclaimedMsg); ok {
59+
got = append(got, m)
60+
}
61+
if e, ok := msg.(StreamErrMsg); ok {
62+
t.Fatalf("假端点不该报错: %v", e.Err)
63+
}
64+
}
65+
66+
if len(got) == 0 {
67+
t.Fatal("历史已过阈值,回收应发生并发 ContextReclaimedMsg")
68+
}
69+
if got[0].Count != wantCount || got[0].Tokens != wantFreed {
70+
t.Errorf("回收消息 = {Count:%d Tokens:%d}, want {Count:%d Tokens:%d}",
71+
got[0].Count, got[0].Tokens, wantCount, wantFreed)
72+
}
73+
// 回收后估算应为正、且明显小于回收前的整段历史(说明真减了负)。
74+
if got[0].EstPromptTokens <= 0 {
75+
t.Errorf("回收后估算应为正, got %d", got[0].EstPromptTokens)
76+
}
77+
if before := EstimateHistoryTokens(hist); got[0].EstPromptTokens >= before {
78+
t.Errorf("回收后估算 %d 应小于回收前历史 %d", got[0].EstPromptTokens, before)
79+
}
80+
}
81+
82+
// 上下文没到阈值:不回收,也不该发 ContextReclaimedMsg。
83+
func TestStartStream_NoReclaimWhenSmall(t *testing.T) {
84+
url := reclaimPlainSSE(t, "好的")
85+
cfg := ModelConfig{Flash: ModelEntry{
86+
BaseURL: url, Model: "m", APIKey: "k",
87+
ContextWindow: 200000, MaxTokens: 256,
88+
}}
89+
_, ch := StartStream(context.Background(), cfg,
90+
[]ChatMessage{{Role: "user", Content: "你好"}},
91+
AgentMode_Auto, t.TempDir(), "", "", "flash", WorkingModeDefault)
92+
93+
for msg := range ch {
94+
if m, ok := msg.(ContextReclaimedMsg); ok {
95+
t.Errorf("小上下文不该触发回收: %+v", m)
96+
}
97+
}
98+
}

agent/reclaim_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func TestReclaim_OldReplacedRecentKept(t *testing.T) {
4646
convo := buildConvo(10, "Read", big)
4747
before := len(convo)
4848

49-
if !reclaimToolOutputs(convo, 4096) { // 预算 = 4096*20% ≈ 819 token
49+
if n, _ := reclaimToolOutputs(convo, 4096); n == 0 { // 预算 = 4096*20% ≈ 819 token
5050
t.Fatal("应发生回收")
5151
}
5252
reclaimed, kept := countTool(convo)
@@ -63,7 +63,7 @@ func TestReclaim_OldReplacedRecentKept(t *testing.T) {
6363

6464
func TestReclaim_TailProtected(t *testing.T) {
6565
convo := buildConvo(reclaimMinTailToolMsgs+3, "Bash", strings.Repeat("x", 5000))
66-
reclaimToolOutputs(convo, 1024) // 预算极小
66+
_, _ = reclaimToolOutputs(convo, 1024) // 预算极小
6767

6868
seen := 0
6969
for i := len(convo) - 1; i >= 0; i-- {
@@ -79,7 +79,7 @@ func TestReclaim_TailProtected(t *testing.T) {
7979

8080
func TestReclaim_ReferenceHasNameAndPath(t *testing.T) {
8181
convo := buildConvo(8, "Read", strings.Repeat("y", 8000))
82-
reclaimToolOutputs(convo, 2048)
82+
_, _ = reclaimToolOutputs(convo, 2048)
8383

8484
found := false
8585
for _, m := range convo {
@@ -98,10 +98,10 @@ func TestReclaim_ReferenceHasNameAndPath(t *testing.T) {
9898

9999
func TestReclaim_Idempotent(t *testing.T) {
100100
convo := buildConvo(8, "Read", strings.Repeat("z", 6000))
101-
if !reclaimToolOutputs(convo, 2048) {
101+
if n, _ := reclaimToolOutputs(convo, 2048); n == 0 {
102102
t.Fatal("首次应有改动")
103103
}
104-
if reclaimToolOutputs(convo, 2048) {
104+
if n, _ := reclaimToolOutputs(convo, 2048); n > 0 {
105105
t.Fatal("再次 reclaim 不应有新改动(幂等)")
106106
}
107107
}
@@ -116,7 +116,7 @@ func TestReclaim_NonToolUntouched(t *testing.T) {
116116
{Role: "assistant", Content: asstBig},
117117
}
118118
before := len(convo)
119-
reclaimToolOutputs(convo, 1024)
119+
_, _ = reclaimToolOutputs(convo, 1024)
120120
if convo[1].Content != userBig {
121121
t.Fatal("user 消息不应被改")
122122
}
@@ -130,7 +130,7 @@ func TestReclaim_NonToolUntouched(t *testing.T) {
130130

131131
func TestReclaim_UnderBudgetNoChange(t *testing.T) {
132132
convo := buildConvo(6, "Read", "tiny") // 工具输出很小
133-
if reclaimToolOutputs(convo, 1_000_000) {
133+
if n, _ := reclaimToolOutputs(convo, 1_000_000); n > 0 {
134134
t.Fatal("预算充足时不应回收")
135135
}
136136
}
@@ -150,7 +150,7 @@ func TestReclaim_SmallOldOutputsKept(t *testing.T) {
150150
convo = append(convo, asstCall(id, "Read", fmt.Sprintf(`{"path":"f%d.go"}`, k)))
151151
convo = append(convo, toolMsg(id, "Read", big))
152152
}
153-
reclaimToolOutputs(convo, 2048) // keepBudget≈409;5 条大 Read 远超,更旧的进入回收判定
153+
_, _ = reclaimToolOutputs(convo, 2048) // keepBudget≈409;5 条大 Read 远超,更旧的进入回收判定
154154

155155
reclaimedRead := false
156156
for _, m := range convo {
@@ -186,7 +186,7 @@ func TestReclaim_BelowTotalFloorNoChange(t *testing.T) {
186186
convo = append(convo, toolMsg(id, "Read", huge))
187187
}
188188

189-
if reclaimToolOutputs(convo, ctxWin) {
189+
if n, _ := reclaimToolOutputs(convo, ctxWin); n > 0 {
190190
t.Fatal("总回收量低于聚合下限时不应回收(护缓存)")
191191
}
192192
for _, m := range convo {
@@ -205,7 +205,7 @@ func TestReclaim_PairingPreserved(t *testing.T) {
205205
ids[i] = m.ToolCallID
206206
}
207207
}
208-
reclaimToolOutputs(convo, 2048)
208+
_, _ = reclaimToolOutputs(convo, 2048)
209209
for i, m := range convo {
210210
if m.Role == "tool" {
211211
if m.ToolCallID != ids[i] {

agent/workflow_tool.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ func handleWorkflowTool(
6363
}
6464
var sb strings.Builder
6565
for _, m := range metas {
66-
fmt.Fprintf(&sb, "- %s (%s):%s\n", m.Name, m.Scope, m.Description)
66+
// 不带 scope 括号:模型会把 " (builtin)" 连同 name 一起抄进调用参数,导致匹配失败
67+
// (同 buildSkillCatalog 的处理)。scope 对模型选 workflow 无决策价值。
68+
fmt.Fprintf(&sb, "- %s: %s\n", m.Name, m.Description)
6769
}
6870
return tools.ToolResult{Output: strings.TrimRight(sb.String(), "\n"), Success: true}
6971

0 commit comments

Comments
 (0)