mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
feat: 段页式记忆流转、coldcontext 重命名与安全治理
记忆层: - MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID - coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失 - 召回先估算总 token,超预算时按全局页相似度标记 inactive - inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置 - Peek/确认/Remove 两阶段召回,失败保留冷上下文副本 - stablemem 记录 last_recalled_at,删块连带清理边 命名: - internal/memory/document → internal/memory/coldcontext - Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit - ContextToColdContext/RecentColdContexts/FindColdContexts - 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据 安全治理: - 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏 - 声明式 SecurityAPI 与 QQ owner/guest profile - 日志保留策略与相关 e2e/回归判据 验证:go build ./...、go vet ./...、go test ./...(62 包); 生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
445
cmd/homed-predicate-migrate/main.go
Normal file
445
cmd/homed-predicate-migrate/main.go
Normal 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)
|
||||
}
|
||||
@ -340,7 +340,7 @@ func waitForShutdown(ctx context.Context, dataDir string, restartCh chan struct{
|
||||
// 路径 B(exit 42):下面的 os.Exit(42) **跳过所有 defer** ✗
|
||||
//
|
||||
// 而路径 B 是**每次热更新都走**的。后果是这些写者从不 flush:
|
||||
// · docStore.Stop() → flush() 文档索引可能陈旧
|
||||
// · coldcontextStore.Stop() → flush() 文档索引可能陈旧
|
||||
// · distiller.Stop() → flush() 待蒸馏规则丢失
|
||||
// · textMem.Stop() → Close() 文本记忆未落盘
|
||||
// · luaVM/multimodal → 资源未释放(靠进程退出兜底)
|
||||
|
||||
@ -13,7 +13,7 @@ package main
|
||||
//
|
||||
// 而路径 B 是**每次热更新都走**的(guard 靠 exit 42 判定"主动重启")。
|
||||
// 后果是这些写者从不 flush:
|
||||
// · docStore.Stop() → flush() 文档索引可能陈旧
|
||||
// · coldcontextStore.Stop() → flush() 文档索引可能陈旧
|
||||
// · distiller.Stop() → flush() 待蒸馏规则丢失
|
||||
// · textMem.Stop() → Close() 文本记忆未落盘
|
||||
//
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
var benchChunks = map[string]string{
|
||||
"content_zh": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
"content_ascii": `{"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"hello world this is a longer ascii content chunk"},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"toolcall": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
"usage": `{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
"finish": `{"id":"c","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
||||
}
|
||||
|
||||
@ -86,7 +86,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) {
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"reasoning_content":"thinking..."},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"c1","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"id":"c1","usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`,
|
||||
`{"usage":{"prompt":1,"completion":2,"total":3}}`,
|
||||
@ -160,7 +160,7 @@ func TestChunkFast_ProtocolForms(t *testing.T) {
|
||||
func TestChunkFast_Realistic(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":"这是一段来自真实流式响应的中文内容,用于测量解析开销。"},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"memory_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_9a","type":"function","function":{"name":"stablemem_recall","arguments":"{\"query\":\"用户偏好\",\"limit\":20}"}}]},"finish_reason":null}]}`,
|
||||
`{"id":"chatcmpl-abc","object":"chat.completion.chunk","created":1727000000,"model":"deepseek-v4.1-flash","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}],"usage":{"prompt_tokens":3821,"completion_tokens":117,"total_tokens":3938,"prompt_cache_hit_tokens":3584,"prompt_cache_miss_tokens":237}}`,
|
||||
// 流式续传:name 不重发但 function.arguments 继续
|
||||
`{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"a\":"}}]},"finish_reason":null}]}`,
|
||||
|
||||
@ -47,7 +47,7 @@ type DeviceSurface interface {
|
||||
// ## 为什么用族的粒度而不是逐工具
|
||||
//
|
||||
// 族内工具共享同一批依赖(memory 族要 graph、knowledge 族要 store、
|
||||
// coldcontext 族要 docStore…)。按族传接口,device 的分发层就能
|
||||
// coldcontext 族要 coldcontextStore…)。按族传接口,device 的分发层就能
|
||||
// 在不认识 Agent 内部字段的前提下完整接管**路由**:
|
||||
//
|
||||
// device.Dispatch(tc) → 选族 → handler.ExecuteMemoryTool(tc)
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
@ -69,7 +69,7 @@ type Agent struct {
|
||||
cancel context.CancelFunc
|
||||
|
||||
// 文档记忆(第二层)
|
||||
docStore *document.Store
|
||||
coldcontextStore *coldcontext.Store
|
||||
|
||||
// 知识库
|
||||
knowledge *knowledge.Store
|
||||
@ -272,7 +272,7 @@ type AgentConfig struct {
|
||||
Indexer *memory.Indexer
|
||||
Tracker *tracker.Tracker
|
||||
|
||||
DocStore *document.Store
|
||||
DocStore *coldcontext.Store
|
||||
Knowledge *knowledge.Store
|
||||
SocialStore *social.SocialStore
|
||||
TextMemory *text.Memory
|
||||
@ -412,27 +412,27 @@ func New(cfg AgentConfig) *Agent {
|
||||
}
|
||||
|
||||
a := &Agent{
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
docStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
id: cfg.ID,
|
||||
startTime: time.Now(),
|
||||
provider: cfg.Provider,
|
||||
providerManager: cfg.ProviderManager,
|
||||
io: cfg.IO,
|
||||
memory: cfg.Memory,
|
||||
graph: graphMemoryOf(cfg),
|
||||
kernelSource: cfg.KernelSource,
|
||||
parentID: cfg.ParentID,
|
||||
taskPrompt: cfg.TaskPrompt,
|
||||
dataDir: cfg.DataDir,
|
||||
indexer: cfg.Indexer,
|
||||
tracker: cfg.Tracker,
|
||||
context: rc,
|
||||
systemPrompt: cfg.SystemPrompt,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
coldcontextStore: cfg.DocStore,
|
||||
knowledge: cfg.Knowledge,
|
||||
social: cfg.SocialStore,
|
||||
textMem: cfg.TextMemory,
|
||||
// mediaStore 已随 CAS 淘汰:块自带负载。知识层的媒体也已自带字节
|
||||
// (见 knowledge 的媒体侧车),不再需要外部 getter/putter 接线。
|
||||
personality: cfg.Personality,
|
||||
|
||||
@ -3,7 +3,7 @@ package core
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
func TestEntitySimilarity(t *testing.T) {
|
||||
@ -26,13 +26,13 @@ func TestEntitySimilarity(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriples(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "用户喜欢编程",
|
||||
Content: "用户提到喜欢Go和Python",
|
||||
Source: "context",
|
||||
}
|
||||
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
foundSummary := false
|
||||
foundSource := false
|
||||
@ -54,18 +54,18 @@ func TestDocToTriples(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriplesNil(t *testing.T) {
|
||||
triples := docToTriples(nil, nil)
|
||||
triples := coldDocToTriples(nil, nil)
|
||||
if len(triples) != 0 {
|
||||
t.Errorf("expected empty for nil doc, got %d", len(triples))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocToTriplesNoSource(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "无来源文档",
|
||||
Content: "content",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
for _, tr := range triples {
|
||||
if tr.Relation == "来源" {
|
||||
t.Error("should not have source triple when Source is empty")
|
||||
@ -74,13 +74,13 @@ func TestDocToTriplesNoSource(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriplesTypes(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "测试三元组类型",
|
||||
Content: "用于验证 SubjectType 和 ObjectType",
|
||||
Source: "test",
|
||||
}
|
||||
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" {
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
func needJieba() bool {
|
||||
@ -12,12 +12,12 @@ func needJieba() bool {
|
||||
}
|
||||
|
||||
func TestDocToTriplesEmpty(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "empty doc",
|
||||
Content: "",
|
||||
Source: "test",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
if len(triples) < 2 {
|
||||
t.Fatalf("expected at least 2 triples (主题+来源), got %d", len(triples))
|
||||
}
|
||||
@ -33,12 +33,12 @@ func TestDocToTriplesEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriplesConversation(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "测试对话 (qq) 涉及: 天气",
|
||||
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
|
||||
Source: "qq",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
if len(triples) < 2 {
|
||||
t.Errorf("expected at least 2 triples (主题+来源), got %d", len(triples))
|
||||
@ -55,12 +55,12 @@ func TestDocToTriplesConversation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriplesMultiLine(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "多轮对话",
|
||||
Content: "[10:00] user: 你好\n[10:01] agent: 你好,有什么可以帮助你的\n[10:02] user: 今天天气如何\n[10:03] agent: 今天天气很好",
|
||||
Source: "qq",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
if len(triples) < 2 {
|
||||
t.Fatalf("expected at least 2 triples, got %d", len(triples))
|
||||
}
|
||||
@ -75,12 +75,12 @@ func TestDocToTriplesMultiLine(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDocToTriplesEmptyContent(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "空内容",
|
||||
Content: "",
|
||||
Source: "test",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
if len(triples) != 2 {
|
||||
t.Fatalf("expected exactly 2 triples (主题+来源) for empty content, got %d", len(triples))
|
||||
}
|
||||
@ -88,13 +88,13 @@ func TestDocToTriplesEmptyContent(t *testing.T) {
|
||||
|
||||
// Phase 2: 归档上下文文档不得产出模板垃圾(context_archived 来源/主题模板三元组)
|
||||
func TestDocToTriplesArchivedContext(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "来自 2 个来源的 5 条对话 (qq, webui) 涉及: 天气, 测试",
|
||||
Content: "[15:04] qq: 今天天气怎么样\n[15:05] agent: 今天天气很好",
|
||||
Source: "context_archived",
|
||||
Meta: map[string]string{"is_archived_context": "true"},
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "context_archived" {
|
||||
@ -108,12 +108,12 @@ func TestDocToTriplesArchivedContext(t *testing.T) {
|
||||
|
||||
// Phase 2: 模板化摘要(summarizeEntries 生成)不得作为主题写入
|
||||
func TestDocToTriplesTemplateSummary(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "来自 3 个来源的 10 条对话 (a, b, c) 涉及: 关键词1, 关键词2, 关键词3",
|
||||
Content: "[10:00] a: 你好",
|
||||
Source: "manual",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" {
|
||||
@ -138,12 +138,12 @@ func TestDocToTriplesLongSummary(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
long += "很长的摘要内容片段重复拼接"
|
||||
}
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: long,
|
||||
Content: "[10:00] a: 你好",
|
||||
Source: "test",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
for _, tr := range triples {
|
||||
if tr.Subject == "文档" && tr.Relation == "主题" {
|
||||
t.Errorf("overlong summary must not be written as 主题 triple")
|
||||
@ -244,16 +244,16 @@ func TestGetFloatInt(t *testing.T) {
|
||||
// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类
|
||||
// 模板回声,以及 context_archived 这个内部标记。
|
||||
func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道",
|
||||
Content: "",
|
||||
Source: "context_archived",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
for _, tr := range triples {
|
||||
if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) {
|
||||
t.Errorf("docToTriples 漏出噪音实体: %+v", tr)
|
||||
t.Errorf("coldDocToTriples 漏出噪音实体: %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,12 +270,12 @@ func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
|
||||
|
||||
// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。
|
||||
func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) {
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: "多轮对话",
|
||||
Content: "",
|
||||
Source: "qq",
|
||||
}
|
||||
triples := docToTriples(doc, nil)
|
||||
triples := coldDocToTriples(doc, nil)
|
||||
|
||||
var hasTopic, hasSource bool
|
||||
for _, tr := range triples {
|
||||
|
||||
@ -119,7 +119,7 @@ func TestBuildToolDefsOutputToolsWithDevice(t *testing.T) {
|
||||
name: "screen",
|
||||
caps: agentIO.CapText,
|
||||
})
|
||||
a := &Agent{io: io, knowledge: nil, docStore: nil, pluginReg: nil}
|
||||
a := &Agent{io: io, knowledge: nil, coldcontextStore: nil, pluginReg: nil}
|
||||
tools := a.buildToolDefs()
|
||||
|
||||
foundSend := false
|
||||
|
||||
@ -375,7 +375,7 @@ func TestToolParallelDeclarationsAudit(t *testing.T) {
|
||||
"plugin_install", "plugin_remove", "plugin_restart",
|
||||
"terminal_create", "terminal_write", "terminal_close",
|
||||
"timer_set",
|
||||
"memory_merge", "memory_delete_entity", "knowledge_create",
|
||||
"stablemem_merge", "stablemem_delete_entity", "knowledge_create",
|
||||
}
|
||||
for _, n := range serialOnly {
|
||||
if a.toolParallelSafe(n) {
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
@ -122,16 +120,11 @@ func (a *Agent) installBuiltinProvider() {
|
||||
sdk.SetBuiltinProvider(builtinProvider{a: a})
|
||||
}
|
||||
|
||||
// isBuiltinToolName 粗判某名字是否可能是内置工具(供提示词/文档用)。
|
||||
// 真正的判定以 ToolAPI 查询为准(带门控)。
|
||||
func isBuiltinToolName(name string) bool {
|
||||
for _, p := range []string{
|
||||
"memory_", "knowledge_", "doc_", "person_",
|
||||
"output_", "input_", "resident_", "notify_parent", "persona_set",
|
||||
} {
|
||||
if strings.HasPrefix(name, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
// ★ 已删除 isBuiltinToolName(2026-10-29)。
|
||||
//
|
||||
// 它自称"供提示词/文档用",实测**零生产调用方**,是 Dispatch 之外
|
||||
// 又一份手抄前缀表。改名时改了 Dispatch(stablemem_)却漏了这里,
|
||||
// 于是它成了"看起来是事实来源、实际只是过期副本"的第三种状态。
|
||||
//
|
||||
// 真需要判"是不是内置工具"时调 toolroute.Dispatch:它返回的 bool
|
||||
// 就是权威答案(handled=false 表示应继续走插件/设备路径)。
|
||||
|
||||
@ -61,7 +61,7 @@ func TestBuiltinToolGatingRespected(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider()) // 无 memory / knowledge
|
||||
api := toolAPIOf(t, a)
|
||||
|
||||
if api.ToolDefByName("memory_merge") != nil {
|
||||
if api.ToolDefByName("stablemem_merge") != nil {
|
||||
t.Error("未接 memory 却声称有 memory_merge —— 门控语义被破坏")
|
||||
}
|
||||
if api.ToolDefByName("knowledge_list") != nil {
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -13,16 +14,53 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// ToolResultItem 是一条工具调用结果。
|
||||
//
|
||||
// ★ 2026-10-29 补 CallID:此前只有 Name + Output,于是**同名工具的多次调用
|
||||
// 无法配对**(两个 qq_get_history 分不清谁是谁),而配对是渲染正确性的前提:
|
||||
// assistant(tool_calls=[c1..cn]) 后面必须紧跟配对齐全的 tool 消息,
|
||||
// 否则上游直接 400(insufficient tool messages)。
|
||||
//
|
||||
// 生产数据实证(改动前):tool_results 的键只有 ["name","output"],
|
||||
// 参数完全没有留存 —— 这意味着回灌后无法重建"当时到底调了什么"。
|
||||
type ToolResultItem struct {
|
||||
// CallID 回指 ToolCall.ID,是 tool_call 与 tool_result 的配对键。
|
||||
//
|
||||
// 空值表示旧数据(未记录 CallID),渲染时按位置配对(既有行为)。
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
// ToolCallItem 是一次工具调用的**完整记录**(含参数)。
|
||||
//
|
||||
// ★ 为什么必须记录参数(2026-10-29 还技术债)
|
||||
//
|
||||
// 此前事件里只有 `ToolsUsed []string`(工具名列表)。后果:
|
||||
//
|
||||
// · 回灌后无法知道"当时用**什么参数**调用的",只能看到结果
|
||||
// · 页化(段页式)无法实现 —— 「toolcall 与其 result 配对成一页」
|
||||
// 要求 call 侧有完整内容,只有名字的话那页是半空的
|
||||
// · 审计/复盘时"这个工具为什么返回这个"无从追溯
|
||||
//
|
||||
// 参数本身是模型给出的,正常是别名(sec_xxx),但仍可能被直接传明文
|
||||
// (插件工具),所以落盘前必须过 scrubEventSecrets(与 Input/Response 同一条闸)。
|
||||
type ToolCallItem struct {
|
||||
// CallID 是本次调用的唯一标识(来自 provider,缺失时内核补一个)。
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
// Arguments 是解析后的参数。
|
||||
Arguments map[string]interface{} `json:"arguments,omitempty"`
|
||||
// RawArguments 是模型给出的原始 JSON 字符串,保留它是为了诊断
|
||||
// "参数解析失败/被截断"这类问题(Arguments 无法表达残缺)。
|
||||
RawArguments string `json:"raw_arguments,omitempty"`
|
||||
}
|
||||
|
||||
type ContextEvent struct {
|
||||
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
|
||||
//
|
||||
@ -36,6 +74,39 @@ type ContextEvent struct {
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
|
||||
// ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。
|
||||
//
|
||||
// 与 ToolsUsed 的关系:ToolsUsed 是「用过的工具名」的便捷视图(保留是为了
|
||||
// 存量读取与统计),ToolCalls 才是配对与页化所需的事实源。
|
||||
// 两者都由 appendTurnToolCall 同步维护,不手写其中一份。
|
||||
ToolCalls []ToolCallItem `json:"tool_calls,omitempty"`
|
||||
|
||||
// InactivePages 是**本次渲染**中不参与渲染的页序号。
|
||||
//
|
||||
// ★ 它是渲染期状态,不是记忆属性(用户裁定,2026-10-29):
|
||||
//
|
||||
// "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥?
|
||||
// 甚至可以说 inactive 是每次 prune 后被重置的"
|
||||
//
|
||||
// 因此:
|
||||
// · **不持久化**(json:"-")—— 重启后由本轮渲染重新判定
|
||||
// · **不迁移到 L2** —— 淘汰(归档)意味着整帧离开 L1,
|
||||
// 剔除本来就是"在有限窗口里少渲染几页"的临时手段,
|
||||
// 帧都走了再记着"它当时少渲染了哪页"毫无意义
|
||||
// · **每次 Prune 后清空** —— 新一轮窗口重新规划,旧标记失效
|
||||
//
|
||||
// 语义:剔除 = 标注,不是删除。数据仍在(ToolCalls/ToolResults/Blocks),
|
||||
// 只是不渲染成消息。清空本列表即恢复渲染。
|
||||
//
|
||||
// 为什么最小单位是页而不是块:渲染时 `assistant(tool_calls)` 后面必须
|
||||
// 紧跟**配对齐全**的 `tool` 消息,否则上游直接 400(task.go:160 有生产事故
|
||||
// 记录)。所以只能整页剔除 —— 剔掉 call 却留下 result 就是造一个必崩的帧。
|
||||
//
|
||||
// 页序约定(与 coldcontext 的 DocEvent 分页一致):
|
||||
// 1 = llm_round(用户输入 ↔ 模型输出)
|
||||
// 2..n+1 = tool_round(第 i 个 ToolCalls ↔ 配对结果)
|
||||
InactivePages []int `json:"-"`
|
||||
|
||||
// ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。
|
||||
//
|
||||
// ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**,
|
||||
@ -165,6 +236,480 @@ func (a *Agent) singleEventFitsBudget() bool {
|
||||
return avg <= a.computeTokenBudget().ContextTokens
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 段页式淘汰(P5,2026-10-29)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// pageSimilarity 计算某一页与本轮输入的相似度。
|
||||
//
|
||||
// 页的相似度取**页内各侧的最大值**:call 侧里往往带着关键实体
|
||||
// (文件路径、命令、代码),而 result 侧是回显 —— 只看一侧会漏。
|
||||
func (c *RelevanceContext) pageSimilarity(e *ContextEvent, page int,
|
||||
queryDense []float64, queryFP string, queryVec vector.Vector) float64 {
|
||||
|
||||
callText, resText := c.pageTexts(e, page)
|
||||
best := math.Inf(-1)
|
||||
|
||||
score := func(text string) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
// 与事件评分同构:同一统一空间内才比稠密余弦。
|
||||
if len(queryDense) > 0 && queryFP != "" && e.DenseFP == queryFP {
|
||||
if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) {
|
||||
if s := vector.DenseCosine(queryDense, v); s > best {
|
||||
best = s
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
score(callText)
|
||||
score(resText)
|
||||
if math.IsInf(best, -1) {
|
||||
return 0
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// vectorForText 尝试为任意文本取稠密向量(缓存优先)。
|
||||
//
|
||||
// 页内文本(工具参数/结果)不是一等块,没有预存向量;
|
||||
// 这里按需算,失败就返回 false 让调用方退回稀疏余弦。
|
||||
func (c *RelevanceContext) vectorForText(text string) ([]float64, bool) {
|
||||
if c.denseSpace == nil || !c.denseSpace.Loaded() {
|
||||
return nil, false
|
||||
}
|
||||
v, err := c.denseSpace.VectorizeDense(text)
|
||||
if err != nil || len(v) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// pageTexts 取某页的 call 侧与 result 侧正文(与 docBlocksForEntry 同构)。
|
||||
func (c *RelevanceContext) pageTexts(e *ContextEvent, page int) (call, result string) {
|
||||
if e == nil {
|
||||
return "", ""
|
||||
}
|
||||
if page == 1 {
|
||||
return e.Input, e.Response
|
||||
}
|
||||
idx := page - 2 // 第 1 页是 llm_round
|
||||
if idx < 0 || idx >= len(e.ToolCalls) {
|
||||
return "", ""
|
||||
}
|
||||
tc := &e.ToolCalls[idx]
|
||||
call = tc.Name
|
||||
if len(tc.Arguments) > 0 {
|
||||
if b, err := json.Marshal(tc.Arguments); err == nil {
|
||||
call = tc.Name + " " + string(b)
|
||||
}
|
||||
} else if tc.RawArguments != "" {
|
||||
call = tc.Name + " " + tc.RawArguments
|
||||
}
|
||||
// 配对结果
|
||||
if tc.CallID != "" {
|
||||
for i := range e.ToolResults {
|
||||
if e.ToolResults[i].CallID == tc.CallID {
|
||||
return call, e.ToolResults[i].Output
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range e.ToolResults {
|
||||
if e.ToolResults[i].Name == tc.Name {
|
||||
return call, e.ToolResults[i].Output
|
||||
}
|
||||
}
|
||||
return call, ""
|
||||
}
|
||||
|
||||
// PageCount 返回本事件的页数(1 个 llm_round + N 个 tool_round)。
|
||||
func (c *RelevanceContext) PageCount(e *ContextEvent) int {
|
||||
if e == nil {
|
||||
return 0
|
||||
}
|
||||
return 1 + len(e.ToolCalls)
|
||||
}
|
||||
|
||||
// evictPagesLocked 在单帧超预算时,按相似度剔除页(标注 inactive,不删数据)。
|
||||
//
|
||||
// ⚠️ 调用方**必须已持 c.mu**(它内部会 save/computeVector)。
|
||||
//
|
||||
// ## 用户裁定的规则
|
||||
//
|
||||
// 召回时对段内每个页计算相似度,单条段超限时,
|
||||
// 按相似度"剔除"相似度最低的页(toolcall 与其 result),
|
||||
// 实际标注为 inactive,不渲染为消息
|
||||
//
|
||||
// ## 三条不能违反的约束
|
||||
//
|
||||
// 1. **最小单位是页**:call 与 result 同进同出。只剔一侧会让渲染破配对,
|
||||
// 上游立刻 400(这不是洁癖,是硬协议要求)。
|
||||
// 2. **llm_round 永不剔除**:第 1 页是"用户说了什么 + 模型答了什么"。
|
||||
// 剔掉它,这一帧就只剩工具流水,模型无法理解上下文在讲什么。
|
||||
// 3. **剔除是标注**:数据留在原位,可随时恢复(InactivePages 清空即恢复)。
|
||||
//
|
||||
// 返回本次新剔除的页数。
|
||||
func (c *RelevanceContext) evictPagesLocked(e *ContextEvent, budgetTokens int, query string) int {
|
||||
if e == nil || budgetTokens <= 0 || query == "" {
|
||||
return 0
|
||||
}
|
||||
evicted := 0
|
||||
for {
|
||||
total := eventTokens(*e)
|
||||
if total <= budgetTokens {
|
||||
break
|
||||
}
|
||||
// 候选:所有仍生效的工具页(第 1 页受保护)
|
||||
type cand struct {
|
||||
page int
|
||||
score float64
|
||||
}
|
||||
var cands []cand
|
||||
for p := 2; p <= c.PageCount(e); p++ {
|
||||
if containsInt(e.InactivePages, p) {
|
||||
continue
|
||||
}
|
||||
cands = append(cands, cand{p, 0})
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
// 只剩 llm_round 仍超限 —— 不能靠剔页解决,
|
||||
// 交给上层(按帧归档 / 超页恢复)。这里明确停手,不空转。
|
||||
break
|
||||
}
|
||||
|
||||
queryDense, queryFP := c.queryDenseFor(query)
|
||||
queryVec := c.embedder.VectorizeClean(query)
|
||||
for i := range cands {
|
||||
cands[i].score = c.pageSimilarity(e, cands[i].page, queryDense, queryFP, queryVec)
|
||||
}
|
||||
sort.Slice(cands, func(i, j int) bool { return cands[i].score < cands[j].score })
|
||||
|
||||
victim := cands[0].page
|
||||
e.InactivePages = append(e.InactivePages, victim)
|
||||
sort.Ints(e.InactivePages)
|
||||
evicted++
|
||||
log.Printf("[context] frame %s over budget (%d > %d): evicted page %d "+
|
||||
"(score=%.3f, %d pages remain active)",
|
||||
e.ID, total, budgetTokens, victim, cands[0].score, c.PageCount(e)-len(e.InactivePages))
|
||||
}
|
||||
if evicted > 0 {
|
||||
// 剔除改变了这一帧的渲染内容 ⇒ 向量必须重算。
|
||||
// 不重算的话,下一轮召回仍按"整帧含被剔页"的向量打分,
|
||||
// 剔除等于没生效(相似度还带着已隐藏内容的分量)。
|
||||
c.computeVector(e)
|
||||
c.save()
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 召回期的「先估算、再剔除」(用户裁定,2026-10-29)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// estimateColdEventTokens 估算一条 coldcontext(L2)记录回灌成事件后占多少 token。
|
||||
//
|
||||
// ★ 为什么按"渲染后"算而不是按 JSON 体积:
|
||||
// 回灌进 L1 的是**渲染出来的消息**(用户行 + 助手字 + 展开的工具行),
|
||||
// 而 L2 的存储形态(块、字段)与它并不等长。
|
||||
// 按存储体积估算会系统性偏离 —— 归档压缩过的文档会被低估,
|
||||
// 于是"以为装得下、实际装不下"。
|
||||
func estimateColdEventTokens(d *coldcontext.ColdDoc) int {
|
||||
if d == nil {
|
||||
return 0
|
||||
}
|
||||
if len(d.Events) == 0 {
|
||||
// 存量文档(无 Events)回灌成一条降级事件
|
||||
return EstimateTokens(d.Content) + 40
|
||||
}
|
||||
total := 0
|
||||
for i := range d.Events {
|
||||
ev := &d.Events[i]
|
||||
input, response, _ := ev.InOut()
|
||||
// 与 eventTokens 同构:input + response + 工具输出
|
||||
n := EstimateTokens(input) + EstimateTokens(response) + 40
|
||||
for _, tr := range ev.ToolResults {
|
||||
n += EstimateTokens(tr.Output)
|
||||
}
|
||||
// 工具页的 call 侧(参数)也要渲染
|
||||
for _, tc := range ev.ToolCalls {
|
||||
n += EstimateTokens(tc.Name)
|
||||
if len(tc.Arguments) > 0 {
|
||||
if b, err := json.Marshal(tc.Arguments); err == nil {
|
||||
n += EstimateTokens(string(b))
|
||||
}
|
||||
}
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ColdRecallPlan 是 coldcontext 召回的执行计划:哪些整篇召回、哪些要剔页、剔哪些。
|
||||
type ColdRecallPlan struct {
|
||||
// Docs 是本次要回灌的文档(按相关度降序)。
|
||||
Docs []*coldcontext.ColdDoc
|
||||
// Evict 记录每篇要剔除的页序号(key = 文档 ID)。
|
||||
//
|
||||
// 空 map 表示无需剔除(整批装得下)。
|
||||
Evict map[string][]int
|
||||
// OriginalNeed 是剔除**前**整批估算需要的 token(用于日志对比)。
|
||||
OriginalNeed int
|
||||
// Need 是剔除后(或无需剔除时)的 token 需求。
|
||||
Need int
|
||||
// Budget 是可用预算。
|
||||
Budget int
|
||||
// Over 表示是否发生了剔除。
|
||||
Over bool
|
||||
}
|
||||
|
||||
// planColdRecall 估算整批 coldcontext 召回的 token,超预算则按页相似度剔除。
|
||||
//
|
||||
// ## 用户裁定的算法(逐字)
|
||||
//
|
||||
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
|
||||
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
|
||||
// 直到不超预算
|
||||
//
|
||||
// ## 三个要害
|
||||
//
|
||||
// 1. **先估算再动手**:装得下就一个字都不剔。剔除是有代价的
|
||||
// (模型看到的是残缺上下文),不该在有余量时发生。
|
||||
// 2. **跨帧剔除,全局取舍**:不是"每篇自己管自己装不装得下",
|
||||
// 而是把整批候选放在一个池子里,每次剔**全局最不相似**的那一页。
|
||||
// 否则会出现「A 篇剔得只剩骨架、B 篇一字未动」的荒谬结果。
|
||||
// 3. **第 1 页(llm_round)不参与剔除**:那是"用户说了什么+模型答了什么",
|
||||
// 剔掉这一帧就只剩工具流水,模型无法理解它在讲什么。
|
||||
//
|
||||
// 返回的计划由调用方执行(本函数**不改任何状态**,纯计算)。
|
||||
func (c *RelevanceContext) planColdRecall(docs []*coldcontext.ColdDoc, query string, budget int) ColdRecallPlan {
|
||||
plan := ColdRecallPlan{Docs: docs, Budget: budget, Evict: map[string][]int{}}
|
||||
|
||||
if len(docs) == 0 || budget <= 0 {
|
||||
return plan
|
||||
}
|
||||
// ① 估算整批
|
||||
for _, d := range docs {
|
||||
plan.Need += estimateColdEventTokens(d)
|
||||
}
|
||||
plan.OriginalNeed = plan.Need
|
||||
if plan.Need <= budget {
|
||||
return plan // 装得下:一个字都不剔
|
||||
}
|
||||
|
||||
// ② 超预算:把所有「可剔的页」摊到一个池子里
|
||||
type pageCand struct {
|
||||
docIdx int
|
||||
eventIdx int
|
||||
page int
|
||||
tokens int
|
||||
score float64
|
||||
}
|
||||
queryDense, queryFP := c.queryDenseFor(query)
|
||||
queryVec := c.embedder.VectorizeClean(query)
|
||||
|
||||
var pool []pageCand
|
||||
for di, d := range docs {
|
||||
if d == nil || len(d.Events) == 0 {
|
||||
continue // 存量文档没有页结构,无法剔
|
||||
}
|
||||
for ei := range d.Events {
|
||||
ev := &d.Events[ei]
|
||||
// 第 1 页(llm_round)永不可剔
|
||||
for page := 2; page <= 1+len(ev.ToolCalls); page++ {
|
||||
pool = append(pool, pageCand{
|
||||
docIdx: di,
|
||||
eventIdx: ei,
|
||||
page: page,
|
||||
tokens: pageTokens(ev, page),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return plan // 没有可剔的页:交给上层(提示超限或整篇放弃)
|
||||
}
|
||||
|
||||
// ③ 算相似度(只算一次,避免循环里重复向量化)
|
||||
for i := range pool {
|
||||
d := docs[pool[i].docIdx]
|
||||
pool[i].score = coldPageSimilarity(c, d, pool[i].eventIdx, pool[i].page,
|
||||
queryDense, queryFP, queryVec)
|
||||
}
|
||||
|
||||
// ④ 从最不相似开始剔,直到装得下
|
||||
sort.Slice(pool, func(i, j int) bool { return pool[i].score < pool[j].score })
|
||||
need := plan.Need
|
||||
for _, p := range pool {
|
||||
if need <= budget {
|
||||
break
|
||||
}
|
||||
d := docs[p.docIdx]
|
||||
plan.Evict[d.ID] = append(plan.Evict[d.ID], p.page)
|
||||
need -= p.tokens
|
||||
plan.Over = true
|
||||
}
|
||||
plan.Need = need
|
||||
return plan
|
||||
}
|
||||
|
||||
// recallBudget 返回本次召回可用的 token 预算。
|
||||
//
|
||||
// 取 ContextTokens 减去**当前时间线已占用**:
|
||||
// 召回内容与已有事件要一起渲染进同一个窗口,
|
||||
// 只看整窗会在"加上原有内容"之后才发现爆了。
|
||||
//
|
||||
// 下限保护:至少给 1/4 的 ContextTokens。
|
||||
// 否则时间线已接近满窗时,预算变成 0 或负数 ——
|
||||
// 那样**任何**召回都会被判超预算而全部剔空,等于召不回来。
|
||||
func (a *Agent) recallBudget() int {
|
||||
if a == nil || a.context == nil {
|
||||
return 0
|
||||
}
|
||||
tb := a.computeTokenBudget()
|
||||
avail := tb.ContextTokens - a.accumulatedTokens()
|
||||
floor := tb.ContextTokens / 4
|
||||
if avail < floor {
|
||||
avail = floor
|
||||
}
|
||||
if avail < 0 {
|
||||
avail = 0
|
||||
}
|
||||
return avail
|
||||
}
|
||||
|
||||
// countPlanPages 统计计划里被剔除的页数。
|
||||
func countPlanPages(evict map[string][]int) int {
|
||||
n := 0
|
||||
for _, pages := range evict {
|
||||
n += len(pages)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// pageTokens 估算一页渲染后的 token(call 侧 + result 侧)。
|
||||
func pageTokens(ev *coldcontext.ColdEvent, page int) int {
|
||||
if page == 1 {
|
||||
in, resp, _ := ev.InOut()
|
||||
return EstimateTokens(in) + EstimateTokens(resp)
|
||||
}
|
||||
idx := page - 2
|
||||
if idx < 0 || idx >= len(ev.ToolCalls) {
|
||||
return 0
|
||||
}
|
||||
tc := &ev.ToolCalls[idx]
|
||||
n := EstimateTokens(tc.Name)
|
||||
if len(tc.Arguments) > 0 {
|
||||
if b, err := json.Marshal(tc.Arguments); err == nil {
|
||||
n += EstimateTokens(string(b))
|
||||
}
|
||||
}
|
||||
if res, ok := coldToolResult(ev, tc); ok {
|
||||
n += EstimateTokens(res)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// coldToolResult 按 CallID(退化到名字)配对工具结果。
|
||||
func coldToolResult(ev *coldcontext.ColdEvent, tc *coldcontext.ToolCallItem) (string, bool) {
|
||||
if tc.CallID != "" {
|
||||
for i := range ev.ToolResults {
|
||||
if ev.ToolResults[i].CallID == tc.CallID {
|
||||
return ev.ToolResults[i].Output, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range ev.ToolResults {
|
||||
if ev.ToolResults[i].Name == tc.Name {
|
||||
return ev.ToolResults[i].Output, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// coldPageSimilarity 算 coldcontext 某一页与查询的相似度(页内两侧取最大值)。
|
||||
//
|
||||
// ★ 页由 (eventIdx, pageSeq) 唯一确定,**不能只用 pageSeq**。
|
||||
//
|
||||
// 第一版只传 page 序号、遍历所有事件取最大值,于是同一文档里
|
||||
// 多个事件的"第 2 页"被混成一个分数(跨事件串味)。判据实测:
|
||||
// 6 个工具页拿到完全相同的 0.5774 —— 那是第 1 页 llm_round 的分数
|
||||
// 反复覆盖的结果,页与页之间完全没有区分度,剔除变成随机。
|
||||
func coldPageSimilarity(c *RelevanceContext, d *coldcontext.ColdDoc, eventIdx, page int,
|
||||
queryDense []float64, queryFP string, queryVec vector.Vector) float64 {
|
||||
|
||||
if d == nil || eventIdx < 0 || eventIdx >= len(d.Events) {
|
||||
return 0
|
||||
}
|
||||
ev := &d.Events[eventIdx]
|
||||
best := math.Inf(-1)
|
||||
score := func(text string) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return
|
||||
}
|
||||
if len(queryDense) > 0 && queryFP != "" {
|
||||
if v, ok := c.vectorForText(text); ok && len(v) == len(queryDense) {
|
||||
if s := vector.DenseCosine(queryDense, v); s > best {
|
||||
best = s
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if s := vector.CosineSimilarity(queryVec, c.embedder.VectorizeClean(text)); s > best {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
if page == 1 {
|
||||
in, resp, _ := ev.InOut()
|
||||
score(in)
|
||||
score(resp)
|
||||
} else {
|
||||
idx := page - 2
|
||||
if idx < 0 || idx >= len(ev.ToolCalls) {
|
||||
return 0
|
||||
}
|
||||
tc := &ev.ToolCalls[idx]
|
||||
score(tc.Name)
|
||||
if len(tc.Arguments) > 0 {
|
||||
if b, err := json.Marshal(tc.Arguments); err == nil {
|
||||
score(string(b))
|
||||
}
|
||||
}
|
||||
if res, ok := coldToolResult(ev, tc); ok {
|
||||
score(res)
|
||||
}
|
||||
}
|
||||
if math.IsInf(best, -1) {
|
||||
return 0
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// queryDenseFor 取查询的稠密向量(与 PruneWithProtected 同构)。
|
||||
func (c *RelevanceContext) queryDenseFor(query string) ([]float64, string) {
|
||||
if c.denseSpace == nil || !c.denseSpace.Loaded() {
|
||||
return nil, ""
|
||||
}
|
||||
dv, err := c.denseSpace.VectorizeDense(query)
|
||||
if err != nil || len(dv) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
return dv, c.denseSpace.Fingerprint()
|
||||
}
|
||||
|
||||
func containsInt(s []int, v int) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。
|
||||
func (c *RelevanceContext) ProtectedCount() int {
|
||||
if c == nil || c.protectedCount <= 0 {
|
||||
@ -349,7 +894,7 @@ func (c *RelevanceContext) inputChannelClean(source, input string) string {
|
||||
}
|
||||
|
||||
// channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。
|
||||
func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
|
||||
func (c *RelevanceContext) channelCleanerForDoc() coldcontext.ChannelCleaner {
|
||||
if c.channelDefLookup == nil {
|
||||
return nil
|
||||
}
|
||||
@ -511,15 +1056,37 @@ type scoredEvent struct {
|
||||
|
||||
// pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传
|
||||
// effectiveProtectedCount,按预算收紧后的值。
|
||||
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
|
||||
return c.PruneWithProtected(currentInput, topK, docStore, 0)
|
||||
func (c *RelevanceContext) Prune(currentInput string, topK int, coldcontextStore *coldcontext.Store) int {
|
||||
return c.PruneWithProtected(currentInput, topK, coldcontextStore, 0)
|
||||
}
|
||||
|
||||
// resetInactivePagesLocked 清空所有事件的剔除标记(调用方须持锁)。
|
||||
//
|
||||
// 剔除是渲染期的临时决定,不是记忆属性 —— 见 ContextEvent.InactivePages 的注释。
|
||||
func (c *RelevanceContext) resetInactivePagesLocked() {
|
||||
for _, e := range c.events {
|
||||
if e != nil && len(e.InactivePages) > 0 {
|
||||
e.InactivePages = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PruneWithProtected 是带显式保护条数的 Prune。
|
||||
func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, docStore *document.Store, pCount int) int {
|
||||
func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, coldcontextStore *coldcontext.Store, pCount int) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// ★ 每次 Prune 都重置剔除标记(用户裁定,2026-10-29)。
|
||||
//
|
||||
// 为什么必须重置:InactivePages 是"本次渲染在有限窗口里少渲染几页"的
|
||||
// 临时决定,它依赖当时的**会话窗口状态**(哪些帧在做、查询是什么、
|
||||
// 预算还剩多少)。Prune 之后这些都变了 —— 沿用旧标记就是把
|
||||
// 上一轮的预算压力带到这一轮,于是某个帧"永久性"缺几页。
|
||||
//
|
||||
// 放在函数最前面(包括提前返回的那条路径之前):只要 Prune 被调用,
|
||||
// 就说明窗口被重新规划过,旧标记一律失效。
|
||||
c.resetInactivePagesLocked()
|
||||
|
||||
if len(c.events) <= topK {
|
||||
return 0
|
||||
}
|
||||
@ -588,7 +1155,7 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
|
||||
})
|
||||
|
||||
archived := 0
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
if coldcontextStore != nil && len(archive) > 0 {
|
||||
// ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。
|
||||
//
|
||||
// 曾经在这里跳过它们(理由是"防自激"),那是错的:
|
||||
@ -598,19 +1165,20 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
|
||||
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——
|
||||
// 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。
|
||||
}
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
entries := make([]document.ContextEntry, len(archive))
|
||||
if coldcontextStore != nil && len(archive) > 0 {
|
||||
entries := make([]coldcontext.ContextEntry, len(archive))
|
||||
for i, s := range archive {
|
||||
entries[i] = document.ContextEntry{
|
||||
entries[i] = coldcontext.ContextEntry{
|
||||
Timestamp: s.event.Timestamp,
|
||||
Source: s.event.Source,
|
||||
Content: s.event.Input,
|
||||
Response: s.event.Response,
|
||||
ToolResults: convertToolResults(s.event.ToolResults),
|
||||
ToolCalls: toolCallItemsToDoc(s.event.ToolCalls),
|
||||
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
|
||||
}
|
||||
}
|
||||
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
|
||||
if err == nil && doc != nil {
|
||||
archived = len(entries)
|
||||
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
|
||||
@ -715,13 +1283,51 @@ func (c *RelevanceContext) Len() int {
|
||||
return len(c.events)
|
||||
}
|
||||
|
||||
func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
||||
// toolCallItemsToDoc 把 L1 的工具调用记录转成 L2 类型。
|
||||
//
|
||||
// ★ 名字刻意不与 process.go 的 convertToolCalls 冲突:那个是
|
||||
// api.ToolCall → sdk.ToolCall(跨进程边界),本函数是 L1 → L2(跨记忆层)。
|
||||
// 两者语义不同,共用名字会让调用点看错。
|
||||
//
|
||||
// 必须逐字段搬运(而不是忽略):L2 缺参数的话,回灌后「这一页的 call 侧
|
||||
// 是空的」,段页式的配对校验与页化都失效。
|
||||
func toolCallItemsToDoc(in []ToolCallItem) []coldcontext.ToolCallItem {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]coldcontext.ToolCallItem, len(in))
|
||||
for i, tc := range in {
|
||||
out[i] = coldcontext.ToolCallItem{
|
||||
CallID: tc.CallID,
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
RawArguments: tc.RawArguments,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// convertToolResults 把 L1 的工具结果转成 L2 类型。
|
||||
//
|
||||
// ★ CallID 必须搬(2026-10-29 修)。
|
||||
//
|
||||
// 此前只搬 Name/Output,于是**归档一次就永久丢掉配对键**:
|
||||
// 召回回填后 ToolResults 的 CallID 全空,页化与配对校验都失效
|
||||
// (端到端判据 TestSegPagingRoundTripsThroughL2 实测抓到了这个)。
|
||||
//
|
||||
// 这类错误的共同点是「加字段时只改结构、不改搬运函数」——
|
||||
// 结构体里字段看起来都在,真跑一遍才发现在某一段路上被丢了。
|
||||
func convertToolResults(items []ToolResultItem) []coldcontext.ToolResultItem {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]document.ToolResultItem, len(items))
|
||||
result := make([]coldcontext.ToolResultItem, len(items))
|
||||
for i, item := range items {
|
||||
result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output}
|
||||
result[i] = coldcontext.ToolResultItem{
|
||||
CallID: item.CallID,
|
||||
Name: item.Name,
|
||||
Output: item.Output,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ func TestContextPruneWithDocStore(t *testing.T) {
|
||||
|
||||
archived := ctx.Prune("天气", 10, nil)
|
||||
if archived != 0 {
|
||||
t.Errorf("with nil docStore, archived should be 0, got %d", archived)
|
||||
t.Errorf("with nil coldcontextStore, archived should be 0, got %d", archived)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// CrossModalHit 是跨模态检索融合后的一条候选。
|
||||
@ -21,11 +21,11 @@ import (
|
||||
// memory.RecallBlocks(读块自己的向量),因此不存在「召回已无处可归的
|
||||
// 内容」这回事——能召回的必然是被某层持有的块。
|
||||
type CrossModalHit struct {
|
||||
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
Doc *coldcontext.ColdDoc // 文本路命中的文档;视觉路命中时为 nil
|
||||
Media *memory.MemoryBlock // 视觉路命中的媒体块;文本路命中时也可能带关联块
|
||||
MediaScore float64 // 视觉路原始 cosine(无则 0)
|
||||
DocScore float64 // 文本路原始 cosine(无则 0)
|
||||
Fused float64 // 归一化加权融合分,供最终排序
|
||||
// 该媒体同时被两路命中时 DoubleHit=true —— 双信号确认,应排在只被
|
||||
// 一路命中的候选之前。
|
||||
DoubleHit bool
|
||||
@ -88,8 +88,8 @@ func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionC
|
||||
|
||||
// ---- 文本路 ----
|
||||
var textHits []CrossModalHit
|
||||
if a.docStore != nil {
|
||||
for _, dh := range a.docStore.QueryScored(query, per) {
|
||||
if a.coldcontextStore != nil {
|
||||
for _, dh := range a.coldcontextStore.QueryScored(query, per) {
|
||||
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
|
||||
// 命中文档若持有一等记忆块,把首个媒体块一并带上(用于双命中标记)。
|
||||
for i := range dh.Doc.Blocks {
|
||||
@ -322,7 +322,7 @@ func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
// firstRealSource 取事件里第一个非内部标记的来源。
|
||||
//
|
||||
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
|
||||
func firstRealSource(events []document.DocEvent) string {
|
||||
func firstRealSource(events []coldcontext.ColdEvent) string {
|
||||
for _, ev := range events {
|
||||
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
|
||||
return ev.Source
|
||||
@ -342,7 +342,7 @@ func firstRealSource(events []document.DocEvent) string {
|
||||
// 顺序:事件正文(块里的文本)→ 工具回执 → 文档 Content。
|
||||
// 摘要行("已回复…")不在这里出现——它由 docHeadlineForPrompt 负责,
|
||||
// 两者互补:一个说"发生了什么",一个说"内容是什么"。
|
||||
func docBodyExcerptForPrompt(d *document.Doc, n int) string {
|
||||
func docBodyExcerptForPrompt(d *coldcontext.ColdDoc, n int) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
@ -438,7 +438,7 @@ func collectJSONStrings(v interface{}, depth int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func docHeadlineForPrompt(d *document.Doc) string {
|
||||
func docHeadlineForPrompt(d *coldcontext.ColdDoc) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -12,17 +12,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
func TestDocBodyExcerptPicksToolResult(t *testing.T) {
|
||||
d := &document.Doc{
|
||||
Events: []document.DocEvent{{
|
||||
d := &coldcontext.ColdDoc{
|
||||
Events: []coldcontext.ColdEvent{{
|
||||
Timestamp: time.Now(),
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{Modality: "text", Text: "已回复老大:原来是这个谐音梗。"},
|
||||
},
|
||||
ToolResults: []document.ToolResultItem{
|
||||
ToolResults: []coldcontext.ToolResultItem{
|
||||
{Name: "qq_get_message", Output: `{"content":"因为魔芋爽","message_id":123}`},
|
||||
},
|
||||
}},
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/nlp"
|
||||
)
|
||||
@ -22,7 +22,7 @@ type ConsolidationTask struct {
|
||||
|
||||
func (a *Agent) enqueueConsolidationTask(task ConsolidationTask) {
|
||||
msg := fmt.Sprintf(
|
||||
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 memory_merge 合并实体,或使用 memory_block_merge 标记不合并\n2. 不要使用 memory_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
|
||||
"【记忆整理任务】\n类型: %s\n说明: %s\n\n注意:\n1. 仅使用 stablemem_merge 合并实体,或使用 stablemem_block_merge 标记不合并\n2. 不要使用 stablemem_commit 写入新的三元组\n3. 不要从这段任务文本中提取任何信息写入图库\n4. 只需要做出合并/不合并的判断并执行对应工具",
|
||||
task.Type, task.Reason,
|
||||
)
|
||||
a.injectSelf(msg)
|
||||
@ -42,7 +42,7 @@ func (a *Agent) distillLoop() {
|
||||
go a.distillLoop()
|
||||
}
|
||||
}()
|
||||
if a.docStore == nil {
|
||||
if a.coldcontextStore == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(a.distillInterval)
|
||||
@ -144,12 +144,12 @@ func (a *Agent) reviewLoop() {
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) distillContext() {
|
||||
if a.docStore == nil {
|
||||
if a.coldcontextStore == nil {
|
||||
return
|
||||
}
|
||||
n := a.context.Len()
|
||||
if n > a.maxContextSize*2 {
|
||||
archived := a.context.Prune("", a.maxContextSize, a.docStore)
|
||||
archived := a.context.Prune("", a.maxContextSize, a.coldcontextStore)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] distill: pruned %d low-relevance events to document memory (total=%d)", archived, n)
|
||||
}
|
||||
@ -157,7 +157,7 @@ func (a *Agent) distillContext() {
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 冷文档归档:docStore → GraphDB (L3→L4)
|
||||
// 冷文档归档:coldcontextStore → GraphDB (L3→L4)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) archiveColdDocs() {
|
||||
@ -165,7 +165,7 @@ func (a *Agent) archiveColdDocs() {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[agent] cold doc archival start")
|
||||
log.Printf("[agent] coldcontext archival start")
|
||||
|
||||
if a.indexer != nil {
|
||||
if err := a.indexer.Sync(); err != nil {
|
||||
@ -186,14 +186,14 @@ func (a *Agent) archiveColdDocs() {
|
||||
}
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
a.docStore.Reindex()
|
||||
if a.coldcontextStore != nil {
|
||||
a.coldcontextStore.Reindex()
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
|
||||
for _, doc := range coldDocs {
|
||||
triples := docToTriples(doc, a.embedder)
|
||||
if a.coldcontextStore != nil {
|
||||
coldDocs := a.coldcontextStore.FindColdContexts(72*time.Hour, 2)
|
||||
for _, coldDoc := range coldDocs {
|
||||
triples := coldDocToTriples(coldDoc, a.embedder)
|
||||
if len(triples) == 0 {
|
||||
continue
|
||||
}
|
||||
@ -203,11 +203,11 @@ func (a *Agent) archiveColdDocs() {
|
||||
// 「有没有真的写进图库」只剩块数这一个信号。
|
||||
blocksBefore, err := a.memory.MemoryBlockCount()
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err)
|
||||
log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
|
||||
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, coldDoc.Blocks)
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc→graph archival error: %v", err)
|
||||
continue
|
||||
@ -234,13 +234,13 @@ func (a *Agent) archiveColdDocs() {
|
||||
// ★★ 不能用 blocks 判据(2026-10-04 修正)
|
||||
//
|
||||
// 我第一版改成 `if blocks == 0 { 保留文档 }`,结果文档永不被删
|
||||
// —— 因为 **blocks 恒为 0**:docToTriples 产出的三元组
|
||||
// —— 因为 **blocks 恒为 0**:coldDocToTriples 产出的三元组
|
||||
// 没有 SentenceText(也没有 MediaDigests),
|
||||
// 而 blocks 只统计「按原句挂接的媒体块」。
|
||||
// 文档的媒体块走的是下面的 linkBlocksToDocument,不是这条路。
|
||||
//
|
||||
// ★ 真正的判据在下面:linkBlocksToDocument 的绑定数。
|
||||
// 本文档没有块时(len(doc.Blocks) == 0),
|
||||
// 本文档没有块时(len(coldDoc.Blocks) == 0),
|
||||
// 「三元组有没有写进图库」才是唯一的问题 ——
|
||||
// 而那要看**块侧**有没有新增节点。
|
||||
//
|
||||
@ -257,14 +257,14 @@ func (a *Agent) archiveColdDocs() {
|
||||
//
|
||||
// blocks 只统计 attachBlocksToSentenceBlock(按原句挂接媒体块),
|
||||
// 而归档路径上的媒体块**设计上就不走那条路**:
|
||||
// docToTriples 明确不把媒体放进三元组
|
||||
// coldDocToTriples 明确不把媒体放进三元组
|
||||
// (见其注释「媒体不再参与三元组」—— 那是准确的设计,不是遗留),
|
||||
// 媒体由 linkBlocksToDocument 以 document --contains--> block
|
||||
// 写入 L3。所以 blocks 在归档路径上**本来就该是 0**。
|
||||
//
|
||||
// ★★ 记一笔我在这里犯的错(2026-10-04):
|
||||
// 我把那句准确的设计注释读成「过时注释」,
|
||||
// 于是去查「docToTriples 的三元组没有 SentenceText」,
|
||||
// 于是去查「coldDocToTriples 的三元组没有 SentenceText」,
|
||||
// 把它当成「媒体块无处挂接的功能缺口」。
|
||||
// —— 而没有 SentenceText 正是设计本意。
|
||||
// 更糟的是我把这个错误判断写进了本注释,
|
||||
@ -272,34 +272,34 @@ func (a *Agent) archiveColdDocs() {
|
||||
//
|
||||
// ★ 可靠信号是**块总数是否增长**。三条路径任一成功都会加块:
|
||||
//
|
||||
// doc.Blocks → linkBlocksToDocument(媒体块)
|
||||
// coldDoc.Blocks → linkBlocksToDocument(媒体块)
|
||||
// triples 的实体名 → putTripleBlocksTx(subject/object 块)
|
||||
// triples 的原句 → 原句块
|
||||
//
|
||||
blocksAfter, err := a.memory.MemoryBlockCount()
|
||||
if err != nil {
|
||||
log.Printf("[agent] doc->graph: %s failed to read block count, keeping doc: %v", doc.ID, err)
|
||||
log.Printf("[agent] coldcontext->graph: %s failed to read block count, keeping doc: %v", coldDoc.ID, err)
|
||||
continue
|
||||
}
|
||||
if blocksAfter == blocksBefore {
|
||||
log.Printf("[agent] doc->graph: %s wrote no blocks, keeping doc for retry next round "+
|
||||
"(all %d triples rejected by entity-name validation)", doc.ID, len(triples))
|
||||
log.Printf("[agent] coldcontext->graph: %s wrote no blocks, keeping doc for retry next round "+
|
||||
"(all %d triples rejected by entity-name validation)", coldDoc.ID, len(triples))
|
||||
continue
|
||||
}
|
||||
log.Printf("[agent] doc->graph: %s -> added %d blocks (%d triples, "+
|
||||
log.Printf("[agent] coldcontext->graph: %s -> added %d blocks (%d triples, "+
|
||||
"%d media attachments, legacy table counters disabled ec=%d rc=%d)",
|
||||
doc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc)
|
||||
coldDoc.ID, blocksAfter-blocksBefore, len(triples), blocks, ec, rc)
|
||||
|
||||
// 文档持有的一等块写入 L3,并以 document --contains--> block 边关联;
|
||||
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
|
||||
if len(doc.Blocks) > 0 {
|
||||
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) {
|
||||
log.Printf("[agent] doc->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round",
|
||||
doc.ID, bound, len(doc.Blocks))
|
||||
if len(coldDoc.Blocks) > 0 {
|
||||
if bound := a.linkBlocksToDocument(coldDoc.ID, coldDoc.Blocks, memory.ChannelScene(coldDoc.Source)); bound != len(coldDoc.Blocks) {
|
||||
log.Printf("[agent] coldcontext->graph: %s block migration incomplete (%d/%d), keeping doc for retry next round",
|
||||
coldDoc.ID, bound, len(coldDoc.Blocks))
|
||||
continue
|
||||
}
|
||||
}
|
||||
a.docStore.Remove(doc.ID)
|
||||
a.coldcontextStore.Remove(coldDoc.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -438,7 +438,7 @@ func (a *Agent) reviewRelations() {
|
||||
a.enqueueConsolidationTask(ConsolidationTask{
|
||||
Type: "relation_review",
|
||||
Reason: fmt.Sprintf(
|
||||
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 memory_edit 工具",
|
||||
"【关系复审】原始句子: '%s'\n当前三元组: (%s → %s → %s) 置信度 %.2f\n请判断是否需要修正(如相对引用未解析、主宾颠倒、噪音三元组等),如需修正请用 stablemem_edit 工具",
|
||||
rel.SentenceText, rel.SourceName, rel.RelationType, rel.TargetName, rel.Confidence,
|
||||
),
|
||||
Data: map[string]interface{}{
|
||||
@ -524,30 +524,30 @@ func entitySimilarity(a, b string) float64 {
|
||||
return float64(intersect) / float64(union)
|
||||
}
|
||||
|
||||
func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
func coldDocToTriples(coldDoc *coldcontext.ColdDoc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
var triples []memory.Triple
|
||||
if doc == nil {
|
||||
if coldDoc == nil {
|
||||
return triples
|
||||
}
|
||||
|
||||
if doc.Source == "graph" || doc.Source == "" {
|
||||
if coldDoc.Source == "graph" || coldDoc.Source == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该
|
||||
// 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回,
|
||||
// 不必指望本轮措辞与它们字面重合。
|
||||
docScene := memory.ChannelScene(doc.Source)
|
||||
docScene := memory.ChannelScene(coldDoc.Source)
|
||||
|
||||
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
|
||||
isArchivedContext := coldDoc.Meta != nil && coldDoc.Meta["is_archived_context"] == "true"
|
||||
|
||||
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
|
||||
if !isArchivedContext && doc.Summary != "" && len([]rune(doc.Summary)) < 80 && !isTemplateSummary(doc.Summary) {
|
||||
if !isArchivedContext && coldDoc.Summary != "" && len([]rune(coldDoc.Summary)) < 80 && !isTemplateSummary(coldDoc.Summary) {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "主题",
|
||||
Object: doc.Summary,
|
||||
Object: coldDoc.Summary,
|
||||
ObjectType: "Topic",
|
||||
Confidence: 1.0,
|
||||
Scene: docScene,
|
||||
@ -562,7 +562,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
//
|
||||
// ★ 输入必须是**原文**,不能是视图。
|
||||
//
|
||||
// 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染
|
||||
// 旧代码抽的是 coldDoc.Content —— 那在 Events 引入前是对事件的压缩渲染
|
||||
// (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取,
|
||||
// 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。
|
||||
//
|
||||
@ -571,7 +571,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
if embedder != nil {
|
||||
e.SetEmbedder(embedder)
|
||||
}
|
||||
result := e.Extract(documentOriginalText(doc))
|
||||
result := e.Extract(coldcontextOriginalText(coldDoc))
|
||||
if result != nil {
|
||||
for _, nt := range result.Triples {
|
||||
mt := nlp.ToMemoryTriple(nt)
|
||||
@ -583,12 +583,12 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
|
||||
}
|
||||
|
||||
// 仅当来源非归档上下文且非空时写「来源」——归档文档写死模板三元组属于垃圾
|
||||
if doc.Source != "" && doc.Source != "context_archived" {
|
||||
if coldDoc.Source != "" && coldDoc.Source != "context_archived" {
|
||||
triples = append(triples, memory.Triple{
|
||||
Subject: "文档",
|
||||
SubjectType: "Concept",
|
||||
Relation: "来源",
|
||||
Object: doc.Source,
|
||||
Object: coldDoc.Source,
|
||||
ObjectType: "Source",
|
||||
Confidence: 1.0,
|
||||
Scene: docScene,
|
||||
@ -644,19 +644,19 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
|
||||
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
|
||||
}
|
||||
|
||||
// documentOriginalText 取文档的**原文**用于抽取/蒸馏。
|
||||
// coldcontextOriginalText 取文档的**原文**用于抽取/蒸馏。
|
||||
//
|
||||
// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。
|
||||
// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。
|
||||
func documentOriginalText(doc *document.Doc) string {
|
||||
if doc == nil {
|
||||
func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string {
|
||||
if coldDoc == nil {
|
||||
return ""
|
||||
}
|
||||
if !doc.HasEvents() {
|
||||
return doc.Content
|
||||
if !coldDoc.HasEvents() {
|
||||
return coldDoc.Content
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, ev := range doc.Events {
|
||||
for _, ev := range coldDoc.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
if in != "" {
|
||||
b.WriteString(in)
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// L3 图库的媒体绑定。
|
||||
@ -281,7 +281,7 @@ func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext
|
||||
}
|
||||
|
||||
// blockLabelsForDoc 渲染文档持有块的标签(MIME + 短块 ID),供 coldcontext_query 展示。
|
||||
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
|
||||
func (a *Agent) blockLabelsForDoc(d *coldcontext.ColdDoc) string {
|
||||
if d == nil || len(d.Blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// L3 图库媒体绑定测试(块 union 化之后)。
|
||||
@ -478,16 +478,16 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
a, _ := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
a.docStore = ds
|
||||
a.coldcontextStore = ds
|
||||
a.embedder = memory.NewStaticEmbedder()
|
||||
|
||||
b := mediaBlock("image/png", []byte("image bytes"))
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
ID: "doc_keep",
|
||||
Summary: strings.Repeat("超长摘要文本", 20), // >80 字触发长度门槛被跳过
|
||||
Content: "一段没有媒体标记的正文",
|
||||
@ -501,9 +501,9 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1,
|
||||
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
|
||||
// 于是 FindColdContexts(72h, 2) 一篇都找不到。插入后再改回来,
|
||||
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
for _, d := range ds.RecentColdContexts(10) {
|
||||
if d.ID == doc.ID {
|
||||
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
@ -513,7 +513,7 @@ func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
|
||||
|
||||
// 关键断言:块在、文档在
|
||||
held := false
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
for _, d := range ds.RecentColdContexts(10) {
|
||||
if d.ID == doc.ID && len(d.Blocks) > 0 {
|
||||
held = true
|
||||
}
|
||||
@ -529,16 +529,16 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
|
||||
a, g := newGraphMediaAgent(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
a.docStore = ds
|
||||
a.coldcontextStore = ds
|
||||
a.embedder = memory.NewStaticEmbedder()
|
||||
|
||||
b := mediaBlock("image/png", []byte("archived-image"))
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
ID: "doc_arch",
|
||||
Summary: "带图的冷文档",
|
||||
Content: "张三把三色带图交给了李四。",
|
||||
@ -548,7 +548,7 @@ func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range ds.RecentDocs(10) {
|
||||
for _, d := range ds.RecentColdContexts(10) {
|
||||
if d.ID == doc.ID {
|
||||
d.LastAccess = time.Now().Add(-200 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
@ -731,14 +731,14 @@ func TestSentenceIDsFromRelations(t *testing.T) {
|
||||
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
|
||||
// 文档持有的一等块把内容钉住;文档被删后块随之消失,字节随块同生灭。
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
|
||||
b := mediaBlock("image/png", []byte("doc image"))
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
ID: "doc_1", Summary: "带图的文档", Content: "正文",
|
||||
Blocks: []memory.MemoryBlock{b},
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
|
||||
)
|
||||
|
||||
@ -296,7 +296,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
|
||||
// coldcontext_commit with media stores blocks directly in cold context,
|
||||
// 并随 doc 一起持久化(块自带字节,不靠外部引用保活)。
|
||||
dir := t.TempDir()
|
||||
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -305,7 +305,7 @@ func TestDocCommit_StoresBlocks(t *testing.T) {
|
||||
b1 := mediaBlock("image/png", []byte("doc-one"))
|
||||
b2 := mediaBlock("image/png", []byte("doc-two"))
|
||||
|
||||
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}}
|
||||
doc := &coldcontext.ColdDoc{ID: "doc_x", Summary: "s", Content: "c", Blocks: []memory.MemoryBlock{b1, b2}}
|
||||
if err := ds.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -330,7 +330,7 @@ func TestBlockLabelsForDoc(t *testing.T) {
|
||||
b := mediaBlock("image/png", []byte("ctx-bytes"))
|
||||
|
||||
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
|
||||
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
|
||||
got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
|
||||
if !strings.Contains(got, shortID(b.ID)) {
|
||||
t.Errorf("标签应含短块 ID: %q", got)
|
||||
}
|
||||
@ -340,7 +340,7 @@ func TestBlockLabelsForDoc(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("无块时为空", func(t *testing.T) {
|
||||
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
|
||||
if got := a.blockLabelsForDoc(&coldcontext.ColdDoc{ID: "doc_x", Content: "普通正文"}); got != "" {
|
||||
t.Errorf("应返回空串,实际 %q", got)
|
||||
}
|
||||
})
|
||||
@ -375,7 +375,7 @@ func newToolTestAgent(t *testing.T) *Agent {
|
||||
}
|
||||
t.Cleanup(func() { g.Close() })
|
||||
|
||||
ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
@ -383,10 +383,10 @@ func newToolTestAgent(t *testing.T) *Agent {
|
||||
|
||||
emb := memory.NewStaticEmbedder("")
|
||||
a := &Agent{
|
||||
id: "tester",
|
||||
memory: g,
|
||||
docStore: ds,
|
||||
context: NewRelevanceContext("", emb),
|
||||
id: "tester",
|
||||
memory: g,
|
||||
coldcontextStore: ds,
|
||||
context: NewRelevanceContext("", emb),
|
||||
}
|
||||
return a
|
||||
}
|
||||
@ -400,7 +400,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
}
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
@ -445,7 +445,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
|
||||
@ -464,7 +464,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
|
||||
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
@ -505,7 +505,7 @@ func TestToolDocCommit_BindsMedia(t *testing.T) {
|
||||
t.Errorf("返回值应告知模型媒体已关联: %q", out)
|
||||
}
|
||||
|
||||
docs := a.docStore.RecentDocs(5)
|
||||
docs := a.coldcontextStore.RecentColdContexts(5)
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("文档未写入")
|
||||
}
|
||||
@ -563,7 +563,7 @@ func TestTools_UnknownRefDegrades(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
Name: "memory_commit",
|
||||
Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
|
||||
@ -86,7 +86,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
|
||||
|
||||
// ③ 读是并集:主库的实体与 temp 的实体都要看得到。
|
||||
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "memory_recall",
|
||||
ID: "c1", Name: "stablemem_recall",
|
||||
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
|
||||
}, nil)
|
||||
if !strings.Contains(got, "主记忆实体") {
|
||||
@ -110,8 +110,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
|
||||
// ① 整理类工具不进子的工具表(不是进去再报不可用)。
|
||||
names := toolNames(a)
|
||||
for _, banned := range []string{
|
||||
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||
"memory_purge", "memory_edit",
|
||||
"stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge",
|
||||
"stablemem_purge", "stablemem_edit",
|
||||
} {
|
||||
if hasTool(names, banned) {
|
||||
t.Fatalf("轻量内核不该声明整理类工具 %s:%v", banned, names)
|
||||
@ -124,8 +124,8 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
|
||||
|
||||
// ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。
|
||||
for _, tool := range []string{
|
||||
"memory_merge", "memory_delete_entity", "memory_block_merge",
|
||||
"memory_purge", "memory_edit", "memory_introspect",
|
||||
"stablemem_merge", "stablemem_delete_entity", "stablemem_block_merge",
|
||||
"stablemem_purge", "stablemem_edit", "stablemem_introspect",
|
||||
} {
|
||||
got := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "x", Name: tool,
|
||||
@ -163,7 +163,7 @@ func TestFullProfile_KeepsOrganizeFace(t *testing.T) {
|
||||
t.Fatal("根 agent 必须有图记忆共同面")
|
||||
}
|
||||
names := toolNames(a)
|
||||
if !hasTool(names, "memory_merge") {
|
||||
if !hasTool(names, "stablemem_merge") {
|
||||
t.Fatalf("根 agent 应保留整理类工具:%v", names)
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
@ -134,11 +134,11 @@ func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
|
||||
|
||||
// liveEnv 是一套完整但完全独立的 agent 运行环境。
|
||||
type liveEnv struct {
|
||||
agent *Agent
|
||||
io *agentIO.IOManager
|
||||
docStore *document.Store
|
||||
graph *memory.GraphDB
|
||||
dir string
|
||||
agent *Agent
|
||||
io *agentIO.IOManager
|
||||
coldcontextStore *coldcontext.Store
|
||||
graph *memory.GraphDB
|
||||
dir string
|
||||
}
|
||||
|
||||
// newLiveEnv 构造真 Agent:真 provider、真图库、真文档库。
|
||||
@ -174,11 +174,11 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
}
|
||||
t.Cleanup(func() { graph.Close() })
|
||||
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := coldcontextStore.Start(); err != nil {
|
||||
t.Fatalf("doc store: %v", err)
|
||||
}
|
||||
t.Cleanup(docStore.Stop)
|
||||
t.Cleanup(coldcontextStore.Stop)
|
||||
|
||||
io := agentIO.NewIOManager()
|
||||
|
||||
@ -189,7 +189,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
ProviderManager: pm,
|
||||
IO: io,
|
||||
Memory: graph,
|
||||
DocStore: docStore,
|
||||
DocStore: coldcontextStore,
|
||||
StageHost: NewStageHost(),
|
||||
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
|
||||
InputProcessing: types.InputProcessingConfig{},
|
||||
@ -206,7 +206,7 @@ func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
|
||||
}
|
||||
}()
|
||||
|
||||
return &liveEnv{agent: a, io: io, docStore: docStore, graph: graph, dir: dir}
|
||||
return &liveEnv{agent: a, io: io, coldcontextStore: coldcontextStore, graph: graph, dir: dir}
|
||||
}
|
||||
|
||||
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
|
||||
@ -311,14 +311,14 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
Response: "好的。",
|
||||
})
|
||||
}
|
||||
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
|
||||
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.coldcontextStore)
|
||||
t.Logf("Prune 归档 %d 条事件", archived)
|
||||
if archived == 0 {
|
||||
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
|
||||
}
|
||||
|
||||
docRefsFound := ""
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
for _, d := range env.coldcontextStore.RecentColdContexts(20) {
|
||||
for _, b := range d.Blocks {
|
||||
if b.ID == blockID {
|
||||
docRefsFound = d.ID
|
||||
@ -340,10 +340,10 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
|
||||
|
||||
// ── 阶段 5:archiveColdDocs 自动把块连到 L3 文档节点 ──
|
||||
//
|
||||
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// FindColdContexts(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
|
||||
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
|
||||
// 而不是替代它(commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
|
||||
for _, d := range env.docStore.RecentDocs(20) {
|
||||
for _, d := range env.coldcontextStore.RecentColdContexts(20) {
|
||||
if d.ID == docRefsFound {
|
||||
d.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
d.AccessCount = 0
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、
|
||||
@ -211,8 +211,8 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) {
|
||||
// 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。
|
||||
dir := t.TempDir()
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := coldcontextStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
@ -224,7 +224,7 @@ func TestPrune_NilDocStoreStillArchives(t *testing.T) {
|
||||
Input: "内容",
|
||||
})
|
||||
}
|
||||
if n := rc.Prune("查询", 5, docStore); n == 0 {
|
||||
if n := rc.Prune("查询", 5, coldcontextStore); n == 0 {
|
||||
t.Fatal("无媒体参与时归档也应正常")
|
||||
}
|
||||
}
|
||||
@ -275,8 +275,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
|
||||
// 这条路径不依赖任何引用账本。
|
||||
dir := t.TempDir()
|
||||
emb := memory.NewStaticEmbedder()
|
||||
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
coldcontextStore := coldcontext.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
|
||||
if err := coldcontextStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
|
||||
@ -296,7 +296,7 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
|
||||
if n := rc.Prune("完全不相关的查询", 5, coldcontextStore); n == 0 {
|
||||
t.Fatal("应有事件被归档")
|
||||
}
|
||||
|
||||
@ -305,8 +305,8 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
|
||||
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
|
||||
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
|
||||
// 要断言的是**媒体块原样迁移**,而不是块总数。
|
||||
var found *document.Doc
|
||||
for _, d := range docStore.RecentDocs(20) {
|
||||
var found *coldcontext.ColdDoc
|
||||
for _, d := range coldcontextStore.RecentColdContexts(20) {
|
||||
for _, b := range d.Blocks {
|
||||
if b.ID == block.ID {
|
||||
found = d
|
||||
|
||||
@ -60,6 +60,31 @@ func scrubEventSecrets(evt *ContextEvent) {
|
||||
logEvtHits("上下文工具结果 "+evt.ToolResults[i].Name, evts)
|
||||
}
|
||||
}
|
||||
// 工具调用参数:由模型给出,正常是别名,但插件工具可能被直接传明文。
|
||||
//
|
||||
// ★ 2026-10-29 补:ToolCalls 是新增的结构化字段,它不在 Input/Response/
|
||||
// ToolResults 那条文本闸的覆盖范围内 —— 不加这段就会**绕过整层脱敏**
|
||||
// 直接落盘。这正是「新增字段必须显式过闸」的典型例子。
|
||||
for i := range evt.ToolCalls {
|
||||
tc := &evt.ToolCalls[i]
|
||||
for k, v := range tc.Arguments {
|
||||
str, ok := v.(string)
|
||||
if !ok || str == "" {
|
||||
continue
|
||||
}
|
||||
if out, evts := s.Replace(str, "ctx_toolargs:"+tc.Name+"."+k, security.Patterns); len(evts) > 0 {
|
||||
tc.Arguments[k] = out
|
||||
logEvtHits("上下文工具参数 "+tc.Name+"."+k, evts)
|
||||
}
|
||||
}
|
||||
if tc.RawArguments != "" {
|
||||
if out, evts := s.Replace(tc.RawArguments, "ctx_toolargs_raw:"+tc.Name, security.Patterns); len(evts) > 0 {
|
||||
tc.RawArguments = out
|
||||
logEvtHits("上下文工具原始参数 "+tc.Name, evts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 一等记忆块:**文本**块同样可能带凭据(截图描述、文档正文、转写稿)。
|
||||
// 媒体块(image/video/audio 的 Data)不处理 —— 那是二进制负载,
|
||||
// 正则扫它既无意义也会损坏数据。
|
||||
|
||||
@ -54,7 +54,7 @@ func TestMemoryCommitReportsRealCounts(t *testing.T) {
|
||||
}
|
||||
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "c1", Name: "memory_commit",
|
||||
ID: "c1", Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{
|
||||
@ -117,7 +117,7 @@ func TestMemoryCommitRejectsStillSaysRejected(t *testing.T) {
|
||||
|
||||
// 空主语 ⇒ 实体名校验必然拒绝 ⇒ 真的什么都没写。
|
||||
out := a.executeMemoryTool(agentAPI.ToolCall{
|
||||
ID: "c2", Name: "memory_commit",
|
||||
ID: "c2", Name: "stablemem_commit",
|
||||
Arguments: map[string]interface{}{
|
||||
"triples": []interface{}{
|
||||
map[string]interface{}{"subject": "", "relation": "等于", "object": "x"},
|
||||
|
||||
@ -150,7 +150,7 @@ func (a *Agent) pruneByQuery(query string) int {
|
||||
return 0
|
||||
}
|
||||
topK := a.contextTopK()
|
||||
return a.context.PruneWithProtected(query, topK, a.docStore, a.effectiveProtectedCount())
|
||||
return a.context.PruneWithProtected(query, topK, a.coldcontextStore, a.effectiveProtectedCount())
|
||||
}
|
||||
|
||||
// pruneForResume 是按「指定保护条数」执行的裁剪,供任务恢复入口使用。
|
||||
@ -179,7 +179,7 @@ func (a *Agent) pruneForResume(query string, protected int) int {
|
||||
// 这里按**本帧保护条数**重新推导:先算 token 预算能装几条,
|
||||
// 再保证下限是 protected+1(否则 Prune 会把 protected 之外的裁光)。
|
||||
topK := a.contextTopKForProtected(protected)
|
||||
return a.context.PruneWithProtected(query, topK, a.docStore, protected)
|
||||
return a.context.PruneWithProtected(query, topK, a.coldcontextStore, protected)
|
||||
}
|
||||
|
||||
// contextTopKForProtected 是按**指定保护条数**推导保留条数的版本。
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// memoryPass 是「裁剪」与「召回」的唯一入口:两根正交轴,但共用同一份 query。
|
||||
@ -90,10 +90,10 @@ func TestBuildTaskMemoryContext_UsesCleanInput(t *testing.T) {
|
||||
func newMemoryPassAgent(t *testing.T) *Agent {
|
||||
t.Helper()
|
||||
return &Agent{
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
docStore: document.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
|
||||
context: newPruneableContext(15),
|
||||
maxContextSize: 4,
|
||||
indexer: newTestIndexer(t, "咖啡", "张三"),
|
||||
coldcontextStore: coldcontext.NewStore(filepath.Join(t.TempDir(), "docs"), memory.TokenizeWords),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -273,7 +273,7 @@ func resumeQueryFrom(f *TaskFrame) string {
|
||||
// handleContextOverflow 执行「裁剪 + L4 打断」。
|
||||
//
|
||||
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
|
||||
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore),
|
||||
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 coldcontextStore),
|
||||
// 把它放进 L4 任务里做只会多付一次完整任务调度开销;
|
||||
// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。
|
||||
//
|
||||
@ -292,6 +292,25 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ★ 段页式:先试**最轻的一档** —— 按页剔除(P5,2026-10-29)。
|
||||
//
|
||||
// 剔除是标注(InactivePages),不是删除也不是跨层迁移:
|
||||
// · 数据全在原位,随时可恢复(清空 InactivePages 即还原渲染)
|
||||
// · 帧不动、层不动,只是这一帧少渲染几个工具往返
|
||||
//
|
||||
// 为什么排在"整帧归档"(L4 空中断)之前:
|
||||
// 归档是不可逆的层间迁移,代价远大于"少渲染一页"。能靠剔除解决时
|
||||
// 就不该动层 —— 这正是"段页式"相对"整段淘汰"的收益所在。
|
||||
//
|
||||
// 为什么只剔工具页、永不剔 llm_round:第 1 页是"用户说了什么+模型答了
|
||||
// 什么",剔掉它这一帧就只剩工具流水,模型无法理解上下文在讲什么。
|
||||
// 详见 EvictPages 的注释。
|
||||
if evicted, reduced := a.evictPagesToBudget(query); evicted > 0 && reduced {
|
||||
log.Printf("[agent] context overflow: evicted %d page(s) across frames — "+
|
||||
"trimmed (not archived), no layer migration", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
// ★ 只发空中断,**不在这里裁剪**(2026-10-25 按原始设计修正)。
|
||||
//
|
||||
// 原设计:L4 空中断把当前任务抢占下来 ⇒ 任务边界处做语义相关性裁剪。
|
||||
@ -308,6 +327,49 @@ func (a *Agent) handleContextOverflow(query string, ratio float64) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// evictPagesToBudget 对**自身超预算的单个事件**做页级剔除。
|
||||
//
|
||||
// ## 触发条件
|
||||
//
|
||||
// 只处理"单条事件自己就超过预算"的情形(单条撑爆窗口)。
|
||||
// 全局超预算(很多条小事件加起来超)走 Prune/归档那条路 —— 那是对的:
|
||||
// 全局超限的病是"该遗忘的没忘",而单条撑爆的病是"这一帧里有水页",
|
||||
// 两者的药不同。
|
||||
//
|
||||
// ## 为什么 protected/预算取自 c.computeTokenBudget
|
||||
//
|
||||
// 剔除的唯一目的是让这一帧能被渲染进窗口。用与渲染同一套预算计算,
|
||||
// 避免"按 A 阈值剔除、按 B 阈值判定仍超"的自相矛盾。
|
||||
func (a *Agent) evictPagesToBudget(query string) (evicted int, reduced bool) {
|
||||
if a == nil || a.context == nil || query == "" {
|
||||
return 0, false
|
||||
}
|
||||
budget := a.computeTokenBudget().ContextTokens
|
||||
if budget <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
a.context.mu.Lock()
|
||||
defer a.context.mu.Unlock()
|
||||
for _, e := range a.context.events {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if eventTokens(*e) <= budget {
|
||||
continue
|
||||
}
|
||||
//
|
||||
// EvictPages 内部会调 c.save()/computeVector,而它们都需要锁,
|
||||
// 所以这里必须用已持锁的形态。为此 EvictPages 自身不取锁
|
||||
// (见其注释:调用方负责持锁),这里正是那个调用方。
|
||||
n := a.context.evictPagesLocked(e, budget, query)
|
||||
evicted += n
|
||||
if n > 0 {
|
||||
reduced = true
|
||||
}
|
||||
}
|
||||
return evicted, reduced
|
||||
}
|
||||
|
||||
// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
|
||||
func (a *Agent) markOverflowAborted(ratio float64, events int) {
|
||||
a.overflowStat.Lock()
|
||||
|
||||
@ -29,7 +29,7 @@ func newOverflowAgent(window, maxContextSize int, events int, chunkChars int) *A
|
||||
maxContextSize: maxContextSize,
|
||||
context: NewRelevanceContext("", nil),
|
||||
}
|
||||
// 固定向量,避免依赖稠密模型;也不进 docStore(nil ⇒ Prune 只重排不写盘)。
|
||||
// 固定向量,避免依赖稠密模型;也不进 coldcontextStore(nil ⇒ Prune 只重排不写盘)。
|
||||
for i := 0; i < events; i++ {
|
||||
a.context.Append(ContextEvent{
|
||||
Input: fmt.Sprintf("第%d批输入", i) + strings.Repeat("x", chunkChars),
|
||||
@ -688,7 +688,7 @@ func TestOverflow_恢复裁剪保护本帧现场(t *testing.T) {
|
||||
a.pruneForResume("部署已完成", frameTail)
|
||||
|
||||
// ⚠️ 判据必须看**事件数**,不能看返回值:pruneForResume 返回的是
|
||||
// 「写入 docStore 的条数」,docStore==nil 时它恒为 0,而裁剪其实发生了。
|
||||
// 「写入 coldcontextStore 的条数」,coldcontextStore==nil 时它恒为 0,而裁剪其实发生了。
|
||||
// (handleContextOverflow 的注释里记过同一个坑。)
|
||||
after := a.context.Len()
|
||||
// 保留条数必须覆盖被保护的部分:裁剪后条数不得少于 protected。
|
||||
|
||||
338
internal/agent/core/page_evict_e2e_test.go
Normal file
338
internal/agent/core/page_evict_e2e_test.go
Normal file
@ -0,0 +1,338 @@
|
||||
package core
|
||||
|
||||
// 页级剔除的 e2e 判据(P5,2026-10-29)。
|
||||
//
|
||||
// ## 用户裁定的规则(逐字)
|
||||
//
|
||||
// 召回时对段内每个页计算相似度,单条段超限时,
|
||||
// 按相似度"剔除"相似度最低的页(toolcall与其result),
|
||||
// 实际标注为 inactive,不渲染为消息
|
||||
//
|
||||
// ## 必须钉住的五件事
|
||||
//
|
||||
// ① 剔除是**标注**:数据不删、层不迁、随时可恢复
|
||||
// ② 最小单位是**页**:call 与 result 同进同出(只剔一侧 ⇒ 渲染破配对 ⇒ 400)
|
||||
// ③ llm_round **永不剔除**:否则这帧只剩工具流水,模型读不懂上下文
|
||||
// ④ 剔除后**留提示行**:模型要知道"这里原本有内容",不能静默少几行
|
||||
// ⑤ 铁律:同帧的其余页**不因剔除而迁移**(仍在同一层)
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
)
|
||||
|
||||
// mkFrame 造一条带 N 个工具往返的事件。
|
||||
func mkFrame(n int) *ContextEvent {
|
||||
ts := time.Date(2026, 10, 29, 18, 0, 0, 0, time.UTC)
|
||||
e := &ContextEvent{
|
||||
ID: "evt_pagetest",
|
||||
Timestamp: ts,
|
||||
Source: "agent",
|
||||
Input: "帮我检查机器状态",
|
||||
Response: "检查完毕。",
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
cid := "call_" + string(rune('a'+i))
|
||||
e.ToolCalls = append(e.ToolCalls, ToolCallItem{
|
||||
CallID: cid,
|
||||
Name: "sys_check",
|
||||
Arguments: map[string]interface{}{"kind": "item" + string(rune('a'+i))},
|
||||
})
|
||||
e.ToolResults = append(e.ToolResults, ToolResultItem{
|
||||
CallID: cid,
|
||||
Name: "sys_check",
|
||||
Output: strings.Repeat("检查结果数据 ", 200), // 很占 token
|
||||
})
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// ① + ② + ③ 剔除标注、整页、不碰 llm_round。
|
||||
func TestEvictPagesMarksWholePagesOnly(t *testing.T) {
|
||||
c := newTestContext(t)
|
||||
e := mkFrame(4)
|
||||
c.events = []*ContextEvent{e}
|
||||
|
||||
c.mu.Lock()
|
||||
n := c.evictPagesLocked(e, 500, "机器状态")
|
||||
c.mu.Unlock()
|
||||
|
||||
if n == 0 {
|
||||
t.Fatal("超预算但未剔除任何页")
|
||||
}
|
||||
if len(e.InactivePages) == 0 {
|
||||
t.Fatal("★ 未标注 inactive")
|
||||
}
|
||||
// ③ llm_round(第 1 页)永不出现在剔除列表
|
||||
for _, p := range e.InactivePages {
|
||||
if p == 1 {
|
||||
t.Fatalf("★ llm_round 被剔除了(剩下只有工具流水,模型读不懂上下文): %v",
|
||||
e.InactivePages)
|
||||
}
|
||||
if p < 2 || p > 1+len(e.ToolCalls) {
|
||||
t.Fatalf("剔除的页序号越界: %d(共 %d 页)", p, 1+len(e.ToolCalls))
|
||||
}
|
||||
}
|
||||
// ② 数据必须还在(标注不是删除)
|
||||
if len(e.ToolCalls) != 4 || len(e.ToolResults) != 4 {
|
||||
t.Fatalf("★ 剔除动了数据(应只标注): calls=%d results=%d",
|
||||
len(e.ToolCalls), len(e.ToolResults))
|
||||
}
|
||||
// 页的 call/result 仍然都在(整页剔除,不会只剩一侧)
|
||||
for i := range e.ToolCalls {
|
||||
if e.ToolCalls[i].CallID != e.ToolResults[i].CallID {
|
||||
t.Fatalf("★ 配对被破坏: %+v vs %+v", e.ToolCalls[i], e.ToolResults[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 铁律:剔除不迁移层,其余页仍在同一帧。
|
||||
func TestEvictPagesDoesNotMigrateLayers(t *testing.T) {
|
||||
c := newTestContext(t)
|
||||
e := mkFrame(3)
|
||||
c.events = []*ContextEvent{e}
|
||||
beforeLen := len(c.events)
|
||||
|
||||
c.mu.Lock()
|
||||
c.evictPagesLocked(e, 300, "机器状态")
|
||||
c.mu.Unlock()
|
||||
|
||||
if len(c.events) != beforeLen {
|
||||
t.Fatalf("★ 剔除导致事件数变化(%d → %d)—— 剔除不应迁移/删除",
|
||||
beforeLen, len(c.events))
|
||||
}
|
||||
// 被剔页与保留页共享同一 Timestamp(同一帧)
|
||||
for _, x := range c.events {
|
||||
if !x.Timestamp.Equal(e.Timestamp) {
|
||||
t.Fatalf("★ 帧被拆开了: %v vs %v", x.Timestamp, e.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 渲染必须留提示行(不静默降级)。
|
||||
func TestEvictedPagesLeaveHintInRender(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
e := mkFrame(3)
|
||||
e.InactivePages = []int{2, 3} // 直接标注,绕过评分(这里只验渲染)
|
||||
a.context.Append(*e)
|
||||
|
||||
out := a.formatMergedTimeline(8000)
|
||||
if !strings.Contains(out, "被省略") {
|
||||
t.Fatalf("★ 剔除后渲染没有提示行(静默少几行 = 模型以为当时没发生):\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "coldcontext_query") {
|
||||
t.Fatalf("★ 提示行未告知找回手段:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复:清空 InactivePages 即恢复渲染(剔除是可逆的)。
|
||||
func TestEvictionIsReversible(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
e := mkFrame(2)
|
||||
e.InactivePages = []int{2}
|
||||
a.context.Append(*e)
|
||||
|
||||
hidden := a.formatMergedTimeline(8000)
|
||||
if !strings.Contains(hidden, "被省略") {
|
||||
t.Fatal("前置条件失败:剔除未生效")
|
||||
}
|
||||
|
||||
// 恢复
|
||||
a.context.mu.Lock()
|
||||
for _, x := range a.context.events {
|
||||
x.InactivePages = nil
|
||||
}
|
||||
a.context.mu.Unlock()
|
||||
|
||||
restored := a.formatMergedTimeline(8000)
|
||||
if strings.Contains(restored, "被省略") {
|
||||
t.Fatalf("★ 清空 InactivePages 后仍在省略(剔除不可逆 = 数据实际丢了):\n%s", restored)
|
||||
}
|
||||
}
|
||||
|
||||
// 页相似度:低相似度页先被剔除。
|
||||
func TestEvictPrefersLowestSimilarityPage(t *testing.T) {
|
||||
c := newTestContext(t)
|
||||
e := &ContextEvent{
|
||||
ID: "evt_sim", Timestamp: time.Now(), Source: "agent",
|
||||
Input: "关于磁盘空间的问题", Response: "好的。",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: "c_rel", Name: "disk_check", Arguments: map[string]interface{}{"q": "磁盘空间占用"}},
|
||||
{CallID: "c_irr", Name: "weather_check", Arguments: map[string]interface{}{"q": "明天天气"}},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: "c_rel", Name: "disk_check", Output: strings.Repeat("磁盘空间 ", 300)},
|
||||
{CallID: "c_irr", Name: "weather_check", Output: strings.Repeat("天气晴朗 ", 300)},
|
||||
},
|
||||
}
|
||||
c.events = []*ContextEvent{e}
|
||||
|
||||
c.mu.Lock()
|
||||
c.evictPagesLocked(e, 600, "磁盘空间")
|
||||
c.mu.Unlock()
|
||||
|
||||
if len(e.InactivePages) == 0 {
|
||||
t.Fatal("未剔除任何页")
|
||||
}
|
||||
// 与"磁盘"无关的那页(第 3 页)应先被剔
|
||||
found := false
|
||||
for _, p := range e.InactivePages {
|
||||
if p == 3 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Logf("剔除结果: %v(评分受词向量精度影响,这里只断言方向不绝对)",
|
||||
e.InactivePages)
|
||||
}
|
||||
}
|
||||
|
||||
// pageTexts 必须能取到页的两侧正文(否则相似度全是 0,剔除变成随机)。
|
||||
func TestPageTextsReturnsBothSides(t *testing.T) {
|
||||
c := newTestContext(t)
|
||||
e := mkFrame(2)
|
||||
|
||||
call, res := c.pageTexts(e, 2)
|
||||
if !strings.Contains(call, "sys_check") {
|
||||
t.Fatalf("页 call 侧未取到: %q", call)
|
||||
}
|
||||
if !strings.Contains(res, "检查结果") {
|
||||
t.Fatalf("页 result 侧未取到: %q", res)
|
||||
}
|
||||
// 第 1 页 = llm_round
|
||||
c1, r1 := c.pageTexts(e, 1)
|
||||
if c1 != e.Input || r1 != e.Response {
|
||||
t.Fatalf("llm_round 页取错: %q / %q", c1, r1)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestContext 造一个可落盘的最小上下文(t.TempDir 隔离)。
|
||||
func newTestContext(t *testing.T) *RelevanceContext {
|
||||
t.Helper()
|
||||
return NewRelevanceContext(t.TempDir()+"/ctx.json", memory.NewStaticEmbedder())
|
||||
}
|
||||
|
||||
// ── inactive 的生命周期(用户裁定,2026-10-29)──────────────────
|
||||
//
|
||||
// "inactive 是在 context 渲染时的标记,被淘汰到 doc 后还保留干啥?
|
||||
// 甚至可以说 inactive 是每次 prune 后被重置的"
|
||||
//
|
||||
// 三条推论,逐条钉住:
|
||||
// ① 不持久化(渲染期状态,重启由本轮重新判定)
|
||||
// ② 每次 Prune 后重置(旧标记依赖旧窗口状态,Prune 后失效)
|
||||
// ③ 不进 L2(归档 = 整帧离开 L1,再记着当时少渲染了哪页毫无意义)
|
||||
|
||||
// ① 不持久化:写盘再读回,InactivePages 必须为空。
|
||||
func TestInactivePagesNotPersisted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := dir + "/ctx.json"
|
||||
|
||||
c1 := NewRelevanceContext(path, memory.NewStaticEmbedder())
|
||||
e := mkFrame(2)
|
||||
e.InactivePages = []int{2, 3}
|
||||
c1.Append(*e)
|
||||
|
||||
// ★ 必须显式 flush:save() 只是**安排** 5 秒后写盘(dirty + saveTimer),
|
||||
// 不 flush 的话文件根本不存在,下面的 load 会提前返回、
|
||||
// 循环体一次都不执行 —— 判据变成永远 PASS 的空测试。
|
||||
// (第一版就是这么写的,变异验证时发现打不红。)
|
||||
c1.flush()
|
||||
|
||||
// 前置条件:文件真的写出来了,且里面有事件
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("前置条件失败:文件未落盘: %v", err)
|
||||
}
|
||||
if len(raw) == 0 || string(raw) == "[]" {
|
||||
t.Fatalf("前置条件失败:落盘内容为空 %q", string(raw))
|
||||
}
|
||||
|
||||
c2 := NewRelevanceContext(path, memory.NewStaticEmbedder())
|
||||
events := c2.Recent(0)
|
||||
if len(events) == 0 {
|
||||
t.Fatalf("前置条件失败:load 后没有事件(落盘内容: %s)", truncateForTest(string(raw)))
|
||||
}
|
||||
for _, got := range events {
|
||||
if len(got.InactivePages) > 0 {
|
||||
t.Fatalf("★ InactivePages 被持久化了: %v —— 它是渲染期状态,不该落盘",
|
||||
got.InactivePages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// truncateForTest 截断长字符串用于错误信息。
|
||||
func truncateForTest(s string) string {
|
||||
if len(s) <= 200 {
|
||||
return s
|
||||
}
|
||||
return s[:200] + "…"
|
||||
}
|
||||
|
||||
// ② 每次 Prune 后重置。
|
||||
func TestPruneResetsInactivePages(t *testing.T) {
|
||||
c := newTestContext(t)
|
||||
|
||||
// 造一条带剔除标记的事件
|
||||
e := mkFrame(3)
|
||||
e.InactivePages = []int{2}
|
||||
c.Append(*e)
|
||||
|
||||
// 确认标记在
|
||||
found := false
|
||||
for _, x := range c.Recent(0) {
|
||||
if len(x.InactivePages) > 0 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("前置条件失败:标记未生效")
|
||||
}
|
||||
|
||||
// 触发 Prune(条目少于 topK 时提前返回,但重置必须在那之前发生)
|
||||
c.Prune("随便一个查询", 100, nil)
|
||||
|
||||
for _, x := range c.Recent(0) {
|
||||
if len(x.InactivePages) > 0 {
|
||||
t.Fatalf("★ Prune 后剔除标记未重置: %v —— 会把上一轮的预算压力带到这一轮",
|
||||
x.InactivePages)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 不进 L2:归档时剔除标记不出现在 coldcontext 事件里。
|
||||
func TestInactivePagesNotArchivedToColdContext(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
ev := ContextEvent{
|
||||
Timestamp: time.Now().Add(-time.Hour),
|
||||
Source: "inactive-archive-src",
|
||||
Input: "这条事件带剔除标记",
|
||||
Response: "好的。",
|
||||
InactivePages: []int{2, 3},
|
||||
}
|
||||
archiveEvents(t, a, []ContextEvent{ev})
|
||||
|
||||
// coldcontext 里的事件类型没有 InactivePages 字段(结构上就不该有)——
|
||||
// 这里验证归档路径不因它而改变内容
|
||||
var docID string
|
||||
for _, d := range a.coldcontextStore.RecentColdContexts(100) {
|
||||
if len(d.Events) > 0 && d.Events[0].Source == "inactive-archive-src" {
|
||||
docID = d.ID
|
||||
}
|
||||
}
|
||||
if docID == "" {
|
||||
t.Skip("该事件未被归档(可能仍在保护窗口内),跳过")
|
||||
}
|
||||
d := a.coldcontextStore.Get(docID)
|
||||
if d == nil || len(d.Events) == 0 {
|
||||
t.Fatal("文档内容为空")
|
||||
}
|
||||
// L2 的事件里没有 InactivePages 这个概念:核对正文照常保存即可
|
||||
if d.Events[0].Blocks == nil && len(d.Events[0].ToolCalls) == 0 {
|
||||
t.Fatal("归档丢失了全部内容")
|
||||
}
|
||||
}
|
||||
@ -42,7 +42,7 @@ func TestResolveToolPlugin_NoPhantomPlugins(t *testing.T) {
|
||||
// 这些内置工具的名字都含下划线,按旧规则会被截成假插件名。
|
||||
for _, name := range []string{
|
||||
"ocr_image", "describe_image", "transcribe_audio",
|
||||
"memory_commit", "memory_recall", "coldcontext_query",
|
||||
"stablemem_commit", "stablemem_recall", "coldcontext_query",
|
||||
"knowledge_search", "person_set_trait", "spawn_child",
|
||||
} {
|
||||
got := a.resolveToolPlugin(name)
|
||||
@ -144,7 +144,7 @@ func TestGetPluginTools_CoreFilterWorks(t *testing.T) {
|
||||
// 不在表里),把「条件不满足」误报成「名字拼错了」。
|
||||
// 第 2 版:扫源码里的 toolDef("…") 字面量
|
||||
// ⇒ 也错。memory_recall 是由 internal/memory/indexer.go:442 **动态注入**的,
|
||||
// 源码里根本没有 toolDef("memory_recall" 这段文本。
|
||||
// 源码里根本没有 toolDef("stablemem_recall" 这段文本。
|
||||
//
|
||||
// ⇒ 正确的基准是:**归属表的名字必须是内核真的会产出的工具名**。
|
||||
//
|
||||
|
||||
@ -59,10 +59,9 @@ func (a *Agent) autoReloadPlugins() {
|
||||
// 根本不是插件,靠规则永远猜不对。
|
||||
var builtinToolOwner = map[string]string{
|
||||
// 记忆子系统(内核能力,不是插件)
|
||||
"memory_recall": "core", "memory_commit": "core", "memory_merge": "core",
|
||||
"memory_purge": "core", "memory_edit": "core", "memory_delete_entity": "core",
|
||||
"memory_block_merge": "core", "memory_introspect": "core",
|
||||
"memory_document_query": "core", "memory_document_commit": "core",
|
||||
"stablemem_recall": "core", "stablemem_commit": "core", "stablemem_merge": "core",
|
||||
"stablemem_purge": "core", "stablemem_edit": "core", "stablemem_delete_entity": "core",
|
||||
"stablemem_block_merge": "core", "stablemem_introspect": "core",
|
||||
// 知识库
|
||||
"knowledge_search": "core", "knowledge_create": "core", "knowledge_list": "core",
|
||||
"knowledge_delete": "core", "knowledge_import_dir": "core",
|
||||
|
||||
@ -511,11 +511,11 @@ func convertBackToolCalls(tcs []sdk.ToolCall) []agentAPI.ToolCall {
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Agent) docStoreSize() int {
|
||||
if a.docStore == nil {
|
||||
func (a *Agent) coldcontextStoreSize() int {
|
||||
if a.coldcontextStore == nil {
|
||||
return 0
|
||||
}
|
||||
s := a.docStore.Stats()
|
||||
s := a.coldcontextStore.Stats()
|
||||
if n, ok := s["doc_count"]; ok {
|
||||
if ni, ok := n.(int); ok {
|
||||
return ni
|
||||
@ -582,9 +582,29 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
||||
}
|
||||
// 工具结果在 context 中**展开**(全文,不截断)。
|
||||
// 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。
|
||||
for _, line := range a.timelineToolLines(e) {
|
||||
//
|
||||
// ★ 段页式淘汰(P5):InactivePages 里的页不渲染,但在**原位**留一行提示。
|
||||
//
|
||||
// "在原位留提示"(用户裁定)而不是"悄悄少几行":模型必须知道
|
||||
// 「这里原本有内容,是被省略了」,否则它会把"没看见"当成"当时没发生",
|
||||
// 进而基于残缺上下文编造。这也是项目一贯的"不静默降级"原则。
|
||||
//
|
||||
// 提示里带上页序号与恢复手段:模型可据此用 coldcontext_query 找回。
|
||||
lines := a.timelineToolLines(e)
|
||||
pageOf := toolResultPageIndex(e)
|
||||
dropped := 0
|
||||
for i, line := range lines {
|
||||
if len(e.InactivePages) > 0 && i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line))
|
||||
}
|
||||
if dropped > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[%s] (本帧有 %d 个工具往返因超出上下文预算被省略;"+
|
||||
"内容仍在记忆中,可用 coldcontext_query 按需找回)\n",
|
||||
e.Timestamp.Format("15:04:05"), dropped))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@ -652,6 +672,38 @@ func chatUtteranceFromResults(trs []ToolResultItem) string {
|
||||
//
|
||||
// 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓
|
||||
// "静默降级"同族),而历史里工具结果正是消解指代的依据。
|
||||
// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。
|
||||
//
|
||||
// 页序约定与 document.docBlocksForEntry 一致:第 1 页是 llm_round,
|
||||
// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对,
|
||||
// 因此这里用同样的配对顺序(并行调用时位置未必一一对应)。
|
||||
//
|
||||
// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages,
|
||||
// 即"配不上页就不剔除"—— 宁可多显示,不可猜错)。
|
||||
func toolResultPageIndex(e *ContextEvent) []int {
|
||||
if e == nil || len(e.ToolResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, len(e.ToolResults))
|
||||
for i := range e.ToolResults {
|
||||
out[i] = 0
|
||||
for j := range e.ToolCalls {
|
||||
tc := &e.ToolCalls[j]
|
||||
hit := false
|
||||
if tc.CallID != "" && e.ToolResults[i].CallID != "" {
|
||||
hit = tc.CallID == e.ToolResults[i].CallID
|
||||
} else {
|
||||
hit = tc.Name == e.ToolResults[i].Name
|
||||
}
|
||||
if hit {
|
||||
out[i] = j + 2 // 第 1 页是 llm_round
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *Agent) timelineToolLines(e *ContextEvent) []string {
|
||||
if e == nil || len(e.ToolResults) == 0 {
|
||||
return nil
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// ① coldcontext_query 的描述必须覆盖五项,且与真实行为一致。
|
||||
@ -27,7 +27,7 @@ func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
|
||||
"past episode",
|
||||
"original timestamps",
|
||||
"inserted back into the timeline",
|
||||
"memory_recall",
|
||||
"stablemem_recall",
|
||||
"consuming",
|
||||
}
|
||||
for _, sub := range mustHave {
|
||||
@ -46,7 +46,7 @@ func TestDocCommitDescriptionExplainsScope(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
desc := findToolDescription(t, a, "coldcontext_commit")
|
||||
|
||||
for _, sub := range []string{"complete process", "single fact", "memory_commit", "knowledge_create"} {
|
||||
for _, sub := range []string{"complete process", "single fact", "stablemem_commit", "knowledge_create"} {
|
||||
if !strings.Contains(desc, sub) {
|
||||
t.Errorf("cold context commit description is missing %q: %s", sub, desc)
|
||||
}
|
||||
@ -70,7 +70,7 @@ func TestSystemPromptExplainsThreeLayers(t *testing.T) {
|
||||
for _, sub := range []string{
|
||||
"[Memory]",
|
||||
"coldcontext_query",
|
||||
"memory_recall",
|
||||
"stablemem_recall",
|
||||
"knowledge_search",
|
||||
} {
|
||||
if !strings.Contains(prompt, sub) {
|
||||
@ -144,14 +144,14 @@ func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{}
|
||||
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
|
||||
// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。
|
||||
//
|
||||
// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent:
|
||||
// ★ 用**真实归档路径**(ContextToColdContext)构造文档,不手工拼 DocEvent:
|
||||
//
|
||||
// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块,
|
||||
// 那样测的是"我拼的数据对不对",不是生产过程。
|
||||
func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{
|
||||
if _, err := a.coldcontextStore.ContextToColdContext("context_archived", []coldcontext.ContextEntry{{
|
||||
Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC),
|
||||
Source: "webui",
|
||||
Content: "配色方案怎么定?",
|
||||
@ -159,7 +159,7 @@ func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := a.docStore.RecentDocs(1)[0]
|
||||
doc := a.coldcontextStore.RecentColdContexts(1)[0]
|
||||
|
||||
md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}})
|
||||
|
||||
|
||||
@ -23,6 +23,26 @@ import (
|
||||
//
|
||||
// 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。
|
||||
// 文件权限 0600 —— 内容是用户隐私。
|
||||
// promptDump 是 opt-in 的**一次性诊断**手段,不是常驻功能。
|
||||
//
|
||||
// ## 生命周期约定(2026-10-29 教训)
|
||||
//
|
||||
// 这个开关原本只为一次排查而加("抓一轮真实提示词,确认 coldcontext
|
||||
// 预填充到底注入了什么")。但当时的注释写着"排查完应删除本 drop-in 并删文件",
|
||||
// 而**没有人执行那一步**:它在生产上挂了两天,`prompt.jsonl` 涨到 531MB。
|
||||
//
|
||||
// 这次为此加过"大小上限"(`HOMED_PROMPT_DUMP_MAX_MB`),但那其实是把
|
||||
// 临时手段往"长期功能"上推 —— 方向错了。正确做法就是照它自己写明的
|
||||
// 处置步骤做掉:删 drop-in、删文件。上限机制已随之移除。
|
||||
//
|
||||
// ## 若要再用一次
|
||||
//
|
||||
// 一次性诊断的正确姿势:
|
||||
//
|
||||
// HOMED_PROMPT_DUMP=/tmp/prompt-$(date +%s).jsonl homed ... # 不要写进 systemd
|
||||
// # 看几轮就停,看完立刻删。
|
||||
//
|
||||
// 不要写进 drop-in —— 那会让"临时"变成"永久",且没有任何人记得删。
|
||||
const promptDumpEnv = "HOMED_PROMPT_DUMP"
|
||||
|
||||
var promptDumpMu sync.Mutex
|
||||
@ -59,6 +79,7 @@ func dumpPrompt(channel string, req *agentAPI.CompletionRequest) {
|
||||
|
||||
promptDumpMu.Lock()
|
||||
defer promptDumpMu.Unlock()
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
243
internal/agent/core/recall_plan_e2e_test.go
Normal file
243
internal/agent/core/recall_plan_e2e_test.go
Normal file
@ -0,0 +1,243 @@
|
||||
package core
|
||||
|
||||
// 召回期「先估算、超预算按页剔除」的 e2e 判据(2026-10-29)。
|
||||
//
|
||||
// ## 用户裁定的算法(逐字)
|
||||
//
|
||||
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
|
||||
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
|
||||
// 直到不超预算
|
||||
//
|
||||
// ## 必须钉住的六件事
|
||||
//
|
||||
// ① 装得下 ⇒ **一个字都不剔**(剔除有代价,有余量时不该发生)
|
||||
// ② 装不下 ⇒ 剔除到刚好装下(不多剔一页)
|
||||
// ③ 每次剔的是**全局最不相似**的页(不是"每篇自己管自己")
|
||||
// ④ llm_round(第 1 页)**永不剔除**
|
||||
// ⑤ 剔除是标注:数据仍在、仍可回读、清空即恢复
|
||||
// ⑥ 回执必须**说明**剔了几页(模型要知道看到的是残缺上下文)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// mkDoc 造一篇带 n 个工具往返的 L2 文档。
|
||||
//
|
||||
// ★ 走**真实归档路径**(ContextToColdContext),不手工伪造块 ID。
|
||||
//
|
||||
// 第一版是手工构造 DocEvent 的,结果两条判据全是假象:
|
||||
//
|
||||
// · 块 ID 不满足 entryTextBlockID 的派生规则 ⇒ InOut() 取不到正文
|
||||
// (第 1 页 token 恒为 0)
|
||||
// · 工具参数写成纯英文 "df -h",与中文查询零词重叠 ⇒ 相似度恒为 0
|
||||
//
|
||||
// 手工伪造的 fixture 与生产形态不一致时,判据测的是 fixture 而不是代码。
|
||||
func mkDoc(t *testing.T, a *Agent, id string, n int, body string) *coldcontext.ColdDoc {
|
||||
t.Helper()
|
||||
ts := time.Date(2026, 10, 29, 23, 0, 0, 0, time.UTC).
|
||||
Add(time.Duration(len(id)) * time.Second) // 避免 ID 派生撞车
|
||||
entry := coldcontext.ContextEntry{
|
||||
Timestamp: ts,
|
||||
Source: "agent",
|
||||
Content: body + " 用户提问",
|
||||
Response: body + " 模型回答",
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
cid := id + "_c" + itoaLocal(i)
|
||||
entry.ToolCalls = append(entry.ToolCalls, coldcontext.ToolCallItem{
|
||||
CallID: cid, Name: "cmd_run",
|
||||
Arguments: map[string]interface{}{"command": body + " 检查命令"},
|
||||
})
|
||||
entry.ToolResults = append(entry.ToolResults, coldcontext.ToolResultItem{
|
||||
CallID: cid, Name: "cmd_run",
|
||||
Output: strings.Repeat(body+" 输出数据 ", 60),
|
||||
})
|
||||
}
|
||||
doc, err := a.coldcontextStore.ContextToColdContext(id, []coldcontext.ContextEntry{entry},
|
||||
nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("归档失败: %v", err)
|
||||
}
|
||||
if doc == nil {
|
||||
t.Fatal("归档返回空文档")
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func itoaLocal(n int) string {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
|
||||
// ① 装得下:一个字都不剔。
|
||||
//
|
||||
// 这是本算法最重要的性质:剔除会让模型看到残缺上下文,
|
||||
// 有余量时必须完整召回。
|
||||
func TestPlanRecallNoEvictionWhenFits(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
c := a.context
|
||||
docs := []*coldcontext.ColdDoc{
|
||||
mkDoc(t, a, "d1", 2, "磁盘空间检查"),
|
||||
mkDoc(t, a, "d2", 2, "磁盘空间检查"),
|
||||
}
|
||||
|
||||
plan := c.planColdRecall(docs, "磁盘空间", 1_000_000) // 预算充足
|
||||
|
||||
if plan.Over {
|
||||
t.Fatalf("★ 预算充足却发生了剔除(应当完整召回): %v", plan.Evict)
|
||||
}
|
||||
if len(plan.Evict) != 0 {
|
||||
t.Fatalf("★ 预算充足却剔了页: %v", plan.Evict)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 装不下:剔到刚好装下,不多剔。
|
||||
func TestPlanRecallEvictsJustEnough(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
c := a.context
|
||||
docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 6, "磁盘空间检查")}
|
||||
|
||||
full := c.planColdRecall(docs, "磁盘空间", 1_000_000)
|
||||
if full.Over {
|
||||
t.Fatal("前置条件失败")
|
||||
}
|
||||
// 给一个"不够装全部、但装得下大部分"的预算
|
||||
budget := full.Need / 2
|
||||
plan := c.planColdRecall(docs, "磁盘空间", budget)
|
||||
|
||||
if !plan.Over {
|
||||
t.Fatal("★ 预算不足却未剔除——会导致渲染时丢掉更早的真实历史")
|
||||
}
|
||||
if plan.Need > budget {
|
||||
t.Fatalf("★ 剔完仍超预算: need=%d budget=%d(会继续溢出)", plan.Need, budget)
|
||||
}
|
||||
// ★ 必须真的剔了东西(否则"够装下"是假的)
|
||||
evictedPages := countPlanPages(plan.Evict)
|
||||
if evictedPages == 0 {
|
||||
t.Fatalf("★ 判超预算却剔了 0 页(token 降了但没记录剔除,执行时会整批回灌)")
|
||||
}
|
||||
// 不能过度剔除:6 个工具页里剔掉的不该是全部
|
||||
if evictedPages >= 6 {
|
||||
t.Fatalf("★ 剔了 %d 页(共 6 个工具页),过度剔除", evictedPages)
|
||||
}
|
||||
t.Logf("预算 %d / 原需 %d:剔除 %d 页后 %d token", budget, full.Need, evictedPages, plan.Need)
|
||||
}
|
||||
|
||||
// ③ 跨文档取全局最不相似:不是"每篇自己管自己"。
|
||||
func TestPlanRecallEvictsGloballyNotPerDoc(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
c := a.context
|
||||
|
||||
// d_rel 与磁盘相关;d_irr 与天气相关(正文/参数/结果全都换掉)
|
||||
rel := mkDoc(t, a, "d_rel", 3, "磁盘空间检查")
|
||||
irr := mkDoc(t, a, "d_irr", 3, "明天天气晴朗二十四度")
|
||||
|
||||
docs := []*coldcontext.ColdDoc{rel, irr}
|
||||
full := c.planColdRecall(docs, "磁盘空间占用", 1_000_000)
|
||||
plan := c.planColdRecall(docs, "磁盘空间占用", full.Need*2/3)
|
||||
|
||||
if !plan.Over {
|
||||
t.Fatal("前置条件失败:应触发剔除")
|
||||
}
|
||||
// 与查询无关的那篇应被剔得更多
|
||||
// 汇总各篇被剔页数(key 是文档 ID,不是固定名字)
|
||||
nRel := len(plan.Evict[rel.ID])
|
||||
nIrr := len(plan.Evict[irr.ID])
|
||||
t.Logf("剔除分布:相关篇(%s) %d 页,无关篇(%s) %d 页",
|
||||
rel.ID, nRel, irr.ID, nIrr)
|
||||
|
||||
if countPlanPages(plan.Evict) == 0 {
|
||||
t.Fatal("★ 判超预算却剔了 0 页")
|
||||
}
|
||||
if nIrr <= nRel {
|
||||
t.Fatalf("★ 无关篇只剔了 %d 页、相关篇却剔了 %d 页 —— "+
|
||||
"应按全局相似度优先剔无关页", nIrr, nRel)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ llm_round(第 1 页)永不可剔。
|
||||
func TestPlanRecallNeverEvictsLLMRound(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
c := a.context
|
||||
docs := []*coldcontext.ColdDoc{mkDoc(t, a, "d1", 4, "磁盘空间检查")}
|
||||
|
||||
// 极端预算:逼它剔到极限
|
||||
plan := c.planColdRecall(docs, "磁盘", 10)
|
||||
|
||||
for docID, pages := range plan.Evict {
|
||||
for _, p := range pages {
|
||||
if p == 1 {
|
||||
t.Fatalf("★ 文档 %s 剔除了 llm_round(第 1 页): %v", docID, pages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 剔除是标注:数据仍在文档里,可回读。
|
||||
func TestPlanRecallEvictionKeepsData(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
c := a.context
|
||||
d := mkDoc(t, a, "d1", 4, "磁盘空间检查")
|
||||
docs := []*coldcontext.ColdDoc{d}
|
||||
|
||||
callsBefore := len(d.Events[0].ToolCalls)
|
||||
resultsBefore := len(d.Events[0].ToolResults)
|
||||
|
||||
_ = c.planColdRecall(docs, "磁盘", 100) // 强制剔除
|
||||
|
||||
if len(d.Events[0].ToolCalls) != callsBefore ||
|
||||
len(d.Events[0].ToolResults) != resultsBefore {
|
||||
t.Fatalf("★ 剔除动了数据(应只标注): calls %d→%d results %d→%d",
|
||||
callsBefore, len(d.Events[0].ToolCalls),
|
||||
resultsBefore, len(d.Events[0].ToolResults))
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ 回执说明剔了几页(模型必须知道自己看到的是残缺上下文)。
|
||||
func TestRecallReplyAnnouncesEviction(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
base := time.Date(2026, 10, 29, 23, 30, 0, 0, time.UTC)
|
||||
var want []ContextEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
ev := ContextEvent{
|
||||
Timestamp: base.Add(time.Duration(i) * time.Minute),
|
||||
Source: "plan-src",
|
||||
Input: "检查磁盘并顺便看天气-" + itoaLocal(i),
|
||||
Response: "已检查。",
|
||||
}
|
||||
for j := 0; j < 3; j++ {
|
||||
cid := "c" + itoaLocal(i) + itoaLocal(j)
|
||||
ev.ToolCalls = append(ev.ToolCalls, ToolCallItem{
|
||||
CallID: cid, Name: "cmd_run",
|
||||
Arguments: map[string]interface{}{"command": strings.Repeat("x", 400)},
|
||||
})
|
||||
ev.ToolResults = append(ev.ToolResults, ToolResultItem{
|
||||
CallID: cid, Name: "cmd_run",
|
||||
Output: strings.Repeat("输出数据 ", 400),
|
||||
})
|
||||
}
|
||||
want = append(want, ev)
|
||||
}
|
||||
archiveEvents(t, a, want)
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "coldcontext_query",
|
||||
Arguments: map[string]interface{}{"query": "磁盘 天气", "top_k": float64(5)},
|
||||
})
|
||||
if strings.Contains(out, "no relevant cold context") {
|
||||
t.Fatalf("未召回:%s", out)
|
||||
}
|
||||
// 有剔除时必须写明;无剔除时不该出现
|
||||
if strings.Contains(out, "marked inactive") {
|
||||
if !strings.Contains(out, "page(s)") {
|
||||
t.Fatalf("★ 回执说明了剔除但没给页数: %s", out)
|
||||
}
|
||||
t.Logf("回执已声明剔除: %.200s", out)
|
||||
} else {
|
||||
t.Logf("本次未触发剔除(预算充足),回执为正常召回: %.150s", out)
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ package core
|
||||
//
|
||||
// coldcontext_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除
|
||||
//
|
||||
// 三条不变量:
|
||||
// ① 逐条:N 条事件回到时间线,不是 1 条合成事件
|
||||
@ -20,7 +20,7 @@ import (
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。
|
||||
@ -40,7 +40,7 @@ func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) {
|
||||
Response: "好的。",
|
||||
})
|
||||
}
|
||||
if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 {
|
||||
if n := a.context.Prune("当前输入", 2, a.coldcontextStore); n == 0 {
|
||||
t.Fatal("Prune 未归档任何事件")
|
||||
}
|
||||
}
|
||||
@ -154,7 +154,7 @@ func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
|
||||
t.Errorf("迁回的块字节被改动: %q", string(back.Data))
|
||||
}
|
||||
// 迁移而非复制。
|
||||
for _, d := range a.docStore.RecentDocs(50) {
|
||||
for _, d := range a.coldcontextStore.RecentColdContexts(50) {
|
||||
for _, db := range d.Blocks {
|
||||
if db.ID == b.ID {
|
||||
t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量")
|
||||
@ -205,7 +205,7 @@ func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
|
||||
func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
if err := a.docStore.Insert(&document.Doc{
|
||||
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
|
||||
Summary: "紫蓝红三色带的旧格式文档",
|
||||
Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图",
|
||||
Source: "context_archived",
|
||||
@ -338,7 +338,7 @@ func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
|
||||
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
|
||||
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
if err := a.docStore.Insert(&document.Doc{
|
||||
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
|
||||
Summary: "旧格式摘要",
|
||||
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
|
||||
Source: "context_archived",
|
||||
@ -100,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无事件时不 panic,且只有工具特征时也成立
|
||||
if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
|
||||
if feats := a.situationFeaturesFor(nil, "", "stablemem_recall"); len(feats) != 1 {
|
||||
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,9 +49,32 @@ func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
|
||||
//
|
||||
// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型
|
||||
// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。
|
||||
//
|
||||
// ★ 2026-10-29:本函数此前是**死代码**(`a.security` 从未被赋值 ⇒ 永远
|
||||
// 走 nil 分支 ⇒ 永远放行),`Policy.CheckTool` 因此对工具执行链完全不生效。
|
||||
// 修复分两步:① 补 `SetSecurity` 注入通道;② 在 `executeToolCallInner`
|
||||
// 的**所有实际执行分支之前**调用本函数。
|
||||
func (a *Agent) securityDecisionFor(tool string) security.Decision {
|
||||
if a == nil || a.security == nil {
|
||||
return security.Decision{Allowed: true}
|
||||
}
|
||||
return a.security.CheckTool(tool)
|
||||
}
|
||||
|
||||
// SetSecurity 注入安全运行时(装配期调用,幂等)。
|
||||
//
|
||||
// ## 为什么需要显式注入点
|
||||
//
|
||||
// `Agent.security` 曾经是**只有测试赋过值**的字段:生产装配链里没有任何
|
||||
// 地方写它,于是 `securityDecisionFor` 恒定放行。这不是"策略写错了",
|
||||
// 而是"策略根本没接上",而两种故障在日志上看起来一样(都是没有拒绝记录)。
|
||||
//
|
||||
// ## 为什么允许 nil
|
||||
//
|
||||
// 测试与独立工具(memgc 等)不需要安全模块;传 nil 表示显式不启用。
|
||||
func (a *Agent) SetSecurity(rt *security.Runtime) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
a.security = rt
|
||||
}
|
||||
|
||||
126
internal/agent/core/security_toolgate_test.go
Normal file
126
internal/agent/core/security_toolgate_test.go
Normal file
@ -0,0 +1,126 @@
|
||||
package core
|
||||
|
||||
// 安全策略闸的**接线**判据。
|
||||
//
|
||||
// ## 为什么需要这个文件(2026-10-29)
|
||||
//
|
||||
// `Policy.CheckTool` 此前是完美的机制 + 零个生产调用点:
|
||||
//
|
||||
// CheckTool / CheckToolFor / ScanContent / CheckPath / CheckNetwork
|
||||
// → 除了 security 包自己的测试,全仓 0 个调用方
|
||||
//
|
||||
// 更糟的是 `Agent.security` 字段**从未被赋值**(只有测试赋过),
|
||||
// 所以 `securityDecisionFor` 恒走 nil 分支放行。两种故障叠加的结果是:
|
||||
// 策略表存在、判定函数正确、日志里一条拒绝记录都没有 —— 看起来"策略已启用"。
|
||||
//
|
||||
// 本文件的判据钉的是**接线**,不是机制:
|
||||
// - 机制(deny 命中返回 !Allowed):security/policy_test.go 已经钉过
|
||||
// - 接线(!Allowed 时工具真的不执行):本文件钉
|
||||
//
|
||||
// ## 判据必须能红
|
||||
//
|
||||
// 如果把 `executeToolCallInner` 里的策略闸整段删掉,
|
||||
// `TestToolDeniedByPolicyNeverExecutes` 必须失败。
|
||||
// 这一点比"测试通过"更重要 —— 否则它只是又一份自我确认。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// toolCallTo 构造一个最小可用的工具调用。
|
||||
func toolCallTo(name string) agentAPI.ToolCall {
|
||||
return agentAPI.ToolCall{Name: name, Arguments: map[string]interface{}{}}
|
||||
}
|
||||
|
||||
// policyRuntime 构造一个带工具黑名单的独立运行时(不碰全局单例)。
|
||||
func policyRuntime(t *testing.T, deny ...string) *security.Runtime {
|
||||
t.Helper()
|
||||
rt := security.NewRuntime(nil)
|
||||
rt.SetPolicy(security.Policy{ToolDeny: deny})
|
||||
return rt
|
||||
}
|
||||
|
||||
// ① 未注入安全运行时 ⇒ 全部放行(独立工具/测试的既有行为不变)。
|
||||
func TestToolGateAbsentAllowsEverything(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.SetSecurity(nil)
|
||||
|
||||
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("未注入时应放行: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ② SetSecurity 真的接上了(此前该字段只有测试赋过值)。
|
||||
func TestSetSecurityWiresDecision(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.SetSecurity(policyRuntime(t, "cmd_*"))
|
||||
|
||||
if d := a.securityDecisionFor("cmd_run"); d.Allowed {
|
||||
t.Fatal("SetSecurity 注入后 cmd_* 必须被拒 —— 否则注入通道是假的")
|
||||
}
|
||||
if d := a.securityDecisionFor("stablemem_recall"); !d.Allowed {
|
||||
t.Fatalf("未命中黑名单的工具必须放行: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ ★ 核心判据:被策略拒绝的工具**不执行**,且返回结构化拒绝。
|
||||
//
|
||||
// "不执行" 通过让工具注册一个必被调用的探针来证明:
|
||||
// 若闸门缺失,探针会被调用(hit=true),判据失败。
|
||||
func TestToolDeniedByPolicyNeverExecutes(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
|
||||
const toolName = "probe_tool"
|
||||
hit := false
|
||||
// 插件工具经 StageHost 注册(内置工具不走这条路,见 tooldefs.go 的说明)。
|
||||
if err := a.stageHost.RegisterTool(toolName, sdk.ToolDef{
|
||||
Name: toolName,
|
||||
Description: "probe",
|
||||
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
}, func(args map[string]interface{}) (interface{}, error) {
|
||||
hit = true
|
||||
return "should not run", nil
|
||||
}); err != nil {
|
||||
t.Fatalf("注册探针工具失败: %v", err)
|
||||
}
|
||||
|
||||
// 先证明探针本身可用(否则"没执行"可能只是工具不存在)。
|
||||
a.SetSecurity(nil)
|
||||
if out := a.executeToolCall(toolCallTo(toolName), "test"); !strings.Contains(out, "should not run") {
|
||||
t.Fatalf("前置条件不成立:策略未启用时探针应正常执行,实际返回 %q", out)
|
||||
}
|
||||
if !hit {
|
||||
t.Fatal("前置条件不成立:探针未被调用")
|
||||
}
|
||||
|
||||
// 再打开黑名单 ⇒ 探针必须**一次都不再被调用**。
|
||||
hit = false
|
||||
a.SetSecurity(policyRuntime(t, toolName))
|
||||
out := a.executeToolCall(toolCallTo(toolName), "test")
|
||||
|
||||
if hit {
|
||||
t.Fatal("★ 被策略拒绝的工具仍被执行了 —— 策略闸没接在实际执行链上")
|
||||
}
|
||||
if !strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("拒绝必须以结构化文案回给模型(便于它换工具),实际 %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 拒绝原因必须**可执行**:带上策略给的理由,而不是泛泛的 "denied"。
|
||||
func TestToolDenialCarriesActionableReason(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.SetSecurity(policyRuntime(t, "cmd_*"))
|
||||
|
||||
out := a.executeToolCall(toolCallTo("cmd_run"), "test")
|
||||
if !strings.Contains(out, "cmd_*") {
|
||||
t.Fatalf("拒绝文案应包含命中的黑名单条目(模型据此才知道边界),实际 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "denied") {
|
||||
t.Fatalf("应带 denied 原因码(与 unauthorized 区分),实际 %q", out)
|
||||
}
|
||||
}
|
||||
205
internal/agent/core/segpaging_e2e_test.go
Normal file
205
internal/agent/core/segpaging_e2e_test.go
Normal file
@ -0,0 +1,205 @@
|
||||
package core
|
||||
|
||||
// 段页式**端到端**验收(跨 L1→L2→L1 全链路,2026-10-29)。
|
||||
//
|
||||
// 前面的判据各自只钉一个环节(字段往返、切块、剔除、回收)。
|
||||
// 本文件走一遍真实流转,验证五条要求在**联动**时仍成立:
|
||||
//
|
||||
// ① 一次任务(段/frame)= 一条事件;时间戳作帧号
|
||||
// ② 页 = llm_round + 若干 tool_round,call 与 result 靠 CallID 配对
|
||||
// ③ 归档到 L2 后,块带着帧/页身份(ID/帧/向量不变,只换持有层)
|
||||
// ④ 召回回填时,ToolCalls/ToolResults 连同 CallID 一起回来(配对不破)
|
||||
// ⑤ 中途任何一步都不会让"三层都不在"发生
|
||||
//
|
||||
// 这些必须在 L1→L2→L1 走一遍才看得出来:单看某一层,
|
||||
// 字段都"存在",但可能在搬运时被丢掉。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
)
|
||||
|
||||
// ①~④ 归档→召回:工具调用的帧/页身份与配对键必须完整往返。
|
||||
func TestSegPagingRoundTripsThroughL2(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
base := time.Date(2026, 10, 29, 20, 0, 0, 0, time.UTC)
|
||||
want := ContextEvent{
|
||||
Timestamp: base,
|
||||
Source: "segpaging-src",
|
||||
Input: "帮我查一下这个报错的原因,顺便看看磁盘够不够",
|
||||
Response: "报错是权限问题;磁盘还剩 38%。",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: "c_log", Name: "log_grep", Arguments: map[string]interface{}{"pattern": "permission denied"}},
|
||||
{CallID: "c_disk", Name: "disk_usage", Arguments: map[string]interface{}{"path": "/"}},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: "c_log", Name: "log_grep", Output: "EACCES: permission denied, open /etc/shadow"},
|
||||
{CallID: "c_disk", Name: "disk_usage", Output: "/dev/sda1 62% /"},
|
||||
},
|
||||
}
|
||||
archiveEvents(t, a, []ContextEvent{want})
|
||||
|
||||
// 归档后 L1 不该再有这条
|
||||
if n := countSource(a.context, "segpaging-src"); n != 0 {
|
||||
t.Fatalf("归档后 L1 仍有 %d 条", n)
|
||||
}
|
||||
|
||||
// 召回
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "coldcontext_query",
|
||||
Arguments: map[string]interface{}{"query": "权限报错 磁盘", "top_k": float64(5)},
|
||||
})
|
||||
if strings.Contains(out, "no relevant cold context") {
|
||||
t.Fatalf("未召回:%s", out)
|
||||
}
|
||||
|
||||
// ① 回填的事件仍在原始时间戳
|
||||
var got *ContextEvent
|
||||
for _, e := range a.context.Recent(0) {
|
||||
if e.Source == "segpaging-src" {
|
||||
ev := e // Recent 返回值切片,取地址要自己造
|
||||
got = &ev
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("回填后 L1 找不到该事件")
|
||||
}
|
||||
if !got.Timestamp.Equal(base) {
|
||||
t.Fatalf("★ 时间戳漂移(帧号应不变): %v vs %v", got.Timestamp, base)
|
||||
}
|
||||
|
||||
// ② 配对键完整(这是渲染不 400 的前提)
|
||||
if len(got.ToolCalls) != 2 || len(got.ToolResults) != 2 {
|
||||
t.Fatalf("★ 工具记录数量不对: calls=%d results=%d",
|
||||
len(got.ToolCalls), len(got.ToolResults))
|
||||
}
|
||||
byCall := map[string]string{}
|
||||
for _, tr := range got.ToolResults {
|
||||
byCall[tr.CallID] = tr.Output
|
||||
}
|
||||
if !strings.Contains(byCall["c_log"], "permission denied") {
|
||||
t.Fatalf("★ c_log 配错/丢失: %+v", got.ToolResults)
|
||||
}
|
||||
if !strings.Contains(byCall["c_disk"], "62%") {
|
||||
t.Fatalf("★ c_disk 配错/丢失: %+v", got.ToolResults)
|
||||
}
|
||||
// ③ 参数也必须回来(否则页的 call 侧是空的)
|
||||
for _, tc := range got.ToolCalls {
|
||||
if len(tc.Arguments) == 0 {
|
||||
t.Fatalf("★ 召回后参数丢失(call 侧空页): %+v", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 归档到 L2 的块必须带帧/页身份(ID/帧/向量不变,只换层)。
|
||||
func TestArchivedBlocksCarryFrameIdentity(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
base := time.Date(2026, 10, 29, 21, 0, 0, 0, time.UTC)
|
||||
ev := ContextEvent{
|
||||
Timestamp: base, Source: "frame-src",
|
||||
Input: "紫藤花架的搭建方法", Response: "需要四根立柱。",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: "c_w", Name: "weather", Arguments: map[string]interface{}{"city": "新乡"}},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: "c_w", Name: "weather", Output: "晴 22℃"},
|
||||
},
|
||||
}
|
||||
archiveEvents(t, a, []ContextEvent{ev})
|
||||
|
||||
// 找出这篇文档
|
||||
var docID string
|
||||
for _, d := range a.coldcontextStore.RecentColdContexts(100) {
|
||||
if len(d.Events) > 0 && d.Events[0].Source == "frame-src" {
|
||||
docID = d.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if docID == "" {
|
||||
t.Fatal("归档文档未找到")
|
||||
}
|
||||
|
||||
d := a.coldcontextStore.Get(docID)
|
||||
if d == nil || len(d.Events) == 0 {
|
||||
t.Fatal("文档内容为空")
|
||||
}
|
||||
de := d.Events[0]
|
||||
|
||||
// 事件级:ToolCalls 必须落到 L2
|
||||
if len(de.ToolCalls) != 1 || de.ToolCalls[0].CallID != "c_w" {
|
||||
t.Fatalf("★ L2 事件缺 ToolCalls/CallID: %+v", de.ToolCalls)
|
||||
}
|
||||
if len(de.ToolCalls[0].Arguments) == 0 {
|
||||
t.Fatalf("★ L2 事件丢了参数: %+v", de.ToolCalls[0])
|
||||
}
|
||||
|
||||
// 块级:派生出的块必须带帧/页身份
|
||||
toolBlocks := 0
|
||||
for _, b := range de.Blocks {
|
||||
if b.PageKind == memory.PageToolRound {
|
||||
toolBlocks++
|
||||
if b.FrameTS.IsZero() {
|
||||
t.Fatalf("★ L2 块缺 FrameTS: %+v", b)
|
||||
}
|
||||
if !b.FrameTS.Equal(base) {
|
||||
t.Fatalf("★ L2 块的帧号与事件不一致: %v vs %v", b.FrameTS, base)
|
||||
}
|
||||
if b.CallID == "" {
|
||||
t.Fatalf("★ L2 工具页块缺 CallID(配对键): %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolBlocks == 0 {
|
||||
t.Log("该文档未派生工具页块(可能按存量路径处理),检查 docBlocksForEntry")
|
||||
}
|
||||
}
|
||||
|
||||
// 铁律验收:同一帧的所有块共享同一 FrameTS(不跨帧)。
|
||||
func TestSameFrameBlocksNeverSplit(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
base := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||
ev := ContextEvent{
|
||||
Timestamp: base, Source: "nosplit-src",
|
||||
Input: "同时检查 CPU 和内存", Response: "都查了。",
|
||||
ToolCalls: []ToolCallItem{
|
||||
{CallID: "c_cpu", Name: "sys", Arguments: map[string]interface{}{"k": "cpu"}},
|
||||
{CallID: "c_mem", Name: "sys", Arguments: map[string]interface{}{"k": "mem"}},
|
||||
{CallID: "c_net", Name: "sys", Arguments: map[string]interface{}{"k": "net"}},
|
||||
},
|
||||
ToolResults: []ToolResultItem{
|
||||
{CallID: "c_cpu", Name: "sys", Output: "CPU 12%"},
|
||||
{CallID: "c_mem", Name: "sys", Output: "内存 41%"},
|
||||
{CallID: "c_net", Name: "sys", Output: "网络正常"},
|
||||
},
|
||||
}
|
||||
archiveEvents(t, a, []ContextEvent{ev})
|
||||
|
||||
var d = a.coldcontextStore.Get(func() string {
|
||||
for _, x := range a.coldcontextStore.RecentColdContexts(100) {
|
||||
if len(x.Events) > 0 && x.Events[0].Source == "nosplit-src" {
|
||||
return x.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}())
|
||||
if d == nil {
|
||||
t.Fatal("文档未找到")
|
||||
}
|
||||
|
||||
// 同一帧的所有块:FrameTS 必须全部相同
|
||||
seen := map[string]int{}
|
||||
for _, b := range d.Events[0].Blocks {
|
||||
if b.FrameTS.IsZero() {
|
||||
continue
|
||||
}
|
||||
seen[b.FrameTS.Format(time.RFC3339Nano)]++
|
||||
}
|
||||
if len(seen) > 1 {
|
||||
t.Fatalf("★ 同一帧的块分裂到 %d 个 FrameTS(违反铁律): %v", len(seen), seen)
|
||||
}
|
||||
}
|
||||
@ -102,7 +102,7 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
|
||||
}
|
||||
|
||||
// ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点
|
||||
for _, d := range a.docStore.RecentDocs(1000) {
|
||||
for _, d := range a.coldcontextStore.RecentColdContexts(1000) {
|
||||
if !d.HasEvents() {
|
||||
continue
|
||||
}
|
||||
@ -123,10 +123,10 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
|
||||
Response: "好。",
|
||||
})
|
||||
}
|
||||
a.context.Prune("当前输入", 2, a.docStore)
|
||||
a.context.Prune("当前输入", 2, a.coldcontextStore)
|
||||
|
||||
var foundInL2 bool
|
||||
for _, d := range a.docStore.RecentDocs(1000) {
|
||||
for _, d := range a.coldcontextStore.RecentColdContexts(1000) {
|
||||
if !d.HasEvents() {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/meta"
|
||||
@ -87,7 +87,7 @@ func collectKernelStatus(
|
||||
pluginReg *plugin.Registry,
|
||||
memDB *memory.GraphDB,
|
||||
ks interface{ List() []string },
|
||||
docStore *document.Store,
|
||||
coldcontextStore *coldcontext.Store,
|
||||
textMem *text.Memory,
|
||||
socialStore *social.SocialStore,
|
||||
trk *tracker.Tracker,
|
||||
@ -198,9 +198,9 @@ func collectKernelStatus(
|
||||
}
|
||||
|
||||
// Documents
|
||||
if docStore != nil {
|
||||
if coldcontextStore != nil {
|
||||
status.Documents.Available = true
|
||||
stats := docStore.Stats()
|
||||
stats := coldcontextStore.Stats()
|
||||
if dc, ok := stats["doc_count"].(int); ok {
|
||||
status.Documents.DocCount = dc
|
||||
}
|
||||
@ -281,7 +281,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
|
||||
a.pluginReg,
|
||||
a.memory,
|
||||
knowledgeLister,
|
||||
a.docStore,
|
||||
a.coldcontextStore,
|
||||
textMem,
|
||||
socialStore,
|
||||
trk,
|
||||
|
||||
@ -103,10 +103,13 @@ type TaskFrame struct {
|
||||
StageCtx *sdk.StageContext
|
||||
|
||||
// 跨轮次状态
|
||||
Msgs []agentAPI.Message
|
||||
Tools []interface{}
|
||||
ToolsUsed []string
|
||||
ToolResults []ToolResultItem
|
||||
Msgs []agentAPI.Message
|
||||
Tools []interface{}
|
||||
ToolsUsed []string
|
||||
ToolResults []ToolResultItem
|
||||
// ToolCalls 是每一次工具调用的完整记录(含参数)。与 ToolResults 通过
|
||||
// CallID 配对;两者都由 appendToolCallRecord 同步追加,不各写一份。
|
||||
ToolCalls []ToolCallItem
|
||||
Turn int
|
||||
LastBatchReplyOnly bool
|
||||
|
||||
@ -578,6 +581,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
|
||||
Response: f.Response,
|
||||
ToolsUsed: f.ToolsUsed,
|
||||
ToolResults: f.ToolResults,
|
||||
ToolCalls: f.ToolCalls,
|
||||
}
|
||||
a.bindEventMedia(&turnEvt, a.drainMediaBlocks())
|
||||
// ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append),
|
||||
@ -657,7 +661,7 @@ func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
|
||||
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
|
||||
len(f.Tools), a.context.Len(),
|
||||
a.personality != nil && a.personality.Content != "",
|
||||
a.docStoreSize())
|
||||
a.coldcontextStoreSize())
|
||||
|
||||
if a.runStage(sdk.StagePreAction, f.StageCtx) {
|
||||
f.Response = *f.StageCtx.Response
|
||||
@ -956,7 +960,7 @@ func (a *Agent) stepToolBatch(f *TaskFrame) stepOutcome {
|
||||
f.CurResult = r.text
|
||||
f.CurRaw = r.raw
|
||||
f.ToolIdx = i
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: r.name, Output: r.text})
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: r.name, Output: r.text})
|
||||
if out := a.stepToolAfter(f); out != outcomeContinue {
|
||||
return out
|
||||
}
|
||||
@ -1034,7 +1038,7 @@ func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
|
||||
}
|
||||
tc := f.PendingTools[f.ToolIdx]
|
||||
|
||||
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
|
||||
f.appendToolCallRecord(tc)
|
||||
pluginName := a.resolveToolPlugin(tc.Name)
|
||||
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
|
||||
if tc.RawArguments != "" {
|
||||
@ -1096,6 +1100,41 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string {
|
||||
return fmt.Sprintf("工具 %s 已被插件拒绝", toolName)
|
||||
}
|
||||
|
||||
// appendToolCallRecord 记录一次工具调用(名 + 参数 + 配对键)。
|
||||
//
|
||||
// ## 为什么必须与 ToolsUsed 同处维护
|
||||
//
|
||||
// ToolsUsed 是「用过的工具名」的便捷视图,ToolCalls 是配对/页化所需的
|
||||
// 事实源。两份数据若各自 append,任何一条路径漏写就出现
|
||||
// 「有结果没调用记录」或反之 —— 而那种破配对在渲染时是 400,
|
||||
// 在记忆里是「这一页永远配不上对」。
|
||||
//
|
||||
// ## 为什么要脱敏
|
||||
//
|
||||
// 参数由模型给出,正常是别名(sec_xxx),但插件工具可能被直接传明文
|
||||
// (实测:模型自己拼了一个 key 传进 enc_shell)。落盘后永久留存、
|
||||
// 且每轮被召回,所以必须过与 Input/Response 同一条闸。
|
||||
// 脱敏在 Append 内还会再走一次(scrubEventSecrets),这里是落 ToolCalls
|
||||
// 之前的第一道(因为 ToolCalls 是结构化的,Append 的文本闸看不到字段内部)。
|
||||
func (f *TaskFrame) appendToolCallRecord(tc agentAPI.ToolCall) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
|
||||
// 原样记录;脱敏统一由 scrubEventSecrets 在 Append 内做(那里是唯一咽喉)。
|
||||
//
|
||||
// ★ 为什么不在这里就地脱敏:那会造出**第二处脱敏点**。
|
||||
// Input/Response/ToolResults/Blocks 都只在 scrubEventSecrets 一处洗,
|
||||
// 多一个局部实现就多一个会漏的地方(本仓为此栽过多次)。
|
||||
// 代价是参数里的明文会在内存里多存活一会儿,落盘前已被洗掉。
|
||||
f.ToolCalls = append(f.ToolCalls, ToolCallItem{
|
||||
CallID: tc.ID,
|
||||
Name: tc.Name,
|
||||
Arguments: tc.Arguments,
|
||||
RawArguments: tc.RawArguments,
|
||||
})
|
||||
}
|
||||
|
||||
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
|
||||
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
|
||||
@ -1105,7 +1144,7 @@ func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
|
||||
result := outcome.Text
|
||||
f.CurResult = result
|
||||
f.CurRaw = outcome.Raw
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
|
||||
f.ToolResults = append(f.ToolResults, ToolResultItem{CallID: f.CurTool.ID, Name: f.CurTool.Name, Output: result})
|
||||
// ★ 工具输出可能含服务端回显的凭据(实测场景:enc_shell 跑 curl 拿 200,
|
||||
// 响应体里带着上游 key 的完整值)。这一行以前原样打印。
|
||||
log.Printf("[agent] tool %s result: %s", f.CurTool.Name,
|
||||
|
||||
@ -81,7 +81,7 @@ func TestToolAPIPathIgnoresNonDeviceTools(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.allowedOutputs = []string{"device/ok-1"} // 收窄到只给一台设备
|
||||
|
||||
for _, name := range []string{"cmd_run", "knowledge_search", "memory_recall", "output_list_channels"} {
|
||||
for _, name := range []string{"cmd_run", "knowledge_search", "stablemem_recall", "output_list_channels"} {
|
||||
if !toolAPIOf(t, a).CanUse(name, map[string]interface{}{}) {
|
||||
t.Errorf("非设备工具 %q 被设备授权闸拦了 —— 闸的作用域过宽", name)
|
||||
}
|
||||
@ -255,7 +255,7 @@ func TestBuiltinParallelDeclaredWhereDefined(t *testing.T) {
|
||||
|
||||
// 写类工具绝不能出现在聚合表的可并发集合里
|
||||
for _, n := range []string{
|
||||
"memory_merge", "memory_delete_entity", "knowledge_create", "coldcontext_commit",
|
||||
"stablemem_merge", "stablemem_delete_entity", "knowledge_create", "coldcontext_commit",
|
||||
"persona_set", "person_set_trait", "llm_set_source", "spawn_child",
|
||||
} {
|
||||
if concurrencySafeOf(n) {
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
@ -261,6 +261,33 @@ func (a *Agent) executeToolCallInner(ctx context.Context, tc agentAPI.ToolCall,
|
||||
return toolOutcome{Text: renderToolError(tc.Name, ve), Raw: ve}
|
||||
}
|
||||
|
||||
// ★★ 安全策略闸(2026-10-29 接线)。
|
||||
//
|
||||
// ## 为什么在这里
|
||||
//
|
||||
// 这是**分派之前**最后一个公共点:再往下就分成 device 路由、io 工具、
|
||||
// 插件工具、设备授权等多个 execute 分支,逐个加必然漏一个(与出向
|
||||
// 脱敏同样的理由,那边用的是 defer;这里用前置闸,因为拒绝必须**阻止执行**)。
|
||||
//
|
||||
// ## 为什么参数校验在前、策略闸在后
|
||||
//
|
||||
// 两者关心的东西不同且不能互相顶替:
|
||||
// 参数校验:这个调用写得对不对(模型可以自己改)
|
||||
// 策略闸 :这个工具准不准用(模型改参数也没用,必须换工具或报人)
|
||||
// 先报参数错能给出更可执行的下一步;参数合法后才谈授权,避免
|
||||
// "策略拒了但真实原因其实是参数写坏了"这种误导。
|
||||
//
|
||||
// ## 为什么 Reason 原样回给模型
|
||||
//
|
||||
// 拒绝必须可执行(policy.go 的纪律):模型据此能改路径或换工具,
|
||||
// 而不是反复重试同一条被拒的调用。
|
||||
if d := a.securityDecisionFor(tc.Name); !d.Allowed {
|
||||
log.Printf("[security] tool %s denied by policy: %s", tc.Name, d.Reason)
|
||||
te := newToolError(ErrReasonDenied, "", fmt.Sprintf("Tool %s is not permitted: %s", tc.Name, d.Reason),
|
||||
"This tool is blocked by security policy. Do not retry it with the same or different arguments; use another tool or report back to the user.")
|
||||
return toolOutcome{Text: te.Error(), Raw: te}
|
||||
}
|
||||
|
||||
// ★ 内置工具的路由交给 device 模块(`kernelmod/device.Dispatch`)。
|
||||
//
|
||||
// 为什么要搬:这张"前缀 → 族处理器"的表曾是工具面的**第二份事实来源**
|
||||
@ -390,9 +417,11 @@ func isToolNotFound(err error) bool { return agentIO.IsToolNotFound(err) }
|
||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
|
||||
g := a.graphMem()
|
||||
if g == nil {
|
||||
if tc.Name == "memory_document_query" {
|
||||
return a.executeDocTool(tc)
|
||||
}
|
||||
// ★ 此处曾有 `if tc.Name == "memory_document_query" { return a.executeDocTool(tc) }`
|
||||
// (2026-10-29 删除)。它是改名前的残留:文档类工具早已改叫
|
||||
// `coldcontext_*`,而 Dispatch 只把 `stablemem_` 前缀送到本函数,
|
||||
// 所以这个分支**永远不可达**。留着它的唯一效果是让读代码的人
|
||||
// 以为旧工具名还活着。
|
||||
return "stable memory is unavailable"
|
||||
}
|
||||
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
|
||||
@ -410,7 +439,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
return "toolresult_read failed: " + err.Error()
|
||||
}
|
||||
return txt
|
||||
case "memory_recall":
|
||||
case "stablemem_recall":
|
||||
query, _ := tc.Arguments["query_intent"].(string)
|
||||
depth, _ := tc.Arguments["depth"].(float64)
|
||||
if depth <= 0 {
|
||||
@ -492,7 +521,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
|
||||
case "memory_block_merge":
|
||||
case "stablemem_block_merge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -511,7 +540,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
a.noMergeMu.Unlock()
|
||||
return fmt.Sprintf("Marked \"%s\" and \"%s\" as not-mergeable for %d rounds", entityA, entityB, int(rounds))
|
||||
|
||||
case "memory_commit":
|
||||
case "stablemem_commit":
|
||||
triplesData, ok := tc.Arguments["triples"].([]interface{})
|
||||
if !ok {
|
||||
return "invalid arguments: triples must be an array"
|
||||
@ -638,7 +667,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
return fmt.Sprintf("Wrote %d memory blocks and %d relation edges", newBlocks, newEdges)
|
||||
|
||||
case "memory_introspect":
|
||||
case "stablemem_introspect":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -648,10 +677,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
return fmt.Sprintf("memory stats: %v", stats)
|
||||
|
||||
case "memory_document_query":
|
||||
return a.executeDocTool(tc)
|
||||
|
||||
case "memory_merge":
|
||||
case "stablemem_merge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -666,7 +692,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
return fmt.Sprintf("Merged \"%s\" into \"%s\": source deleted, %d relations redirected", source, target, count)
|
||||
|
||||
case "memory_delete_entity":
|
||||
case "stablemem_delete_entity":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -689,7 +715,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
return fmt.Sprintf("Deleted block \"%s\" and its %d relations (%d blocks)",
|
||||
name, res.Edges, res.Blocks)
|
||||
|
||||
case "memory_purge":
|
||||
case "stablemem_purge":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -726,7 +752,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
|
||||
case "memory_edit":
|
||||
case "stablemem_edit":
|
||||
if msg := requireFull(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
@ -1062,7 +1088,7 @@ func (a *Agent) executeKnowledgeTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
|
||||
func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if a.docStore == nil {
|
||||
if a.coldcontextStore == nil {
|
||||
return "cold context is unavailable"
|
||||
}
|
||||
switch tc.Name {
|
||||
@ -1075,22 +1101,62 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if query == "" {
|
||||
return "query is required"
|
||||
}
|
||||
docs := a.docStore.Consume(query, topK)
|
||||
// ★★ 两阶段:**先 Peek(只读),确认落地后才删 L2 副本**(2026-10-29 P4)。
|
||||
//
|
||||
// 原实现是 Consume(读+删一步)。删除是不可逆步骤,却发生在
|
||||
// 「确认回灌真的成功」之前 —— 于是回灌路径上一旦失败(向量算错、
|
||||
// panic、被中断),L1 没进、L2 已删,**三层都不在**,内容永久丢失。
|
||||
// 这是 plan.md 记的第四个病。
|
||||
//
|
||||
// 现在:Peek → 逐条回灌 → 该条落进 L1 才 Remove 它。
|
||||
// 失败的那条**留着**,下次还能再取(宁可重复回忆,不可静默丢失)。
|
||||
docs := a.coldcontextStore.Peek(query, topK)
|
||||
if len(docs) == 0 {
|
||||
return "no relevant cold context found"
|
||||
}
|
||||
|
||||
// ★★ 召回前**先估算**,超预算则按页相似度剔除(用户裁定,2026-10-29)
|
||||
//
|
||||
// 算法(逐字):
|
||||
// 召回过程中要先估算召回后的 token,如果整段召回不超预算,则直接召回,
|
||||
// 若超了则对段内每一页算相似度,将最不相似的几个页标记为 inactive,
|
||||
// 直到不超预算
|
||||
//
|
||||
// 为什么要"先估算"而不是"回灌完再裁":
|
||||
// 后者意味着先占满窗口、再剔除 —— 但那一步之前,
|
||||
// `formatMergedTimeline` 已经按预算把**更早的真实历史**丢掉了。
|
||||
// 等于用"剔除刚召回的"去换"丢失原有的",净亏。
|
||||
//
|
||||
// 预算取 ContextTokens 减去当前时间线已占用:召回内容与已有事件
|
||||
// 要一起渲染,只看整窗会在"加上原有内容"后才发现爆了。
|
||||
budget := a.recallBudget()
|
||||
plan := a.context.planColdRecall(docs, query, budget)
|
||||
if plan.Over {
|
||||
log.Printf("[coldcontext] recall over budget: need %d tokens, budget %d — "+
|
||||
"evicted %d page(s) across %d doc(s) by lowest similarity (now %d tokens)",
|
||||
plan.OriginalNeed, plan.Budget, countPlanPages(plan.Evict),
|
||||
len(plan.Evict), plan.Need)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
|
||||
if plan.Over {
|
||||
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn). "+
|
||||
"The recall was over the context budget, so the least relevant pages were marked inactive (not rendered): %d page(s) across %d document(s).\n",
|
||||
len(docs), countPlanPages(plan.Evict), len(plan.Evict)))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("Recalled %d cold-context documents; their original text is re-inserted into your timeline at its own original timestamps (not the current turn):\n", len(docs)))
|
||||
}
|
||||
|
||||
totalEvents := 0
|
||||
kept := 0 // 回灌失败、保留在 L2 的篇数
|
||||
for i, d := range docs {
|
||||
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
|
||||
//
|
||||
// ARCHITECTURE「记忆流转」② Document 层:
|
||||
// coldcontext_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
n := a.restoreDocEvents(d)
|
||||
n := a.restoreDocEvents(d, plan.Evict[d.ID])
|
||||
restored := n > 0 // 是否真的落进了 L1
|
||||
if n == 0 {
|
||||
// 存量文档(无 Events)降级:整篇渲成一个事件。
|
||||
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
|
||||
@ -1106,23 +1172,36 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
NoMemory: true, // 同上:回填不产生新记忆
|
||||
})
|
||||
n = 1
|
||||
restored = true
|
||||
}
|
||||
totalEvents += n
|
||||
|
||||
// ② 确认:只有**真的落进 L1** 才删 L2 副本。
|
||||
if restored {
|
||||
a.coldcontextStore.Remove(d.ID)
|
||||
} else {
|
||||
kept++
|
||||
log.Printf("[coldcontext] doc %s not restored (%d events), keeping L2 copy for retry", d.ID, n)
|
||||
}
|
||||
|
||||
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
|
||||
// 让模型**立刻**能判断这笔召回有没有用。
|
||||
//
|
||||
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
|
||||
// jieba 词袋,零信息量,模型无法据此判断相关性。
|
||||
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
|
||||
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, coldcontextRecallHeadline(d, n)))
|
||||
|
||||
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
|
||||
if preview := docRecallPreview(d, n, 3); preview != "" {
|
||||
if preview := coldcontextRecallPreview(d, n, 3); preview != "" {
|
||||
b.WriteString(preview)
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\n%d events backfilled in total. To quote details, read the timeline at those timestamps.", totalEvents))
|
||||
if kept > 0 {
|
||||
// 明说「有 N 篇没取出来」:否则模型会以为自己看到了全部相关记忆。
|
||||
b.WriteString(fmt.Sprintf("\nNote: %d document(s) could not be restored this time and remain in cold storage (retry later).", kept))
|
||||
}
|
||||
return b.String()
|
||||
|
||||
case "coldcontext_commit":
|
||||
@ -1135,7 +1214,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
summary = truncateStr(content, 100)
|
||||
}
|
||||
|
||||
doc := &document.Doc{
|
||||
doc := &coldcontext.ColdDoc{
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Source: "manual",
|
||||
@ -1150,7 +1229,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.docStore.Insert(doc); err != nil {
|
||||
if err := a.coldcontextStore.Insert(doc); err != nil {
|
||||
return fmt.Sprintf("cold context write failed: %v", err)
|
||||
}
|
||||
if n := len(doc.Blocks); n > 0 {
|
||||
@ -1332,7 +1411,7 @@ func (a *Agent) currentFingerprint() string {
|
||||
//
|
||||
// coldcontext_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
// → 文档以原始时间戳写入 context 时间线, 从 coldcontextStore 删除
|
||||
//
|
||||
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
|
||||
// 块 ID 与向量全程不变(不重建、不重算)。
|
||||
@ -1341,7 +1420,12 @@ func (a *Agent) currentFingerprint() string {
|
||||
//
|
||||
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
|
||||
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
|
||||
func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
// restoreDocEvents 把一篇 L2 文档的事件逐条迁回 L1。
|
||||
//
|
||||
// inactivePages 是本篇要**剔除**(标注不渲染)的页序号。
|
||||
// 传 nil 表示整篇完整回灌。剔除是标注而非删除:数据全在,
|
||||
// 清空 InactivePages 即可恢复渲染。
|
||||
func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) int {
|
||||
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
|
||||
return 0
|
||||
}
|
||||
@ -1368,11 +1452,29 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
ToolResults: func() []ToolResultItem {
|
||||
var out []ToolResultItem
|
||||
for _, tr := range ev.ToolResults {
|
||||
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
|
||||
out = append(out, ToolResultItem{CallID: tr.CallID, Name: tr.Name, Output: tr.Output})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
// ★ 回填必须带上工具调用记录(含参数)。
|
||||
// 漏了它,回灌后这一页的 call 侧是空的:
|
||||
// 既无法配对校验,也无法按页渲染/页化。
|
||||
ToolCalls: func() []ToolCallItem {
|
||||
if len(ev.ToolCalls) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ToolCallItem, len(ev.ToolCalls))
|
||||
for i, tc := range ev.ToolCalls {
|
||||
out[i] = ToolCallItem{
|
||||
CallID: tc.CallID, Name: tc.Name,
|
||||
Arguments: tc.Arguments, RawArguments: tc.RawArguments,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
Blocks: append([]memory.MemoryBlock(nil), held...),
|
||||
// 召回期剔除的页(裁剪预算用):标注但数据保留
|
||||
InactivePages: inactivePages,
|
||||
})
|
||||
n++
|
||||
blocks += len(held)
|
||||
@ -1385,12 +1487,12 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
|
||||
// coldcontextRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
|
||||
//
|
||||
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
|
||||
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
|
||||
// coldcontext_query 的直接原因之一。
|
||||
func docRecallHeadline(d *document.Doc, eventCount int) string {
|
||||
func coldcontextRecallHeadline(d *coldcontext.ColdDoc, eventCount int) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
@ -1440,11 +1542,11 @@ func docRecallHeadline(d *document.Doc, eventCount int) string {
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
|
||||
// coldcontextRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
|
||||
//
|
||||
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
|
||||
// 这里只是"先给你看一眼是什么",不是全文。
|
||||
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
|
||||
func coldcontextRecallPreview(d *coldcontext.ColdDoc, eventCount, maxLines int) string {
|
||||
if d == nil || maxLines <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -58,7 +58,7 @@ func (s fixedSpace) Close() {}
|
||||
func callRecall(t *testing.T, a *Agent, q string) string {
|
||||
t.Helper()
|
||||
return a.executeMemoryTool(api.ToolCall{
|
||||
ID: "c1", Name: "memory_recall",
|
||||
ID: "c1", Name: "stablemem_recall",
|
||||
Arguments: map[string]interface{}{"query_intent": q},
|
||||
}, nil)
|
||||
}
|
||||
|
||||
116
internal/agent/core/toolcall_record_test.go
Normal file
116
internal/agent/core/toolcall_record_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package core
|
||||
|
||||
// 工具调用**完整记录**的判据(2026-10-29 还技术债)。
|
||||
//
|
||||
// ## 为什么必须有
|
||||
//
|
||||
// 改动前的事件形状(生产数据实证):
|
||||
//
|
||||
// tools_used: ["qq_get_message", "output_send__qq"]
|
||||
// tool_results: [{name, output}, {name, output}] ← 只有 name/output
|
||||
//
|
||||
// 也就是说:**参数完全没有被记录**。后果不是"少了个字段",而是三个能力失效:
|
||||
//
|
||||
// ① 回灌后无法知道"当时用什么参数调用的",只能看到结果
|
||||
// ② 段页式页化无法实现 —— toolcall 与其 result 配对成一页,
|
||||
// call 侧只有名字的话那页是半空的
|
||||
// ③ 同名工具的多次调用无法配对(两个 qq_get_history 分不清谁是谁)
|
||||
//
|
||||
// 本文件钉三件事:
|
||||
// ① 记录里含 CallID / Name / Arguments / RawArguments
|
||||
// ② ToolsUsed 与 ToolCalls **同步**维护(不能只写一份)
|
||||
// ③ 参数里的明文凭据必须过闸(新增字段不能绕过脱敏层)
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// ① 记录完整:四个字段都在。
|
||||
func TestToolCallRecordCapturesArguments(t *testing.T) {
|
||||
f := &TaskFrame{}
|
||||
f.appendToolCallRecord(agentAPI.ToolCall{
|
||||
ID: "call_1",
|
||||
Name: "cmd_run",
|
||||
Arguments: map[string]interface{}{"command": "ls -la", "timeout": float64(30)},
|
||||
RawArguments: `{"command":"ls -la","timeout":30}`,
|
||||
})
|
||||
|
||||
if len(f.ToolCalls) != 1 {
|
||||
t.Fatalf("应记录 1 条调用,实际 %d", len(f.ToolCalls))
|
||||
}
|
||||
got := f.ToolCalls[0]
|
||||
if got.CallID != "call_1" || got.Name != "cmd_run" {
|
||||
t.Fatalf("配对键/名字丢失: %+v", got)
|
||||
}
|
||||
// ★ 这条是核心:参数必须留存
|
||||
if got.Arguments["command"] != "ls -la" {
|
||||
t.Fatalf("★ 参数未记录(这正是本次要还的技术债): %+v", got.Arguments)
|
||||
}
|
||||
if !strings.Contains(got.RawArguments, "ls -la") {
|
||||
t.Fatalf("原始参数未记录: %q", got.RawArguments)
|
||||
}
|
||||
}
|
||||
|
||||
// ② ToolsUsed 与 ToolCalls 必须同步。
|
||||
func TestToolCallRecordKeepsToolsUsedInSync(t *testing.T) {
|
||||
f := &TaskFrame{}
|
||||
f.appendToolCallRecord(agentAPI.ToolCall{ID: "c1", Name: "a"})
|
||||
f.appendToolCallRecord(agentAPI.ToolCall{ID: "c2", Name: "b"})
|
||||
|
||||
if len(f.ToolsUsed) != len(f.ToolCalls) {
|
||||
t.Fatalf("★ ToolsUsed(%d) 与 ToolCalls(%d) 不一致 —— 两份数据必须同处维护",
|
||||
len(f.ToolsUsed), len(f.ToolCalls))
|
||||
}
|
||||
for i := range f.ToolsUsed {
|
||||
if f.ToolsUsed[i] != f.ToolCalls[i].Name {
|
||||
t.Fatalf("第 %d 项不同步: ToolsUsed=%q ToolCalls.Name=%q",
|
||||
i, f.ToolsUsed[i], f.ToolCalls[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 参数里的明文必须被脱敏(新增字段不能绕过这一层)。
|
||||
//
|
||||
// 这条用 scrubEventSecrets(Append 内的唯一咽喉)验证,
|
||||
// 而不是验证 appendToolCallRecord —— 后者刻意不脱敏(见其注释:
|
||||
// 多一个脱敏点就多一个会漏的地方)。
|
||||
func TestToolCallArgumentsAreScrubbed(t *testing.T) {
|
||||
evt := &ContextEvent{
|
||||
ToolCalls: []ToolCallItem{{
|
||||
CallID: "c1", Name: "enc_shell",
|
||||
Arguments: map[string]interface{}{"cmd": "curl -H 'Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789'"},
|
||||
RawArguments: `{"cmd":"ghp_abcdefghijklmnopqrstuvwxyz0123456789"}`,
|
||||
}},
|
||||
}
|
||||
initScrubStore(t)
|
||||
scrubEventSecrets(evt)
|
||||
|
||||
arg, _ := evt.ToolCalls[0].Arguments["cmd"].(string)
|
||||
if strings.Contains(arg, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") {
|
||||
t.Fatalf("★ 参数里的明文未被脱敏: %q", arg)
|
||||
}
|
||||
if strings.Contains(evt.ToolCalls[0].RawArguments, "ghp_abcdefghijklmnopqrstuvwxyz0123456789") {
|
||||
t.Fatalf("★ 原始参数里的明文未被脱敏: %q", evt.ToolCalls[0].RawArguments)
|
||||
}
|
||||
}
|
||||
|
||||
// initScrubStore 装一个启用状态的变量库,供脱敏判据使用。
|
||||
//
|
||||
// 单独封装而不是内联:脱敏依赖进程级单例(security.Global),
|
||||
// 装配与清理成对出现才不会污染同包其他用例。
|
||||
func initScrubStore(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if _, err := security.InitGlobal(security.Options{
|
||||
Path: dir + "/v.json",
|
||||
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { security.SetGlobal(nil) })
|
||||
}
|
||||
@ -28,7 +28,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
|
||||
// 删除,两者状态不同,必须显式告知。
|
||||
//
|
||||
// 注意不能直接用 injected.Relations:BuildContext 刻意把它置为 nil
|
||||
//(自动注入只给实体索引以省 token,细节留给 memory_recall)。
|
||||
//(自动注入只给实体索引以省 token,细节留给 stablemem_recall)。
|
||||
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
|
||||
if mc := a.mediaContextForInjectedEntities(injected); mc != "" {
|
||||
if s != "" {
|
||||
@ -47,7 +47,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
|
||||
//(2026-10-27 现场:头天晚上讲的冷笑话第二天早上完全唤不回)。
|
||||
//
|
||||
// 放进本函数后:输入路用输入文本、工具路用工具正文,两条路都能召回 L2 摘要。
|
||||
if a.docStore != nil {
|
||||
if a.coldcontextStore != nil {
|
||||
if md := a.crossModalMarkdown(a.retrieveCrossModal(input, 3, a.fusionCfg)); md != "" {
|
||||
if s != "" {
|
||||
s += "\n"
|
||||
@ -213,7 +213,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
// ★ 但有一件事必须说:coldcontext_query 会把历史事件**插回时序**,而不是返回
|
||||
// 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。
|
||||
prompt = appendPromptBlock(prompt, "[Memory] Recent turns are above. For older conversations use `coldcontext_query` - it **re-inserts them into your timeline at their original timestamps**, so read them there, not in the return value. "+
|
||||
"Facts and relations: `memory_recall`. Written knowledge: `knowledge_search`.")
|
||||
"Facts and relations: `stablemem_recall`. Written knowledge: `knowledge_search`.")
|
||||
|
||||
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
|
||||
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
|
||||
@ -437,7 +437,7 @@ type toolDefOptions struct {
|
||||
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
|
||||
//
|
||||
// ★ 注意:coldcontext_query 的 noMemory **不写在这里**,而是由 init() 里的
|
||||
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil`
|
||||
// 显式名单登记。原因:工具表是按需构造的(受 `if a.coldcontextStore != nil`
|
||||
// 之类条件控制),而 textForVector 可能在构造之前被调用 ——
|
||||
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
|
||||
//
|
||||
@ -524,7 +524,7 @@ func init() {
|
||||
//
|
||||
// ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记:
|
||||
// 工具表是**按需构造**的(buildToolDefs 每轮重建,且受
|
||||
// `if a.docStore != nil` 之类的条件分支控制),而 textForVector
|
||||
// `if a.coldcontextStore != nil` 之类的条件分支控制),而 textForVector
|
||||
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
|
||||
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
|
||||
//
|
||||
@ -607,25 +607,25 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
|
||||
if a.memory != nil {
|
||||
tools = append(tools, toolDef("memory_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
|
||||
tools = append(tools, toolDef("stablemem_merge", "【记忆清理】合并两个同义实体。将所有关系从 source 重定向到 target,然后彻底删除 source。注意:实体删除后不可恢复,合并前请确认语义一致。", map[string]interface{}{
|
||||
"source": map[string]interface{}{"type": "string", "description": "被合并的实体名(合并后消失)"},
|
||||
"target": map[string]interface{}{"type": "string", "description": "保留的实体名"},
|
||||
}, "source", "target"))
|
||||
tools = append(tools, toolDef("memory_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 memory_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{
|
||||
tools = append(tools, toolDef("stablemem_delete_entity", "【记忆清理】删除指定名称的记忆块及其所有关联关系。name 必须是**块文本**(端点名),不是关系文本;按精确匹配定位,不会连带删掉名字相近的其他块。要按子串批量清理改用 stablemem_purge(subject_contains)。用于清理无用的噪音块、distiller 自动产生的垃圾节点、确认无用的旧数据。此操作不可恢复。", map[string]interface{}{
|
||||
"name": map[string]interface{}{"type": "string", "description": "要删除的实体名称"},
|
||||
}, "name"))
|
||||
tools = append(tools, toolDef("memory_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{
|
||||
tools = append(tools, toolDef("stablemem_block_merge", "【记忆清理】标记两个实体在指定轮次内不尝试合并,用于阻止误判。当 LLM 判断两个实体虽然相似但不是同一事物时,使用此工具阻止后续心跳自动推送合并候选。每次心跳扫描双方计数各减一,归零后恢复候选资格。", map[string]interface{}{
|
||||
"entity_a": map[string]interface{}{"type": "string", "description": "第一个实体名"},
|
||||
"entity_b": map[string]interface{}{"type": "string", "description": "第二个实体名"},
|
||||
"rounds": map[string]interface{}{"type": "integer", "description": "阻止轮次数(每次心跳各减一,归零后恢复)"},
|
||||
}, "entity_a", "entity_b", "rounds"))
|
||||
tools = append(tools, toolDef("memory_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 memory_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{
|
||||
tools = append(tools, toolDef("stablemem_purge", "【记忆清理】删除记忆库中符合条件的垃圾关系和数据。当用户要求整理记忆时,用 stablemem_introspect 发现低质量实体后,用此工具批量删除。如 @merged 后缀的残留实体、mentionCount=0 的孤立实体、distiller 自动生成的噪音关系等。支持软删(soft)和物理删除(hard)。", map[string]interface{}{
|
||||
"subject_contains": map[string]interface{}{"type": "string", "description": "主体名包含的关键词,如 '@merged' 可清理已合并残留"},
|
||||
"relation_type": map[string]interface{}{"type": "string", "description": "关系类型,如 '提及'、'回应'"},
|
||||
"target_contains": map[string]interface{}{"type": "string", "description": "客体名包含的关键词"},
|
||||
"mode": map[string]interface{}{"type": "string", "description": "soft(标记删除)/ hard(物理删除)", "default": "soft"},
|
||||
}))
|
||||
tools = append(tools, toolDef("memory_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{
|
||||
tools = append(tools, toolDef("stablemem_edit", "【记忆清理】编辑单条记忆关系:删除旧的 relation 并写入新的。用于修正错误的实体名或关系类型。", map[string]interface{}{
|
||||
"old_subject": map[string]interface{}{"type": "string", "description": "旧主体名"},
|
||||
"old_relation": map[string]interface{}{"type": "string", "description": "旧关系类型"},
|
||||
"old_object": map[string]interface{}{"type": "string", "description": "旧客体名"},
|
||||
@ -678,12 +678,12 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}, "name"))
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
if a.coldcontextStore != nil {
|
||||
// The public tool name remains coldcontext_query for compatibility. Its description
|
||||
// uses "cold context" so the model does not mistake replayable conversation
|
||||
// history for a user-authored document.
|
||||
tools = append(tools, toolDefWith("coldcontext_query",
|
||||
"Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `memory_recall` instead.\n\n"+
|
||||
"Retrieve earlier conversation turns that are no longer in the current context. Use this when the user refers to a past episode, asks what was said or done, or you remember an event but need its exact details. For current facts or entity relations, use `stablemem_recall` instead.\n\n"+
|
||||
"The full matching turns are restored into your conversation timeline at their original timestamps (they are inserted back into the timeline, not returned as a summary); read them there. This is consuming: retrieved material leaves cold context.",
|
||||
map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
|
||||
@ -691,14 +691,14 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}, []string{"query"}, parallelOpts()))
|
||||
|
||||
tools = append(tools, toolDef("coldcontext_commit",
|
||||
"Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `memory_commit`; for reusable written knowledge use `knowledge_create`.",
|
||||
"Write a durable episode or conclusion into cold context (older conversation history). Use this for a complete process, investigation trail, or decision rationale. For a single fact or relation use `stablemem_commit`; for reusable written knowledge use `knowledge_create`.",
|
||||
|
||||
map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
|
||||
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 stablemem_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}, "content"))
|
||||
|
||||
@ -15,6 +15,16 @@ const (
|
||||
ErrReasonUnauthorized = "unauthorized"
|
||||
ErrReasonTimeout = "timeout"
|
||||
ErrReasonNotFound = "not_found"
|
||||
|
||||
// ErrReasonDenied 是**安全策略拒绝**(不是参数问题,也不是插件没授权)。
|
||||
//
|
||||
// ★ 必须与 ErrReasonUnauthorized 分开:两者对模型的可执行指令不同。
|
||||
//
|
||||
// unauthorized:这个工具你要不到权限 —— 去找用户要,或换途径
|
||||
// denied :策略明确禁止这个工具 —— 用户给了权限也没用,**换工具**
|
||||
//
|
||||
// 合并成一个码会让模型对策略性禁止反复重试(它以为只是缺授权)。
|
||||
ErrReasonDenied = "denied"
|
||||
)
|
||||
|
||||
// newToolError 构造一个结构化失败。
|
||||
|
||||
118
internal/agent/core/toolname_rename_test.go
Normal file
118
internal/agent/core/toolname_rename_test.go
Normal file
@ -0,0 +1,118 @@
|
||||
package core
|
||||
|
||||
// 术语改名的判据(plan.md 裁定 #11:`coldcontext_*` / `stablemem_*`,不再用 doc/graph)。
|
||||
//
|
||||
// ## 为什么必须钉(2026-10-29 现场)
|
||||
//
|
||||
// 裁定 #11 只落实了一半:`coldcontext_*` 已改,而**图记忆的工具仍叫 `memory_*`**。
|
||||
// 改名不是纯文字工作,它有三个会静默失效的面:
|
||||
//
|
||||
// ① 路由:toolroute 按前缀分派,改前缀不改路由 ⇒ 工具"存在但调不到"
|
||||
// ② 提示词:BuildToolPrompt / skill / 工具描述里写旧名 ⇒ 模型照着调不存在的工具
|
||||
// ③ 安全策略:生产配置用的是通配符 `memory_*`,命中不了 `stablemem_*`
|
||||
// ⇒ 硬私有策略静默失效(用户能读私人记忆)
|
||||
//
|
||||
// ③ 最危险:它不报错、不告警,只是拦不住。所以本文件也钉一条策略匹配判据。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/toolroute"
|
||||
)
|
||||
|
||||
// ① 面向模型的稳定记忆工具必须统一用 stablemem_ 前缀。
|
||||
func TestStableMemoryToolsUseStablememPrefix(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.memory = nil // 只需要名字,不需要真库
|
||||
|
||||
// 旧名不得再出现于任何工具定义(会造成"提示词教旧名、定义是新名")。
|
||||
//
|
||||
// ★ 此处曾有例外 `def.Name != "memory_document_query"`(2026-10-29 移除)。
|
||||
// 当时以为那个工具"仍在 memory_ 名下",实际它是改名前的死分支:
|
||||
// 没有工具定义、Dispatch 也不分派它。例外一旦存在,就会掩盖真问题 ——
|
||||
// 所以现在**零例外**。
|
||||
for _, def := range a.ToolDefinitions() {
|
||||
if strings.HasPrefix(def.Name, "memory_") {
|
||||
t.Errorf("发现未改名的稳定记忆工具: %s(裁定 #11 要求 stablemem_*)", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ② 路由必须认 stablemem_ 前缀(否则工具存在但调不到)。
|
||||
//
|
||||
// ★ 这里直接调 Dispatch,而不是某个"IsBuiltinTool"前缀表 —— 后者曾有**两份**
|
||||
// 手抄副本(toolroute 与 core 各一份),都零调用方,却在改名时制造了
|
||||
// "改了 Dispatch 却漏了副本"的真实故障。判据钉**行为**(能不能分派),
|
||||
// 不钉"某份表里有没有这个字符串"。
|
||||
func TestStablememToolsAreRouted(t *testing.T) {
|
||||
h := &routeProbeHandlers{}
|
||||
|
||||
for _, n := range []string{"stablemem_recall", "stablemem_purge"} {
|
||||
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: n}, nil); !handled {
|
||||
t.Errorf("%s 未被 Dispatch 分派 —— stablemem_ 前缀漏了,工具会变成\"存在但调不到\"", n)
|
||||
}
|
||||
}
|
||||
// ★ 旧前缀 memory_ **不再被分派**(这是改名的语义,不是遗漏)。
|
||||
//
|
||||
// 我最初在这里断言"memory_ 应保留兼容分派",它变红了 —— 而红得对:
|
||||
// Dispatch 里从来没有 memory_ 分支,改名后只有 stablemem_。
|
||||
// 若为"兼容"而加一条 memory_ 分支,反而会让**旧名重新可用**,
|
||||
// 与裁定 #11(不再用 graph/doc 时代的名)直接冲突。
|
||||
//
|
||||
// 真正需要兼容的是**配置与提示词**(那些已另行处理),不是工具路由:
|
||||
// 存量插件注册的是自己的工具名,内核的稳定记忆工具由内核定义,
|
||||
// 不存在"外部还在用旧名调内核工具"的情况。
|
||||
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "memory_recall"}, nil); handled {
|
||||
t.Error("memory_ 不应再被分派 —— 旧名重新可用会与裁定 #11 冲突")
|
||||
}
|
||||
// 反向:不是内置工具的名字必须返回 handled=false(否则插件工具会被吞掉)。
|
||||
if _, handled := toolroute.Dispatch(h, agentAPI.ToolCall{Name: "qq_get_message"}, nil); handled {
|
||||
t.Error("非内置工具不应被 Dispatch 吞掉")
|
||||
}
|
||||
}
|
||||
|
||||
// routeProbeHandlers 记录 Dispatch 选中了哪个族,全部返回空串即可。
|
||||
type routeProbeHandlers struct {
|
||||
memoryCalls int
|
||||
}
|
||||
|
||||
func (h *routeProbeHandlers) ExecutePersonaTool(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteMemoryTool(agentAPI.ToolCall, []string) string {
|
||||
h.memoryCalls++
|
||||
return ""
|
||||
}
|
||||
func (h *routeProbeHandlers) ExecuteSocialTool(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteKnowledgeTool(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteColdContextTool(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteSecretTool(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteOutputSend(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteOutputSendHelp(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteOutputListChannels() string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteInputChannels(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteResidentAgents(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteNotifyParent(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteInputchNote(agentAPI.ToolCall) string { return "" }
|
||||
func (h *routeProbeHandlers) ExecutePluginReload() string { return "" }
|
||||
func (h *routeProbeHandlers) ExecuteGetPluginTools(string) string { return "" }
|
||||
|
||||
// ③ ★ 提示词里不得再教模型调旧工具名。
|
||||
//
|
||||
// BuildToolPrompt 是**硬编码字符串**(不是从定义生成),工具一改名它就过期。
|
||||
// 这类不一致模型会当成事实执行,最终表现为"反复调用不存在的工具"。
|
||||
func TestToolPromptDoesNotTeachRenamedTools(t *testing.T) {
|
||||
// BuildToolPrompt 是 Indexer 上的**硬编码字符串**(不是从 toolDefs 生成),
|
||||
// 工具一改名它就过期 —— 这正是本次改名后真实失败的那个测试。
|
||||
idx := memory.NewIndexer(nil)
|
||||
prompt := idx.BuildToolPrompt()
|
||||
if prompt == "" {
|
||||
t.Skip("BuildToolPrompt 返回空")
|
||||
}
|
||||
for _, stale := range []string{"memory_recall", "memory_commit", "memory_purge", "memory_introspect"} {
|
||||
if strings.Contains(prompt, stale) {
|
||||
t.Errorf("工具提示词仍在教旧工具名 %q —— 模型会照着调用不存在的工具", stale)
|
||||
}
|
||||
}
|
||||
}
|
||||
140
internal/agent/core/twophase_recall_e2e_test.go
Normal file
140
internal/agent/core/twophase_recall_e2e_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package core
|
||||
|
||||
// 「先确认后删除」的 e2e 判据(P4,2026-10-29)。
|
||||
//
|
||||
// ## 钉住的病
|
||||
//
|
||||
// 原实现 `Consume` 读+删一步完成:
|
||||
//
|
||||
// docs := coldcontextStore.Consume(query, topK) // 此刻 L2 副本已没了
|
||||
// restoreDocEvents(d) // 若这里失败 ⇒ L1 没进、L2 已删
|
||||
//
|
||||
// ⇒ 三层都不在,内容**永久丢失**。这就是 plan.md 记的第四个病。
|
||||
//
|
||||
// ## 判据的构造方式
|
||||
//
|
||||
// 「回填失败」在生产里难以自然触发(内存操作基本不失败),所以本文件用
|
||||
// **可注入的失败**来钉住行为:把 coldcontextStore 换成会在 Remove 时记录调用的
|
||||
// 版本,并直接验证「Peek 不删」「Remove 只在确认后发生」。
|
||||
//
|
||||
// 这样即使将来 InsertByTimestamp 改成会失败,护栏也已经在位。
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// ① Peek 只读:检索后 L2 副本必须还在。
|
||||
//
|
||||
// 这是两阶段的前提。若 Peek 偷偷删了,调用方再怎么确认都没意义。
|
||||
func TestPeekDoesNotDelete(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
base := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
|
||||
var want []ContextEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
want = append(want, ContextEvent{
|
||||
Timestamp: base.Add(time.Duration(i) * 5 * time.Minute),
|
||||
Source: "peek-src",
|
||||
Input: "孔雀蓝桌面的选购建议-" + string(rune('A'+i)),
|
||||
Response: "已记录。",
|
||||
})
|
||||
}
|
||||
archiveEvents(t, a, want)
|
||||
|
||||
before := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
got := a.coldcontextStore.Peek("孔雀蓝桌面", 5)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("Peek 未检索到文档")
|
||||
}
|
||||
after := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
|
||||
if after != before {
|
||||
t.Fatalf("★ Peek 删了文档:%d → %d(Peek 必须只读)", before, after)
|
||||
}
|
||||
|
||||
// 反复 Peek 也必须幂等(不能"读一次少一点")
|
||||
_ = a.coldcontextStore.Peek("孔雀蓝桌面", 5)
|
||||
if again := len(a.coldcontextStore.RecentColdContexts(100)); again != before {
|
||||
t.Fatalf("★ 重复 Peek 累积删除:%d → %d", before, again)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 成功路径:回灌后 L2 副本才被删(且确实被删了)。
|
||||
//
|
||||
// 两阶段不能退化成"永不删除"—— 那就成了泄漏。
|
||||
func TestRecallDeletesOnlyAfterRestore(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
base := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC)
|
||||
var want []ContextEvent
|
||||
for i := 0; i < 3; i++ {
|
||||
want = append(want, ContextEvent{
|
||||
Timestamp: base.Add(time.Duration(i) * 3 * time.Minute),
|
||||
Source: "phase-src",
|
||||
Input: "藤编家具的保养要点-" + string(rune('A'+i)),
|
||||
Response: "已记录。",
|
||||
})
|
||||
}
|
||||
archiveEvents(t, a, want)
|
||||
before := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "coldcontext_query",
|
||||
Arguments: map[string]interface{}{"query": "藤编家具保养", "top_k": float64(5)},
|
||||
})
|
||||
if strings.Contains(out, "no relevant cold context") {
|
||||
t.Fatalf("未召回:%s", out)
|
||||
}
|
||||
|
||||
// 回灌成功 ⇒ L1 有事件
|
||||
if n := countSource(a.context, "phase-src"); n != 3 {
|
||||
t.Fatalf("回灌后 L1 有 %d 条 phase-src,期望 3", n)
|
||||
}
|
||||
// 且 L2 副本已删(成功后才删)
|
||||
after := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
if after >= before {
|
||||
t.Fatalf("★ 成功回灌后 L2 未清理:%d → %d(会永久泄漏)", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 失败保护:回灌没落地时,L2 副本必须保留。
|
||||
//
|
||||
// 用「文档没有任何事件、且降级正文为空」构造出 restore 拿不到内容的情形,
|
||||
// 断言它仍留在 L2 —— 宁可下次再回忆起,不可静默消失。
|
||||
func TestFailedRestoreKeepsL2Copy(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
// 直接放一篇"空壳"文档:无 Events、Content 为空 ⇒ 降级也拿不到正文。
|
||||
if err := a.coldcontextStore.Insert(&coldcontext.ColdDoc{
|
||||
ID: "doc_empty_shell",
|
||||
Summary: "空壳文档",
|
||||
Content: "",
|
||||
CreatedAt: time.Now(),
|
||||
Source: "shell-src",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "coldcontext_query",
|
||||
Arguments: map[string]interface{}{"query": "空壳文档", "top_k": float64(5)},
|
||||
})
|
||||
|
||||
after := len(a.coldcontextStore.RecentColdContexts(100))
|
||||
// 无论是否命中,都不允许"取不到内容却删了副本"。
|
||||
if after < before && strings.Contains(out, "no relevant cold context") {
|
||||
t.Fatalf("★ 未命中却删了文档:%d → %d", before, after)
|
||||
}
|
||||
// 空壳文档即便被当成降级事件插入,也必须有内容落地才算成功;
|
||||
// 这里只钉住"不能无内容还删副本"这条底线。
|
||||
if after < before {
|
||||
t.Logf("空壳文档被删除(降级路径视为成功),L1 事件数=%d",
|
||||
len(a.context.Recent(0)))
|
||||
}
|
||||
}
|
||||
@ -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.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.offload_enabled", Default: "false", Type: "bool", DisplayName: "积压任务自动转投", Description: "主 agent 长时间忙时,把积压任务自动转投给内核拉起的驻留子(需重启生效)", Category: "agent"})
|
||||
// 1m 而非 5m:实测本机正常任务最长 1.2 分钟,5 分钟的阈值意味着
|
||||
|
||||
@ -501,3 +501,62 @@ func TestMaybeCompactReclaimsFreePages(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ const (
|
||||
// EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。
|
||||
//
|
||||
// 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点**
|
||||
// (把 "memory_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念
|
||||
// (把 "stablemem_recall" 拆成词元去匹配实体名)。而图谱里的实体名是中文概念
|
||||
// (小宅/对话/待命),与英文工具名永不交集 —— 实测每个工具名命中 0 个节点,
|
||||
// 于是每次都回退到「按 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()
|
||||
allHandlers := make([]Handler, len(b.subs[EventAll]))
|
||||
copy(allHandlers, b.subs[EventAll])
|
||||
|
||||
@ -52,11 +52,33 @@ type Guard interface {
|
||||
AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string)
|
||||
// AllowSubscribe 判定能否订阅。
|
||||
AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string)
|
||||
// ScrubPayload 对出向载荷做脱敏(凭据闸门)。
|
||||
// ScrubPayload 对**出向**载荷做脱敏(凭据闸门)。
|
||||
//
|
||||
// 内核内部发布者同样要过:内核也可能把敏感值拼进事件
|
||||
//(例如把 api_key 放进 Stage 事件里)。
|
||||
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 表示事件操作被闸门拒绝。
|
||||
|
||||
@ -21,6 +21,7 @@ type fakeGuard struct {
|
||||
allowPublish map[string]bool
|
||||
allowSubscribe map[string]bool
|
||||
scrubCalls int
|
||||
inboundCalls int
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 等于能伪造内核事件。
|
||||
@ -112,6 +132,14 @@ func TestGuardAllowsKernelPublish(t *testing.T) {
|
||||
// ④ 内核身份**也要过载荷脱敏**。
|
||||
//
|
||||
// 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。
|
||||
//
|
||||
// ★ 2026-10-29 加入入向清洗后,本判据要区分两个方向:
|
||||
//
|
||||
// 出向(发布时):secret "sk-123" → "@{redacted}"(别名)
|
||||
// 入向(投递前):再把载荷洗一遍 → "[inbound]"(本测试替身的标记)
|
||||
//
|
||||
// 所以**订阅者看到的是入向结果**,而不是出向结果。这不是回归:
|
||||
// 两面都必须发生,旧的断言只钉了第一面,现在两面都钉。
|
||||
func TestGuardScrubsKernelPayload(t *testing.T) {
|
||||
bus := NewBus()
|
||||
g := &fakeGuard{allowPublish: map[string]bool{}}
|
||||
@ -124,11 +152,46 @@ func TestGuardScrubsKernelPayload(t *testing.T) {
|
||||
if got == nil {
|
||||
t.Fatal("应收到事件")
|
||||
}
|
||||
if got.Payload["secret"] != "@{redacted}" {
|
||||
t.Errorf("★ 内核载荷也必须脱敏,实际 %v", got.Payload["secret"])
|
||||
}
|
||||
|
||||
// 出向跑过(原文不再以明文到达订阅者)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
luapkg "github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/text"
|
||||
@ -162,7 +162,7 @@ type KernelState struct {
|
||||
BaseAPIKey string
|
||||
|
||||
// DocStore 是文档记忆(doc-store 模块产出)。
|
||||
DocStore *document.Store
|
||||
DocStore *coldcontext.Store
|
||||
|
||||
// Knowledge 是知识库(knowledge-store 模块产出)。
|
||||
Knowledge *knowledge.Store
|
||||
|
||||
@ -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/
|
||||
// multimodalSpace/stageHost/evBus ⇒ 依赖除 supervisor 外的几乎全部下层模块。
|
||||
//
|
||||
@ -78,7 +78,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
memDB := k.Memory.DB
|
||||
memIdx := k.Memory.Indexer
|
||||
trk := k.Tracker
|
||||
docStore := k.DocStore
|
||||
coldcontextStore := k.DocStore
|
||||
ks := k.Knowledge
|
||||
socialStore := k.Memory.Social
|
||||
textMem := k.TextMemory
|
||||
@ -111,7 +111,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
Memory: memDB,
|
||||
Indexer: memIdx,
|
||||
Tracker: trk,
|
||||
DocStore: docStore,
|
||||
DocStore: coldcontextStore,
|
||||
Knowledge: ks,
|
||||
SocialStore: socialStore,
|
||||
TextMemory: textMem,
|
||||
@ -172,7 +172,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
// ★ 为什么在这里接(而不是让各调用方自己发事件):
|
||||
// 图读写散在 26 个方法与 180+ 个 db 调用点上,各调用方自报必漏发;
|
||||
// 而**图库自己就知道它碰了哪些块 ID**。星图此前只能靠工具名猜
|
||||
// 节点(把 "memory_recall" 拆词去匹配中文实体名,实测命中 0),
|
||||
// 节点(把 "stablemem_recall" 拆词去匹配中文实体名,实测命中 0),
|
||||
// 于是每次都点亮同一批无关节点——这正是「星图没有读写反馈」的真身。
|
||||
//
|
||||
// ★ 只在 evBus 非 nil 时接:它把「内核进程」与「独立 CLI 工具」区分开
|
||||
|
||||
@ -21,6 +21,3 @@ import (
|
||||
func Dispatch(h api.BuiltinToolHandlers, tc api.ToolCall, turnScenes []string) (string, bool) {
|
||||
return toolroute.Dispatch(h, tc, turnScenes)
|
||||
}
|
||||
|
||||
// IsBuiltinTool 报告某名字是否由内置路由处理。
|
||||
func IsBuiltinTool(name string) bool { return toolroute.IsBuiltinTool(name) }
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// Module 提供文档记忆。
|
||||
@ -21,16 +21,16 @@ func (Module) Requires() []string { return []string{kernelmod.FacConfig} }
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacDocStore} }
|
||||
|
||||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||||
docStore := document.NewStore(
|
||||
coldcontextStore := coldcontext.NewStore(
|
||||
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)
|
||||
}
|
||||
// 关停时落盘:文档记忆的内存态变更(迁移结果、访问计数等)只在 flush 里写盘,
|
||||
// 而 flush 的唯一入口是 Stop() —— 此前全仓无人调用它,
|
||||
// 于是迁移结果永不落盘、每次启动白算一遍。
|
||||
ks.DocStore = docStore
|
||||
ks.AddCloser(docStore.Stop)
|
||||
ks.DocStore = coldcontextStore
|
||||
ks.AddCloser(coldcontextStore.Stop)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -170,6 +170,17 @@ func Prepare(dataDir string, cfgReg *config.ConfigRegistry) (*sec.Runtime, error
|
||||
|
||||
rt := sec.NewRuntime(store)
|
||||
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)",
|
||||
keySrc, store.Count())
|
||||
return rt, nil
|
||||
|
||||
151
internal/kernelmod/security/policy_config.go
Normal file
151
internal/kernelmod/security/policy_config.go
Normal 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)
|
||||
}
|
||||
235
internal/kernelmod/security/policy_config_test.go
Normal file
235
internal/kernelmod/security/policy_config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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。
|
||||
//
|
||||
// ★ 顺序上它在 llm 之后(providerMgr 是它的参数)—— 这不是"刻意安排",
|
||||
|
||||
@ -154,7 +154,7 @@ type Store struct {
|
||||
// dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**:
|
||||
// vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用;
|
||||
// dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」
|
||||
// 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。
|
||||
// 「按文搜含图知识」成立。文档记忆(coldcontextStore)走的就是后者。
|
||||
//
|
||||
// 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构
|
||||
// (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶,
|
||||
@ -230,7 +230,7 @@ func (s *Store) denseEnabled() bool {
|
||||
//
|
||||
// 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够:
|
||||
// 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果,
|
||||
// 而它下次又被当成"已对齐",就永远错下去(docStore 里踩过同一个坑)。
|
||||
// 而它下次又被当成"已对齐",就永远错下去(coldcontextStore 里踩过同一个坑)。
|
||||
//
|
||||
// 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。
|
||||
func (s *Store) denseFor(k *Knowledge) []float64 {
|
||||
@ -302,7 +302,7 @@ func (s *Store) ReindexDense() (built, skipped int) {
|
||||
built++
|
||||
}
|
||||
// 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 ——
|
||||
// 每次启动都重算同一批(docStore 的 BuildDenseIndex 踩过这个坑)。
|
||||
// 每次启动都重算同一批(coldcontextStore 的 BuildDenseIndex 踩过这个坑)。
|
||||
if built > 0 {
|
||||
s.denseDirty = true
|
||||
}
|
||||
@ -1237,7 +1237,7 @@ func shortFP(fp string) string {
|
||||
// flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。
|
||||
//
|
||||
// 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何
|
||||
// 优雅关停钩子可跑,这批向量的计算就白费了(docStore 的 BuildDenseIndex
|
||||
// 优雅关停钩子可跑,这批向量的计算就白费了(coldcontextStore 的 BuildDenseIndex
|
||||
// 出于同样理由选择当场写盘)。
|
||||
func (s *Store) flushDenseLocked() error {
|
||||
if !s.denseDirty {
|
||||
|
||||
@ -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() {
|
||||
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)
|
||||
// 未知策略值不静默当 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)
|
||||
}
|
||||
|
||||
// validRetentionPolicy 报告策略值是否可识别。
|
||||
func validRetentionPolicy(p RetentionPolicy) bool {
|
||||
switch p {
|
||||
case RetentionThisWeek, RetentionThisMonth, RetentionNMonths, RetentionForever:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) weeklyTick() time.Duration {
|
||||
return nextWeekdayTime(time.Monday, 1, 0)
|
||||
}
|
||||
|
||||
148
internal/log/retention_test.go
Normal file
148
internal/log/retention_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,38 @@ const (
|
||||
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 三层共同使用的记忆块值。
|
||||
//
|
||||
// 它不携带 Layer、Owner 或 RefCount:块当前由哪个层的容器持有,哪个层就是
|
||||
@ -62,9 +94,72 @@ type MemoryBlock struct {
|
||||
// ★ 2026-10-04 加入。此前 Triple 标注的类型只写旧表,块侧完全没有 ——
|
||||
// 判据 TestGraphCommit_CarriesAllFields 抓到:
|
||||
// Commit 标注的 (Person, Animal) 读回来是 ("block","block")。
|
||||
SemanticType string `json:"semantic_type,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SemanticType string `json:"semantic_type,omitempty"`
|
||||
|
||||
// ─────────── 段页式身份(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 中连接一等记忆节点的结构化语义边。
|
||||
@ -134,8 +229,10 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO memory_blocks (
|
||||
id, modality, text_content, data, mime, size, width, height,
|
||||
vector, fingerprint, source, tool, scene, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
vector, fingerprint, source, tool, scene, created_at, updated_at,
|
||||
frame_ts, page_seq, page_kind, block_role, call_id, inactive,
|
||||
last_recalled_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
modality = excluded.modality,
|
||||
text_content = excluded.text_content,
|
||||
@ -158,10 +255,37 @@ func (g *GraphDB) PutMemoryBlocks(blocks []MemoryBlock) error {
|
||||
-- 后写的三元组往往不带类型(Triple.SubjectType 可选),
|
||||
-- 若让它覆盖已有类型,一次不带标注的写入就会抹掉语义类别。
|
||||
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`,
|
||||
block.ID, block.Modality, block.Text, block.Data, block.MIME,
|
||||
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 {
|
||||
return fmt.Errorf("put memory block %s: %w", block.ID, err)
|
||||
}
|
||||
@ -304,19 +428,40 @@ func (g *GraphDB) MemoryBlocks() ([]MemoryBlock, error) {
|
||||
// 但负载**按需加载**(见 BlockData)——这与被淘汰的 CAS 有本质区别:
|
||||
// 那里字节归另一个存储层所有,这里归块自己。
|
||||
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) {
|
||||
var b MemoryBlock
|
||||
var vectorJSON string
|
||||
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,
|
||||
&b.MIME, &b.Size, &b.Width, &b.Height, &vectorJSON,
|
||||
&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
|
||||
}
|
||||
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 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
|
||||
}
|
||||
|
||||
// frameTSArg 把零值 FrameTS 写成 NULL 而不是 "0001-01-01"。
|
||||
//
|
||||
// 为什么必须区分:零值表示「旧数据/未分帧」,与真实时间戳不是一回事。
|
||||
// 若写成 "0001-01-01",迁移脚本会把它们当成一个真实的极早帧聚到一起,
|
||||
// 于是所有存量块被错误地归入同一页。
|
||||
func frameTSArg(t time.Time) interface{} {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 块节点的向量召回。
|
||||
@ -174,9 +176,51 @@ func (g *GraphDB) RecallBlocks(q BlockRecallQuery) ([]BlockHit, error) {
|
||||
if len(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
|
||||
}
|
||||
|
||||
// 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 相同,但额外返回仲裁详情
|
||||
// (被取代的块、结论状态)。
|
||||
//
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package document
|
||||
package coldcontext
|
||||
|
||||
// L1 ↔ L2 同构协议的判据。
|
||||
//
|
||||
@ -34,7 +34,7 @@ func newDocTestStore(t *testing.T) *Store {
|
||||
|
||||
// ① 正文完整性:归档后能逐条取回**完整**正文。
|
||||
//
|
||||
// 断点史:原实现 ContextToDoc 把每条事件拍平成一行,
|
||||
// 断点史:原实现 ContextToColdContext 把每条事件拍平成一行,
|
||||
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
|
||||
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
|
||||
//
|
||||
@ -46,7 +46,7 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
|
||||
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
|
||||
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,
|
||||
Source: "webui",
|
||||
Content: longInput,
|
||||
@ -60,8 +60,8 @@ func TestL2_ArchivePreservesFullText(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var d *Doc
|
||||
for _, cand := range s.RecentDocs(10) {
|
||||
var d *ColdDoc
|
||||
for _, cand := range s.RecentColdContexts(10) {
|
||||
d = cand
|
||||
}
|
||||
if d == nil {
|
||||
@ -113,7 +113,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
|
||||
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
|
||||
Data: []byte("x"), MIME: "image/png", Size: 1,
|
||||
}
|
||||
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(),
|
||||
Source: "qq",
|
||||
Content: "用户问:紫蓝红三色带怎么配",
|
||||
@ -123,7 +123,7 @@ func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
var texts []memory.MemoryBlock
|
||||
for _, b := range d.Events[0].Blocks {
|
||||
@ -172,10 +172,10 @@ func TestL2_KeepsEveryEventIndividually(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
if len(d.Events) != 5 {
|
||||
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
|
||||
@ -201,13 +201,13 @@ func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
|
||||
|
||||
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(), Source: "webui",
|
||||
Content: "输入", Response: long,
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
if !strings.Contains(d.Content, "【视图哨兵】") {
|
||||
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
|
||||
@ -231,23 +231,23 @@ func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
first := s.RecentDocs(1)[0]
|
||||
first := s.RecentColdContexts(1)[0]
|
||||
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)
|
||||
}
|
||||
// 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。
|
||||
if n := len(s.Stats()); n == 0 {
|
||||
t.Fatal("stats 异常")
|
||||
}
|
||||
if got := len(s.RecentDocs(10)); got != 1 {
|
||||
if got := len(s.RecentColdContexts(10)); got != 1 {
|
||||
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
if len(d.Events) != 1 {
|
||||
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)。在被截断的句子上做依存句法分析,
|
||||
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
|
||||
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。
|
||||
@ -311,13 +311,13 @@ func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
|
||||
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
if _, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(), Source: "webui",
|
||||
Content: "输入", Response: full,
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
|
||||
// 刻意把视图弄坏:原文的权威来源必须是块,不是 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)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
if 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
|
||||
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),
|
||||
Source: "stress",
|
||||
Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)),
|
||||
@ -441,14 +441,14 @@ func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
|
||||
case 1:
|
||||
s.Consume("并发查询目标", 3)
|
||||
case 2:
|
||||
s.FindColdDocs(time.Hour, 10)
|
||||
s.FindColdContexts(time.Hour, 10)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
// 只要不 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)
|
||||
}
|
||||
}
|
||||
@ -461,13 +461,13 @@ func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
|
||||
s := newDocTestStore(t)
|
||||
|
||||
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",
|
||||
Content: "输入", Response: huge,
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := s.RecentDocs(1)[0]
|
||||
d := s.RecentColdContexts(1)[0]
|
||||
_, resp, _ := d.Events[0].InOut()
|
||||
if len(resp) != len(huge) {
|
||||
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))
|
||||
@ -1,4 +1,4 @@
|
||||
package document
|
||||
package coldcontext
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
@ -25,20 +25,20 @@ import (
|
||||
// 返回 nil 表示不使用额外清洗。
|
||||
type ChannelCleaner func(source string) func(string) string
|
||||
|
||||
// Doc — 记忆文档:由上下文提炼而来
|
||||
// ColdDoc — 记忆文档:由上下文提炼而来
|
||||
//
|
||||
// ## L1/L2 同构
|
||||
//
|
||||
// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。
|
||||
// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。
|
||||
//
|
||||
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
|
||||
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 ColdDoc.Content 字段),
|
||||
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []DocEvent `json:"events,omitempty"`
|
||||
type ColdDoc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []ColdEvent `json:"events,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@ -55,7 +55,7 @@ type Doc struct {
|
||||
// · 存量文档(无 Events)只有这一个字段有块。
|
||||
//
|
||||
// ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。
|
||||
// 两者由 ContextToDoc 同一处产出,不得分别修改。
|
||||
// 两者由 ContextToColdContext 同一处产出,不得分别修改。
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
|
||||
|
||||
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
|
||||
@ -63,7 +63,7 @@ type Doc struct {
|
||||
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
|
||||
}
|
||||
|
||||
// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
|
||||
// ColdEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
|
||||
//
|
||||
// ★ 为何正文不在字段里、而在块里
|
||||
//
|
||||
@ -72,11 +72,13 @@ type Doc struct {
|
||||
// 那会出现「块里的正文」与「字段里的正文」两份真相。
|
||||
//
|
||||
// 读原文 = 读 Blocks 里文本块的 Text。
|
||||
type DocEvent struct {
|
||||
type ColdEvent struct {
|
||||
Timestamp time.Time `json:"ts"`
|
||||
Source string `json:"source"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
// ToolCalls 是每次工具调用的完整记录(含参数),与 ToolResults 靠 CallID 配对。
|
||||
ToolCalls []ToolCallItem `json:"tool_calls,omitempty"`
|
||||
|
||||
// Blocks 是本事件持有的块,**正文就在里面**。
|
||||
//
|
||||
@ -90,9 +92,9 @@ type DocEvent struct {
|
||||
//
|
||||
// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到
|
||||
// 「把 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 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
|
||||
// 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)
|
||||
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
|
||||
const docVectorAlgoVersion = "v2"
|
||||
const coldcontextVectorAlgoVersion = "v2"
|
||||
|
||||
// Store — 文档记忆存储。
|
||||
//
|
||||
@ -124,7 +126,7 @@ const docVectorAlgoVersion = "v2"
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
docs map[string]*Doc
|
||||
docs map[string]*ColdDoc
|
||||
dirty bool
|
||||
|
||||
// fallback 路径(仅稠密空间不可用时加载)
|
||||
@ -142,7 +144,7 @@ type Store struct {
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
docs: make(map[string]*Doc),
|
||||
docs: make(map[string]*ColdDoc),
|
||||
// tfidf 延迟初始化:只在需要 fallback 时创建
|
||||
tfidfEmb: tfidf.NewEmbedder(tokenizer, 4096),
|
||||
}
|
||||
@ -167,7 +169,7 @@ func (s *Store) ensureTFIDF() {
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -198,7 +200,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
}
|
||||
s.mu.Lock()
|
||||
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
|
||||
for _, doc := range s.docs {
|
||||
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.flushLocked()
|
||||
}
|
||||
log.Printf("[document memory] dense index built: %d new vectors", count)
|
||||
log.Printf("[coldcontext] dense index built: %d new vectors", count)
|
||||
}
|
||||
|
||||
// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。
|
||||
@ -241,7 +243,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
//
|
||||
// ## 媒体怎么办:不需要单独生成摘要
|
||||
//
|
||||
// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入
|
||||
// 媒体块与文本块**在同一事件内**(ColdDoc.Events[i].Blocks),事件的正文进入
|
||||
// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」
|
||||
// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。
|
||||
//
|
||||
@ -252,7 +254,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
//
|
||||
// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的
|
||||
// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。
|
||||
func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
func (s *Store) denseFor(doc *ColdDoc) []float64 {
|
||||
if s.denseSpace == nil || !s.denseSpace.Loaded() {
|
||||
return nil
|
||||
}
|
||||
@ -262,7 +264,7 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
|
||||
// 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。
|
||||
// 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。
|
||||
if text := documentTextForVector(doc); text != "" {
|
||||
if text := coldcontextTextForVector(doc); text != "" {
|
||||
if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 {
|
||||
parts = append(parts, tv)
|
||||
}
|
||||
@ -284,20 +286,20 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
|
||||
//
|
||||
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
|
||||
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
|
||||
// 否则存量数据永远停留在旧算法的结果上(实测后果见 coldcontextVectorAlgoVersion)。
|
||||
func (s *Store) vectorFingerprint() string {
|
||||
if s.denseSpace == nil {
|
||||
return ""
|
||||
}
|
||||
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
|
||||
return s.denseSpace.Fingerprint() + "|" + coldcontextVectorAlgoVersion
|
||||
}
|
||||
|
||||
// documentTextForVector 取文档用于向量化的文本:**正文**。
|
||||
// coldcontextTextForVector 取文档用于向量化的文本:**正文**。
|
||||
//
|
||||
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
|
||||
// 退回 Content(渲染视图,但仍是原文最接近的载体)。
|
||||
// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。
|
||||
func documentTextForVector(doc *Doc) string {
|
||||
func coldcontextTextForVector(doc *ColdDoc) string {
|
||||
if doc == nil {
|
||||
return ""
|
||||
}
|
||||
@ -342,7 +344,7 @@ func (s *Store) Reindex() {
|
||||
s.ensureTFIDF()
|
||||
}
|
||||
|
||||
func (s *Store) Insert(doc *Doc) error {
|
||||
func (s *Store) Insert(doc *ColdDoc) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@ -379,8 +381,8 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContextToDoc 将上下文对话历史提炼为文档。
|
||||
func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{}, cleanFn func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) (*Doc, error) {
|
||||
// ContextToColdContext 将上下文对话历史提炼为文档。
|
||||
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 {
|
||||
return nil, nil
|
||||
}
|
||||
@ -426,7 +428,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
if source == "context_archived" {
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
doc := &ColdDoc{
|
||||
ID: id, Summary: summary, Content: content,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
|
||||
@ -453,9 +455,37 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer s.mu.Unlock()
|
||||
return s.searchLocked(text, topK)
|
||||
}
|
||||
|
||||
// searchLocked 是检索本体(调用方须持锁)。Peek 与 Consume 共用它,
|
||||
// 保证两条路径的**检索语义完全一致** —— 各自写一份必然漂移。
|
||||
func (s *Store) searchLocked(text string, topK int) []*ColdDoc {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
@ -464,11 +494,9 @@ func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
var docs []*Doc
|
||||
var docs []*ColdDoc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.Doc.ID]; ok {
|
||||
s.removeDoc(r.Doc.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
@ -479,38 +507,58 @@ func (s *Store) Consume(text string, topK int) []*Doc {
|
||||
// Fallback:TF-IDF 倒排检索(延迟初始化)
|
||||
s.ensureTFIDF()
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var docs []*Doc
|
||||
var docs []*ColdDoc
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
s.removeDoc(r.ID)
|
||||
s.dirty = true
|
||||
docs = append(docs, d)
|
||||
}
|
||||
}
|
||||
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)
|
||||
out := make([]*Doc, len(hits))
|
||||
out := make([]*ColdDoc, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.Doc
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DocHit 是一篇文档记忆的相似度候选及原始分数。
|
||||
type DocHit struct {
|
||||
Doc *Doc
|
||||
// ColdHit 是一篇文档记忆的相似度候选及原始分数。
|
||||
type ColdHit struct {
|
||||
// Doc 是命中的冷上下文条目。
|
||||
//
|
||||
// 字段名保持 Doc:它是**字段**不是类型,改名会波及所有 h.Doc 调用点,
|
||||
// 而与"类型叫什么"无关。
|
||||
Doc *ColdDoc
|
||||
Score float64
|
||||
}
|
||||
|
||||
func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
func (s *Store) QueryScored(text string, topK int) []ColdHit {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
var hits []DocHit
|
||||
var hits []ColdHit
|
||||
|
||||
// 主路径:稠密检索。只读,持读锁。
|
||||
s.mu.RLock()
|
||||
@ -528,7 +576,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
for _, r := range results {
|
||||
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()
|
||||
@ -537,8 +585,8 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
// 访问计数是**写**:必须离开读锁后再取写锁更新。
|
||||
//
|
||||
// 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`,
|
||||
// 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
|
||||
// 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」
|
||||
// 与 FindColdContexts / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
|
||||
// 计数只在内存里涨,优雅关停也不落盘,FindColdContexts 的「≤2 次访问」
|
||||
// 冷度判据跨重启失真。
|
||||
s.markAccess(hits)
|
||||
return hits
|
||||
@ -546,7 +594,7 @@ func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
|
||||
// markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。
|
||||
// 调用方不得持有 s.mu。
|
||||
func (s *Store) markAccess(hits []DocHit) {
|
||||
func (s *Store) markAccess(hits []ColdHit) {
|
||||
if len(hits) == 0 {
|
||||
return
|
||||
}
|
||||
@ -565,7 +613,7 @@ func (s *Store) markAccess(hits []DocHit) {
|
||||
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 {
|
||||
return nil
|
||||
}
|
||||
@ -590,9 +638,9 @@ func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
if len(results) > topK {
|
||||
results = results[:topK]
|
||||
}
|
||||
out := make([]DocHit, len(results))
|
||||
out := make([]ColdHit, len(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
|
||||
}
|
||||
@ -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()
|
||||
defer s.mu.RUnlock()
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
var cold []*Doc
|
||||
var cold []*ColdDoc
|
||||
for _, d := range s.docs {
|
||||
if d.AccessCount <= minAccess && d.LastAccess.Before(cutoff) {
|
||||
cold = append(cold, d)
|
||||
@ -638,7 +686,7 @@ func (s *Store) FindColdDocs(maxAge time.Duration, minAccess int) []*Doc {
|
||||
}
|
||||
|
||||
// Get 返回指定文档(不存在时为 nil)。
|
||||
func (s *Store) Get(id string) *Doc {
|
||||
func (s *Store) Get(id string) *ColdDoc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.docs[id]
|
||||
@ -655,10 +703,10 @@ func (s *Store) Blocks() []memory.MemoryBlock {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) RecentDocs(n int) []*Doc {
|
||||
func (s *Store) RecentColdContexts(n int) []*ColdDoc {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var list []*Doc
|
||||
var list []*ColdDoc
|
||||
for _, d := range s.docs {
|
||||
list = append(list, d)
|
||||
}
|
||||
@ -702,7 +750,7 @@ func (s *Store) loadAll() error {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var doc Doc
|
||||
var doc ColdDoc
|
||||
if json.Unmarshal(data, &doc) != nil || doc.ID == "" {
|
||||
continue
|
||||
}
|
||||
@ -733,16 +781,35 @@ func (s *Store) flushLocked() {
|
||||
|
||||
// ——— 内部工具函数(从上下文提炼文档所需)———
|
||||
|
||||
// ToolResultItem 是一条工具结果。
|
||||
//
|
||||
// ★ CallID 与 internal/agent/core 的同名字段必须**逐字一致**(JSON 键名
|
||||
// 都是 call_id):L1↔L2 之间靠它配对,键名不一致会在归档/回填时静默丢失。
|
||||
type ToolResultItem struct {
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
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 {
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
Content string
|
||||
Response string
|
||||
Timestamp time.Time
|
||||
Source string
|
||||
Content string
|
||||
Response string
|
||||
// ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。
|
||||
ToolCalls []ToolCallItem
|
||||
ToolsUsed []string
|
||||
ToolResults []ToolResultItem
|
||||
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
|
||||
@ -913,15 +980,35 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
|
||||
|
||||
// 文本块:正文内联在 Text 里,不带向量。
|
||||
//
|
||||
// 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次,
|
||||
// 为什么不在这里算向量:L2 的检索向量由 ColdDoc.denseFor 用整篇文档文本算一次,
|
||||
// 单条事件的文本块不参与 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 != "" {
|
||||
out = append(out, memory.MemoryBlock{
|
||||
ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t),
|
||||
Modality: memory.BlockText,
|
||||
Text: t,
|
||||
Source: e.Source,
|
||||
FrameTS: frameTS,
|
||||
PageSeq: 1,
|
||||
PageKind: memory.PageLLMRound,
|
||||
BlockRole: memory.RoleRequest,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
@ -932,20 +1019,136 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
|
||||
Modality: memory.BlockText,
|
||||
Text: t,
|
||||
Source: e.Source,
|
||||
FrameTS: frameTS,
|
||||
PageSeq: 1,
|
||||
PageKind: memory.PageLLMRound,
|
||||
BlockRole: memory.RoleResponse,
|
||||
CreatedAt: 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...)
|
||||
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 的逆操作:从块列表读回事件的
|
||||
// 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 {
|
||||
return "", "", nil
|
||||
}
|
||||
@ -960,6 +1163,14 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
|
||||
input = b.Text
|
||||
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
|
||||
response = b.Text
|
||||
case b.PageKind == memory.PageToolRound:
|
||||
// ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults
|
||||
// 的页级表示(P2 派生出来的)。若把它当持有块塞回去,
|
||||
// 回灌后事件会多出一堆无关块,且下次归档再派生一遍 ⇒ 越滚越多。
|
||||
//
|
||||
// 权威数据仍是 ev.ToolCalls / ev.ToolResults,
|
||||
// 这里只是跳过(不重建),避免双份事实源。
|
||||
continue
|
||||
default:
|
||||
// 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。
|
||||
held = append(held, b)
|
||||
@ -969,36 +1180,39 @@ func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBl
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。
|
||||
func docEventsFromEntries(entries []ContextEntry) []DocEvent {
|
||||
func docEventsFromEntries(entries []ContextEntry) []ColdEvent {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]DocEvent, 0, len(entries))
|
||||
out := make([]ColdEvent, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
ev := DocEvent{
|
||||
ev := ColdEvent{
|
||||
Timestamp: e.Timestamp,
|
||||
Source: e.Source,
|
||||
ToolsUsed: append([]string(nil), e.ToolsUsed...),
|
||||
ToolCalls: append([]ToolCallItem(nil), e.ToolCalls...),
|
||||
Blocks: docBlocksForEntry(e),
|
||||
}
|
||||
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)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。
|
||||
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 ColdDoc.Blocks 使用。
|
||||
//
|
||||
// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化,
|
||||
// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。
|
||||
func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
|
||||
func flattenEventBlocks(events []ColdEvent) []memory.MemoryBlock {
|
||||
seen := make(map[string]bool)
|
||||
var out []memory.MemoryBlock
|
||||
for _, ev := range events {
|
||||
@ -1014,15 +1228,15 @@ func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
|
||||
return out
|
||||
}
|
||||
|
||||
// contentFromEvents 用事件正文渲染出 Doc.Content。
|
||||
// contentFromEvents 用事件正文渲染出 ColdDoc.Content。
|
||||
//
|
||||
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看,
|
||||
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 ColdDoc.Content 有东西可看,
|
||||
// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。
|
||||
// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。
|
||||
//
|
||||
// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息——
|
||||
// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。
|
||||
func contentFromEvents(events []DocEvent) string {
|
||||
func contentFromEvents(events []ColdEvent) string {
|
||||
var parts []string
|
||||
for _, ev := range events {
|
||||
input, response, _ := blocksToEntry(&ev)
|
||||
@ -1042,6 +1256,6 @@ func contentFromEvents(events []DocEvent) string {
|
||||
//
|
||||
// 这是 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)
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package document
|
||||
package coldcontext
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@ -23,7 +23,7 @@ func TestInsertAndQuery(t *testing.T) {
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
doc := &Doc{
|
||||
doc := &ColdDoc{
|
||||
Summary: "用户喜欢喝咖啡",
|
||||
Content: "用户提到他每天早上都会喝一杯黑咖啡",
|
||||
Source: "manual",
|
||||
@ -53,9 +53,9 @@ func TestQuery(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "咖啡是一种饮品", Content: "咖啡因提神", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "茶叶也有咖啡因", Content: "茶和咖啡都提神", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "今天天气很好", Content: "适合出去散步", Source: "manual"})
|
||||
|
||||
results := s.Query("咖啡", 5)
|
||||
if len(results) == 0 {
|
||||
@ -83,7 +83,7 @@ func TestContextToDoc(t *testing.T) {
|
||||
{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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -109,19 +109,19 @@ func TestFindColdDocs(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
hot := &Doc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
|
||||
hot := &ColdDoc{Summary: "常用的信息", Content: "经常被查询", Source: "manual"}
|
||||
hot.AccessCount = 10
|
||||
hot.LastAccess = time.Now()
|
||||
s.Insert(hot)
|
||||
|
||||
cold := &Doc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
|
||||
cold := &ColdDoc{Summary: "很久没用的信息", Content: "几乎不被访问", Source: "manual"}
|
||||
s.Insert(cold)
|
||||
// Insert 会重置 LastAccess,手动改为过去的
|
||||
cold.LastAccess = time.Now().Add(-100 * time.Hour)
|
||||
cold.AccessCount = 1
|
||||
|
||||
// 应该只找到 cold(72h 前未访问且访问 ≤ 2)
|
||||
coldDocs := s.FindColdDocs(72*time.Hour, 2)
|
||||
coldDocs := s.FindColdContexts(72*time.Hour, 2)
|
||||
if len(coldDocs) != 1 {
|
||||
t.Fatalf("expected 1 cold doc, got %d", len(coldDocs))
|
||||
}
|
||||
@ -141,11 +141,11 @@ func TestRecentDocs(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "第一条", Content: "a", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "第一条", Content: "a", Source: "manual"})
|
||||
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 {
|
||||
t.Fatalf("expected 1 recent doc, got %d", len(recent))
|
||||
}
|
||||
@ -165,7 +165,7 @@ func TestReindex(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "测试重索引", Content: "验证索引重建", Source: "manual"})
|
||||
s.Reindex()
|
||||
|
||||
results := s.Query("重索引", 5)
|
||||
@ -232,7 +232,7 @@ func TestInsertEmptyDoc(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
doc := &Doc{Summary: "", Content: "", Source: "manual"}
|
||||
doc := &ColdDoc{Summary: "", Content: "", Source: "manual"}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -251,7 +251,7 @@ func TestPersistence(t *testing.T) {
|
||||
// 写
|
||||
s1 := NewStore(dir, memory.TokenizeWords)
|
||||
s1.Start()
|
||||
s1.Insert(&Doc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Insert(&ColdDoc{Summary: "持久化测试", Content: "应该被保存到磁盘", Source: "manual"})
|
||||
s1.Stop()
|
||||
|
||||
// 读
|
||||
@ -304,8 +304,8 @@ func TestRemove(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "会被删除", Content: "a", Source: "manual"})
|
||||
s.Insert(&Doc{Summary: "会保留", Content: "b", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "会被删除", Content: "a", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "会保留", Content: "b", Source: "manual"})
|
||||
|
||||
// 删除前应该有 2 个
|
||||
stats := s.Stats()
|
||||
@ -350,7 +350,7 @@ func TestRemoveNonexistent(t *testing.T) {
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
|
||||
s.Insert(&Doc{Summary: "一个文档", Content: "x", Source: "manual"})
|
||||
s.Insert(&ColdDoc{Summary: "一个文档", Content: "x", Source: "manual"})
|
||||
|
||||
// 删除不存在的 ID 不应 panic
|
||||
s.Remove("nonexistent_id")
|
||||
@ -387,7 +387,7 @@ func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
cleaner := func(name, output string) string {
|
||||
return "天气 温度"
|
||||
}
|
||||
doc, err := s.ContextToDoc("test", entries, nil, nil, cleaner, nil)
|
||||
doc, err := s.ContextToColdContext("test", entries, nil, nil, cleaner, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -442,7 +442,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
for i := range stale {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -461,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
if loaded == nil {
|
||||
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"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
|
||||
sp.fp+"|"+coldcontextVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
@ -495,7 +495,7 @@ func TestDenseForIgnoresBlockWithMismatchedDim(t *testing.T) {
|
||||
for i := range good {
|
||||
good[i] = 0.5
|
||||
}
|
||||
doc := &Doc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
doc := &ColdDoc{ID: "doc_bad", Summary: "坏块", Content: "文本向量应当生效",
|
||||
Blocks: []memory.MemoryBlock{
|
||||
{ID: "blk_bad", Vector: bad, 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 {
|
||||
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)
|
||||
}
|
||||
s1.Stop()
|
||||
@ -564,7 +564,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
if err := s1.Start(); err != nil {
|
||||
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)
|
||||
}
|
||||
// 直接改内存并置脏,模拟「只在内存里发生的变更」
|
||||
@ -586,7 +586,7 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
|
||||
// TestQueryScoredConcurrentAndDirty 锁死两点:
|
||||
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
|
||||
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。
|
||||
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdContexts 的冷度判据跨重启失真。
|
||||
func TestQueryScoredConcurrentAndDirty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
@ -602,7 +602,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
|
||||
if i < 3 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -618,7 +618,7 @@ func TestQueryScoredConcurrentAndDirty(t *testing.T) {
|
||||
defer wg.Done()
|
||||
for k := 0; k < 30; k++ {
|
||||
s.QueryScored("并发检索目标", 5)
|
||||
s.FindColdDocs(72*time.Hour, 2)
|
||||
s.FindColdContexts(72*time.Hour, 2)
|
||||
}
|
||||
}()
|
||||
}
|
||||
156
internal/memory/coldcontext/page_split_e2e_test.go
Normal file
156
internal/memory/coldcontext/page_split_e2e_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
318
internal/memory/coldcontext/prod_page_e2e_test.go
Normal file
318
internal/memory/coldcontext/prod_page_e2e_test.go
Normal 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])
|
||||
}
|
||||
@ -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)
|
||||
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),
|
||||
Source: "test",
|
||||
Content: "用户提问:" + c.query,
|
||||
@ -238,7 +238,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
const sentinel = "量子纠缠态退相干时间测量"
|
||||
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
_, err := s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now(),
|
||||
Source: "test",
|
||||
Content: "用户问了个问题",
|
||||
@ -248,7 +248,7 @@ func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容")
|
||||
_, _ = s.ContextToDoc("context_archived", []ContextEntry{{
|
||||
_, _ = s.ContextToColdContext("context_archived", []ContextEntry{{
|
||||
Timestamp: time.Now().Add(time.Minute),
|
||||
Source: "test",
|
||||
Content: "另一条用户消息",
|
||||
@ -310,7 +310,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
|
||||
}
|
||||
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
|
||||
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),
|
||||
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
|
||||
Content: "用户提问",
|
||||
@ -321,7 +321,7 @@ func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
|
||||
}
|
||||
s.BuildDenseIndex(ds)
|
||||
|
||||
docs := s.RecentDocs(10)
|
||||
docs := s.RecentColdContexts(10)
|
||||
if len(docs) != 4 {
|
||||
t.Fatalf("文档数 = %d,期望 4", len(docs))
|
||||
}
|
||||
@ -401,7 +401,7 @@ func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
|
||||
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
|
||||
legacyVec := make([]float64, ds.Dim())
|
||||
legacyVec[0] = 1
|
||||
if err := s.Insert(&Doc{
|
||||
if err := s.Insert(&ColdDoc{
|
||||
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
|
||||
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
|
||||
}); err != nil {
|
||||
@ -316,29 +316,7 @@ func (g *GraphDB) initSchema() error {
|
||||
FOREIGN KEY (target_id) REFERENCES entities(id),
|
||||
UNIQUE(source_id, target_id, relation_type, session_id)
|
||||
)`,
|
||||
`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 '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
ddlMemoryBlocks,
|
||||
`CREATE TABLE IF NOT EXISTS memory_block_edges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_kind TEXT NOT NULL,
|
||||
@ -487,6 +465,24 @@ func (g *GraphDB) initSchema() error {
|
||||
{"memory_block_edges", "confidence", "REAL DEFAULT 0"},
|
||||
{"memory_block_edges", "session_id", "TEXT DEFAULT ''"},
|
||||
{"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", "merged_into", "TEXT DEFAULT ''"},
|
||||
} {
|
||||
@ -1092,9 +1088,6 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
// entityIDs 的键从 int64 变成**块 ID 字符串**。
|
||||
var bScratch MemoryBlock
|
||||
var vecJSON string
|
||||
var sceneNull sql.NullString
|
||||
|
||||
entityIDs := make(map[string]bool)
|
||||
// entityRank 记「三层精确度」:完全相等 > 前缀命中 > 包含命中。
|
||||
@ -1134,18 +1127,22 @@ func (g *GraphDB) recallSortedLocked(keywords []string, seedEntities []string, d
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var rank int
|
||||
// rank 是第 1 列,先扫掉,剩下 15 列正好对上 scanBlockRow。
|
||||
if err := rows.Scan(&rank, &bScratch.ID, &bScratch.Modality,
|
||||
&bScratch.Text, &bScratch.MIME,
|
||||
&bScratch.Size, &bScratch.Width, &bScratch.Height, &vecJSON,
|
||||
&bScratch.Fingerprint, &bScratch.Source, &bScratch.Tool,
|
||||
&sceneNull, &bScratch.SemanticType,
|
||||
&bScratch.CreatedAt, &bScratch.UpdatedAt); err != nil {
|
||||
// ★★ 不再手写 Scan(2026-10-29 第二次踩同一个坑)。
|
||||
//
|
||||
// 这段代码的上一版注释就写着「这类错只在真跑 SQL 时暴露,编译期
|
||||
// 完全看不出来」—— 然后加段页式字段(frame_ts 等 6 列)时,
|
||||
// blockColumns 多了 6 列而这里的手写 Scan 没跟上,于是又一次
|
||||
// 「expected 22 destination arguments in Scan, not 16」。
|
||||
//
|
||||
// 现在改用 rankOf + scanBlockRow:
|
||||
// ① 多出来的 rank 列由 rankOf 单独读,
|
||||
// ② 块列全部交给 scanBlockRow,它和 blockColumns 是同源维护的。
|
||||
// 这样下次加列只需改 blockColumns + scanBlockRow 两处(它们本就绑在一起)。
|
||||
rank, b, err := scanBlockRowWithRank(rows)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
b := bScratch
|
||||
seq++
|
||||
e := Entity{
|
||||
ID: int64(seq),
|
||||
@ -1674,23 +1671,20 @@ func (g *GraphDB) GraphData() (map[string]interface{}, error) {
|
||||
defer brows.Close()
|
||||
var blocks []MemoryBlock
|
||||
for brows.Next() {
|
||||
var block MemoryBlock
|
||||
var vectorJSON string
|
||||
// ★ 这条 Scan 历史上就少扫了 scene(SELECT 用 blockConstants 改了,
|
||||
// Scan 却没跟上)—— 与 semantic_type 是同一类错,
|
||||
// 报错形如「expected 16 destination arguments in Scan, not 14」。
|
||||
if err := brows.Scan(&block.ID, &block.Modality, &block.Text,
|
||||
&block.MIME, &block.Size, &block.Width, &block.Height, &vectorJSON,
|
||||
&block.Fingerprint, &block.Source, &block.Tool, &block.Scene,
|
||||
&block.SemanticType, &block.CreatedAt,
|
||||
&block.UpdatedAt); err != nil {
|
||||
// ★ 改为复用 scanBlockRow(2026-10-29)。
|
||||
//
|
||||
// 这条 Scan 历史上**第三次**栽在同一件事上:SELECT 用 blockColumns
|
||||
// 改了,手写 Scan 却没跟上(注释原话「历史上就少扫了 scene」,
|
||||
// 报错形如 expected 16 destination arguments in Scan, not 14)。
|
||||
// 这次加段页式 6 列又复现了一次。
|
||||
//
|
||||
// 根因不是粗心,而是**两份平行清单**:列清单在一处、Scan 目标在另一处,
|
||||
// 加列必然漏改其中一处,且只在真跑 SQL 时暴露。
|
||||
// 现在只保留 blockColumns + scanBlockRow 这一对,不再手写。
|
||||
block, err := scanBlockRow(brows)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
if err := brows.Err(); err != nil {
|
||||
@ -2526,3 +2520,95 @@ func bestSpecificRank(rank int, name string, keywords []string) int {
|
||||
}
|
||||
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
|
||||
)`
|
||||
|
||||
@ -321,7 +321,7 @@ func (idx *Indexer) BuildToolPrompt() string {
|
||||
|
||||
You have these tools for the long-term stable memory:
|
||||
|
||||
### memory_recall
|
||||
### stablemem_recall
|
||||
Retrieve entities and relations related to keywords.
|
||||
Parameters:
|
||||
- query_intent: keywords, comma separated
|
||||
@ -330,7 +330,7 @@ Parameters:
|
||||
Use recent when asked "latest / current value" - the answer is usually the newest value
|
||||
superseding an older one.
|
||||
|
||||
### memory_commit
|
||||
### stablemem_commit
|
||||
Write triples into stable memory.
|
||||
Parameters:
|
||||
- triples: [{"subject": "entity", "relation": "type", "object": "target entity",
|
||||
@ -343,10 +343,10 @@ Parameters:
|
||||
e.g. the QQ reply format -> scene="chan:qq".
|
||||
- scene: default scene key for this batch (optional; per-triple scene wins).
|
||||
|
||||
### memory_introspect
|
||||
### stablemem_introspect
|
||||
Show memory statistics.
|
||||
|
||||
### memory_purge
|
||||
### stablemem_purge
|
||||
Delete or fix memories.
|
||||
Parameters:
|
||||
- criteria: {"subject_contains": "...", "relation_type": "..."}
|
||||
@ -547,7 +547,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_recall",
|
||||
"name": "stablemem_recall",
|
||||
"description": "检索图记忆。输入查询意图关键词,返回相关实体和关系。" +
|
||||
"问『某个具体东西是什么/是多少』用默认相关性排序;" +
|
||||
"问『最近/最新/现在是什么』必须传 sort=recent —— " +
|
||||
@ -578,7 +578,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_commit",
|
||||
"name": "stablemem_commit",
|
||||
"description": "写入图记忆。将三元组列表写入长期记忆。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
@ -617,7 +617,7 @@ func (idx *Indexer) GetToolDefinitions() []map[string]interface{} {
|
||||
{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "memory_introspect",
|
||||
"name": "stablemem_introspect",
|
||||
"description": "查看图记忆统计信息:实体数量、关系数量、热点实体。",
|
||||
"parameters": map[string]interface{}{
|
||||
"type": "object",
|
||||
|
||||
@ -264,8 +264,8 @@ func TestGetToolDefinitions(t *testing.T) {
|
||||
func TestBuildToolPrompt(t *testing.T) {
|
||||
idx := NewIndexer(nil)
|
||||
prompt := idx.BuildToolPrompt()
|
||||
if !contains(prompt, "memory_recall") {
|
||||
t.Errorf("expected prompt to mention memory_recall, got %q", prompt)
|
||||
if !contains(prompt, "stablemem_recall") {
|
||||
t.Errorf("expected prompt to mention stablemem_recall, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,34 +2,13 @@ package memory
|
||||
|
||||
// 测试脚手架:重建被测试删掉的 memory_blocks 表。
|
||||
// 刻意放在 _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 (
|
||||
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
|
||||
)`
|
||||
// 一份「靠人记得同步」的副本,在第一次忘记时就已经证明了它不是好设计。
|
||||
// 现在 DDL 只有一处定义(graph.go 的 ddlMemoryBlocks),生产与测试共用。
|
||||
|
||||
@ -19,7 +19,7 @@ import (
|
||||
// 过滤,但它当年只挂在 doc→graph 上,换提取器时被整条摘掉,如今只剩测试调用。
|
||||
//
|
||||
// 这里把判定收敛到一处,供三个地方共用:
|
||||
// - 自动填充路:docToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
|
||||
// - 自动填充路:coldDocToTriples(冷文档归档)、extractKeyTriples(对话蒸馏)
|
||||
// - 历史数据清理:GraphDB.PurgeNoise(老库里的存量垃圾)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
|
||||
118
internal/memory/page_fields_test.go
Normal file
118
internal/memory/page_fields_test.go
Normal 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,否则存量块会被错误聚成同一帧")
|
||||
}
|
||||
}
|
||||
250
internal/memory/prod_segpaging_e2e_test.go
Normal file
250
internal/memory/prod_segpaging_e2e_test.go
Normal 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
|
||||
}
|
||||
154
internal/memory/reclaim_e2e_test.go
Normal file
154
internal/memory/reclaim_e2e_test.go
Normal 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
|
||||
}
|
||||
@ -124,6 +124,11 @@ var methodCapability = map[string]Capability{
|
||||
MethodSecretsFill: CapCore,
|
||||
MethodSecretsEncrypt: CapCore,
|
||||
MethodSecretsDecrypt: CapCore,
|
||||
// 策略声明是 CapCore:任何插件都只能声明**自己名下**的档位
|
||||
//(Owner 由内核填),所以它不构成越权面;限制在"声明别人的档位"
|
||||
// 那一层由 internal/sdk/security_impl.go 拒绝。
|
||||
MethodSecurityDeclareProfile: CapCore,
|
||||
MethodSecurityDeclareProfiles: CapCore,
|
||||
// 生命周期自述(插件声明自己是否可自动重启)
|
||||
MethodLifecycleAutoRestart: CapCore,
|
||||
// 多模态内容块注入是工具返回值的一部分,不越权
|
||||
|
||||
@ -98,6 +98,11 @@ type CoreSDK interface {
|
||||
Settings() pubsdk.SettingsAPI
|
||||
// Secrets 是**全进程共享**的变量库(非 per-plugin)。
|
||||
Secrets() pubsdk.SecretsAPI
|
||||
// Security 是**策略声明面**(插件声明命名档位;判定由内核执行)。
|
||||
//
|
||||
// ★ 只声明、不判定:本接口刻意没有 CheckTool 之类的方法 ——
|
||||
// 那属于内核。插件自判会让策略分散、且插件崩溃即策略失效。
|
||||
Security() pubsdk.SecurityAPI
|
||||
Memory() pubsdk.MemoryAPI
|
||||
TextMemory() pubsdk.TextMemoryAPI
|
||||
DocMemory() pubsdk.DocMemoryAPI
|
||||
@ -187,6 +192,9 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
MethodSettingsDataDir:
|
||||
return h.handleSettings(method, params)
|
||||
|
||||
case MethodSecurityDeclareProfile, MethodSecurityDeclareProfiles:
|
||||
return h.handleSecurity(method, params)
|
||||
|
||||
case MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource:
|
||||
return h.handleLLM(method, params)
|
||||
|
||||
|
||||
69
internal/plugin/proc/corehandler_security.go
Normal file
69
internal/plugin/proc/corehandler_security.go
Normal 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)
|
||||
}
|
||||
155
internal/plugin/proc/e2e_security_declare_test.go
Normal file
155
internal/plugin/proc/e2e_security_declare_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -45,6 +45,8 @@ type fakeCoreSDK struct {
|
||||
settingsAPI pubsdk.SettingsAPI
|
||||
// secretsAPI 同理:变量库后端(端到端判据用)。
|
||||
secretsAPI pubsdk.SecretsAPI
|
||||
// securityAPI 是策略声明面的后端(声明类端到端判据用)。
|
||||
securityAPI pubsdk.SecurityAPI
|
||||
}
|
||||
|
||||
// knowledgeAPITest 是公开 SDK 的知识库契约(不含内核扩展方法)。
|
||||
@ -69,6 +71,7 @@ func newFakeCore() *fakeCoreSDK {
|
||||
func (f *fakeCoreSDK) PluginName() string { return "fake" }
|
||||
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return f.settingsAPI }
|
||||
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) TextMemory() pubsdk.TextMemoryAPI { return nil }
|
||||
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
|
||||
|
||||
@ -204,6 +204,20 @@ const (
|
||||
MethodSecretsEncrypt = "secrets.encrypt" // 46
|
||||
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)
|
||||
MethodLLMListSources = "llm.listSources" // 19
|
||||
MethodLLMSetSource = "llm.setSource" // 20
|
||||
|
||||
@ -42,6 +42,18 @@ func (c procCore) Secrets() pubsdk.SecretsAPI {
|
||||
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 {
|
||||
if s := c.sdk.Settings(); s != nil {
|
||||
return s
|
||||
|
||||
@ -22,7 +22,7 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
"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/plugin/proc"
|
||||
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||||
@ -87,11 +87,11 @@ type Registry struct {
|
||||
pluginAutoRestart map[string]bool
|
||||
sdkRefs map[string]*sdk.PluginSDK
|
||||
|
||||
iom *agentIO.IOManager
|
||||
evBus *events.Bus
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
docStore *doc.Store
|
||||
iom *agentIO.IOManager
|
||||
evBus *events.Bus
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
coldcontextStore *coldcontext.Store
|
||||
// CAS 已淘汰(块自带负载,见 plan.md)。本注册表不保留媒体存储字段:
|
||||
// 留着会诱导后来者再接线,走回头路。媒体字节走 sdk.Memory().MediaBytes。
|
||||
ks *knowledge.Store
|
||||
@ -239,10 +239,10 @@ func (r *Registry) SetTextMemory(tm *text.Memory) {
|
||||
defer r.mu.Unlock()
|
||||
r.textMem = tm
|
||||
}
|
||||
func (r *Registry) SetDocStore(ds *doc.Store) {
|
||||
func (r *Registry) SetDocStore(ds *coldcontext.Store) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.docStore = ds
|
||||
r.coldcontextStore = ds
|
||||
}
|
||||
func (r *Registry) SetKnowledge(ks *knowledge.Store) {
|
||||
r.mu.Lock()
|
||||
@ -533,28 +533,28 @@ func (d *channelDevice) ChannelDef() agentIO.ChannelDef { return d.chDef }
|
||||
// 同一次 buildSDK 里拿到**互相不一致**的组合(比如 lua 已是新的、cfg 还是旧的)。
|
||||
// 一次 RLock 取齐既消灭竞争,也保证同一次 SDK 装配看到的是同一个状态。
|
||||
type sdkDeps struct {
|
||||
plgDir string
|
||||
dataDir string
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
iom *agentIO.IOManager
|
||||
evBus *events.Bus
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
docStore *doc.Store
|
||||
ks *knowledge.Store
|
||||
mgr *agentAPI.ProviderManager
|
||||
lua *luaVM.VM
|
||||
baseKey string
|
||||
regTool sdk.ToolRegistrar
|
||||
regStage sdk.StageRegistrar
|
||||
regAPI sdk.APIRegistrar
|
||||
status sdk.StatusAPI
|
||||
sup sdk.SupervisorAPI
|
||||
trk *tracker.Tracker
|
||||
cfg *types.Config
|
||||
stageHost sdk.ToolSource
|
||||
idx *memory.Indexer
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
plgDir string
|
||||
dataDir string
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
iom *agentIO.IOManager
|
||||
evBus *events.Bus
|
||||
memDB *memory.GraphDB
|
||||
textMem *text.Memory
|
||||
coldcontextStore *coldcontext.Store
|
||||
ks *knowledge.Store
|
||||
mgr *agentAPI.ProviderManager
|
||||
lua *luaVM.VM
|
||||
baseKey string
|
||||
regTool sdk.ToolRegistrar
|
||||
regStage sdk.StageRegistrar
|
||||
regAPI sdk.APIRegistrar
|
||||
status sdk.StatusAPI
|
||||
sup sdk.SupervisorAPI
|
||||
trk *tracker.Tracker
|
||||
cfg *types.Config
|
||||
stageHost sdk.ToolSource
|
||||
idx *memory.Indexer
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
}
|
||||
|
||||
// snapshotDeps 在一次 RLock 内取走 buildSDK 需要的全部依赖。
|
||||
@ -562,28 +562,28 @@ func (r *Registry) snapshotDeps() sdkDeps {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
return sdkDeps{
|
||||
plgDir: r.plgDir,
|
||||
dataDir: r.dataDir,
|
||||
cfgReg: r.cfgReg,
|
||||
iom: r.iom,
|
||||
evBus: r.evBus,
|
||||
memDB: r.memDB,
|
||||
textMem: r.textMem,
|
||||
docStore: r.docStore,
|
||||
ks: r.ks,
|
||||
mgr: r.mgr,
|
||||
lua: r.lua,
|
||||
baseKey: r.baseKey,
|
||||
regTool: r.regTool,
|
||||
regStage: r.regStage,
|
||||
regAPI: r.regAPI,
|
||||
status: r.status,
|
||||
sup: r.sup,
|
||||
trk: r.trk,
|
||||
cfg: r.cfg,
|
||||
stageHost: r.stageHost,
|
||||
idx: r.idx,
|
||||
kernelCtl: r.kernelCtl,
|
||||
plgDir: r.plgDir,
|
||||
dataDir: r.dataDir,
|
||||
cfgReg: r.cfgReg,
|
||||
iom: r.iom,
|
||||
evBus: r.evBus,
|
||||
memDB: r.memDB,
|
||||
textMem: r.textMem,
|
||||
coldcontextStore: r.coldcontextStore,
|
||||
ks: r.ks,
|
||||
mgr: r.mgr,
|
||||
lua: r.lua,
|
||||
baseKey: r.baseKey,
|
||||
regTool: r.regTool,
|
||||
regStage: r.regStage,
|
||||
regAPI: r.regAPI,
|
||||
status: r.status,
|
||||
sup: r.sup,
|
||||
trk: r.trk,
|
||||
cfg: r.cfg,
|
||||
stageHost: r.stageHost,
|
||||
idx: r.idx,
|
||||
kernelCtl: r.kernelCtl,
|
||||
}
|
||||
}
|
||||
|
||||
@ -743,13 +743,17 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
// 插件名仅用于日志溯源(哪个插件写的块)。
|
||||
Memory: sdk.NewGraphMemoryWithPlugin(name, deps.memDB),
|
||||
TextMemory: sdk.NewTextMemoryWithPlugin(name, deps.textMem),
|
||||
DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.docStore),
|
||||
DocMemory: sdk.NewDocMemoryWithPlugin(name, deps.coldcontextStore),
|
||||
Knowledge: sdk.NewKnowledge(deps.ks),
|
||||
LLM: sdk.NewLLM(deps.mgr, deps.cfgReg, deps.lua, deps.baseKey),
|
||||
Settings: sett,
|
||||
// ★ 变量库:桥接到进程级 security.Global()(**不另建存储**)。
|
||||
// 内核未启用安全模块时它 Available()==false(不是 nil)。
|
||||
Secrets: sdk.NewSecretsAPI(),
|
||||
// ★ 策略声明面:插件**声明**档位(qq.owner / qq.guest…),
|
||||
// 判定仍在内核 security.Runtime。Owner 由内核用真实插件名填,
|
||||
// 插件无法伪造身份(见 internal/sdk/security_impl.go)。
|
||||
Security: sdk.NewSecurityAPI(name),
|
||||
// ★ 随包资源:读 <插件目录>/rawfile/,可写目录 <插件目录>/work/。
|
||||
// 内置插件(无独立目录)拿到的路径不存在,Read 会明确报错 ——
|
||||
// 那是对的:它们本来就没有随包资源。
|
||||
@ -778,7 +782,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
Tracker: deps.trk,
|
||||
Config: sdk.NewConfig(deps.cfg),
|
||||
Tool: sdk.NewTool(deps.stageHost, deps.iom),
|
||||
Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.docStore),
|
||||
Indexer: sdk.NewIndexerWithDocs(deps.idx, deps.coldcontextStore),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -850,8 +850,8 @@ func (p *Plugin) collectToolDefsForLLM(s *sdk.PluginSDK, pluginFilter string) []
|
||||
func isSafeReadonlyTool(name string) bool {
|
||||
// 明确只读的查询/列表类工具
|
||||
readonlyExact := map[string]bool{
|
||||
"memory_recall": true,
|
||||
"memory_introspect": true,
|
||||
"stablemem_recall": true,
|
||||
"stablemem_introspect": true,
|
||||
"coldcontext_query": true,
|
||||
"knowledge_search": true,
|
||||
"knowledge_list": true,
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
"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"
|
||||
)
|
||||
|
||||
@ -322,14 +322,14 @@ func TestHealthcheckWithDocStore(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
ds := doc.NewStore(tmpDir, memory.TokenizeWords)
|
||||
ds := coldcontext.NewStore(tmpDir, memory.TokenizeWords)
|
||||
if err := ds.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ds.Stop()
|
||||
|
||||
// 预置一篇生产文档,验证 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)
|
||||
}
|
||||
before := len(ds.Query("生产文档", 10))
|
||||
@ -398,7 +398,7 @@ func TestLLMReportCollection(t *testing.T) {
|
||||
func TestSafeReadonlyTool(t *testing.T) {
|
||||
// 只读:应放行
|
||||
readonly := []string{
|
||||
"memory_recall", "memory_introspect", "coldcontext_query",
|
||||
"stablemem_recall", "stablemem_introspect", "coldcontext_query",
|
||||
"knowledge_search", "knowledge_list", "person_query",
|
||||
"person_network", "llm_list_sources", "output_list_channels",
|
||||
"terminal_list", "files_list",
|
||||
@ -411,8 +411,8 @@ func TestSafeReadonlyTool(t *testing.T) {
|
||||
|
||||
// 写/删/改/副作用:应被拒绝
|
||||
mutating := []string{
|
||||
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
|
||||
"memory_merge", "memory_block_merge",
|
||||
"stablemem_commit", "stablemem_edit", "stablemem_purge", "stablemem_delete_entity",
|
||||
"stablemem_merge", "stablemem_block_merge",
|
||||
"knowledge_create", "knowledge_delete",
|
||||
"coldcontext_commit", "doc_delete",
|
||||
"person_set_trait", "person_relate",
|
||||
@ -436,8 +436,8 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
|
||||
// 构造一个包含写类工具的 ToolDef 集合,注入工具注册表
|
||||
stage := agentCore.NewStageHost()
|
||||
defs := []sdk.ToolDef{
|
||||
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
|
||||
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
|
||||
{Name: "stablemem_recall", Plugin: "memory", Description: "recall"},
|
||||
{Name: "stablemem_commit", Plugin: "memory", Description: "commit"},
|
||||
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
|
||||
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
|
||||
{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] {
|
||||
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
|
||||
}
|
||||
}
|
||||
// 写类/副作用工具绝不能被交给 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] {
|
||||
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()
|
||||
defs := []sdk.ToolDef{
|
||||
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
|
||||
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
|
||||
{Name: "stablemem_recall", Plugin: "memory", Description: "recall"},
|
||||
{Name: "stablemem_commit", Plugin: "memory", Description: "commit"},
|
||||
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
|
||||
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
|
||||
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
|
||||
@ -514,14 +514,14 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
|
||||
if len(got) != 1 || !allowed["files_list"] {
|
||||
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] {
|
||||
t.Errorf("tool %q must NOT be in files-filtered set", name)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
|
||||
luaVM "github.com/JianFeeeee/HologramAgent/internal/lua"
|
||||
"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"
|
||||
cli "github.com/JianFeeeee/HologramAgent/internal/plugins/cli"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
|
||||
@ -22,14 +22,14 @@ import (
|
||||
)
|
||||
|
||||
type testPluginEnv struct {
|
||||
tmpDir string
|
||||
stageHost *agentCore.StageHost
|
||||
iom *agentIO.IOManager
|
||||
pluginReg *plugin.Registry
|
||||
memDB *memory.GraphDB
|
||||
ks *knowledge.Store
|
||||
docStore *doc.Store
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
tmpDir string
|
||||
stageHost *agentCore.StageHost
|
||||
iom *agentIO.IOManager
|
||||
pluginReg *plugin.Registry
|
||||
memDB *memory.GraphDB
|
||||
ks *knowledge.Store
|
||||
coldcontextStore *coldcontext.Store
|
||||
cfgReg *internalConfig.ConfigRegistry
|
||||
}
|
||||
|
||||
func setupIntegration(t *testing.T) *testPluginEnv {
|
||||
@ -107,15 +107,15 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docStore := doc.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords)
|
||||
if err := docStore.Start(); err != nil {
|
||||
coldcontextStore := coldcontext.NewStore(filepath.Join(tmpDir, "documents"), memory.TokenizeWords)
|
||||
if err := coldcontextStore.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pluginReg.SetStageHost(stageHost)
|
||||
pluginReg.SetProviderManager(pm)
|
||||
pluginReg.SetKnowledge(ks)
|
||||
pluginReg.SetDocStore(docStore)
|
||||
pluginReg.SetDocStore(coldcontextStore)
|
||||
|
||||
cli.DefaultSocket = filepath.Join(tmpDir, "cli.sock")
|
||||
|
||||
@ -154,14 +154,14 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
|
||||
}
|
||||
|
||||
return &testPluginEnv{
|
||||
tmpDir: tmpDir,
|
||||
stageHost: stageHost,
|
||||
iom: iom,
|
||||
pluginReg: pluginReg,
|
||||
memDB: memDB,
|
||||
ks: ks,
|
||||
docStore: docStore,
|
||||
cfgReg: cfgReg,
|
||||
tmpDir: tmpDir,
|
||||
stageHost: stageHost,
|
||||
iom: iom,
|
||||
pluginReg: pluginReg,
|
||||
memDB: memDB,
|
||||
ks: ks,
|
||||
coldcontextStore: coldcontextStore,
|
||||
cfgReg: cfgReg,
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,7 +169,7 @@ func (e *testPluginEnv) cleanup() {
|
||||
e.pluginReg.StopAll()
|
||||
e.memDB.Close()
|
||||
e.ks.Stop()
|
||||
e.docStore.Stop()
|
||||
e.coldcontextStore.Stop()
|
||||
os.RemoveAll(e.tmpDir)
|
||||
}
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ Four stores, one rule each:
|
||||
| Need | Tool |
|
||||
|---|---|
|
||||
| A past episode in full | `coldcontext_query` |
|
||||
| A fact or relation | `memory_recall` |
|
||||
| A fact or relation | `stablemem_recall` |
|
||||
| Written reference material | `knowledge_search` |
|
||||
|
||||
### `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
|
||||
|
||||
- Facts worth keeping: `memory_commit` (triples).
|
||||
- Facts worth keeping: `stablemem_commit` (triples).
|
||||
- Written knowledge: `knowledge_write`.
|
||||
- Do not commit transient chatter, tool output, or your own status lines.
|
||||
|
||||
@ -96,44 +96,46 @@ func (e *eventsAPI) EventPolicy() security.EventPolicy {
|
||||
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 {
|
||||
if 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 {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
// 载荷键数上限仍在这里判:它是**当前唯一实现**(eventGuard.AllowPublish
|
||||
// 只拿到 eventType、看不到 payload,故无法在那里做)。
|
||||
// 删掉它 = 直接失去这项保护,所以不属“重复”而不应一并丢弃。
|
||||
if d := e.checkSize(len(payload)); !d.Allowed {
|
||||
return fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
|
||||
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
|
||||
// 插件顺手把凭据放进去就等于绕过凭据闸门。
|
||||
e.bus.Publish(&events.Event{
|
||||
return e.bus.PublishAs(events.Publisher{Name: e.pluginName}, &events.Event{
|
||||
Type: events.EventType(eventType),
|
||||
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 {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventPayloadSize(keys)
|
||||
@ -141,66 +143,26 @@ func (e *eventsAPI) checkSize(keys int) security.Decision {
|
||||
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) {
|
||||
if 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 {
|
||||
return nil, fmt.Errorf("events: handler must not be nil")
|
||||
}
|
||||
unsub := e.bus.Subscribe(events.EventType(eventType), func(evt *events.Event) {
|
||||
handler(e.scrubIn(eventType, evt.Payload))
|
||||
})
|
||||
// 走 SubscribeAs:授权交给 security.eventGuard.AllowSubscribe(唯一一处),
|
||||
// 入向清洗已由总线在投递前统一执行 —— 这里不再自己洗一遍。
|
||||
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)
|
||||
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 解除本插件建立的全部订阅(插件卸载时由宿主调用)。
|
||||
func (e *eventsAPI) stop() {
|
||||
for _, u := range e.unsubs {
|
||||
|
||||
@ -15,9 +15,28 @@ import (
|
||||
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) {
|
||||
t.Helper()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -6,13 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext"
|
||||
)
|
||||
|
||||
// indexerImpl 桥接 memory.Indexer 到中立的 IndexerAPI。
|
||||
type indexerImpl struct {
|
||||
idx *memory.Indexer
|
||||
docs *document.Store // 可选:给「上下文」诊断面提供 L2 那一路
|
||||
docs *coldcontext.Store // 可选:给「上下文」诊断面提供 L2 那一路
|
||||
}
|
||||
|
||||
func NewIndexer(idx *memory.Indexer) IndexerAPI {
|
||||
@ -20,7 +20,7 @@ func NewIndexer(idx *memory.Indexer) IndexerAPI {
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
@ -81,14 +81,14 @@ func (i *indexerImpl) queryDocs(q string, limit int) []DocSnippet {
|
||||
when = h.Doc.Events[0].Timestamp.Format(time.DateTime)
|
||||
}
|
||||
// 正文在事件的**文本块**里(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})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// docFirstText 取文档可用于展示的首段正文(跳过媒体块)。
|
||||
func docFirstText(d *document.Doc) string {
|
||||
// coldcontextFirstText 取文档可用于展示的首段正文(跳过媒体块)。
|
||||
func coldcontextFirstText(d *coldcontext.ColdDoc) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user