mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 01:07:35 +00:00
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:
@ -14,7 +14,7 @@ import (
|
||||
var benchChunks = map[string]string{
|
||||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
"content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) {
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"id":"c1","usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"usage":{"prompt":1,"completion":2,"total":3}}`,
|
||||
@ -160,7 +160,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) {
|
||||
func TestChunkFast_Realistic(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
// 流式续传:name 不重发但 function.arguments 继续
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`,
|
||||
|
||||
@ -47,7 +47,7 @@ type DeviceSurface interface {
|
||||
// ## 为什么用族的粒度而不是逐工具
|
||||
//
|
||||
// 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、
|
||||
// coldcontext 族要 docStore…)。按族传接口,device 的分发层就能
|
||||
// coldcontext 族要 coldcontextStore…)。按族传接口,device 的分发层就能
|
||||
// 在不认识 Agent 内部字段的前提下完整接管**路由**:
|
||||
//
|
||||
// device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 == "文档" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 表示应继续走插件/设备路径)。
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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}`},
|
||||
},
|
||||
}},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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},
|
||||
}
|
||||
|
||||
@ -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{}{
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)不处理 —— 那是二进制负载,
|
||||
// 正则扫它既无意义也会损坏数据。
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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 是按**指定保护条数**推导保留条数的版本。
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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。
|
||||
|
||||
338
internal/agent/core/page_evict_e2e_test.go
Normal file
338
internal/agent/core/page_evict_e2e_test.go
Normal 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("归档丢失了全部内容")
|
||||
}
|
||||
}
|
||||
@ -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" 这段文本。
|
||||
//
|
||||
// ⇒ 正确的基准是:**归属表的名字必须是内核真的会产出的工具名**。
|
||||
//
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
243
internal/agent/core/recall_plan_e2e_test.go
Normal file
243
internal/agent/core/recall_plan_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
126
internal/agent/core/security_toolgate_test.go
Normal file
126
internal/agent/core/security_toolgate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
205
internal/agent/core/segpaging_e2e_test.go
Normal file
205
internal/agent/core/segpaging_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
116
internal/agent/core/toolcall_record_test.go
Normal file
116
internal/agent/core/toolcall_record_test.go
Normal 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) })
|
||||
}
|
||||
@ -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"))
|
||||
|
||||
@ -15,6 +15,16 @@ const (
|
||||
ErrReasonUnauthorized = "unauthorized"
|
||||
ErrReasonTimeout = "timeout"
|
||||
ErrReasonNotFound = "not_found"
|
||||
|
||||
// ErrReasonDenied 是**安全策略拒绝**(不是参数问题,也不是插件没授权)。
|
||||
//
|
||||
// ★ 必须与 ErrReasonUnauthorized 分开:两者对模型的可执行指令不同。
|
||||
//
|
||||
// unauthorized:这个工具你要不到权限 —— 去找用户要,或换途径
|
||||
// denied :策略明确禁止这个工具 —— 用户给了权限也没用,**换工具**
|
||||
//
|
||||
// 合并成一个码会让模型对策略性禁止反复重试(它以为只是缺授权)。
|
||||
ErrReasonDenied = "denied"
|
||||
)
|
||||
|
||||
// newToolError 构造一个结构化失败。
|
||||
|
||||
118
internal/agent/core/toolname_rename_test.go
Normal file
118
internal/agent/core/toolname_rename_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
internal/agent/core/twophase_recall_e2e_test.go
Normal file
140
internal/agent/core/twophase_recall_e2e_test.go
Normal 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)))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user