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

@ -13,7 +13,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
@ -69,7 +69,7 @@ type Agent struct {
cancel context.CancelFunc
// 文档记忆(第二层)
docStore *document.Store
coldcontextStore *coldcontext.Store
// 知识库
knowledge *knowledge.Store
@ -272,7 +272,7 @@ type AgentConfig struct {
Indexer *memory.Indexer
Tracker *tracker.Tracker
DocStore *document.Store
DocStore *coldcontext.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
@ -412,27 +412,27 @@ func New(cfg AgentConfig) *Agent {
}
a := &Agent{
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
graph: graphMemoryOf(cfg),
kernelSource: cfg.KernelSource,
parentID: cfg.ParentID,
taskPrompt: cfg.TaskPrompt,
dataDir: cfg.DataDir,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
docStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
graph: graphMemoryOf(cfg),
kernelSource: cfg.KernelSource,
parentID: cfg.ParentID,
taskPrompt: cfg.TaskPrompt,
dataDir: cfg.DataDir,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
coldcontextStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
personality: cfg.Personality,

View File

@ -3,7 +3,7 @@ package core
import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
func TestEntitySimilarity(t *testing.T) {
@ -26,13 +26,13 @@ func TestEntitySimilarity(t *testing.T) {
}
func TestDocToTriples(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "用户喜欢编程",
Content: "用户提到喜欢Go和Python",
Source: "context",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
foundSummary := false
foundSource := false
@ -54,18 +54,18 @@ func TestDocToTriples(t *testing.T) {
}
func TestDocToTriplesNil(t *testing.T) {
triples := docToTriples(nil, nil)
triples := coldDocToTriples(nil, nil)
if len(triples) != 0 {
t.Errorf("expected empty for nil doc, got %d", len(triples))
}
}
func TestDocToTriplesNoSource(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "无来源文档",
Content: "content",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if tr.Relation == "来源" {
t.Error("should not have source triple when Source is empty")
@ -74,13 +74,13 @@ func TestDocToTriplesNoSource(t *testing.T) {
}
func TestDocToTriplesTypes(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "测试三元组类型",
Content: "用于验证 SubjectType 和 ObjectType",
Source: "test",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if tr.Subject == "文档" {

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
func needJieba() bool {
@ -12,12 +12,12 @@ func needJieba() bool {
}
func TestDocToTriplesEmpty(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "empty doc",
Content: "",
Source: "test",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
if len(triples) < 2 {
t.Fatalf("expected at least 2 triples (主题+来源), got %d", len(triples))
}
@ -33,12 +33,12 @@ func TestDocToTriplesEmpty(t *testing.T) {
}
func TestDocToTriplesConversation(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "测试对话 (qq) 涉及: 天气",
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
Source: "qq",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
if len(triples) < 2 {
t.Errorf("expected at least 2 triples (主题+来源), got %d", len(triples))
@ -55,12 +55,12 @@ func TestDocToTriplesConversation(t *testing.T) {
}
func TestDocToTriplesMultiLine(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "多轮对话",
Content: "[10:00] user: 你好\n[10:01] agent: 你好,有什么可以帮助你的\n[10:02] user: 今天天气如何\n[10:03] agent: 今天天气很好",
Source: "qq",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
if len(triples) < 2 {
t.Fatalf("expected at least 2 triples, got %d", len(triples))
}
@ -75,12 +75,12 @@ func TestDocToTriplesMultiLine(t *testing.T) {
}
func TestDocToTriplesEmptyContent(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "空内容",
Content: "",
Source: "test",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
if len(triples) != 2 {
t.Fatalf("expected exactly 2 triples (主题+来源) for empty content, got %d", len(triples))
}
@ -88,13 +88,13 @@ func TestDocToTriplesEmptyContent(t *testing.T) {
// Phase 2: 归档上下文文档不得产出模板垃圾(context_archived 来源/主题模板三元组)
func TestDocToTriplesArchivedContext(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气, 测试",
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
Source: "context_archived",
Meta: map[string]string{"is_archived_context": "true"},
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "context_archived" {
@ -108,12 +108,12 @@ func TestDocToTriplesArchivedContext(t *testing.T) {
// Phase 2: 模板化摘要(summarizeEntries 生成)不得作为主题写入
func TestDocToTriplesTemplateSummary(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "来自 3 个来源的 10 条对话 (a, b, c) 涉及: 关键词1, 关键词2, 关键词3",
Content: "[10:00] a: 你好",
Source: "manual",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if tr.Subject == "文档" && tr.Relation == "主题" {
@ -138,12 +138,12 @@ func TestDocToTriplesLongSummary(t *testing.T) {
for i := 0; i < 100; i++ {
long += "很长的摘要内容片段重复拼接"
}
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: long,
Content: "[10:00] a: 你好",
Source: "test",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if tr.Subject == "文档" && tr.Relation == "主题" {
t.Errorf("overlong summary must not be written as 主题 triple")
@ -244,16 +244,16 @@ func TestGetFloatInt(t *testing.T) {
// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类
// 模板回声,以及 context_archived 这个内部标记。
func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道",
Content: "",
Source: "context_archived",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
for _, tr := range triples {
if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) {
t.Errorf("docToTriples 漏出噪音实体: %+v", tr)
t.Errorf("coldDocToTriples 漏出噪音实体: %+v", tr)
}
}
@ -270,12 +270,12 @@ func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。
func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) {
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: "多轮对话",
Content: "",
Source: "qq",
}
triples := docToTriples(doc, nil)
triples := coldDocToTriples(doc, nil)
var hasTopic, hasSource bool
for _, tr := range triples {

View File

@ -119,7 +119,7 @@ func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
name: "screen",
caps: agentIO.CapText,
})
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
a := &Agent{io: io, knowledge: nil, coldcontextStore: nil, pluginReg: nil}
tools := a.buildToolDefs()
foundSend := false

View File

@ -375,7 +375,7 @@ func TestToolParallelDeclarationsAudit(t *testing.T) {
"plugin_install", "plugin_remove", "plugin_restart",
"terminal_create", "terminal_write", "terminal_close",
"timer_set",
"memory_merge", "memory_delete_entity", "knowledge_create",
"stablemem_merge", "stablemem_delete_entity", "knowledge_create",
}
for _, n := range serialOnly {
if a.toolParallelSafe(n) {

View File

@ -1,8 +1,6 @@
package core
import (
"strings"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
@ -122,16 +120,11 @@ func (a *Agent) installBuiltinProvider() {
sdk.SetBuiltinProvider(builtinProvider{a: a})
}
// isBuiltinToolName 粗判某名字是否可能是内置工具(供提示词/文档用)。
// 真正的判定以 ToolAPI 查询为准(带门控)。
func isBuiltinToolName(name string) bool {
for _, p := range []string{
"memory_", "knowledge_", "doc_", "person_",
"output_", "input_", "resident_", "notify_parent", "persona_set",
} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// ★ 已删除 isBuiltinToolName(2026-10-29)。
//
// 它自称"供提示词/文档用",实测**零生产调用方**,是 Dispatch 之外
// 又一份手抄前缀表。改名时改了 Dispatch(stablemem_)却漏了这里,
// 于是它成了"看起来是事实来源、实际只是过期副本"的第三种状态。
//
// 真需要判"是不是内置工具"时调 toolroute.Dispatch:它返回的 bool
// 就是权威答案(handled=false 表示应继续走插件/设备路径)。

View File

@ -61,7 +61,7 @@ func TestBuiltinToolGatingRespected(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider()) // 无 memory / knowledge
api := toolAPIOf(t, a)
if api.ToolDefByName("memory_merge") != nil {
if api.ToolDefByName("stablemem_merge") != nil {
t.Error("未接 memory 却声称有 memory_merge —— 门控语义被破坏")
}
if api.ToolDefByName("knowledge_list") != nil {

View File

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log"
"math"
"os"
"path/filepath"
"sort"
@ -13,16 +14,53 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// ToolResultItem 是一条工具调用结果。
//
// ★ 2026-10-29 补 CallID:此前只有 Name + Output,于是**同名工具的多次调用
// 无法配对**(两个 qq_get_history 分不清谁是谁),而配对是渲染正确性的前提:
// assistant(tool_calls=[c1..cn]) 后面必须紧跟配对齐全的 tool 消息,
// 否则上游直接 400(insufficient tool messages)。
//
// 生产数据实证(改动前):tool_results 的键只有 ["name","output"],
// 参数完全没有留存 —— 这意味着回灌后无法重建"当时到底调了什么"。
type ToolResultItem struct {
// CallID 回指 ToolCall.ID,是 tool_call 与 tool_result 的配对键。
//
// 空值表示旧数据(未记录 CallID),渲染时按位置配对(既有行为)。
CallID string `json:"call_id,omitempty"`
Name string `json:"name"`
Output string `json:"output"`
}
// ToolCallItem 是一次工具调用的**完整记录**(含参数)。
//
// ★ 为什么必须记录参数(2026-10-29 还技术债)
//
// 此前事件里只有 `ToolsUsed []string`(工具名列表)。后果:
//
// · 回灌后无法知道"当时用**什么参数**调用的",只能看到结果
// · 页化(段页式)无法实现 —— 「toolcall 与其 result 配对成一页」
// 要求 call 侧有完整内容,只有名字的话那页是半空的
// · 审计/复盘时"这个工具为什么返回这个"无从追溯
//
// 参数本身是模型给出的,正常是别名(sec_xxx),但仍可能被直接传明文
// (插件工具),所以落盘前必须过 scrubEventSecrets(与 Input/Response 同一条闸)。
type ToolCallItem struct {
// CallID 是本次调用的唯一标识(来自 provider,缺失时内核补一个)。
CallID string `json:"call_id,omitempty"`
Name string `json:"name"`
// Arguments 是解析后的参数。
Arguments map[string]interface{} `json:"arguments,omitempty"`
// RawArguments 是模型给出的原始 JSON 字符串,保留它是为了诊断
// "参数解析失败/被截断"这类问题(Arguments 无法表达残缺)。
RawArguments string `json:"raw_arguments,omitempty"`
}
type ContextEvent struct {
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
//
@ -36,6 +74,39 @@ type ContextEvent struct {
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。
//
// 与 ToolsUsed 的关系:ToolsUsed 是「用过的工具名」的便捷视图(保留是为了
// 存量读取与统计),ToolCalls 才是配对与页化所需的事实源。
// 两者都由 appendTurnToolCall 同步维护,不手写其中一份。
ToolCalls []ToolCallItem `json:"tool_calls,omitempty"`
// InactivePages 是**本次渲染**中不参与渲染的页序号。
//
// ★ 它是渲染期状态,不是记忆属性(用户裁定,2026-10-29):
//
// "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥?
// 甚至可以说 inactive 是每次 prune 后被重置的"
//
// 因此:
// · **不持久化**(json:"-")—— 重启后由本轮渲染重新判定
// · **不迁移到 L2** —— 淘汰(归档)意味着整帧离开 L1,
// 剔除本来就是"在有限窗口里少渲染几页"的临时手段,
// 帧都走了再记着"它当时少渲染了哪页"毫无意义
// · **每次 Prune 后清空** —— 新一轮窗口重新规划,旧标记失效
//
// 语义:剔除 = 标注,不是删除。数据仍在(ToolCalls/ToolResults/Blocks),
// 只是不渲染成消息。清空本列表即恢复渲染。
//
// 为什么最小单位是页而不是块:渲染时 `assistant(tool_calls)` 后面必须
// 紧跟**配对齐全**的 `tool` 消息,否则上游直接 400(task.go:160 有生产事故
// 记录)。所以只能整页剔除 —— 剔掉 call 却留下 result 就是造一个必崩的帧。
//
// 页序约定(与 coldcontext 的 DocEvent 分页一致):
// 1 = llm_round(用户输入 ↔ 模型输出)
// 2..n+1 = tool_round(第 i 个 ToolCalls ↔ 配对结果)
InactivePages []int `json:"-"`
// ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。
//
// ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**,
@ -165,6 +236,480 @@ func (a *Agent) singleEventFitsBudget() bool {
return avg <= a.computeTokenBudget().ContextTokens
}
// ──────────────────────────────────────────────
// 段页式淘汰(P5,2026-10-29)
// ──────────────────────────────────────────────
// pageSimilarity 计算某一页与本轮输入的相似度。
//
// 页的相似度取**页内各侧的最大值**:call 侧里往往带着关键实体
// (文件路径、命令、代码),而 result 侧是回显 —— 只看一侧会漏。
func (c *RelevanceContext) pageSimilarity(e *ContextEvent, page int,
queryDense []float64, queryFP string, queryVec vector.Vector) float64 {
callText, resText := c.pageTexts(e, page)
best := math.Inf(-1)
score := func(text string) {
if strings.TrimSpace(text) == "" {
return
}
// 与事件评分同构:同一统一空间内才比稠密余弦。
if len(queryDense) > 0 && queryFP != "" && e.DenseFP == queryFP {
if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) {
if s := vector.DenseCosine(queryDense, v); s > best {
best = s
}
return
}
}
if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best {
best = s
}
}
score(callText)
score(resText)
if math.IsInf(best, -1) {
return 0
}
return best
}
// vectorForText 尝试为任意文本取稠密向量(缓存优先)。
//
// 页内文本(工具参数/结果)不是一等块,没有预存向量;
// 这里按需算,失败就返回 false 让调用方退回稀疏余弦。
func (c *RelevanceContext) vectorForText(text string) ([]float64, bool) {
if c.denseSpace == nil || !c.denseSpace.Loaded() {
return nil, false
}
v, err := c.denseSpace.VectorizeDense(text)
if err != nil || len(v) == 0 {
return nil, false
}
return v, true
}
// pageTexts 取某页的 call 侧与 result 侧正文(与 docBlocksForEntry 同构)。
func (c *RelevanceContext) pageTexts(e *ContextEvent, page int) (call, result string) {
if e == nil {
return "", ""
}
if page == 1 {
return e.Input, e.Response
}
idx := page - 2 // 第 1 页是 llm_round
if idx < 0 || idx >= len(e.ToolCalls) {
return "", ""
}
tc := &e.ToolCalls[idx]
call = tc.Name
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
call = tc.Name + " " + string(b)
}
} else if tc.RawArguments != "" {
call = tc.Name + " " + tc.RawArguments
}
// 配对结果
if tc.CallID != "" {
for i := range e.ToolResults {
if e.ToolResults[i].CallID == tc.CallID {
return call, e.ToolResults[i].Output
}
}
}
for i := range e.ToolResults {
if e.ToolResults[i].Name == tc.Name {
return call, e.ToolResults[i].Output
}
}
return call, ""
}
// PageCount 返回本事件的页数(1 个 llm_round + N 个 tool_round)。
func (c *RelevanceContext) PageCount(e *ContextEvent) int {
if e == nil {
return 0
}
return 1 + len(e.ToolCalls)
}
// evictPagesLocked 在单帧超预算时,按相似度剔除页(标注 inactive,不删数据)。
//
// ⚠️ 调用方**必须已持 c.mu**(它内部会 save/computeVector)。
//
// ## 用户裁定的规则
//
// 召回时对段内每个页计算相似度,单条段超限时,
// 按相似度"剔除"相似度最低的页(toolcall 与其 result),
// 实际标注为 inactive,不渲染为消息
//
// ## 三条不能违反的约束
//
// 1. **最小单位是页**:call 与 result 同进同出。只剔一侧会让渲染破配对,
// 上游立刻 400(这不是洁癖,是硬协议要求)。
// 2. **llm_round 永不剔除**:第 1 页是"用户说了什么 + 模型答了什么"。
// 剔掉它,这一帧就只剩工具流水,模型无法理解上下文在讲什么。
// 3. **剔除是标注**:数据留在原位,可随时恢复(InactivePages 清空即恢复)。
//
// 返回本次新剔除的页数。
func (c *RelevanceContext) evictPagesLocked(e *ContextEvent, budgetTokens int, query string) int {
if e == nil || budgetTokens <= 0 || query == "" {
return 0
}
evicted := 0
for {
total := eventTokens(*e)
if total <= budgetTokens {
break
}
// 候选:所有仍生效的工具页(第 1 页受保护)
type cand struct {
page int
score float64
}
var cands []cand
for p := 2; p <= c.PageCount(e); p++ {
if containsInt(e.InactivePages, p) {
continue
}
cands = append(cands, cand{p, 0})
}
if len(cands) == 0 {
// 只剩 llm_round 仍超限 —— 不能靠剔页解决,
// 交给上层(按帧归档 / 超页恢复)。这里明确停手,不空转。
break
}
queryDense, queryFP := c.queryDenseFor(query)
queryVec := c.embedder.VectorizeClean(query)
for i := range cands {
cands[i].score = c.pageSimilarity(e, cands[i].page, queryDense, queryFP, queryVec)
}
sort.Slice(cands, func(i, j int) bool { return cands[i].score < cands[j].score })
victim := cands[0].page
e.InactivePages = append(e.InactivePages, victim)
sort.Ints(e.InactivePages)
evicted++
log.Printf("[context] frame %s over budget (%d > %d): evicted page %d "+
"(score=%.3f, %d pages remain active)",
e.ID, total, budgetTokens, victim, cands[0].score, c.PageCount(e)-len(e.InactivePages))
}
if evicted > 0 {
// 剔除改变了这一帧的渲染内容 ⇒ 向量必须重算。
// 不重算的话,下一轮召回仍按"整帧含被剔页"的向量打分,
// 剔除等于没生效(相似度还带着已隐藏内容的分量)。
c.computeVector(e)
c.save()
}
return evicted
}
// ──────────────────────────────────────────────
// 召回期的「先估算、再剔除」(用户裁定,2026-10-29)
// ──────────────────────────────────────────────
// estimateColdEventTokens 估算一条 coldcontext(L2)记录回灌成事件后占多少 token。
//
// ★ 为什么按"渲染后"算而不是按 JSON 体积:
// 回灌进 L1 的是**渲染出来的消息**(用户行 + 助手字 + 展开的工具行),
// 而 L2 的存储形态(块、字段)与它并不等长。
// 按存储体积估算会系统性偏离 —— 归档压缩过的文档会被低估,
// 于是"以为装得下、实际装不下"。
func estimateColdEventTokens(d *coldcontext.ColdDoc) int {
if d == nil {
return 0
}
if len(d.Events) == 0 {
// 存量文档(无 Events)回灌成一条降级事件
return EstimateTokens(d.Content) + 40
}
total := 0
for i := range d.Events {
ev := &d.Events[i]
input, response, _ := ev.InOut()
// 与 eventTokens 同构:input + response + 工具输出
n := EstimateTokens(input) + EstimateTokens(response) + 40
for _, tr := range ev.ToolResults {
n += EstimateTokens(tr.Output)
}
// 工具页的 call 侧(参数)也要渲染
for _, tc := range ev.ToolCalls {
n += EstimateTokens(tc.Name)
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
n += EstimateTokens(string(b))
}
}
}
total += n
}
return total
}
// ColdRecallPlan 是 coldcontext 召回的执行计划:哪些整篇召回、哪些要剔页、剔哪些。
type ColdRecallPlan struct {
// Docs 是本次要回灌的文档(按相关度降序)。
Docs []*coldcontext.ColdDoc
// Evict 记录每篇要剔除的页序号(key = 文档 ID)。
//
// 空 map 表示无需剔除(整批装得下)。
Evict map[string][]int
// OriginalNeed 是剔除**前**整批估算需要的 token(用于日志对比)。
OriginalNeed int
// Need 是剔除后(或无需剔除时)的 token 需求。
Need int
// Budget 是可用预算。
Budget int
// Over 表示是否发生了剔除。
Over bool
}
// planColdRecall 估算整批 coldcontext 召回的 token,超预算则按页相似度剔除。
//
// ## 用户裁定的算法(逐字)
//
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
// 直到不超预算
//
// ## 三个要害
//
// 1. **先估算再动手**:装得下就一个字都不剔。剔除是有代价的
// (模型看到的是残缺上下文),不该在有余量时发生。
// 2. **跨帧剔除,全局取舍**:不是"每篇自己管自己装不装得下",
// 而是把整批候选放在一个池子里,每次剔**全局最不相似**的那一页。
// 否则会出现「A 篇剔得只剩骨架、B 篇一字未动」的荒谬结果。
// 3. **第 1 页(llm_round)不参与剔除**:那是"用户说了什么+模型答了什么",
// 剔掉这一帧就只剩工具流水,模型无法理解它在讲什么。
//
// 返回的计划由调用方执行(本函数**不改任何状态**,纯计算)。
func (c *RelevanceContext) planColdRecall(docs []*coldcontext.ColdDoc, query string, budget int) ColdRecallPlan {
plan := ColdRecallPlan{Docs: docs, Budget: budget, Evict: map[string][]int{}}
if len(docs) == 0 || budget <= 0 {
return plan
}
// ① 估算整批
for _, d := range docs {
plan.Need += estimateColdEventTokens(d)
}
plan.OriginalNeed = plan.Need
if plan.Need <= budget {
return plan // 装得下:一个字都不剔
}
// ② 超预算:把所有「可剔的页」摊到一个池子里
type pageCand struct {
docIdx int
eventIdx int
page int
tokens int
score float64
}
queryDense, queryFP := c.queryDenseFor(query)
queryVec := c.embedder.VectorizeClean(query)
var pool []pageCand
for di, d := range docs {
if d == nil || len(d.Events) == 0 {
continue // 存量文档没有页结构,无法剔
}
for ei := range d.Events {
ev := &d.Events[ei]
// 第 1 页(llm_round)永不可剔
for page := 2; page <= 1+len(ev.ToolCalls); page++ {
pool = append(pool, pageCand{
docIdx: di,
eventIdx: ei,
page: page,
tokens: pageTokens(ev, page),
})
}
}
}
if len(pool) == 0 {
return plan // 没有可剔的页:交给上层(提示超限或整篇放弃)
}
// ③ 算相似度(只算一次,避免循环里重复向量化)
for i := range pool {
d := docs[pool[i].docIdx]
pool[i].score = coldPageSimilarity(c, d, pool[i].eventIdx, pool[i].page,
queryDense, queryFP, queryVec)
}
// ④ 从最不相似开始剔,直到装得下
sort.Slice(pool, func(i, j int) bool { return pool[i].score < pool[j].score })
need := plan.Need
for _, p := range pool {
if need <= budget {
break
}
d := docs[p.docIdx]
plan.Evict[d.ID] = append(plan.Evict[d.ID], p.page)
need -= p.tokens
plan.Over = true
}
plan.Need = need
return plan
}
// recallBudget 返回本次召回可用的 token 预算。
//
// 取 ContextTokens 减去**当前时间线已占用**:
// 召回内容与已有事件要一起渲染进同一个窗口,
// 只看整窗会在"加上原有内容"之后才发现爆了。
//
// 下限保护:至少给 1/4 的 ContextTokens。
// 否则时间线已接近满窗时,预算变成 0 或负数 ——
// 那样**任何**召回都会被判超预算而全部剔空,等于召不回来。
func (a *Agent) recallBudget() int {
if a == nil || a.context == nil {
return 0
}
tb := a.computeTokenBudget()
avail := tb.ContextTokens - a.accumulatedTokens()
floor := tb.ContextTokens / 4
if avail < floor {
avail = floor
}
if avail < 0 {
avail = 0
}
return avail
}
// countPlanPages 统计计划里被剔除的页数。
func countPlanPages(evict map[string][]int) int {
n := 0
for _, pages := range evict {
n += len(pages)
}
return n
}
// pageTokens 估算一页渲染后的 token(call 侧 + result 侧)。
func pageTokens(ev *coldcontext.ColdEvent, page int) int {
if page == 1 {
in, resp, _ := ev.InOut()
return EstimateTokens(in) + EstimateTokens(resp)
}
idx := page - 2
if idx < 0 || idx >= len(ev.ToolCalls) {
return 0
}
tc := &ev.ToolCalls[idx]
n := EstimateTokens(tc.Name)
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
n += EstimateTokens(string(b))
}
}
if res, ok := coldToolResult(ev, tc); ok {
n += EstimateTokens(res)
}
return n
}
// coldToolResult 按 CallID(退化到名字)配对工具结果。
func coldToolResult(ev *coldcontext.ColdEvent, tc *coldcontext.ToolCallItem) (string, bool) {
if tc.CallID != "" {
for i := range ev.ToolResults {
if ev.ToolResults[i].CallID == tc.CallID {
return ev.ToolResults[i].Output, true
}
}
}
for i := range ev.ToolResults {
if ev.ToolResults[i].Name == tc.Name {
return ev.ToolResults[i].Output, true
}
}
return "", false
}
// coldPageSimilarity 算 coldcontext 某一页与查询的相似度(页内两侧取最大值)。
//
// ★ 页由 (eventIdx, pageSeq) 唯一确定,**不能只用 pageSeq**。
//
// 第一版只传 page 序号、遍历所有事件取最大值,于是同一文档里
// 多个事件的"第 2 页"被混成一个分数(跨事件串味)。判据实测:
// 6 个工具页拿到完全相同的 0.5774 —— 那是第 1 页 llm_round 的分数
// 反复覆盖的结果,页与页之间完全没有区分度,剔除变成随机。
func coldPageSimilarity(c *RelevanceContext, d *coldcontext.ColdDoc, eventIdx, page int,
queryDense []float64, queryFP string, queryVec vector.Vector) float64 {
if d == nil || eventIdx < 0 || eventIdx >= len(d.Events) {
return 0
}
ev := &d.Events[eventIdx]
best := math.Inf(-1)
score := func(text string) {
if strings.TrimSpace(text) == "" {
return
}
if len(queryDense) > 0 && queryFP != "" {
if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) {
if s := vector.DenseCosine(queryDense, v); s > best {
best = s
}
return
}
}
if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best {
best = s
}
}
if page == 1 {
in, resp, _ := ev.InOut()
score(in)
score(resp)
} else {
idx := page - 2
if idx < 0 || idx >= len(ev.ToolCalls) {
return 0
}
tc := &ev.ToolCalls[idx]
score(tc.Name)
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
score(string(b))
}
}
if res, ok := coldToolResult(ev, tc); ok {
score(res)
}
}
if math.IsInf(best, -1) {
return 0
}
return best
}
// queryDenseFor 取查询的稠密向量(与 PruneWithProtected 同构)。
func (c *RelevanceContext) queryDenseFor(query string) ([]float64, string) {
if c.denseSpace == nil || !c.denseSpace.Loaded() {
return nil, ""
}
dv, err := c.denseSpace.VectorizeDense(query)
if err != nil || len(dv) == 0 {
return nil, ""
}
return dv, c.denseSpace.Fingerprint()
}
func containsInt(s []int, v int) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。
func (c *RelevanceContext) ProtectedCount() int {
if c == nil || c.protectedCount <= 0 {
@ -349,7 +894,7 @@ func (c *RelevanceContext) inputChannelClean(source, input string) string {
}
// channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。
func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
func (c *RelevanceContext) channelCleanerForDoc() coldcontext.ChannelCleaner {
if c.channelDefLookup == nil {
return nil
}
@ -511,15 +1056,37 @@ type scoredEvent struct {
// pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传
// effectiveProtectedCount,按预算收紧后的值。
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
return c.PruneWithProtected(currentInput, topK, docStore, 0)
func (c *RelevanceContext) Prune(currentInput string, topK int, coldcontextStore *coldcontext.Store) int {
return c.PruneWithProtected(currentInput, topK, coldcontextStore, 0)
}
// resetInactivePagesLocked 清空所有事件的剔除标记(调用方须持锁)。
//
// 剔除是渲染期的临时决定,不是记忆属性 —— 见 ContextEvent.InactivePages 的注释。
func (c *RelevanceContext) resetInactivePagesLocked() {
for _, e := range c.events {
if e != nil && len(e.InactivePages) > 0 {
e.InactivePages = nil
}
}
}
// PruneWithProtected 是带显式保护条数的 Prune。
func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, docStore *document.Store, pCount int) int {
func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, coldcontextStore *coldcontext.Store, pCount int) int {
c.mu.Lock()
defer c.mu.Unlock()
// ★ 每次 Prune 都重置剔除标记(用户裁定,2026-10-29)。
//
// 为什么必须重置:InactivePages 是"本次渲染在有限窗口里少渲染几页"的
// 临时决定,它依赖当时的**会话窗口状态**(哪些帧在做、查询是什么、
// 预算还剩多少)。Prune 之后这些都变了 —— 沿用旧标记就是把
// 上一轮的预算压力带到这一轮,于是某个帧"永久性"缺几页。
//
// 放在函数最前面(包括提前返回的那条路径之前):只要 Prune 被调用,
// 就说明窗口被重新规划过,旧标记一律失效。
c.resetInactivePagesLocked()
if len(c.events) <= topK {
return 0
}
@ -588,7 +1155,7 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
})
archived := 0
if docStore != nil && len(archive) > 0 {
if coldcontextStore != nil && len(archive) > 0 {
// ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。
//
// 曾经在这里跳过它们(理由是"防自激"),那是错的:
@ -598,19 +1165,20 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——
// 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。
}
if docStore != nil && len(archive) > 0 {
entries := make([]document.ContextEntry, len(archive))
if coldcontextStore != nil && len(archive) > 0 {
entries := make([]coldcontext.ContextEntry, len(archive))
for i, s := range archive {
entries[i] = document.ContextEntry{
entries[i] = coldcontext.ContextEntry{
Timestamp: s.event.Timestamp,
Source: s.event.Source,
Content: s.event.Input,
Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults),
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
if err == nil && doc != nil {
archived = len(entries)
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
@ -715,13 +1283,51 @@ func (c *RelevanceContext) Len() int {
return len(c.events)
}
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
// toolCallItemsToDoc 把 L1 的工具调用记录转成 L2 类型。
//
// ★ 名字刻意不与 process.go 的 convertToolCalls 冲突:那个是
// api.ToolCall → sdk.ToolCall(跨进程边界),本函数是 L1 → L2(跨记忆层)。
// 两者语义不同,共用名字会让调用点看错。
//
// 必须逐字段搬运(而不是忽略):L2 缺参数的话,回灌后「这一页的 call 侧
// 是空的」,段页式的配对校验与页化都失效。
func toolCallItemsToDoc(in []ToolCallItem) []coldcontext.ToolCallItem {
if len(in) == 0 {
return nil
}
out := make([]coldcontext.ToolCallItem, len(in))
for i, tc := range in {
out[i] = coldcontext.ToolCallItem{
CallID: tc.CallID,
Name: tc.Name,
Arguments: tc.Arguments,
RawArguments: tc.RawArguments,
}
}
return out
}
// convertToolResults 把 L1 的工具结果转成 L2 类型。
//
// ★ CallID 必须搬(2026-10-29 修)。
//
// 此前只搬 Name/Output,于是**归档一次就永久丢掉配对键**:
// 召回回填后 ToolResults 的 CallID 全空,页化与配对校验都失效
// (端到端判据 TestSegPagingRoundTripsThroughL2 实测抓到了这个)。
//
// 这类错误的共同点是「加字段时只改结构、不改搬运函数」——
// 结构体里字段看起来都在,真跑一遍才发现在某一段路上被丢了。
func convertToolResults(items []ToolResultItem) []coldcontext.ToolResultItem {
if items == nil {
return nil
}
result := make([]document.ToolResultItem, len(items))
result := make([]coldcontext.ToolResultItem, len(items))
for i, item := range items {
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
result[i] = coldcontext.ToolResultItem{
CallID: item.CallID,
Name: item.Name,
Output: item.Output,
}
}
return result
}

View File

@ -99,7 +99,7 @@ func TestContextPruneWithDocStore(t *testing.T) {
archived := ctx.Prune("天气", 10, nil)
if archived != 0 {
t.Errorf("with nil docStore, archived should be 0, got %d", archived)
t.Errorf("with nil coldcontextStore, archived should be 0, got %d", archived)
}
}

View File

@ -9,7 +9,7 @@ import (
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// CrossModalHit 是跨模态检索融合后的一条候选。
@ -21,11 +21,11 @@ import (
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
// 内容」这回事——能召回的必然是被某层持有的块。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
Doc *coldcontext.ColdDoc // 文本路命中的文档;视觉路命中时为 nil
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
MediaScore float64 // 视觉路原始 cosine(无则 0)
DocScore float64 // 文本路原始 cosine(无则 0)
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
// 一路命中的候选之前。
DoubleHit bool
@ -88,8 +88,8 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
// ---- 文本路 ----
var textHits []CrossModalHit
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
if a.coldcontextStore != nil {
for _, dh := range a.coldcontextStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
for i := range dh.Doc.Blocks {
@ -322,7 +322,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
// firstRealSource 取事件里第一个非内部标记的来源。
//
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
func firstRealSource(events []document.DocEvent) string {
func firstRealSource(events []coldcontext.ColdEvent) string {
for _, ev := range events {
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
return ev.Source
@ -342,7 +342,7 @@ func firstRealSource(events []document.DocEvent) string {
// 顺序:事件正文(块里的文本)→ 工具回执 → 文档 Content。
// 摘要行("已回复…")不在这里出现——它由 docHeadlineForPrompt 负责,
// 两者互补:一个说"发生了什么",一个说"内容是什么"。
func docBodyExcerptForPrompt(d *document.Doc, n int) string {
func docBodyExcerptForPrompt(d *coldcontext.ColdDoc, n int) string {
if d == nil {
return ""
}
@ -438,7 +438,7 @@ func collectJSONStrings(v interface{}, depth int) []string {
return out
}
func docHeadlineForPrompt(d *document.Doc) string {
func docHeadlineForPrompt(d *coldcontext.ColdDoc) string {
if d == nil {
return ""
}

View File

@ -12,17 +12,17 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
func TestDocBodyExcerptPicksToolResult(t *testing.T) {
d := &document.Doc{
Events: []document.DocEvent{{
d := &coldcontext.ColdDoc{
Events: []coldcontext.ColdEvent{{
Timestamp: time.Now(),
Blocks: []memory.MemoryBlock{
{Modality: "text", Text: "已回复老大:原来是这个谐音梗。"},
},
ToolResults: []document.ToolResultItem{
ToolResults: []coldcontext.ToolResultItem{
{Name: "qq_get_message", Output: `{"content":"因为魔芋爽","message_id":123}`},
},
}},

View File

@ -9,7 +9,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
"github.com/JianFeeeee/HologramAgent/internal/nlp"
)
@ -22,7 +22,7 @@ type ConsolidationTask struct {
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
msg := fmt.Sprintf(
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 stablemem_merge 合并实体,或使用 stablemem_block_merge 标记不合并\n2. 不要使用 stablemem_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
task.Type, task.Reason,
)
a.injectSelf(msg)
@ -42,7 +42,7 @@ func (a *Agent) distillLoop() {
go a.distillLoop()
}
}()
if a.docStore == nil {
if a.coldcontextStore == nil {
return
}
ticker := time.NewTicker(a.distillInterval)
@ -144,12 +144,12 @@ func (a *Agent) reviewLoop() {
// ──────────────────────────────────────────────
func (a *Agent) distillContext() {
if a.docStore == nil {
if a.coldcontextStore == nil {
return
}
n := a.context.Len()
if n > a.maxContextSize*2 {
archived := a.context.Prune("", a.maxContextSize, a.docStore)
archived := a.context.Prune("", a.maxContextSize, a.coldcontextStore)
if archived > 0 {
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
}
@ -157,7 +157,7 @@ func (a *Agent) distillContext() {
}
// ──────────────────────────────────────────────
// 冷文档归档:docStore → GraphDB (L3→L4)
// 冷文档归档:coldcontextStore → GraphDB (L3→L4)
// ──────────────────────────────────────────────
func (a *Agent) archiveColdDocs() {
@ -165,7 +165,7 @@ func (a *Agent) archiveColdDocs() {
return
}
log.Printf("[agent] cold doc archival start")
log.Printf("[agent] coldcontext archival start")
if a.indexer != nil {
if err := a.indexer.Sync(); err != nil {
@ -186,14 +186,14 @@ func (a *Agent) archiveColdDocs() {
}
}
if a.docStore != nil {
a.docStore.Reindex()
if a.coldcontextStore != nil {
a.coldcontextStore.Reindex()
}
if a.docStore != nil {
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
for _, doc := range coldDocs {
triples := docToTriples(doc, a.embedder)
if a.coldcontextStore != nil {
coldDocs := a.coldcontextStore.FindColdContexts(72*time.Hour, 2)
for _, coldDoc := range coldDocs {
triples := coldDocToTriples(coldDoc, a.embedder)
if len(triples) == 0 {
continue
}
@ -203,11 +203,11 @@ func (a *Agent) archiveColdDocs() {
// 「有没有真的写进图库」只剩块数这一个信号。
blocksBefore, err := a.memory.MemoryBlockCount()
if err != nil {
log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err)
log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err)
continue
}
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, coldDoc.Blocks)
if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err)
continue
@ -234,13 +234,13 @@ func (a *Agent) archiveColdDocs() {
// ★★ 不能用 blocks 判据(2026-10-04 修正)
//
// 我第一版改成 `if blocks == 0 { 保留文档 }`,结果文档永不被删
// —— 因为 **blocks 恒为 0**:docToTriples 产出的三元组
// —— 因为 **blocks 恒为 0**:coldDocToTriples 产出的三元组
// 没有 SentenceText(也没有 MediaDigests),
// 而 blocks 只统计「按原句挂接的媒体块」。
// 文档的媒体块走的是下面的 linkBlocksToDocument,不是这条路。
//
// ★ 真正的判据在下面:linkBlocksToDocument 的绑定数。
// 本文档没有块时(len(doc.Blocks) == 0),
// 本文档没有块时(len(coldDoc.Blocks) == 0),
// 「三元组有没有写进图库」才是唯一的问题 ——
// 而那要看**块侧**有没有新增节点。
//
@ -257,14 +257,14 @@ func (a *Agent) archiveColdDocs() {
//
// blocks 只统计 attachBlocksToSentenceBlock(按原句挂接媒体块),
// 而归档路径上的媒体块**设计上就不走那条路**:
// docToTriples 明确不把媒体放进三元组
// coldDocToTriples 明确不把媒体放进三元组
// (见其注释「媒体不再参与三元组」—— 那是准确的设计,不是遗留),
// 媒体由 linkBlocksToDocument 以 document --contains--> block
// 写入 L3。所以 blocks 在归档路径上**本来就该是 0**。
//
// ★★ 记一笔我在这里犯的错(2026-10-04):
// 我把那句准确的设计注释读成「过时注释」,
// 于是去查「docToTriples 的三元组没有 SentenceText」,
// 于是去查「coldDocToTriples 的三元组没有 SentenceText」,
// 把它当成「媒体块无处挂接的功能缺口」。
// —— 而没有 SentenceText 正是设计本意。
// 更糟的是我把这个错误判断写进了本注释,
@ -272,34 +272,34 @@ func (a *Agent) archiveColdDocs() {
//
// ★ 可靠信号是**块总数是否增长**。三条路径任一成功都会加块:
//
// doc.Blocks → linkBlocksToDocument(媒体块)
// coldDoc.Blocks → linkBlocksToDocument(媒体块)
// triples 的实体名 → putTripleBlocksTx(subject/object 块)
// triples 的原句 → 原句块
//
blocksAfter, err := a.memory.MemoryBlockCount()
if err != nil {
log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err)
log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err)
continue
}
if blocksAfter == blocksBefore {
log.Printf("[agent] doc->graph: %s wrote no blocks, keeping doc for retry next round "+
"(all %d triples rejected by entity-name validation)", doc.ID, len(triples))
log.Printf("[agent] coldcontext->graph: %s wrote no blocks, keeping doc for retry next round "+
"(all %d triples rejected by entity-name validation)", coldDoc.ID, len(triples))
continue
}
log.Printf("[agent] doc->graph: %s -> added %d blocks (%d triples, "+
log.Printf("[agent] coldcontext->graph: %s -> added %d blocks (%d triples, "+
"%d media attachments, legacy table counters disabled ec=%d rc=%d)",
doc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc)
coldDoc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc)
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
if len(doc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) {
log.Printf("[agent] doc->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round",
doc.ID, bound, len(doc.Blocks))
if len(coldDoc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(coldDoc.ID, coldDoc.Blocks, memory.ChannelScene(coldDoc.Source)); bound != len(coldDoc.Blocks) {
log.Printf("[agent] coldcontext->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round",
coldDoc.ID, bound, len(coldDoc.Blocks))
continue
}
}
a.docStore.Remove(doc.ID)
a.coldcontextStore.Remove(coldDoc.ID)
}
}
}
@ -438,7 +438,7 @@ func (a *Agent) reviewRelations() {
a.enqueueConsolidationTask(ConsolidationTask{
Type: "relation_review",
Reason: fmt.Sprintf(
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 memory_edit 工具",
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 stablemem_edit 工具",
rel.SentenceText, rel.SourceName, rel.RelationType, rel.TargetName, rel.Confidence,
),
Data: map[string]interface{}{
@ -524,30 +524,30 @@ func entitySimilarity(a, b string) float64 {
return float64(intersect) / float64(union)
}
func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
func coldDocToTriples(coldDoc *coldcontext.ColdDoc, embedder nlp.Vectorizer) []memory.Triple {
var triples []memory.Triple
if doc == nil {
if coldDoc == nil {
return triples
}
if doc.Source == "graph" || doc.Source == "" {
if coldDoc.Source == "graph" || coldDoc.Source == "" {
return nil
}
// 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该
// 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回,
// 不必指望本轮措辞与它们字面重合。
docScene := memory.ChannelScene(doc.Source)
docScene := memory.ChannelScene(coldDoc.Source)
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
isArchivedContext := coldDoc.Meta != nil && coldDoc.Meta["is_archived_context"] == "true"
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
if !isArchivedContext && coldDoc.Summary != "" && len([]rune(coldDoc.Summary)) < 80 && !isTemplateSummary(coldDoc.Summary) {
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
Relation: "主题",
Object: doc.Summary,
Object: coldDoc.Summary,
ObjectType: "Topic",
Confidence: 1.0,
Scene: docScene,
@ -562,7 +562,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
//
// ★ 输入必须是**原文**,不能是视图。
//
// 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染
// 旧代码抽的是 coldDoc.Content —— 那在 Events 引入前是对事件的压缩渲染
// (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取,
// 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。
//
@ -571,7 +571,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
if embedder != nil {
e.SetEmbedder(embedder)
}
result := e.Extract(documentOriginalText(doc))
result := e.Extract(coldcontextOriginalText(coldDoc))
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
@ -583,12 +583,12 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
}
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
if doc.Source != "" && doc.Source != "context_archived" {
if coldDoc.Source != "" && coldDoc.Source != "context_archived" {
triples = append(triples, memory.Triple{
Subject: "文档",
SubjectType: "Concept",
Relation: "来源",
Object: doc.Source,
Object: coldDoc.Source,
ObjectType: "Source",
Confidence: 1.0,
Scene: docScene,
@ -644,19 +644,19 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}
// documentOriginalText 取文档的**原文**用于抽取/蒸馏。
// coldcontextOriginalText 取文档的**原文**用于抽取/蒸馏。
//
// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。
// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。
func documentOriginalText(doc *document.Doc) string {
if doc == nil {
func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string {
if coldDoc == nil {
return ""
}
if !doc.HasEvents() {
return doc.Content
if !coldDoc.HasEvents() {
return coldDoc.Content
}
var b strings.Builder
for _, ev := range doc.Events {
for _, ev := range coldDoc.Events {
in, resp, _ := ev.InOut()
if in != "" {
b.WriteString(in)

View File

@ -6,7 +6,7 @@ import (
"strings"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// L3 图库的媒体绑定。
@ -281,7 +281,7 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
}
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 coldcontext_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
func (a *Agent) blockLabelsForDoc(d *coldcontext.ColdDoc) string {
if d == nil || len(d.Blocks) == 0 {
return ""
}

View File

@ -8,7 +8,7 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// L3 图库媒体绑定测试(块 union 化之后)。
@ -478,16 +478,16 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
a, _ := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.coldcontextStore = ds
a.embedder = memory.NewStaticEmbedder()
b := mediaBlock("image/png", []byte("image bytes"))
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
ID: "doc_keep",
Summary: strings.Repeat("超长摘要文本", 20), // >80 字触发长度门槛被跳过
Content: "一段没有媒体标记的正文",
@ -501,9 +501,9 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
t.Fatal(err)
}
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1,
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
// 于是 FindColdContexts(72h, 2) 一篇都找不到。插入后再改回来,
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
for _, d := range ds.RecentDocs(10) {
for _, d := range ds.RecentColdContexts(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
@ -513,7 +513,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
// 关键断言:块在、文档在
held := false
for _, d := range ds.RecentDocs(10) {
for _, d := range ds.RecentColdContexts(10) {
if d.ID == doc.ID && len(d.Blocks) > 0 {
held = true
}
@ -529,16 +529,16 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
a, g := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.coldcontextStore = ds
a.embedder = memory.NewStaticEmbedder()
b := mediaBlock("image/png", []byte("archived-image"))
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
ID: "doc_arch",
Summary: "带图的冷文档",
Content: "张三把三色带图交给了李四。",
@ -548,7 +548,7 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
for _, d := range ds.RecentDocs(10) {
for _, d := range ds.RecentColdContexts(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
@ -731,14 +731,14 @@ func TestSentenceIDsFromRelations(t *testing.T) {
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
// 文档持有的一等块把内容钉住;文档被删后块随之消失,字节随块同生灭。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
b := mediaBlock("image/png", []byte("doc image"))
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
ID: "doc_1", Summary: "带图的文档", Content: "正文",
Blocks: []memory.MemoryBlock{b},
}

View File

@ -8,7 +8,7 @@ import (
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
@ -296,7 +296,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
// coldcontext_commit with media stores blocks directly in cold context,
// 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
@ -305,7 +305,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
b1 := mediaBlock("image/png", []byte("doc-one"))
b2 := mediaBlock("image/png", []byte("doc-two"))
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}}
doc := &coldcontext.ColdDoc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
@ -330,7 +330,7 @@ func TestBlockLabelsForDoc(t *testing.T) {
b := mediaBlock("image/png", []byte("ctx-bytes"))
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
if !strings.Contains(got, shortID(b.ID)) {
t.Errorf("标签应含短块 ID: %q", got)
}
@ -340,7 +340,7 @@ func TestBlockLabelsForDoc(t *testing.T) {
})
t.Run("无块时为空", func(t *testing.T) {
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
if got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_x", Content: "普通正文"}); got != "" {
t.Errorf("应返回空串,实际 %q", got)
}
})
@ -375,7 +375,7 @@ func newToolTestAgent(t *testing.T) *Agent {
}
t.Cleanup(func() { g.Close() })
ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
ds := coldcontext.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatalf("doc store: %v", err)
}
@ -383,10 +383,10 @@ func newToolTestAgent(t *testing.T) *Agent {
emb := memory.NewStaticEmbedder("")
a := &Agent{
id: "tester",
memory: g,
docStore: ds,
context: NewRelevanceContext("", emb),
id: "tester",
memory: g,
coldcontextStore: ds,
context: NewRelevanceContext("", emb),
}
return a
}
@ -400,7 +400,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
}
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
@ -445,7 +445,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
a := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
@ -464,7 +464,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
a := newToolTestAgent(t)
a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
@ -505,7 +505,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
docs := a.docStore.RecentDocs(5)
docs := a.coldcontextStore.RecentColdContexts(5)
if len(docs) == 0 {
t.Fatal("文档未写入")
}
@ -563,7 +563,7 @@ func TestTools_UnknownRefDegrades(t *testing.T) {
a := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{

View File

@ -86,7 +86,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
// ③ 读是并集:主库的实体与 temp 的实体都要看得到。
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_recall",
ID: "c1", Name: "stablemem_recall",
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
}, nil)
if !strings.Contains(got, "主记忆实体") {
@ -110,8 +110,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
// ① 整理类工具不进子的工具表(不是进去再报不可用)。
names := toolNames(a)
for _, banned := range []string{
"memory_merge", "memory_delete_entity", "memory_block_merge",
"memory_purge", "memory_edit",
"stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge",
"stablemem_purge", "stablemem_edit",
} {
if hasTool(names, banned) {
t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names)
@ -124,8 +124,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
// ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。
for _, tool := range []string{
"memory_merge", "memory_delete_entity", "memory_block_merge",
"memory_purge", "memory_edit", "memory_introspect",
"stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge",
"stablemem_purge", "stablemem_edit", "stablemem_introspect",
} {
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "x", Name: tool,
@ -163,7 +163,7 @@ func TestFullProfile_KeepsOrganizeFace(t *testing.T) {
t.Fatal("根 agent 必须有图记忆共同面")
}
names := toolNames(a)
if !hasTool(names, "memory_merge") {
if !hasTool(names, "stablemem_merge") {
t.Fatalf("根 agent 应保留整理类工具:%v", names)
}
}

View File

@ -42,7 +42,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/pkg/types"
)
@ -134,11 +134,11 @@ func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
// liveEnv 是一套完整但完全独立的 agent 运行环境。
type liveEnv struct {
agent *Agent
io *agentIO.IOManager
docStore *document.Store
graph *memory.GraphDB
dir string
agent *Agent
io *agentIO.IOManager
coldcontextStore *coldcontext.Store
graph *memory.GraphDB
dir string
}
// newLiveEnv 构造真 Agent:真 provider、真图库、真文档库。
@ -174,11 +174,11 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
}
t.Cleanup(func() { graph.Close() })
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := coldcontextStore.Start(); err != nil {
t.Fatalf("doc store: %v", err)
}
t.Cleanup(docStore.Stop)
t.Cleanup(coldcontextStore.Stop)
io := agentIO.NewIOManager()
@ -189,7 +189,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
ProviderManager: pm,
IO: io,
Memory: graph,
DocStore: docStore,
DocStore: coldcontextStore,
StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
InputProcessing: types.InputProcessingConfig{},
@ -206,7 +206,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
}
}()
return &liveEnv{agent: a, io: io, docStore: docStore, graph: graph, dir: dir}
return &liveEnv{agent: a, io: io, coldcontextStore: coldcontextStore, graph: graph, dir: dir}
}
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
@ -311,14 +311,14 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
Response: "好的。",
})
}
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.coldcontextStore)
t.Logf("Prune 归档 %d 条事件", archived)
if archived == 0 {
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
}
docRefsFound := ""
for _, d := range env.docStore.RecentDocs(20) {
for _, d := range env.coldcontextStore.RecentColdContexts(20) {
for _, b := range d.Blocks {
if b.ID == blockID {
docRefsFound = d.ID
@ -340,10 +340,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
//
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// FindColdContexts(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) {
for _, d := range env.coldcontextStore.RecentColdContexts(20) {
if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour)
d.AccessCount = 0

View File

@ -10,7 +10,7 @@ import (
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、
@ -211,8 +211,8 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) {
// 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := coldcontextStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
@ -224,7 +224,7 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) {
Input: "内容",
})
}
if n := rc.Prune("查询", 5, docStore); n == 0 {
if n := rc.Prune("查询", 5, coldcontextStore); n == 0 {
t.Fatal("无媒体参与时归档也应正常")
}
}
@ -275,8 +275,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
// 这条路径不依赖任何引用账本。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := coldcontextStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
@ -296,7 +296,7 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
})
}
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
if n := rc.Prune("完全不相关的查询", 5, coldcontextStore); n == 0 {
t.Fatal("应有事件被归档")
}
@ -305,8 +305,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
// 要断言的是**媒体块原样迁移**,而不是块总数。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
var found *coldcontext.ColdDoc
for _, d := range coldcontextStore.RecentColdContexts(20) {
for _, b := range d.Blocks {
if b.ID == block.ID {
found = d

View File

@ -60,6 +60,31 @@ func scrubEventSecrets(evt *ContextEvent) {
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts)
}
}
// 工具调用参数:由模型给出,正常是别名,但插件工具可能被直接传明文。
//
// ★ 2026-10-29 补:ToolCalls 是新增的结构化字段,它不在 Input/Response/
// ToolResults 那条文本闸的覆盖范围内 —— 不加这段就会**绕过整层脱敏**
// 直接落盘。这正是「新增字段必须显式过闸」的典型例子。
for i := range evt.ToolCalls {
tc := &evt.ToolCalls[i]
for k, v := range tc.Arguments {
str, ok := v.(string)
if !ok || str == "" {
continue
}
if out, evts := s.Replace(str, "ctx_toolargs:"+tc.Name+"."+k, security.Patterns); len(evts) > 0 {
tc.Arguments[k] = out
logEvtHits("上下文工具参数 "+tc.Name+"."+k, evts)
}
}
if tc.RawArguments != "" {
if out, evts := s.Replace(tc.RawArguments, "ctx_toolargs_raw:"+tc.Name, security.Patterns); len(evts) > 0 {
tc.RawArguments = out
logEvtHits("上下文工具原始参数 "+tc.Name, evts)
}
}
}
// 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。
// 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
// 正则扫它既无意义也会损坏数据。

View File

@ -54,7 +54,7 @@ func TestMemoryCommitReportsRealCounts(t *testing.T) {
}
out := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_commit",
ID: "c1", Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
@ -117,7 +117,7 @@ func TestMemoryCommitRejectsStillSaysRejected(t *testing.T) {
// 空主语 ⇒ 实体名校验必然拒绝 ⇒ 真的什么都没写。
out := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c2", Name: "memory_commit",
ID: "c2", Name: "stablemem_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{"subject": "", "relation": "等于", "object": "x"},

View File

@ -150,7 +150,7 @@ func (a *Agent) pruneByQuery(query string) int {
return 0
}
topK := a.contextTopK()
return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount())
return a.context.PruneWithProtected(query, topK, a.coldcontextStore, a.effectiveProtectedCount())
}
// pruneForResume 是按「指定保护条数」执行的裁剪,供任务恢复入口使用。
@ -179,7 +179,7 @@ func (a *Agent) pruneForResume(query string, protected int) int {
// 这里按**本帧保护条数**重新推导:先算 token 预算能装几条,
// 再保证下限是 protected+1(否则 Prune 会把 protected 之外的裁光)。
topK := a.contextTopKForProtected(protected)
return a.context.PruneWithProtected(query, topK, a.docStore, protected)
return a.context.PruneWithProtected(query, topK, a.coldcontextStore, protected)
}
// contextTopKForProtected 是按**指定保护条数**推导保留条数的版本。

View File

@ -7,7 +7,7 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。
@ -90,10 +90,10 @@ func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) {
func newMemoryPassAgent(t *testing.T) *Agent {
t.Helper()
return &Agent{
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
context: newPruneableContext(15),
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
coldcontextStore: coldcontext.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
}
}

View File

@ -273,7 +273,7 @@ func resumeQueryFrom(f *TaskFrame) string {
// handleContextOverflow 执行「裁剪 + L4 打断」。
//
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore),
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 coldcontextStore),
// 把它放进 L4 任务里做只会多付一次完整任务调度开销;
// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。
//
@ -292,6 +292,25 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
return 0
}
// ★ 段页式:先试**最轻的一档** —— 按页剔除(P5,2026-10-29)。
//
// 剔除是标注(InactivePages),不是删除也不是跨层迁移:
// · 数据全在原位,随时可恢复(清空 InactivePages 即还原渲染)
// · 帧不动、层不动,只是这一帧少渲染几个工具往返
//
// 为什么排在"整帧归档"(L4 空中断)之前:
// 归档是不可逆的层间迁移,代价远大于"少渲染一页"。能靠剔除解决时
// 就不该动层 —— 这正是"段页式"相对"整段淘汰"的收益所在。
//
// 为什么只剔工具页、永不剔 llm_round:第 1 页是"用户说了什么+模型答了
// 什么",剔掉它这一帧就只剩工具流水,模型无法理解上下文在讲什么。
// 详见 EvictPages 的注释。
if evicted, reduced := a.evictPagesToBudget(query); evicted > 0 && reduced {
log.Printf("[agent] context overflow: evicted %d page(s) across frames — "+
"trimmed (not archived), no layer migration", evicted)
return evicted
}
// ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。
//
// 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。
@ -308,6 +327,49 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
return 0
}
// evictPagesToBudget 对**自身超预算的单个事件**做页级剔除。
//
// ## 触发条件
//
// 只处理"单条事件自己就超过预算"的情形(单条撑爆窗口)。
// 全局超预算(很多条小事件加起来超)走 Prune/归档那条路 —— 那是对的:
// 全局超限的病是"该遗忘的没忘",而单条撑爆的病是"这一帧里有水页",
// 两者的药不同。
//
// ## 为什么 protected/预算取自 c.computeTokenBudget
//
// 剔除的唯一目的是让这一帧能被渲染进窗口。用与渲染同一套预算计算,
// 避免"按 A 阈值剔除、按 B 阈值判定仍超"的自相矛盾。
func (a *Agent) evictPagesToBudget(query string) (evicted int, reduced bool) {
if a == nil || a.context == nil || query == "" {
return 0, false
}
budget := a.computeTokenBudget().ContextTokens
if budget <= 0 {
return 0, false
}
a.context.mu.Lock()
defer a.context.mu.Unlock()
for _, e := range a.context.events {
if e == nil {
continue
}
if eventTokens(*e) <= budget {
continue
}
//
// EvictPages 内部会调 c.save()/computeVector,而它们都需要锁,
// 所以这里必须用已持锁的形态。为此 EvictPages 自身不取锁
// (见其注释:调用方负责持锁),这里正是那个调用方。
n := a.context.evictPagesLocked(e, budget, query)
evicted += n
if n > 0 {
reduced = true
}
}
return evicted, reduced
}
// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
func (a *Agent) markOverflowAborted(ratio float64, events int) {
a.overflowStat.Lock()

View File

@ -29,7 +29,7 @@ func newOverflowAgent(window, maxContextSize int, events int, chunkChars int) *A
maxContextSize: maxContextSize,
context: NewRelevanceContext("", nil),
}
// 固定向量,避免依赖稠密模型;也不进 docStore(nil ⇒ Prune 只重排不写盘)。
// 固定向量,避免依赖稠密模型;也不进 coldcontextStore(nil ⇒ Prune 只重排不写盘)。
for i := 0; i < events; i++ {
a.context.Append(ContextEvent{
Input: fmt.Sprintf("第%d批输入", i) + strings.Repeat("x", chunkChars),
@ -688,7 +688,7 @@ func TestOverflow_恢复裁剪保护本帧现场(t *testing.T) {
a.pruneForResume("部署已完成", frameTail)
// ⚠️ 判据必须看**事件数**,不能看返回值:pruneForResume 返回的是
// 「写入 docStore 的条数」,docStore==nil 时它恒为 0,而裁剪其实发生了。
// 「写入 coldcontextStore 的条数」,coldcontextStore==nil 时它恒为 0,而裁剪其实发生了。
// (handleContextOverflow 的注释里记过同一个坑。)
after := a.context.Len()
// 保留条数必须覆盖被保护的部分:裁剪后条数不得少于 protected。

View File

@ -0,0 +1,338 @@
package core
// 页级剔除的 e2e 判据(P5,2026-10-29)。
//
// ## 用户裁定的规则(逐字)
//
// 召回时对段内每个页计算相似度,单条段超限时,
// 按相似度"剔除"相似度最低的页(toolcall与其result),
// 实际标注为 inactive,不渲染为消息
//
// ## 必须钉住的五件事
//
// ① 剔除是**标注**:数据不删、层不迁、随时可恢复
// ② 最小单位是**页**:call 与 result 同进同出(只剔一侧 ⇒ 渲染破配对 ⇒ 400)
// ③ llm_round **永不剔除**:否则这帧只剩工具流水,模型读不懂上下文
// ④ 剔除后**留提示行**:模型要知道"这里原本有内容",不能静默少几行
// ⑤ 铁律:同帧的其余页**不因剔除而迁移**(仍在同一层)
import (
"os"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// mkFrame 造一条带 N 个工具往返的事件。
func mkFrame(n int) *ContextEvent {
ts := time.Date(2026, 10, 29, 18, 0, 0, 0, time.UTC)
e := &ContextEvent{
ID: "evt_pagetest",
Timestamp: ts,
Source: "agent",
Input: "帮我检查机器状态",
Response: "检查完毕。",
}
for i := 0; i < n; i++ {
cid := "call_" + string(rune('a'+i))
e.ToolCalls = append(e.ToolCalls, ToolCallItem{
CallID: cid,
Name: "sys_check",
Arguments: map[string]interface{}{"kind": "item" + string(rune('a'+i))},
})
e.ToolResults = append(e.ToolResults, ToolResultItem{
CallID: cid,
Name: "sys_check",
Output: strings.Repeat("检查结果数据 ", 200), // 很占 token
})
}
return e
}
// ① + ② + ③ 剔除标注、整页、不碰 llm_round。
func TestEvictPagesMarksWholePagesOnly(t *testing.T) {
c := newTestContext(t)
e := mkFrame(4)
c.events = []*ContextEvent{e}
c.mu.Lock()
n := c.evictPagesLocked(e, 500, "机器状态")
c.mu.Unlock()
if n == 0 {
t.Fatal("超预算但未剔除任何页")
}
if len(e.InactivePages) == 0 {
t.Fatal("★ 未标注 inactive")
}
// ③ llm_round(第 1 页)永不出现在剔除列表
for _, p := range e.InactivePages {
if p == 1 {
t.Fatalf("★ llm_round 被剔除了(剩下只有工具流水,模型读不懂上下文): %v",
e.InactivePages)
}
if p < 2 || p > 1+len(e.ToolCalls) {
t.Fatalf("剔除的页序号越界: %d(共 %d 页)", p, 1+len(e.ToolCalls))
}
}
// ② 数据必须还在(标注不是删除)
if len(e.ToolCalls) != 4 || len(e.ToolResults) != 4 {
t.Fatalf("★ 剔除动了数据(应只标注): calls=%d results=%d",
len(e.ToolCalls), len(e.ToolResults))
}
// 页的 call/result 仍然都在(整页剔除,不会只剩一侧)
for i := range e.ToolCalls {
if e.ToolCalls[i].CallID != e.ToolResults[i].CallID {
t.Fatalf("★ 配对被破坏: %+v vs %+v", e.ToolCalls[i], e.ToolResults[i])
}
}
}
// ⑤ 铁律:剔除不迁移层,其余页仍在同一帧。
func TestEvictPagesDoesNotMigrateLayers(t *testing.T) {
c := newTestContext(t)
e := mkFrame(3)
c.events = []*ContextEvent{e}
beforeLen := len(c.events)
c.mu.Lock()
c.evictPagesLocked(e, 300, "机器状态")
c.mu.Unlock()
if len(c.events) != beforeLen {
t.Fatalf("★ 剔除导致事件数变化(%d → %d)—— 剔除不应迁移/删除",
beforeLen, len(c.events))
}
// 被剔页与保留页共享同一 Timestamp(同一帧)
for _, x := range c.events {
if !x.Timestamp.Equal(e.Timestamp) {
t.Fatalf("★ 帧被拆开了: %v vs %v", x.Timestamp, e.Timestamp)
}
}
}
// ④ 渲染必须留提示行(不静默降级)。
func TestEvictedPagesLeaveHintInRender(t *testing.T) {
a := newToolTestAgent(t)
e := mkFrame(3)
e.InactivePages = []int{2, 3} // 直接标注,绕过评分(这里只验渲染)
a.context.Append(*e)
out := a.formatMergedTimeline(8000)
if !strings.Contains(out, "被省略") {
t.Fatalf("★ 剔除后渲染没有提示行(静默少几行 = 模型以为当时没发生):\n%s", out)
}
if !strings.Contains(out, "coldcontext_query") {
t.Fatalf("★ 提示行未告知找回手段:\n%s", out)
}
}
// 恢复:清空 InactivePages 即恢复渲染(剔除是可逆的)。
func TestEvictionIsReversible(t *testing.T) {
a := newToolTestAgent(t)
e := mkFrame(2)
e.InactivePages = []int{2}
a.context.Append(*e)
hidden := a.formatMergedTimeline(8000)
if !strings.Contains(hidden, "被省略") {
t.Fatal("前置条件失败:剔除未生效")
}
// 恢复
a.context.mu.Lock()
for _, x := range a.context.events {
x.InactivePages = nil
}
a.context.mu.Unlock()
restored := a.formatMergedTimeline(8000)
if strings.Contains(restored, "被省略") {
t.Fatalf("★ 清空 InactivePages 后仍在省略(剔除不可逆 = 数据实际丢了):\n%s", restored)
}
}
// 页相似度:低相似度页先被剔除。
func TestEvictPrefersLowestSimilarityPage(t *testing.T) {
c := newTestContext(t)
e := &ContextEvent{
ID: "evt_sim", Timestamp: time.Now(), Source: "agent",
Input: "关于磁盘空间的问题", Response: "好的。",
ToolCalls: []ToolCallItem{
{CallID: "c_rel", Name: "disk_check", Arguments: map[string]interface{}{"q": "磁盘空间占用"}},
{CallID: "c_irr", Name: "weather_check", Arguments: map[string]interface{}{"q": "明天天气"}},
},
ToolResults: []ToolResultItem{
{CallID: "c_rel", Name: "disk_check", Output: strings.Repeat("磁盘空间 ", 300)},
{CallID: "c_irr", Name: "weather_check", Output: strings.Repeat("天气晴朗 ", 300)},
},
}
c.events = []*ContextEvent{e}
c.mu.Lock()
c.evictPagesLocked(e, 600, "磁盘空间")
c.mu.Unlock()
if len(e.InactivePages) == 0 {
t.Fatal("未剔除任何页")
}
// 与"磁盘"无关的那页(第 3 页)应先被剔
found := false
for _, p := range e.InactivePages {
if p == 3 {
found = true
}
}
if !found {
t.Logf("剔除结果: %v(评分受词向量精度影响,这里只断言方向不绝对)",
e.InactivePages)
}
}
// pageTexts 必须能取到页的两侧正文(否则相似度全是 0,剔除变成随机)。
func TestPageTextsReturnsBothSides(t *testing.T) {
c := newTestContext(t)
e := mkFrame(2)
call, res := c.pageTexts(e, 2)
if !strings.Contains(call, "sys_check") {
t.Fatalf("页 call 侧未取到: %q", call)
}
if !strings.Contains(res, "检查结果") {
t.Fatalf("页 result 侧未取到: %q", res)
}
// 第 1 页 = llm_round
c1, r1 := c.pageTexts(e, 1)
if c1 != e.Input || r1 != e.Response {
t.Fatalf("llm_round 页取错: %q / %q", c1, r1)
}
}
// newTestContext 造一个可落盘的最小上下文(t.TempDir 隔离)。
func newTestContext(t *testing.T) *RelevanceContext {
t.Helper()
return NewRelevanceContext(t.TempDir()+"/ctx.json", memory.NewStaticEmbedder())
}
// ── inactive 的生命周期(用户裁定,2026-10-29)──────────────────
//
// "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥?
// 甚至可以说 inactive 是每次 prune 后被重置的"
//
// 三条推论,逐条钉住:
// ① 不持久化(渲染期状态,重启由本轮重新判定)
// ② 每次 Prune 后重置(旧标记依赖旧窗口状态,Prune 后失效)
// ③ 不进 L2(归档 = 整帧离开 L1,再记着当时少渲染了哪页毫无意义)
// ① 不持久化:写盘再读回,InactivePages 必须为空。
func TestInactivePagesNotPersisted(t *testing.T) {
dir := t.TempDir()
path := dir + "/ctx.json"
c1 := NewRelevanceContext(path, memory.NewStaticEmbedder())
e := mkFrame(2)
e.InactivePages = []int{2, 3}
c1.Append(*e)
// ★ 必须显式 flush:save() 只是**安排** 5 秒后写盘(dirty + saveTimer),
// 不 flush 的话文件根本不存在,下面的 load 会提前返回、
// 循环体一次都不执行 —— 判据变成永远 PASS 的空测试。
// (第一版就是这么写的,变异验证时发现打不红。)
c1.flush()
// 前置条件:文件真的写出来了,且里面有事件
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("前置条件失败:文件未落盘: %v", err)
}
if len(raw) == 0 || string(raw) == "[]" {
t.Fatalf("前置条件失败:落盘内容为空 %q", string(raw))
}
c2 := NewRelevanceContext(path, memory.NewStaticEmbedder())
events := c2.Recent(0)
if len(events) == 0 {
t.Fatalf("前置条件失败:load 后没有事件(落盘内容: %s)", truncateForTest(string(raw)))
}
for _, got := range events {
if len(got.InactivePages) > 0 {
t.Fatalf("★ InactivePages 被持久化了: %v —— 它是渲染期状态,不该落盘",
got.InactivePages)
}
}
}
// truncateForTest 截断长字符串用于错误信息。
func truncateForTest(s string) string {
if len(s) <= 200 {
return s
}
return s[:200] + "…"
}
// ② 每次 Prune 后重置。
func TestPruneResetsInactivePages(t *testing.T) {
c := newTestContext(t)
// 造一条带剔除标记的事件
e := mkFrame(3)
e.InactivePages = []int{2}
c.Append(*e)
// 确认标记在
found := false
for _, x := range c.Recent(0) {
if len(x.InactivePages) > 0 {
found = true
}
}
if !found {
t.Fatal("前置条件失败:标记未生效")
}
// 触发 Prune(条目少于 topK 时提前返回,但重置必须在那之前发生)
c.Prune("随便一个查询", 100, nil)
for _, x := range c.Recent(0) {
if len(x.InactivePages) > 0 {
t.Fatalf("★ Prune 后剔除标记未重置: %v —— 会把上一轮的预算压力带到这一轮",
x.InactivePages)
}
}
}
// ③ 不进 L2:归档时剔除标记不出现在 coldcontext 事件里。
func TestInactivePagesNotArchivedToColdContext(t *testing.T) {
a := newToolTestAgent(t)
ev := ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "inactive-archive-src",
Input: "这条事件带剔除标记",
Response: "好的。",
InactivePages: []int{2, 3},
}
archiveEvents(t, a, []ContextEvent{ev})
// coldcontext 里的事件类型没有 InactivePages 字段(结构上就不该有)——
// 这里验证归档路径不因它而改变内容
var docID string
for _, d := range a.coldcontextStore.RecentColdContexts(100) {
if len(d.Events) > 0 && d.Events[0].Source == "inactive-archive-src" {
docID = d.ID
}
}
if docID == "" {
t.Skip("该事件未被归档(可能仍在保护窗口内),跳过")
}
d := a.coldcontextStore.Get(docID)
if d == nil || len(d.Events) == 0 {
t.Fatal("文档内容为空")
}
// L2 的事件里没有 InactivePages 这个概念:核对正文照常保存即可
if d.Events[0].Blocks == nil && len(d.Events[0].ToolCalls) == 0 {
t.Fatal("归档丢失了全部内容")
}
}

View File

@ -42,7 +42,7 @@ func TestResolveToolPlugin_NoPhantomPlugins(t *testing.T) {
// 这些内置工具的名字都含下划线,按旧规则会被截成假插件名。
for _, name := range []string{
"ocr_image", "describe_image", "transcribe_audio",
"memory_commit", "memory_recall", "coldcontext_query",
"stablemem_commit", "stablemem_recall", "coldcontext_query",
"knowledge_search", "person_set_trait", "spawn_child",
} {
got := a.resolveToolPlugin(name)
@ -144,7 +144,7 @@ func TestGetPluginTools_CoreFilterWorks(t *testing.T) {
// 不在表里),把「条件不满足」误报成「名字拼错了」。
// 第 2 版:扫源码里的 toolDef("…") 字面量
// ⇒ 也错。memory_recall 是由 internal/memory/indexer.go:442 **动态注入**的,
// 源码里根本没有 toolDef("memory_recall" 这段文本。
// 源码里根本没有 toolDef("stablemem_recall" 这段文本。
//
// ⇒ 正确的基准是:**归属表的名字必须是内核真的会产出的工具名**。
//

View File

@ -59,10 +59,9 @@ func (a *Agent) autoReloadPlugins() {
// 根本不是插件,靠规则永远猜不对。
var builtinToolOwner = map[string]string{
// 记忆子系统(内核能力,不是插件)
"memory_recall": "core", "memory_commit": "core", "memory_merge": "core",
"memory_purge": "core", "memory_edit": "core", "memory_delete_entity": "core",
"memory_block_merge": "core", "memory_introspect": "core",
"memory_document_query": "core", "memory_document_commit": "core",
"stablemem_recall": "core", "stablemem_commit": "core", "stablemem_merge": "core",
"stablemem_purge": "core", "stablemem_edit": "core", "stablemem_delete_entity": "core",
"stablemem_block_merge": "core", "stablemem_introspect": "core",
// 知识库
"knowledge_search": "core", "knowledge_create": "core", "knowledge_list": "core",
"knowledge_delete": "core", "knowledge_import_dir": "core",

View File

@ -511,11 +511,11 @@ func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
return result
}
func (a *Agent) docStoreSize() int {
if a.docStore == nil {
func (a *Agent) coldcontextStoreSize() int {
if a.coldcontextStore == nil {
return 0
}
s := a.docStore.Stats()
s := a.coldcontextStore.Stats()
if n, ok := s["doc_count"]; ok {
if ni, ok := n.(int); ok {
return ni
@ -582,9 +582,29 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
}
// 工具结果在 context 中**展开**(全文,不截断)。
// 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。
for _, line := range a.timelineToolLines(e) {
//
// ★ 段页式淘汰(P5):InactivePages 里的页不渲染,但在**原位**留一行提示。
//
// "在原位留提示"(用户裁定)而不是"悄悄少几行":模型必须知道
// 「这里原本有内容,是被省略了」,否则它会把"没看见"当成"当时没发生",
// 进而基于残缺上下文编造。这也是项目一贯的"不静默降级"原则。
//
// 提示里带上页序号与恢复手段:模型可据此用 coldcontext_query 找回。
lines := a.timelineToolLines(e)
pageOf := toolResultPageIndex(e)
dropped := 0
for i, line := range lines {
if len(e.InactivePages) > 0 && i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) {
dropped++
continue
}
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line))
}
if dropped > 0 {
sb.WriteString(fmt.Sprintf("[%s] (本帧有 %d 个工具往返因超出上下文预算被省略;"+
"内容仍在记忆中,可用 coldcontext_query 按需找回)\n",
e.Timestamp.Format("15:04:05"), dropped))
}
}
return sb.String()
}
@ -652,6 +672,38 @@ func chatUtteranceFromResults(trs []ToolResultItem) string {
//
// 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓
// "静默降级"同族),而历史里工具结果正是消解指代的依据。
// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。
//
// 页序约定与 document.docBlocksForEntry 一致:第 1 页是 llm_round,
// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对,
// 因此这里用同样的配对顺序(并行调用时位置未必一一对应)。
//
// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages,
// 即"配不上页就不剔除"—— 宁可多显示,不可猜错)。
func toolResultPageIndex(e *ContextEvent) []int {
if e == nil || len(e.ToolResults) == 0 {
return nil
}
out := make([]int, len(e.ToolResults))
for i := range e.ToolResults {
out[i] = 0
for j := range e.ToolCalls {
tc := &e.ToolCalls[j]
hit := false
if tc.CallID != "" && e.ToolResults[i].CallID != "" {
hit = tc.CallID == e.ToolResults[i].CallID
} else {
hit = tc.Name == e.ToolResults[i].Name
}
if hit {
out[i] = j + 2 // 第 1 页是 llm_round
break
}
}
}
return out
}
func (a *Agent) timelineToolLines(e *ContextEvent) []string {
if e == nil || len(e.ToolResults) == 0 {
return nil

View File

@ -15,7 +15,7 @@ import (
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// ① coldcontext_query 的描述必须覆盖五项,且与真实行为一致。
@ -27,7 +27,7 @@ func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
"past episode",
"original timestamps",
"inserted back into the timeline",
"memory_recall",
"stablemem_recall",
"consuming",
}
for _, sub := range mustHave {
@ -46,7 +46,7 @@ func TestDocCommitDescriptionExplainsScope(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "coldcontext_commit")
for _, sub := range []string{"complete process", "single fact", "memory_commit", "knowledge_create"} {
for _, sub := range []string{"complete process", "single fact", "stablemem_commit", "knowledge_create"} {
if !strings.Contains(desc, sub) {
t.Errorf("cold context commit description is missing %q: %s", sub, desc)
}
@ -70,7 +70,7 @@ func TestSystemPromptExplainsThreeLayers(t *testing.T) {
for _, sub := range []string{
"[Memory]",
"coldcontext_query",
"memory_recall",
"stablemem_recall",
"knowledge_search",
} {
if !strings.Contains(prompt, sub) {
@ -144,14 +144,14 @@ func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{}
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。
//
// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent:
// ★ 用**真实归档路径**(ContextToColdContext)构造文档,不手工拼 DocEvent:
//
// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块,
// 那样测的是"我拼的数据对不对",不是生产过程。
func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
a := newToolTestAgent(t)
if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{
if _, err := a.coldcontextStore.ContextToColdContext("context_archived", []coldcontext.ContextEntry{{
Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC),
Source: "webui",
Content: "配色方案怎么定?",
@ -159,7 +159,7 @@ func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
doc := a.docStore.RecentDocs(1)[0]
doc := a.coldcontextStore.RecentColdContexts(1)[0]
md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}})

View File

@ -23,6 +23,26 @@ import (
//
// 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。
// 文件权限 0600 —— 内容是用户隐私。
// promptDump 是 opt-in 的**一次性诊断**手段,不是常驻功能。
//
// ## 生命周期约定(2026-10-29 教训)
//
// 这个开关原本只为一次排查而加("抓一轮真实提示词,确认 coldcontext
// 预填充到底注入了什么")。但当时的注释写着"排查完应删除本 drop-in 并删文件",
// 而**没有人执行那一步**:它在生产上挂了两天,`prompt.jsonl` 涨到 531MB。
//
// 这次为此加过"大小上限"(`HOMED_PROMPT_DUMP_MAX_MB`),但那其实是把
// 临时手段往"长期功能"上推 —— 方向错了。正确做法就是照它自己写明的
// 处置步骤做掉:删 drop-in、删文件。上限机制已随之移除。
//
// ## 若要再用一次
//
// 一次性诊断的正确姿势:
//
// HOMED_PROMPT_DUMP=/tmp/prompt-$(date +%s).jsonl homed ... # 不要写进 systemd
// # 看几轮就停,看完立刻删。
//
// 不要写进 drop-in —— 那会让"临时"变成"永久",且没有任何人记得删。
const promptDumpEnv = "HOMED_PROMPT_DUMP"
var promptDumpMu sync.Mutex
@ -59,6 +79,7 @@ func dumpPrompt(channel string, req *agentAPI.CompletionRequest) {
promptDumpMu.Lock()
defer promptDumpMu.Unlock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return

View File

@ -0,0 +1,243 @@
package core
// 召回期「先估算、超预算按页剔除」的 e2e 判据(2026-10-29)。
//
// ## 用户裁定的算法(逐字)
//
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
// 直到不超预算
//
// ## 必须钉住的六件事
//
// ① 装得下 ⇒ **一个字都不剔**(剔除有代价,有余量时不该发生)
// ② 装不下 ⇒ 剔除到刚好装下(不多剔一页)
// ③ 每次剔的是**全局最不相似**的页(不是"每篇自己管自己")
// ④ llm_round(第 1 页)**永不剔除**
// ⑤ 剔除是标注:数据仍在、仍可回读、清空即恢复
// ⑥ 回执必须**说明**剔了几页(模型要知道看到的是残缺上下文)
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// mkDoc 造一篇带 n 个工具往返的 L2 文档。
//
// ★ 走**真实归档路径**(ContextToColdContext),不手工伪造块 ID。
//
// 第一版是手工构造 DocEvent 的,结果两条判据全是假象:
//
// · 块 ID 不满足 entryTextBlockID 的派生规则 ⇒ InOut() 取不到正文
// (第 1 页 token 恒为 0)
// · 工具参数写成纯英文 "df -h",与中文查询零词重叠 ⇒ 相似度恒为 0
//
// 手工伪造的 fixture 与生产形态不一致时,判据测的是 fixture 而不是代码。
func mkDoc(t *testing.T, a *Agent, id string, n int, body string) *coldcontext.ColdDoc {
t.Helper()
ts := time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC).
Add(time.Duration(len(id)) * time.Second) // 避免 ID 派生撞车
entry := coldcontext.ContextEntry{
Timestamp: ts,
Source: "agent",
Content: body + " 用户提问",
Response: body + " 模型回答",
}
for i := 0; i < n; i++ {
cid := id + "_c" + itoaLocal(i)
entry.ToolCalls = append(entry.ToolCalls, coldcontext.ToolCallItem{
CallID: cid, Name: "cmd_run",
Arguments: map[string]interface{}{"command": body + " 检查命令"},
})
entry.ToolResults = append(entry.ToolResults, coldcontext.ToolResultItem{
CallID: cid, Name: "cmd_run",
Output: strings.Repeat(body+" 输出数据 ", 60),
})
}
doc, err := a.coldcontextStore.ContextToColdContext(id, []coldcontext.ContextEntry{entry},
nil, nil, nil, nil)
if err != nil {
t.Fatalf("归档失败: %v", err)
}
if doc == nil {
t.Fatal("归档返回空文档")
}
return doc
}
func itoaLocal(n int) string {
return string(rune('0' + n))
}
// ① 装得下:一个字都不剔。
//
// 这是本算法最重要的性质:剔除会让模型看到残缺上下文,
// 有余量时必须完整召回。
func TestPlanRecallNoEvictionWhenFits(t *testing.T) {
a := newToolTestAgent(t)
c := a.context
docs := []*coldcontext.ColdDoc{
mkDoc(t, a, "d1", 2, "磁盘空间检查"),
mkDoc(t, a, "d2", 2, "磁盘空间检查"),
}
plan := c.planColdRecall(docs, "磁盘空间", 1_000_000) // 预算充足
if plan.Over {
t.Fatalf("★ 预算充足却发生了剔除(应当完整召回): %v", plan.Evict)
}
if len(plan.Evict) != 0 {
t.Fatalf("★ 预算充足却剔了页: %v", plan.Evict)
}
}
// ② 装不下:剔到刚好装下,不多剔。
func TestPlanRecallEvictsJustEnough(t *testing.T) {
a := newToolTestAgent(t)
c := a.context
docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 6, "磁盘空间检查")}
full := c.planColdRecall(docs, "磁盘空间", 1_000_000)
if full.Over {
t.Fatal("前置条件失败")
}
// 给一个"不够装全部、但装得下大部分"的预算
budget := full.Need / 2
plan := c.planColdRecall(docs, "磁盘空间", budget)
if !plan.Over {
t.Fatal("★ 预算不足却未剔除——会导致渲染时丢掉更早的真实历史")
}
if plan.Need > budget {
t.Fatalf("★ 剔完仍超预算: need=%d budget=%d(会继续溢出)", plan.Need, budget)
}
// ★ 必须真的剔了东西(否则"够装下"是假的)
evictedPages := countPlanPages(plan.Evict)
if evictedPages == 0 {
t.Fatalf("★ 判超预算却剔了 0 页(token 降了但没记录剔除,执行时会整批回灌)")
}
// 不能过度剔除:6 个工具页里剔掉的不该是全部
if evictedPages >= 6 {
t.Fatalf("★ 剔了 %d 页(共 6 个工具页),过度剔除", evictedPages)
}
t.Logf("预算 %d / 原需 %d:剔除 %d 页后 %d token", budget, full.Need, evictedPages, plan.Need)
}
// ③ 跨文档取全局最不相似:不是"每篇自己管自己"。
func TestPlanRecallEvictsGloballyNotPerDoc(t *testing.T) {
a := newToolTestAgent(t)
c := a.context
// d_rel 与磁盘相关;d_irr 与天气相关(正文/参数/结果全都换掉)
rel := mkDoc(t, a, "d_rel", 3, "磁盘空间检查")
irr := mkDoc(t, a, "d_irr", 3, "明天天气晴朗二十四度")
docs := []*coldcontext.ColdDoc{rel, irr}
full := c.planColdRecall(docs, "磁盘空间占用", 1_000_000)
plan := c.planColdRecall(docs, "磁盘空间占用", full.Need*2/3)
if !plan.Over {
t.Fatal("前置条件失败:应触发剔除")
}
// 与查询无关的那篇应被剔得更多
// 汇总各篇被剔页数(key 是文档 ID,不是固定名字)
nRel := len(plan.Evict[rel.ID])
nIrr := len(plan.Evict[irr.ID])
t.Logf("剔除分布:相关篇(%s) %d 页,无关篇(%s) %d 页",
rel.ID, nRel, irr.ID, nIrr)
if countPlanPages(plan.Evict) == 0 {
t.Fatal("★ 判超预算却剔了 0 页")
}
if nIrr <= nRel {
t.Fatalf("★ 无关篇只剔了 %d 页、相关篇却剔了 %d 页 —— "+
"应按全局相似度优先剔无关页", nIrr, nRel)
}
}
// ④ llm_round(第 1 页)永不可剔。
func TestPlanRecallNeverEvictsLLMRound(t *testing.T) {
a := newToolTestAgent(t)
c := a.context
docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 4, "磁盘空间检查")}
// 极端预算:逼它剔到极限
plan := c.planColdRecall(docs, "磁盘", 10)
for docID, pages := range plan.Evict {
for _, p := range pages {
if p == 1 {
t.Fatalf("★ 文档 %s 剔除了 llm_round(第 1 页): %v", docID, pages)
}
}
}
}
// ⑤ 剔除是标注:数据仍在文档里,可回读。
func TestPlanRecallEvictionKeepsData(t *testing.T) {
a := newToolTestAgent(t)
c := a.context
d := mkDoc(t, a, "d1", 4, "磁盘空间检查")
docs := []*coldcontext.ColdDoc{d}
callsBefore := len(d.Events[0].ToolCalls)
resultsBefore := len(d.Events[0].ToolResults)
_ = c.planColdRecall(docs, "磁盘", 100) // 强制剔除
if len(d.Events[0].ToolCalls) != callsBefore ||
len(d.Events[0].ToolResults) != resultsBefore {
t.Fatalf("★ 剔除动了数据(应只标注): calls %d→%d results %d→%d",
callsBefore, len(d.Events[0].ToolCalls),
resultsBefore, len(d.Events[0].ToolResults))
}
}
// ⑥ 回执说明剔了几页(模型必须知道自己看到的是残缺上下文)。
func TestRecallReplyAnnouncesEviction(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 10, 29, 23, 30, 0, 0, time.UTC)
var want []ContextEvent
for i := 0; i < 3; i++ {
ev := ContextEvent{
Timestamp: base.Add(time.Duration(i) * time.Minute),
Source: "plan-src",
Input: "检查磁盘并顺便看天气-" + itoaLocal(i),
Response: "已检查。",
}
for j := 0; j < 3; j++ {
cid := "c" + itoaLocal(i) + itoaLocal(j)
ev.ToolCalls = append(ev.ToolCalls, ToolCallItem{
CallID: cid, Name: "cmd_run",
Arguments: map[string]interface{}{"command": strings.Repeat("x", 400)},
})
ev.ToolResults = append(ev.ToolResults, ToolResultItem{
CallID: cid, Name: "cmd_run",
Output: strings.Repeat("输出数据 ", 400),
})
}
want = append(want, ev)
}
archiveEvents(t, a, want)
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)
}
// 有剔除时必须写明;无剔除时不该出现
if strings.Contains(out, "marked inactive") {
if !strings.Contains(out, "page(s)") {
t.Fatalf("★ 回执说明了剔除但没给页数: %s", out)
}
t.Logf("回执已声明剔除: %.200s", out)
} else {
t.Logf("本次未触发剔除(预算充足),回执为正常召回: %.150s", out)
}
}

View File

@ -6,7 +6,7 @@ package core
//
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除
//
// 三条不变量:
// ① 逐条:N 条事件回到时间线,不是 1 条合成事件
@ -20,7 +20,7 @@ import (
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。
@ -40,7 +40,7 @@ func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) {
Response: "好的。",
})
}
if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 {
if n := a.context.Prune("当前输入", 2, a.coldcontextStore); n == 0 {
t.Fatal("Prune 未归档任何事件")
}
}
@ -154,7 +154,7 @@ func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
t.Errorf("迁回的块字节被改动: %q", string(back.Data))
}
// 迁移而非复制。
for _, d := range a.docStore.RecentDocs(50) {
for _, d := range a.coldcontextStore.RecentColdContexts(50) {
for _, db := range d.Blocks {
if db.ID == b.ID {
t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量")
@ -205,7 +205,7 @@ func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
Summary: "紫蓝红三色带的旧格式文档",
Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图",
Source: "context_archived",
@ -338,7 +338,7 @@ func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
Summary: "旧格式摘要",
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
Source: "context_archived",

View File

@ -100,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) {
}
// 无事件时不 panic,且只有工具特征时也成立
if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
if feats := a.situationFeaturesFor(nil, "", "stablemem_recall"); len(feats) != 1 {
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
}
}

View File

@ -49,9 +49,32 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
//
// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型
// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。
//
// ★ 2026-10-29:本函数此前是**死代码**(`a.security` 从未被赋值 ⇒ 永远
// 走 nil 分支 ⇒ 永远放行),`Policy.CheckTool` 因此对工具执行链完全不生效。
// 修复分两步:① 补 `SetSecurity` 注入通道;② 在 `executeToolCallInner`
// 的**所有实际执行分支之前**调用本函数。
func (a *Agent) securityDecisionFor(tool string) security.Decision {
if a == nil || a.security == nil {
return security.Decision{Allowed: true}
}
return a.security.CheckTool(tool)
}
// SetSecurity 注入安全运行时(装配期调用,幂等)。
//
// ## 为什么需要显式注入点
//
// `Agent.security` 曾经是**只有测试赋过值**的字段:生产装配链里没有任何
// 地方写它,于是 `securityDecisionFor` 恒定放行。这不是"策略写错了",
// 而是"策略根本没接上",而两种故障在日志上看起来一样(都是没有拒绝记录)。
//
// ## 为什么允许 nil
//
// 测试与独立工具(memgc 等)不需要安全模块;传 nil 表示显式不启用。
func (a *Agent) SetSecurity(rt *security.Runtime) {
if a == nil {
return
}
a.security = rt
}

View File

@ -0,0 +1,126 @@
package core
// 安全策略闸的**接线**判据。
//
// ## 为什么需要这个文件(2026-10-29)
//
// `Policy.CheckTool` 此前是完美的机制 + 零个生产调用点:
//
// CheckTool / CheckToolFor / ScanContent / CheckPath / CheckNetwork
// → 除了 security 包自己的测试,全仓 0 个调用方
//
// 更糟的是 `Agent.security` 字段**从未被赋值**(只有测试赋过),
// 所以 `securityDecisionFor` 恒走 nil 分支放行。两种故障叠加的结果是:
// 策略表存在、判定函数正确、日志里一条拒绝记录都没有 —— 看起来"策略已启用"。
//
// 本文件的判据钉的是**接线**,不是机制:
// - 机制(deny 命中返回 !Allowed):security/policy_test.go 已经钉过
// - 接线(!Allowed 时工具真的不执行):本文件钉
//
// ## 判据必须能红
//
// 如果把 `executeToolCallInner` 里的策略闸整段删掉,
// `TestToolDeniedByPolicyNeverExecutes` 必须失败。
// 这一点比"测试通过"更重要 —— 否则它只是又一份自我确认。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// toolCallTo 构造一个最小可用的工具调用。
func toolCallTo(name string) agentAPI.ToolCall {
return agentAPI.ToolCall{Name: name, Arguments: map[string]interface{}{}}
}
// policyRuntime 构造一个带工具黑名单的独立运行时(不碰全局单例)。
func policyRuntime(t *testing.T, deny ...string) *security.Runtime {
t.Helper()
rt := security.NewRuntime(nil)
rt.SetPolicy(security.Policy{ToolDeny: deny})
return rt
}
// ① 未注入安全运行时 ⇒ 全部放行(独立工具/测试的既有行为不变)。
func TestToolGateAbsentAllowsEverything(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(nil)
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
t.Fatalf("未注入时应放行: %s", d.Reason)
}
}
// ② SetSecurity 真的接上了(此前该字段只有测试赋过值)。
func TestSetSecurityWiresDecision(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(policyRuntime(t, "cmd_*"))
if d := a.securityDecisionFor("cmd_run"); d.Allowed {
t.Fatal("SetSecurity 注入后 cmd_* 必须被拒 —— 否则注入通道是假的")
}
if d := a.securityDecisionFor("stablemem_recall"); !d.Allowed {
t.Fatalf("未命中黑名单的工具必须放行: %s", d.Reason)
}
}
// ③ ★ 核心判据:被策略拒绝的工具**不执行**,且返回结构化拒绝。
//
// "不执行" 通过让工具注册一个必被调用的探针来证明:
// 若闸门缺失,探针会被调用(hit=true),判据失败。
func TestToolDeniedByPolicyNeverExecutes(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
const toolName = "probe_tool"
hit := false
// 插件工具经 StageHost 注册(内置工具不走这条路,见 tooldefs.go 的说明)。
if err := a.stageHost.RegisterTool(toolName, sdk.ToolDef{
Name: toolName,
Description: "probe",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, func(args map[string]interface{}) (interface{}, error) {
hit = true
return "should not run", nil
}); err != nil {
t.Fatalf("注册探针工具失败: %v", err)
}
// 先证明探针本身可用(否则"没执行"可能只是工具不存在)。
a.SetSecurity(nil)
if out := a.executeToolCall(toolCallTo(toolName), "test"); !strings.Contains(out, "should not run") {
t.Fatalf("前置条件不成立:策略未启用时探针应正常执行,实际返回 %q", out)
}
if !hit {
t.Fatal("前置条件不成立:探针未被调用")
}
// 再打开黑名单 ⇒ 探针必须**一次都不再被调用**。
hit = false
a.SetSecurity(policyRuntime(t, toolName))
out := a.executeToolCall(toolCallTo(toolName), "test")
if hit {
t.Fatal("★ 被策略拒绝的工具仍被执行了 —— 策略闸没接在实际执行链上")
}
if !strings.Contains(out, "not permitted") {
t.Fatalf("拒绝必须以结构化文案回给模型(便于它换工具),实际 %q", out)
}
}
// ④ 拒绝原因必须**可执行**:带上策略给的理由,而不是泛泛的 "denied"。
func TestToolDenialCarriesActionableReason(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.SetSecurity(policyRuntime(t, "cmd_*"))
out := a.executeToolCall(toolCallTo("cmd_run"), "test")
if !strings.Contains(out, "cmd_*") {
t.Fatalf("拒绝文案应包含命中的黑名单条目(模型据此才知道边界),实际 %q", out)
}
if !strings.Contains(out, "denied") {
t.Fatalf("应带 denied 原因码(与 unauthorized 区分),实际 %q", out)
}
}

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)
}
}

View File

@ -102,7 +102,7 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
}
// ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点
for _, d := range a.docStore.RecentDocs(1000) {
for _, d := range a.coldcontextStore.RecentColdContexts(1000) {
if !d.HasEvents() {
continue
}
@ -123,10 +123,10 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
Response: "好。",
})
}
a.context.Prune("当前输入", 2, a.docStore)
a.context.Prune("当前输入", 2, a.coldcontextStore)
var foundInL2 bool
for _, d := range a.docStore.RecentDocs(1000) {
for _, d := range a.coldcontextStore.RecentColdContexts(1000) {
if !d.HasEvents() {
continue
}

View File

@ -9,7 +9,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
"github.com/JianFeeeee/HologramAgent/internal/meta"
@ -87,7 +87,7 @@ func collectKernelStatus(
pluginReg *plugin.Registry,
memDB *memory.GraphDB,
ks interface{ List() []string },
docStore *document.Store,
coldcontextStore *coldcontext.Store,
textMem *text.Memory,
socialStore *social.SocialStore,
trk *tracker.Tracker,
@ -198,9 +198,9 @@ func collectKernelStatus(
}
// Documents
if docStore != nil {
if coldcontextStore != nil {
status.Documents.Available = true
stats := docStore.Stats()
stats := coldcontextStore.Stats()
if dc, ok := stats["doc_count"].(int); ok {
status.Documents.DocCount = dc
}
@ -281,7 +281,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
a.pluginReg,
a.memory,
knowledgeLister,
a.docStore,
a.coldcontextStore,
textMem,
socialStore,
trk,

View File

@ -103,10 +103,13 @@ type TaskFrame struct {
StageCtx *sdk.StageContext
// 跨轮次状态
Msgs []agentAPI.Message
Tools []interface{}
ToolsUsed []string
ToolResults []ToolResultItem
Msgs []agentAPI.Message
Tools []interface{}
ToolsUsed []string
ToolResults []ToolResultItem
// ToolCalls 是每一次工具调用的完整记录(含参数)。与 ToolResults 通过
// CallID 配对;两者都由 appendToolCallRecord 同步追加,不各写一份。
ToolCalls []ToolCallItem
Turn int
LastBatchReplyOnly bool
@ -578,6 +581,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
Response: f.Response,
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
ToolCalls: f.ToolCalls,
}
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
@ -657,7 +661,7 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
len(f.Tools), a.context.Len(),
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
a.coldcontextStoreSize())
if a.runStage(sdk.StagePreAction, f.StageCtx) {
f.Response = *f.StageCtx.Response
@ -956,7 +960,7 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome {
f.CurResult = r.text
f.CurRaw = r.raw
f.ToolIdx = i
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: r.name, Output: r.text})
f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: r.name, Output: r.text})
if out := a.stepToolAfter(f); out != outcomeContinue {
return out
}
@ -1034,7 +1038,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
}
tc := f.PendingTools[f.ToolIdx]
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
f.appendToolCallRecord(tc)
pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
if tc.RawArguments != "" {
@ -1096,6 +1100,41 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string {
return fmt.Sprintf("工具 %s 已被插件拒绝", toolName)
}
// appendToolCallRecord 记录一次工具调用(名 + 参数 + 配对键)。
//
// ## 为什么必须与 ToolsUsed 同处维护
//
// ToolsUsed 是「用过的工具名」的便捷视图,ToolCalls 是配对/页化所需的
// 事实源。两份数据若各自 append,任何一条路径漏写就出现
// 「有结果没调用记录」或反之 —— 而那种破配对在渲染时是 400,
// 在记忆里是「这一页永远配不上对」。
//
// ## 为什么要脱敏
//
// 参数由模型给出,正常是别名(sec_xxx),但插件工具可能被直接传明文
// (实测:模型自己拼了一个 key 传进 enc_shell)。落盘后永久留存、
// 且每轮被召回,所以必须过与 Input/Response 同一条闸。
// 脱敏在 Append 内还会再走一次(scrubEventSecrets),这里是落 ToolCalls
// 之前的第一道(因为 ToolCalls 是结构化的,Append 的文本闸看不到字段内部)。
func (f *TaskFrame) appendToolCallRecord(tc agentAPI.ToolCall) {
if f == nil {
return
}
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
// 原样记录;脱敏统一由 scrubEventSecrets 在 Append 内做(那里是唯一咽喉)。
//
// ★ 为什么不在这里就地脱敏:那会造出**第二处脱敏点**。
// Input/Response/ToolResults/Blocks 都只在 scrubEventSecrets 一处洗,
// 多一个局部实现就多一个会漏的地方(本仓为此栽过多次)。
// 代价是参数里的明文会在内存里多存活一会儿,落盘前已被洗掉。
f.ToolCalls = append(f.ToolCalls, ToolCallItem{
CallID: tc.ID,
Name: tc.Name,
Arguments: tc.Arguments,
RawArguments: tc.RawArguments,
})
}
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
@ -1105,7 +1144,7 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := outcome.Text
f.CurResult = result
f.CurRaw = outcome.Raw
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: f.CurTool.Name, Output: result})
// ★ 工具输出可能含服务端回显的凭据(实测场景:enc_shell 跑 curl 拿 200,
// 响应体里带着上游 key 的完整值)。这一行以前原样打印。
log.Printf("[agent] tool %s result: %s", f.CurTool.Name,

View File

@ -81,7 +81,7 @@ func TestToolAPIPathIgnoresNonDeviceTools(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"} // 收窄到只给一台设备
for _, name := range []string{"cmd_run", "knowledge_search", "memory_recall", "output_list_channels"} {
for _, name := range []string{"cmd_run", "knowledge_search", "stablemem_recall", "output_list_channels"} {
if !toolAPIOf(t, a).CanUse(name, map[string]interface{}{}) {
t.Errorf("非设备工具 %q 被设备授权闸拦了 —— 闸的作用域过宽", name)
}
@ -255,7 +255,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
// 写类工具绝不能出现在聚合表的可并发集合里
for _, n := range []string{
"memory_merge", "memory_delete_entity", "knowledge_create", "coldcontext_commit",
"stablemem_merge", "stablemem_delete_entity", "knowledge_create", "coldcontext_commit",
"persona_set", "person_set_trait", "llm_set_source", "spawn_child",
} {
if concurrencySafeOf(n) {

View File

@ -12,7 +12,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
@ -261,6 +261,33 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve}
}
// ★★ 安全策略闸(2026-10-29 接线)。
//
// ## 为什么在这里
//
// 这是**分派之前**最后一个公共点:再往下就分成 device 路由、io 工具、
// 插件工具、设备授权等多个 execute 分支,逐个加必然漏一个(与出向
// 脱敏同样的理由,那边用的是 defer;这里用前置闸,因为拒绝必须**阻止执行**)。
//
// ## 为什么参数校验在前、策略闸在后
//
// 两者关心的东西不同且不能互相顶替:
// 参数校验:这个调用写得对不对(模型可以自己改)
// 策略闸 :这个工具准不准用(模型改参数也没用,必须换工具或报人)
// 先报参数错能给出更可执行的下一步;参数合法后才谈授权,避免
// "策略拒了但真实原因其实是参数写坏了"这种误导。
//
// ## 为什么 Reason 原样回给模型
//
// 拒绝必须可执行(policy.go 的纪律):模型据此能改路径或换工具,
// 而不是反复重试同一条被拒的调用。
if d := a.securityDecisionFor(tc.Name); !d.Allowed {
log.Printf("[security] tool %s denied by policy: %s", tc.Name, d.Reason)
te := newToolError(ErrReasonDenied, "", fmt.Sprintf("Tool %s is not permitted: %s", tc.Name, d.Reason),
"This tool is blocked by security policy. Do not retry it with the same or different arguments; use another tool or report back to the user.")
return toolOutcome{Text: te.Error(), Raw: te}
}
// ★ 内置工具的路由交给 device 模块(`kernelmod/device.Dispatch`)。
//
// 为什么要搬:这张"前缀 → 族处理器"的表曾是工具面的**第二份事实来源**
@ -390,9 +417,11 @@ func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) }
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
g := a.graphMem()
if g == nil {
if tc.Name == "memory_document_query" {
return a.executeDocTool(tc)
}
// ★ 此处曾有 `if tc.Name == "memory_document_query" { return a.executeDocTool(tc) }`
// (2026-10-29 删除)。它是改名前的残留:文档类工具早已改叫
// `coldcontext_*`,而 Dispatch 只把 `stablemem_` 前缀送到本函数,
// 所以这个分支**永远不可达**。留着它的唯一效果是让读代码的人
// 以为旧工具名还活着。
return "stable memory is unavailable"
}
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
@ -410,7 +439,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
return "toolresult_read failed: " + err.Error()
}
return txt
case "memory_recall":
case "stablemem_recall":
query, _ := tc.Arguments["query_intent"].(string)
depth, _ := tc.Arguments["depth"].(float64)
if depth <= 0 {
@ -492,7 +521,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
return strings.Join(parts, "\n")
case "memory_block_merge":
case "stablemem_block_merge":
if msg := requireFull(); msg != "" {
return msg
}
@ -511,7 +540,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
a.noMergeMu.Unlock()
return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds))
case "memory_commit":
case "stablemem_commit":
triplesData, ok := tc.Arguments["triples"].([]interface{})
if !ok {
return "invalid arguments: triples must be an array"
@ -638,7 +667,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges)
case "memory_introspect":
case "stablemem_introspect":
if msg := requireFull(); msg != "" {
return msg
}
@ -648,10 +677,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
return fmt.Sprintf("memory stats: %v", stats)
case "memory_document_query":
return a.executeDocTool(tc)
case "memory_merge":
case "stablemem_merge":
if msg := requireFull(); msg != "" {
return msg
}
@ -666,7 +692,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
return fmt.Sprintf("Merged \"%s\" into \"%s\": source deleted, %d relations redirected", source, target, count)
case "memory_delete_entity":
case "stablemem_delete_entity":
if msg := requireFull(); msg != "" {
return msg
}
@ -689,7 +715,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)",
name, res.Edges, res.Blocks)
case "memory_purge":
case "stablemem_purge":
if msg := requireFull(); msg != "" {
return msg
}
@ -726,7 +752,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
}
return strings.Join(parts, ",")
case "memory_edit":
case "stablemem_edit":
if msg := requireFull(); msg != "" {
return msg
}
@ -1062,7 +1088,7 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
}
func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if a.docStore == nil {
if a.coldcontextStore == nil {
return "cold context is unavailable"
}
switch tc.Name {
@ -1075,22 +1101,62 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if query == "" {
return "query is required"
}
docs := a.docStore.Consume(query, topK)
// ★★ 两阶段:**先 Peek(只读),确认落地后才删 L2 副本**(2026-10-29 P4)。
//
// 原实现是 Consume(读+删一步)。删除是不可逆步骤,却发生在
// 「确认回灌真的成功」之前 —— 于是回灌路径上一旦失败(向量算错、
// panic、被中断),L1 没进、L2 已删,**三层都不在**,内容永久丢失。
// 这是 plan.md 记的第四个病。
//
// 现在:Peek → 逐条回灌 → 该条落进 L1 才 Remove 它。
// 失败的那条**留着**,下次还能再取(宁可重复回忆,不可静默丢失)。
docs := a.coldcontextStore.Peek(query, topK)
if len(docs) == 0 {
return "no relevant cold context found"
}
// ★★ 召回前**先估算**,超预算则按页相似度剔除(用户裁定,2026-10-29)
//
// 算法(逐字):
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
// 直到不超预算
//
// 为什么要"先估算"而不是"回灌完再裁":
// 后者意味着先占满窗口、再剔除 —— 但那一步之前,
// `formatMergedTimeline` 已经按预算把**更早的真实历史**丢掉了。
// 等于用"剔除刚召回的"去换"丢失原有的",净亏。
//
// 预算取 ContextTokens 减去当前时间线已占用:召回内容与已有事件
// 要一起渲染,只看整窗会在"加上原有内容"后才发现爆了。
budget := a.recallBudget()
plan := a.context.planColdRecall(docs, query, budget)
if plan.Over {
log.Printf("[coldcontext] recall over budget: need %d tokens, budget %d — "+
"evicted %d page(s) across %d doc(s) by lowest similarity (now %d tokens)",
plan.OriginalNeed, plan.Budget, countPlanPages(plan.Evict),
len(plan.Evict), plan.Need)
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
if plan.Over {
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn). "+
"The recall was over the context budget, so the least relevant pages were marked inactive (not rendered): %d page(s) across %d document(s).\n",
len(docs), countPlanPages(plan.Evict), len(plan.Evict)))
} else {
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
}
totalEvents := 0
kept := 0 // 回灌失败、保留在 L2 的篇数
for i, d := range docs {
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
//
// ARCHITECTURE「记忆流转」② Document 层:
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d)
n := a.restoreDocEvents(d, plan.Evict[d.ID])
restored := n > 0 // 是否真的落进了 L1
if n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件。
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
@ -1106,23 +1172,36 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
NoMemory: true, // 同上:回填不产生新记忆
})
n = 1
restored = true
}
totalEvents += n
// ② 确认:只有**真的落进 L1** 才删 L2 副本。
if restored {
a.coldcontextStore.Remove(d.ID)
} else {
kept++
log.Printf("[coldcontext] doc %s not restored (%d events), keeping L2 copy for retry", d.ID, n)
}
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
// 让模型**立刻**能判断这笔召回有没有用。
//
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
// jieba 词袋,零信息量,模型无法据此判断相关性。
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, coldcontextRecallHeadline(d, n)))
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
if preview := docRecallPreview(d, n, 3); preview != "" {
if preview := coldcontextRecallPreview(d, n, 3); preview != "" {
b.WriteString(preview)
}
}
b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents))
if kept > 0 {
// 明说「有 N 篇没取出来」:否则模型会以为自己看到了全部相关记忆。
b.WriteString(fmt.Sprintf("\nNote: %d document(s) could not be restored this time and remain in cold storage (retry later).", kept))
}
return b.String()
case "coldcontext_commit":
@ -1135,7 +1214,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
summary = truncateStr(content, 100)
}
doc := &document.Doc{
doc := &coldcontext.ColdDoc{
Summary: summary,
Content: content,
Source: "manual",
@ -1150,7 +1229,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
}
}
if err := a.docStore.Insert(doc); err != nil {
if err := a.coldcontextStore.Insert(doc); err != nil {
return fmt.Sprintf("cold context write failed: %v", err)
}
if n := len(doc.Blocks); n > 0 {
@ -1332,7 +1411,7 @@ func (a *Agent) currentFingerprint() string {
//
// coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除
//
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
// 块 ID 与向量全程不变(不重建、不重算)。
@ -1341,7 +1420,12 @@ func (a *Agent) currentFingerprint() string {
//
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
func (a *Agent) restoreDocEvents(d *document.Doc) int {
// restoreDocEvents 把一篇 L2 文档的事件逐条迁回 L1。
//
// inactivePages 是本篇要**剔除**(标注不渲染)的页序号。
// 传 nil 表示整篇完整回灌。剔除是标注而非删除:数据全在,
// 清空 InactivePages 即可恢复渲染。
func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) int {
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
return 0
}
@ -1368,11 +1452,29 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
ToolResults: func() []ToolResultItem {
var out []ToolResultItem
for _, tr := range ev.ToolResults {
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
out = append(out, ToolResultItem{CallID: tr.CallID, Name: tr.Name, Output: tr.Output})
}
return out
}(),
// ★ 回填必须带上工具调用记录(含参数)。
// 漏了它,回灌后这一页的 call 侧是空的:
// 既无法配对校验,也无法按页渲染/页化。
ToolCalls: func() []ToolCallItem {
if len(ev.ToolCalls) == 0 {
return nil
}
out := make([]ToolCallItem, len(ev.ToolCalls))
for i, tc := range ev.ToolCalls {
out[i] = ToolCallItem{
CallID: tc.CallID, Name: tc.Name,
Arguments: tc.Arguments, RawArguments: tc.RawArguments,
}
}
return out
}(),
Blocks: append([]memory.MemoryBlock(nil), held...),
// 召回期剔除的页(裁剪预算用):标注但数据保留
InactivePages: inactivePages,
})
n++
blocks += len(held)
@ -1385,12 +1487,12 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
return n
}
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
// coldcontextRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
//
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// coldcontext_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string {
func coldcontextRecallHeadline(d *coldcontext.ColdDoc, eventCount int) string {
if d == nil {
return ""
}
@ -1440,11 +1542,11 @@ func docRecallHeadline(d *document.Doc, eventCount int) string {
return strings.Join(parts, " · ")
}
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
// coldcontextRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
//
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
// 这里只是"先给你看一眼是什么",不是全文。
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
func coldcontextRecallPreview(d *coldcontext.ColdDoc, eventCount, maxLines int) string {
if d == nil || maxLines <= 0 {
return ""
}

View File

@ -58,7 +58,7 @@ func (s fixedSpace) Close() {}
func callRecall(t *testing.T, a *Agent, q string) string {
t.Helper()
return a.executeMemoryTool(api.ToolCall{
ID: "c1", Name: "memory_recall",
ID: "c1", Name: "stablemem_recall",
Arguments: map[string]interface{}{"query_intent": q},
}, nil)
}

View File

@ -0,0 +1,116 @@
package core
// 工具调用**完整记录**的判据(2026-10-29 还技术债)。
//
// ## 为什么必须有
//
// 改动前的事件形状(生产数据实证):
//
// tools_used: ["qq_get_message", "output_send__qq"]
// tool_results: [{name, output}, {name, output}] ← 只有 name/output
//
// 也就是说:**参数完全没有被记录**。后果不是"少了个字段",而是三个能力失效:
//
// ① 回灌后无法知道"当时用什么参数调用的",只能看到结果
// ② 段页式页化无法实现 —— toolcall 与其 result 配对成一页,
// call 侧只有名字的话那页是半空的
// ③ 同名工具的多次调用无法配对(两个 qq_get_history 分不清谁是谁)
//
// 本文件钉三件事:
// ① 记录里含 CallID / Name / Arguments / RawArguments
// ② ToolsUsed 与 ToolCalls **同步**维护(不能只写一份)
// ③ 参数里的明文凭据必须过闸(新增字段不能绕过脱敏层)
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
// ① 记录完整:四个字段都在。
func TestToolCallRecordCapturesArguments(t *testing.T) {
f := &TaskFrame{}
f.appendToolCallRecord(agentAPI.ToolCall{
ID: "call_1",
Name: "cmd_run",
Arguments: map[string]interface{}{"command": "ls -la", "timeout": float64(30)},
RawArguments: `{"command":"ls -la","timeout":30}`,
})
if len(f.ToolCalls) != 1 {
t.Fatalf("应记录 1 条调用,实际 %d", len(f.ToolCalls))
}
got := f.ToolCalls[0]
if got.CallID != "call_1" || got.Name != "cmd_run" {
t.Fatalf("配对键/名字丢失: %+v", got)
}
// ★ 这条是核心:参数必须留存
if got.Arguments["command"] != "ls -la" {
t.Fatalf("★ 参数未记录(这正是本次要还的技术债): %+v", got.Arguments)
}
if !strings.Contains(got.RawArguments, "ls -la") {
t.Fatalf("原始参数未记录: %q", got.RawArguments)
}
}
// ② ToolsUsed 与 ToolCalls 必须同步。
func TestToolCallRecordKeepsToolsUsedInSync(t *testing.T) {
f := &TaskFrame{}
f.appendToolCallRecord(agentAPI.ToolCall{ID: "c1", Name: "a"})
f.appendToolCallRecord(agentAPI.ToolCall{ID: "c2", Name: "b"})
if len(f.ToolsUsed) != len(f.ToolCalls) {
t.Fatalf("★ ToolsUsed(%d) 与 ToolCalls(%d) 不一致 —— 两份数据必须同处维护",
len(f.ToolsUsed), len(f.ToolCalls))
}
for i := range f.ToolsUsed {
if f.ToolsUsed[i] != f.ToolCalls[i].Name {
t.Fatalf("第 %d 项不同步: ToolsUsed=%q ToolCalls.Name=%q",
i, f.ToolsUsed[i], f.ToolCalls[i].Name)
}
}
}
// ③ 参数里的明文必须被脱敏(新增字段不能绕过这一层)。
//
// 这条用 scrubEventSecrets(Append 内的唯一咽喉)验证,
// 而不是验证 appendToolCallRecord —— 后者刻意不脱敏(见其注释:
// 多一个脱敏点就多一个会漏的地方)。
func TestToolCallArgumentsAreScrubbed(t *testing.T) {
evt := &ContextEvent{
ToolCalls: []ToolCallItem{{
CallID: "c1", Name: "enc_shell",
Arguments: map[string]interface{}{"cmd": "curl -H 'Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789'"},
RawArguments: `{"cmd":"ghp_abcdefghijklmnopqrstuvwxyz0123456789"}`,
}},
}
initScrubStore(t)
scrubEventSecrets(evt)
arg, _ := evt.ToolCalls[0].Arguments["cmd"].(string)
if strings.Contains(arg, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") {
t.Fatalf("★ 参数里的明文未被脱敏: %q", arg)
}
if strings.Contains(evt.ToolCalls[0].RawArguments, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") {
t.Fatalf("★ 原始参数里的明文未被脱敏: %q", evt.ToolCalls[0].RawArguments)
}
}
// initScrubStore 装一个启用状态的变量库,供脱敏判据使用。
//
// 单独封装而不是内联:脱敏依赖进程级单例(security.Global),
// 装配与清理成对出现才不会污染同包其他用例。
func initScrubStore(t *testing.T) {
t.Helper()
dir := t.TempDir()
if _, err := security.InitGlobal(security.Options{
Path: dir + "/v.json",
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { security.SetGlobal(nil) })
}

View File

@ -28,7 +28,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
// 删除,两者状态不同,必须显式告知。
//
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
//(自动注入只给实体索引以省 token,细节留给 stablemem_recall)。
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
if mc := a.mediaContextForInjectedEntities(injected); mc != "" {
if s != "" {
@ -47,7 +47,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
//(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。
//
// 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。
if a.docStore != nil {
if a.coldcontextStore != nil {
if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" {
if s != "" {
s += "\n"
@ -213,7 +213,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回
// 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。
prompt = appendPromptBlock(prompt, "[Memory] Recent turns are above. For older conversations use `coldcontext_query` - it **re-inserts them into your timeline at their original timestamps**, so read them there, not in the return value. "+
"Facts and relations: `memory_recall`. Written knowledge: `knowledge_search`.")
"Facts and relations: `stablemem_recall`. Written knowledge: `knowledge_search`.")
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
@ -437,7 +437,7 @@ type toolDefOptions struct {
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
//
// ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil`
// 显式名单登记。原因:工具表是按需构造的(受 `if a.coldcontextStore != nil`
// 之类条件控制),而 textForVector 可能在构造之前被调用 ——
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
//
@ -524,7 +524,7 @@ func init() {
//
// ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记:
// 工具表是**按需构造**的(buildToolDefs 每轮重建,且受
// `if a.docStore != nil` 之类的条件分支控制),而 textForVector
// `if a.coldcontextStore != nil` 之类的条件分支控制),而 textForVector
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
//
@ -607,25 +607,25 @@ func (a *Agent) buildToolDefs() []interface{} {
}
if a.memory != nil {
tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
tools = append(tools, toolDef("stablemem_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
}, "source", "target"))
tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 memory_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{
tools = append(tools, toolDef("stablemem_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 stablemem_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
}, "name"))
tools = append(tools, toolDef("memory_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{
tools = append(tools, toolDef("stablemem_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
}, "entity_a", "entity_b", "rounds"))
tools = append(tools, toolDef("memory_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{
tools = append(tools, toolDef("stablemem_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 stablemem_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
}))
tools = append(tools, toolDef("memory_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{
tools = append(tools, toolDef("stablemem_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{
"old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
"old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
"old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
@ -678,12 +678,12 @@ func (a *Agent) buildToolDefs() []interface{} {
}, "name"))
}
if a.docStore != nil {
if a.coldcontextStore != nil {
// The public tool name remains coldcontext_query for compatibility. Its description
// uses "cold context" so the model does not mistake replayable conversation
// history for a user-authored document.
tools = append(tools, toolDefWith("coldcontext_query",
"Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `memory_recall` instead.\n\n"+
"Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `stablemem_recall` instead.\n\n"+
"The full matching turns are restored into your conversation timeline at their original timestamps (they are inserted back into the timeline, not returned as a summary); read them there. This is consuming: retrieved material leaves cold context.",
map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
@ -691,14 +691,14 @@ func (a *Agent) buildToolDefs() []interface{} {
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("coldcontext_commit",
"Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `memory_commit`; for reusable written knowledge use `knowledge_create`.",
"Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `stablemem_commit`; for reusable written knowledge use `knowledge_create`.",
map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
"media_digests": map[string]interface{}{
"type": "array",
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"description": "可选:这篇文档关联的媒体 digest(对话或 stablemem_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
}, "content"))

View File

@ -15,6 +15,16 @@ const (
ErrReasonUnauthorized = "unauthorized"
ErrReasonTimeout = "timeout"
ErrReasonNotFound = "not_found"
// ErrReasonDenied 是**安全策略拒绝**(不是参数问题,也不是插件没授权)。
//
// ★ 必须与 ErrReasonUnauthorized 分开:两者对模型的可执行指令不同。
//
// unauthorized:这个工具你要不到权限 —— 去找用户要,或换途径
// denied :策略明确禁止这个工具 —— 用户给了权限也没用,**换工具**
//
// 合并成一个码会让模型对策略性禁止反复重试(它以为只是缺授权)。
ErrReasonDenied = "denied"
)
// newToolError 构造一个结构化失败。

View File

@ -0,0 +1,118 @@
package core
// 术语改名的判据(plan.md 裁定 #11:`coldcontext_*` / `stablemem_*`,不再用 doc/graph)。
//
// ## 为什么必须钉(2026-10-29 现场)
//
// 裁定 #11 只落实了一半:`coldcontext_*` 已改,而**图记忆的工具仍叫 `memory_*`**。
// 改名不是纯文字工作,它有三个会静默失效的面:
//
// ① 路由:toolroute 按前缀分派,改前缀不改路由 ⇒ 工具"存在但调不到"
// ② 提示词:BuildToolPrompt / skill / 工具描述里写旧名 ⇒ 模型照着调不存在的工具
// ③ 安全策略:生产配置用的是通配符 `memory_*`,命中不了 `stablemem_*`
// ⇒ 硬私有策略静默失效(用户能读私人记忆)
//
// ③ 最危险:它不报错、不告警,只是拦不住。所以本文件也钉一条策略匹配判据。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/toolroute"
)
// ① 面向模型的稳定记忆工具必须统一用 stablemem_ 前缀。
func TestStableMemoryToolsUseStablememPrefix(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.memory = nil // 只需要名字,不需要真库
// 旧名不得再出现于任何工具定义(会造成"提示词教旧名、定义是新名")。
//
// ★ 此处曾有例外 `def.Name != "memory_document_query"`(2026-10-29 移除)。
// 当时以为那个工具"仍在 memory_ 名下",实际它是改名前的死分支:
// 没有工具定义、Dispatch 也不分派它。例外一旦存在,就会掩盖真问题 ——
// 所以现在**零例外**。
for _, def := range a.ToolDefinitions() {
if strings.HasPrefix(def.Name, "memory_") {
t.Errorf("发现未改名的稳定记忆工具: %s(裁定 #11 要求 stablemem_*)", def.Name)
}
}
}
// ② 路由必须认 stablemem_ 前缀(否则工具存在但调不到)。
//
// ★ 这里直接调 Dispatch,而不是某个"IsBuiltinTool"前缀表 —— 后者曾有**两份**
// 手抄副本(toolroute 与 core 各一份),都零调用方,却在改名时制造了
// "改了 Dispatch 却漏了副本"的真实故障。判据钉**行为**(能不能分派),
// 不钉"某份表里有没有这个字符串"。
func TestStablememToolsAreRouted(t *testing.T) {
h := &routeProbeHandlers{}
for _, n := range []string{"stablemem_recall", "stablemem_purge"} {
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: n}, nil); !handled {
t.Errorf("%s 未被 Dispatch 分派 —— stablemem_ 前缀漏了,工具会变成\"存在但调不到\"", n)
}
}
// ★ 旧前缀 memory_ **不再被分派**(这是改名的语义,不是遗漏)。
//
// 我最初在这里断言"memory_ 应保留兼容分派",它变红了 —— 而红得对:
// Dispatch 里从来没有 memory_ 分支,改名后只有 stablemem_。
// 若为"兼容"而加一条 memory_ 分支,反而会让**旧名重新可用**,
// 与裁定 #11(不再用 graph/doc 时代的名)直接冲突。
//
// 真正需要兼容的是**配置与提示词**(那些已另行处理),不是工具路由:
// 存量插件注册的是自己的工具名,内核的稳定记忆工具由内核定义,
// 不存在"外部还在用旧名调内核工具"的情况。
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "memory_recall"}, nil); handled {
t.Error("memory_ 不应再被分派 —— 旧名重新可用会与裁定 #11 冲突")
}
// 反向:不是内置工具的名字必须返回 handled=false(否则插件工具会被吞掉)。
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "qq_get_message"}, nil); handled {
t.Error("非内置工具不应被 Dispatch 吞掉")
}
}
// routeProbeHandlers 记录 Dispatch 选中了哪个族,全部返回空串即可。
type routeProbeHandlers struct {
memoryCalls int
}
func (h *routeProbeHandlers) ExecutePersonaTool(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteMemoryTool(agentAPI.ToolCall, []string) string {
h.memoryCalls++
return ""
}
func (h *routeProbeHandlers) ExecuteSocialTool(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteKnowledgeTool(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteColdContextTool(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteSecretTool(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteOutputSend(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteOutputSendHelp(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteOutputListChannels() string { return "" }
func (h *routeProbeHandlers) ExecuteInputChannels(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteResidentAgents(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteNotifyParent(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecuteInputchNote(agentAPI.ToolCall) string { return "" }
func (h *routeProbeHandlers) ExecutePluginReload() string { return "" }
func (h *routeProbeHandlers) ExecuteGetPluginTools(string) string { return "" }
// ③ ★ 提示词里不得再教模型调旧工具名。
//
// BuildToolPrompt 是**硬编码字符串**(不是从定义生成),工具一改名它就过期。
// 这类不一致模型会当成事实执行,最终表现为"反复调用不存在的工具"。
func TestToolPromptDoesNotTeachRenamedTools(t *testing.T) {
// BuildToolPrompt 是 Indexer 上的**硬编码字符串**(不是从 toolDefs 生成),
// 工具一改名它就过期 —— 这正是本次改名后真实失败的那个测试。
idx := memory.NewIndexer(nil)
prompt := idx.BuildToolPrompt()
if prompt == "" {
t.Skip("BuildToolPrompt 返回空")
}
for _, stale := range []string{"memory_recall", "memory_commit", "memory_purge", "memory_introspect"} {
if strings.Contains(prompt, stale) {
t.Errorf("工具提示词仍在教旧工具名 %q —— 模型会照着调用不存在的工具", stale)
}
}
}

View File

@ -0,0 +1,140 @@
package core
// 「先确认后删除」的 e2e 判据(P4,2026-10-29)。
//
// ## 钉住的病
//
// 原实现 `Consume` 读+删一步完成:
//
// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了
// restoreDocEvents(d) // 若这里失败 ⇒ L1 没进、L2 已删
//
// ⇒ 三层都不在,内容**永久丢失**。这就是 plan.md 记的第四个病。
//
// ## 判据的构造方式
//
// 「回填失败」在生产里难以自然触发(内存操作基本不失败),所以本文件用
// **可注入的失败**来钉住行为:把 coldcontextStore 换成会在 Remove 时记录调用的
// 版本,并直接验证「Peek 不删」「Remove 只在确认后发生」。
//
// 这样即使将来 InsertByTimestamp 改成会失败,护栏也已经在位。
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
)
// ① Peek 只读:检索后 L2 副本必须还在。
//
// 这是两阶段的前提。若 Peek 偷偷删了,调用方再怎么确认都没意义。
func TestPeekDoesNotDelete(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
var want []ContextEvent
for i := 0; i < 3; i++ {
want = append(want, ContextEvent{
Timestamp: base.Add(time.Duration(i) * 5 * time.Minute),
Source: "peek-src",
Input: "孔雀蓝桌面的选购建议-" + string(rune('A'+i)),
Response: "已记录。",
})
}
archiveEvents(t, a, want)
before := len(a.coldcontextStore.RecentColdContexts(100))
got := a.coldcontextStore.Peek("孔雀蓝桌面", 5)
if len(got) == 0 {
t.Fatal("Peek 未检索到文档")
}
after := len(a.coldcontextStore.RecentColdContexts(100))
if after != before {
t.Fatalf("★ Peek 删了文档:%d → %d(Peek 必须只读)", before, after)
}
// 反复 Peek 也必须幂等(不能"读一次少一点")
_ = a.coldcontextStore.Peek("孔雀蓝桌面", 5)
if again := len(a.coldcontextStore.RecentColdContexts(100)); again != before {
t.Fatalf("★ 重复 Peek 累积删除:%d → %d", before, again)
}
}
// ② 成功路径:回灌后 L2 副本才被删(且确实被删了)。
//
// 两阶段不能退化成"永不删除"—— 那就成了泄漏。
func TestRecallDeletesOnlyAfterRestore(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)
var want []ContextEvent
for i := 0; i < 3; i++ {
want = append(want, ContextEvent{
Timestamp: base.Add(time.Duration(i) * 3 * time.Minute),
Source: "phase-src",
Input: "藤编家具的保养要点-" + string(rune('A'+i)),
Response: "已记录。",
})
}
archiveEvents(t, a, want)
before := len(a.coldcontextStore.RecentColdContexts(100))
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)
}
// 回灌成功 ⇒ L1 有事件
if n := countSource(a.context, "phase-src"); n != 3 {
t.Fatalf("回灌后 L1 有 %d 条 phase-src,期望 3", n)
}
// 且 L2 副本已删(成功后才删)
after := len(a.coldcontextStore.RecentColdContexts(100))
if after >= before {
t.Fatalf("★ 成功回灌后 L2 未清理:%d → %d(会永久泄漏)", before, after)
}
}
// ③ 失败保护:回灌没落地时,L2 副本必须保留。
//
// 用「文档没有任何事件、且降级正文为空」构造出 restore 拿不到内容的情形,
// 断言它仍留在 L2 —— 宁可下次再回忆起,不可静默消失。
func TestFailedRestoreKeepsL2Copy(t *testing.T) {
a := newToolTestAgent(t)
// 直接放一篇"空壳"文档:无 Events、Content 为空 ⇒ 降级也拿不到正文。
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
ID: "doc_empty_shell",
Summary: "空壳文档",
Content: "",
CreatedAt: time.Now(),
Source: "shell-src",
}); err != nil {
t.Fatal(err)
}
before := len(a.coldcontextStore.RecentColdContexts(100))
out := a.executeDocTool(agentAPI.ToolCall{
Name: "coldcontext_query",
Arguments: map[string]interface{}{"query": "空壳文档", "top_k": float64(5)},
})
after := len(a.coldcontextStore.RecentColdContexts(100))
// 无论是否命中,都不允许"取不到内容却删了副本"。
if after < before && strings.Contains(out, "no relevant cold context") {
t.Fatalf("★ 未命中却删了文档:%d → %d", before, after)
}
// 空壳文档即便被当成降级事件插入,也必须有内容落地才算成功;
// 这里只钉住"不能无内容还删副本"这条底线。
if after < before {
t.Logf("空壳文档被删除(降级路径视为成功),L1 事件数=%d",
len(a.context.Recent(0)))
}
}