diff --git a/deps/agent-native b/deps/agent-native index ead690b..64b8bd4 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit ead690be3049cc51bf775cd0dbc527d057d11343 +Subproject commit 64b8bd4c980d018470be943c74cdb5aa9eaf0db4 diff --git a/deps/agent-squad b/deps/agent-squad index 029edc0..56941bf 160000 --- a/deps/agent-squad +++ b/deps/agent-squad @@ -1 +1 @@ -Subproject commit 029edc08e7bec58b2909847b2db3ac914489b148 +Subproject commit 56941bf8ea5934ba28fc274af7e7cec63e604726 diff --git a/internal/ctxasm/finish.go b/internal/ctxasm/finish.go index ad4c17f..c33c13b 100644 --- a/internal/ctxasm/finish.go +++ b/internal/ctxasm/finish.go @@ -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 { diff --git a/internal/ctxasm/finish_test.go b/internal/ctxasm/finish_test.go index 4c8ceb1..d417cbf 100644 --- a/internal/ctxasm/finish_test.go +++ b/internal/ctxasm/finish_test.go @@ -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("前缀变化后哈希必须不同") + } +}