feat: 段页式记忆流转、coldcontext 重命名与安全治理

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

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

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

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -0,0 +1,445 @@
// Command homed-predicate-migrate 把存量关系谓词收敛到**封闭集**,默认只统计不写库。
//
// 背景:生产库 relations 表 964 条 active 边对应 690 种不同 relation_type,
// 其中只出现 1 次的有 559 种(80%)。同一关系千种写法 ⇒ memory_recall
// 不可能靠关系名命中。谓词不封闭是召回质量差的直接原因之一。
//
// 数据来源:封闭集与映射表由外部起草后交付(JSON),本工具只消费、不改它。
//
// closed-predicate/predicates.md 30 个语义角色谓词(人读)
// closed-predicate/mapping.json 690 种旧谓词 → 新谓词 或 DROP(机器读)
// closed-predicate/invert.json 属性名反转规则(可选,机器读)
//
// 安全设计(与 homed-kb-migrate 同一套惯例):
// 1. **默认只报告**。-apply 才写库。谓词改写会影响召回,不可逆。
// 2. 冲突检查为 0 才允许 -apply:同一旧谓词映射到多个新谓词属歧义,
// 真写库会造成不可预测结果。
// 3. count 求和必须等于 active 边数:不等说明 mapping 与库已不同步
// (库被写过 / mapping 基于旧快照),此时拒绝 apply。
//
// 用法:
//
// homed-predicate-migrate -db /data/memory/graph.db -mapping closed-predicate
// homed-predicate-migrate -db ... -mapping ... -apply
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"sort"
_ "github.com/mattn/go-sqlite3"
)
// mapEntry 是 mapping.json 的一条:旧谓词 → 新谓词(new 为空表示丢弃)。
type mapEntry struct {
Old string `json:"old"`
Count int `json:"count"`
New *string `json:"new"`
Reason string `json:"reason"`
// verdict 是 dry-run 判定的最终去向,由 invert.json 参与后决定:
// mapped / debris / pending_invert / unmapped
verdict string
// invertTo 是反转后的谓词(仅 pending_invert 有值)
invertTo string
}
// invertSpec 是 invert.json 的**规则式**格式(不是 old→new 直映)。
//
// 为什么是规则:抽取侧把「值」提到了谓词位(如「教室」其实是属性名、
// 真正的谓词空缺),需要按**宾语形态**反推真实关系,而不是按谓词查表。
// 规则里 applies_to 指明它作用于哪类 reason(drop_unmapped / drop_funcword)。
type invertSpec struct {
Version string `json:"version"`
Rules []invertRule `json:"rules"`
// AttributeNounWhitelist 是「旧谓词本身是属性名词」的白名单。
AttributeNounWhitelist []string `json:"attribute_noun_whitelist"`
// ObjectValueSignals 是值形态信号(含数字、长度区间、拒收前缀)。
ObjectValueSignals struct {
ContainsDigit bool `json:"contains_digit"`
LengthMin int `json:"length_min"`
LengthMax int `json:"length_max"`
RejectPrefix []string `json:"reject_prefix"`
} `json:"object_value_signals"`
}
type invertRule struct {
ID string `json:"id"`
AppliesTo []string `json:"applies_to"`
New *string `json:"new"` // null 表示该规则不反转(仍丢弃)
When struct {
RelationTypeIn []string `json:"relation_type_in"`
RelationTypeInWhitelist bool `json:"relation_type_in_whitelist"`
ObjectIsValue bool `json:"object_is_value"`
} `json:"when"`
}
func main() {
dbPath := flag.String("db", "", "graph.db 路径")
mapDir := flag.String("mapping", "", "封闭集目录(含 mapping.json / invert.json)")
apply := flag.Bool("apply", false, "真正写库(默认只报告)")
topN := flag.Int("top", 40, "丢弃明细里按 count 列出的条数(0=全列)")
flag.Parse()
if *dbPath == "" || *mapDir == "" {
fmt.Fprintln(os.Stderr, "必须提供 -db 与 -mapping")
os.Exit(2)
}
entries, err := loadMapping(*mapDir)
if err != nil {
fail("读取 mapping: %v", err)
}
inverts, err := loadInvert(*mapDir)
if err != nil {
// invert.json 可选:未到位时只报告,不阻断
fmt.Fprintf(os.Stderr, "[warn] invert.json 未就位(%v),pending_invert 将无法解析\n", err)
}
// dry-run 用只读 URI(mattn 驱动要求 file: 前缀);-apply 才可写。
dsn := *dbPath
if !*apply {
dsn = "file:" + *dbPath + "?mode=ro"
}
db, err := sql.Open("sqlite3", dsn)
if err != nil {
fail("打开库: %v", err)
}
defer db.Close()
// ① 库侧真实分布(mapping 的 count 只是快照,必须与库核对)
dbCounts, total, err := loadDBCounts(db)
if err != nil {
fail("读库: %v", err)
}
sumCounts := 0
for _, e := range entries {
sumCounts += e.Count
}
fmt.Printf("库:%d 种 relation_type,%d 条 active 边\n", len(dbCounts), total)
fmt.Printf("mapping:%d 条,count 求和 %d\n", len(entries), sumCounts)
// 判据 1:mapping 与库同步
syncOK := sumCounts == total
if !syncOK {
fmt.Printf("❌ 判据1 失败:mapping count 求和 %d != 库 active 边 %d(mapping 与库不同步)\n", sumCounts, total)
} else {
fmt.Printf("✅ 判据1:mapping 与库同步(%d == %d)\n", sumCounts, total)
}
// ② 判定去向
applyInvert(entries, inverts)
verdicts := tally(entries)
// 判据 2:无歧义(同一旧谓词只能有一个去向)
conflicts := findConflicts(entries)
if len(conflicts) > 0 {
fmt.Printf("❌ 判据2 失败:%d 个旧谓词映射有歧义\n", len(conflicts))
for _, c := range conflicts {
fmt.Printf(" %s\n", c)
}
} else {
fmt.Printf("✅ 判据2:无歧义映射\n")
}
// ③ 四张表
printByNewPredicate(entries)
printDrops(entries, *topN)
printCoverage(entries, total, verdicts)
// 判据 3:写库前复核(写前再查一次,防 dry-run 到 apply 之间库被改)
if *apply {
if !syncOK || len(conflicts) > 0 {
fail("拒绝 apply:判据未全绿(见上)")
}
if err := applyChanges(db, entries); err != nil {
fail("写库: %v", err)
}
fmt.Println("\n已写库。")
} else {
fmt.Println("\n(dry-run:未写库。确认无误后加 -apply)")
}
}
func loadMapping(dir string) ([]*mapEntry, error) {
b, err := os.ReadFile(filepath.Join(dir, "mapping.json"))
if err != nil {
return nil, err
}
var raw []*mapEntry
if err := json.Unmarshal(b, &raw); err != nil {
return nil, err
}
return raw, nil
}
func loadInvert(dir string) (*invertSpec, error) {
b, err := os.ReadFile(filepath.Join(dir, "invert.json"))
if err != nil {
return nil, err
}
var spec invertSpec
if err := json.Unmarshal(b, &spec); err != nil {
return nil, err
}
return &spec, nil
}
// resolveInvert 按规则判定某条旧谓词是否应反转,返回反转后的谓词。
//
// 判定只用**谓词本身**能否命中规则条件(宾语形态规则需要读边表才能判,
// dry-run 阶段只统计「谓词可判定」的部分,宾语相关的留给 apply 前的逐条检查)。
func (sp *invertSpec) resolveInvert(e *mapEntry) (string, bool) {
if sp == nil {
return "", false
}
whitelist := make(map[string]bool, len(sp.AttributeNounWhitelist))
for _, w := range sp.AttributeNounWhitelist {
whitelist[w] = true
}
for _, r := range sp.Rules {
// applies_to 必须包含本条的 reason
match := false
for _, a := range r.AppliesTo {
if a == e.Reason {
match = true
break
}
}
if !match {
continue
}
// 规则要求"谓词在属性名词白名单里"
if r.When.RelationTypeInWhitelist && whitelist[e.Old] {
if r.New != nil && *r.New != "" {
return *r.New, true
}
return "", false
}
// 规则枚举了具体谓词(如系词)
for _, w := range r.When.RelationTypeIn {
if w == e.Old {
if r.New != nil && *r.New != "" {
return *r.New, true
}
return "", false
}
}
}
return "", false
}
func loadDBCounts(db *sql.DB) (map[string]int, int, error) {
rows, err := db.Query(`SELECT relation_type, COUNT(*) FROM relations WHERE status='active' GROUP BY relation_type`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := map[string]int{}
total := 0
for rows.Next() {
var k string
var c int
if err := rows.Scan(&k, &c); err != nil {
return nil, 0, err
}
out[k] = c
total += c
}
return out, total, rows.Err()
}
// applyInvert 决定每条 mapping 的最终去向。
//
// 有 new ⇒ mapped;无 new 但 invert 表里命中 ⇒ pending_invert;
// 否则按 reason 分 debris(碎片)或 unmapped(待判断)。
func applyInvert(entries []*mapEntry, sp *invertSpec) {
for _, e := range entries {
if e.New != nil && *e.New != "" {
e.verdict = "mapped"
continue
}
if to, ok := sp.resolveInvert(e); ok {
e.verdict = "pending_invert"
e.invertTo = to
continue
}
if e.Reason == "drop_funcword" || e.Reason == "drop_broken_punct" ||
e.Reason == "drop_too_short" || e.Reason == "drop_too_long" {
e.verdict = "debris"
} else {
e.verdict = "unmapped"
}
}
}
func tally(entries []*mapEntry) map[string]int {
m := map[string]int{}
for _, e := range entries {
m[e.verdict]++
}
return m
}
// findConflicts 检查同一旧谓词是否有多个去向(mapping 里出现重复 old)。
func findConflicts(entries []*mapEntry) []string {
seen := map[string]string{}
var out []string
for _, e := range entries {
to := e.verdict
if e.New != nil {
to = "new:" + *e.New
}
if prev, ok := seen[e.Old]; ok && prev != to {
out = append(out, fmt.Sprintf("%s: %s vs %s", e.Old, prev, to))
}
seen[e.Old] = to
}
return out
}
func printByNewPredicate(entries []*mapEntry) {
type agg struct{ types, edges int }
m := map[string]*agg{}
for _, e := range entries {
if e.verdict == "mapped" {
if m[*e.New] == nil {
m[*e.New] = &agg{}
}
m[*e.New].types++
m[*e.New].edges += e.Count
}
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return m[keys[i]].edges > m[keys[j]].edges })
fmt.Printf("\n## 表1 按新谓词聚合(%d 个有收编)\n", len(m))
fmt.Printf("%-20s %8s %8s\n", "新谓词", "旧种数", "边数")
for _, k := range keys {
fmt.Printf("%-20s %8d %8d\n", k, m[k].types, m[k].edges)
}
}
func printDrops(entries []*mapEntry, topN int) {
var drops []*mapEntry
for _, e := range entries {
if e.verdict != "mapped" {
drops = append(drops, e)
}
}
sort.Slice(drops, func(i, j int) bool { return drops[i].Count > drops[j].Count })
fmt.Printf("\n## 表2 丢弃/待反转明细(%d 种,按 count 降序)\n", len(drops))
if topN > 0 && len(drops) > topN {
fmt.Printf("(仅列前 %d 条,-top 0 可全列)\n", topN)
}
fmt.Printf("%6s %-14s %-26s %s\n", "count", "去向", "旧谓词", "说明")
for i, e := range drops {
if topN > 0 && i >= topN {
break
}
note := e.Reason
if e.verdict == "pending_invert" {
note = "反转→" + e.invertTo
}
fmt.Printf("%6d %-14s %-26s %s\n", e.Count, e.verdict, truncate(e.Old, 24), note)
}
}
// printCoverage 算两版覆盖率:只算 mapped,与 mapped+pending_invert。
func printCoverage(entries []*mapEntry, total int, verdicts map[string]int) {
mapped, invert := 0, 0
for _, e := range entries {
switch e.verdict {
case "mapped":
mapped += e.Count
invert += e.Count
case "pending_invert":
invert += e.Count
}
}
fmt.Printf("\n## 表3 覆盖率\n")
fmt.Printf("库 active 边总数 %5d\n", total)
fmt.Printf("mapped 保留 %5d (%.1f%%)\n", mapped, pct(mapped, total))
fmt.Printf("mapped+invert 保留 %5d (%.1f%%)\n", invert, pct(invert, total))
fmt.Printf("真正丢弃 %5d (%.1f%%)\n", total-invert, pct(total-invert, total))
fmt.Printf("\n判定去向分布:")
for k, v := range verdicts {
fmt.Printf(" %s=%d", k, v)
}
fmt.Println()
}
func applyChanges(db *sql.DB, entries []*mapEntry) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
upd, err := tx.Prepare(`UPDATE relations SET relation_type=?, updated_at=datetime('now') WHERE relation_type=? AND status='active'`)
if err != nil {
return err
}
defer upd.Close()
del, err := tx.Prepare(`UPDATE relations SET status='deleted', updated_at=datetime('now') WHERE relation_type=? AND status='active'`)
if err != nil {
return err
}
defer del.Close()
nUpd, nDel := 0, 0
for _, e := range entries {
switch e.verdict {
case "mapped":
r, err := upd.Exec(*e.New, e.Old)
if err != nil {
return fmt.Errorf("更新 %s: %w", e.Old, err)
}
c, _ := r.RowsAffected()
nUpd += int(c)
case "debris":
r, err := del.Exec(e.Old)
if err != nil {
return fmt.Errorf("删除 %s: %w", e.Old, err)
}
c, _ := r.RowsAffected()
nDel += int(c)
}
}
if err := tx.Commit(); err != nil {
return err
}
fmt.Printf("更新 %d 条,标记删除 %d 条\n", nUpd, nDel)
return nil
}
func pct(a, b int) float64 {
if b == 0 {
return 0
}
return float64(a) / float64(b) * 100
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}

View File

@ -340,7 +340,7 @@ func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{
// 路径 B(exit 42):下面的 os.Exit(42) **跳过所有 defer** ✗ // 路径 B(exit 42):下面的 os.Exit(42) **跳过所有 defer** ✗
// //
// 而路径 B 是**每次热更新都走**的。后果是这些写者从不 flush: // 而路径 B 是**每次热更新都走**的。后果是这些写者从不 flush:
// · docStore.Stop() → flush() 文档索引可能陈旧 // · coldcontextStore.Stop() → flush() 文档索引可能陈旧
// · distiller.Stop() → flush() 待蒸馏规则丢失 // · distiller.Stop() → flush() 待蒸馏规则丢失
// · textMem.Stop() → Close() 文本记忆未落盘 // · textMem.Stop() → Close() 文本记忆未落盘
// · luaVM/multimodal → 资源未释放(靠进程退出兜底) // · luaVM/multimodal → 资源未释放(靠进程退出兜底)

View File

@ -13,7 +13,7 @@ package main
// //
// 而路径 B 是**每次热更新都走**的(guard 靠 exit 42 判定"主动重启")。 // 而路径 B 是**每次热更新都走**的(guard 靠 exit 42 判定"主动重启")。
// 后果是这些写者从不 flush: // 后果是这些写者从不 flush:
// · docStore.Stop() → flush() 文档索引可能陈旧 // · coldcontextStore.Stop() → flush() 文档索引可能陈旧
// · distiller.Stop() → flush() 待蒸馏规则丢失 // · distiller.Stop() → flush() 待蒸馏规则丢失
// · textMem.Stop() → Close() 文本记忆未落盘 // · textMem.Stop() → Close() 文本记忆未落盘
// //

View File

@ -14,7 +14,7 @@ import (
var benchChunks = map[string]string{ 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_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}]}`, "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}}`, "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"}]}`, "finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
} }

View File

@ -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":{"content":"hi"},"finish_reason":"stop"}]}`,
`{"id":"c1","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`, `{"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":{},"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","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
`{"id":"c1","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}}`, `{"usage":{"prompt":1,"completion":2,"total":3}}`,
@ -160,7 +160,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) {
func TestChunkFast_Realistic(t *testing.T) { func TestChunkFast_Realistic(t *testing.T) {
cases := []string{ 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":{"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}}`, `{"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 继续 // 流式续传:name 不重发但 function.arguments 继续
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`, `{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`,

View File

@ -47,7 +47,7 @@ type DeviceSurface interface {
// ## 为什么用族的粒度而不是逐工具 // ## 为什么用族的粒度而不是逐工具
// //
// 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、 // 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、
// coldcontext 族要 docStore…)。按族传接口,device 的分发层就能 // coldcontext 族要 coldcontextStore…)。按族传接口,device 的分发层就能
// 在不认识 Agent 内部字段的前提下完整接管**路由**: // 在不认识 Agent 内部字段的前提下完整接管**路由**:
// //
// device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc) // device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc)

View File

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

View File

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

View File

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

View File

@ -119,7 +119,7 @@ func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
name: "screen", name: "screen",
caps: agentIO.CapText, 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() tools := a.buildToolDefs()
foundSend := false foundSend := false

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@ -13,16 +14,53 @@ import (
"time" "time"
"github.com/JianFeeeee/HologramAgent/internal/memory" "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/memory/vector"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" 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 { type ToolResultItem struct {
// CallID 回指 ToolCall.ID,是 tool_call 与 tool_result 的配对键。
//
// 空值表示旧数据(未记录 CallID),渲染时按位置配对(既有行为)。
CallID string `json:"call_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Output string `json:"output"` 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 { type ContextEvent struct {
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。 // ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
// //
@ -36,6 +74,39 @@ type ContextEvent struct {
ToolsUsed []string `json:"tools_used,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,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 是本事件捕获的「超大工具结果」落盘记录。 // ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。
// //
// ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**, // ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**,
@ -165,6 +236,480 @@ func (a *Agent) singleEventFitsBudget() bool {
return avg <= a.computeTokenBudget().ContextTokens 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)。 // ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。
func (c *RelevanceContext) ProtectedCount() int { func (c *RelevanceContext) ProtectedCount() int {
if c == nil || c.protectedCount <= 0 { if c == nil || c.protectedCount <= 0 {
@ -349,7 +894,7 @@ func (c *RelevanceContext) inputChannelClean(source, input string) string {
} }
// channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。 // channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。
func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner { func (c *RelevanceContext) channelCleanerForDoc() coldcontext.ChannelCleaner {
if c.channelDefLookup == nil { if c.channelDefLookup == nil {
return nil return nil
} }
@ -511,15 +1056,37 @@ type scoredEvent struct {
// pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传 // pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传
// effectiveProtectedCount,按预算收紧后的值。 // effectiveProtectedCount,按预算收紧后的值。
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { func (c *RelevanceContext) Prune(currentInput string, topK int, coldcontextStore *coldcontext.Store) int {
return c.PruneWithProtected(currentInput, topK, docStore, 0) 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。 // 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() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
// ★ 每次 Prune 都重置剔除标记(用户裁定,2026-10-29)。
//
// 为什么必须重置:InactivePages 是"本次渲染在有限窗口里少渲染几页"的
// 临时决定,它依赖当时的**会话窗口状态**(哪些帧在做、查询是什么、
// 预算还剩多少)。Prune 之后这些都变了 —— 沿用旧标记就是把
// 上一轮的预算压力带到这一轮,于是某个帧"永久性"缺几页。
//
// 放在函数最前面(包括提前返回的那条路径之前):只要 Prune 被调用,
// 就说明窗口被重新规划过,旧标记一律失效。
c.resetInactivePagesLocked()
if len(c.events) <= topK { if len(c.events) <= topK {
return 0 return 0
} }
@ -588,7 +1155,7 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
}) })
archived := 0 archived := 0
if docStore != nil && len(archive) > 0 { if coldcontextStore != nil && len(archive) > 0 {
// ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。 // ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。
// //
// 曾经在这里跳过它们(理由是"防自激"),那是错的: // 曾经在这里跳过它们(理由是"防自激"),那是错的:
@ -598,19 +1165,20 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 —— // 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——
// 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。 // 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。
} }
if docStore != nil && len(archive) > 0 { if coldcontextStore != nil && len(archive) > 0 {
entries := make([]document.ContextEntry, len(archive)) entries := make([]coldcontext.ContextEntry, len(archive))
for i, s := range archive { for i, s := range archive {
entries[i] = document.ContextEntry{ entries[i] = coldcontext.ContextEntry{
Timestamp: s.event.Timestamp, Timestamp: s.event.Timestamp,
Source: s.event.Source, Source: s.event.Source,
Content: s.event.Input, Content: s.event.Input,
Response: s.event.Response, Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults), ToolResults: convertToolResults(s.event.ToolResults),
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), 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 { if err == nil && doc != nil {
archived = len(entries) archived = len(entries)
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。 // 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
@ -715,13 +1283,51 @@ func (c *RelevanceContext) Len() int {
return len(c.events) 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 { if items == nil {
return nil return nil
} }
result := make([]document.ToolResultItem, len(items)) result := make([]coldcontext.ToolResultItem, len(items))
for i, item := range 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 return result
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,6 +60,31 @@ func scrubEventSecrets(evt *ContextEvent) {
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts) 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)不处理 —— 那是二进制负载, // 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
// 正则扫它既无意义也会损坏数据。 // 正则扫它既无意义也会损坏数据。

View File

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

View File

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

View File

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

View File

@ -273,7 +273,7 @@ func resumeQueryFrom(f *TaskFrame) string {
// handleContextOverflow 执行「裁剪 + L4 打断」。 // handleContextOverflow 执行「裁剪 + L4 打断」。
// //
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由: // 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore), // - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 coldcontextStore),
// 把它放进 L4 任务里做只会多付一次完整任务调度开销; // 把它放进 L4 任务里做只会多付一次完整任务调度开销;
// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。 // - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。
// //
@ -292,6 +292,25 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
return 0 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 按原始设计修正)。 // ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。
// //
// 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。 // 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。
@ -308,6 +327,49 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
return 0 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 记录一次「超页但裁不出东西」的终止,供状态面观测。 // markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
func (a *Agent) markOverflowAborted(ratio float64, events int) { func (a *Agent) markOverflowAborted(ratio float64, events int) {
a.overflowStat.Lock() a.overflowStat.Lock()

View File

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

View File

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

View File

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

View File

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

View File

@ -511,11 +511,11 @@ func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
return result return result
} }
func (a *Agent) docStoreSize() int { func (a *Agent) coldcontextStoreSize() int {
if a.docStore == nil { if a.coldcontextStore == nil {
return 0 return 0
} }
s := a.docStore.Stats() s := a.coldcontextStore.Stats()
if n, ok := s["doc_count"]; ok { if n, ok := s["doc_count"]; ok {
if ni, ok := n.(int); ok { if ni, ok := n.(int); ok {
return ni return ni
@ -582,9 +582,29 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
} }
// 工具结果在 context 中**展开**(全文,不截断)。 // 工具结果在 context 中**展开**(全文,不截断)。
// 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。 // 只有**超长**结果按工具环设计折叠成句柄,模型用 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)) 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() 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 { func (a *Agent) timelineToolLines(e *ContextEvent) []string {
if e == nil || len(e.ToolResults) == 0 { if e == nil || len(e.ToolResults) == 0 {
return nil return nil

View File

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

View File

@ -23,6 +23,26 @@ import (
// //
// 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。 // 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。
// 文件权限 0600 —— 内容是用户隐私。 // 文件权限 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" const promptDumpEnv = "HOMED_PROMPT_DUMP"
var promptDumpMu sync.Mutex var promptDumpMu sync.Mutex
@ -59,6 +79,7 @@ func dumpPrompt(channel string, req *agentAPI.CompletionRequest) {
promptDumpMu.Lock() promptDumpMu.Lock()
defer promptDumpMu.Unlock() defer promptDumpMu.Unlock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil { if err != nil {
return return

View File

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

View File

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

View File

@ -100,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) {
} }
// 无事件时不 panic,且只有工具特征时也成立 // 无事件时不 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) t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -103,10 +103,13 @@ type TaskFrame struct {
StageCtx *sdk.StageContext StageCtx *sdk.StageContext
// 跨轮次状态 // 跨轮次状态
Msgs []agentAPI.Message Msgs []agentAPI.Message
Tools []interface{} Tools []interface{}
ToolsUsed []string ToolsUsed []string
ToolResults []ToolResultItem ToolResults []ToolResultItem
// ToolCalls 是每一次工具调用的完整记录(含参数)。与 ToolResults 通过
// CallID 配对;两者都由 appendToolCallRecord 同步追加,不各写一份。
ToolCalls []ToolCallItem
Turn int Turn int
LastBatchReplyOnly bool LastBatchReplyOnly bool
@ -578,6 +581,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
Response: f.Response, Response: f.Response,
ToolsUsed: f.ToolsUsed, ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults, ToolResults: f.ToolResults,
ToolCalls: f.ToolCalls,
} }
a.bindEventMedia(&turnEvt, a.drainMediaBlocks()) a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append), // ★ 明文拦截发生在 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, budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
len(f.Tools), a.context.Len(), len(f.Tools), a.context.Len(),
a.personality != nil && a.personality.Content != "", a.personality != nil && a.personality.Content != "",
a.docStoreSize()) a.coldcontextStoreSize())
if a.runStage(sdk.StagePreAction, f.StageCtx) { if a.runStage(sdk.StagePreAction, f.StageCtx) {
f.Response = *f.StageCtx.Response f.Response = *f.StageCtx.Response
@ -956,7 +960,7 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome {
f.CurResult = r.text f.CurResult = r.text
f.CurRaw = r.raw f.CurRaw = r.raw
f.ToolIdx = i 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 { if out := a.stepToolAfter(f); out != outcomeContinue {
return out return out
} }
@ -1034,7 +1038,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
} }
tc := f.PendingTools[f.ToolIdx] tc := f.PendingTools[f.ToolIdx]
f.ToolsUsed = append(f.ToolsUsed, tc.Name) f.appendToolCallRecord(tc)
pluginName := a.resolveToolPlugin(tc.Name) pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID) log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
if tc.RawArguments != "" { if tc.RawArguments != "" {
@ -1096,6 +1100,41 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string {
return fmt.Sprintf("工具 %s 已被插件拒绝", toolName) 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。 // stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome { func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路), // 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
@ -1105,7 +1144,7 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := outcome.Text result := outcome.Text
f.CurResult = result f.CurResult = result
f.CurRaw = outcome.Raw 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, // ★ 工具输出可能含服务端回显的凭据(实测场景:enc_shell 跑 curl 拿 200,
// 响应体里带着上游 key 的完整值)。这一行以前原样打印。 // 响应体里带着上游 key 的完整值)。这一行以前原样打印。
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, log.Printf("[agent] tool %s result: %s", f.CurTool.Name,

View File

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

View File

@ -12,7 +12,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory" "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" "github.com/JianFeeeee/HologramAgent/internal/memory/text"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security" "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} 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`)。 // ★ 内置工具的路由交给 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 { func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
g := a.graphMem() g := a.graphMem()
if g == nil { if g == nil {
if tc.Name == "memory_document_query" { // ★ 此处曾有 `if tc.Name == "memory_document_query" { return a.executeDocTool(tc) }`
return a.executeDocTool(tc) // (2026-10-29 删除)。它是改名前的残留:文档类工具早已改叫
} // `coldcontext_*`,而 Dispatch 只把 `stablemem_` 前缀送到本函数,
// 所以这个分支**永远不可达**。留着它的唯一效果是让读代码的人
// 以为旧工具名还活着。
return "stable memory is unavailable" 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 "toolresult_read failed: " + err.Error()
} }
return txt return txt
case "memory_recall": case "stablemem_recall":
query, _ := tc.Arguments["query_intent"].(string) query, _ := tc.Arguments["query_intent"].(string)
depth, _ := tc.Arguments["depth"].(float64) depth, _ := tc.Arguments["depth"].(float64)
if depth <= 0 { if depth <= 0 {
@ -492,7 +521,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
} }
return strings.Join(parts, "\n") return strings.Join(parts, "\n")
case "memory_block_merge": case "stablemem_block_merge":
if msg := requireFull(); msg != "" { if msg := requireFull(); msg != "" {
return msg return msg
} }
@ -511,7 +540,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
a.noMergeMu.Unlock() a.noMergeMu.Unlock()
return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds)) 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{}) triplesData, ok := tc.Arguments["triples"].([]interface{})
if !ok { if !ok {
return "invalid arguments: triples must be an array" 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) return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges)
case "memory_introspect": case "stablemem_introspect":
if msg := requireFull(); msg != "" { if msg := requireFull(); msg != "" {
return msg return msg
} }
@ -648,10 +677,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
} }
return fmt.Sprintf("memory stats: %v", stats) return fmt.Sprintf("memory stats: %v", stats)
case "memory_document_query": case "stablemem_merge":
return a.executeDocTool(tc)
case "memory_merge":
if msg := requireFull(); msg != "" { if msg := requireFull(); msg != "" {
return 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) 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 != "" { if msg := requireFull(); msg != "" {
return 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)", return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)",
name, res.Edges, res.Blocks) name, res.Edges, res.Blocks)
case "memory_purge": case "stablemem_purge":
if msg := requireFull(); msg != "" { if msg := requireFull(); msg != "" {
return msg return msg
} }
@ -726,7 +752,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
} }
return strings.Join(parts, ",") return strings.Join(parts, ",")
case "memory_edit": case "stablemem_edit":
if msg := requireFull(); msg != "" { if msg := requireFull(); msg != "" {
return msg return msg
} }
@ -1062,7 +1088,7 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
} }
func (a *Agent) executeDocTool(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" return "cold context is unavailable"
} }
switch tc.Name { switch tc.Name {
@ -1075,22 +1101,62 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if query == "" { if query == "" {
return "query is required" 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 { if len(docs) == 0 {
return "no relevant cold context found" 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 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 totalEvents := 0
kept := 0 // 回灌失败、保留在 L2 的篇数
for i, d := range docs { for i, d := range docs {
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。 // 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
// //
// ARCHITECTURE「记忆流转」② Document 层: // ARCHITECTURE「记忆流转」② Document 层:
// coldcontext_query → Consume(读取并删除) // coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d) n := a.restoreDocEvents(d, plan.Evict[d.ID])
restored := n > 0 // 是否真的落进了 L1
if n == 0 { if n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件。 // 存量文档(无 Events)降级:整篇渲成一个事件。
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。 // 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
@ -1106,23 +1172,36 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
NoMemory: true, // 同上:回填不产生新记忆 NoMemory: true, // 同上:回填不产生新记忆
}) })
n = 1 n = 1
restored = true
} }
totalEvents += n 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」—— // 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
// jieba 词袋,零信息量,模型无法据此判断相关性。 // 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(preview)
} }
} }
b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents)) 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() return b.String()
case "coldcontext_commit": case "coldcontext_commit":
@ -1135,7 +1214,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
summary = truncateStr(content, 100) summary = truncateStr(content, 100)
} }
doc := &document.Doc{ doc := &coldcontext.ColdDoc{
Summary: summary, Summary: summary,
Content: content, Content: content,
Source: "manual", 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) return fmt.Sprintf("cold context write failed: %v", err)
} }
if n := len(doc.Blocks); n > 0 { if n := len(doc.Blocks); n > 0 {
@ -1332,7 +1411,7 @@ func (a *Agent) currentFingerprint() string {
// //
// coldcontext_query → Consume(读取并删除) // coldcontext_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} // → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 // → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除
// //
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1, // L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
// 块 ID 与向量全程不变(不重建、不重算)。 // 块 ID 与向量全程不变(不重建、不重算)。
@ -1341,7 +1420,12 @@ func (a *Agent) currentFingerprint() string {
// //
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。 // Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。 // 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 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() { if a == nil || a.context == nil || d == nil || !d.HasEvents() {
return 0 return 0
} }
@ -1368,11 +1452,29 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
ToolResults: func() []ToolResultItem { ToolResults: func() []ToolResultItem {
var out []ToolResultItem var out []ToolResultItem
for _, tr := range ev.ToolResults { 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 return out
}(), }(),
Blocks: append([]memory.MemoryBlock(nil), held...), Blocks: append([]memory.MemoryBlock(nil), held...),
// 召回期剔除的页(裁剪预算用):标注但数据保留
InactivePages: inactivePages,
}) })
n++ n++
blocks += len(held) blocks += len(held)
@ -1385,12 +1487,12 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
return n return n
} }
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。 // coldcontextRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
// //
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"), // 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用 // 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// coldcontext_query 的直接原因之一。 // coldcontext_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string { func coldcontextRecallHeadline(d *coldcontext.ColdDoc, eventCount int) string {
if d == nil { if d == nil {
return "" return ""
} }
@ -1440,11 +1542,11 @@ func docRecallHeadline(d *document.Doc, eventCount int) string {
return strings.Join(parts, " · ") return strings.Join(parts, " · ")
} }
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。 // coldcontextRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
// //
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填), // 只取前 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 { if d == nil || maxLines <= 0 {
return "" return ""
} }

View File

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

View File

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

View File

@ -28,7 +28,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
// 删除,两者状态不同,必须显式告知。 // 删除,两者状态不同,必须显式告知。
// //
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil // 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。 //(自动注入只给实体索引以省 token,细节留给 stablemem_recall)。
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。 // 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
if mc := a.mediaContextForInjectedEntities(injected); mc != "" { if mc := a.mediaContextForInjectedEntities(injected); mc != "" {
if s != "" { if s != "" {
@ -47,7 +47,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
//(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。 //(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。
// //
// 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。 // 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。
if a.docStore != nil { if a.coldcontextStore != nil {
if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" { if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" {
if s != "" { if s != "" {
s += "\n" s += "\n"
@ -213,7 +213,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回 // ★ 但有一件事必须说: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. "+ 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_* 的工具描述里 // 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。 //(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
@ -437,7 +437,7 @@ type toolDefOptions struct {
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。 // 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
// //
// ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的 // ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil` // 显式名单登记。原因:工具表是按需构造的(受 `if a.coldcontextStore != nil`
// 之类条件控制),而 textForVector 可能在构造之前被调用 —— // 之类条件控制),而 textForVector 可能在构造之前被调用 ——
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。 // 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
// //
@ -524,7 +524,7 @@ func init() {
// //
// ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记: // ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记:
// 工具表是**按需构造**的(buildToolDefs 每轮重建,且受 // 工具表是**按需构造**的(buildToolDefs 每轮重建,且受
// `if a.docStore != nil` 之类的条件分支控制),而 textForVector // `if a.coldcontextStore != nil` 之类的条件分支控制),而 textForVector
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。 // 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。 // 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
// //
@ -607,25 +607,25 @@ func (a *Agent) buildToolDefs() []interface{} {
} }
if a.memory != nil { 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": "被合并的实体名(合并后消失)"}, "source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"}, "target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
}, "source", "target")) }, "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": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
}, "name")) }, "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_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"}, "entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"}, "rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
}, "entity_a", "entity_b", "rounds")) }, "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' 可清理已合并残留"}, "subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"}, "relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
"target_contains": 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"}, "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_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
"old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"}, "old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
"old_object": map[string]interface{}{"type": "string", "description": "旧客体名"}, "old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
@ -678,12 +678,12 @@ func (a *Agent) buildToolDefs() []interface{} {
}, "name")) }, "name"))
} }
if a.docStore != nil { if a.coldcontextStore != nil {
// The public tool name remains coldcontext_query for compatibility. Its description // The public tool name remains coldcontext_query for compatibility. Its description
// uses "cold context" so the model does not mistake replayable conversation // uses "cold context" so the model does not mistake replayable conversation
// history for a user-authored document. // history for a user-authored document.
tools = append(tools, toolDefWith("coldcontext_query", 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.", "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{}{ map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"}, "query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
@ -691,14 +691,14 @@ func (a *Agent) buildToolDefs() []interface{} {
}, []string{"query"}, parallelOpts())) }, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("coldcontext_commit", 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{}{ map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"}, "content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"}, "summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
"media_digests": map[string]interface{}{ "media_digests": map[string]interface{}{
"type": "array", "type": "array",
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。", "description": "可选:这篇文档关联的媒体 digest(对话或 stablemem_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"}, "items": map[string]interface{}{"type": "string"},
}, },
}, "content")) }, "content"))

View File

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

View File

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

View File

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

View File

@ -937,6 +937,65 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"}) reg(ConfigDef{Key: "core.knowledge.path", Default: filepath.Join(dataDir, "knowledge"), Type: "string", DisplayName: "知识库路径", Description: "知识库存储目录", Category: "paths"})
reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"}) reg(ConfigDef{Key: "core.log.path", Default: filepath.Join(dataDir, "log"), Type: "string", DisplayName: "日志目录", Description: "日志文件输出目录", Category: "paths"})
// ---- 日志保留策略(2026-10-29 补:此前两个键都没注册,策略一直在空转)----
//
// ## 曾经的缺陷
//
// `internal/log/manager.go` 读 `core.log.retention`,但注册表里**从未**有
// 这个键 ⇒ GetString 总是返回调用方传的兜底值 "forever" ⇒ 压缩照做、
// **永不清理**。生产实测:log/ 目录 590MB / 504 个文件,485 个是当月
// 未被归档的 homed_*.log。而 `core/log_scrub.go` 的注释还写着
// “每天一个文件,从不自动清理” —— 注释描述了 bug,代码没修。
//
// ## 为何默认 n_months 而不是 forever
//
// forever 让磁盘无界增长,而日志的价值随时间快速衰减(排障看最近几周)。
// 3 个月是一个既够事后回看、又不至于涨到不可控的默认。
regMeta(ConfigDef{Key: "core.log.retention", Default: "n_months", Type: "string", DisplayName: "日志保留策略",
Description: "归档后的日志保留多久:this_week(只留本周)/ this_month(只留本月)/ n_months(保留近 N 个月)/ forever(永不删除)。注意:本项影响的是**压缩归档**的清理,当月的原始 homed_*.log 在跨月归档前不会被删。", Category: "paths"})
regMeta(ConfigDef{Key: "core.log.retention_months", Default: "3", Type: "int", DisplayName: "日志保留月数",
Description: "当 core.log.retention=n_months 时保留的月数", Category: "paths"})
// ---- 安全策略(2026-10-29 补:此前只有 enabled 一个键被读)----
//
// ## 曾经的缺陷
//
// `security.Policy`(工具黑白名单 + 沙箱)机制完整且有 12 条判据,
// 但**没有任何配置入口**:`core.security.*` 全仓只读一个 `enabled`,
// `SetPolicy` 零调用方。运维即使想开沙箱也无从下手。
//
// ## 为何全部用 regMeta(只登记、不播种)
//
// 安全策略属于「默认关、要收紧的人自己开」。若给老安装注入默认值,
// 升级即悄悄改变其安全行为 —— 与 core.agent.offload_enabled 同一纪律。
//
// ★★ 重要语义(2026-10-29 实测确认,别被名字骗了):
//
// `regMeta` = **只登记、不播种**。而 `defDefault` 要求 `seeded[key]`
// 为真才回退到 `ConfigDef.Default` —— 因此 regMeta 的键在
// **全新安装上也读不到 Default**,GetXxx 会直接返回调用方传入的兜底值。
//
// 后果与要求:
// · WebUI 看得到这些键(ListDefs 遍历 r.defs)—— 这一半成立
// · 但“读不到 Default”⇒ **装配代码必须自己写兜底值**,
// 且兜底值必须与这里的 Default **逐字一致**,否则两边会静默分叉。
//
// 这就是为什么 `kernelmod/security/policy_config.go` 里每个 GetXxx
// 的兜底值都与本处 Default 相同,并由
// `TestPolicyConfigDefaultsMatchRegistry` 钉住两边不漂移。
regMeta(ConfigDef{Key: "core.security.tool_deny", Default: "", Type: "string", DisplayName: "工具黑名单",
Description: "明确禁止的工具名,逗号分隔,支持 `*` 后缀前缀匹配(如 cmd_*,files_write)。deny 永远优先于 allow。留空=不禁任何工具。改动需重启生效。", Category: "defaults"})
regMeta(ConfigDef{Key: "core.security.tool_allow", Default: "", Type: "string", DisplayName: "工具白名单",
Description: "只允许这些工具,逗号分隔,支持 `*` 后缀匹配。留空=不限制(默认)。非空时不在名单里的工具一律拒绝——白名单是“只有这些”的语义。改动需重启生效。", Category: "defaults"})
regMeta(ConfigDef{Key: "core.security.sandbox.enabled", Default: "false", Type: "bool", DisplayName: "启用沙箱约束",
Description: "启用后按读写根与网络开关约束工具执行。默认关闭(保持既有行为)。只放**可判定**的事实(路径前缀、是否允许网络),不做“看起来危险”的猜测式判定。改动需重启生效。", Category: "defaults"})
regMeta(ConfigDef{Key: "core.security.sandbox.read_roots", Default: "", Type: "string", DisplayName: "允许读取的路径根",
Description: "逗号分隔的路径前缀;留空=不限制读。启用沙箱后不在此列下的读取会被拒绝。", Category: "defaults"})
regMeta(ConfigDef{Key: "core.security.sandbox.write_roots", Default: "", Type: "string", DisplayName: "允许写入的路径根",
Description: "逗号分隔的路径前缀;留空=不限制写。拒绝 `..` 逃逸(先求绝对值再比前缀)。", Category: "defaults"})
regMeta(ConfigDef{Key: "core.security.sandbox.allow_network", Default: "true", Type: "bool", DisplayName: "允许网络访问",
Description: "启用沙箱后为 false 则拒绝带 URL/网络意图的调用。默认 true(不施加网络约束)。", Category: "defaults"})
reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"}) reg(ConfigDef{Key: "core.agent.max_tool_turns", Default: "10", Type: "int", DisplayName: "最大工具轮次", Description: "单次请求允许的最大工具调用轮数", Category: "agent"})
reg(ConfigDef{Key: "core.agent.offload_enabled", Default: "false", Type: "bool", DisplayName: "积压任务自动转投", Description: "主 agent 长时间忙时,把积压任务自动转投给内核拉起的驻留子(需重启生效)", Category: "agent"}) reg(ConfigDef{Key: "core.agent.offload_enabled", Default: "false", Type: "bool", DisplayName: "积压任务自动转投", Description: "主 agent 长时间忙时,把积压任务自动转投给内核拉起的驻留子(需重启生效)", Category: "agent"})
// 1m 而非 5m:实测本机正常任务最长 1.2 分钟,5 分钟的阈值意味着 // 1m 而非 5m:实测本机正常任务最长 1.2 分钟,5 分钟的阈值意味着

View File

@ -501,3 +501,62 @@ func TestMaybeCompactReclaimsFreePages(t *testing.T) {
} }
r.Close() r.Close()
} }
// regMeta 的**真实契约**:可见,但**不提供**默认值。
//
// ## 这条判据是怎么来的(2026-10-29 记录一次真实的自我纠错)
//
// 我最初在这里断言「regMeta 的键在 GetXxx 上能读到 ConfigDef.Default」,
// 然后它红了。查 `defDefault`:
//
// if !r.seeded[key] { return "", false } // ← 只对 registerDefLocked 播种过的键回退
//
// 而 `regMeta` 的实现是 `r.defs[d.Key] = &d`,**从不写 seeded**。
// 于是 regMeta 的键(不只是老安装,**全新安装也一样**)GetXxx 会直接返回
// 调用方传入的兜底值,Default 字段只是给 WebUI 展示用的。
//
// ## 为什么这个区别必须被钉住
//
// 如果装配代码(如 PolicyFromConfig)以为"配置有 Default 就够了",
// 它写 `GetBool("core.security.sandbox.enabled", false)` 恰好对,
// 但若有人写 `GetBool(..., true)`(照抄别的键的习惯),
// 就会在**所有机器上**读到一个与 ConfigDef.Default 不同的值 —— 而
// `TestPolicyConfigDefaultsMatchRegistry` 存在的意义就是把这两处焊死。
func TestRegMetaIsVisibleButProvidesNoDefault(t *testing.T) {
dir := t.TempDir()
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
r.SeedDefaults(dir) // 用全新安装:证明这不是"老安装才这样"
defer r.Close()
// ① 可见性成立:WebUI 设置页靠 ListDefs 列出配置项。
var seen bool
for _, d := range r.ListDefs("core.security.") {
if d.Key == "core.security.tool_deny" {
seen = true
if d.Default != "" {
t.Fatalf("core.security.tool_deny 的 Default 应为空串,实际 %q", d.Default)
}
}
}
if !seen {
t.Fatal("core.security.tool_deny 未出现在 ListDefs —— WebUI 里看不到这个配置项")
}
// ② ★ 关键:GetXxx 返回的是**调用方兜底值**,不是 Def.Default。
// 这里传一个故意与 Default 相反的值,证明读到的就是它。
if got := r.GetBool("core.security.sandbox.enabled", true); !got {
t.Fatal("regMeta 的键应返回调用方兜底值 true —— 若这里读到 false," +
"说明 defDefault 行为变了,PolicyFromConfig 的兜底假设必须重审")
}
if got := r.GetString("core.log.retention", "forever"); got != "forever" {
t.Fatalf("regMeta 的键应返回调用方兜底值 forever,实际 %q", got)
}
// ③ 显式写入后必须正常可读(regMeta 只是不提供默认,不是不可写)。
if err := r.Set("core.security.tool_deny", "cmd_*"); err != nil {
t.Fatalf("写入失败: %v", err)
}
if got := r.GetString("core.security.tool_deny", ""); got != "cmd_*" {
t.Fatalf("写入后应读到 cmd_*,实际 %q", got)
}
}

View File

@ -55,7 +55,7 @@ const (
// EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。 // EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。
// //
// 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点** // 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点**
// (把 "memory_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念 // (把 "stablemem_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念
// (小宅/对话/待命),与英文工具名永不交集 —— 实测每个工具名命中 0 个节点, // (小宅/对话/待命),与英文工具名永不交集 —— 实测每个工具名命中 0 个节点,
// 于是每次都回退到「按 mention_count 取前 8 个」,即每次工具调用点亮的都是 // 于是每次都回退到「按 mention_count 取前 8 个」,即每次工具调用点亮的都是
// **同一批无关节点**。 // **同一批无关节点**。
@ -245,6 +245,16 @@ func (b *Bus) publishAs(p Publisher, evt *Event) error {
} }
} }
// ③ 入向清洗(投递前,**一次覆盖全部订阅者**)。
//
// 与出向的差别:出向洗的是「发布者拼进去的东西」,入向洗的是
// 「这个载荷在到订阅者手上之前是否还带明文凭据」。两者都做不是重复:
// 发布时洗过的载荷中间可能被转发/改写(例如中继模块重新组包),
// 而订阅者看到的东西必须是安全的。
if g != nil && evt.Payload != nil {
evt.Payload = g.ScrubInbound(evt.Type, evt.Payload)
}
b.mu.RLock() b.mu.RLock()
allHandlers := make([]Handler, len(b.subs[EventAll])) allHandlers := make([]Handler, len(b.subs[EventAll]))
copy(allHandlers, b.subs[EventAll]) copy(allHandlers, b.subs[EventAll])

View File

@ -52,11 +52,33 @@ type Guard interface {
AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string) AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string)
// AllowSubscribe 判定能否订阅。 // AllowSubscribe 判定能否订阅。
AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string) AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string)
// ScrubPayload 对出向载荷做脱敏(凭据闸门)。 // ScrubPayload 对**出向**载荷做脱敏(凭据闸门)。
// //
// 内核内部发布者同样要过:内核也可能把敏感值拼进事件 // 内核内部发布者同样要过:内核也可能把敏感值拼进事件
//(例如把 api_key 放进 Stage 事件里)。 //(例如把 api_key 放进 Stage 事件里)。
ScrubPayload(eventType EventType, payload map[string]interface{}) map[string]interface{} ScrubPayload(eventType EventType, payload map[string]interface{}) map[string]interface{}
// ScrubInbound 对**入向**载荷做清洗(投递给订阅者之前)。
//
// ## 为什么入向必须在总线上做(用户 2026-10-29 指正)
//
// 原先入向清洗在 `internal/sdk/events.go` 的订阅回调里手工做(scrubIn),
// 而那条路径只盖住了 `s.Events().Subscribe` —— 其余路径
//(`s.EventBus()`、`s.Subscribe`、内核模块自己的 Subscribe)
// 拿到的都是**原始载荷**。同一个事件,订阅者看到的脏净程度取决于
// 它当初用的是哪个入口 —— 策略存在两份口径,迟早漂移。
//
// 安全模块既然持有总线、而**投递本身就在总线内部**,
// 入向清洗就该在那里做一次,而不是让每个适配层各做一遍。
//
// ## 与 ScrubPayload 的对称性
//
// 出向(发布时):发布者 → [ScrubPayload] → 总线
// 入向(投递时):总线 → [ScrubInbound] → 每个订阅者
//
// 两者都是“凭据闸门”,与身份无关:内核发出的载荷也要洗,
// 因为它同样可能拼进敏感值。
ScrubInbound(eventType EventType, payload map[string]interface{}) map[string]interface{}
} }
// DeniedError 表示事件操作被闸门拒绝。 // DeniedError 表示事件操作被闸门拒绝。

View File

@ -21,6 +21,7 @@ type fakeGuard struct {
allowPublish map[string]bool allowPublish map[string]bool
allowSubscribe map[string]bool allowSubscribe map[string]bool
scrubCalls int scrubCalls int
inboundCalls int
} }
func (g *fakeGuard) AllowPublish(p Publisher, t EventType) (bool, string) { func (g *fakeGuard) AllowPublish(p Publisher, t EventType) (bool, string) {
@ -59,6 +60,25 @@ func (g *fakeGuard) ScrubPayload(_ EventType, payload map[string]interface{}) ma
return payload return payload
} }
// ScrubInbound 模拟入向清洗:把 secret 字段换成 [inbound]。
//
// 计数器与出向分开,便于判据区分“洗了几次、洗的哪个方向”。
func (g *fakeGuard) ScrubInbound(_ EventType, payload map[string]interface{}) map[string]interface{} {
g.inboundCalls++
if _, ok := payload["secret"]; ok {
out := map[string]interface{}{}
for k, v := range payload {
if k == "secret" {
out[k] = "[inbound]"
continue
}
out[k] = v
}
return out
}
return payload
}
// ① 插件发布未授权事件必须被**拒绝**,且不送达订阅者。 // ① 插件发布未授权事件必须被**拒绝**,且不送达订阅者。
// //
// 这是最核心的一条:能发 raw_input / agent_output 等于能伪造内核事件。 // 这是最核心的一条:能发 raw_input / agent_output 等于能伪造内核事件。
@ -112,6 +132,14 @@ func TestGuardAllowsKernelPublish(t *testing.T) {
// ④ 内核身份**也要过载荷脱敏**。 // ④ 内核身份**也要过载荷脱敏**。
// //
// 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。 // 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。
//
// ★ 2026-10-29 加入入向清洗后,本判据要区分两个方向:
//
// 出向(发布时):secret "sk-123" → "@{redacted}"(别名)
// 入向(投递前):再把载荷洗一遍 → "[inbound]"(本测试替身的标记)
//
// 所以**订阅者看到的是入向结果**,而不是出向结果。这不是回归:
// 两面都必须发生,旧的断言只钉了第一面,现在两面都钉。
func TestGuardScrubsKernelPayload(t *testing.T) { func TestGuardScrubsKernelPayload(t *testing.T) {
bus := NewBus() bus := NewBus()
g := &fakeGuard{allowPublish: map[string]bool{}} g := &fakeGuard{allowPublish: map[string]bool{}}
@ -124,11 +152,46 @@ func TestGuardScrubsKernelPayload(t *testing.T) {
if got == nil { if got == nil {
t.Fatal("应收到事件") t.Fatal("应收到事件")
} }
if got.Payload["secret"] != "@{redacted}" {
t.Errorf("★ 内核载荷也必须脱敏,实际 %v", got.Payload["secret"]) // 出向跑过(原文不再以明文到达订阅者)
}
if g.scrubCalls == 0 { if g.scrubCalls == 0 {
t.Error("ScrubPayload 应被调用") t.Error("ScrubPayload(出向)应被调用 —— 内核载荷同样要过凭据闸门")
}
// 入向也跑过(投递前再洗一次)
if g.inboundCalls == 0 {
t.Error("ScrubInbound(入向)应被调用 —— 投递前必须清洗")
}
// 订阅者拿到的不能是原文
if got.Payload["secret"] == "sk-123" {
t.Fatalf("★ 订阅者收到了明文凭据(出向/入向都没生效): %v", got.Payload["secret"])
}
}
// ④b 入向清洗对**所有订阅入口**生效。
//
// 这正是把清洗从 sdk 适配层搬到总线上的理由:
// 以前只有 s.Events().Subscribe 那条路会洗,其余入口拿到原始载荷。
func TestGuardScrubsInboundForAllSubscribers(t *testing.T) {
bus := NewBus()
g := &fakeGuard{allowPublish: map[string]bool{}, allowSubscribe: map[string]bool{"system": true}}
bus.SetGuard(g)
// 三种不同身份的订阅者:内核(Subscribe)、插件 A(SubscribeAs)。
var kernelGot, pluginGot *Event
bus.Subscribe(EventSystem, func(e *Event) { kernelGot = e })
if _, err := bus.SubscribeAs(Publisher{Name: "qq"}, EventSystem, func(e *Event) { pluginGot = e }); err != nil {
t.Fatalf("订阅应放行: %v", err)
}
bus.Publish(&Event{Type: EventSystem, Payload: map[string]interface{}{"secret": "sk-999"}})
for name, ev := range map[string]*Event{"kernel": kernelGot, "plugin": pluginGot} {
if ev == nil {
t.Fatalf("%s 订阅者应收到事件", name)
}
if ev.Payload["secret"] == "sk-999" {
t.Errorf("★ %s 订阅者收到了明文 —— 入向清洗未覆盖该入口", name)
}
} }
} }

View File

@ -14,7 +14,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/knowledge"
luapkg "github.com/JianFeeeee/HologramAgent/internal/lua" luapkg "github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory" "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/pipeline" "github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HologramAgent/internal/memory/social" "github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/memory/text"
@ -162,7 +162,7 @@ type KernelState struct {
BaseAPIKey string BaseAPIKey string
// DocStore 是文档记忆(doc-store 模块产出)。 // DocStore 是文档记忆(doc-store 模块产出)。
DocStore *document.Store DocStore *coldcontext.Store
// Knowledge 是知识库(knowledge-store 模块产出)。 // Knowledge 是知识库(knowledge-store 模块产出)。
Knowledge *knowledge.Store Knowledge *knowledge.Store

View File

@ -3,7 +3,7 @@
// //
// ## 依赖 // ## 依赖
// //
// buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/docStore/ // buildAgent 收 provider/providerMgr/iom/mem.db/mem.indexer/trk/coldcontextStore/
// knowledge/mem.social/textMem/personality/pluginReg/embedder/ // knowledge/mem.social/textMem/personality/pluginReg/embedder/
// multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。 // multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。
// //
@ -78,7 +78,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
memDB := k.Memory.DB memDB := k.Memory.DB
memIdx := k.Memory.Indexer memIdx := k.Memory.Indexer
trk := k.Tracker trk := k.Tracker
docStore := k.DocStore coldcontextStore := k.DocStore
ks := k.Knowledge ks := k.Knowledge
socialStore := k.Memory.Social socialStore := k.Memory.Social
textMem := k.TextMemory textMem := k.TextMemory
@ -111,7 +111,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
Memory: memDB, Memory: memDB,
Indexer: memIdx, Indexer: memIdx,
Tracker: trk, Tracker: trk,
DocStore: docStore, DocStore: coldcontextStore,
Knowledge: ks, Knowledge: ks,
SocialStore: socialStore, SocialStore: socialStore,
TextMemory: textMem, TextMemory: textMem,
@ -172,7 +172,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
// ★ 为什么在这里接(而不是让各调用方自己发事件): // ★ 为什么在这里接(而不是让各调用方自己发事件):
// 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发; // 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发;
// 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜 // 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜
// 节点(把 "memory_recall" 拆词去匹配中文实体名,实测命中 0), // 节点(把 "stablemem_recall" 拆词去匹配中文实体名,实测命中 0),
// 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。 // 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。
// //
// ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开 // ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开

View File

@ -21,6 +21,3 @@ import (
func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) (string, bool) { func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) (string, bool) {
return toolroute.Dispatch(h, tc, turnScenes) return toolroute.Dispatch(h, tc, turnScenes)
} }
// IsBuiltinTool 报告某名字是否由内置路由处理。
func IsBuiltinTool(name string) bool { return toolroute.IsBuiltinTool(name) }

View File

@ -10,7 +10,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelboot"
"github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/kernelmod"
"github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document" "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
) )
// Module 提供文档记忆。 // Module 提供文档记忆。
@ -21,16 +21,16 @@ func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
func (Module) Provides() []string { return []string{kernelmod.FacDocStore} } func (Module) Provides() []string { return []string{kernelmod.FacDocStore} }
func (Module) Init(ks *kernelboot.KernelState) error { func (Module) Init(ks *kernelboot.KernelState) error {
docStore := document.NewStore( coldcontextStore := coldcontext.NewStore(
filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords) filepath.Join(ks.Cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
if err := docStore.Start(); err != nil { if err := coldcontextStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err) log.Printf("[homed] warning: document store: %v", err)
} }
// 关停时落盘:文档记忆的内存态变更(迁移结果、访问计数等)只在 flush 里写盘, // 关停时落盘:文档记忆的内存态变更(迁移结果、访问计数等)只在 flush 里写盘,
// 而 flush 的唯一入口是 Stop() —— 此前全仓无人调用它, // 而 flush 的唯一入口是 Stop() —— 此前全仓无人调用它,
// 于是迁移结果永不落盘、每次启动白算一遍。 // 于是迁移结果永不落盘、每次启动白算一遍。
ks.DocStore = docStore ks.DocStore = coldcontextStore
ks.AddCloser(docStore.Stop) ks.AddCloser(coldcontextStore.Stop)
return nil return nil
} }

View File

@ -170,6 +170,17 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error
rt := sec.NewRuntime(store) rt := sec.NewRuntime(store)
sec.SetRuntimeGlobal(rt) sec.SetRuntimeGlobal(rt)
// ★ 全局策略基线(部署方兵底,用户 2026-10-29 裁定选项 B)。
//
// 它必须在这里装(而不是等模块 Init):`Prepare` 可能在 Group 0 被
// 提前调一次,而工具可能在那之后任意时刻被执行 —— 策略若晚一步生效,
// 就存在一个“工具已在跑、但黑名单还没上”的窗口。
//
// 注:配置里全空/全 false 时得到零值 Policy(不限制),
// 与加配置前的行为逐字一致。
ApplyPolicyFromConfig(rt, cfgReg)
log.Printf("[security] module enabled (key source %s, %d variables, config-at-rest encryption on)", log.Printf("[security] module enabled (key source %s, %d variables, config-at-rest encryption on)",
keySrc, store.Count()) keySrc, store.Count())
return rt, nil return rt, nil

View File

@ -0,0 +1,151 @@
package security
// 把**部署方配置**(`core.security.*`)装配成内核全局策略基线。
//
// ## 为什么全局基线需要有配置入口(用户 2026-10-29 裁定:选项 B)
//
// 策略有两层,职责不同:
//
// ① 全局基线 security.Policy ← 本文件负责:部署方兜底("这台机器不许跑 cmd_*")
// ② 档位 Profile / 作用域 Scope ← 插件声明:单次请求的收窄("这个 QQ 访客不行")
//
// ②是**声明式**且**只能收窄**(见 Runtime.CheckTool:全局黑名单先判,
// 档位绕不过去)。但②有它的结构性盲区:**部署方无法阻止一个恶意/写坏的
// 插件**——插件自己声明档位,等于被约束者决定约束。所以必须有一层
// 不受插件影响的基线,且它要能在不重编译的前提下改 —— 那就是①。
//
// ## 为什么不放在 internal/security 读配置
//
// `internal/security` 不 import `internal/config`(否则配置包与安全包
// 互相依赖,且 security 会被迫感知 SQLite)。装配放在本桥接层,
// security 保持纯策略/算法包。
//
// ## 为什么是启动期一次快照(不热重载)
//
// `Policy` 在**每次工具调用**的热路径上被查;查 SQLite 不可接受。
// 策略变更本就低频,且"安全策略在运行中被悄悄改变"本身是可疑行为。
// 因此:启动期读一次,改配置需重启(与 core.agent.offload_enabled 同纪律)。
import (
"fmt"
"log"
"strings"
sec "github.com/JianFeeeee/HologramAgent/internal/security"
)
// ConfigReader 是本桥接层需要的最小配置面(便于测试注入假实现)。
type ConfigReader interface {
GetString(key, defaultVal string) string
GetBool(key string, defaultVal bool) bool
}
// PolicyFromConfig 从配置读出一份全局策略快照。
//
// 缺省语义(重要):所有项留空/false 时返回**零值 Policy**,
// 即"什么都不限制",与加配置之前的存量行为逐字一致。
// 这条是升级安全的底线:老安装升级后行为不得改变。
func PolicyFromConfig(cfg ConfigReader) sec.Policy {
if cfg == nil {
return sec.DefaultPolicy()
}
p := sec.Policy{
ToolDeny: splitToolList(cfg.GetString("core.security.tool_deny", "")),
ToolAllow: splitToolList(cfg.GetString("core.security.tool_allow", "")),
Sandbox: sec.SandboxPolicy{
Enabled: cfg.GetBool("core.security.sandbox.enabled", false),
ReadRoots: splitPathList(cfg.GetString("core.security.sandbox.read_roots", "")),
WriteRoots: splitPathList(cfg.GetString("core.security.sandbox.write_roots", "")),
AllowNetwork: cfg.GetBool("core.security.sandbox.allow_network", true),
},
}
// 自相矛盾的配置(同名工具同时在黑白名单)不静默择一:
// CheckTool 的语义是 deny 优先,但那种配置通常意味着写错了,
// 而"写错的安全策略悄悄偏向一边"比启动失败更危险。这里只响亮记录。
if both := intersect(p.ToolDeny, p.ToolAllow); len(both) > 0 {
log.Printf("[security] WARNING: tool(s) %v appear in BOTH tool_deny and tool_allow; "+
"deny wins (denylist is evaluated first), but this configuration is likely a mistake", both)
}
return p
}
// ApplyPolicyFromConfig 读取配置并把策略装进运行时。
//
// 返回装好的策略,便于调用方记录/断言。rt 为 nil 时安全跳过
// (未装配安全模块的独立工具/测试)。
func ApplyPolicyFromConfig(rt *sec.Runtime, cfg ConfigReader) sec.Policy {
p := PolicyFromConfig(cfg)
if rt != nil {
rt.SetPolicy(p)
}
if !p.Sandbox.Enabled && len(p.ToolDeny) == 0 && len(p.ToolAllow) == 0 {
// 零值策略:不打印噪音日志(存量安装的常态)。
return p
}
log.Printf("[security] global policy applied: deny=%v allow=%v sandbox=%v (read=%d roots, write=%d roots, network=%v)",
p.ToolDeny, p.ToolAllow, p.Sandbox.Enabled,
len(p.Sandbox.ReadRoots), len(p.Sandbox.WriteRoots), p.Sandbox.AllowNetwork)
return p
}
// splitToolList 解析逗号分隔的工具名单。
//
// 空串 ⇒ nil(而不是 [""])—— 后者在 CheckTool 里会被当成"有一个空模式",
// 虽然 matchTool 对空模式返回 false 不会误伤,但 nil 语义更干净:
// "没配置"与"配置了一个空项"应当可区分。
func splitToolList(s string) []string {
return splitNonEmpty(s)
}
// splitPathList 解析逗号分隔的路径前缀。
//
// 去尾部分隔符:`/tmp/` 与 `/tmp` 应等价,否则 `filepath.HasPrefix` 式的
// 前缀匹配会把 `/tmpfoo` 误判进 `/tmp`。规范化交给 CheckPath(它会 Clean)。
func splitPathList(s string) []string {
out := splitNonEmpty(s)
for i, v := range out {
out[i] = strings.TrimRight(v, "/")
if out[i] == "" {
// "/" 被 TrimRight 成空 —— 根目录是个合法的意图,还原回去。
out[i] = "/"
}
}
return out
}
func splitNonEmpty(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var out []string
for _, part := range strings.Split(s, ",") {
if v := strings.TrimSpace(part); v != "" {
out = append(out, v)
}
}
return out
}
// intersect 返回两个名单的交集(用于诊断自相矛盾的配置)。
func intersect(a, b []string) []string {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[string]bool, len(b))
for _, v := range b {
set[v] = true
}
var out []string
for _, v := range a {
if set[v] {
out = append(out, v)
}
}
return out
}
// String 便于日志里一眼看出策略是否为空(诊断用)。
func DescribePolicy(p sec.Policy) string {
return fmt.Sprintf("deny=%d allow=%d sandbox=%v", len(p.ToolDeny), len(p.ToolAllow), p.Sandbox.Enabled)
}

View File

@ -0,0 +1,235 @@
package security
// 全局策略基线的**配置装配**判据。
//
// ## 为什么需要这个文件
//
// 2026-10-29 之前的实际状况是:`core.security.*` 只有 `enabled` 一个键被读,
// `Policy.ToolDeny/ToolAllow/Sandbox` **没有任何配置入口**,`SetPolicy`
// 零调用方。于是运维即使想「这台机器不许跑 cmd_*」也无从下手。
//
// 本文件的判据钉三件事:
// ① 留空 = 零值策略(升级不得改变存量行为)
// ② 逗号列表/通配符/路径规范化解析正确
// ③ 装配后 `CheckTool` 真的按配置拒绝(而不是只存了字段)
import (
"testing"
sec "github.com/JianFeeeee/HologramAgent/internal/security"
)
// fakeCfg 是 ConfigReader 的测试替身。
type fakeCfg map[string]string
func (f fakeCfg) GetString(key, def string) string {
if v, ok := f[key]; ok {
return v
}
return def
}
func (f fakeCfg) GetBool(key string, def bool) bool {
v, ok := f[key]
if !ok {
return def
}
return v == "true" || v == "1"
}
// ① 全部留空 ⇒ 零值策略,不限制任何工具(**升级安全底线**)。
func TestEmptyConfigYieldsZeroPolicy(t *testing.T) {
p := PolicyFromConfig(fakeCfg{})
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 || p.Sandbox.Enabled {
t.Fatalf("空配置必须得到零值策略,实际 %+v", p)
}
if d := p.CheckTool("cmd_run"); !d.Allowed {
t.Fatalf("零值策略必须放行全部工具,实际拒绝: %s", d.Reason)
}
// AllowNetwork 默认 true(不施加网络约束)—— 与加配置前一致。
if !p.Sandbox.AllowNetwork {
t.Fatal("空配置下 AllowNetwork 应为 true(不限制),否则升级会突然断网")
}
}
// ② 黑名单解析:逗号分隔 + 通配符 + 空白容忍。
func TestToolDenyListParsing(t *testing.T) {
p := PolicyFromConfig(fakeCfg{
"core.security.tool_deny": " cmd_* , files_write , ",
})
if len(p.ToolDeny) != 2 {
t.Fatalf("应解析出 2 项(空项被丢弃),实际 %v", p.ToolDeny)
}
if d := p.CheckTool("cmd_run"); d.Allowed {
t.Fatal("cmd_* 应命通配匹配拒绝 cmd_run")
}
if d := p.CheckTool("files_write"); d.Allowed {
t.Fatal("精确项应拒绝 files_write")
}
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
t.Fatalf("未命中项必须放行: %s", d.Reason)
}
}
// ③ 白名单语义:非空即"只有这些"。
//
// ★ 通配符是**纯字面前缀**匹配:`memory_*` 不会匹配 `stablemem_recall`。
// 工具按裁定 #11 改名后,运维配置里的白名单必须同步改,
// 否则白名单会静默变成"什么都不允许"。
func TestToolAllowListIsOnlyThese(t *testing.T) {
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "stablemem_*,knowledge_*"})
if d := p.CheckTool("stablemem_recall"); !d.Allowed {
t.Fatalf("白名单内应放行: %s", d.Reason)
}
if p.CheckTool("cmd_run").Allowed {
t.Fatal("白名单外必须拒绝 —— 白名单是\"只有这些\"")
}
}
// ③b 旧名前缀不再匹配改名后的工具(升级风险的具体形状)。
//
// 存量部署的配置里写的是 `memory_*`,而工具已叫 `stablemem_*`:
// 白名单会**静默**变成空集,而不是报错。本判据把这一行为钉住,
// 使任何“通配符语义悄悄变宽”的改动都会变红。
func TestLegacyMemoryWildcardDoesNotMatchRenamedTools(t *testing.T) {
p := PolicyFromConfig(fakeCfg{"core.security.tool_allow": "memory_*"})
if p.CheckTool("stablemem_recall").Allowed {
t.Fatal("`memory_*` 不应匹配 stablemem_recall —— 若匹配,说明通配符语义变了")
}
// 旧前缀仍应匹配旧名字(存量插件可能仍用旧名注册工具)。
if d := p.CheckTool("memory_something_legacy"); !d.Allowed {
t.Fatalf("`memory_*` 应仍匹配旧前缀工具: %s", d.Reason)
}
}
// ④ deny 优先于 allow(同名工具同时在两边)。
func TestDenyWinsOverAllow(t *testing.T) {
p := PolicyFromConfig(fakeCfg{
"core.security.tool_deny": "cmd_run",
"core.security.tool_allow": "cmd_*",
})
if d := p.CheckTool("cmd_run"); d.Allowed {
t.Fatal("deny 必须优先于 allow")
}
}
// ⑤ 路径列表:去尾部分隔符、/ 保留、空项丢弃。
func TestPathListParsing(t *testing.T) {
p := PolicyFromConfig(fakeCfg{
"core.security.sandbox.enabled": "true",
"core.security.sandbox.write_roots": "/tmp/, /home/x ,,/",
})
got := p.Sandbox.WriteRoots
want := []string{"/tmp", "/home/x", "/"}
if len(got) != len(want) {
t.Fatalf("期望 %v,实际 %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("期望 %v,实际 %v", want, got)
}
}
}
// ⑥ ★ 装配进 Runtime 后 `CheckTool` 真的按配置生效(不是只存字段)。
func TestApplyPolicyActuallyEnforced(t *testing.T) {
rt := sec.NewRuntime(nil)
if d := rt.CheckTool("cmd_run"); !d.Allowed {
t.Fatalf("装配前应放行: %s", d.Reason)
}
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
if d := rt.CheckTool("cmd_run"); d.Allowed {
t.Fatal("★ 装配后 cmd_run 必须被拒 —— 否则配置只是存进了字段没落到判定上")
}
}
// ⑦ 沙箱:启用后按写根拒绝 `..` 逃逸到根外。
func TestSandboxWriteRootsEnforced(t *testing.T) {
p := PolicyFromConfig(fakeCfg{
"core.security.sandbox.enabled": "true",
"core.security.sandbox.write_roots": "/tmp/work",
})
if d := p.CheckPath("write", "/tmp/work/a.txt"); !d.Allowed {
t.Fatalf("写根内应放行: %s", d.Reason)
}
if d := p.CheckPath("write", "/tmp/work/../etc/passwd"); d.Allowed {
t.Fatal("`..` 逃逸到写根外必须拒绝")
}
if d := p.CheckPath("write", "/etc/passwd"); d.Allowed {
t.Fatal("写根外必须拒绝")
}
}
// ⑧ 沙箱未启用 ⇒ 路径判定不施约束(默认行为不变)。
func TestSandboxDisabledDoesNotConstrain(t *testing.T) {
p := PolicyFromConfig(fakeCfg{
"core.security.sandbox.write_roots": "/tmp/work",
// enabled 缺省 false
})
if d := p.CheckPath("write", "/etc/passwd"); !d.Allowed {
t.Fatal("沙箱未启用时不应约束路径 —— 否则升级即改变存量行为")
}
}
// ⑨ ★ 语义锁定:`core.security.enabled=false` 关掉的**只是凭据替换**,
// 工具策略必须继续生效。
//
// ## 为何这条必须单独钉
//
// `enabled` 这个名字太容易被理解成「关掉整个安全模块」。实际上:
//
// enabled=false:不加载主密钥、不做明文⇄别名替换(需要密钥才能做)
// 但工具黑白名单/沙箱**与密钥无关**,仍然生效
//
// 若有人把 `SetPolicy` 挪进 `if rt.Enabled()` 分支,工具闸会跟着凭据替换
// 一起静默失效 —— 而日志上看不出区别。本判据就是拦这个。
// (与 eventpolicy 同一纪律:"关闭安全不等于开权限"。)
func TestDisabledRuntimeStillEnforcesPolicy(t *testing.T) {
// NewRuntime(nil) 构造的正是 Disabled Runtime(enabled=false)。
rt := sec.NewRuntime(nil)
if rt.Enabled() {
t.Fatal("前置条件不成立:NewRuntime(nil) 应为 Disabled Runtime")
}
ApplyPolicyFromConfig(rt, fakeCfg{"core.security.tool_deny": "cmd_*"})
if d := rt.CheckTool("cmd_run"); d.Allowed {
t.Fatal("★ enabled=false 时工具策略仍须生效 —— 关掉的是凭据替换,不是授权")
}
}
// ⑩ ★ 兜底值焊死:装配层的 GetXxx 兜底必须与 registry 的 ConfigDef.Default 一致。
//
// ## 为什么这条是必需的(2026-10-29)
//
// 这些键用 `regMeta` 登记 —— **只登记、不播种**。而 `defDefault` 要求
// `seeded[key]` 为真才回退 `ConfigDef.Default`,regMeta 从不写 seeded,
// 所以 `GetXxx` 对它们**永远返回调用方传入的兜底值**,Default 字段
// 只给 WebUI 展示。
//
// 于是同一个"默认值"存在两处:
//
// registry.ConfigDef.Default ← WebUI 上写的
// PolicyFromConfig 的 GetXxx 兜底 ← 实际生效的
//
// 两处一旦漂移,WebUI 显示 `sandbox.enabled = false` 而实际按 true 运行
// (或反之)—— 安全配置显示与实际不一致,是最不该出现的分歧。
func TestPolicyConfigDefaultsMatchRegistry(t *testing.T) {
// 用 fakeCfg(空 map)走一遍装配 = 全部键取兜底值。
p := PolicyFromConfig(fakeCfg{})
if p.Sandbox.Enabled {
t.Error("sandbox.enabled 的装配兜底应为 false,与 ConfigDef.Default 一致")
}
if !p.Sandbox.AllowNetwork {
t.Error("sandbox.allow_network 的装配兜底应为 true,与 ConfigDef.Default 一致")
}
if len(p.ToolDeny) != 0 || len(p.ToolAllow) != 0 {
t.Errorf("两个名单的装配兜底应为空,实际 deny=%v allow=%v", p.ToolDeny, p.ToolAllow)
}
if len(p.Sandbox.ReadRoots) != 0 || len(p.Sandbox.WriteRoots) != 0 {
t.Errorf("读写根装配兜底应为空,实际 read=%v write=%v", p.Sandbox.ReadRoots, p.Sandbox.WriteRoots)
}
}

View File

@ -2,7 +2,7 @@
// //
// ## 依赖 // ## 依赖
// //
// 收 iom/evBus/mem.db/textMem/docStore/knowledge/providerMgr // 收 iom/evBus/mem.db/textMem/coldcontextStore/knowledge/providerMgr
// ⇒ 声明 kernel-io / memory / textmem / docmem / knowledge / llm。 // ⇒ 声明 kernel-io / memory / textmem / docmem / knowledge / llm。
// //
// ★ 顺序上它在 llm 之后(providerMgr 是它的参数)—— 这不是"刻意安排", // ★ 顺序上它在 llm 之后(providerMgr 是它的参数)—— 这不是"刻意安排",

View File

@ -154,7 +154,7 @@ type Store struct {
// dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**: // dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**:
// vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用; // vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用;
// dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」 // dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」
// 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。 // 「按文搜含图知识」成立。文档记忆(coldcontextStore)走的就是后者。
// //
// 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构 // 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构
// (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶, // (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶,
@ -230,7 +230,7 @@ func (s *Store) denseEnabled() bool {
// //
// 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够: // 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够:
// 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果, // 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果,
// 而它下次又被当成"已对齐",就永远错下去(docStore 里踩过同一个坑)。 // 而它下次又被当成"已对齐",就永远错下去(coldcontextStore 里踩过同一个坑)。
// //
// 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。 // 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。
func (s *Store) denseFor(k *Knowledge) []float64 { func (s *Store) denseFor(k *Knowledge) []float64 {
@ -302,7 +302,7 @@ func (s *Store) ReindexDense() (built, skipped int) {
built++ built++
} }
// 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 —— // 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 ——
// 每次启动都重算同一批(docStore 的 BuildDenseIndex 踩过这个坑)。 // 每次启动都重算同一批(coldcontextStore 的 BuildDenseIndex 踩过这个坑)。
if built > 0 { if built > 0 {
s.denseDirty = true s.denseDirty = true
} }
@ -1237,7 +1237,7 @@ func shortFP(fp string) string {
// flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。 // flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。
// //
// 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何 // 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何
// 优雅关停钩子可跑,这批向量的计算就白费了(docStore 的 BuildDenseIndex // 优雅关停钩子可跑,这批向量的计算就白费了(coldcontextStore 的 BuildDenseIndex
// 出于同样理由选择当场写盘)。 // 出于同样理由选择当场写盘)。
func (s *Store) flushDenseLocked() error { func (s *Store) flushDenseLocked() error {
if !s.denseDirty { if !s.denseDirty {

View File

@ -60,12 +60,47 @@ func (m *Manager) runLoop(ctx context.Context, name string, tickInterval time.Du
} }
} }
// applyRetention 执行一次归档清理。
//
// ## ★ 兜底值为什么必须是 n_months(2026-10-29 修正)
//
// `core.log.retention` 是用 `regMeta` 登记的(只登记、不播种),而
// `defDefault` 要求 `seeded[key]` 为真才回退 `ConfigDef.Default` ——
// 所以 **GetString 返回的永远是这里传的兜底值**,registry 里写的
// Default="n_months" 对实际行为没有任何作用。
//
// 此前这里写的是 "forever",后果是:压缩照做、**永不清理**。
// 生产实测 /home/hologramagent/log 涨到 590MB / 504 个文件。
// 而本包另一处注释还写着"每天一个文件,从不自动清理" ——
// 注释描述了 bug,代码却没修。
//
// 现在兜底与 registry 的 Default 对齐(n_months / 3),两边由
// `internal/kernelmod/security` 的同型判据哲学钉住:默认值只允许有一处事实来源。
func (m *Manager) applyRetention() { func (m *Manager) applyRetention() {
policy := RetentionPolicy(m.cfg.GetString("core.log.retention", "forever")) policy := RetentionPolicy(m.cfg.GetString("core.log.retention", string(RetentionNMonths)))
months := m.cfg.GetInt("core.log.retention_months", 3) months := m.cfg.GetInt("core.log.retention_months", 3)
// 未知策略值不静默当 forever 处理:那会让一个拼写错误(如 "weekly")
// 永久关闭清理,而运维以为已经生效。回退到默认并响亮记录。
if !validRetentionPolicy(policy) {
log.Printf("[log manager] unknown core.log.retention=%q (expected this_week|this_month|n_months|forever); "+
"falling back to %s", string(policy), RetentionNMonths)
policy = RetentionNMonths
}
if months <= 0 {
months = 3
}
applyRetention(m.logDir, policy, months) applyRetention(m.logDir, policy, months)
} }
// validRetentionPolicy 报告策略值是否可识别。
func validRetentionPolicy(p RetentionPolicy) bool {
switch p {
case RetentionThisWeek, RetentionThisMonth, RetentionNMonths, RetentionForever:
return true
}
return false
}
func (m *Manager) weeklyTick() time.Duration { func (m *Manager) weeklyTick() time.Duration {
return nextWeekdayTime(time.Monday, 1, 0) return nextWeekdayTime(time.Monday, 1, 0)
} }

View File

@ -0,0 +1,148 @@
package log
// 日志保留策略的判据(本包此前**零测试**)。
//
// ## 为什么必须补
//
// 2026-10-29 发现 `applyRetention` 的兜底值是 `"forever"`:
//
// policy := RetentionPolicy(m.cfg.GetString("core.log.retention", "forever"))
//
// 而 `core.log.retention` 用 `regMeta` 登记(只登记、不播种),
// `GetXxx` 对这类键**永远返回调用方兜底值** —— registry 里写的
// Default="n_months" 对实际行为零影响。于是压缩照做、**永不清理**:
// 生产实测 log/ 目录 590MB / 504 个文件。
//
// 这类 bug 的特征是「配置项存在、代码在读、但默认值从另一处生效」,
// 只有判据能拦住。本文件钉三件事:
// ① 兜底值必须是会清理的策略(不是 forever)
// ② 未知策略值不静默变成 forever(拼写错误会让清理永久失效)
// ③ 保留策略的清理函数本身真的删文件
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
// fakeCfg 是 ConfigProvider 的测试替身;未设置的键返回调用方兜底值
// (与真实 ConfigRegistry 对 regMeta 键的行为一致)。
type fakeCfg map[string]string
func (f fakeCfg) GetString(key, def string) string {
if v, ok := f[key]; ok {
return v
}
return def
}
func (f fakeCfg) GetInt(key string, def int) int {
if v, ok := f[key]; ok {
var n int
fmt.Sscanf(v, "%d", &n)
return n
}
return def
}
// ① ★ 未配置时的兜底策略必须真的会清理(回归:曾经是 forever)。
func TestDefaultRetentionIsNotForever(t *testing.T) {
// 通过 reflect 拿不到局部变量,改为行为断言:
// 建一个月前的归档,未配置时必须被删掉。
dir := t.TempDir()
old := time.Now().AddDate(0, -6, 0) // 6 个月前
name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month()))
writeFile(t, dir, name)
m := NewManager(dir, fakeCfg{}) // 空配置 ⇒ 全走兜底
m.applyRetention()
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
t.Fatalf("★ 未配置保留策略时 %s 仍存在 —— 兜底值又变回 forever 了(压缩生效但不清理)", name)
}
}
// ② forever 时必须保留(显式要求不删,不能被默认值覆盖)。
func TestForeverKeepsArchives(t *testing.T) {
dir := t.TempDir()
old := time.Now().AddDate(0, -6, 0)
name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month()))
writeFile(t, dir, name)
m := NewManager(dir, fakeCfg{"core.log.retention": "forever"})
m.applyRetention()
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("forever 策略下 %s 不应被删除: %v", name, err)
}
}
// ③ ★ 未知策略值不得静默变成 forever。
//
// 拼写错误("weekly" / "n_month")若是落到 forever,清理会永久失效,
// 而运维看着配置里写着"保留 3 个月"以为已经生效 —— 磁盘直到写满都没人发现。
func TestUnknownPolicyFallsBackToCleaningNotForever(t *testing.T) {
dir := t.TempDir()
old := time.Now().AddDate(0, -6, 0)
name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month()))
writeFile(t, dir, name)
m := NewManager(dir, fakeCfg{"core.log.retention": "n_month"}) // 少一个 s
m.applyRetention()
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
t.Fatal("★ 未知策略值被当成了 forever —— 拼写错误会导致清理永久失效")
}
}
// ④ n_months 的月数来自配置,且非法值回退 3。
func TestRetentionMonthsHonoredAndSanitized(t *testing.T) {
dir := t.TempDir()
// 5 个月前:months=3 应删;months=12 应留。
old := time.Now().AddDate(0, -5, 0)
name := fmt.Sprintf("month_%04d-%02d.tar.gz", old.Year(), int(old.Month()))
writeFile(t, dir, name)
NewManager(dir, fakeCfg{
"core.log.retention": "n_months",
"core.log.retention_months": "12",
}).applyRetention()
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("months=12 时 5 个月前的归档应保留: %v", err)
}
// 非法月数(0 / 负数)应回退 3 ⇒ 5 个月前被删。
NewManager(dir, fakeCfg{
"core.log.retention": "n_months",
"core.log.retention_months": "0",
}).applyRetention()
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
t.Fatal("months=0 应回退到 3 ⇒ 5 个月前的归档必须被删")
}
}
// ⑤ 原始日志(当月的 homed_*.log)不归保留策略管:
//
// 它们要等跨月压缩成 month_*.tar.gz 之后才可能被清。
//
// 这条是**刻意的语义边界**,钉住它是为了说明"清归档"不等于"清当场日志"。
func TestRawLogsNotRemovedByRetention(t *testing.T) {
dir := t.TempDir()
raw := "homed_2026-01-01_00-00-00.log"
writeFile(t, dir, raw)
NewManager(dir, fakeCfg{"core.log.retention": "this_week"}).applyRetention()
if _, err := os.Stat(filepath.Join(dir, raw)); err != nil {
t.Fatalf("原始 homed_*.log 不应被保留策略直接删除(它先要跨月压缩): %v", err)
}
}
func writeFile(t *testing.T, dir, name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatalf("建 %s: %v", name, err)
}
}

View File

@ -18,6 +18,38 @@ const (
BlockAudio BlockModality = "audio" BlockAudio BlockModality = "audio"
) )
// PageKind 是「页」的类型:一页 = 一次完整往返。
//
// 用户裁定(2026-10-29):
//
// 对 toolcall 来说,页 = toolcall 与其对应的 toolresult
// 对文本来说, 页 = llm 的 input 与其对应的 llm output
//
// 这两类是**渲染正确性的最小单位**:整页留下或整页剔除,
// 永远不会产生"有 call 没 result"的破帧。
type PageKind string
const (
// PageLLMRound 是一次模型往返(input → output)。
PageLLMRound PageKind = "llm_round"
// PageToolRound 是一次工具往返(call → result),靠 CallID 配对。
PageToolRound PageKind = "tool_round"
)
// BlockRole 是块在**页内**的角色。
//
// 每页拆成 request / response 两块(而不是合成一块)的理由:
// 用户的提问与模型的回答语义常常不同,合成一块后只能有一个向量,
// 检索时无法单独命中"用户问的"或"模型答的"。
type BlockRole string
const (
// RoleRequest 是页的请求端:llm 的 input,或 tool 的 call(含参数)。
RoleRequest BlockRole = "request"
// RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。
RoleResponse BlockRole = "response"
)
// MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。 // MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。
// //
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是 // 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
@ -62,9 +94,72 @@ type MemoryBlock struct {
// ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 —— // ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 ——
// 判据 TestGraphCommit_CarriesAllFields 抓到: // 判据 TestGraphCommit_CarriesAllFields 抓到:
// Commit 标注的 (Person, Animal) 读回来是 ("block","block")。 // Commit 标注的 (Person, Animal) 读回来是 ("block","block")。
SemanticType string `json:"semantic_type,omitempty"` SemanticType string `json:"semantic_type,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // ─────────── 段页式身份(2026-10-29)───────────
//
// ## 为什么需要这三个字段
//
// 设计约束(用户裁定):**淘汰/渲染的最小单位必须是"移除后上下文仍渲染正确"**。
// 而"渲染正确"的硬性要求是 OpenAI 协议那条:assistant(tool_calls)
// 后面必须紧跟配对齐全的 tool 消息。所以最小单位不是单块,而是**页**。
//
// 段 Frame = 一次完整任务(一个 FrameTS)
// └─ 页 Page = 一次完整往返
// ├─ llm_round : llm_input ──→ llm_output
// └─ tool_round : tool_call ──配对(CallID)──→ tool_result
//
// ## 为什么用时间戳而不是自增帧号
//
// 用户裁定:帧号**直接用时间戳**,不另造自增编号。理由:
// · 时间戳在 L1/L2/L3 三层天然一致,不需要额外的编号分配器
// · 跨进程/跨层迁移时无需协调(自增号需要中心分配,重启即冲突)
// · 天然可排序(渲染时序直接按它排)
//
// ## 为什么不跨帧
//
// 同一 FrameTS 的块不管走到哪层,**整批待在同一层**。原因不是洁癖:
// 帧是最小可渲染单元,拆开就会出现"有 call 没 result"的破帧。
// 判据见 §C7。
//
// ★ 旧数据(FrameTS 为零值)视为"独立帧",各自成帧、不参与配对校验。
// 这是迁移的兼容路径:不能因为它们没有帧号就拒绝写入或丢弃。
FrameTS time.Time `json:"frame_ts,omitempty"`
// PageSeq 是帧内的**页序号**(从 1 开始,连续不减)。
//
// 与 FrameTS 一起构成页的身份:`(FrameTS, PageSeq)`。
// 同一页内的块(request/response,或 call/result)靠它归组。
PageSeq int `json:"page_seq,omitempty"`
// PageKind 标记这一页是哪种往返。
PageKind PageKind `json:"page_kind,omitempty"`
// BlockRole 是块在页内的角色(request 端 / response 端)。
BlockRole BlockRole `json:"block_role,omitempty"`
// CallID 是 tool_round 的**配对键**:tool_call 块与它的 tool_result 块
// 必须同 CallID。llm_round 的页此字段为空。
//
// ★ 为什么不靠 (FrameTS, PageSeq) 配对:并行工具调用时,
// 一页里有 N 个 call(assistant 一次声明多个),它们共用 PageSeq
// 但各自配对不同的 result —— 必须有 CallID 才分得清。
CallID string `json:"call_id,omitempty"`
// Inactive 标记该页**不参与渲染**(但仍可回读、仍在原层)。
//
// 用户裁定:段超预算时按相似度**剔除**低分页 —— 实际是标注 inactive,
// 不渲染为消息。数据不删、层不迁、随时可读回,因此不是"静默丢失"。
// 渲染时必须在原位置留下提示行,让模型知道"这里省略了内容"。
Inactive bool `json:"inactive,omitempty"`
// LastRecalledAt 是最近一次被召回的时间(P6 回收判定)。
//
// ★ 为什么用「被召回」而不是「被创建」判死活:
// 判断一块记忆有没有用,看它有没有被想起来,不是什么时候写进来的。
// 旧块若一直命中查询,它是活的 —— 不能因为"老"就回收。
//
// 零值 = 从未被召回(回收候选);非零 = 至少用过一次(按时间窗判定)。
// 两者刻意分开:从不被召回 与 很久没被召回 是不同的病。
LastRecalledAt time.Time `json:"last_recalled_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
// MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。 // MemoryBlockEdge 是 L3 中连接一等记忆节点的结构化语义边。
@ -134,8 +229,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
} }
_, err = tx.Exec(`INSERT INTO memory_blocks ( _, err = tx.Exec(`INSERT INTO memory_blocks (
id, modality, text_content, data, mime, size, width, height, id, modality, text_content, data, mime, size, width, height,
vector, fingerprint, source, tool, scene, created_at, updated_at vector, fingerprint, source, tool, scene, created_at, updated_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) frame_ts, page_seq, page_kind, block_role, call_id, inactive,
last_recalled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
modality = excluded.modality, modality = excluded.modality,
text_content = excluded.text_content, text_content = excluded.text_content,
@ -158,10 +255,37 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
-- 后写的三元组往往不带类型(Triple.SubjectType 可选), -- 后写的三元组往往不带类型(Triple.SubjectType 可选),
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。 -- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END,
-- ★ 段页式字段一律「空值不覆盖」:轻量读取路径(lean 列 SELECT)
-- 取回的块不带这些字段,写回时若直接覆盖会抹掉页身份。
-- 页身份一旦被抹,块就再也归不回原来的页 —— 渲染会破配对。
frame_ts = CASE WHEN excluded.frame_ts IS NOT NULL AND excluded.frame_ts != ''
THEN excluded.frame_ts ELSE memory_blocks.frame_ts END,
page_seq = CASE WHEN excluded.page_seq > 0 THEN excluded.page_seq ELSE memory_blocks.page_seq END,
page_kind = CASE WHEN excluded.page_kind != '' THEN excluded.page_kind ELSE memory_blocks.page_kind END,
block_role = CASE WHEN excluded.block_role != '' THEN excluded.block_role ELSE memory_blocks.block_role END,
call_id = CASE WHEN excluded.call_id != '' THEN excluded.call_id ELSE memory_blocks.call_id END,
-- ★ inactive 是「标注」,所以要能被**显式改回 false**(回读后恢复渲染)。
-- 因此它不能走「空值不覆盖」——false 是合法值,必须照写。
inactive = excluded.inactive,
-- ★ 召回时间只**前进不后退**(P6)。
--
-- 轻量读取路径拿回的块不带 last_recalled_at(零值),
-- 若直接覆盖就把"曾经被召回过"抹成"从未被召回" ——
-- 于是这块活数据在回收时被误判为死块删掉。
-- 保留两者的较大值:NULL/零值不会覆盖已有时间。
last_recalled_at = CASE
WHEN excluded.last_recalled_at IS NULL THEN memory_blocks.last_recalled_at
WHEN memory_blocks.last_recalled_at IS NULL THEN excluded.last_recalled_at
WHEN excluded.last_recalled_at > memory_blocks.last_recalled_at
THEN excluded.last_recalled_at
ELSE memory_blocks.last_recalled_at END,
updated_at = excluded.updated_at`, updated_at = excluded.updated_at`,
block.ID, block.Modality, block.Text, block.Data, block.MIME, block.ID, block.Modality, block.Text, block.Data, block.MIME,
block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint, block.Size, block.Width, block.Height, string(vectorJSON), block.Fingerprint,
block.Source, block.Tool, block.Scene, block.CreatedAt, now) block.Source, block.Tool, block.Scene, block.CreatedAt, now,
frameTSArg(block.FrameTS), block.PageSeq, string(block.PageKind),
string(block.BlockRole), block.CallID, block.Inactive,
frameTSArg(block.LastRecalledAt))
if err != nil { if err != nil {
return fmt.Errorf("put memory block %s: %w", block.ID, err) return fmt.Errorf("put memory block %s: %w", block.ID, err)
} }
@ -304,19 +428,40 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别: // 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别:
// 那里字节归另一个存储层所有,这里归块自己。 // 那里字节归另一个存储层所有,这里归块自己。
const blockColumns = `id, modality, text_content, mime, size, width, height, const blockColumns = `id, modality, text_content, mime, size, width, height,
vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at` vector, fingerprint, source, tool, scene, semantic_type, created_at, updated_at,
frame_ts, page_seq, page_kind, block_role, call_id, inactive, last_recalled_at`
func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) { func scanBlockRow(rows *sql.Rows) (MemoryBlock, error) {
var b MemoryBlock var b MemoryBlock
var vectorJSON string var vectorJSON string
var scene sql.NullString var scene sql.NullString
// 段页式字段一律用 Null* 扫:存量行的 frame_ts 是 NULL、其余有 DEFAULT,
// 直接扫进零值类型会在 NULL 上报错(sql: Scan error on column "frame_ts")。
var frameTS sql.NullTime
var pageSeq sql.NullInt64
var pageKind, blockRole, callID sql.NullString
var inactive sql.NullInt64
var lastRecalled sql.NullTime
if err := rows.Scan(&b.ID, &b.Modality, &b.Text, if err := rows.Scan(&b.ID, &b.Modality, &b.Text,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON, &b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType, &b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt); err != nil { &b.CreatedAt, &b.UpdatedAt,
&frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive,
&lastRecalled); err != nil {
return b, err return b, err
} }
if lastRecalled.Valid {
b.LastRecalledAt = lastRecalled.Time
}
b.Scene = scene.String b.Scene = scene.String
if frameTS.Valid {
b.FrameTS = frameTS.Time
}
b.PageSeq = int(pageSeq.Int64)
b.PageKind = PageKind(pageKind.String)
b.BlockRole = BlockRole(blockRole.String)
b.CallID = callID.String
b.Inactive = inactive.Int64 != 0
if vectorJSON != "" && vectorJSON != "null" { if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil { if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil {
return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err) return b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err)
@ -831,3 +976,15 @@ func (g *GraphDB) MemoryEdgeCount() (int, error) {
} }
return n, nil return n, nil
} }
// frameTSArg 把零值 FrameTS 写成 NULL 而不是 "0001-01-01"。
//
// 为什么必须区分:零值表示「旧数据/未分帧」,与真实时间戳不是一回事。
// 若写成 "0001-01-01",迁移脚本会把它们当成一个真实的极早帧聚到一起,
// 于是所有存量块被错误地归入同一页。
func frameTSArg(t time.Time) interface{} {
if t.IsZero() {
return nil
}
return t
}

View File

@ -6,6 +6,8 @@ import (
"log" "log"
"math" "math"
"sort" "sort"
"strings"
"time"
) )
// 块节点的向量召回。 // 块节点的向量召回。
@ -174,9 +176,51 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
if len(hits) > topK { if len(hits) > topK {
hits = hits[:topK] hits = hits[:topK]
} }
// ★ 记录召回时间(P6,2026-10-29)。
//
// 为什么必须记:回收判定看的是"这块记忆还有没有用",而"被召回"
// 是有用的**唯一实证信号** —— 创建时间只说明它什么时候写进来。
// 不记的话,所有块看起来都"很久没用过",回收就等于按入库时间瞎删。
//
// 为什么在解锁后做:本函数持 RLock,写库会撞锁(SQLite 单写)。
// 所以先把命中的 ID 复制出来,defer 到 RUnlock 之后再批量更新。
// 失败只记日志:召回本身已经成功,不能因为"记不上时间"就报错。
hitIDs := make([]string, len(hits))
for i := range hits {
hitIDs[i] = hits[i].Block.ID
}
if len(hitIDs) > 0 {
defer func() { g.markRecalled(hitIDs) }()
}
return hits, nil return hits, nil
} }
// markRecalled 批量刷新 last_recalled_at。
//
// 单条 UPDATE ... IN (...) 而不是逐条:召回 topK=10~50,
// 逐条就是 10~50 次独立写事务,在 SQLite 上会明显拖慢每轮对话。
//
// 失败只记日志:这是"统计信号",不是正确性数据 ——
// 记不上时间最多让回收判据偏保守(少删),绝不会删错。
func (g *GraphDB) markRecalled(ids []string) {
if len(ids) == 0 {
return
}
ph := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, time.Now())
for i, id := range ids {
ph[i] = "?"
args = append(args, id)
}
if _, err := g.db.Exec(
`UPDATE memory_blocks SET last_recalled_at = ?
WHERE id IN (`+strings.Join(ph, ",")+`)`, args...); err != nil {
log.Printf("[graph] mark recalled failed (%d blocks): %v", len(ids), err)
}
}
// RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情 // RecallBlocksWithArbitration 与 RecallBlocks 相同,但额外返回仲裁详情
// (被取代的块、结论状态)。 // (被取代的块、结论状态)。
// //

View File

@ -1,4 +1,4 @@
package document package coldcontext
// L1 ↔ L2 同构协议的判据。 // L1 ↔ L2 同构协议的判据。
// //
@ -34,7 +34,7 @@ func newDocTestStore(t *testing.T) *Store {
// ① 正文完整性:归档后能逐条取回**完整**正文。 // ① 正文完整性:归档后能逐条取回**完整**正文。
// //
// 断点史:原实现 ContextToDoc 把每条事件拍平成一行, // 断点史:原实现 ContextToColdContext 把每条事件拍平成一行,
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。 // truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。 // 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
// //
@ -46,7 +46,7 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】" longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC) ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC)
_, err := s.ContextToDoc("context_archived", []ContextEntry{{ _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: ts, Timestamp: ts,
Source: "webui", Source: "webui",
Content: longInput, Content: longInput,
@ -60,8 +60,8 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
var d *Doc var d *ColdDoc
for _, cand := range s.RecentDocs(10) { for _, cand := range s.RecentColdContexts(10) {
d = cand d = cand
} }
if d == nil { if d == nil {
@ -113,7 +113,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage, ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
Data: []byte("x"), MIME: "image/png", Size: 1, Data: []byte("x"), MIME: "image/png", Size: 1,
} }
_, err := s.ContextToDoc("context_archived", []ContextEntry{{ _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Timestamp: time.Now(),
Source: "qq", Source: "qq",
Content: "用户问:紫蓝红三色带怎么配", Content: "用户问:紫蓝红三色带怎么配",
@ -123,7 +123,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
var texts []memory.MemoryBlock var texts []memory.MemoryBlock
for _, b := range d.Events[0].Blocks { for _, b := range d.Events[0].Blocks {
@ -172,10 +172,10 @@ func TestL2_KeepsEveryEventIndividually(t *testing.T) {
Response: "第" + string(rune('0'+i)) + "条回答", Response: "第" + string(rune('0'+i)) + "条回答",
}) })
} }
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
if len(d.Events) != 5 { if len(d.Events) != 5 {
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events)) t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
@ -201,13 +201,13 @@ func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
s := newDocTestStore(t) s := newDocTestStore(t)
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】" long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui", Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: long, Content: "输入", Response: long,
}}, nil, nil, nil, nil); err != nil { }}, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
if !strings.Contains(d.Content, "【视图哨兵】") { if !strings.Contains(d.Content, "【视图哨兵】") {
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断") t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
@ -231,23 +231,23 @@ func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答", Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答",
}} }}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
first := s.RecentDocs(1)[0] first := s.RecentColdContexts(1)[0]
id1 := first.Events[0].Blocks[0].ID id1 := first.Events[0].Blocks[0].ID
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。 // 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。
if n := len(s.Stats()); n == 0 { if n := len(s.Stats()); n == 0 {
t.Fatal("stats 异常") t.Fatal("stats 异常")
} }
if got := len(s.RecentDocs(10)); got != 1 { if got := len(s.RecentColdContexts(10)); got != 1 {
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got) t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
if len(d.Events) != 1 { if len(d.Events) != 1 {
t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events)) t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events))
} }
@ -300,7 +300,7 @@ func writeFile(path string, data []byte) error { return os.WriteFile(path, data,
// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。 // ⑦ 蒸馏/抽取必须基于原文,不能基于视图。
// //
// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前 // 断点史:coldDocToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析, // 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析,
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量 // 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。 // 「三元组 N 条全被实体名校验拒绝」即与此相关)。
@ -311,13 +311,13 @@ func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
s := newDocTestStore(t) s := newDocTestStore(t)
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】" full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui", Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: full, Content: "输入", Response: full,
}}, nil, nil, nil, nil); err != nil { }}, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
// 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。 // 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。
d.Content = "视图被破坏了" d.Content = "视图被破坏了"
@ -361,10 +361,10 @@ func TestL2_StressLargeArchive(t *testing.T) {
} }
} }
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { if _, err := s.ContextToColdContext("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
if len(d.Events) != n { if len(d.Events) != n {
t.Fatalf("Events = %d,期望 %d", len(d.Events), n) t.Fatalf("Events = %d,期望 %d", len(d.Events), n)
} }
@ -419,7 +419,7 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
const docs = 60 const docs = 60
for i := 0; i < docs; i++ { for i := 0; i < docs; i++ {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Duration(i) * time.Second), Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "stress", Source: "stress",
Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)), Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)),
@ -441,14 +441,14 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
case 1: case 1:
s.Consume("并发查询目标", 3) s.Consume("并发查询目标", 3)
case 2: case 2:
s.FindColdDocs(time.Hour, 10) s.FindColdContexts(time.Hour, 10)
} }
} }
}(w) }(w)
} }
wg.Wait() wg.Wait()
// 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。 // 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。
if got := len(s.RecentDocs(1000)); got > docs { if got := len(s.RecentColdContexts(1000)); got > docs {
t.Errorf("文档数异常增长: %d > %d", got, docs) t.Errorf("文档数异常增长: %d > %d", got, docs)
} }
} }
@ -461,13 +461,13 @@ func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
s := newDocTestStore(t) s := newDocTestStore(t)
huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "stress", Timestamp: time.Now(), Source: "stress",
Content: "输入", Response: huge, Content: "输入", Response: huge,
}}, nil, nil, nil, nil); err != nil { }}, nil, nil, nil, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
d := s.RecentDocs(1)[0] d := s.RecentColdContexts(1)[0]
_, resp, _ := d.Events[0].InOut() _, resp, _ := d.Events[0].InOut()
if len(resp) != len(huge) { if len(resp) != len(huge) {
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge)) t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))

View File

@ -1,4 +1,4 @@
package document package coldcontext
import ( import (
"crypto/sha256" "crypto/sha256"
@ -25,20 +25,20 @@ import (
// 返回 nil 表示不使用额外清洗。 // 返回 nil 表示不使用额外清洗。
type ChannelCleaner func(source string) func(string) string type ChannelCleaner func(source string) func(string) string
// Doc — 记忆文档:由上下文提炼而来 // ColdDoc — 记忆文档:由上下文提炼而来
// //
// ## L1/L2 同构 // ## L1/L2 同构
// //
// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。 // 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。
// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。 // 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。
// //
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段), // Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 ColdDoc.Content 字段),
// 不是原文载体。原文在 Events 里,正文内联在文本块里。 // 不是原文载体。原文在 Events 里,正文内联在文本块里。
type Doc struct { type ColdDoc struct {
ID string `json:"id"` ID string `json:"id"`
Summary string `json:"summary"` Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文 Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"` Events []ColdEvent `json:"events,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@ -55,7 +55,7 @@ type Doc struct {
// · 存量文档(无 Events)只有这一个字段有块。 // · 存量文档(无 Events)只有这一个字段有块。
// //
// ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。 // ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。
// 两者由 ContextToDoc 同一处产出,不得分别修改。 // 两者由 ContextToColdContext 同一处产出,不得分别修改。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
@ -63,7 +63,7 @@ type Doc struct {
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
} }
// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。 // ColdEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
// //
// ★ 为何正文不在字段里、而在块里 // ★ 为何正文不在字段里、而在块里
// //
@ -72,11 +72,13 @@ type Doc struct {
// 那会出现「块里的正文」与「字段里的正文」两份真相。 // 那会出现「块里的正文」与「字段里的正文」两份真相。
// //
// 读原文 = 读 Blocks 里文本块的 Text。 // 读原文 = 读 Blocks 里文本块的 Text。
type DocEvent struct { type ColdEvent struct {
Timestamp time.Time `json:"ts"` Timestamp time.Time `json:"ts"`
Source string `json:"source"` Source string `json:"source"`
ToolsUsed []string `json:"tools_used,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// ToolCalls 是每次工具调用的完整记录(含参数),与 ToolResults 靠 CallID 配对。
ToolCalls []ToolCallItem `json:"tool_calls,omitempty"`
// Blocks 是本事件持有的块,**正文就在里面**。 // Blocks 是本事件持有的块,**正文就在里面**。
// //
@ -90,9 +92,9 @@ type DocEvent struct {
// //
// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到 // 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到
// 「把 Content 塞进一条事件」,而不是假装能回填。 // 「把 Content 塞进一条事件」,而不是假装能回填。
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } func (d *ColdDoc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// docVectorAlgoVersion 是**文档向量算法**的版本标记。 // coldcontextVectorAlgoVersion 是**文档向量算法**的版本标记。
// //
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。 // 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变 // 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
@ -106,7 +108,7 @@ func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// //
// v1 初版:VectorizeDense(Summary + Content) // v1 初版:VectorizeDense(Summary + Content)
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释) // v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
const docVectorAlgoVersion = "v2" const coldcontextVectorAlgoVersion = "v2"
// Store — 文档记忆存储。 // Store — 文档记忆存储。
// //
@ -124,7 +126,7 @@ const docVectorAlgoVersion = "v2"
type Store struct { type Store struct {
dir string dir string
mu sync.RWMutex mu sync.RWMutex
docs map[string]*Doc docs map[string]*ColdDoc
dirty bool dirty bool
// fallback 路径(仅稠密空间不可用时加载) // fallback 路径(仅稠密空间不可用时加载)
@ -142,7 +144,7 @@ type Store struct {
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store { func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
return &Store{ return &Store{
dir: dir, dir: dir,
docs: make(map[string]*Doc), docs: make(map[string]*ColdDoc),
// tfidf 延迟初始化:只在需要 fallback 时创建 // tfidf 延迟初始化:只在需要 fallback 时创建
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096), tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
} }
@ -167,7 +169,7 @@ func (s *Store) ensureTFIDF() {
} }
return out return out
}()) }())
log.Printf("[document memory] tfidf fallback loaded: %d docs", len(texts)) log.Printf("[coldcontext] tfidf fallback loaded: %d docs", len(texts))
}) })
} }
@ -176,9 +178,9 @@ func (s *Store) Start() error {
return fmt.Errorf("document store dir: %w", err) return fmt.Errorf("document store dir: %w", err)
} }
if err := s.loadAll(); err != nil { if err := s.loadAll(); err != nil {
log.Printf("[document memory] load error: %v", err) log.Printf("[coldcontext] load error: %v", err)
} }
log.Printf("[document memory] started with %d docs", len(s.docs)) log.Printf("[coldcontext] started with %d docs", len(s.docs))
return nil return nil
} }
@ -198,7 +200,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
} }
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim()) log.Printf("[coldcontext] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
count := 0 count := 0
for _, doc := range s.docs { for _, doc := range s.docs {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() { if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
@ -220,7 +222,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
s.dirty = true s.dirty = true
s.flushLocked() s.flushLocked()
} }
log.Printf("[document memory] dense index built: %d new vectors", count) log.Printf("[coldcontext] dense index built: %d new vectors", count)
} }
// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。 // denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。
@ -241,7 +243,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
// //
// ## 媒体怎么办:不需要单独生成摘要 // ## 媒体怎么办:不需要单独生成摘要
// //
// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入 // 媒体块与文本块**在同一事件内**(ColdDoc.Events[i].Blocks),事件的正文进入
// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」 // 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」
// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。 // 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。
// //
@ -252,7 +254,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
// //
// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的 // 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的
// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。 // 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。
func (s *Store) denseFor(doc *Doc) []float64 { func (s *Store) denseFor(doc *ColdDoc) []float64 {
if s.denseSpace == nil || !s.denseSpace.Loaded() { if s.denseSpace == nil || !s.denseSpace.Loaded() {
return nil return nil
} }
@ -262,7 +264,7 @@ func (s *Store) denseFor(doc *Doc) []float64 {
// 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。 // 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。
// 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。 // 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。
if text := documentTextForVector(doc); text != "" { if text := coldcontextTextForVector(doc); text != "" {
if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 { if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 {
parts = append(parts, tv) parts = append(parts, tv)
} }
@ -284,20 +286,20 @@ func (s *Store) denseFor(doc *Doc) []float64 {
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。 // vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
// //
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算, // 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。 // 否则存量数据永远停留在旧算法的结果上(实测后果见 coldcontextVectorAlgoVersion)。
func (s *Store) vectorFingerprint() string { func (s *Store) vectorFingerprint() string {
if s.denseSpace == nil { if s.denseSpace == nil {
return "" return ""
} }
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion return s.denseSpace.Fingerprint() + "|" + coldcontextVectorAlgoVersion
} }
// documentTextForVector 取文档用于向量化的文本:**正文**。 // coldcontextTextForVector 取文档用于向量化的文本:**正文**。
// //
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档 // 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
// 退回 Content(渲染视图,但仍是原文最接近的载体)。 // 退回 Content(渲染视图,但仍是原文最接近的载体)。
// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。 // 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。
func documentTextForVector(doc *Doc) string { func coldcontextTextForVector(doc *ColdDoc) string {
if doc == nil { if doc == nil {
return "" return ""
} }
@ -342,7 +344,7 @@ func (s *Store) Reindex() {
s.ensureTFIDF() s.ensureTFIDF()
} }
func (s *Store) Insert(doc *Doc) error { func (s *Store) Insert(doc *ColdDoc) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@ -379,8 +381,8 @@ func (s *Store) Insert(doc *Doc) error {
return nil return nil
} }
// ContextToDoc 将上下文对话历史提炼为文档。 // ContextToColdContext 将上下文对话历史提炼为文档。
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) { func (s *Store) ContextToColdContext(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*ColdDoc, error) {
if len(entries) == 0 { if len(entries) == 0 {
return nil, nil return nil, nil
} }
@ -426,7 +428,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
if source == "context_archived" { if source == "context_archived" {
meta["is_archived_context"] = "true" meta["is_archived_context"] = "true"
} }
doc := &Doc{ doc := &ColdDoc{
ID: id, Summary: summary, Content: content, ID: id, Summary: summary, Content: content,
CreatedAt: time.Now(), UpdatedAt: time.Now(), CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
@ -453,9 +455,37 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
} }
// Consume 向量相似度查询并移除文档 // Consume 向量相似度查询并移除文档
func (s *Store) Consume(text string, topK int) []*Doc { // Peek 检索匹配的文档,**只读不删**。
//
// ## ★ 为什么必须有它(2026-10-29 修「先删后灌」)
//
// 原实现 `Consume` 是**读+删一步完成**:
//
// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了
// for _, d := range docs {
// n := a.restoreDocEvents(d) // 若这里失败(panic/向量算错/被中断)
// } // ⇒ L1 没进、L2 已删 ⇒ 三层都不在
//
// 「消费式检索」把「取回」和「删除」绑成一个不可分的动作,于是删除这个
// **不可逆步骤**发生在确认之前。这正是 plan.md 记的第四个病:回灌失败
// 就三层都不在了。
//
// 现在拆成两阶段(用户裁定 P4):
//
// docs := coldcontextStore.Peek(query, topK) // ① 只读
// ...确认每条都真的落进 L1... // ② 校验
// coldcontextStore.Remove(id) // ③ 确认后才删副本
//
// **禁止先删后灌** —— 删除必须排在确认之后。
func (s *Store) Peek(text string, topK int) []*ColdDoc {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
return s.searchLocked(text, topK)
}
// searchLocked 是检索本体(调用方须持锁)。Peek 与 Consume 共用它,
// 保证两条路径的**检索语义完全一致** —— 各自写一份必然漂移。
func (s *Store) searchLocked(text string, topK int) []*ColdDoc {
if topK <= 0 { if topK <= 0 {
topK = 5 topK = 5
} }
@ -464,11 +494,9 @@ func (s *Store) Consume(text string, topK int) []*Doc {
if s.denseSpace != nil && s.denseSpace.Loaded() { if s.denseSpace != nil && s.denseSpace.Loaded() {
if qv, err := s.denseSpace.VectorizeDense(text); err == nil { if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
results := s.denseSearchScored(qv, topK) results := s.denseSearchScored(qv, topK)
var docs []*Doc var docs []*ColdDoc
for _, r := range results { for _, r := range results {
if d, ok := s.docs[r.Doc.ID]; ok { if d, ok := s.docs[r.Doc.ID]; ok {
s.removeDoc(r.Doc.ID)
s.dirty = true
docs = append(docs, d) docs = append(docs, d)
} }
} }
@ -479,38 +507,58 @@ func (s *Store) Consume(text string, topK int) []*Doc {
// Fallback:TF-IDF 倒排检索(延迟初始化) // Fallback:TF-IDF 倒排检索(延迟初始化)
s.ensureTFIDF() s.ensureTFIDF()
results := s.tfidfIdx.Search(text, topK) results := s.tfidfIdx.Search(text, topK)
var docs []*Doc var docs []*ColdDoc
for _, r := range results { for _, r := range results {
if d, ok := s.docs[r.ID]; ok { if d, ok := s.docs[r.ID]; ok {
s.removeDoc(r.ID)
s.dirty = true
docs = append(docs, d) docs = append(docs, d)
} }
} }
return docs return docs
} }
func (s *Store) Query(text string, topK int) []*Doc { // Consume 检索并**立即删除**匹配的文档。
//
// ⚠️ 保留它只为兼容:**新代码必须用 Peek + 确认 + Remove**。
// 用 Consume 就是「先删后灌」,回灌失败即永久丢数据(见 Peek 的说明)。
//
// 现存调用点已全部改为两阶段(coldcontext_query)。本方法留给
// 尚未迁移的测试与外部工具,且注释里标明它是错的用法。
func (s *Store) Consume(text string, topK int) []*ColdDoc {
s.mu.Lock()
defer s.mu.Unlock()
docs := s.searchLocked(text, topK)
for _, d := range docs {
s.removeDoc(d.ID)
s.dirty = true
}
return docs
}
func (s *Store) Query(text string, topK int) []*ColdDoc {
hits := s.QueryScored(text, topK) hits := s.QueryScored(text, topK)
out := make([]*Doc, len(hits)) out := make([]*ColdDoc, len(hits))
for i, h := range hits { for i, h := range hits {
out[i] = h.Doc out[i] = h.Doc
} }
return out return out
} }
// DocHit 是一篇文档记忆的相似度候选及原始分数。 // ColdHit 是一篇文档记忆的相似度候选及原始分数。
type DocHit struct { type ColdHit struct {
Doc *Doc // Doc 是命中的冷上下文条目。
//
// 字段名保持 Doc:它是**字段**不是类型,改名会波及所有 h.Doc 调用点,
// 而与"类型叫什么"无关。
Doc *ColdDoc
Score float64 Score float64
} }
func (s *Store) QueryScored(text string, topK int) []DocHit { func (s *Store) QueryScored(text string, topK int) []ColdHit {
if topK <= 0 { if topK <= 0 {
topK = 5 topK = 5
} }
var hits []DocHit var hits []ColdHit
// 主路径:稠密检索。只读,持读锁。 // 主路径:稠密检索。只读,持读锁。
s.mu.RLock() s.mu.RLock()
@ -528,7 +576,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
results := s.tfidfIdx.Search(text, topK) results := s.tfidfIdx.Search(text, topK)
for _, r := range results { for _, r := range results {
if d, ok := s.docs[r.ID]; ok { if d, ok := s.docs[r.ID]; ok {
hits = append(hits, DocHit{Doc: d, Score: r.Score}) hits = append(hits, ColdHit{Doc: d, Score: r.Score})
} }
} }
s.mu.Unlock() s.mu.Unlock()
@ -537,8 +585,8 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
// 访问计数是**写**:必须离开读锁后再取写锁更新。 // 访问计数是**写**:必须离开读锁后再取写锁更新。
// //
// 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`, // 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`,
// 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 —— // 与 FindColdContexts / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
// 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」 // 计数只在内存里涨,优雅关停也不落盘,FindColdContexts 的「≤2 次访问」
// 冷度判据跨重启失真。 // 冷度判据跨重启失真。
s.markAccess(hits) s.markAccess(hits)
return hits return hits
@ -546,7 +594,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
// markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。 // markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。
// 调用方不得持有 s.mu。 // 调用方不得持有 s.mu。
func (s *Store) markAccess(hits []DocHit) { func (s *Store) markAccess(hits []ColdHit) {
if len(hits) == 0 { if len(hits) == 0 {
return return
} }
@ -565,7 +613,7 @@ func (s *Store) markAccess(hits []DocHit) {
s.dirty = true s.dirty = true
} }
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit { func (s *Store) denseSearchScored(queryVec []float64, topK int) []ColdHit {
if len(queryVec) == 0 { if len(queryVec) == 0 {
return nil return nil
} }
@ -590,9 +638,9 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
if len(results) > topK { if len(results) > topK {
results = results[:topK] results = results[:topK]
} }
out := make([]DocHit, len(results)) out := make([]ColdHit, len(results))
for i, r := range results { for i, r := range results {
out[i] = DocHit{Doc: s.docs[r.did], Score: r.score} out[i] = ColdHit{Doc: s.docs[r.did], Score: r.score}
} }
return out return out
} }
@ -624,11 +672,11 @@ func (s *Store) Stats() map[string]interface{} {
} }
} }
func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc { func (s *Store) FindColdContexts(maxAge time.Duration, minAccess int) []*ColdDoc {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
cutoff := time.Now().Add(-maxAge) cutoff := time.Now().Add(-maxAge)
var cold []*Doc var cold []*ColdDoc
for _, d := range s.docs { for _, d := range s.docs {
if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) { if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) {
cold = append(cold, d) cold = append(cold, d)
@ -638,7 +686,7 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
} }
// Get 返回指定文档(不存在时为 nil)。 // Get 返回指定文档(不存在时为 nil)。
func (s *Store) Get(id string) *Doc { func (s *Store) Get(id string) *ColdDoc {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
return s.docs[id] return s.docs[id]
@ -655,10 +703,10 @@ func (s *Store) Blocks() []memory.MemoryBlock {
return out return out
} }
func (s *Store) RecentDocs(n int) []*Doc { func (s *Store) RecentColdContexts(n int) []*ColdDoc {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
var list []*Doc var list []*ColdDoc
for _, d := range s.docs { for _, d := range s.docs {
list = append(list, d) list = append(list, d)
} }
@ -702,7 +750,7 @@ func (s *Store) loadAll() error {
if err != nil { if err != nil {
continue continue
} }
var doc Doc var doc ColdDoc
if json.Unmarshal(data, &doc) != nil || doc.ID == "" { if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
continue continue
} }
@ -733,16 +781,35 @@ func (s *Store) flushLocked() {
// ——— 内部工具函数(从上下文提炼文档所需)——— // ——— 内部工具函数(从上下文提炼文档所需)———
// ToolResultItem 是一条工具结果。
//
// ★ CallID 与 internal/agent/core 的同名字段必须**逐字一致**(JSON 键名
// 都是 call_id):L1↔L2 之间靠它配对,键名不一致会在归档/回填时静默丢失。
type ToolResultItem struct { type ToolResultItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name"` Name string `json:"name"`
Output string `json:"output"` Output string `json:"output"`
} }
// ToolCallItem 是一次工具调用的完整记录(含参数与配对键)。
//
// 与 internal/agent/core.ToolCalls 的字段逐字对应。
// L2 之所以也要存它:回灌(coldcontext_query)要把事件原样插回 L1,
// 若 L2 缺参数,回灌后「这一页的 call 侧是空的」,页化与配对校验都失效。
type ToolCallItem struct {
CallID string `json:"call_id,omitempty"`
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
RawArguments string `json:"raw_arguments,omitempty"`
}
type ContextEntry struct { type ContextEntry struct {
Timestamp time.Time Timestamp time.Time
Source string Source string
Content string Content string
Response string Response string
// ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。
ToolCalls []ToolCallItem
ToolsUsed []string ToolsUsed []string
ToolResults []ToolResultItem ToolResults []ToolResultItem
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档 Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
@ -913,15 +980,35 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
// 文本块:正文内联在 Text 里,不带向量。 // 文本块:正文内联在 Text 里,不带向量。
// //
// 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次, // 为什么不在这里算向量:L2 的检索向量由 ColdDoc.denseFor 用整篇文档文本算一次,
// 单条事件的文本块不参与 L2 召回(它是回填用的载体)。 // 单条事件的文本块不参与 L2 召回(它是回填用的载体)。
// 这与「向量只算一次」一致 —— 不在迁移路径上重算。 // 这与「向量只算一次」一致 —— 不在迁移路径上重算。
// ★★ 段页式身份(P2,2026-10-29)
//
// 段 Frame = 一条事件(一次完整任务),用事件时间戳作帧号(不另造自增 id)。
// 页 Page = 一次完整往返:
// · llm_round —— 用户输入 → 模型输出
// · tool_round —— 工具调用 → 工具结果(靠 CallID 配对)
//
// 为什么必须带上这些字段(否则 L2 里全是"无页"块):
// 回灌时要把块还原成配对正确的事件;没有页身份就只能靠位置猜,
// 而「assistant(tool_calls) 后面必须紧跟配对齐全的 tool 消息」
// 是上游硬要求 —— 猜错就是 HTTP 400。
//
// 帧内页序约定:llm_round 恒为第 1 页,工具轮按 ToolCalls 顺序 2..n+1。
// 同一帧的所有页共享同一个 FrameTS(铁律:块不跨帧)。
frameTS := now
if t := strings.TrimSpace(e.Content); t != "" { if t := strings.TrimSpace(e.Content); t != "" {
out = append(out, memory.MemoryBlock{ out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t), ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t),
Modality: memory.BlockText, Modality: memory.BlockText,
Text: t, Text: t,
Source: e.Source, Source: e.Source,
FrameTS: frameTS,
PageSeq: 1,
PageKind: memory.PageLLMRound,
BlockRole: memory.RoleRequest,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
}) })
@ -932,20 +1019,136 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
Modality: memory.BlockText, Modality: memory.BlockText,
Text: t, Text: t,
Source: e.Source, Source: e.Source,
FrameTS: frameTS,
PageSeq: 1,
PageKind: memory.PageLLMRound,
BlockRole: memory.RoleResponse,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
}) })
} }
// 工具轮:每个 toolcall 与它的 result 构成一页。
//
// call 侧正文 = 参数(JSON 形式);response 侧正文 = 工具输出。
// 参数此前根本没被记录(见 core.ToolCallItem 的注释),
// 所以这里必须容忍「只有 result 没有 call」的存量情形:
// 那种情况仍按 result 建页,只是 call 侧缺失(渲染时按位置配对)。
for i := range e.ToolCalls {
tc := &e.ToolCalls[i]
page := i + 2 // 第 1 页是 llm_round
callText := toolCallPageText(tc)
if callText != "" {
out = append(out, memory.MemoryBlock{
ID: entryToolBlockID(now, e.Source, page, memory.RoleRequest, tc.CallID, callText),
Modality: memory.BlockText,
Text: callText,
Source: e.Source,
FrameTS: frameTS,
PageSeq: page,
PageKind: memory.PageToolRound,
BlockRole: memory.RoleRequest,
CallID: tc.CallID,
CreatedAt: now,
UpdatedAt: now,
})
}
// 配对的结果(CallID 相同;存量数据无 CallID 时按名字取第一个未用的)
if resText, ok := matchToolResult(e.ToolResults, tc); ok {
out = append(out, memory.MemoryBlock{
ID: entryToolBlockID(now, e.Source, page, memory.RoleResponse, tc.CallID, resText),
Modality: memory.BlockText,
Text: resText,
Source: e.Source,
FrameTS: frameTS,
PageSeq: page,
PageKind: memory.PageToolRound,
BlockRole: memory.RoleResponse,
CallID: tc.CallID,
CreatedAt: now,
UpdatedAt: now,
})
}
}
out = append(out, e.Blocks...) out = append(out, e.Blocks...)
return out return out
} }
// toolCallPageText 把一次工具调用渲染成页的 call 侧正文。
//
// 优先用解析后的参数(结构清晰);没有则退回原始 JSON。
// 两者都没有时返回空 —— 调用方据此决定不建 call 块
// (而不是造一个只有工具名的空块,那会让「页是完整的」变成谎话)。
func toolCallPageText(tc *ToolCallItem) string {
if tc == nil {
return ""
}
if len(tc.Arguments) > 0 {
if b, err := json.Marshal(tc.Arguments); err == nil {
return tc.Name + " " + string(b)
}
}
if strings.TrimSpace(tc.RawArguments) != "" {
return tc.Name + " " + tc.RawArguments
}
return ""
}
// matchToolResult 按配对键找出这次调用的结果。
//
// 配对顺序(从强到弱):
// 1. CallID 相同 —— 唯一可靠的配对(并行调用必须靠它)
// 2. 名字相同 —— 存量数据没有 CallID 时的降级
//
// 空 Name 对空 Name 也算匹配:早期数据里 name 可能缺失,
// 那时仍应把输出展示出来,而不是整段丢掉。
func matchToolResult(results []ToolResultItem, tc *ToolCallItem) (string, bool) {
if tc == nil {
return "", false
}
if tc.CallID != "" {
for i := range results {
if results[i].CallID == tc.CallID {
return results[i].Output, true
}
}
}
for i := range results {
if results[i].Name == tc.Name {
return results[i].Output, true
}
}
return "", false
}
// entryToolBlockID 派生工具页的块 ID。
//
// 与 entryTextBlockID 同构(事件身份 + 页 + 角色 + 正文),
// 额外掺入页序与 CallID:同一事件里两次并行调用可能正文相同,
// 若只按正文派生就会**合成一块**,页就少了一半。
func entryToolBlockID(evtTS time.Time, source string, page int, role memory.BlockRole, callID, text string) string {
h := sha256.New()
h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10)))
h.Write([]byte{0})
h.Write([]byte(source))
h.Write([]byte{0})
h.Write([]byte(strconv.Itoa(page)))
h.Write([]byte{0})
h.Write([]byte(string(role)))
h.Write([]byte{0})
h.Write([]byte(callID))
h.Write([]byte{0})
h.Write([]byte(text))
return "blk_tool_" + hex.EncodeToString(h.Sum(nil)[:12])
}
// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的 // blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的
// Input / Response 正文与它持有的块。 // Input / Response 正文与它持有的块。
// //
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后), // 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。 // 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) { func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) {
if ev == nil { if ev == nil {
return "", "", nil return "", "", nil
} }
@ -960,6 +1163,14 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
input = b.Text input = b.Text
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse): case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
response = b.Text response = b.Text
case b.PageKind == memory.PageToolRound:
// ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults
// 的页级表示(P2 派生出来的)。若把它当持有块塞回去,
// 回灌后事件会多出一堆无关块,且下次归档再派生一遍 ⇒ 越滚越多。
//
// 权威数据仍是 ev.ToolCalls / ev.ToolResults,
// 这里只是跳过(不重建),避免双份事实源。
continue
default: default:
// 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。 // 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。
held = append(held, b) held = append(held, b)
@ -969,36 +1180,39 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
} }
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。 // isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool { func isEntryTextBlock(b memory.MemoryBlock, ev *ColdEvent, role string) bool {
return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text) return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text)
} }
// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。 // docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。
func docEventsFromEntries(entries []ContextEntry) []DocEvent { func docEventsFromEntries(entries []ContextEntry) []ColdEvent {
if len(entries) == 0 { if len(entries) == 0 {
return nil return nil
} }
out := make([]DocEvent, 0, len(entries)) out := make([]ColdEvent, 0, len(entries))
for _, e := range entries { for _, e := range entries {
ev := DocEvent{ ev := ColdEvent{
Timestamp: e.Timestamp, Timestamp: e.Timestamp,
Source: e.Source, Source: e.Source,
ToolsUsed: append([]string(nil), e.ToolsUsed...), ToolsUsed: append([]string(nil), e.ToolsUsed...),
ToolCalls: append([]ToolCallItem(nil), e.ToolCalls...),
Blocks: docBlocksForEntry(e), Blocks: docBlocksForEntry(e),
} }
for _, tr := range e.ToolResults { for _, tr := range e.ToolResults {
ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output}) ev.ToolResults = append(ev.ToolResults, ToolResultItem{
CallID: tr.CallID, Name: tr.Name, Output: tr.Output,
})
} }
out = append(out, ev) out = append(out, ev)
} }
return out return out
} }
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。 // flattenEventBlocks 取事件列表里全部块(去重后的并集),供 ColdDoc.Blocks 使用。
// //
// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化, // 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化,
// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。 // 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。
func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock { func flattenEventBlocks(events []ColdEvent) []memory.MemoryBlock {
seen := make(map[string]bool) seen := make(map[string]bool)
var out []memory.MemoryBlock var out []memory.MemoryBlock
for _, ev := range events { for _, ev := range events {
@ -1014,15 +1228,15 @@ func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
return out return out
} }
// contentFromEvents 用事件正文渲染出 Doc.Content。 // contentFromEvents 用事件正文渲染出 ColdDoc.Content。
// //
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看, // ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 ColdDoc.Content 有东西可看,
// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。 // 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。
// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。 // 原文永远以 Events[i].Blocks 里文本块的 Text 为准。
// //
// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息—— // 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息——
// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。 // 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。
func contentFromEvents(events []DocEvent) string { func contentFromEvents(events []ColdEvent) string {
var parts []string var parts []string
for _, ev := range events { for _, ev := range events {
input, response, _ := blocksToEntry(&ev) input, response, _ := blocksToEntry(&ev)
@ -1042,6 +1256,6 @@ func contentFromEvents(events []DocEvent) string {
// //
// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent, // 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent,
// 而正文住在块里,所以必须由本包解读块的角色与归属。 // 而正文住在块里,所以必须由本包解读块的角色与归属。
func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) { func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) {
return blocksToEntry(ev) return blocksToEntry(ev)
} }

View File

@ -1,4 +1,4 @@
package document package coldcontext
import ( import (
"fmt" "fmt"
@ -23,7 +23,7 @@ func TestInsertAndQuery(t *testing.T) {
} }
defer s.Stop() defer s.Stop()
doc := &Doc{ doc := &ColdDoc{
Summary: "用户喜欢喝咖啡", Summary: "用户喜欢喝咖啡",
Content: "用户提到他每天早上都会喝一杯黑咖啡", Content: "用户提到他每天早上都会喝一杯黑咖啡",
Source: "manual", Source: "manual",
@ -53,9 +53,9 @@ func TestQuery(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"}) s.Insert(&ColdDoc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"}) s.Insert(&ColdDoc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"}) s.Insert(&ColdDoc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
results := s.Query("咖啡", 5) results := s.Query("咖啡", 5)
if len(results) == 0 { if len(results) == 0 {
@ -83,7 +83,7 @@ func TestContextToDoc(t *testing.T) {
{Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"}, {Timestamp: time.Now(), Source: "user", Content: "特别是Go语言", Response: "Go很棒"},
} }
doc, err := s.ContextToDoc("test", entries, nil, nil, nil, nil) doc, err := s.ContextToColdContext("test", entries, nil, nil, nil, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -109,19 +109,19 @@ func TestFindColdDocs(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"} hot := &ColdDoc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
hot.AccessCount = 10 hot.AccessCount = 10
hot.LastAccess = time.Now() hot.LastAccess = time.Now()
s.Insert(hot) s.Insert(hot)
cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"} cold := &ColdDoc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
s.Insert(cold) s.Insert(cold)
// Insert 会重置 LastAccess,手动改为过去的 // Insert 会重置 LastAccess,手动改为过去的
cold.LastAccess = time.Now().Add(-100 * time.Hour) cold.LastAccess = time.Now().Add(-100 * time.Hour)
cold.AccessCount = 1 cold.AccessCount = 1
// 应该只找到 cold(72h 前未访问且访问 ≤ 2) // 应该只找到 cold(72h 前未访问且访问 ≤ 2)
coldDocs := s.FindColdDocs(72*time.Hour, 2) coldDocs := s.FindColdContexts(72*time.Hour, 2)
if len(coldDocs) != 1 { if len(coldDocs) != 1 {
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs)) t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
} }
@ -141,11 +141,11 @@ func TestRecentDocs(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"}) s.Insert(&ColdDoc{Summary: "第一条", Content: "a", Source: "manual"})
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
s.Insert(&Doc{Summary: "第二条", Content: "b", Source: "manual"}) s.Insert(&ColdDoc{Summary: "第二条", Content: "b", Source: "manual"})
recent := s.RecentDocs(1) recent := s.RecentColdContexts(1)
if len(recent) != 1 { if len(recent) != 1 {
t.Fatalf("expected 1 recent doc, got %d", len(recent)) t.Fatalf("expected 1 recent doc, got %d", len(recent))
} }
@ -165,7 +165,7 @@ func TestReindex(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"}) s.Insert(&ColdDoc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
s.Reindex() s.Reindex()
results := s.Query("重索引", 5) results := s.Query("重索引", 5)
@ -232,7 +232,7 @@ func TestInsertEmptyDoc(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
doc := &Doc{Summary: "", Content: "", Source: "manual"} doc := &ColdDoc{Summary: "", Content: "", Source: "manual"}
if err := s.Insert(doc); err != nil { if err := s.Insert(doc); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -251,7 +251,7 @@ func TestPersistence(t *testing.T) {
// 写 // 写
s1 := NewStore(dir, memory.TokenizeWords) s1 := NewStore(dir, memory.TokenizeWords)
s1.Start() s1.Start()
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"}) s1.Insert(&ColdDoc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
s1.Stop() s1.Stop()
// 读 // 读
@ -304,8 +304,8 @@ func TestRemove(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"}) s.Insert(&ColdDoc{Summary: "会被删除", Content: "a", Source: "manual"})
s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"}) s.Insert(&ColdDoc{Summary: "会保留", Content: "b", Source: "manual"})
// 删除前应该有 2 个 // 删除前应该有 2 个
stats := s.Stats() stats := s.Stats()
@ -350,7 +350,7 @@ func TestRemoveNonexistent(t *testing.T) {
s.Start() s.Start()
defer s.Stop() defer s.Stop()
s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"}) s.Insert(&ColdDoc{Summary: "一个文档", Content: "x", Source: "manual"})
// 删除不存在的 ID 不应 panic // 删除不存在的 ID 不应 panic
s.Remove("nonexistent_id") s.Remove("nonexistent_id")
@ -387,7 +387,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
cleaner := func(name, output string) string { cleaner := func(name, output string) string {
return "天气 温度" return "天气 温度"
} }
doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil) doc, err := s.ContextToColdContext("test", entries, nil, nil, cleaner, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -442,7 +442,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
for i := range stale { for i := range stale {
stale[i] = 0.01 stale[i] = 0.01
} }
if err := s1.Insert(&Doc{ID: "doc_persist", Summary: "迁移", Content: "落盘", if err := s1.Insert(&ColdDoc{ID: "doc_persist", Summary: "迁移", Content: "落盘",
DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil { DenseVec: stale, DenseFP: "space-OLD-999"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -461,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
if loaded == nil { if loaded == nil {
t.Fatal("重启后文档不见了") t.Fatal("重启后文档不见了")
} }
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion { if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+coldcontextVectorAlgoVersion {
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+ t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)", "(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP) sp.fp+"|"+coldcontextVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
} }
// 已对齐 → 一次向量计算都不该发生 // 已对齐 → 一次向量计算都不该发生
sp2 := &fakeSpace{fp: sp.fp, dim: 8} sp2 := &fakeSpace{fp: sp.fp, dim: 8}
@ -495,7 +495,7 @@ func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
for i := range good { for i := range good {
good[i] = 0.5 good[i] = 0.5
} }
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效", doc := &ColdDoc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
Blocks: []memory.MemoryBlock{ Blocks: []memory.MemoryBlock{
{ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略 {ID: "blk_bad", Vector: bad, Fingerprint: sp.fp}, // 指纹对、维度错 → 必须忽略
{ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合 {ID: "blk_good", Vector: good, Fingerprint: sp.fp}, // 指纹与维度都对 → 参与融合
@ -542,7 +542,7 @@ func TestLoadAllLoadsCustomID(t *testing.T) {
if err := s1.Start(); err != nil { if err := s1.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s1.Insert(&Doc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil { if err := s1.Insert(&ColdDoc{ID: "my-notes", Summary: "自定义 ID", Content: "内容"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
s1.Stop() s1.Stop()
@ -564,7 +564,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
if err := s1.Start(); err != nil { if err := s1.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := s1.Insert(&Doc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil { if err := s1.Insert(&ColdDoc{ID: "doc_flush", Summary: "关停落盘", Content: "内容"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// 直接改内存并置脏,模拟「只在内存里发生的变更」 // 直接改内存并置脏,模拟「只在内存里发生的变更」
@ -586,7 +586,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
// TestQueryScoredConcurrentAndDirty 锁死两点: // TestQueryScoredConcurrentAndDirty 锁死两点:
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报); // 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。 // 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdContexts 的冷度判据跨重启失真。
func TestQueryScoredConcurrentAndDirty(t *testing.T) { func TestQueryScoredConcurrentAndDirty(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
s := NewStore(dir, memory.TokenizeWords) s := NewStore(dir, memory.TokenizeWords)
@ -602,7 +602,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
if i < 3 { if i < 3 {
summary, content = "并发检索目标", "并发内容" summary, content = "并发检索目标", "并发内容"
} }
if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil { if err := s.Insert(&ColdDoc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
@ -618,7 +618,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
defer wg.Done() defer wg.Done()
for k := 0; k < 30; k++ { for k := 0; k < 30; k++ {
s.QueryScored("并发检索目标", 5) s.QueryScored("并发检索目标", 5)
s.FindColdDocs(72*time.Hour, 2) s.FindColdContexts(72*time.Hour, 2)
} }
}() }()
} }

View File

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

View File

@ -0,0 +1,318 @@
package coldcontext
// 页结构在**生产数据副本**上的验收(2026-10-29)。
//
// ## 设计前提(用户裁定)
//
// 页**不引入任何新结构**:它就是用现有的「记忆块」(MemoryBlock) 承载 ——
// 一个块带 PageKind/PageSeq/BlockRole/CallID/FrameTS 字段,
// 同一帧同一页的块自然聚成一页。没有 page 表、没有 Page 类型、没有第二份数据。
//
// 这不是省事,而是必要:
// · 块已经是一等图节点(有向量、有边、能召回)
// · 若页另立结构,页与块就要双向同步 —— 又多一处会漂移的真相源
//
// ## 本文件钉什么
//
// ① 生产 L2 文档的块能按 (FrameTS, PageSeq) 分组,组内恰好一对 request/response
// ② 存量文档(旧 schema 写入、无页字段)不被误判、不被拒绝
// ③ 页化的块数与「工具调用次数 + 1」一致(C1)
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// loadProdDocs 从生产 L2 副本目录加载文档(走真实 Store 的读取路径)。
func loadProdDocs(t *testing.T) *Store {
t.Helper()
src := os.Getenv("HA_PROD_DOCS")
if src == "" {
t.Skip("未设置 HA_PROD_DOCS(生产 documents 目录副本),跳过")
}
if _, err := os.Stat(src); err != nil {
t.Skip("生产文档副本不存在,跳过:" + err.Error())
}
s := NewStore(t.TempDir(), nil)
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
loaded := 0
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" {
continue
}
raw, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
continue
}
var d ColdDoc
if err := json.Unmarshal(raw, &d); err != nil {
continue
}
if d.ID == "" {
d.ID = e.Name()[:len(e.Name())-5]
}
s.docs[d.ID] = &d
loaded++
}
t.Logf("载入生产文档 %d 篇", loaded)
if loaded == 0 {
t.Skip("没有可用文档,跳过")
}
return s
}
// ① 生产 L2 的块能按页分组;存量块(无页字段)不参与分组但也不被丢弃。
func TestProdPage_BlocksGroupIntoPages(t *testing.T) {
s := loadProdDocs(t)
var totalBlocks, framedBlocks, legacyBlocks int
var pageGroups int
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 按 (FrameTS, PageSeq) 分组
groups := map[string][]memory.MemoryBlock{}
for _, b := range ev.Blocks {
totalBlocks++
if b.FrameTS.IsZero() {
legacyBlocks++
continue
}
framedBlocks++
key := b.FrameTS.Format("2006-01-02T15:04:05.000000000") + "|" + itoa(b.PageSeq)
groups[key] = append(groups[key], b)
}
pageGroups += len(groups)
}
}
t.Logf("生产 L2:块 %d(有帧 %d / 存量无帧 %d),页组 %d",
totalBlocks, framedBlocks, legacyBlocks, pageGroups)
// 这是存量数据:页字段在旧 schema 里根本不存在,
// 所以这里**不应**期待有页化的块。断言的重点是"不被错误标记"。
if framedBlocks > 0 {
t.Logf("注意:存量数据里出现 %d 个带帧的块(可能已被新版本归档过)", framedBlocks)
}
if totalBlocks == 0 {
t.Fatal("生产 L2 里一个块都没有 —— 读取路径坏了")
}
}
// ② + ③ 对**真实事件**跑页化,验证块数 = 工具调用数 + 1 页(C1)。
//
// 用生产 L2 里的真实事件(含 ToolCalls/ToolResults 或至少 Input/Response)
// 走 docBlocksForEntry,验证页结构与输入一致。
func TestProdPage_SplitMatchesRealEvents(t *testing.T) {
s := loadProdDocs(t)
checked, withTools := 0, 0
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 把 ColdEvent 转成 ContextEntry(生产回填的真实方向)
entry := ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: firstText(ev, memory.RoleRequest),
Response: firstText(ev, memory.RoleResponse),
ToolCalls: ev.ToolCalls,
ToolResults: ev.ToolResults,
Blocks: ev.Blocks,
}
blocks := docBlocksForEntry(entry)
if len(blocks) == 0 {
continue
}
checked++
// 期望页数 = 1 (llm_round) + len(ToolCalls)
wantPages := 1 + len(ev.ToolCalls)
byPage := map[int][]memory.MemoryBlock{}
for _, b := range blocks {
if b.PageKind == "" {
continue // 事件持有的原始块(媒体等),不算页
}
byPage[b.PageSeq] = append(byPage[b.PageSeq], b)
}
if len(byPage) > wantPages {
t.Fatalf("★ 页数超出预期: 得到 %d 页,期望 ≤%d(工具调用 %d 次)",
len(byPage), wantPages, len(ev.ToolCalls))
}
if len(ev.ToolCalls) > 0 {
withTools++
}
// 同一帧的所有页共享 FrameTS(铁律)
var frame = blocks[0].FrameTS
for _, b := range blocks {
if b.FrameTS.IsZero() {
continue
}
if !b.FrameTS.Equal(frame) {
t.Fatalf("★ 同帧块分裂: %v vs %v", b.FrameTS, frame)
}
}
if checked >= 200 {
break
}
}
if checked >= 200 {
break
}
}
t.Logf("检查 %d 条真实事件(含工具调用的 %d 条)", checked, withTools)
if checked == 0 {
t.Skip("没有可检查的事件")
}
}
func firstText(ev ColdEvent, role memory.BlockRole) string {
for _, b := range ev.Blocks {
if b.PageKind == memory.PageLLMRound && b.BlockRole == role {
return b.Text
}
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// 用生产**真实正文**构造带工具调用的事件,验证新归档路径把页结构落在块上。
//
// ## 为什么必须用生产正文
//
// 合成正文("test"/"foo")在分词、向量、去重上都与真实文本行为不同。
// 生产里出现的是长中文段落、代码片段、路径、甚至空 Response ——
// 这些才会触发「块 ID 派生」「空页跳过」「同一帧多页」的边界。
func TestProdPage_RealTextBecomesPages(t *testing.T) {
s := loadProdDocs(t)
// 取真实事件正文(前若干条非空)
var entries []ContextEntry
for _, d := range s.docs {
if len(entries) >= 20 {
break
}
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
body := firstText(ev, memory.RoleRequest)
if body == "" && len(ev.Blocks) > 0 {
body = ev.Blocks[0].Text
}
if len([]rune(body)) < 20 {
continue // 太短的正文不足以验证分词/派生
}
entries = append(entries, ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: body,
Response: firstText(ev, memory.RoleResponse),
// 真实数据没有 ToolCalls,这里补上以验证新路径
ToolCalls: []ToolCallItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run",
Arguments: map[string]interface{}{"command": "df -h " + minRune(body, 12)},
RawArguments: `{"command":"df -h"}`},
},
ToolResults: []ToolResultItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run", Output: "/dev/sda1 62% /"},
},
})
if len(entries) >= 20 {
break
}
}
}
if len(entries) == 0 {
t.Skip("没有足够长的真实正文,跳过")
}
for _, e := range entries {
blocks := docBlocksForEntry(e)
// ① llm_round 页必须有 request(正文非空)
var llmReq, llmResp int
var toolReqs, toolResps int
for _, b := range blocks {
switch {
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleRequest:
llmReq++
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleResponse:
llmResp++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleRequest:
toolReqs++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleResponse:
toolResps++
}
}
if llmReq != 1 {
t.Fatalf("★ 真实正文未生成 llm_round request 块: %+v", blocks)
}
// ② 工具页的 call/result 必须成对
if toolReqs != toolResps {
t.Fatalf("★ 工具页配对不完整(call=%d result=%d)—— 渲染会 400",
toolReqs, toolResps)
}
// ③ 每块都带帧号
for _, b := range blocks {
if b.FrameTS.IsZero() {
t.Fatalf("★ 页块缺 FrameTS: %+v", b)
}
}
}
// ④ 真实正文派生出的块 ID 必须幂等(重复归档不增生)
a := docBlocksForEntry(entries[0])
b := docBlocksForEntry(entries[0])
if len(a) != len(b) {
t.Fatalf("重复派生块数不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].ID != b[i].ID {
t.Fatalf("★ 块 ID 不幂等: %q vs %q", a[i].ID, b[i].ID)
}
}
t.Logf("用 %d 条真实正文验证页化:llm_round + tool_round 结构正确", len(entries))
}
func minRune(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}

View File

@ -1,4 +1,4 @@
package document package coldcontext
// 检索质量判据 —— 这一层是「结构判据」的补位。 // 检索质量判据 —— 这一层是「结构判据」的补位。
// //
@ -133,7 +133,7 @@ func buildStore(t *testing.T, cases []topicCase) *Store {
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, c := range cases { for i, c := range cases {
_, err := s.ContextToDoc("context_archived", []ContextEntry{{ _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour), Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "test", Source: "test",
Content: "用户提问:" + c.query, Content: "用户提问:" + c.query,
@ -238,7 +238,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
s.SetDenseSpace(ds) s.SetDenseSpace(ds)
const sentinel = "量子纠缠态退相干时间测量" const sentinel = "量子纠缠态退相干时间测量"
_, err := s.ContextToDoc("context_archived", []ContextEntry{{ _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now(), Timestamp: time.Now(),
Source: "test", Source: "test",
Content: "用户问了个问题", Content: "用户问了个问题",
@ -248,7 +248,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容") // 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容")
_, _ = s.ContextToDoc("context_archived", []ContextEntry{{ _, _ = s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Minute), Timestamp: time.Now().Add(time.Minute),
Source: "test", Source: "test",
Content: "另一条用户消息", Content: "另一条用户消息",
@ -310,7 +310,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
} }
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC) base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, b := range bodies { for i, b := range bodies {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour), Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致 Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
Content: "用户提问", Content: "用户提问",
@ -321,7 +321,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
} }
s.BuildDenseIndex(ds) s.BuildDenseIndex(ds)
docs := s.RecentDocs(10) docs := s.RecentColdContexts(10)
if len(docs) != 4 { if len(docs) != 4 {
t.Fatalf("文档数 = %d,期望 4", len(docs)) t.Fatalf("文档数 = %d,期望 4", len(docs))
} }
@ -401,7 +401,7 @@ func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀) // 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
legacyVec := make([]float64, ds.Dim()) legacyVec := make([]float64, ds.Dim())
legacyVec[0] = 1 legacyVec[0] = 1
if err := s.Insert(&Doc{ if err := s.Insert(&ColdDoc{
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量", ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本 DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
}); err != nil { }); err != nil {

View File

@ -316,29 +316,7 @@ func (g *GraphDB) initSchema() error {
FOREIGN KEY (target_id) REFERENCES entities(id), FOREIGN KEY (target_id) REFERENCES entities(id),
UNIQUE(source_id, target_id, relation_type, session_id) UNIQUE(source_id, target_id, relation_type, session_id)
)`, )`,
`CREATE TABLE IF NOT EXISTS memory_blocks ( ddlMemoryBlocks,
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
data BLOB,
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- semantic_type 是实体的语义类别(Person / Animal / Concept),
-- 对应旧 entities.type。2026-10-04 加入:social 层靠它
-- 区分人物与特质,检索层靠它区分「谁」与「什么」。
semantic_type TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS memory_block_edges ( `CREATE TABLE IF NOT EXISTS memory_block_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
source_kind TEXT NOT NULL, source_kind TEXT NOT NULL,
@ -487,6 +465,24 @@ func (g *GraphDB) initSchema() error {
{"memory_block_edges", "confidence", "REAL DEFAULT 0"}, {"memory_block_edges", "confidence", "REAL DEFAULT 0"},
{"memory_block_edges", "session_id", "TEXT DEFAULT ''"}, {"memory_block_edges", "session_id", "TEXT DEFAULT ''"},
{"memory_block_edges", "turn_id", "INTEGER DEFAULT 0"}, {"memory_block_edges", "turn_id", "INTEGER DEFAULT 0"},
// 段页式字段(2026-10-29):存量库补列,幂等。
// 不回填 frame_ts —— 存量块没有真实帧信息,留 NULL 表示"未分帧",
// 由 migration 命令按需推断,而不是在这里瞎猜一个时间戳。
{"memory_blocks", "frame_ts", "TIMESTAMP"},
{"memory_blocks", "page_seq", "INTEGER DEFAULT 0"},
{"memory_blocks", "page_kind", "TEXT DEFAULT ''"},
{"memory_blocks", "block_role", "TEXT DEFAULT ''"},
{"memory_blocks", "call_id", "TEXT DEFAULT ''"},
{"memory_blocks", "inactive", "INTEGER DEFAULT 0"},
// 回收判定用(P6,2026-10-29):最近一次被召回的时间。
//
// ★ 用「被召回」而不是「被创建」判死活:判断一块记忆是否有用,
// 看的是它有没有被想起来,不是它什么时候写进来的。
// 旧块若一直被召回,说明它是活的(不能因为"老"就回收)。
//
// 留 NULL 表示"从未被召回"—— 与"被召回过但很久以前"是两回事:
// 前者是候选回收对象,后者要看时间窗。用零值时间会混淆两者。
{"memory_blocks", "last_recalled_at", "TIMESTAMP"},
{"memory_block_edges", "status", "TEXT DEFAULT ''"}, {"memory_block_edges", "status", "TEXT DEFAULT ''"},
{"memory_block_edges", "merged_into", "TEXT DEFAULT ''"}, {"memory_block_edges", "merged_into", "TEXT DEFAULT ''"},
} { } {
@ -1092,9 +1088,6 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
// ══════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════
// entityIDs 的键从 int64 变成**块 ID 字符串**。 // entityIDs 的键从 int64 变成**块 ID 字符串**。
var bScratch MemoryBlock
var vecJSON string
var sceneNull sql.NullString
entityIDs := make(map[string]bool) entityIDs := make(map[string]bool)
// entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。 // entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。
@ -1134,18 +1127,22 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
} }
for rows.Next() { for rows.Next() {
var rank int // ★★ 不再手写 Scan(2026-10-29 第二次踩同一个坑)。
// rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。 //
if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality, // 这段代码的上一版注释就写着「这类错只在真跑 SQL 时暴露,编译期
&bScratch.Text, &bScratch.MIME, // 完全看不出来」—— 然后加段页式字段(frame_ts 等 6 列)时,
&bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON, // blockColumns 多了 6 列而这里的手写 Scan 没跟上,于是又一次
&bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool, // 「expected 22 destination arguments in Scan, not 16」。
&sceneNull, &bScratch.SemanticType, //
&bScratch.CreatedAt, &bScratch.UpdatedAt); err != nil { // 现在改用 rankOf + scanBlockRow:
// ① 多出来的 rank 列由 rankOf 单独读,
// ② 块列全部交给 scanBlockRow,它和 blockColumns 是同源维护的。
// 这样下次加列只需改 blockColumns + scanBlockRow 两处(它们本就绑在一起)。
rank, b, err := scanBlockRowWithRank(rows)
if err != nil {
rows.Close() rows.Close()
return nil, err return nil, err
} }
b := bScratch
seq++ seq++
e := Entity{ e := Entity{
ID: int64(seq), ID: int64(seq),
@ -1674,23 +1671,20 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
defer brows.Close() defer brows.Close()
var blocks []MemoryBlock var blocks []MemoryBlock
for brows.Next() { for brows.Next() {
var block MemoryBlock // ★ 改为复用 scanBlockRow(2026-10-29)。
var vectorJSON string //
// ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了, // 这条 Scan 历史上**第三次**栽在同一件事上:SELECT 用 blockColumns
// Scan 却没跟上)—— 与 semantic_type 是同一类错, // 改了,手写 Scan 却没跟上(注释原话「历史上就少扫了 scene」,
// 报错形如「expected 16 destination arguments in Scan, not 14」。 // 报错形如 expected 16 destination arguments in Scan, not 14)。
if err := brows.Scan(&block.ID, &block.Modality, &block.Text, // 这次加段页式 6 列又复现了一次。
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON, //
&block.Fingerprint, &block.Source, &block.Tool, &block.Scene, // 根因不是粗心,而是**两份平行清单**:列清单在一处、Scan 目标在另一处,
&block.SemanticType, &block.CreatedAt, // 加列必然漏改其中一处,且只在真跑 SQL 时暴露。
&block.UpdatedAt); err != nil { // 现在只保留 blockColumns + scanBlockRow 这一对,不再手写。
block, err := scanBlockRow(brows)
if err != nil {
return nil, err return nil, err
} }
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &block.Vector); err != nil {
return nil, fmt.Errorf("decode memory block %s vector: %w", block.ID, err)
}
}
blocks = append(blocks, block) blocks = append(blocks, block)
} }
if err := brows.Err(); err != nil { if err := brows.Err(); err != nil {
@ -2526,3 +2520,95 @@ func bestSpecificRank(rank int, name string, keywords []string) int {
} }
return best return best
} }
// scanBlockRowWithRank 读「首列 int + 其余同 blockColumns」的行。
//
// 用于那些在块列**前面**多带一列的查询(如关键词精确度的 0/1/2 排序键)。
//
// 存在的唯一目的:让这类查询**不必手写 20+ 个 Scan 目标**。
// 手写清单与 blockColumns 是两份平行事实来源,加列时必漏一处 ——
// 本仓为此在同一处栽过两次(见调用点注释)。
func scanBlockRowWithRank(rows *sql.Rows) (int, MemoryBlock, error) {
var b MemoryBlock
var rank int
var vectorJSON string
var scene sql.NullString
var frameTS sql.NullTime
var pageSeq, inactive sql.NullInt64
var pageKind, blockRole, callID sql.NullString
var lastRecalled sql.NullTime
if err := rows.Scan(&rank, &b.ID, &b.Modality, &b.Text,
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
&b.Fingerprint, &b.Source, &b.Tool, &scene, &b.SemanticType,
&b.CreatedAt, &b.UpdatedAt,
&frameTS, &pageSeq, &pageKind, &blockRole, &callID, &inactive,
&lastRecalled); err != nil {
return rank, b, err
}
if lastRecalled.Valid {
b.LastRecalledAt = lastRecalled.Time
}
b.Scene = scene.String
if frameTS.Valid {
b.FrameTS = frameTS.Time
}
b.PageSeq = int(pageSeq.Int64)
b.PageKind = PageKind(pageKind.String)
b.BlockRole = BlockRole(blockRole.String)
b.CallID = callID.String
b.Inactive = inactive.Int64 != 0
if vectorJSON != "" && vectorJSON != "null" {
if err := json.Unmarshal([]byte(vectorJSON), &b.Vector); err != nil {
return rank, b, fmt.Errorf("decode memory block %s vector: %w", b.ID, err)
}
}
return rank, b, nil
}
// ddlMemoryBlocks 是 memory_blocks 表的**唯一 DDL 定义**。
//
// 抽成常量的理由(2026-10-29):此前测试侧有一份手抄副本
// (migrate_testhelp_test.go 的 ddlMemoryBlocks),注释里明写
// 「它是重复的真相源 —— 加列时必须两边都改」,并记录了两次因不同步
// 而失败的现场(加 semantic_type 时「no such column」、加段页式 6 列时
// 「no such column: frame_ts」)。
//
// 现在生产 DDL 与测试重建都用这一份,加列只需改这里。
const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
-- data 是媒体块的负载(union 的另一半):文本块用 text_content,
-- 媒体块用 data。字节随块同生同灭,块外不存在独立的字节存储层。
data BLOB,
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- semantic_type 是实体的语义类别(Person / Animal / Concept),
-- 对应旧 entities.type。2026-10-04 加入:social 层靠它
-- 区分人物与特质,检索层靠它区分「谁」与「什么」。
semantic_type TEXT DEFAULT '',
-- ─── 段页式身份(2026-10-29)───
-- frame_ts 是「帧号」,直接用时间戳(用户裁定:不另造自增编号)。
-- 同一 frame_ts 的块必须整批待在同一层(不跨帧),见 MemoryBlock 的说明。
frame_ts TIMESTAMP,
-- page_seq 是帧内页序号(1..n);与 frame_ts 一起构成页身份。
page_seq INTEGER DEFAULT 0,
-- page_kind: llm_round | tool_round
page_kind TEXT DEFAULT '',
-- block_role: request | response
block_role TEXT DEFAULT '',
-- call_id 是 tool_round 的配对键(tool_call ↔ tool_result)
call_id TEXT DEFAULT '',
-- inactive=1 表示该页不参与渲染(但仍可回读、仍在原层)
inactive INTEGER DEFAULT 0,
last_recalled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`

View File

@ -321,7 +321,7 @@ func (idx *Indexer) BuildToolPrompt() string {
You have these tools for the long-term stable memory: You have these tools for the long-term stable memory:
### memory_recall ### stablemem_recall
Retrieve entities and relations related to keywords. Retrieve entities and relations related to keywords.
Parameters: Parameters:
- query_intent: keywords, comma separated - query_intent: keywords, comma separated
@ -330,7 +330,7 @@ Parameters:
Use recent when asked "latest / current value" - the answer is usually the newest value Use recent when asked "latest / current value" - the answer is usually the newest value
superseding an older one. superseding an older one.
### memory_commit ### stablemem_commit
Write triples into stable memory. Write triples into stable memory.
Parameters: Parameters:
- triples: [{"subject": "entity", "relation": "type", "object": "target entity", - triples: [{"subject": "entity", "relation": "type", "object": "target entity",
@ -343,10 +343,10 @@ Parameters:
e.g. the QQ reply format -> scene="chan:qq". e.g. the QQ reply format -> scene="chan:qq".
- scene: default scene key for this batch (optional; per-triple scene wins). - scene: default scene key for this batch (optional; per-triple scene wins).
### memory_introspect ### stablemem_introspect
Show memory statistics. Show memory statistics.
### memory_purge ### stablemem_purge
Delete or fix memories. Delete or fix memories.
Parameters: Parameters:
- criteria: {"subject_contains": "...", "relation_type": "..."} - criteria: {"subject_contains": "...", "relation_type": "..."}
@ -547,7 +547,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{ {
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": "memory_recall", "name": "stablemem_recall",
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" + "description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" +
"问『某个具体东西是什么/是多少』用默认相关性排序;" + "问『某个具体东西是什么/是多少』用默认相关性排序;" +
"问『最近/最新/现在是什么』必须传 sort=recent —— " + "问『最近/最新/现在是什么』必须传 sort=recent —— " +
@ -578,7 +578,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{ {
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": "memory_commit", "name": "stablemem_commit",
"description": "写入图记忆。将三元组列表写入长期记忆。", "description": "写入图记忆。将三元组列表写入长期记忆。",
"parameters": map[string]interface{}{ "parameters": map[string]interface{}{
"type": "object", "type": "object",
@ -617,7 +617,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
{ {
"type": "function", "type": "function",
"function": map[string]interface{}{ "function": map[string]interface{}{
"name": "memory_introspect", "name": "stablemem_introspect",
"description": "查看图记忆统计信息:实体数量、关系数量、热点实体。", "description": "查看图记忆统计信息:实体数量、关系数量、热点实体。",
"parameters": map[string]interface{}{ "parameters": map[string]interface{}{
"type": "object", "type": "object",

View File

@ -264,8 +264,8 @@ func TestGetToolDefinitions(t *testing.T) {
func TestBuildToolPrompt(t *testing.T) { func TestBuildToolPrompt(t *testing.T) {
idx := NewIndexer(nil) idx := NewIndexer(nil)
prompt := idx.BuildToolPrompt() prompt := idx.BuildToolPrompt()
if !contains(prompt, "memory_recall") { if !contains(prompt, "stablemem_recall") {
t.Errorf("expected prompt to mention memory_recall, got %q", prompt) t.Errorf("expected prompt to mention stablemem_recall, got %q", prompt)
} }
} }

View File

@ -2,34 +2,13 @@ package memory
// 测试脚手架:重建被测试删掉的 memory_blocks 表。 // 测试脚手架:重建被测试删掉的 memory_blocks 表。
// 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。 // 刻意放在 _test.go 里 —— 它不是生产符号,只是让回滚测试能自造失败。
// ddlMemoryBlocks 是 graph.go 里同一段 DDL 的副本,仅供测试重建被删的表。
// //
// ★★ 它是**重复的真相源** —— 加列时必须两边都改。 // ★ 2026-10-29:这里的 `ddlMemoryBlocks` 副本已**删除**,改用生产同名常量。
// //
// 2026-10-04 加 semantic_type 时就漏了这里,报 // 原副本的注释自己就写着「它是重复的真相源 —— 加列时必须两边都改」,
// 「no such column: semantic_type」。抽公共常量是正解, // 并记录了两次因不同步而失败的现场:
// 但 graph.go 的 DDL 是一整段 schema 初始化(多张表), // · 2026-10-04 加 semantic_type → "no such column: semantic_type"
// 拆出来会改动面更大 —— 暂时保留副本,改列时记得同步。 // · 2026-10-29 加段页式 6 列 → "no such column: frame_ts"
// //
// 刻意不导出成生产符号:它是测试脚手架,不是 API。 // 一份「靠人记得同步」的副本,在第一次忘记时就已经证明了它不是好设计。
const ddlMemoryBlocks = `CREATE TABLE IF NOT EXISTS memory_blocks ( // 现在 DDL 只有一处定义(graph.go 的 ddlMemoryBlocks),生产与测试共用。
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT DEFAULT '',
payload_digest TEXT DEFAULT '',
mime TEXT DEFAULT '',
size INTEGER DEFAULT 0,
width INTEGER DEFAULT 0,
height INTEGER DEFAULT 0,
vector TEXT DEFAULT '',
fingerprint TEXT DEFAULT '',
source TEXT DEFAULT '',
tool TEXT DEFAULT '',
scene TEXT DEFAULT '',
-- ★ semantic_type 必须与 graph.go 的 DDL 同步(2026-10-04 加列时漏过一次,
-- 报 'no such column: semantic_type')。改那边记得改这里。
semantic_type TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`

View File

@ -19,7 +19,7 @@ import (
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。 // 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
// //
// 这里把判定收敛到一处,供三个地方共用: // 这里把判定收敛到一处,供三个地方共用:
// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏) // - 自动填充路:coldDocToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾) // - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾)
// ────────────────────────────────────────────── // ──────────────────────────────────────────────

View File

@ -0,0 +1,118 @@
package memory
// P1 段页式字段的判据(C5:迁移后 ID / 帧号 / 指纹逐字节相同)。
//
// ## 为什么必须有这条
//
// 段页式身份(FrameTS / PageSeq / PageKind / BlockRole / CallID / Inactive)
// 是「跨层不变」的前提。一旦某个写入路径把字段抹掉,块就再也归不回原来的页,
// 渲染时会破配对 —— 而这类错**不报错**,只是行为悄悄变了。
//
// 本文件钉四件事:
// ① 六个字段能写进去、能读回来(往返不丢)
// ② 轻量读取路径(不带这些字段的块)写回时**不覆盖**已有页身份
// ③ inactive 是标注,能被显式改回 false(与"空值不覆盖"不同)
// ④ 零值 FrameTS 存成 NULL,不写成 0001-01-01
import (
"testing"
"time"
)
// ① 六个字段往返。
func TestBlockPageFieldsRoundTrip(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 15, 4, 5, 123456789, time.UTC)
b := MemoryBlock{
ID: "blk_txt_roundtrip", Modality: BlockText, Text: "一段正文",
FrameTS: ts, PageSeq: 3, PageKind: PageToolRound, BlockRole: RoleResponse,
CallID: "call_abc", Inactive: true,
Vector: []float64{0.1, 0.2}, Fingerprint: "fp-1",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, ok, err := g.BlockByIDPrefix("blk_txt_roundtrip")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if !got.FrameTS.Equal(ts) {
t.Errorf("FrameTS 往返不一致: 写 %v 读 %v", ts, got.FrameTS)
}
if got.PageSeq != 3 || got.PageKind != PageToolRound ||
got.BlockRole != RoleResponse || got.CallID != "call_abc" || !got.Inactive {
t.Errorf("页字段往返不一致: %+v", got)
}
if got.Fingerprint != "fp-1" || len(got.Vector) != 2 {
t.Errorf("指纹/向量往返不一致: fp=%q vec=%v", got.Fingerprint, got.Vector)
}
}
// ② 轻量写回不覆盖页身份。
//
// 场景:BlockByIDPrefix 这类 lean 读取拿到的块不带页字段,
// 某些路径会把它重新 Put 回去(如挂场景)。若直接覆盖,
// 页身份被抹 → 块归不回页 → 渲染破配对。
func TestPageFieldsNotOverwrittenByLeanWrite(t *testing.T) {
g := newTestGraph(t)
ts := time.Date(2026, 10, 29, 16, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_keep", Modality: BlockText, Text: "正文",
FrameTS: ts, PageSeq: 2, PageKind: PageLLMRound, BlockRole: RoleRequest,
CallID: "call_keep",
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 模拟 lean 写回:只有 ID + 文本,页字段全零
lean := MemoryBlock{ID: "blk_txt_keep", Modality: BlockText, Text: "正文"}
if err := g.PutMemoryBlocks([]MemoryBlock{lean}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_keep")
if !got.FrameTS.Equal(ts) || got.PageSeq != 2 || got.CallID != "call_keep" {
t.Fatalf("★ 轻量写回抹掉了页身份: FrameTS=%v PageSeq=%d CallID=%q",
got.FrameTS, got.PageSeq, got.CallID)
}
}
// ③ inactive 必须能被显式改回 false("读回后恢复渲染")。
func TestInactiveCanBeCleared(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{ID: "blk_txt_inact", Modality: BlockText, Text: "x", Inactive: true}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
b.Inactive = false
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_inact")
if got.Inactive {
t.Fatal("★ inactive 无法改回 false —— 「剔除后可恢复渲染」就失效了")
}
}
// ④ 零值 FrameTS 存 NULL,不能写成 0001-01-01。
//
// 若写成 0001-01-01,迁移脚本会把它当成一个真实的极早帧,
// 于是所有存量块被错误归入同一页。
func TestZeroFrameTSStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_noft", Modality: BlockText, Text: "旧数据"},
}); err != nil {
t.Fatal(err)
}
var nullCount int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_noft' AND frame_ts IS NULL`).
Scan(&nullCount); err != nil {
t.Fatal(err)
}
if nullCount != 1 {
t.Fatal("★ 零值 FrameTS 应存成 NULL,否则存量块会被错误聚成同一帧")
}
}

View File

@ -0,0 +1,250 @@
package memory
// 段页式在**生产数据副本**上的 e2e 验收(2026-10-29)。
//
// ## 为什么必须有这一组
//
// 合成判据的库是「干净的新库」,而生产库是「跑了几个月的存量数据」:
//
// · 7013 个块、6305 条边,全部由旧 schema 写入
// · 新加 7 个列之前,它们只有 17 列
// · L1 的 29 条事件里 **0 条有 tool_calls、0 条有 call_id**
//
// 这正是 P1/P6 最容易出事的地方:迁移必须给存量行补列,
// 且**不能改动任何既有值**(ID/向量/指纹逐字节相同)。
// 合成数据永远测不出「ALTER TABLE 之后老数据还在不在」。
//
// ## 取快照(不要直接指生产库,判据会写)
//
// sqlite3 "file:$HOME/../memory/graph.db?mode=ro" ".backup /tmp/graph-copy.db"
// HA_PROD_DB=/tmp/graph-copy.db go test -count=1 -run ProdSeg ./internal/memory/
//
// 没有副本时自动跳过(CI 不该依赖本机路径)。
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"testing"
)
// vecDigestForTest 把向量压成定长摘要,用于「逐字节相同」的比较。
//
// 为什么不直接比 []float64:浮点切片不能直接 `==` 做全等判断,
// 而「逐字节不变」正是本判据要钉的东西 —— 用摘要比更严格也更好读。
func vecDigestForTest(v []float64) string {
if len(v) == 0 {
return ""
}
h := sha256.New()
for _, x := range v {
fmt.Fprintf(h, "%x;", math.Float64bits(x))
}
return hex.EncodeToString(h.Sum(nil))
}
// vecDigestForTestRaw 把基准侧的原始 JSON 解成向量后,用**同一个**摘要算法。
//
// ★ 关键:两侧必须用同一算法,否则比较毫无意义。
//
// 第一版这里是「对原始文本做 sha256」,而比较侧是「对浮点位模式做 sha256」——
// 两个算法必然不等,于是在生产副本上误报 1877 个块"被重建"
// (正好是"有向量的块数",因为空向量两侧都返回空串、恰好相等)。
// 这是判据自身的假阳性,不是迁移的缺陷。
func vecDigestForTestRaw(raw string) string {
if raw == "" || raw == "null" {
return ""
}
var v []float64
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return "unparsable"
}
return vecDigestForTest(v)
}
// openProdCopy 把生产库复制到临时目录并打开(判据会写,绝不能动原件)。
func openProdCopy(t *testing.T) *GraphDB {
t.Helper()
src := prodCopySrc()
raw, err := os.ReadFile(src)
if err != nil {
t.Skip("无生产库副本,跳过:" + err.Error())
}
p := filepath.Join(t.TempDir(), "g.db")
if err := os.WriteFile(p, raw, 0o644); err != nil {
t.Fatal(err)
}
g, err := NewGraphDB(p)
if err != nil {
t.Fatalf("打开生产副本失败(迁移可能崩了): %v", err)
}
t.Cleanup(func() { g.Close() })
return g
}
// C5:迁移后每一行的 **ID / 向量 / 指纹逐字节不变**。
//
// 这是「不静默重建」的判据:如果迁移顺手重算了向量(或改了 ID),
// 那么所有块与新查询就落到不同空间里 —— 召回会全面失准,而且**不报错**。
func TestProdSeg_MigrationPreservesIdentity(t *testing.T) {
src := prodCopySrc()
if _, err := os.Stat(src); err != nil {
t.Skip("无生产库副本,跳过")
}
// 先从**未迁移的原始副本**读出基线(用只读 SQL 直接读,不经过 NewGraphDB)。
baseline := readRawBlockIdentity(t, src)
if len(baseline) < 100 {
t.Skipf("原始副本只读到 %d 块,不足以验证", len(baseline))
}
// 迁移(NewGraphDB 内部跑 ALTER TABLE)
g := openProdCopy(t)
same, changed, missing := 0, 0, 0
var changedSample string
for id, want := range baseline {
got, ok, err := g.BlockByIDPrefix(id)
if err != nil {
t.Fatalf("读块 %s 失败: %v", id, err)
}
if !ok {
missing++
continue
}
if got.ID == id && got.Fingerprint == want.fp &&
vecDigestForTest(got.Vector) == want.vecDigest {
same++
} else {
changed++
if changedSample == "" {
changedSample = id
}
}
}
t.Logf("生产副本 %d 块:不变 %d,变化 %d,缺失 %d", len(baseline), same, changed, missing)
if missing > 0 {
t.Fatalf("★ 迁移后丢了 %d 个块(数据丢失)", missing)
}
if changed > 0 {
t.Fatalf("★ 迁移改动了 %d 个块的 ID/向量/指纹(静默重建,首例 %s)",
changed, changedSample)
}
}
// 新列在生产副本上可用,且存量行的语义正确(frame_ts 为 NULL = 未分帧)。
func TestProdSeg_NewColumnsOnLegacyRows(t *testing.T) {
g := openProdCopy(t)
var total, nullFrame, nullRecalled int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks`).Scan(&total); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE frame_ts IS NULL`).Scan(&nullFrame); err != nil {
t.Fatal(err)
}
if err := g.db.QueryRow(`SELECT COUNT(*) FROM memory_blocks WHERE last_recalled_at IS NULL`).Scan(&nullRecalled); err != nil {
t.Fatal(err)
}
t.Logf("生产副本:%d 块,frame_ts NULL=%d,last_recalled_at NULL=%d",
total, nullFrame, nullRecalled)
if total != nullFrame {
t.Errorf("★ 存量行不应有 frame_ts(旧数据没有帧信息,必须留 NULL 表示标记未分帧的状态): "+
"%d/%d 行带了 frame_ts", total-nullFrame, total)
}
// last_recalled_at 允许非空(已在跑的新版本可能记过),但不应全部非空。
if nullRecalled == 0 {
t.Errorf("★ 所有行都有 last_recalled_at —— 存量行不可能都被召回过,迁移写错了")
}
}
// 存量块能被正常召回(迁移没有破坏检索能力)。
func TestProdSeg_RecallStillWorksOnLegacyData(t *testing.T) {
g := openProdCopy(t)
// 取一个有向量的块,用它的向量查自己(必然命中,除非检索坏了)
var id, vectorJSON, fp string
if err := g.db.QueryRow(`SELECT id, vector, fingerprint FROM memory_blocks
WHERE modality='text' AND vector IS NOT NULL AND vector!='' AND vector!='null'
LIMIT 1`).Scan(&id, &vectorJSON, &fp); err != nil {
t.Skip("没有带向量的文本块,跳过")
}
var vec []float64
if err := json.Unmarshal([]byte(vectorJSON), &vec); err != nil {
t.Fatalf("解析向量失败: %v", err)
}
if len(vec) == 0 {
t.Skip("向量为空,跳过")
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: vec, TopK: 10, Fingerprint: fp,
})
if err != nil {
t.Fatalf("召回失败: %v", err)
}
if len(hits) == 0 {
t.Fatal("★ 用块自己的向量都召回不到东西 —— 迁移破坏了检索")
}
// 命中自己
found := false
for _, h := range hits {
if h.Block.ID == id {
found = true
break
}
}
if !found {
t.Logf("自查询未命中自身(前 10 名被高权重块占据),属正常,hits=%d", len(hits))
}
// P6:召回必须刷新 last_recalled_at
got, ok, err := g.BlockByIDPrefix(hits[0].Block.ID)
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 生产副本上召回仍未记录 last_recalled_at")
}
}
// ── 基线读取(只读,不触发迁移)──
type blockIdentity struct {
fp string
vecDigest string
}
func readRawBlockIdentity(t *testing.T, path string) map[string]blockIdentity {
t.Helper()
// 用只读模式打开,避免触发 ALTER TABLE。
// 只读打开:不触发 ALTER TABLE(NewGraphDB 才会跑迁移)。
db, err := sql.Open("sqlite3", "file:"+path+"?mode=ro")
if err != nil {
t.Skipf("只读打开失败(跳过基线对比): %v", err)
}
defer db.Close()
rows, err := db.Query(`SELECT id, fingerprint, vector FROM memory_blocks`)
if err != nil {
t.Skipf("读基线失败(可能是旧库 schema 差异): %v", err)
}
defer rows.Close()
out := map[string]blockIdentity{}
for rows.Next() {
var id, fp, vectorJSON string
if err := rows.Scan(&id, &fp, &vectorJSON); err != nil {
t.Fatal(err)
}
out[id] = blockIdentity{fp: fp, vecDigest: vecDigestForTestRaw(vectorJSON)}
}
return out
}

View File

@ -0,0 +1,154 @@
package memory
// stablemem 回收的 e2e 判据(P6,2026-10-29)。
//
// ## 钉住的四件事
//
// ① last_recalled_at 在召回时被刷新(否则回收等于按入库时间瞎删)
// ② 只前进不后退(轻量写回不能把"用过"抹成"从没用过")
// ③ 零值存 NULL("从未召回" ≠ "很久以前召回过")
// ④ 删块必须连带删边(memory_block_edges 无外键,不清理就留悬挂边)
import (
"testing"
"time"
)
// ① 召回后 last_recalled_at 被写回。
func TestRecallMarksLastRecalledAt(t *testing.T) {
g := newTestGraph(t)
b := MemoryBlock{
ID: "blk_txt_recal1", Modality: BlockText, Text: "关于磁盘清理的办法",
Vector: []float64{1, 0, 0}, Fingerprint: "fp-r",
}
if err := g.PutMemoryBlocks([]MemoryBlock{b}); err != nil {
t.Fatal(err)
}
hits, err := g.RecallBlocks(BlockRecallQuery{
Vector: []float64{1, 0, 0}, TopK: 5, Fingerprint: "fp-r",
})
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Fatal("未召回")
}
// markRecalled 是 defer 调用的,这里等一下再读(它同步执行,defer 在返回前)
got, ok, err := g.BlockByIDPrefix("blk_txt_recal1")
if err != nil || !ok {
t.Fatalf("读回失败: %v ok=%v", err, ok)
}
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 召回未记录 last_recalled_at —— 回收会按入库时间瞎删")
}
}
// ② 只前进不后退:轻量写回(零值)不能抹掉已记录的召回时间。
func TestLastRecalledAtOnlyMovesForward(t *testing.T) {
g := newTestGraph(t)
past := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
full := MemoryBlock{
ID: "blk_txt_recal2", Modality: BlockText, Text: "正文",
LastRecalledAt: past,
}
if err := g.PutMemoryBlocks([]MemoryBlock{full}); err != nil {
t.Fatal(err)
}
// 轻量写回:不带 last_recalled_at
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文"},
}); err != nil {
t.Fatal(err)
}
got, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if got.LastRecalledAt.IsZero() {
t.Fatalf("★ 轻量写回把 last_recalled_at 抹成零值 —— 「活块」会被误判为死块删掉")
}
if !got.LastRecalledAt.Equal(past) {
t.Fatalf("时间被改写: %v(期望 %v)", got.LastRecalledAt, past)
}
// 更新的时间要能推进
newer := past.Add(72 * time.Hour)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_recal2", Modality: BlockText, Text: "正文", LastRecalledAt: newer},
}); err != nil {
t.Fatal(err)
}
got2, _, _ := g.BlockByIDPrefix("blk_txt_recal2")
if !got2.LastRecalledAt.Equal(newer) {
t.Fatalf("更新的召回时间未生效: %v(期望 %v)", got2.LastRecalledAt, newer)
}
}
// ③ 从未召回存 NULL(区别于"很久以前召回过")。
func TestNeverRecalledStoredAsNull(t *testing.T) {
g := newTestGraph(t)
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_never", Modality: BlockText, Text: "从未被召回"},
}); err != nil {
t.Fatal(err)
}
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_blocks WHERE id='blk_txt_never' AND last_recalled_at IS NULL`).
Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("★ 从未召回应存 NULL(零值时间会与'很久以前召回过'混淆)")
}
}
// ④ 删块必须连带删边(否则留悬挂边)。
//
// memory_block_edges **没有外键约束**(边是独立单位,见 DDL 注释),
// 所以不能指望 DB 级联 —— 清理必须在删除代码里显式做。
//
// 走真实路径 DeleteEntity:它按 text_content 精确匹配找出块,
// 然后①删关联边 ②删块 ③同步删旧表。
func TestDeleteBlockRemovesEdges(t *testing.T) {
g := newTestGraph(t)
const body = "这条正文会被当作实体名精确匹配"
// 边的两端都必须是真实存在的块(AddMemoryBlockEdge 拒绝悬空端点,
// 这条校验本身是对的 —— 它保证"边指向的东西确实存在")。
if err := g.PutMemoryBlocks([]MemoryBlock{
{ID: "blk_txt_orphan", Modality: BlockText, Text: body},
{ID: "blk_other", Modality: BlockText, Text: "对端块"},
}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("block", "blk_txt_orphan", "block", "blk_other", "contains"); err != nil {
t.Fatal(err)
}
before := countBlockEdges(t, g, "blk_txt_orphan")
if before == 0 {
t.Fatal("前置条件失败:边未建立")
}
if _, err := g.DeleteEntity(body); err != nil {
t.Fatal(err)
}
after := countBlockEdges(t, g, "blk_txt_orphan")
if after != 0 {
t.Fatalf("★ 删除块后残留 %d 条悬挂边(无外键,必须显式清理)", after)
}
}
// countBlockEdges 数某个块作为端点的边。
func countBlockEdges(t *testing.T, g *GraphDB, blockID string) int {
t.Helper()
var n int
if err := g.db.QueryRow(
`SELECT COUNT(*) FROM memory_block_edges
WHERE (source_kind='block' AND source_id=?) OR (target_kind='block' AND target_id=?)`,
blockID, blockID).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}

View File

@ -124,6 +124,11 @@ var methodCapability = map[string]Capability{
MethodSecretsFill: CapCore, MethodSecretsFill: CapCore,
MethodSecretsEncrypt: CapCore, MethodSecretsEncrypt: CapCore,
MethodSecretsDecrypt: CapCore, MethodSecretsDecrypt: CapCore,
// 策略声明是 CapCore:任何插件都只能声明**自己名下**的档位
//(Owner 由内核填),所以它不构成越权面;限制在"声明别人的档位"
// 那一层由 internal/sdk/security_impl.go 拒绝。
MethodSecurityDeclareProfile: CapCore,
MethodSecurityDeclareProfiles: CapCore,
// 生命周期自述(插件声明自己是否可自动重启) // 生命周期自述(插件声明自己是否可自动重启)
MethodLifecycleAutoRestart: CapCore, MethodLifecycleAutoRestart: CapCore,
// 多模态内容块注入是工具返回值的一部分,不越权 // 多模态内容块注入是工具返回值的一部分,不越权

View File

@ -98,6 +98,11 @@ type CoreSDK interface {
Settings() pubsdk.SettingsAPI Settings() pubsdk.SettingsAPI
// Secrets 是**全进程共享**的变量库(非 per-plugin)。 // Secrets 是**全进程共享**的变量库(非 per-plugin)。
Secrets() pubsdk.SecretsAPI Secrets() pubsdk.SecretsAPI
// Security 是**策略声明面**(插件声明命名档位;判定由内核执行)。
//
// ★ 只声明、不判定:本接口刻意没有 CheckTool 之类的方法 ——
// 那属于内核。插件自判会让策略分散、且插件崩溃即策略失效。
Security() pubsdk.SecurityAPI
Memory() pubsdk.MemoryAPI Memory() pubsdk.MemoryAPI
TextMemory() pubsdk.TextMemoryAPI TextMemory() pubsdk.TextMemoryAPI
DocMemory() pubsdk.DocMemoryAPI DocMemory() pubsdk.DocMemoryAPI
@ -187,6 +192,9 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
MethodSettingsDataDir: MethodSettingsDataDir:
return h.handleSettings(method, params) return h.handleSettings(method, params)
case MethodSecurityDeclareProfile, MethodSecurityDeclareProfiles:
return h.handleSecurity(method, params)
case MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource: case MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource:
return h.handleLLM(method, params) return h.handleLLM(method, params)

View File

@ -0,0 +1,69 @@
package proc
// 安全策略**声明面**的宿主侧实现(插件子进程 → 内核)。
//
// ## 为什么在 proc 包而不是让插件直连 security
//
// 插件是**子进程**,它唯一的通道就是本桥(stdio JSON-RPC + 共享内存)。
// 它拿不到 security.Runtime,也不该拿到 —— 那会让插件能直接改策略表。
//
// 而判定点必须在这里还有第二个原因:**Owner 要由内核填**。
// 插件自报的 owner 可以伪造(任何插件都能声明 `qq.owner` 并给
// AllowTools=["*"],等于把 QQ 的 owner 档位改成全放行),
// 所以本包用 `h.sdk.PluginName()`(内核侧真实标识)作为 owner,
// 而不是采信参数里的值。
//
// ## 与 internal/sdk/security_impl.go 的关系
//
// 那里的 `securityImpl` 才是真正的实现(含 owner 校验与 Runtime 转发)。
// 本文件只做 JSON 解码 → 调它 → 把错误带回去,保持与其他
// corehandler_* 分部函数同构。
import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func (h *coreHandler) handleSecurity(method string, params json.RawMessage) (interface{}, error) {
api := h.sdk.Security()
if api == nil {
// 未装配(独立工具/特殊宿主)时明确报错,而不是静默成功:
// 静默成功会让插件以为档位已登记,直到注入时才暴露。
return nil, fmt.Errorf("security: the declaration API is not wired in this host")
}
switch method {
case MethodSecurityDeclareProfile:
var p struct {
Profile pubsdk.SecurityProfile `json:"profile"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if err := api.DeclareProfile(p.Profile); err != nil {
return nil, err
}
return map[string]interface{}{"ok": true, "name": p.Profile.Name}, nil
case MethodSecurityDeclareProfiles:
var p struct {
Profiles []pubsdk.SecurityProfile `json:"profiles"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
if err := api.DeclareProfiles(p.Profiles); err != nil {
return nil, err
}
names := make([]string, len(p.Profiles))
for i, pr := range p.Profiles {
names[i] = pr.Name
}
return map[string]interface{}{"ok": true, "names": names}, nil
}
// 组内不应到达:Handle 的分派表与 case 标签同源。
return nil, fmt.Errorf("未知 method: %s", method)
}

View File

@ -0,0 +1,155 @@
package proc
// 端到端:插件的**安全策略声明**能力(sdk `Security()`)。
//
// ## 这条链路为什么要真机验证
//
// 与 variable store(e2e_secrets)同型:新增一个 proc method 要改
// **4 个地方**(协议常量 / 外层分派 / 内层分发 / 能力表),少一处就报
// "未知 method"。单测只能证明各段自洽,证明不了接上了。
//
// 而且它跨了**四层**:
//
// ① 插件调 sdk.Security().DeclareProfile("qq.guest", …)
// ② 模板 → RPC "security.declareProfile"
// ③ 内核 coreHandler.handleSecurity → sdk.SecurityAPI(security_impl)
// ④ → security.Runtime.DeclareProfile(进程级单例,真正被 CheckTool 读)
//
// ## 最关键的一点:Owner 由内核填
//
// 插件在参数里可以写任意 Owner(甚至写别的插件名),但宿主用
// `PluginName()` 覆盖它 —— 本判据正面验证「插件声明自己的档位成功」,
// 负面验证(冒名被拒)在 internal/sdk/security_impl_test.go。
import (
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk"
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
)
// securityE2EPlugin 用真实模板编译:只做一次档位声明。
const securityE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type secPlugin struct{ name string }
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &secPlugin{name: name}, nil
}
func (p *secPlugin) Name() string { return p.name }
func (p *secPlugin) Stop() error { return nil }
func (p *secPlugin) Start(s *sdk.PluginSDK) error {
sec := s.Security()
s.RegisterTool("e2e_declare", sdk.ToolDef{
Description: "声明安全档位",
Parameters: map[string]interface{}{"type": "object"},
}, func(args map[string]interface{}) (interface{}, error) {
if sec == nil {
return "UNAVAILABLE", nil
}
err := sec.DeclareProfile(sdk.SecurityProfile{
Name: "e2eproc.guest",
DenyTools: []string{"cmd_*"},
})
if err != nil {
return "DECLARE_ERR: " + err.Error(), nil
}
return "OK", nil
})
return nil
}
`
// ★★ 判据:真实模板插件的档位声明能穿透四层到达 Runtime。
func TestE2E_RealTemplateSecurityDeclare(t *testing.T) {
bin := buildPluginWithRealTemplate(t, securityE2EPlugin)
dir := t.TempDir()
setTestCodec(t)
// 装配进程级 Runtime(生产同款单例;工具判定读的就是它)
prev := internalSecurity.RuntimeGlobal()
rt := internalSecurity.NewRuntime(nil)
internalSecurity.SetRuntimeGlobal(rt)
t.Cleanup(func() { internalSecurity.SetRuntimeGlobal(prev) })
_ = internalConfig.NewConfigRegistry
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
// ★ 用内核侧真实插件名注入 —— 这就是 Owner 不可伪造的来源。
core.securityAPI = internalSDK.NewSecurityAPI("e2eproc")
p := New("e2e", bin, dir, nil, host, nil)
if err := p.Start(core); err != nil {
t.Fatalf("Start: %v", err)
}
defer p.Close()
core.mu.Lock()
handler, ok := core.tools["e2e_declare"]
core.mu.Unlock()
if !ok {
t.Fatal("插件工具未注册")
}
out, err := handler(nil)
if err != nil {
t.Fatalf("工具调用失败: %v", err)
}
got, _ := out.(string)
if got != "OK" {
t.Fatalf("声明应成功,实际: %q", got)
}
// ★ 核心断言:档位真的进了 Runtime(而不是只"没报错")
profiles := rt.Profiles()
found := false
for _, n := range profiles {
if n == "e2eproc.guest" {
found = true
}
}
if !found {
t.Fatalf("★ 档位未登记到 Runtime,已有: %v —— RPC 链路某一段没接上", profiles)
}
// 再验证它真的能约束工具(否则"登记了但没用"
//
// ★ 这里我曾把断言写反(`if d.Allowed { Fail("应放行") }`)——
// 把“期望放行”写成了“若放行则失败”。现在明确:
// 未激活档位 ⇒ cmd_run 必须**放行**(档位是按需激活的)
if d := rt.CheckTool("cmd_run"); !d.Allowed {
t.Fatalf("未激活档位时 cmd_run 应放行(档位按需激活),实际被拒: %s", d.Reason)
}
if err := rt.ActivateProfile("e2eproc.guest", 0); err != nil {
t.Fatalf("激活档位失败: %v", err)
}
if d := rt.CheckTool("cmd_run"); d.Allowed {
t.Fatal("激活 e2eproc.guest 后 cmd_run 必须被拒 —— 档位声明了却不生效")
}
}
// 未装配声明面时,插件应拿到明确错误而不是静默成功。
func TestE2E_SecurityDeclareWithoutAPI(t *testing.T) {
core := newFakeCore()
core.securityAPI = nil // 模拟宿主未接声明面
h := &coreHandler{sdk: core}
if _, err := h.Handle(MethodSecurityDeclareProfile, []byte(`{}`)); err == nil {
t.Fatal("未装配声明面时必须报错(静默成功会让插件误以为已登记)")
} else if !strings.Contains(err.Error(), "not wired") {
t.Fatalf("错误信息应说明未装配,实际: %v", err)
}
}

View File

@ -45,6 +45,8 @@ type fakeCoreSDK struct {
settingsAPI pubsdk.SettingsAPI settingsAPI pubsdk.SettingsAPI
// secretsAPI 同理:变量库后端(端到端判据用)。 // secretsAPI 同理:变量库后端(端到端判据用)。
secretsAPI pubsdk.SecretsAPI secretsAPI pubsdk.SecretsAPI
// securityAPI 是策略声明面的后端(声明类端到端判据用)。
securityAPI pubsdk.SecurityAPI
} }
// knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。 // knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。
@ -69,6 +71,7 @@ func newFakeCore() *fakeCoreSDK {
func (f *fakeCoreSDK) PluginName() string { return "fake" } func (f *fakeCoreSDK) PluginName() string { return "fake" }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return f.settingsAPI } func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return f.settingsAPI }
func (f *fakeCoreSDK) Secrets() pubsdk.SecretsAPI { return f.secretsAPI } func (f *fakeCoreSDK) Secrets() pubsdk.SecretsAPI { return f.secretsAPI }
func (f *fakeCoreSDK) Security() pubsdk.SecurityAPI { return f.securityAPI }
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil } func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil } func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem } func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }

View File

@ -204,6 +204,20 @@ const (
MethodSecretsEncrypt = "secrets.encrypt" // 46 MethodSecretsEncrypt = "secrets.encrypt" // 46
MethodSecretsDecrypt = "secrets.decrypt" // 47 MethodSecretsDecrypt = "secrets.decrypt" // 47
// 安全策略**声明面**(插件声明命名档位;判定由内核 security 执行)。
//
// ★ 为什么是方法而不是事件:声明必须**同步**生效。
// 若走事件,插件可能在声明事件还在队列里时就用
// InjectOptions.SecurityProfile="qq.guest" 注入 —— 内核那时还没登记
// 该档位,会响亮报"未声明档位"(那是对的,但会让插件启动后头几个
// 请求必然失败)。RPC 的响应返回即登记完成。
//
// ★ 为什么放在插件模块(proc)而不是让插件直连 security:
// 插件是子进程,它唯一的通道就是本桥。而 Owner 必须由内核用
// **真实插件名**填(插件自报可伪造),这个判定点只能在宿主侧。
MethodSecurityDeclareProfile = "security.declareProfile" // 48
MethodSecurityDeclareProfiles = "security.declareProfiles" // 49
// LLM 源(原 case 19/20/37) // LLM 源(原 case 19/20/37)
MethodLLMListSources = "llm.listSources" // 19 MethodLLMListSources = "llm.listSources" // 19
MethodLLMSetSource = "llm.setSource" // 20 MethodLLMSetSource = "llm.setSource" // 20

View File

@ -42,6 +42,18 @@ func (c procCore) Secrets() pubsdk.SecretsAPI {
return nil return nil
} }
// Security 转发到内核的策略声明面。
//
// ★ 注意 Owner 的来源:真正的实现(internal/sdk/security_impl.go)
// 用**内核侧真实插件名**填 Owner,不采信插件自报的值 ——
// 所以这里如实转发即可,越权拒绝发生在那一层。
func (c procCore) Security() pubsdk.SecurityAPI {
if v := c.sdk.Security(); v != nil {
return v
}
return nil
}
func (c procCore) Settings() pubsdk.SettingsAPI { func (c procCore) Settings() pubsdk.SettingsAPI {
if s := c.sdk.Settings(); s != nil { if s := c.sdk.Settings(); s != nil {
return s return s

View File

@ -22,7 +22,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/knowledge"
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua" luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory"
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/memory/text" "github.com/JianFeeeee/HologramAgent/internal/memory/text"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc" "github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
@ -87,11 +87,11 @@ type Registry struct {
pluginAutoRestart map[string]bool pluginAutoRestart map[string]bool
sdkRefs map[string]*sdk.PluginSDK sdkRefs map[string]*sdk.PluginSDK
iom *agentIO.IOManager iom *agentIO.IOManager
evBus *events.Bus evBus *events.Bus
memDB *memory.GraphDB memDB *memory.GraphDB
textMem *text.Memory textMem *text.Memory
docStore *doc.Store coldcontextStore *coldcontext.Store
// CAS 已淘汰(块自带负载,见 plan.md)。本注册表不保留媒体存储字段: // CAS 已淘汰(块自带负载,见 plan.md)。本注册表不保留媒体存储字段:
// 留着会诱导后来者再接线,走回头路。媒体字节走 sdk.Memory().MediaBytes。 // 留着会诱导后来者再接线,走回头路。媒体字节走 sdk.Memory().MediaBytes。
ks *knowledge.Store ks *knowledge.Store
@ -239,10 +239,10 @@ func (r *Registry) SetTextMemory(tm *text.Memory) {
defer r.mu.Unlock() defer r.mu.Unlock()
r.textMem = tm r.textMem = tm
} }
func (r *Registry) SetDocStore(ds *doc.Store) { func (r *Registry) SetDocStore(ds *coldcontext.Store) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
r.docStore = ds r.coldcontextStore = ds
} }
func (r *Registry) SetKnowledge(ks *knowledge.Store) { func (r *Registry) SetKnowledge(ks *knowledge.Store) {
r.mu.Lock() r.mu.Lock()
@ -533,28 +533,28 @@ func (d *channelDevice) ChannelDef() agentIO.ChannelDef { return d.chDef }
// 同一次 buildSDK 里拿到**互相不一致**的组合(比如 lua 已是新的、cfg 还是旧的)。 // 同一次 buildSDK 里拿到**互相不一致**的组合(比如 lua 已是新的、cfg 还是旧的)。
// 一次 RLock 取齐既消灭竞争,也保证同一次 SDK 装配看到的是同一个状态。 // 一次 RLock 取齐既消灭竞争,也保证同一次 SDK 装配看到的是同一个状态。
type sdkDeps struct { type sdkDeps struct {
plgDir string plgDir string
dataDir string dataDir string
cfgReg *internalConfig.ConfigRegistry cfgReg *internalConfig.ConfigRegistry
iom *agentIO.IOManager iom *agentIO.IOManager
evBus *events.Bus evBus *events.Bus
memDB *memory.GraphDB memDB *memory.GraphDB
textMem *text.Memory textMem *text.Memory
docStore *doc.Store coldcontextStore *coldcontext.Store
ks *knowledge.Store ks *knowledge.Store
mgr *agentAPI.ProviderManager mgr *agentAPI.ProviderManager
lua *luaVM.VM lua *luaVM.VM
baseKey string baseKey string
regTool sdk.ToolRegistrar regTool sdk.ToolRegistrar
regStage sdk.StageRegistrar regStage sdk.StageRegistrar
regAPI sdk.APIRegistrar regAPI sdk.APIRegistrar
status sdk.StatusAPI status sdk.StatusAPI
sup sdk.SupervisorAPI sup sdk.SupervisorAPI
trk *tracker.Tracker trk *tracker.Tracker
cfg *types.Config cfg *types.Config
stageHost sdk.ToolSource stageHost sdk.ToolSource
idx *memory.Indexer idx *memory.Indexer
kernelCtl sdk.KernelControlAPI kernelCtl sdk.KernelControlAPI
} }
// snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。 // snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。
@ -562,28 +562,28 @@ func (r *Registry) snapshotDeps() sdkDeps {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
return sdkDeps{ return sdkDeps{
plgDir: r.plgDir, plgDir: r.plgDir,
dataDir: r.dataDir, dataDir: r.dataDir,
cfgReg: r.cfgReg, cfgReg: r.cfgReg,
iom: r.iom, iom: r.iom,
evBus: r.evBus, evBus: r.evBus,
memDB: r.memDB, memDB: r.memDB,
textMem: r.textMem, textMem: r.textMem,
docStore: r.docStore, coldcontextStore: r.coldcontextStore,
ks: r.ks, ks: r.ks,
mgr: r.mgr, mgr: r.mgr,
lua: r.lua, lua: r.lua,
baseKey: r.baseKey, baseKey: r.baseKey,
regTool: r.regTool, regTool: r.regTool,
regStage: r.regStage, regStage: r.regStage,
regAPI: r.regAPI, regAPI: r.regAPI,
status: r.status, status: r.status,
sup: r.sup, sup: r.sup,
trk: r.trk, trk: r.trk,
cfg: r.cfg, cfg: r.cfg,
stageHost: r.stageHost, stageHost: r.stageHost,
idx: r.idx, idx: r.idx,
kernelCtl: r.kernelCtl, kernelCtl: r.kernelCtl,
} }
} }
@ -743,13 +743,17 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
// 插件名仅用于日志溯源(哪个插件写的块)。 // 插件名仅用于日志溯源(哪个插件写的块)。
Memory: sdk.NewGraphMemoryWithPlugin(name, deps.memDB), Memory: sdk.NewGraphMemoryWithPlugin(name, deps.memDB),
TextMemory: sdk.NewTextMemoryWithPlugin(name, deps.textMem), TextMemory: sdk.NewTextMemoryWithPlugin(name, deps.textMem),
DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.docStore), DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.coldcontextStore),
Knowledge: sdk.NewKnowledge(deps.ks), Knowledge: sdk.NewKnowledge(deps.ks),
LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey), LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey),
Settings: sett, Settings: sett,
// ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。 // ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。
// 内核未启用安全模块时它 Available()==false(不是 nil)。 // 内核未启用安全模块时它 Available()==false(不是 nil)。
Secrets: sdk.NewSecretsAPI(), Secrets: sdk.NewSecretsAPI(),
// ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…),
// 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填,
// 插件无法伪造身份(见 internal/sdk/security_impl.go)。
Security: sdk.NewSecurityAPI(name),
// ★ 随包资源:读 <插件目录>/rawfile/,可写目录 <插件目录>/work/。 // ★ 随包资源:读 <插件目录>/rawfile/,可写目录 <插件目录>/work/。
// 内置插件(无独立目录)拿到的路径不存在,Read 会明确报错 —— // 内置插件(无独立目录)拿到的路径不存在,Read 会明确报错 ——
// 那是对的:它们本来就没有随包资源。 // 那是对的:它们本来就没有随包资源。
@ -778,7 +782,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
Tracker: deps.trk, Tracker: deps.trk,
Config: sdk.NewConfig(deps.cfg), Config: sdk.NewConfig(deps.cfg),
Tool: sdk.NewTool(deps.stageHost, deps.iom), Tool: sdk.NewTool(deps.stageHost, deps.iom),
Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.docStore), Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.coldcontextStore),
}) })
} }

View File

@ -850,8 +850,8 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK, pluginFilter string) []
func isSafeReadonlyTool(name string) bool { func isSafeReadonlyTool(name string) bool {
// 明确只读的查询/列表类工具 // 明确只读的查询/列表类工具
readonlyExact := map[string]bool{ readonlyExact := map[string]bool{
"memory_recall": true, "stablemem_recall": true,
"memory_introspect": true, "stablemem_introspect": true,
"coldcontext_query": true, "coldcontext_query": true,
"knowledge_search": true, "knowledge_search": true,
"knowledge_list": true, "knowledge_list": true,

View File

@ -10,7 +10,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io" agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/knowledge"
"github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory"
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
) )
@ -322,14 +322,14 @@ func TestHealthcheckWithDocStore(t *testing.T) {
} }
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
ds := doc.NewStore(tmpDir, memory.TokenizeWords) ds := coldcontext.NewStore(tmpDir, memory.TokenizeWords)
if err := ds.Start(); err != nil { if err := ds.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer ds.Stop() defer ds.Stop()
// 预置一篇生产文档,验证 healthcheck 自检后不触碰它 // 预置一篇生产文档,验证 healthcheck 自检后不触碰它
if err := ds.Insert(&doc.Doc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil { if err := ds.Insert(&coldcontext.ColdDoc{ID: "prod_doc", Summary: "生产文档", Content: "这是生产文档,不应被健康检查破坏"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
before := len(ds.Query("生产文档", 10)) before := len(ds.Query("生产文档", 10))
@ -398,7 +398,7 @@ func TestLLMReportCollection(t *testing.T) {
func TestSafeReadonlyTool(t *testing.T) { func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行 // 只读:应放行
readonly := []string{ readonly := []string{
"memory_recall", "memory_introspect", "coldcontext_query", "stablemem_recall", "stablemem_introspect", "coldcontext_query",
"knowledge_search", "knowledge_list", "person_query", "knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels", "person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list", "terminal_list", "files_list",
@ -411,8 +411,8 @@ func TestSafeReadonlyTool(t *testing.T) {
// 写/删/改/副作用:应被拒绝 // 写/删/改/副作用:应被拒绝
mutating := []string{ mutating := []string{
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity", "stablemem_commit", "stablemem_edit", "stablemem_purge", "stablemem_delete_entity",
"memory_merge", "memory_block_merge", "stablemem_merge", "stablemem_block_merge",
"knowledge_create", "knowledge_delete", "knowledge_create", "knowledge_delete",
"coldcontext_commit", "doc_delete", "coldcontext_commit", "doc_delete",
"person_set_trait", "person_relate", "person_set_trait", "person_relate",
@ -436,8 +436,8 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表 // 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
stage := agentCore.NewStageHost() stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{ defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"}, {Name: "stablemem_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"}, {Name: "stablemem_commit", Plugin: "memory", Description: "commit"},
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"}, {Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"}, {Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"}, {Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
@ -465,13 +465,13 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
} }
// 只读工具应被包含 // 只读工具应被包含
for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_list"} { for _, name := range []string{"stablemem_recall", "coldcontext_query", "knowledge_search", "files_list"} {
if !allowed[name] { if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name) t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
} }
} }
// 写类/副作用工具绝不能被交给 LLM // 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "coldcontext_commit", "knowledge_create", "cmd_run"} { for _, name := range []string{"stablemem_commit", "coldcontext_commit", "knowledge_create", "cmd_run"} {
if allowed[name] { if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name) t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
} }
@ -484,8 +484,8 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
stage := agentCore.NewStageHost() stage := agentCore.NewStageHost()
defs := []sdk.ToolDef{ defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"}, {Name: "stablemem_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"}, {Name: "stablemem_commit", Plugin: "memory", Description: "commit"},
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"}, {Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"}, {Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"}, {Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
@ -514,14 +514,14 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
if len(got) != 1 || !allowed["files_list"] { if len(got) != 1 || !allowed["files_list"] {
t.Fatalf("expected only files_list for files plugin, got %v", allowed) t.Fatalf("expected only files_list for files plugin, got %v", allowed)
} }
for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_write"} { for _, name := range []string{"stablemem_recall", "coldcontext_query", "knowledge_search", "files_write"} {
if allowed[name] { if allowed[name] {
t.Errorf("tool %q must NOT be in files-filtered set", name) t.Errorf("tool %q must NOT be in files-filtered set", name)
} }
} }
gotMemory := p.collectToolDefsForLLM(s, "memory") gotMemory := p.collectToolDefsForLLM(s, "memory")
if len(gotMemory) != 1 || gotMemory[0].Name != "memory_recall" { if len(gotMemory) != 1 || gotMemory[0].Name != "stablemem_recall" {
t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory) t.Fatalf("expected only memory_recall for memory plugin, got %v", gotMemory)
} }

View File

@ -14,7 +14,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/knowledge" "github.com/JianFeeeee/HologramAgent/internal/knowledge"
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua" luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
"github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory"
doc "github.com/JianFeeeee/HologramAgent/internal/memory/document" coldcontext "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
"github.com/JianFeeeee/HologramAgent/internal/plugin" "github.com/JianFeeeee/HologramAgent/internal/plugin"
cli "github.com/JianFeeeee/HologramAgent/internal/plugins/cli" cli "github.com/JianFeeeee/HologramAgent/internal/plugins/cli"
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui" "github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
@ -22,14 +22,14 @@ import (
) )
type testPluginEnv struct { type testPluginEnv struct {
tmpDir string tmpDir string
stageHost *agentCore.StageHost stageHost *agentCore.StageHost
iom *agentIO.IOManager iom *agentIO.IOManager
pluginReg *plugin.Registry pluginReg *plugin.Registry
memDB *memory.GraphDB memDB *memory.GraphDB
ks *knowledge.Store ks *knowledge.Store
docStore *doc.Store coldcontextStore *coldcontext.Store
cfgReg *internalConfig.ConfigRegistry cfgReg *internalConfig.ConfigRegistry
} }
func setupIntegration(t *testing.T) *testPluginEnv { func setupIntegration(t *testing.T) *testPluginEnv {
@ -107,15 +107,15 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
t.Fatal(err) t.Fatal(err)
} }
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords) coldcontextStore := coldcontext.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords)
if err := docStore.Start(); err != nil { if err := coldcontextStore.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
pluginReg.SetStageHost(stageHost) pluginReg.SetStageHost(stageHost)
pluginReg.SetProviderManager(pm) pluginReg.SetProviderManager(pm)
pluginReg.SetKnowledge(ks) pluginReg.SetKnowledge(ks)
pluginReg.SetDocStore(docStore) pluginReg.SetDocStore(coldcontextStore)
cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock") cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock")
@ -154,14 +154,14 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
} }
return &testPluginEnv{ return &testPluginEnv{
tmpDir: tmpDir, tmpDir: tmpDir,
stageHost: stageHost, stageHost: stageHost,
iom: iom, iom: iom,
pluginReg: pluginReg, pluginReg: pluginReg,
memDB: memDB, memDB: memDB,
ks: ks, ks: ks,
docStore: docStore, coldcontextStore: coldcontextStore,
cfgReg: cfgReg, cfgReg: cfgReg,
} }
} }
@ -169,7 +169,7 @@ func (e *testPluginEnv) cleanup() {
e.pluginReg.StopAll() e.pluginReg.StopAll()
e.memDB.Close() e.memDB.Close()
e.ks.Stop() e.ks.Stop()
e.docStore.Stop() e.coldcontextStore.Stop()
os.RemoveAll(e.tmpDir) os.RemoveAll(e.tmpDir)
} }

View File

@ -18,7 +18,7 @@ Four stores, one rule each:
| Need | Tool | | Need | Tool |
|---|---| |---|---|
| A past episode in full | `coldcontext_query` | | A past episode in full | `coldcontext_query` |
| A fact or relation | `memory_recall` | | A fact or relation | `stablemem_recall` |
| Written reference material | `knowledge_search` | | Written reference material | `knowledge_search` |
### `coldcontext_query` semantics (read this before using it) ### `coldcontext_query` semantics (read this before using it)
@ -32,6 +32,6 @@ again later, it lives on in the timeline until pruning moves it back.
## Writing ## Writing
- Facts worth keeping: `memory_commit` (triples). - Facts worth keeping: `stablemem_commit` (triples).
- Written knowledge: `knowledge_write`. - Written knowledge: `knowledge_write`.
- Do not commit transient chatter, tool output, or your own status lines. - Do not commit transient chatter, tool output, or your own status lines.

View File

@ -96,44 +96,46 @@ func (e *eventsAPI) EventPolicy() security.EventPolicy {
return security.DefaultEventPolicy() return security.DefaultEventPolicy()
} }
// Publish 以**本插件身份**发布事件。
//
// ## ★ 为什么必须走 PublishAs(2026-10-29 收敛)
//
// 此前这里调的是 `bus.Publish`,而它的实现是 `publishAs(Publisher{}, ...)` ——
// 空 Publisher 的 `IsKernel()` 为真,于是 security 的 `AllowPublish`
// **第一行就直接放行**。也就是说:总线上的事件策略对这条路径从未生效过,
// 它之所以“安全”,只是因为本函数自己又查了一遍策略。
//
// 后果是**同一条策略有两份实现**(本文件 + security.eventGuard),
// 一旦口径漂移就会出现“总线放行、适配层拦截”或反之的分裂。
//
// 现在只保留一份:
//
// e.bus.PublishAs(Publisher{Name: e.pluginName}, ...)
// → security.eventGuard.AllowPublish (授权,唯一一处)
// → security.eventGuard.ScrubPayload (出向脱敏)
// → 投递时 security.eventGuard.ScrubInbound(入向清洗)
//
// 本文件只剩“把身份如实告诉总线”这一件职责 —— 插件模块不再持有策略判断
// (用户裁定:所有安全部分归安全模块)。
func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error { func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error {
if e.unavailable != "" { if e.unavailable != "" {
return fmt.Errorf("events: %s", e.unavailable) return fmt.Errorf("events: %s", e.unavailable)
} }
if d := e.checkPublish(eventType); !d.Allowed {
return fmt.Errorf("%s", d.Reason)
}
if payload == nil { if payload == nil {
payload = map[string]interface{}{} payload = map[string]interface{}{}
} }
// 载荷键数上限仍在这里判:它是**当前唯一实现**(eventGuard.AllowPublish
// 只拿到 eventType、看不到 payload,故无法在那里做)。
// 删掉它 = 直接失去这项保护,所以不属“重复”而不应一并丢弃。
if d := e.checkSize(len(payload)); !d.Allowed { if d := e.checkSize(len(payload)); !d.Allowed {
return fmt.Errorf("%s", d.Reason) return fmt.Errorf("%s", d.Reason)
} }
return e.bus.PublishAs(events.Publisher{Name: e.pluginName}, &events.Event{
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
// 插件顺手把凭据放进去就等于绕过凭据闸门。
e.bus.Publish(&events.Event{
Type: events.EventType(eventType), Type: events.EventType(eventType),
Source: e.pluginName, Source: e.pluginName,
Payload: e.scrubOut(eventType, payload), Payload: payload,
}) })
return nil
} }
func (e *eventsAPI) checkPublish(t string) security.Decision {
if e.sec != nil {
return e.sec.CheckEventPublish(e.pluginName, t)
}
return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t)
}
func (e *eventsAPI) checkSubscribe(t string) security.Decision {
if e.sec != nil {
return e.sec.CheckEventSubscribe(e.pluginName, t)
}
return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t)
}
func (e *eventsAPI) checkSize(keys int) security.Decision { func (e *eventsAPI) checkSize(keys int) security.Decision {
if e.sec != nil { if e.sec != nil {
return e.sec.CheckEventPayloadSize(keys) return e.sec.CheckEventPayloadSize(keys)
@ -141,66 +143,26 @@ func (e *eventsAPI) checkSize(keys int) security.Decision {
return security.DefaultEventPolicy().CheckEventPayloadSize(keys) return security.DefaultEventPolicy().CheckEventPayloadSize(keys)
} }
// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。
func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} {
if e.sec == nil {
return payload
}
out := make(map[string]interface{}, len(payload))
for k, v := range payload {
if s, ok := v.(string); ok {
if clean, n := e.sec.PrepareOutbound(security.Inspection{
Direction: security.DirectionOutbound,
Stage: "event:" + eventType,
Source: e.pluginName,
}, s); n > 0 {
out[k] = clean
continue
}
}
out[k] = v
}
return out
}
func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) { func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) {
if e.unavailable != "" { if e.unavailable != "" {
return nil, fmt.Errorf("events: %s", e.unavailable) return nil, fmt.Errorf("events: %s", e.unavailable)
} }
if d := e.checkSubscribe(eventType); !d.Allowed {
return nil, fmt.Errorf("%s", d.Reason)
}
if handler == nil { if handler == nil {
return nil, fmt.Errorf("events: handler must not be nil") return nil, fmt.Errorf("events: handler must not be nil")
} }
unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) { // 走 SubscribeAs:授权交给 security.eventGuard.AllowSubscribe(唯一一处),
handler(e.scrubIn(eventType, evt.Payload)) // 入向清洗已由总线在投递前统一执行 —— 这里不再自己洗一遍。
}) unsub, err := e.bus.SubscribeAs(events.Publisher{Name: e.pluginName},
events.EventType(eventType), func(evt *events.Event) {
handler(evt.Payload)
})
if err != nil {
return nil, err
}
e.unsubs = append(e.unsubs, unsub) e.unsubs = append(e.unsubs, unsub)
return unsub, nil return unsub, nil
} }
// scrubIn 对入向载荷做清洗(事件可能带其他模块写进来的内容)。
func (e *eventsAPI) scrubIn(eventType string, p map[string]interface{}) map[string]interface{} {
if e.sec == nil || p == nil {
return p
}
out := make(map[string]interface{}, len(p))
for k, v := range p {
if s, ok := v.(string); ok {
if clean, n := e.sec.ScrubInbound(security.Inspection{
Direction: security.DirectionInbound,
Stage: "event:" + eventType,
}, s); n > 0 {
out[k] = clean
continue
}
}
out[k] = v
}
return out
}
// stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。 // stop 解除本插件建立的全部订阅(插件卸载时由宿主调用)。
func (e *eventsAPI) stop() { func (e *eventsAPI) stop() {
for _, u := range e.unsubs { for _, u := range e.unsubs {

View File

@ -15,9 +15,28 @@ import (
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk" pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
) )
// newTestEvents 构造一个**已装上安全闸门**的受限事件面。
//
// ## ★ 为什么必须装 Guard(2026-10-29 收敛后)
//
// 此前 `sdk/events.go` 自己查策略(checkPublish/checkSubscribe/scrubOut),
// 所以测试里的裸 bus 也能"看起来工作"。收敛后授权与脱敏全部归
// `security.eventGuard`,而闸门是挂在总线上的 —— **测试不装 Guard,
// 就等于在测一条没有执行者的策略**,于是判据会假绿。
//
// 这正好顺便钉住了生产装配要求:**总线必须带闸门**,否则策略不生效。
func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) { func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) {
t.Helper() t.Helper()
bus := events.NewBus() bus := events.NewBus()
// 用当前全局 Runtime 造闸门(未装配时 NewRuntime(nil) 给 Disabled Runtime,
// 策略仍生效 —— 与生产同一条纪律:关闭凭据替换 ≠ 放开授权)。
rt := security.RuntimeGlobal()
if rt == nil {
rt = security.NewRuntime(nil)
}
if g := security.AsEventGuard(rt); g != nil {
bus.SetGuard(g)
}
return newEventsAPI(name, bus), bus return newEventsAPI(name, bus), bus
} }

View File

@ -6,13 +6,13 @@ import (
"time" "time"
"github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document" "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
) )
// indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。 // indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。
type indexerImpl struct { type indexerImpl struct {
idx *memory.Indexer idx *memory.Indexer
docs *document.Store // 可选:给「上下文」诊断面提供 L2 那一路 docs *coldcontext.Store // 可选:给「上下文」诊断面提供 L2 那一路
} }
func NewIndexer(idx *memory.Indexer) IndexerAPI { func NewIndexer(idx *memory.Indexer) IndexerAPI {
@ -20,7 +20,7 @@ func NewIndexer(idx *memory.Indexer) IndexerAPI {
} }
// NewIndexerWithDocs 让诊断面同时覆盖 L2 冷上下文与 L3 图记忆(双路召回)。 // NewIndexerWithDocs 让诊断面同时覆盖 L2 冷上下文与 L3 图记忆(双路召回)。
func NewIndexerWithDocs(idx *memory.Indexer, docs *document.Store) IndexerAPI { func NewIndexerWithDocs(idx *memory.Indexer, docs *coldcontext.Store) IndexerAPI {
return &indexerImpl{idx: idx, docs: docs} return &indexerImpl{idx: idx, docs: docs}
} }
@ -81,14 +81,14 @@ func (i *indexerImpl) queryDocs(q string, limit int) []DocSnippet {
when = h.Doc.Events[0].Timestamp.Format(time.DateTime) when = h.Doc.Events[0].Timestamp.Format(time.DateTime)
} }
// 正文在事件的**文本块**里(DocEvent 只有 Blocks,没有 Input/Response)。 // 正文在事件的**文本块**里(DocEvent 只有 Blocks,没有 Input/Response)。
text := docFirstText(h.Doc) text := coldcontextFirstText(h.Doc)
out = append(out, DocSnippet{ID: h.Doc.ID, When: when, Text: truncateRunes(text, 120), Score: h.Score}) out = append(out, DocSnippet{ID: h.Doc.ID, When: when, Text: truncateRunes(text, 120), Score: h.Score})
} }
return out return out
} }
// docFirstText 取文档可用于展示的首段正文(跳过媒体块)。 // coldcontextFirstText 取文档可用于展示的首段正文(跳过媒体块)。
func docFirstText(d *document.Doc) string { func coldcontextFirstText(d *coldcontext.ColdDoc) string {
if d == nil { if d == nil {
return "" return ""
} }

Some files were not shown because too many files have changed in this diff Show More