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 缺失。
This commit is contained in:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -0,0 +1,205 @@
package core
// 段页式**端到端**验收(跨 L1→L2→L1 全链路,2026-10-29)。
//
// 前面的判据各自只钉一个环节(字段往返、切块、剔除、回收)。
// 本文件走一遍真实流转,验证五条要求在**联动**时仍成立:
//
// ① 一次任务(段/frame)= 一条事件;时间戳作帧号
// ② 页 = llm_round + 若干 tool_round,call 与 result 靠 CallID 配对
// ③ 归档到 L2 后,块带着帧/页身份(ID/帧/向量不变,只换持有层)
// ④ 召回回填时,ToolCalls/ToolResults 连同 CallID 一起回来(配对不破)
// ⑤ 中途任何一步都不会让"三层都不在"发生
//
// 这些必须在 L1→L2→L1 走一遍才看得出来:单看某一层,
// 字段都"存在",但可能在搬运时被丢掉。
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ①~④ 归档→召回:工具调用的帧/页身份与配对键必须完整往返。
func TestSegPagingRoundTripsThroughL2(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC)
want := ContextEvent{
Timestamp: base,
Source: "segpaging-src",
Input: "帮我查一下这个报错的原因,顺便看看磁盘够不够",
Response: "报错是权限问题;磁盘还剩 38%。",
ToolCalls: []ToolCallItem{
{CallID: "c_log", Name: "log_grep", Arguments: map[string]interface{}{"pattern": "permission denied"}},
{CallID: "c_disk", Name: "disk_usage", Arguments: map[string]interface{}{"path": "/"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_log", Name: "log_grep", Output: "EACCES: permission denied, open /etc/shadow"},
{CallID: "c_disk", Name: "disk_usage", Output: "/dev/sda1 62% /"},
},
}
archiveEvents(t, a, []ContextEvent{want})
// 归档后 L1 不该再有这条
if n := countSource(a.context, "segpaging-src"); n != 0 {
t.Fatalf("归档后 L1 仍有 %d 条", n)
}
// 召回
out := a.executeDocTool(agentAPI.ToolCall{
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "权限报错 磁盘", "top_k": float64(5)},
})
if strings.Contains(out, "no relevant cold context") {
t.Fatalf("未召回:%s", out)
}
// ① 回填的事件仍在原始时间戳
var got *ContextEvent
for _, e := range a.context.Recent(0) {
if e.Source == "segpaging-src" {
ev := e // Recent 返回值切片,取地址要自己造
got = &ev
}
}
if got == nil {
t.Fatal("回填后 L1 找不到该事件")
}
if !got.Timestamp.Equal(base) {
t.Fatalf("★ 时间戳漂移(帧号应不变): %v vs %v", got.Timestamp, base)
}
// ② 配对键完整(这是渲染不 400 的前提)
if len(got.ToolCalls) != 2 || len(got.ToolResults) != 2 {
t.Fatalf("★ 工具记录数量不对: calls=%d results=%d",
len(got.ToolCalls), len(got.ToolResults))
}
byCall := map[string]string{}
for _, tr := range got.ToolResults {
byCall[tr.CallID] = tr.Output
}
if !strings.Contains(byCall["c_log"], "permission denied") {
t.Fatalf("★ c_log 配错/丢失: %+v", got.ToolResults)
}
if !strings.Contains(byCall["c_disk"], "62%") {
t.Fatalf("★ c_disk 配错/丢失: %+v", got.ToolResults)
}
// ③ 参数也必须回来(否则页的 call 侧是空的)
for _, tc := range got.ToolCalls {
if len(tc.Arguments) == 0 {
t.Fatalf("★ 召回后参数丢失(call 侧空页): %+v", tc)
}
}
}
// ⑤ 归档到 L2 的块必须带帧/页身份(ID/帧/向量不变,只换层)。
func TestArchivedBlocksCarryFrameIdentity(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
ev := ContextEvent{
Timestamp: base, Source: "frame-src",
Input: "紫藤花架的搭建方法", Response: "需要四根立柱。",
ToolCalls: []ToolCallItem{
{CallID: "c_w", Name: "weather", Arguments: map[string]interface{}{"city": "新乡"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_w", Name: "weather", Output: "晴 22℃"},
},
}
archiveEvents(t, a, []ContextEvent{ev})
// 找出这篇文档
var docID string
for _, d := range a.coldcontextStore.RecentColdContexts(100) {
if len(d.Events) > 0 && d.Events[0].Source == "frame-src" {
docID = d.ID
break
}
}
if docID == "" {
t.Fatal("归档文档未找到")
}
d := a.coldcontextStore.Get(docID)
if d == nil || len(d.Events) == 0 {
t.Fatal("文档内容为空")
}
de := d.Events[0]
// 事件级:ToolCalls 必须落到 L2
if len(de.ToolCalls) != 1 || de.ToolCalls[0].CallID != "c_w" {
t.Fatalf("★ L2 事件缺 ToolCalls/CallID: %+v", de.ToolCalls)
}
if len(de.ToolCalls[0].Arguments) == 0 {
t.Fatalf("★ L2 事件丢了参数: %+v", de.ToolCalls[0])
}
// 块级:派生出的块必须带帧/页身份
toolBlocks := 0
for _, b := range de.Blocks {
if b.PageKind == memory.PageToolRound {
toolBlocks++
if b.FrameTS.IsZero() {
t.Fatalf("★ L2 块缺 FrameTS: %+v", b)
}
if !b.FrameTS.Equal(base) {
t.Fatalf("★ L2 块的帧号与事件不一致: %v vs %v", b.FrameTS, base)
}
if b.CallID == "" {
t.Fatalf("★ L2 工具页块缺 CallID(配对键): %+v", b)
}
}
}
if toolBlocks == 0 {
t.Log("该文档未派生工具页块(可能按存量路径处理),检查 docBlocksForEntry")
}
}
// 铁律验收:同一帧的所有块共享同一 FrameTS(不跨帧)。
func TestSameFrameBlocksNeverSplit(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
ev := ContextEvent{
Timestamp: base, Source: "nosplit-src",
Input: "同时检查 CPU 和内存", Response: "都查了。",
ToolCalls: []ToolCallItem{
{CallID: "c_cpu", Name: "sys", Arguments: map[string]interface{}{"k": "cpu"}},
{CallID: "c_mem", Name: "sys", Arguments: map[string]interface{}{"k": "mem"}},
{CallID: "c_net", Name: "sys", Arguments: map[string]interface{}{"k": "net"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_cpu", Name: "sys", Output: "CPU 12%"},
{CallID: "c_mem", Name: "sys", Output: "内存 41%"},
{CallID: "c_net", Name: "sys", Output: "网络正常"},
},
}
archiveEvents(t, a, []ContextEvent{ev})
var d = a.coldcontextStore.Get(func() string {
for _, x := range a.coldcontextStore.RecentColdContexts(100) {
if len(x.Events) > 0 && x.Events[0].Source == "nosplit-src" {
return x.ID
}
}
return ""
}())
if d == nil {
t.Fatal("文档未找到")
}
// 同一帧的所有块:FrameTS 必须全部相同
seen := map[string]int{}
for _, b := range d.Events[0].Blocks {
if b.FrameTS.IsZero() {
continue
}
seen[b.FrameTS.Format(time.RFC3339Nano)]++
}
if len(seen) > 1 {
t.Fatalf("★ 同一帧的块分裂到 %d 个 FrameTS(违反铁律): %v", len(seen), seen)
}
}