Files
HomeAgent/internal/memory/coldcontext/page_split_e2e_test.go
JianFeeeee b850feaef2 feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
2026-10-29 23:53:48 +08:00

157 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package coldcontext
// 段页式切块的 e2e 判据(P2,2026-10-29)。
//
// ## 不变量(来自用户裁定)
//
// 段 Frame = 一次完整任务(一条事件),用事件时间戳作帧号
// 页 Page = 一次完整往返:
// · llm_round —— 用户输入 → 模型输出
// · tool_round —— 工具调用 → 工具结果(靠 CallID 配对)
// 铁律:同一个 FrameTS 的块必须整批在同一层,不许分裂
//
// ## 为什么页必须是"配对完整"的最小单位
//
// 渲染时 `assistant(tool_calls)` 后面必须紧跟**配对齐全**的 `tool` 消息,
// 否则上游直接 400(生产事故实证见 task.go:160 记录的错误)。所以
// 淘汰/裁剪的最小单位必须是整页,不能是单块。
import (
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func mkEntry(ts time.Time) ContextEntry {
return ContextEntry{
Timestamp: ts,
Source: "agent",
Content: "帮我看下这台机器的磁盘",
Response: "已查询,根分区使用 62%。",
ToolCalls: []ToolCallItem{
{CallID: "c_disk", Name: "cmd_run", Arguments: map[string]interface{}{"command": "df -h"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_disk", Name: "cmd_run", Output: "/dev/sda1 62% /"},
},
}
}
// C1:一个 toolcall + 它的 result 恰好构成一页;块数 = 轮数 × 2。
func TestPageSplitOneCallOnePage(t *testing.T) {
ts := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC)
blocks := docBlocksForEntry(mkEntry(ts))
// 期望:llm_round 2 块(input/response)+ tool_round 2 块(call/result)
if len(blocks) != 4 {
t.Fatalf("期望 4 块(2 页 × 2 侧),实际 %d: %+v", len(blocks), blocks)
}
byPage := map[int][]memory.MemoryBlock{}
for _, b := range blocks {
byPage[b.PageSeq] = append(byPage[b.PageSeq], b)
}
if len(byPage) != 2 {
t.Fatalf("期望 2 页,实际 %d", len(byPage))
}
// 第 1 页:llm_round,request+response
p1 := byPage[1]
if len(p1) != 2 || p1[0].PageKind != memory.PageLLMRound {
t.Fatalf("第 1 页应是 llm_round 的 2 块: %+v", p1)
}
if p1[0].BlockRole != memory.RoleRequest || p1[1].BlockRole != memory.RoleResponse {
t.Fatalf("第 1 页角色错: %+v", p1)
}
// 第 2 页:tool_round,call+result,且 CallID 配对
p2 := byPage[2]
if len(p2) != 2 || p2[0].PageKind != memory.PageToolRound {
t.Fatalf("第 2 页应是 tool_round 的 2 块: %+v", p2)
}
for _, b := range p2 {
if b.CallID != "c_disk" {
t.Fatalf("工具页块缺 CallID(配对键): %+v", b)
}
}
if !strings.Contains(p2[0].Text, "df -h") {
t.Fatalf("★ 工具页 call 侧没有参数(这正是要还的技术债): %q", p2[0].Text)
}
if !strings.Contains(p2[1].Text, "62%") {
t.Fatalf("工具页 result 侧内容错: %q", p2[1].Text)
}
}
// C7 铁律:同帧所有块共享同一 FrameTS,不许分裂。
func TestPageBlocksShareFrameTS(t *testing.T) {
ts := time.Date(2026, 10, 29, 13, 30, 0, 0, time.UTC)
blocks := docBlocksForEntry(mkEntry(ts))
if len(blocks) == 0 {
t.Fatal("未派生任何块")
}
for _, b := range blocks {
if !b.FrameTS.Equal(ts) {
t.Fatalf("★ 块 %s 的 FrameTS=%v 与本帧 %v 不一致 —— 违反「块不跨帧」",
b.ID, b.FrameTS, ts)
}
}
}
// 并行工具调用:各自独立成页,靠 CallID 配对(不能都配到同一个结果)。
func TestPageSplitParallelCalls(t *testing.T) {
ts := time.Date(2026, 10, 29, 14, 0, 0, 0, time.UTC)
e := ContextEntry{
Timestamp: ts, Source: "agent",
Content: "同时查内存和网络", Response: "都查了。",
ToolCalls: []ToolCallItem{
{CallID: "c_mem", Name: "sys_info", Arguments: map[string]interface{}{"kind": "mem"}},
{CallID: "c_net", Name: "sys_info", Arguments: map[string]interface{}{"kind": "net"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_mem", Name: "sys_info", Output: "内存 3.2G/8G"},
{CallID: "c_net", Name: "sys_info", Output: "网络 192.168.1.7"},
},
}
blocks := docBlocksForEntry(e)
// 页序:1=llm_round, 2=c_mem, 3=c_net
byCall := map[string][]memory.MemoryBlock{}
for _, b := range blocks {
if b.PageKind == memory.PageToolRound {
byCall[b.CallID] = append(byCall[b.CallID], b)
}
}
if len(byCall) != 2 {
t.Fatalf("★ 并行调用未各自成页: %+v", byCall)
}
if !strings.Contains(byCall["c_mem"][1].Text, "3.2G") {
t.Fatalf("★ c_mem 配错结果: %q", byCall["c_mem"][1].Text)
}
if !strings.Contains(byCall["c_net"][1].Text, "192.168") {
t.Fatalf("★ c_net 配错结果: %q", byCall["c_net"][1].Text)
}
// 页序必须不同(否则两页挤在一起,渲染时配对歧义)
if byCall["c_mem"][0].PageSeq == byCall["c_net"][0].PageSeq {
t.Fatal("★ 并行调用的页序不应相同")
}
}
// 块 ID 必须幂等:同一事件重复派生得到同一批 ID(否则归档会不断增生)。
func TestPageBlockIDsAreIdempotent(t *testing.T) {
ts := time.Date(2026, 10, 29, 15, 0, 0, 0, time.UTC)
a := docBlocksForEntry(mkEntry(ts))
b := docBlocksForEntry(mkEntry(ts))
if len(a) != len(b) {
t.Fatalf("两次派生块数不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].ID != b[i].ID {
t.Fatalf("★ 块 ID 不幂等(归档会增生): %q vs %q", a[i].ID, b[i].ID)
}
}
}