mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
记忆层: - 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 缺失。
206 lines
6.7 KiB
Go
206 lines
6.7 KiB
Go
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)
|
||
}
|
||
}
|