Align context trimming to tool groups and take the N2 WP1 pins
ci / vet-and-test (pull_request) Successful in 44s

Why: trimOldest dropped single messages without tool-pair awareness —
after leading users were evicted it could orphan tool results or leave
dangling calls (acceptance clause 2's product-side gap).
Evidence: go test -count=1 ./... green; TestTrimOldestKeepsToolPairs
(pairing holds across repeated trims), TestTrimOldestDropsLeadingOrphanToolRun,
TestTrimOldestStopsWhenGroupCrossesCurrentTurn, TestPrefixHashStableForIdenticalInput;
native pins carry the spill and cache-contract work (Agent-Native#25).
Impact: trimming now evicts whole tool groups; the window may be
slightly exceeded when a group would cross the current turn — pairing
is never broken. Report.Truncations still records the count.
Refs: Agent-Native#25, Agent-Squad#29, N2 plan WP1
This commit is contained in:
2026-10-07 13:11:47 +00:00
parent 68addfda05
commit 7acbefd60d
4 changed files with 147 additions and 10 deletions
+30 -8
View File
@@ -207,12 +207,12 @@ func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []age
}
dropped := 0
for estimateAll(msgs) > limit {
idx := oldestDrop(msgs)
if idx < 0 {
start, end := oldestDropRange(msgs)
if start < 0 {
break
}
msgs = append(msgs[:idx], msgs[idx+1:]...)
dropped++
dropped += end - start
msgs = append(msgs[:start], msgs[end:]...)
}
if dropped > 0 {
rep.Truncations = append(rep.Truncations, fmtDropped(dropped))
@@ -226,9 +226,14 @@ func fmtDropped(n int) string {
func itoa(n int) string { return strconv.Itoa(n) }
func oldestDrop(msgs []agentllm.Message) int {
// oldestDropRange 返回下一次丢弃的区间 [start, end)(找不到返回 -1,-1)。
// 丢弃按工具组对齐:带 tool_calls 的 assistant 与其连续 tool 结果同退,
// 开头的孤立 tool 结果组整组同退——裁切后既不剩无结果的调用,
// 也不剩无调用的结果(与库层 alignToolTail 同语义、方向相反)。
// 组越过最后一条(当前轮)时不再丢弃:窗口允许略超,配对不可破坏。
func oldestDropRange(msgs []agentllm.Message) (int, int) {
if len(msgs) < 3 {
return -1
return -1, -1
}
// 保留开头的系统前缀和最后一条(当前轮)。
start := 0
@@ -236,9 +241,26 @@ func oldestDrop(msgs []agentllm.Message) int {
start++
}
if start >= len(msgs)-1 {
return -1
return -1, -1
}
return start
end := start + 1
m := msgs[start]
switch {
case m.Role == agentllm.RoleAssistant && len(m.ToolCalls) > 0:
// assistant 及其连续 tool 结果整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
case m.Role == agentllm.RoleTool:
// 开头即孤立 tool 结果(其 assistant 必已被丢或不存在):整组同退。
for end < len(msgs) && msgs[end].Role == agentllm.RoleTool {
end++
}
}
if end > len(msgs)-1 {
return -1, -1
}
return start, end
}
func estimateAll(msgs []agentllm.Message) int {
+115
View File
@@ -62,3 +62,118 @@ func TestTrimDropsOldestHistory(t *testing.T) {
t.Fatal("expected truncation")
}
}
// ---- N2 WP1:裁切工具组对齐(验收② 产品侧机器证据)----
// assertNoOrphanToolPairs 双向断言:每个 tool_call 有结果,每个结果有调用。
func assertNoOrphanToolPairs(t *testing.T, msgs []agentllm.Message) {
t.Helper()
calls := map[string]bool{}
results := map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = true
}
if m.Role == agentllm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s(无结果)", id)
}
}
for id := range results {
if !calls[id] {
t.Fatalf("悬空 tool 结果:%s(无调用)", id)
}
}
}
func toolMsgs() []agentllm.Message {
call := func(id, name string) agentllm.ToolCall {
return agentllm.ToolCall{ID: id, Name: name, Arguments: "{}"}
}
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧一", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc1", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc1", Name: "read", Content: strings.Repeat("结果一", 200)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧二", 300)},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{call("tc2", "grep"), call("tc3", "read")}},
{Role: agentllm.RoleTool, ToolCallID: "tc2", Name: "grep", Content: strings.Repeat("结果二", 200)},
{Role: agentllm.RoleTool, ToolCallID: "tc3", Name: "read", Content: strings.Repeat("结果三", 200)},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
// 连续多轮裁切后配对仍成立:assistant 与其结果同退,不逐条拆散。
func TestTrimOldestKeepsToolPairs(t *testing.T) {
out, rep := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 1600})
assertNoOrphanToolPairs(t, out)
if len(rep.Truncations) == 0 {
t.Fatal("expected truncation")
}
if out[len(out)-1].Content != "现在" {
t.Fatal("当前轮必须保留")
}
// 窗口再压:旧二组也须整组同退,仍不出现孤立配对。
out2, _ := FinishAssembly(toolMsgs(), &Report{}, Tail{Window: 900})
assertNoOrphanToolPairs(t, out2)
}
// 开头即孤立 tool 结果组:整组同退(其 assistant 已不存在)。
func TestTrimOldestDropsLeadingOrphanToolRun(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleTool, ToolCallID: "ghost", Name: "read", Content: strings.Repeat("孤", 500)},
{Role: agentllm.RoleUser, Content: strings.Repeat("旧", 500)},
{Role: agentllm.RoleUser, Content: "现在"},
}
out, _ := FinishAssembly(msgs, &Report{}, Tail{Window: 700})
assertNoOrphanToolPairs(t, out)
for _, m := range out {
if m.ToolCallID == "ghost" {
t.Fatal("孤立结果组应整组丢弃")
}
}
}
// 工具组越过当前轮时不再丢弃:窗口可略超,配对不可破坏。
func TestTrimOldestStopsWhenGroupCrossesCurrentTurn(t *testing.T) {
msgs := []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "s"},
{Role: agentllm.RoleAssistant, ToolCalls: []agentllm.ToolCall{{ID: "tc9", Name: "read", Arguments: "{}"}}},
{Role: agentllm.RoleTool, ToolCallID: "tc9", Name: "read", Content: strings.Repeat("结果", 900)},
}
start, end := oldestDropRange(msgs)
if start != -1 || end != -1 {
t.Fatalf("组越过当前轮应返回 -1,-1,得到 %d,%d", start, end)
}
}
// ---- N2 WP1:PrefixHash 稳定契约(同一输入 → 同一前缀哈希)----
func TestPrefixHashStableForIdenticalInput(t *testing.T) {
build := func() []agentllm.Message {
return []agentllm.Message{
{Role: agentllm.RoleSystem, Content: "系统前缀A"},
{Role: agentllm.RoleSystem, Content: "系统前缀B"},
{Role: agentllm.RoleUser, Content: "历史一"},
{Role: agentllm.RoleAssistant, Content: "回复一"},
{Role: agentllm.RoleUser, Content: "现在"},
}
}
_, rep1 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
_, rep2 := FinishAssembly(build(), &Report{}, Tail{Instructions: "个人指令"})
if rep1.PrefixHash == "" || rep1.PrefixHash != rep2.PrefixHash {
t.Fatalf("同一输入的前缀哈希必须一致:%q vs %q", rep1.PrefixHash, rep2.PrefixHash)
}
// 前缀变化必须改变哈希(钉住哈希语义,防退化为常量)。
other := build()
other[0].Content = "系统前缀X"
_, rep3 := FinishAssembly(other, &Report{}, Tail{Instructions: "个人指令"})
if rep3.PrefixHash == rep1.PrefixHash {
t.Fatal("前缀变化后哈希必须不同")
}
}