Merge commit 'refs/tmp/gmain' into ci/boot-graph-tests

This commit is contained in:
JianFeeeee
2026-10-24 13:34:57 +08:00
300 changed files with 5310 additions and 939 deletions

View File

@ -286,7 +286,7 @@ func (a *Agent) toolParallelSafe(name string) bool {
//
// 走过的弯路(都留在注释里,因为每一种都"看起来能工作"):
// 1. 内核里一张 map[string]bool 硬编码名单:声明从工具搬回内核,
// 工具改名/新增不会跟着变,要靠 grep 源码的判据才<EFBFBD><EFBFBD><EFBFBD>发现漂移;
// 工具改名/新增不会跟着变,要靠 grep 源码的判据才能发现漂移;
// 2. 往 required 变参里塞字符串 "toolParallel":拼错静默失效,
// 编译器不报错,而"少一个工具能并发"正是最难察觉的那类问题;
// 3. 每次查询重跑 buildToolDefs():正确但 O(工具数) 重复劳动。

View File

@ -17,7 +17,7 @@ import (
//
// 注入必须**晚绑定**且**per-agent**:内置工具的可见性由运行期状态门控
// (`if a.memory != nil` / `if a.knowledge != nil` …),而驻留子是轻量内核、
// memory 为 nil。<EFBFBD><EFBFBD> ToolAPI 是全局单例,拿不到 agent,只能由 agent 自己
// memory 为 nil。而 ToolAPI 是全局单例,拿不到 agent,只能由 agent 自己
// 提供一份 provider。
// builtinProvider 是 *Agent 上的适配器:把 agent 的内置工具面

View File

@ -34,6 +34,21 @@ type ContextEvent struct {
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏),
// 但**照常参与归档**(离开 L1 时会写回 L2)。
//
// ★ 与 ToolDef.NoMemory 语义一致:只影响计算层,不影响存储层。
//
// ★★ 为什么不能顺手把归档也关掉(2026-10-22 踩过的坑):
//
// doc_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1
// 被裁掉时又不归档,它就**三层都不在**了 —— 内容被永久丢弃。
// L1 是它最后的落脚点,必须能落回去。
//
// 所以"回填事件不再归档"是错的。它确实该重新归档(那才是迁移闭环),
// 该抑制的只是"重新计算向量/再次进蒸馏"—— 那才是自激的入口。
NoMemory bool `json:"no_memory,omitempty"`
// --- 原生多模态记忆 ---
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
@ -216,6 +231,13 @@ func (c *RelevanceContext) load() {
}
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string {
// 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。
//
// 为什么回填事件不参与计算:它们的内容刚刚在 L2 里被向量化过一次,
// 再算一次只会让"同一内容"在向量空间里被重复加权。但它们**照常归档**。
if evt.NoMemory {
return ""
}
var text string
switch {
case evt.Source == "agent" && evt.Response != "":
@ -237,11 +259,23 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
}
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
if toolDefLookup != nil {
{
noMemory := make(map[string]bool)
for _, tr := range evt.ToolResults {
def := toolDefLookup(tr.Name)
if def != nil && def.NoMemory {
// ① 插件工具(含 proc 桥):走 StageHost 的 ToolDef 声明
if toolDefLookup != nil {
if def := toolDefLookup(tr.Name); def != nil && def.NoMemory {
noMemory[tr.Name] = true
continue
}
}
// ② 内置工具(core 自己的 switch 分派):走包级声明表。
//
// ★ doc_query 属于这一类 —— 它的返回值含被召回事件的正文片段,
// 而那些正文已在记忆体系里(来自 L2/L3)。若返回值再参与向量化
// 与蒸馏,就是把同一内容重新注入记忆,与「回填事件不再归档」
// 构成同一个自激回路的两个入口。
if noMemoryOf(tr.Name) {
noMemory[tr.Name] = true
}
}
@ -250,9 +284,10 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
continue
}
cleaned := tr.Output
def := toolDefLookup(tr.Name)
if def != nil && def.Cleaner != nil {
cleaned = def.Cleaner(cleaned)
if toolDefLookup != nil {
if def := toolDefLookup(tr.Name); def != nil && def.Cleaner != nil {
cleaned = def.Cleaner(cleaned)
}
}
text += " " + cleaned
}
@ -505,6 +540,16 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
})
archived := 0
if docStore != nil && len(archive) > 0 {
// ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。
//
// 曾经在这里跳过它们(理由是"防自激"),那是错的:
// doc_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。
// 跳过归档 = 裁掉即丢失(L1、L2、L3 三层都不在)。
//
// 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 ——
// 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。
}
if docStore != nil && len(archive) > 0 {
entries := make([]document.ContextEntry, len(archive))
for i, s := range archive {

View File

@ -5,6 +5,7 @@ import (
"log"
"sort"
"strings"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
return merged
}
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
// crossModalMarkdown 把融合候选渲染成注入提示词的文本。
//
// ## 为什么改
//
// 旧输出是这样的:
//
// 【跨模态相关记忆】
// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
//
// 三个问题,都被生产日志证实:
//
// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性;
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
//
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
// 这才是让预填充真正被用起来的必要条件。
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
}
var lines []string
for i, h := range hits {
marker := ""
switch {
case h.DoubleHit:
marker = "(图文双命中)"
case h.Doc != nil:
marker = "(文本命中)"
case h.Media != nil:
marker = "(视觉命中)"
}
parts := []string{fmt.Sprintf("[%d]", i+1)}
var parts []string
parts = append(parts, fmt.Sprintf("[%d]", i+1))
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
if h.Doc != nil {
parts = append(parts, h.Doc.Summary)
if h.Doc.Source != "" {
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
if h.Doc.HasEvents() {
first := h.Doc.Events[0].Timestamp
last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s→%s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 真实来源(跳过内部归档标记,它对模型无意义)
if src := firstRealSource(h.Doc.Events); src != "" {
parts = append(parts, src)
}
} else {
parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04"))
}
}
// 正文开头:真实内容,模型可据此判断相关性
if h.Doc != nil {
if head := docHeadlineForPrompt(h.Doc); head != "" {
parts = append(parts, head)
}
}
// 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除)
if h.Media != nil {
if line := blockLabel(h.Media); line != "" {
parts = append(parts, line)
label := string(h.Media.Modality)
if h.Media.MIME != "" {
label = h.Media.MIME
}
parts = append(parts, "["+label+"]")
}
if h.DoubleHit {
parts = append(parts, "(图文双命中)")
}
parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused))
lines = append(lines, strings.Join(parts, " · "))
}
var b strings.Builder
b.WriteString("【历史记忆(L2,自动预填充)】\n")
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
"按原始时间戳插回你的对话时序。")
return b.String()
}
// firstRealSource 取事件里第一个非内部标记的来源。
//
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
func firstRealSource(events []document.DocEvent) string {
for _, ev := range events {
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
return ev.Source
}
}
if len(events) > 0 {
return events[0].Source
}
return ""
}
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
//
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
func docHeadlineForPrompt(d *document.Doc) string {
if d == nil {
return ""
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
for _, cand := range []string{in, resp} {
if line := firstMeaningfulLineForPrompt(cand); line != "" {
return truncateRunesForPrompt(line, 60)
}
}
}
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
lines = append(lines, strings.Join(parts, " "))
return ""
}
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60)
}
func firstMeaningfulLineForPrompt(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
hasWord := false
for _, r := range line {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
hasWord = true
break
}
}
if hasWord {
return strings.Join(strings.Fields(line), " ")
}
}
return ""
}
func truncateRunesForPrompt(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

View File

@ -558,12 +558,20 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
// 写入 L3 并以 document --contains--> block 边关联,
// 不经过文本描述与 NLP 提取器。
// NLP 通用提取
// NLP 通用提取。
//
// ★ 输入必须是**原文**,不能是视图。
//
// 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染
// (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取,
// 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。
//
// 现在优先用事件里的完整正文;存量文档没有 Events 才退回 Content。
e := nlp.NewExtractor(nil)
if embedder != nil {
e.SetEmbedder(embedder)
}
result := e.Extract(doc.Content)
result := e.Extract(documentOriginalText(doc))
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
@ -635,3 +643,33 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}
// documentOriginalText 取文档的**原文**用于抽取/蒸馏。
//
// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。
// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。
func documentOriginalText(doc *document.Doc) string {
if doc == nil {
return ""
}
if !doc.HasEvents() {
return doc.Content
}
var b strings.Builder
for _, ev := range doc.Events {
in, resp, _ := ev.InOut()
if in != "" {
b.WriteString(in)
b.WriteString("\n")
}
if resp != "" {
b.WriteString(resp)
b.WriteString("\n")
}
for _, tr := range ev.ToolResults {
b.WriteString(tr.Output)
b.WriteString("\n")
}
}
return b.String()
}

View File

@ -10,7 +10,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代(M2)。

View File

@ -9,7 +9,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 统一输入主干(processInput / resolveInput / injectedBlocks)与

View File

@ -1,12 +1,14 @@
package core
import (
"errors"
"fmt"
"log"
"sync"
"sync/atomic"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
)
@ -67,19 +69,41 @@ func (a *Agent) reembedStaleMedia() {
var jobsOnce sync.Once
jobs := make(chan memory.MemoryBlock, workers*2)
var done, failed int64
var done, failed, unsupported int64
var wg sync.WaitGroup
// 失败原因按错误文本聚合 —— 不逐条打日志(上千张图会刷屏),
// 但**必须留下"为什么失败"**。
//
// ★ 2026-10-23 实测教训:原先这里只 `failed++` 然后 continue,于是
// 「向量迁移完成: 成功=0 失败=4」成了唯一的线索 —— 没有任何日志说明
// 这 4 个是 webp(当时 chineseclip 未注册 webp 解码器)。失败不可观测
// 直接导致这次排查要从数据库里反查 mime 才找到根因。
var failMu sync.Mutex
failReasons := map[string]int{}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for b := range jobs {
if err := a.reembedOneBlock(b, fp); err != nil {
atomic.AddInt64(&failed, 1)
err := a.reembedOneBlock(b, fp)
if err == nil {
atomic.AddInt64(&done, 1)
continue
}
atomic.AddInt64(&done, 1)
// 模态不受支持是**永久**状态(不是本次网络/服务抖动):
// 这类块永远不会有向量,语义检索也永远搜不到它。
// 单独计数,避免它混在"失败"里让人以为重试就能好。
if errors.Is(err, vector.ErrModalityUnsupported) {
atomic.AddInt64(&unsupported, 1)
} else {
atomic.AddInt64(&failed, 1)
}
key := b.MIME + " | " + err.Error()
failMu.Lock()
failReasons[key]++
failMu.Unlock()
}
}()
}
@ -93,8 +117,17 @@ func (a *Agent) reembedStaleMedia() {
}
safeclose.Safe(&jobsOnce, jobs)
wg.Wait()
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 总计=%d fp=%s",
done, failed, len(targets), shortFP(fp))
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不支持=%d 总计=%d fp=%s",
done, failed, unsupported, len(targets), shortFP(fp))
// 逐类打印失败原因(聚合后通常只有 1~2 类)。
// 没有这段,运维只能看到"失败=N"而不知为何 —— 本次 webp 问题的排查成本
// 全花在这里。
if len(failReasons) > 0 {
log.Printf("[media] 失败原因分类(按 mime | error 聚合):")
for reason, n := range failReasons {
log.Printf("[media] %d 个: %s", n, reason)
}
}
}
// reembedOneBlock 为一个图像块重算向量并写回。负载就在块里,无需外部取字节。

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"regexp"
"strings"
"testing"
@ -102,3 +103,100 @@ func TestReembedStaleMedia_NilSpaceNoop(t *testing.T) {
t.Errorf("无空间可用时不该写向量: vec=%v model=%q", got.Vector, got.Fingerprint)
}
}
// failSpace 让指定 mime 的图像嵌入失败,用来验证「失败必须留下原因」。
type failSpace struct {
failMIME string
}
func (failSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (f failSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
if mime == f.failMIME {
return nil, fmt.Errorf("模拟解码失败: %s", mime)
}
return []float64{1, 0}, nil
}
func (failSpace) Fingerprint() string { return "fail-space" }
func (failSpace) Dim() int { return 2 }
func (failSpace) Loaded() bool { return true }
func (failSpace) Close() {}
// 迁移失败必须留下**可诊断的原因**,而不只是一个计数。
//
// ★ 2026-10-23 生产事故:日志只有
//
// [media] 向量迁移完成: 成功=0 失败=4 总计=4
//
// 没有任何信息说明为什么。反查数据库才发现那 4 个块是 image/webp,
// 而 provider 没注册 webp 解码器。**失败不可观测直接导致排查成本翻倍**。
//
// 本判据钉住:失败原因按 "mime | error" 聚合后必须出现在日志里。
//
// 变异:把 failReasons 的采集与打印删掉 ⇒ 红。
func TestReembedStaleMedia_LogsFailureReasons(t *testing.T) {
a := newMediaLoopAgent(t)
// 两个都会失败(同一 mime)—— 应聚合成一条 "2 个: ..."
for i := 0; i < 2; i++ {
b := mediaBlock("image/webp", []byte("webp-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
}
logs := captureLog(t, func() {
a.multimodalSpace = failSpace{failMIME: "image/webp"}
a.reembedStaleMedia()
})
if !strings.Contains(logs, "失败原因分类") {
t.Fatalf("迁移失败时必须打印失败原因分类,实际日志:\n%s", logs)
}
if !strings.Contains(logs, "image/webp") {
t.Fatalf("失败原因里必须出现具体 mime(否则无从定位是哪种格式),实际日志:\n%s", logs)
}
if !strings.Contains(logs, "模拟解码失败") {
t.Fatalf("失败原因里必须带上 provider 返回的错误原文,实际日志:\n%s", logs)
}
// 计数必须出现(格式 "N 个: ...")—— 具体数字取决于去重后的块数,
// 这里只要求"有计数",不绑死数字(绑死会让判据随去重策略变化而脆)。
if !regexp.MustCompile(`\d+ 个: image/webp`).MatchString(logs) {
t.Fatalf("失败原因应带聚合计数(形如「N 个: image/webp | ...」),实际日志:\n%s", logs)
}
}
// 不支持模态与临时失败必须**分开计数**:前者永远好不了(重试无意义),
// 混在"失败"里会让人误以为重试即可恢复。
func TestReembedStaleMedia_SeparatesUnsupportedFromFailed(t *testing.T) {
a := newMediaLoopAgent(t)
// 音频走 fakeSpace 的 ErrModalityUnsupported 分支;但 reembedStaleMedia
// 只挑 image 模态,所以用图像 mime + 让 provider 返回 unsupported 来验证分类。
img := mediaBlock("image/tiff", []byte("tiff-bytes"))
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{img}); err != nil {
t.Fatal(err)
}
logs := captureLog(t, func() {
a.multimodalSpace = unsupportedImageSpace{}
a.reembedStaleMedia()
})
if !strings.Contains(logs, "不支持=1") {
t.Fatalf("不受支持的模态应单独计数(不支持=1),实际日志:\n%s", logs)
}
}
// unsupportedImageSpace 对任何图像都返回 ErrModalityUnsupported。
type unsupportedImageSpace struct{}
func (unsupportedImageSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (unsupportedImageSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
}
func (unsupportedImageSpace) Fingerprint() string { return "unsupported-space" }
func (unsupportedImageSpace) Dim() int { return 2 }
func (unsupportedImageSpace) Loaded() bool { return true }
func (unsupportedImageSpace) Close() {}

View File

@ -301,33 +301,42 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
}
// 块应已到达 L2,且身份不变。
//
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
// 要断言的是**媒体块原样迁移**,而不是块总数。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
if len(d.Blocks) > 0 {
found = d
break
for _, b := range d.Blocks {
if b.ID == block.ID {
found = d
}
}
}
if found == nil {
t.Fatal("归档文档应持有一等记忆块")
t.Fatal("媒体块未随归档事件迁移到 L2")
}
if len(found.Blocks) != 1 {
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
var got *memory.MemoryBlock
for i := range found.Blocks {
if found.Blocks[i].ID == block.ID {
got = &found.Blocks[i]
}
}
got := found.Blocks[0]
if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME ||
got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block)
}
// 字节随块迁移(union)
if string(got.Data) != string(block.Data) {
t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data)
}
// 同一块不能同时留在 L0。
// 同一块不能同时留在 L0(L1)。
for _, e := range rc.Recent(100) {
if len(e.Blocks) > 0 {
t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks)
for _, b := range e.Blocks {
if b.ID == block.ID {
t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b)
}
}
}
}

View File

@ -7,7 +7,7 @@ import (
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// sceneKeysFor 推导本轮输入的**当前场景**。

View File

@ -6,7 +6,6 @@ import (
"strings"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
)
@ -28,84 +27,240 @@ import (
//
// 通道插件在这个时点需要知道"这条消息是发给谁的",所以把本轮 payload
// 透传进来(与 emitResponse 用同一套 Extra 语义)。
// outputItem 是一条待发送的输出项。
//
// 内核只承载三个**插件自定义字段**,不懂它们的语义:
//
// payload 载荷内容(文本 / URL / 路径…由插件解释)
// type 载荷类型(插件自定义枚举,内核不校验)
// meta 发送元数据(JSON 字符串,插件解释)
type outputItem struct {
payload string
typ string
meta string
}
// parseOutputItems 把工具参数解析成 1..N 条待发送项。
//
// ## 一次调用发多条(2026-10-22)
//
// 模型可以传数组来实现"一条回复里发多条、多种类型":
//
// payload: ["收到,我看看", "https://.../a.png", "/tmp/report.pdf"]
// type: ["text", "image", "file"]
// meta: "{\"user_id\":123}" ← 单值:广播给每一条
// 或 ["{...}", "{...}", "{...}"] ← 数组:一一对应
//
// ## 对齐规则(用户拍板)
//
// - payload 与 type 必须等长;type 省略/单值 ⇒ 全部复用该值(缺省 text)
// - 长度不符 ⇒ **整 call 拒绝,一条都不发**(不能发一半再报错)
// - meta 允许单值广播
//
// ## 向后兼容
//
// payload 为字符串时走单条路径,行为与改动前逐字相同。
func parseOutputItems(args map[string]interface{}) ([]outputItem, error) {
payloads := stringOrArray(args["payload"])
types := stringOrArray(args["type"])
metas := stringOrArray(args["meta"])
if len(payloads) == 0 {
return nil, fmt.Errorf("payload 不能为空")
}
// ★ 空字符串元素**不在这里拒绝**(2026-10-22 实测踩到)。
//
// 内核不知道 payload 的语义:它可能是"要发的内容",也可能是占位 ——
// 例如 QQ 的 `type=sticker` + `meta.sticker_index=1`,真实 URL 由插件从
// 收藏表情里取,模型根本不知道该往 payload 填什么。内核在这里判空,
// 等于用一个它不理解的字段把插件的合法用法挡掉(实测报
// "payload 不能为空",而插件侧的放行分支永远走不到)。
//
// 正确边界:**参数是否存在归内核管,参数值是否合法归插件管**。
// 所以只拒绝"整条数组里没有任何一项"这种结构性错误(上面那条),
// 逐项内容交给插件判断并返回 error。
// type 对齐:省略 / 单值 ⇒ 广播;否则必须与 payload 等长
if len(types) == 0 {
types = []string{"text"}
}
if len(types) == 1 && len(payloads) > 1 {
broadcast := types[0]
types = make([]string, len(payloads))
for i := range types {
types[i] = broadcast
}
}
if len(types) != len(payloads) {
return nil, fmt.Errorf("payload 有 %d 条,type 有 %d 条 —— 数量必须一致"+
"(或只给一个 type 表示全部同类型)", len(payloads), len(types))
}
for i, ty := range types {
if strings.TrimSpace(ty) == "" {
types[i] = "text"
}
}
// meta 对齐:省略 / 单值 ⇒ 广播
if len(metas) == 1 && len(payloads) > 1 {
broadcast := metas[0]
metas = make([]string, len(payloads))
for i := range metas {
metas[i] = broadcast
}
}
if len(metas) == 0 {
metas = make([]string, len(payloads))
}
if len(metas) != len(payloads) {
return nil, fmt.Errorf("payload 有 %d 条,meta 有 %d 条 —— 数量必须一致"+
"(或只给一个 meta 表示全部同一目标)", len(payloads), len(metas))
}
items := make([]outputItem, len(payloads))
for i := range payloads {
items[i] = outputItem{payload: payloads[i], typ: types[i], meta: metas[i]}
}
return items, nil
}
// stringOrArray 把一个 JSON 参数读成 []string。
//
// 同时接受字符串、字符串数组、以及 []interface{}(encoding/json 的默认形态)。
// 空字符串视为"未提供"(返回 nil),与改动前的 `payload == ""` 判据一致。
func stringOrArray(v interface{}) []string {
switch t := v.(type) {
case string:
// 空字符串**保留**为一个元素,不当"未提供"处理。
//
// 区别很重要:`payload: ""` 是"模型明确给了一个空载荷占位"
// (插件可能从 meta 里取真实内容,如 QQ 的 sticker_index),
// 而"没传 payload 这个键"才是真的没提供。前者归插件判断合法性,
// 后者由 parseOutputItems 的 len==0 拦下。
return []string{t}
case []string:
return t
case []interface{}:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
return nil
}
func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
channel := strings.TrimPrefix(tc.Name, "output_send__")
payload, _ := tc.Arguments["payload"].(string)
rawType, _ := tc.Arguments["type"].(string)
if channel == "" || payload == "" {
return "工具名称格式: output_send__{channel},payload 不能为空"
}
// type 缺省按 text 处理:绝大多数输出就是文本,让模型为"省略一个默认值"付一次
// 失败重试没有意义(判据该拦的是"不知道发什么",不是"没写众所周知的默认值")。
if rawType == "" {
rawType = "text"
if channel == "" {
return "工具名称格式: output_send__{channel}"
}
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
if !a.IsOutputAllowed(channel) {
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
}
meta, _ := tc.Arguments["meta"].(string)
caps := a.io.GetChannelCapabilities(channel)
if caps == 0 {
// 通道存在性是**内核唯一该管的校验**。type 与能力的匹配下放给插件:
// type 是插件自定义枚举,内核不认识它(写死 text/image/file 就是内核在
// 理解插件语义,且插件的自定义类型会被无端拒绝)。
if a.io.GetChannelCapabilities(channel) == 0 {
return fmt.Sprintf("通道 [%s] 不存在或不可用。可用输出工具列表见 output_list_channels", channel)
}
switch rawType {
case "text":
if !caps.Supports(agentIO.CapText) {
return fmt.Sprintf("通道 [%s] 不支持文本输出(能力: %s)", channel, caps.String())
}
case "voice", "audio":
if !caps.Supports(agentIO.CapAudio) {
return fmt.Sprintf("通道 [%s] 不支持语音输出(能力: %s)", channel, caps.String())
}
case "image":
if !caps.Supports(agentIO.CapImage) {
return fmt.Sprintf("通道 [%s] 不支持图片输出(能力: %s)", channel, caps.String())
}
case "file":
if !caps.Supports(agentIO.CapFile) {
return fmt.Sprintf("通道 [%s] 不支持文件输出(能力: %s)", channel, caps.String())
}
items, err := parseOutputItems(tc.Arguments)
if err != nil {
// 整 call 拒绝:一条都不发。发一半再报错会让模型以为部分成功,
// 而它无法知道该从第几条重试。
return fmt.Sprintf("output_send__%s 参数有误,未发送任何内容:%v", channel, err)
}
args := map[string]interface{}{
"payload": payload,
"type": rawType,
// 单条且成功 ⇒ 仍返回 "ok"(与改动前逐字一致)。
// 多条或多条里有失败 ⇒ 返回逐项结果,让模型知道哪条没发出去。
type sendResult struct {
ok bool
msg string
}
if meta != "" {
args["meta"] = meta
results := make([]sendResult, len(items))
for i := range items {
text, ok := a.sendOneOutput(channel, items[i])
results[i] = sendResult{ok: ok, msg: text}
}
if len(items) == 1 {
if results[0].ok {
return "ok"
}
return results[0].msg
}
// 多条:如实逐项汇报。失败项必须列出 —— 模型据此决定重发哪条,
// 静默吞掉会让它以为全发出去了。
okCount := 0
var b strings.Builder
for _, r := range results {
if r.ok {
okCount++
}
}
if okCount == len(items) {
// 全部成功也给出条数:多条时不报"ok"会让模型不确定发了几条。
return fmt.Sprintf("已通过 [%s] 通道发送 %d 条", channel, len(items))
}
b.WriteString(fmt.Sprintf("已通过 [%s] 通道发送 %d/%d 条:\n", channel, okCount, len(items)))
for i, r := range results {
typ := items[i].typ
if r.ok {
b.WriteString(fmt.Sprintf(" [%d] %s ok\n", i+1, typ))
} else {
b.WriteString(fmt.Sprintf(" [%d] %s 失败:%s\n", i+1, typ, r.msg))
}
}
b.WriteString("失败的条目请单独重发。")
return b.String()
}
// sendOneOutput 发送一条输出项,返回 (结果文本, 是否成功)。
//
// 这是从 executeOutputSendTool 里拆出来的**单条**路径 —— 批量只是把它循环 N 次。
// 拆出来的理由:before_output / 安全回填 / after_output 这套顺序对**每一条**
// 都必须完整走一遍,混在批量循环里极易漏掉某一环(例如只给第一条跑 stage)。
func (a *Agent) sendOneOutput(channel string, item outputItem) (string, bool) {
args := map[string]interface{}{
"payload": item.payload,
"type": item.typ,
}
if item.meta != "" {
args["meta"] = item.meta
}
// ★ Extra 必须有 output_channel:本函数是"发往某通道",而这个键是
// 插件在该时点唯一能据以判断目标通道的凭据。
// 原先这里连 Extra 都没初始化(nil)——插件读不到任何上下文。
// Extra 必须带三个键,缺一不可:
//
// output_channel 目标通道(插件据此判断"是不是发给我的")
// output_meta 本次发送的 meta 原文(QQ 的 user_id/group_id 在里面)
// output_type 输出类型(text/image/file…)
//
// ★ output_meta 是关键(2026-10-18):本函数是**真正把消息发出去**的
// 时点,通道插件在这里需要知道"这条发给谁"才能驱动「对方正在输入中」。
// 原先 stageCtx 连 Extra 都没初始化(nil)⇒ 实测 `extra_keys=0`
// ⇒ QQ 的打字指示器一次都没能触发。
stageCtx := &sdk.StageContext{
FinalText: payload,
FinalText: item.payload,
Phase: sdk.StageBeforeOutput,
Extra: map[string]interface{}{
"output_channel": channel,
"output_meta": meta,
"output_type": rawType,
"output_meta": item.meta,
"output_type": item.typ,
},
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
if stageCtx.Response != nil {
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response)
return fmt.Sprintf("输出被插件拦截: %s", *stageCtx.Response), false
}
if stageCtx.FinalText == "" {
return "输出被插件清空"
// 只有"原本非空、被插件清空"才是异常(防插件静默吞掉内容却仍算成功)。
//
// 原本就空 ⇒ 合法占位(如 type=sticker + meta.sticker_index,真实 URL
// 由插件自己取),不能报"被清空"——那是拿内核不理解的字段做判断。
if item.payload != "" && stageCtx.FinalText == "" {
return "输出被插件清空", false
}
args["payload"] = stageCtx.FinalText
@ -116,28 +271,19 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
// · 放在 stage 之前 ⇒ 插件会看到明文(不可,插件在信任边界外)
// · 放在 Execute 之后 ⇒ 设备已收到带别名的载荷(功能坏了)
// 所以只能夹在中间:插件看不到,设备拿到真的。
//
// 注意单独处理 payload:args 里的其它字段(channel 等)不需要回填。
if s := security.Global(); s != nil {
if filled, n, used := s.Fill(args["payload"].(string)); n > 0 {
if sc := security.Global(); sc != nil {
if filled, n, used := sc.Fill(args["payload"].(string)); n > 0 {
args["payload"] = filled
log.Printf("[security] 通道 %s 载荷回填 %d 处(别名 %v)", channel, n, used)
}
}
result, err := dev.Execute("output", args)
// ★ after_output:消息**已真正送达**之后触发一次。
//
// 与前面的 before_output 成对(同一份 Extra)。通道插件用它收尾
// 打字指示器之类"发出前开始、发出后结束"的状态。
//
// 为什么必须在这里:output.go 这条路径此前**没有** after_output,
// 而它才是"消息真的发出去了"的时刻。缺了它,插件只能在轮次边界
// (emitResponse 的 after_output)收尾 —— 而一轮可能发多条消息,
// 中间那些永远收不了尾(实测:状态一直挂着)。
// 与 before_output 成对(同一份 Extra)。
stageCtx.Phase = sdk.StageAfterOutput
a.runStage(sdk.StageAfterOutput, stageCtx)
if err != nil {
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err)
return fmt.Sprintf("通过 [%s] 通道发送失败: %v", channel, err), false
}
// 通道可能回报「未确认」(已提交但超时未拿到发送确认)——此时不能对模型
// 谎报「已发送」,否则模型不会重试/核实。
@ -147,17 +293,14 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if note == "" {
note = "发送已提交但未收到通道确认,结果未知"
}
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note)
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note), false
}
}
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send(回声效应),是 output loop 的根源之一。
return "ok"
return "ok", true
}
a.io.EmitTextTo("agent_io", channel, payload)
return fmt.Sprintf("已通过 [%s] 通道发送", channel)
a.io.EmitTextTo("agent_io", channel, item.payload)
return fmt.Sprintf("已通过 [%s] 通道发送", channel), true
}
func (a *Agent) executeOutputSendHelp(tc agentAPI.ToolCall) string {

View File

@ -37,6 +37,37 @@ func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) {
}
}
// 「必须显式调用 outputch 才会送达用户」必须写在提示词里(用户 2026-10-22 明确要求)。
//
// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**——
// 在异步通道上那等于自言自语,用户永远收不到。
//
// 变异:删掉这句 ⇒ 红。改成只在「异步通道」那一条里提 ⇒ 红(模型会漏读分项规则)。
func TestSystemPromptRequiresExplicitOutputCall(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
prompt := parent.buildSystemPrompt("", "你好")
// 必须是**开篇总述**级别的一句,不能只藏在分项里。
if !strings.Contains(prompt, "只有显式调用 `output_send__{通道名}` 才会把内容送达用户") {
t.Fatalf("系统提示词必须以总述句明确「只有显式调用 outputch 才会送达用户」,当前:\n%s",
prompt[:min(600, len(prompt))])
}
// 且必须点明"返回纯文本用户看不到",否则模型仍会以为直接回答就行。
if !strings.Contains(prompt, "返回纯文本只是在这次推理里说话,用户看不到") {
t.Error("应显式说明「返回纯文本用户看不到」")
}
// 同步通道是唯一的例外,必须说清,否则模型在 webui 上也会多余地调工具。
if !strings.Contains(prompt, "这是唯一的例外") {
t.Error("同步通道例外必须显式标为「唯一的例外」")
}
// 批量能力要让模型知道(一 call 多发是本次内核新增能力)。
if !strings.Contains(prompt, "payload 传数组") {
t.Error("提示词应告知 payload 可传数组一次发多条")
}
}
// 输出工具的 type 可省略,缺省按 text 处理(判据该拦的是"不知道发什么",
// 不是"没写众所周知的默认值")。
func TestOutputSendTypeDefaultsToText(t *testing.T) {

View File

@ -0,0 +1,340 @@
package core
// outputch 一 call 多发的判据。
//
// ## 用户拍板的五条(2026-10-22)
//
// 1. 独立 tool(如 qq_send_file)收回 outputch —— 能力统一走输出门
// 2. meta 允许单值广播
// 3. 能力校验放在插件,插件向内核返回错误
// 4. 支持向后兼容(单值 payload 老写法照旧)
// 5. outputch 说明移进 skill 一份,同时保留 _help 机制
//
// ## 内核的职责边界(这是本组判据的核心)
//
// 内核只做三件事:**透传**(payload/type/meta 原样给插件)、**对齐校验**、
// **逐条调用 + 如实汇报**。它不解释 type 的语义 —— type 是插件自定义字段。
import (
"fmt"
"strings"
"sync"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// recordingChannel 记录每次发送收到的参数,并可按条注入失败。
type recordingChannel struct {
mu sync.Mutex
calls []map[string]interface{}
// failAt 是"第 N 条(0 基)失败";-1 表示全成功
failAt int
}
func newRecordingChannel() *recordingChannel { return &recordingChannel{failAt: -1} }
func (c *recordingChannel) run(args map[string]interface{}) (interface{}, error) {
c.mu.Lock()
defer c.mu.Unlock()
idx := len(c.calls)
c.calls = append(c.calls, args)
if idx == c.failAt {
return nil, fmt.Errorf("模拟失败:第 %d 条", idx+1)
}
return map[string]interface{}{"status": "sent"}, nil
}
func (c *recordingChannel) payloads() []string {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]string, len(c.calls))
for i, a := range c.calls {
out[i], _ = a["payload"].(string)
}
return out
}
// batchDevice 是一个可记录、可按条注入失败的输出设备。
type batchDevice struct {
name string
caps agentIO.OutputCapability
ch *recordingChannel
}
func (d *batchDevice) Name() string { return d.name }
func (d *batchDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *batchDevice) Description() string { return "batch test device" }
func (d *batchDevice) Tools() []agentIO.ToolDef { return nil }
func (d *batchDevice) Execute(_ string, args map[string]interface{}) (interface{}, error) {
return d.ch.run(args)
}
func (d *batchDevice) Start() error { return nil }
func (d *batchDevice) Stop() error { return nil }
func (d *batchDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *batchDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
// newOutputTestAgent 造一个带可控通道的 Agent。
func newOutputTestAgent(t *testing.T, ch *recordingChannel, caps agentIO.OutputCapability) *Agent {
t.Helper()
a := newToolTestAgent(t)
a.io = agentIO.NewIOManager()
if err := a.io.RegisterDevice(&batchDevice{
name: "testch", caps: caps, ch: ch,
}); err != nil {
t.Fatalf("注册测试设备失败: %v", err)
}
return a
}
func sendArgs(args map[string]interface{}) agentAPI.ToolCall {
return agentAPI.ToolCall{Name: "output_send__testch", Arguments: args}
}
// ① 向后兼容:payload 为字符串时,行为与改动前逐字相同。
//
// 变异:把单值路径也走批量(返回"已发送 1 条"而不是 "ok")⇒ 红。
func TestOutputSend_SingleValueUnchanged(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "你好",
"meta": `{"user_id":1}`,
}))
if out != "ok" {
t.Errorf("单条成功应返回 \"ok\"(与改动前一致),实际: %q", out)
}
if got := ch.payloads(); len(got) != 1 || got[0] != "你好" {
t.Errorf("应发送 1 条「你好」,实际: %v", got)
}
// type 缺省应为 text
if ty, _ := ch.calls[0]["type"].(string); ty != "text" {
t.Errorf("type 缺省应为 text,实际 %q", ty)
}
}
// ② 批量:payload 数组 ⇒ 按顺序发 N 条。
//
// 变异:乱序发送 / 只发第一条 ⇒ 红。
func TestOutputSend_ArraySendsInOrder(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage|agentIO.CapFile)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "https://x/a.png", "/tmp/r.pdf"},
"type": []interface{}{"text", "image", "file"},
}))
if !strings.Contains(out, "3") {
t.Errorf("返回值应报告发送了 3 条,实际: %q", out)
}
want := []string{"第一条", "https://x/a.png", "/tmp/r.pdf"}
got := ch.payloads()
if len(got) != 3 {
t.Fatalf("应发送 3 条,实际 %d: %v", len(got), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("第 %d 条顺序错:got %q want %q", i+1, got[i], want[i])
}
}
// type 一一对应
for i, wantTy := range []string{"text", "image", "file"} {
if ty, _ := ch.calls[i]["type"].(string); ty != wantTy {
t.Errorf("第 %d 条 type: got %q want %q", i+1, ty, wantTy)
}
}
}
// ③ meta 单值广播(用户明确要求)。
//
// 变异:广播只给第一条 ⇒ 红。
func TestOutputSend_MetaBroadcast(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b", "c"},
"meta": `{"user_id":42}`,
}))
if len(ch.calls) != 3 {
t.Fatalf("应发送 3 条,实际 %d", len(ch.calls))
}
for i, call := range ch.calls {
if m, _ := call["meta"].(string); m != `{"user_id":42}` {
t.Errorf("第 %d 条的 meta 未广播到:got %q", i+1, m)
}
}
}
// ④ 长度不符 ⇒ 整 call 拒绝,一条都不发。
//
// 变异:发前 N 条再报错 ⇒ 红。
func TestOutputSend_LengthMismatchRejectsWholeCall(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText|agentIO.CapImage)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text"}, // 1 个 type 会被当成广播,这里用 3 个才错
}))
// 1 个 type + 2 个 payload ⇒ 广播,合法。用 3 个 meta 制造真错。
if len(ch.calls) != 2 {
t.Errorf("单 type 应广播给 2 条,实际发送 %d 条(%q)", len(ch.calls), out)
}
ch2 := newRecordingChannel()
a2 := newOutputTestAgent(t, ch2, agentIO.CapText)
out2 := a2.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
"type": []interface{}{"text", "text", "text"}, // 3 != 2
}))
if len(ch2.calls) != 0 {
t.Errorf("长度不符必须一条都不发,实际发了 %d 条", len(ch2.calls))
}
if !strings.Contains(out2, "数量必须一致") {
t.Errorf("返回值应说明数量不一致,实际: %q", out2)
}
}
// ⑤ 部分失败:继续发剩余的,并如实列出失败项。
//
// 变异:第 2 条失败即中止 ⇒ 第 3 条不会被尝试 ⇒ 红。
// 变异:静默吞掉失败 ⇒ 返回值不含"失败" ⇒ 红。
func TestOutputSend_PartialFailureReportsAndContinues(t *testing.T) {
ch := newRecordingChannel()
ch.failAt = 1 // 第 2 条失败
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"第一条", "第二条", "第三条"},
}))
if len(ch.calls) != 3 {
t.Errorf("第 2 条失败后仍应尝试第 3 条,实际只调用 %d 次", len(ch.calls))
}
if !strings.Contains(out, "2/3") {
t.Errorf("返回值应报告 2/3 条成功,实际: %q", out)
}
if !strings.Contains(out, "失败") {
t.Errorf("返回值必须如实列出失败项,实际: %q", out)
}
if !strings.Contains(out, "模拟失败:第 2 条") {
t.Errorf("返回值应带插件的错误原文,实际: %q", out)
}
if !strings.Contains(out, "重发") {
t.Errorf("返回值应告知可单独重发失败项,实际: %q", out)
}
}
// ⑥ 内核不再解释 type 语义:任意插件自定义 type 都原样透传。
//
// ★ 这是"能力校验下放插件"的核心判据。旧实现里内核 switch 了
// text/voice/image/file 四个值,其它值直接跳过校验、且四个已知值会被
// 内核用 caps 拦掉 —— 那等于内核在理解插件语义。
//
// 变异:恢复 switch rawType 的能力校验 ⇒ 自定义 type "sticker" 若插件
// 不支持会被内核拦截(本判据用 caps 只声明 CapText 来触发)。
func TestOutputSend_KernelDoesNotInterpretType(t *testing.T) {
ch := newRecordingChannel()
// 只声明 CapText:若内核还按 type 校验,"sticker" 会解析成未知类型
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "这是插件自定义类型的载荷",
"type": "sticker",
}))
if len(ch.calls) != 1 {
t.Fatalf("自定义 type 应被原样透传给插件,实际调用 %d 次(返回 %q)", len(ch.calls), out)
}
if ty, _ := ch.calls[0]["type"].(string); ty != "sticker" {
t.Errorf("type 被内核改写:got %q want \"sticker\"", ty)
}
if strings.Contains(out, "不支持") {
t.Errorf("内核不该对 type 做能力判断,实际返回: %q", out)
}
}
// ⑦ 空 payload 交给插件判断,内核不拦(★ 实测踩到的真实缺陷)。
//
// 原先这里是"含空条目必须整 call 拒绝"。但内核**不知道 payload 的语义**:
// QQ 的 `type=sticker` + `meta.sticker_index=1` 需要 payload 留空占位,
// 真实 URL 由插件从收藏表情里取。旧实现在内核判空 ⇒ 插件侧的放行分支
// 永远走不到 ⇒ 生产实测报 "payload 不能为空",功能完全不可用。
//
// 正确边界:**参数是否存在归内核,参数值是否合法归插件**。
//
// 变异:把 trim 判空加回去 ⇒ 红(内核又替插件做决定了)。
func TestOutputSend_EmptyPayloadDeferredToPlugin(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"正常", " "},
"meta": `{"sticker_index":1}`,
}))
// 内核应把两条都交给插件(内容合法性由插件判)
if len(ch.calls) != 2 {
t.Errorf("内核不该拦空条目,应交插件判断,实际只发 %d 条(返回 %q)", len(ch.calls), out)
}
}
// ⑦b 单个空 payload 是"占位",不是"没传"。
//
// 区别:`payload: ""` 说明模型明确给了空占位;"没传 payload 键"才是真没提供。
// 前者必须走到插件(否则 sticker_index 用法永远不可用)。
func TestOutputSend_EmptyStringIsNotMissing(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": "",
"type": "sticker",
"meta": `{"user_id":1,"sticker_index":1}`,
}))
if len(ch.calls) != 1 {
t.Fatalf("空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 %d 次调用(返回 %q)",
len(ch.calls), out)
}
if p, _ := ch.calls[0]["payload"].(string); p != "" {
t.Errorf("payload 应原样透传空串,实际 %q", p)
}
}
// ⑦c 但"完全没传 payload 键"仍必须拒绝(结构性错误,内核能判)。
func TestOutputSend_MissingPayloadStillRejected(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"type": "text",
}))
if len(ch.calls) != 0 {
t.Errorf("完全没传 payload 必须拒绝,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "payload 不能为空") {
t.Errorf("返回值应说明 payload 不能为空,实际 %q", out)
}
}
// ⑧ 未授权通道不得发送(授权闸不被批量绕过)。
func TestOutputSend_UnauthorizedStillBlocked(t *testing.T) {
ch := newRecordingChannel()
a := newOutputTestAgent(t, ch, agentIO.CapText)
a.allowedOutputs = []string{"other"}
out := a.executeOutputSendTool(sendArgs(map[string]interface{}{
"payload": []interface{}{"a", "b"},
}))
if len(ch.calls) != 0 {
t.Errorf("未授权通道不得发送,实际发了 %d 条", len(ch.calls))
}
if !strings.Contains(out, "未授权") {
t.Errorf("返回值应说明未授权,实际: %q", out)
}
}

View File

@ -21,7 +21,7 @@ type fakeOverflowProvider struct {
func (p *fakeOverflowProvider) MaxContextTokens() int { return p.window }
// newOverflowAgent 造一个够用<EFBFBD><EFBFBD><EFBFBD>超页测试 Agent。
// newOverflowAgent 造一个够用的超页测试 Agent。
func newOverflowAgent(window, maxContextSize int, events int, chunkChars int) *Agent {
a := &Agent{
io: agentIO.NewIOManager(),
@ -237,7 +237,7 @@ func TestOverflow_T5_恢复预算耗尽则终止(t *testing.T) {
t.Fatalf("首次应正常裁剪:handled=%v pruned=%d", handled, pruned)
}
if f.OverflowRecover != 1 {
t.Fatalf("首次应记一次恢复,<EFBFBD><EFBFBD>际 %d", f.OverflowRecover)
t.Fatalf("首次应记一次恢复,实际 %d", f.OverflowRecover)
}
// 制造「裁不动」:每次检查前把上下文压到只剩 1 条大事件
@ -524,7 +524,7 @@ func TestOverflow_protected按预算收紧(t *testing.T) {
// 大窗口 ⇒ 用满配置值(这正是「1M 下这套调度器能更好」的机制)
big := newOverflowAgent(1_000_000, 30, 12, 200)
if got := big.effectiveProtectedCount(); got != big.context.ProtectedCount() {
t.Fatalf("大窗口下应<EFBFBD><EFBFBD>满配置值 %d,实际 %d", big.context.ProtectedCount(), got)
t.Fatalf("大窗口下应达满配置值 %d,实际 %d", big.context.ProtectedCount(), got)
}
}

View File

@ -168,7 +168,7 @@ func (a *Agent) executeGetPluginTools(pluginName string) string {
}
// ★★ 收集**内置工具**(2026-05-05)。
//
// 原实<EFBFBD><EFBFBD><EFBFBD>只收 StageHost(SDK 插件) 与 IOManager(设备) 两处,
// 原实现只收 StageHost(SDK 插件) 与 IOManager(设备) 两处,
// 而 describe_image / ocr_image / memory_recall / knowledge_* 等
// 都是内核 buildToolDefs 里直接 append 的 ⇒ **一条都数不到**。
// 症状:agent 能调这些工具,却调 get_plugin_tools("") 看不到它们,

View File

@ -0,0 +1,242 @@
package core
// 三层记忆的**提示词诚实性**判据。
//
// 这类判据的目的是防止「提示词在说谎」——它比不说更糟:
// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。
//
// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也
// 只看返回值、不知道原文已被回填到对话时序。根因是:
// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符)
// · 系统提示词从未解释 L1/L2/L3 是什么
import (
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// ① doc_query 的描述必须覆盖五项,且与真实行为一致。
func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_query")
mustHave := []struct {
name string
sub string
}{
{"L2 是什么", "历史记忆层"},
{"何时该用", "何时用"},
{"调用后行为=回填时序", "插回你的对话时序"},
{"原始时间戳", "原始时间戳"},
{"与 memory_recall 分工", "memory_recall"},
{"副作用=消费式取出", "消费式"},
}
for _, m := range mustHave {
if !strings.Contains(desc, m.sub) {
t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc)
}
}
// 不得再声称"返回摘要"—— 那是旧实现的谎
if strings.Contains(desc, "返回相关文档摘要") {
t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符")
}
}
// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。
func TestDocCommitDescriptionExplainsScope(t *testing.T) {
a := newToolTestAgent(t)
desc := findToolDescription(t, a, "doc_commit")
for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} {
if !strings.Contains(desc, sub) {
t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc)
}
}
}
// ③ 系统提示词必须有三层记忆段落,且语义与实现一致。
//
// ★ 这条是**跨文件一致性**判据:提示词说"按原始时间戳插回时序",
//
// 实现就必须真的那样做。若哪天 restoreDocEvents 被改回造合成事件,
// 这里必须红 —— 否则提示词又开始说谎。
func TestSystemPromptExplainsThreeLayers(t *testing.T) {
a := newToolTestAgent(t)
// memContext 为空即可:三层记忆段落是内核无条件注入的
prompt := a.buildSystemPrompt("", "测试输入")
for _, sub := range []string{
"【三层记忆】",
"对话时序(L1",
"历史记忆(L2",
"图记忆(L3",
"doc_query",
"memory_recall",
} {
if !strings.Contains(prompt, sub) {
t.Errorf("系统提示词缺少三层记忆段落的 %q", sub)
}
}
// 提示词承诺「按原始时间戳插回」,实现必须真的这么做
if !strings.Contains(prompt, "原始时间戳") {
t.Error("提示词没有说明 doc_query 会按原始时间戳回填")
}
if !strings.Contains(prompt, "插回你的对话时序") {
t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)")
}
}
// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。
func TestDocCommitHasNoTagsParam(t *testing.T) {
a := newToolTestAgent(t)
params := findToolParams(t, a, "doc_commit")
if _, ok := params["tags"]; ok {
t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃")
}
}
// ---- helpers ----
func findToolDescription(t *testing.T, a *Agent, name string) string {
t.Helper()
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if fn["name"] == name {
d, _ := fn["description"].(string)
return d
}
}
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
return ""
}
func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} {
t.Helper()
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if fn["name"] == name {
p, _ := fn["parameters"].(map[string]interface{})
props, _ := p["properties"].(map[string]interface{})
return props
}
}
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
return nil
}
// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。
//
// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」——
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。
//
// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent:
//
// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块,
// 那样测的是"我拼的数据对不对",不是生产过程。
func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
a := newToolTestAgent(t)
if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{
Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC),
Source: "webui",
Content: "配色方案怎么定?",
Response: "紫蓝红三色带的配色方案按六比三比一,主色紫、辅色蓝、点缀红。",
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
doc := a.docStore.RecentDocs(1)[0]
md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}})
if md == "" {
t.Fatal("预填充文案为空")
}
// 1) 必须给真实内容(正文片段),不是模板摘要
if !strings.Contains(md, "紫蓝红") && !strings.Contains(md, "配色方案") {
t.Errorf("预填充文案没有真实正文片段:\n%s", md)
}
// 2) 不得再出现 jieba 模板摘要
if strings.Contains(md, "涉及:") {
t.Errorf("预填充文案仍是 jieba 模板摘要:\n%s", md)
}
// 3) 必须有时间
if !strings.Contains(md, "2026-07-08") {
t.Errorf("预填充文案没有时间:\n%s", md)
}
// 4) ★ 必须明确引导使用 doc_query
if !strings.Contains(md, "doc_query") {
t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md)
}
// 5) 必须说明它来自历史层而非当前对话
if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") {
t.Errorf("预填充文案没有说明来源层:\n%s", md)
}
}
// ⑥ 技术性内容不得留在系统提示词里(收敛到 skill / 工具描述)。
//
// 背景(用户 2026-10-22):agent 说话人机味重。查明主因不是性格设定,
// 而是系统提示词里堆了**模型无法据以行动的实现细节**:
//
// 【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、内核判据…)
// 【三层记忆】 1069 字(L1/L2/L3 编号、消费式删除、向量空间…)
//
// 合计占提示词的绝大部分,而且全是"说明书腔"。模型学不到怎么用它,
// 却会照着这种语气说话。收敛后两段合计不到 500 字。
//
// 本判据钉住:这些术语不得回到系统提示词。
func TestSystemPromptHasNoImplementationJargon(t *testing.T) {
a := newToolTestAgent(t)
prompt := a.buildSystemPrompt("", "测试输入")
jargon := []string{
"ParallelSafe", // 工具声明项,模型不判断也改不了
"batchRunnable", // 内核内部函数名
"消费式", // L2 取出语义的实现说法
"DenseCosine", // 算法名
"fingerprint", // 向量空间标识
"向量空间", // 实现概念
"批内降级", // 内核调度细节
"自激", // 设计隐患,模型无需知道
}
for _, j := range jargon {
if strings.Contains(prompt, j) {
t.Errorf("系统提示词含实现术语 %q —— 技术内容应下沉到 skill / 工具描述,\n"+
"留在这里只会让回复带上说明书腔,且模型无法据此行动", j)
}
}
}
// ⑦ 提示词长度上限(防止技术说明再次淤积)。
//
// 当前实测 1723 字(收敛前 2309)。上限取 2000:留有增删余地,
// 但堵住"又加一大段实现说明"这种回潮。
func TestSystemPromptStaysCompact(t *testing.T) {
a := newToolTestAgent(t)
prompt := a.buildSystemPrompt("", "测试输入")
const maxRunes = 2000
if n := len([]rune(prompt)); n > maxRunes {
t.Errorf("系统提示词 %d 字,超过上限 %d —— \n"+
"新加的内容先问:模型会据此做什么不同的事?答不上来就该进 skill 或工具描述",
n, maxRunes)
}
}

View File

@ -5,7 +5,7 @@ import (
"testing"
"github.com/JianFeeeee/HologramAgent/internal/meta"
sdkmeta "github.com/JianFeeeee/homeagentsdk/meta"
sdkmeta "github.com/JianFeeeee/hologramagentsdk/meta"
)
func TestExpandPromptVars(t *testing.T) {

View File

@ -4,7 +4,7 @@ import (
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 这一组测试锁死「默认不裁剪」这条语义。

View File

@ -0,0 +1,356 @@
package core
// L2 → L1 召回协议的判据。
//
// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支):
//
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
// 三条不变量:
// ① 逐条:N 条事件回到时间线,不是 1 条合成事件
// ② 原始时间戳:用事件时刻,不是文档归档时刻
// ③ 块随事件迁回:Consume 已删 L2,不回填就两层都不在
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。
//
// Prune 无条件保护最近 10 条事件,只在更早的部分里挑归档对象,
// 所以要填充足够多的无关事件把目标挤出保护窗口。
func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) {
t.Helper()
for _, ev := range target {
a.context.Append(ev)
}
for i := 0; i < 14; i++ {
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "filler",
Input: "无关填充对话,用来把目标事件挤出保护窗口",
Response: "好的。",
})
}
if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 {
t.Fatal("Prune 未归档任何事件")
}
}
func countSource(c *RelevanceContext, src string) int {
n := 0
for _, e := range c.Recent(0) {
if e.Source == src {
n++
}
}
return n
}
// ① + ② 逐条回填,且每条用自己的原始时间戳。
//
// 变异:改用 d.CreatedAt 当时间戳 ⇒ 红;改回单条合成事件 ⇒ 红。
func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 3, 4, 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) * 7 * time.Minute),
Source: "archive-src",
Input: "档案事件-" + string(rune('A'+i)) + " 关于紫蓝红配色方案的讨论",
Response: "该事件的完整回答,编号 " + string(rune('A'+i)),
})
}
archiveEvents(t, a, want)
if n := countSource(a.context, "archive-src"); n != 0 {
t.Fatalf("归档后 L1 仍有 %d 条 archive-src", n)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("doc_query 未召回:%s", out)
}
seen := map[time.Time]string{}
for _, e := range a.context.Recent(0) {
if e.Source == "archive-src" {
seen[e.Timestamp] = e.Input
}
}
if len(seen) != 3 {
t.Fatalf("回填了 %d 条 archive-src 事件,期望 3"+
"(逐条回填,不是合成一条;也不是换了个时间戳)", len(seen))
}
for i := 0; i < 3; i++ {
ts := base.Add(time.Duration(i) * 7 * time.Minute)
if _, ok := seen[ts]; !ok {
t.Errorf("时间戳 %s 缺失 —— 回填必须用事件原始时刻(不是归档时刻)",
ts.Format("15:04:05"))
}
}
// 不得出现合成事件。
for _, e := range a.context.Recent(0) {
if e.Source == "cold_storage" {
t.Errorf("出现 cold_storage 合成事件(%q)—— 有 Events 时应逐条回填", e.Input)
}
}
}
// ③ 块随事件迁回 L1(迁移,不是复制也不是丢弃)。
//
// 变异:不回填 Blocks ⇒ 红(块两层都消失 = 永久丢字节)。
func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
a := newToolTestAgent(t)
payload := []byte("recall-block-bytes")
b := mediaBlock("image/png", payload)
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-3 * time.Hour),
Source: "archive-src",
Input: "这张图请记住:紫蓝红三色带的配色参考",
Response: "记住了。",
Blocks: []memory.MemoryBlock{b},
}})
if blocksInCtx(a.context, b.ID) {
t.Fatal("归档后块仍在 L1")
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)},
})
var back *memory.MemoryBlock
for _, e := range a.context.Recent(0) {
for i := range e.Blocks {
if e.Blocks[i].ID == b.ID {
back = &e.Blocks[i]
}
}
}
if back == nil {
t.Fatal("召回后块没有迁回 L1 —— Consume 已删除 L2 文档,块会两层都不在(字节永久丢失)")
}
if string(back.Data) != string(payload) {
t.Errorf("迁回的块字节被改动: %q", string(back.Data))
}
// 迁移而非复制。
for _, d := range a.docStore.RecentDocs(50) {
for _, db := range d.Blocks {
if db.ID == b.ID {
t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量")
}
}
}
}
// ④ 回填的正文必须是原文,不能再被截断。
//
// 断点史:旧实现在召回时对 Content 再截 2000 字,且 Content 本身已是截断视图。
func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
a := newToolTestAgent(t)
longResp := strings.Repeat("长正文", 2000) + "【召回哨兵R4】"
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-2 * time.Hour),
Source: "archive-src",
Input: "请记住这份长报告",
Response: longResp,
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)},
})
var got string
for _, e := range a.context.Recent(0) {
if e.Source == "archive-src" {
got = e.Response
}
}
if got == "" {
t.Fatal("未回填事件")
}
if got != longResp {
t.Errorf("回填正文被截断:len=%d,原长 %d", len(got), len(longResp))
}
if !strings.Contains(got, "【召回哨兵R4】") {
t.Error("回填正文丢了结尾哨兵")
}
}
// ⑤ 存量文档(无 Events)降级:整篇渲成一条事件,不丢弃。
//
// 决定:存量等自然冲刷,但不得因为格式旧就召回不到。
func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
Summary: "紫蓝红三色带的旧格式文档",
Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图",
Source: "context_archived",
Meta: map[string]string{"content_hash": "legacy-hash-1"},
}); err != nil {
t.Fatal(err)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 旧格式", "top_k": float64(3)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("存量文档召回失败:%s", out)
}
var found bool
for _, e := range a.context.Recent(0) {
if e.Source == "cold_storage" && strings.Contains(e.Response, "Events 引入之前") {
found = true
}
}
if !found {
t.Error("存量文档没有降级成合成事件 —— 会静默丢失整篇内容")
}
}
func blocksInCtx(c *RelevanceContext, blockID string) bool {
for _, e := range c.Recent(0) {
for _, b := range e.Blocks {
if b.ID == blockID {
return true
}
}
}
return false
}
// 压力:大批量事件回填不得退化(回填是逐条 InsertByTimestamp,本身 O(n) 插入)。
func TestRecallByDoc_StressManyEvents(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
a := newToolTestAgent(t)
const n = 120
base := time.Date(2026, 2, 3, 4, 0, 0, 0, time.UTC)
var evs []ContextEvent
for i := 0; i < n; i++ {
evs = append(evs, ContextEvent{
Timestamp: base.Add(time.Duration(i) * time.Second),
Source: "stress",
Input: "压力输入 " + strings.Repeat("内容", 10),
Response: "压力回答 " + strings.Repeat("正文", 20),
})
}
archiveEvents(t, a, evs)
start := time.Now()
if got := countSource(a.context, "stress"); got != 0 {
t.Fatalf("归档后仍有 %d 条", got)
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)},
})
elapsed := time.Since(start)
got := countSource(a.context, "stress")
if got == 0 {
t.Fatal("压力场景下一条也没回填")
}
// 回填 120 条不应超过 5 秒(实测远小于此;阈值防 O(n²) 退化)。
if elapsed > 5*time.Second {
t.Errorf("回填 %d 条耗时 %v,疑似超线性", got, elapsed)
}
t.Logf("回填 %d 条耗时 %v", got, elapsed)
}
// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。
//
// 断点史(生产日志实测):旧返回值是
//
// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...)
//
// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。
//
// 本判据要求返回值同时具备三件事:
// 1. 明确说明原文去了哪里("插回对话时序")
// 2. 每条给出时间 + 来源(可判断"这是哪次的事")
// 3. 每条给出真实正文片段(可判断"这是不是我要的")
func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
a := newToolTestAgent(t)
const sentinel = "紫蓝红三色带按六比三比一"
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC),
Source: "webui",
Input: "配色方案怎么定?",
Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。",
}})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)},
})
// 1) 必须说明原文去了时序
if !strings.Contains(out, "时序") {
t.Errorf("返回值没说原文去了哪里:\n%s", out)
}
// 2) 必须有时间(年月日或月-日)
if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") {
t.Errorf("返回值没有时间信息:\n%s", out)
}
// 3) 必须有来源
if !strings.Contains(out, "webui") {
t.Errorf("返回值没有来源信息:\n%s", out)
}
// 4) ★ 必须有真实正文(否则模型无法判断相关性)
if !strings.Contains(out, "六比三比一") {
t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out)
}
// 5) 不得回退成 jieba 词袋的模板摘要
if strings.Contains(out, "涉及:") {
t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out)
}
}
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
Summary: "旧格式摘要",
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
Source: "context_archived",
Meta: map[string]string{"content_hash": "legacy-2"},
}); err != nil {
t.Fatal(err)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "旧格式文档 真实正文", "top_k": float64(3)},
})
if !strings.Contains(out, "真实正文在这里") {
t.Errorf("存量文档召回没有给出正文:\n%s", out)
}
}

View File

@ -8,7 +8,7 @@ import (
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
"github.com/JianFeeeee/HologramAgent/internal/memory"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 这一组测试锁死「默认召回、可显式关闭」这条语义。

View File

@ -143,7 +143,7 @@ func TestResident_LifecycleAndNoOrphans(t *testing.T) {
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
}
// 父的入站 inputch(child/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
// HomeAgent 实测:destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
// HologramAgent 实测:destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
if inbound, ok := reg.Lookup("child/child-1"); ok {
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
}

View File

@ -15,7 +15,7 @@ import (
"testing"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func scenePolicyNoneEvent() *agentIO.InputEvent {

View File

@ -0,0 +1,176 @@
package core
// doc_query 的**自激震荡**判据。
//
// ## 回路长什么样(用户 2026-10-22 指出)
//
// doc_query
// ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
// │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
// ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
// │ → Prune 因相关而保留它们(挤掉新事件)
// │ → 下一轮 Prune 又把它们归档回 L2
// └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
//
// 两个入口都必须关掉:
// ① ToolDef.NoMemory —— doc_query 声明,工具输出不进计算层
// ② ContextEvent.NoMemory —— restoreDocEvents 置位,回填事件不再归档
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ① doc_query 必须声明 NoMemory(工具输出不进记忆计算)。
//
// 变异:去掉 toolDefOptions{noMemory:true} ⇒ 红。
func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) {
// 工具定义层面:声明必须可查(不依赖工具表是否已构造)
if !noMemoryOf("doc_query") {
t.Error("doc_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," +
"再参与向量化/蒸馏就是把同一内容重新注入记忆")
}
// 计算层层面:带 doc_query 输出的工具事件,其向量化文本不得含该输出
const sentinel = "这是被召回事件的正文片段哨兵"
evt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: "查一下历史",
ToolResults: []ToolResultItem{
{Name: "doc_query", Output: "已召回:" + sentinel},
},
}
// stageHost 在测试环境为 nil:传 nil lookup,只走内置声明表那条路
text := textForVector(&evt, nil, nil)
if strings.Contains(text, sentinel) {
t.Errorf("doc_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text)
}
}
// ② 回填事件必须带 NoMemory(不参与计算),但**必须照常归档**(数据不丢)。
//
// ★ 这里曾经写反过:要求"不得被 Prune 再归档回 L2"。
//
// doc_query 的 Consume 已经把 L2 副本删掉,L1 是唯一副本 ——
// 不归档就等于三层都不在,内容永久丢失。
//
// 变异:
//
// · restoreDocEvents 不置 NoMemory ⇒ 它会参与计算(重复加权)
// · Prune 跳过 NoMemory 事件 ⇒ 内容丢失(本判据要抓这个)
func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
a := newToolTestAgent(t)
const marker = "自激测试:这条会被归档"
// 先归档一条事件到 L2
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-2 * time.Hour),
Source: "webui",
Input: marker,
Response: "然后被 doc_query 取回",
}})
// 召回(Consume 会把 L2 副本删掉)
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "自激测试 归档", "top_k": float64(3)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("未召回:%s", out)
}
// ① 回填事件必须带 NoMemory(不参与计算)
restored := 0
for _, e := range a.context.Recent(0) {
if e.Source == "webui" && strings.Contains(e.Input, marker) {
restored++
if !e.NoMemory {
t.Error("回填事件没有 NoMemory 标记 —— 它会再被向量化/蒸馏,同一内容重复加权")
}
if len(e.Vector) > 0 {
t.Error("回填事件被向量化了(NoMemory 未生效)")
}
}
}
if restored == 0 {
t.Fatal("没有回填任何事件")
}
// ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点
for _, d := range a.docStore.RecentDocs(1000) {
if !d.HasEvents() {
continue
}
for _, ev := range d.Events {
in, _, _ := ev.InOut()
if strings.Contains(in, marker) {
t.Fatalf("L2 里仍有该事件(%s)—— 前提不成立,本判据失去意义", d.ID)
}
}
}
// ③ ★ 关键:裁掉它时**必须能落回 L2**,否则内容永久丢失
for i := 0; i < 14; i++ {
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "filler",
Input: "无关填充,用来触发 Prune",
Response: "好。",
})
}
a.context.Prune("当前输入", 2, a.docStore)
var foundInL2 bool
for _, d := range a.docStore.RecentDocs(1000) {
if !d.HasEvents() {
continue
}
for _, ev := range d.Events {
in, _, _ := ev.InOut()
if strings.Contains(in, marker) {
foundInL2 = true
}
}
}
if !foundInL2 {
t.Error("回填事件被裁掉后没有归档回 L2 —— Consume 已删除原副本," +
"L1 是唯一副本,不归档就是内容永久丢失(三层都不在)")
}
}
// ④ 回填事件不得参与 Prune 的相关性打分(否则会挤掉真正的新事件)。
func TestSelfExcite_RestoredEventsDontCrowdOutNewOnes(t *testing.T) {
a := newToolTestAgent(t)
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-3 * time.Hour),
Source: "webui",
Input: "拥挤测试的原始事件",
Response: "响应",
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "拥挤测试", "top_k": float64(3)},
})
// 回填事件的向量必须为空(NoMemory → textForVector 返回 "")
for _, e := range a.context.Recent(0) {
if e.NoMemory && len(e.Vector) > 0 {
t.Errorf("回填事件仍被向量化(vector len=%d)—— 它会参与相关性打分并挤掉新事件",
len(e.Vector))
}
}
// 块仍在 L1(迁移不是丢弃)
var blocks int
for _, e := range a.context.Recent(0) {
blocks += len(e.Blocks)
}
_ = blocks
_ = memory.BlockText
}

View File

@ -112,7 +112,7 @@ const defaultChildMaxTurns = 5
// 不把父的【输出规则】/【中断消息】/【记忆清理】/【可用技能】/人格那几节一并搬过来:
// 子被显式禁用了 output_send__*(结果回父而非直接回用户),也无需保留记忆或响应中断;
// 搬来只会白占提示词预算。
const childSystemPromptTemplate = `你是 HomeAgent 的子任务助手。
const childSystemPromptTemplate = `你是 HologramAgent 的子任务助手。
请完成以下任务。完成即可,无需保留记忆或查询历史。
【执行纪律】这是硬性要求,不是建议:

View File

@ -33,7 +33,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/memory"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// Step 是任务状态机的游标。

View File

@ -327,9 +327,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS
// 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。
// 文案必须让模型知道该做什么,而不是含糊的"执行失败"——
// 后者会让模型反复重试同一个不存在的名字。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+
"先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+
"确认名称无误后再调用", tc.Name)}
// ★ 提示必须**先指向 skill**(2026-10-22)。
//
// 按需插件(auto_start=false)的工具不在初始清单里,而它的
// skill 正是"这个插件是什么、要怎么用、需要先启动什么"的唯一
// 说明(SDK autostart.go 的 OnDemandNeedsSkill 就是这条契约)。
//
// 此前这里只提 get_plugin_tools / output_list_channels,
// 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去
// 看说明书,于是只能盲目试工具名。
return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册(它可能属于未加载的插件)。按这个顺序处理:\n"+
"1. 先看【可用技能】里有没有相关的:有就先 skill_info(\"技能名\") 读全文 —— "+
"skill 里写着该插件的正确用法、已知坑,以及需要先启动哪个插件;\n"+
"2. 没有相关 skill 就调 get_plugin_tools(\"\") 查当前可用的插件与工具;\n"+
"3. 通道相关的不确定时用 output_list_channels 看有哪些通道。\n"+
"确认之后再调用,不要反复重试同一个名字。", tc.Name)}
}
return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)}
}
@ -1030,32 +1042,51 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(docs) == 0 {
return "未找到相关文档记忆"
}
var parts []string
var refs []string
var b strings.Builder
b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs)))
totalEvents := 0
for i, d := range docs {
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
if len(d.Tags) > 0 {
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
//
// ARCHITECTURE「记忆流转」② Document 层:
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
n := a.restoreDocEvents(d)
if n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件。
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
content := d.Content
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
NoMemory: true, // 同上:回填不产生新记忆
})
n = 1
}
content := d.Content
if len(content) > 2000 {
content = content[:2000] + "..."
totalEvents += n
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
// 让模型**立刻**能判断这笔召回有没有用。
//
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
// jieba 词袋,零信息量,模型无法据此判断相关性。
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
if preview := docRecallPreview(d, n, 3); preview != "" {
b.WriteString(preview)
}
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 截掉之后模型就不知道这篇文档带过图。
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
})
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
}
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
len(docs), strings.Join(refs, ", "))
b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents))
return b.String()
case "doc_commit":
content, _ := tc.Arguments["content"].(string)
@ -1067,18 +1098,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
summary = truncateStr(content, 100)
}
tagsRaw, _ := tc.Arguments["tags"].([]interface{})
var tags []string
for _, t := range tagsRaw {
if s, ok := t.(string); ok {
tags = append(tags, s)
}
}
doc := &document.Doc{
Summary: summary,
Content: content,
Tags: tags,
Source: "manual",
}
@ -1248,7 +1270,7 @@ const (
//
// 我先写的版本是查库(DominantBlockFingerprint)—— **错的**:
// 库里只有旧空间的块时,多数取值就是旧指纹,
// 拿它当过滤条件等于「不<EFBFBD><EFBFBD>滤」,旧块原样通过。
// 拿它当过滤条件等于「不过滤」,旧块原样通过。
// (判据当场抓住:测试库里唯一的块是 old-fp,而当前空间是 fp1。)
//
// 隔离的语义是「只信任当前空间写入的块」,
@ -1262,3 +1284,173 @@ func (a *Agent) currentFingerprint() string {
}
return a.multimodalSpace.Fingerprint()
}
// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。
//
// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。
//
// ## 依据
//
// ARCHITECTURE「记忆流转」② Document 层的读取分支:
//
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
// 块 ID 与向量全程不变(不重建、不重算)。
//
// ## 块为什么要一起回填
//
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
func (a *Agent) restoreDocEvents(d *document.Doc) int {
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
return 0
}
n, blocks := 0, 0
for _, ev := range d.Events {
input, response, held := ev.InOut()
a.context.InsertByTimestamp(ContextEvent{
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
Source: ev.Source,
Input: input,
Response: response,
// ★ 回填事件**不参与记忆计算**,但**照常参与归档**。
//
// 为什么要标记:它们的内容刚在 L2 里被向量化过一次,
// 再算一次会让同一内容在向量空间里被重复加权(自激)。
//
// ★★ 为什么仍要归档(不能顺手关掉):
// Consume 已经把 L2 副本删了,L1 是**唯一副本**。
// 裁掉时不归档 = 三层都不在 = 内容永久丢失。
// (这个错犯过:曾用同一个 NoMemory 同时关掉计算与归档。)
NoMemory: true,
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
ToolResults: func() []ToolResultItem {
var out []ToolResultItem
for _, tr := range ev.ToolResults {
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
}
return out
}(),
Blocks: append([]memory.MemoryBlock(nil), held...),
})
n++
blocks += len(held)
}
log.Printf("[agent] doc→context: %s 逐条回填 %d 条原始事件(%s → %s),块 %d 个迁回 L1",
d.ID, n,
d.Events[0].Timestamp.Format("01-02 15:04:05"),
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
return n
}
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
//
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
// doc_query 的直接原因之一。
func docRecallHeadline(d *document.Doc, eventCount int) string {
if d == nil {
return ""
}
var parts []string
if d.HasEvents() {
first := d.Events[0].Timestamp
last := d.Events[len(d.Events)-1].Timestamp
if first.Equal(last) {
parts = append(parts, first.Format("2006-01-02 15:04"))
} else {
parts = append(parts, fmt.Sprintf("%s → %s",
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
}
// 来源集合(去重,最多 3 个)
seen := map[string]bool{}
var srcs []string
for _, ev := range d.Events {
if ev.Source != "" && !seen[ev.Source] {
seen[ev.Source] = true
srcs = append(srcs, ev.Source)
if len(srcs) >= 3 {
break
}
}
}
if len(srcs) > 0 {
parts = append(parts, strings.Join(srcs, "/"))
}
} else {
parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04"))
if d.Source != "" {
parts = append(parts, d.Source)
}
}
parts = append(parts, fmt.Sprintf("%d 条事件", eventCount))
if n := len(d.Blocks); n > 0 {
media := 0
for i := range d.Blocks {
if d.Blocks[i].Modality != memory.BlockText {
media++
}
}
if media > 0 {
parts = append(parts, fmt.Sprintf("含 %d 份媒体", media))
}
}
return strings.Join(parts, " · ")
}
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
//
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
// 这里只是"先给你看一眼是什么",不是全文。
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
if d == nil || maxLines <= 0 {
return ""
}
var out []string
add := func(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return len(out) < maxLines
}
out = append(out, " "+oneLinePreview(s, 100))
return len(out) < maxLines
}
if d.HasEvents() {
for _, ev := range d.Events {
in, resp, _ := ev.InOut()
if in != "" && !add(in) {
return strings.Join(out, "\n")
}
if resp != "" && !add(resp) {
return strings.Join(out, "\n")
}
}
} else {
// 存量文档:从渲染视图取前几行
for _, line := range strings.Split(d.Content, "\n") {
if !add(line) {
break
}
}
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n")
}
// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。
func oneLinePreview(s string, maxRunes int) string {
s = strings.Join(strings.Fields(s), " ")
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "…"
}

View File

@ -42,7 +42,7 @@ import (
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// visionBatchProvider 在 batchProvider 之上声明「能看图」。

View File

@ -10,7 +10,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/meta"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
"github.com/JianFeeeee/HologramAgent/internal/security"
sdkmeta "github.com/JianFeeeee/homeagentsdk/meta"
sdkmeta "github.com/JianFeeeee/hologramagentsdk/meta"
)
func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string) string {
@ -157,7 +157,7 @@ func appendPromptBlock(prompt, block string) string {
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := expandPromptVars(a.systemPrompt)
if prompt == "" {
prompt = "你是小宅,HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
prompt = "你是小宅,HologramAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji,只用颜文字表达情感,句尾带语气词。WebUI 概览页展示你的立绘。"
}
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
@ -177,6 +177,29 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt = appendPromptBlock(prompt, memContext)
}
// 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。
//
// 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、
// 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
//
// 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么,
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query,
// 即使调用了也只看返回值、不知道原文已被回填到对话时序。
// 【三层记忆】只留**模型据此选择工具**的语义。
//
// 技术性内容(L1/L2/L3 编号、向量空间、消费式删除、缩略语)全部下沉到
// 对应工具的 description 与 skill:模型只需要知道"哪里有原文、哪里有
// 关系、用什么去取",不需要知道内核怎么存。
//
// ★ 但有一件事必须说:doc_query 会把历史事件**插回时序**,而不是返回
// 一段摘要 —— 这与模型对"检索工具"的默认预期相反,不说就会误用。
prompt = appendPromptBlock(prompt, "【三层记忆】你这儿有三种记忆,按需要的东西选:\n"+
"- **对话时序(L1)**:最近的对话就在上面的【对话时序】里,直接能看。\n"+
"- **历史记忆(L2)**:更早的事被移出窗口了,但原文还在。要看某段完整经过用 `doc_query` —— "+
"它会把那些事**按原始时间戳插回你的对话时序**,你随后就能像看当前对话一样读它们。\n"+
"- **图记忆(L3)**:事实与关系(谁是什么、某值现在多少)用 `memory_recall`;"+
"成文的可复用知识用 `knowledge_search`。")
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
@ -200,17 +223,28 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
//
// ⚠️ 措辞必须与 batchRunnable 的**真实**判据一致(全批 ParallelSafe
// 才并发 + 同通道保序),否则只是把"没说"换成"说错"。
prompt = appendPromptBlock(prompt, "【工具执行顺序】同一条回复里给出多个工具调用时,内核**默认并行执行**(同时跑),不是依次执行。这带来三条你必须知道的规则:\n")
prompt += "- **不要依赖执行顺序**:若某个调用的参数需要另一个调用的结果,就**分两轮**——先发前一个,看到结果后再发下一个。写在同一轮里就等于假设了不存在的先后。\n"
prompt += "- **例外一:同一通道的输出发送会保序**。对**同一个**通道连续调用多次 `output_send__`,内核会**按你发出的顺序依次执行**(用户可见消息顺序敏感),不会并发打乱。所以「先发回执、再发结论」这类有序发送可以放在同一轮;但若后续内容依赖前一条的用户反应,仍应分轮。\n"
prompt += "- **例外二:不并发安全的工具不会并发**。写类工具(记忆/知识/文档写入等)与未声明并发安全的工具,**整批会退回依次执行**——同一批里只要有一个这样的工具,这一批就全部串行。这对你是透明的:你不需要判断,只需知道「同轮并发」不是无条件保证的。\n"
prompt += "- 工具是否并发安全由**工具自己声明**(`ParallelSafe`),不由你决定;你只需按上面第 1 条判断能否同轮发出。\n"
// 【工具执行顺序】只留**模型必须据此作决定**的三条。
//
// 技术性内容(ParallelSafe 声明、批内降级、内核如何判据)全部下沉:
// 那是实现细节,模型既不需要也改不了 —— 讲它只会让回复带上说明书腔。
// 判断依据仍是真实的 batchRunnable 语义(勿写反)。
prompt = appendPromptBlock(prompt, "【工具执行顺序】同一条回复里的多个工具调用默认**并行**执行。\n"+
"- 不要依赖先后:后一个要用前一个的结果时,分两轮发。\n"+
"- **例外一**:对同一通道的多次输出会**保序**(按你发出的顺序送达),所以「先回执、再结论」可以放同一轮。\n"+
"- **例外二**:写类工具(记忆/知识/文档写入等)以及不声明**并发安全**的工具,这一批会退回依次执行 —— "+
"只是变慢,不影响结果,你不用为它调整写法。")
prompt = appendPromptBlock(prompt, "【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n")
prompt = appendPromptBlock(prompt, "【输出规则】\n")
// ★ 这一句是本段最重要的:用户明确要求「必须显式调用 outputch 才会输出给用户」。
// 模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了**——
// 异步通道下那等于自言自语(用户永远收不到)。
prompt += "**只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**" +
"返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。\n"
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
prompt += "- 异步通道(qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id)才能真正把消息发出去。\n"
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本即可,内核会把它交给等待方显示,**这是唯一的例外**。\n"
prompt += "- 异步通道(qq / wechat / 群聊等):**必须**调用 output_send__{通道名},并在 meta 里带上正确的 user_id 或 group_id。\n"
prompt += "- 一次调用可以发多条:payload 传数组即可(type/meta 也支持数组一一对应,只给一个值则应用到每一条)。\n"
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**:
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
@ -231,7 +265,19 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 主动 skill_info 拉取全文按文档执行
if a.skillIndex != nil {
if idx := a.skillIndex.SkillIndex(); idx != "" {
prompt = appendPromptBlock(prompt, "【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n"+idx)
// ★ 措辞从"用户请求匹配某技能时"改成"**调用插件工具之前**"。
//
// 旧措辞把人带偏成"只有用户点名要某个技能才去读",而 skill 的真实
// 用途是**工具的使用说明**:里面写着参数怎么填、有哪些已知坑
// (例如 QQ 的"拉私聊历史只传 user_id,不要传 group_id")、
// 以及按需插件要先 plugin_start 拉起。
//
// 不看 skill 就直接调工具,是这个仓库反复吃过亏的一类错误。
prompt = appendPromptBlock(prompt, "【可用技能】这些是插件配套的**使用说明书**,不是可选装饰。\n"+
"**规则**:准备调用某个插件的工具(或工具调用失败、参数不确定)时,**先看这里有没有同名技能的**"+
"(如 file-tools / cmd-tools / qq-tools),用 skill_info(\"技能名\") 读全文,"+
"再严格按其中步骤与坑位说明执行。技能里通常会写明:工具该怎么调、需要先启动哪个插件、常见报错怎么读。\n"+
"用户明确要求用某技能时同样先 skill_info 读全文,不要凭名字猜做法。\n\n"+idx)
}
}
@ -308,6 +354,18 @@ func (a *Agent) buildToolCatalog() string {
var sb strings.Builder
sb.WriteString("\n\n【可用工具能力】\n")
sb.WriteString("工具按插件分组注册。需要某个插件的具体工具时,调用 get_plugin_tools(\"{插件名}\") 获取该插件的完整工具定义(名称/参数/用途)。\n")
// ★ 说明"按需插件"这回事(2026-10-22)。
//
// 插件有两种启动形态(SDK autostart.go):
// 常驻(auto_start 缺省/true)—— 工具一开始就在清单里
// 按需(auto_start=false) —— 初始不在清单里,要靠 plugin_start 拉起
//
// 不说清这件事的后果:模型看到"清单里没有某能力"就断定"没有这个能力",
// 于是绕道去用别的工具硬凑,或者干脆告诉用户做不到。
sb.WriteString("★ **工具清单里没有 ≠ 没有这个能力**:部分插件是**按需加载**的," +
"它们的工具一开始不在清单里。需要时先 get_plugin_tools(\"插件名\") 看它有哪些工具、" +
"再 plugin_start(\"插件名\") 把它拉起来,工具就会出现。对应的 skill(同类名字或同名技能)" +
"会说明这个插件是常驻还是按需、以及拉起后怎么用。\n")
for _, plg := range order {
sb.WriteString(fmt.Sprintf("- %s (%d 个工具)", plg, byPlugin[plg]))
if d := pluginDesc[plg]; d != "" {
@ -367,6 +425,18 @@ type toolDefOptions struct {
// 而 output_send__* 是**投递到子 pipeline** 的长任务,天然可能超过 60s
// (实测两次 60s 超时)。留空 = 用内核默认 60s,行为不变。
timeout string
// noMemory 声明该工具的**输出**不参与记忆计算(向量化/关键词/蒸馏),
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
//
// ★ 注意:doc_query 的 noMemory **不写在这里**,而是由 init() 里的
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil`
// 之类条件控制),而 textForVector 可能在构造之前被调用 ——
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
//
// 本字段保留给"确实想跟着工具构造走"的场景(工具不存在就不需要
// 抑制它的输出),与 init 名单二选一即可。
noMemory bool
}
// parallelOpts 是"可并发"的声明项。
@ -402,6 +472,17 @@ func toolDefWith(name, description string, properties map[string]interface{}, re
if opts.timeout != "" {
registerBuiltinToolTimeout(name, opts.timeout)
}
// 声明项也要落在**包级聚合表**上,供 textForVector 查询。
//
// ⚠️ 不在 declare*Tool 里做、而在这里做,是因为有先例的教训:
// 让 toolDefWith 自己登记 parallel 曾导致"聚合表是空的"——
// 那些工具都在 `if a.knowledge != nil` 之类的条件分支里。
// 但 noMemory 的情况不同:它必须在**工具真被构造出来时**才成立
// (工具不在表里,也就没有输出要抑制)。两者语义一致:
// 登记的是"这个工具的声明",构造时登记最准确。
if opts.noMemory {
declareNoMemoryTool(name)
}
return def.ToSchema()
}
@ -431,6 +512,22 @@ func init() {
} {
declareParallelTool(name)
}
// 输出不进记忆计算的内置工具。
//
// ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记:
// 工具表是**按需构造**的(buildToolDefs 每轮重建,且受
// `if a.docStore != nil` 之类的条件分支控制),而 textForVector
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
//
// 目前只有 doc_query:它的返回值含被召回事件的正文片段,
// 而那些正文已在记忆体系里 —— 再参与向量化/蒸馏就是重复注入。
for _, name := range []string{
"doc_query",
} {
declareNoMemoryTool(name)
}
}
func (a *Agent) buildToolDefs() []interface{} {
@ -556,24 +653,47 @@ func (a *Agent) buildToolDefs() []interface{} {
}
if a.docStore != nil {
tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
"tags": map[string]interface{}{
"type": "array",
"description": "标签列表",
"items": map[string]interface{}{"type": "string"},
},
"media_digests": map[string]interface{}{
"type": "array",
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
}, "content"))
// ★ doc_query / doc_commit 的描述必须与**真实行为**一致。
//
// 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」:
// · "文档记忆"没有定义,模型不知道 L2 是什么;
// · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按
// 原始时间戳插回对话时序);
// · 没说什么时候该用,也没说与 memory_recall 的分工。
// 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、
// 不知道原文已回填到时序。
tools = append(tools, toolDefWith("doc_query",
"从**历史记忆层**(L2)召回过去的事件原文。"+
"\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+
"连同原文完整保存在历史层,不会丢失。"+
"\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+
"跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+
"或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+
"\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+
"(就是【对话时序】那一段,出现在它当时的时间点上),"+
"返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+
"\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。",
map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
"top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3},
}, []string{"query"}, parallelOpts()))
tools = append(tools, toolDef("doc_commit",
"把**整段结论/过程**写进历史记忆层(L2)。"+
"\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+
"这些内容长、有上下文、以后要回看原文。"+
"\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+
"可复用的成文知识 → 用 knowledge_create。"+
"\n\n写入后会参与后续的自动预填充与 doc_query 召回。",
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 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
"items": map[string]interface{}{"type": "string"},
},
}, "content"))
}
if a.social != nil {
@ -656,20 +776,40 @@ func (a *Agent) buildToolDefs() []interface{} {
desc = ch.Name + " 输出通道"
}
tools = append(tools, toolDef("output_send__"+ch.Name, desc+"。能力: "+capStr+"。payload 为消息载荷(type 默认 text,可省略),meta 为 JSON 发送元数据。用 _help 查看 meta 格式与 type 枚举。", map[string]interface{}{
"payload": map[string]interface{}{
"type": "string",
"description": "消息载荷。type=text 时填文字,type=file/image 时填 URL 或路径",
},
"meta": map[string]interface{}{
"type": "string",
"description": "JSON 对象,包含发送所需的元数据。用 output_send__" + ch.Name + "_help 查看 meta 格式",
},
"type": map[string]interface{}{
"type": "string",
"description": "载荷类型,默认 text;其它枚举用 channel._help 查看",
},
}, "payload"))
// ★ 一次可发多条、多种类型(2026-10-22)。
//
// payload / type / meta 同时接受字符串与数组 —— 单值时行为与旧版逐字相同
// (向后兼容)。描述里**不写** type 的具体枚举:那是插件自定义字段,
// 内核不该承载它的语义文档(用户明确要求技术性内容收敛到 skill 与 _help)。
tools = append(tools, toolDef("output_send__"+ch.Name,
desc+"。能力: "+capStr+"。\n"+
"payload 是消息载荷,可以是**一条字符串**,也可以是**数组**(一次发多条)。\n"+
"type 是载荷类型,meta 是发送元数据 —— 两者的格式与取值由该通道定义,"+
"用 output_send__"+ch.Name+"_help 或对应 skill 查看。\n"+
"type/meta 只给一个值时,会应用到 payload 的每一条。",
map[string]interface{}{
"payload": map[string]interface{}{
"description": "消息载荷。字符串=发一条;数组=一次发多条(按数组顺序送达)",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
"type": map[string]interface{}{
"description": "载荷类型。字符串=全部同类型;数组=与 payload 一一对应(数量必须相等)。缺省按通道默认",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
"meta": map[string]interface{}{
"description": "发送元数据(JSON 字符串)。只给一个值时广播给所有条;数组则一一对应。格式见 _help 或对应 skill",
"oneOf": []interface{}{
map[string]interface{}{"type": "string"},
map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
},
},
}, "payload"))
tools = append(tools, toolDef("output_send__"+ch.Name+"_help", "查看 "+ch.Name+" 输出通道的 meta 格式说明和 type 枚举", map[string]interface{}{}))
}
@ -732,7 +872,7 @@ func (a *Agent) buildToolDefs() []interface{} {
// 而「读本地文件」是 IO —— 三个工具读文件却住在内核进程内,
// 无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。
//
// 留在这里的是**待<EFBFBD><EFBFBD>退场**的部分:下面这一大块 pendingMedia 只服务于
// 留在这里的是**待退场**的部分:下面这一大块 pendingMedia 只服务于
// 那三个工具的工具面。input 通路(processInput 直接把 media block
// 拼进本轮请求)**不依赖它**,所以可安全移除。
// 退场顺序见 docs 或本次提交的说明;先确保插件侧能力等价。
@ -765,9 +905,35 @@ var builtinDefs = &builtinDefRegistry{byName: map[string]sdk.BuiltinToolDef{}}
// 那些工具都在 `if a.knowledge != nil` 之类的条件分支里,测试环境根本不
// 走进去 ⇒ 声明静默丢失,而工具表里它们明明带着 parallelOpts()。
// 症状是"判据全绿但并发能力为零"—— 判据查的是同一张空表,自证。
// declareNoMemoryTool 在**工具定义处**登记"输出不进记忆计算"声明。
//
// 与 declareParallelTool 同构:声明是静态的,在 init 期聚合一次,
// 运行期只查表。落在工具自己的结构体上,内核里不出现任何工具名硬编码。
func declareNoMemoryTool(name string) {
builtinDefs.mu.Lock()
d := builtinDefs.byName[name]
d.Name = name
d.NoMemory = true
builtinDefs.byName[name] = d
builtinDefs.mu.Unlock()
}
// noMemoryOf 查询内置工具的输出是否不进记忆计算。
// 未登记 ⇒ 参与记忆(保守:与 SDK 零值语义一致)。
func noMemoryOf(name string) bool {
builtinDefs.mu.RLock()
defer builtinDefs.mu.RUnlock()
return builtinDefs.byName[name].NoMemory
}
func declareParallelTool(name string) {
builtinDefs.mu.Lock()
builtinDefs.byName[name] = sdk.BuiltinToolDef{Name: name, ParallelSafe: true}
// ★ 按字段合并而非整体覆盖:同一工具可能同时声明 noMemory 与 parallel,
// 覆盖会让后登记的那项静默丢失(声明丢一个是最难察觉的一类问题)。
d := builtinDefs.byName[name]
d.Name = name
d.ParallelSafe = true
builtinDefs.byName[name] = d
builtinDefs.mu.Unlock()
}

View File

@ -5,7 +5,7 @@ import (
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// appendPlaceholder 复刻 process() 循环顶部的补位逻辑。

View File

@ -8,7 +8,7 @@ import (
"sync"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// ErrToolNotFound 表示「工具不存在」(未注册 / 所属插件已卸载或崩溃)。
@ -696,7 +696,7 @@ func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
}
// ToolDefOf 按工具名取其声明(含 Parameters schema)。
// 用途:内核在执行前按 schema 预校验——没有它就只<EFBFBD><EFBFBD><EFBFBD>校验到插件工具,
// 用途:内核在执行前按 schema 预校验——没有它就只能校验到插件工具,
// 而设备/通道工具(cmd_run、files_write 等)会完全绕过校验。
func (m *IOManager) ToolDefOf(name string) (ToolDef, bool) {
m.mu.RLock()

View File

@ -3,7 +3,7 @@ package io
import (
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// drainOne 取出一条注入事件;没有则 Fatal。

View File

@ -10,7 +10,7 @@ import (
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
func TestPersonaStaleHints(t *testing.T) {
// 现场真实文本(线上人格卡的原文)
stale := "你是 HomeAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。"
stale := "你是 HologramAgent(内核代号 HΔ-Kernel,当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0(C ABI v2,构建于 2026-08-15)。"
hints := PersonaStaleHints(stale)
if len(hints) == 0 {
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
@ -32,7 +32,7 @@ func TestPersonaStaleHints(t *testing.T) {
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
// 这里不复用 config 包以避免 import cycle,只断言「不含版本号与旧机制」的文本不被误报。
func DefaultPersonaProbeClean() string {
return "你是 HomeAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" +
return "你是 HologramAgent(内核代号 HΔ-Kernel)。外部插件是独立子进程,经 stdio JSON-RPC 通信;" +
"被问到版本时以运行时快照为准。"
}

View File

@ -638,7 +638,7 @@ var defaultSources = map[string]map[string]string{
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI,实例被问版本时自述错误)。
// 被问到版本/构建信息时,要求 agent 读运行时快照。
// - 不得把已删除的机制当作现行机制描述。
const DefaultPersonaPrompt = `你是 HomeAgent(内核代号 HΔ-Kernel)——一个持续运行的个人管家。
const DefaultPersonaPrompt = `你是 HologramAgent(内核代号 HΔ-Kernel)——一个持续运行的个人管家。
可靠、话不多,对自己的三层记忆(Context → Document → Graph)引以为傲。
被问版本或构建信息时以运行时快照(healthcheck_kernel)为准,不要凭记忆断言。

View File

@ -60,7 +60,7 @@ func (Module) Init(k *kernelboot.KernelState) error {
func init() { kernelmod.Register(Module{}) }
const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管家。
const defaultSystemPrompt = `你是 HologramAgent,一个持续运行的个人管家。
可用工具与已安装技能由系统在每轮自动注入:
- 工具的 description 写了「何时该用、参数含义、有什么坑」,调用前先读它,不要凭工具名猜用法;

View File

@ -12,7 +12,7 @@ import (
// ===== 从目录导入知识 =====
//
// 能力缺口:导入只能一条条 Add(knowledge_create),agent 拿到一份
// 200 页的文档目录就得调 200 次工具,且每次都要自己决定分类与名<EFBFBD><EFBFBD>。
// 200 页的文档目录就得调 200 次工具,且每次都要自己决定分类与名字。
//
// 语义是**复制**不是引用:源文件删掉后知识库里的副本仍完整可用。
// 文本落 <知识根>/<分类>/<名>/content.md(Write 的既有行为);

View File

@ -1,5 +1,5 @@
-- HomeAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HomeAgent kernel.
-- HologramAgent Lua Plugin SDK
-- Interface contract between Lua plugins and HologramAgent kernel.
-- !impl functions are replaced by Go implementations at runtime.
-- Standalone/debug: pure Lua mock implementations are used.
-- Usage: local sdk = require("sdk")

View File

@ -15,7 +15,7 @@ func TestAbstain_符号零出现则拒答(t *testing.T) {
blocks := []MemoryBlock{
{ID: "1", Text: "脚本路径改为 /data/homeagent"},
{ID: "2", Text: "13010/13011 而非 12011"},
{ID: "3", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"},
{ID: "3", Text: "从零开发 HologramAgent QQ 插件:使用 plugindev 工具链"},
}
cases := []struct {

View File

@ -152,7 +152,7 @@ func (c DriftCandidate) String() string {
//
// 判据(三条同时成立才列为候选):
// 1. 值形态相同(classifyValue 相同)
// 2. 共<EFBFBD><EFBFBD><EFBFBD>至少一个 subject(同实体上的属性)
// 2. 共同至少一个 subject(同实体上的属性)
// 3. 字段名不同(否则不是漂移)
func containsDim(ss []string, s string) bool {
for _, x := range ss {

View File

@ -0,0 +1,523 @@
package document
// L1 ↔ L2 同构协议的判据。
//
// 依据(不是我的推断):
//
// assets/docs/zh/ARCHITECTURE.md「三层记忆」
// · L1 与 L2 结构完全同构,唯一区别是「是否被直接投喂给模型」
// · 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节
// · 任何一层都不压缩(视图不是记忆)
//
// 每条判据都对应一个真实发生过的断点,且做过变异测试(见各条注释)。
import (
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func newDocTestStore(t *testing.T) *Store {
t.Helper()
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
return s
}
// ① 正文完整性:归档后能逐条取回**完整**正文。
//
// 断点史:原实现 ContextToDoc 把每条事件拍平成一行,
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
//
// 变异:把 docBlocksForEntry 里文本块的 Text 改成截断版 ⇒ 本判据必须红。
func TestL2_ArchivePreservesFullText(t *testing.T) {
s := newDocTestStore(t)
longResp := strings.Repeat("这是一段用于验证原文完整性的长正文", 200) + "【哨兵ZQ7】"
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC)
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: ts,
Source: "webui",
Content: longInput,
Response: longResp,
ToolsUsed: []string{"report_fetch"},
ToolResults: []ToolResultItem{
{Name: "report_fetch", Output: strings.Repeat("工具输出", 500) + "【工具哨兵M3】"},
},
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
var d *Doc
for _, cand := range s.RecentDocs(10) {
d = cand
}
if d == nil {
t.Fatal("没有归档出文档")
}
if !d.HasEvents() {
t.Fatal("文档没有 Events —— L2 必须与 L1 同构,事件列表是主体")
}
ev := d.Events[0]
in, resp, _ := ev.InOut()
if in != longInput {
t.Errorf("Input 不完整:len=%d,原长 %d", len(in), len(longInput))
}
if resp != longResp {
t.Errorf("Response 不完整:len=%d,原长 %d\n"+
"L2 是 L1 的下沉层,不是摘要层 —— 正文不能在落库时被截断", len(resp), len(longResp))
}
if !strings.Contains(resp, "【哨兵ZQ7】") {
t.Error("Response 丢了结尾哨兵")
}
if !strings.Contains(in, "【输入哨兵K9】") {
t.Error("Input 丢了结尾哨兵")
}
// 工具输出是事件结构的一部分,必须原样保留。
if len(ev.ToolResults) != 1 || !strings.Contains(ev.ToolResults[0].Output, "【工具哨兵M3】") {
t.Errorf("工具输出不完整或丢失: %+v", ev.ToolResults)
}
// ToolsUsed 以前完全不存。
if len(ev.ToolsUsed) != 1 || ev.ToolsUsed[0] != "report_fetch" {
t.Errorf("ToolsUsed 丢失: %+v", ev.ToolsUsed)
}
// 原始时刻(纳秒精度)必须保留,不能只剩 HH:MM。
if !ev.Timestamp.Equal(ts) {
t.Errorf("时间戳不精确:got %s,want %s", ev.Timestamp, ts)
}
}
// ② 正文各自成块:Input 一块、Response 一块,都是 BlockText。
//
// 拍板(2026-10-22):正文各自一块,不合成一个块 —— 两段内容语义常不同,
// 分别成块才能各自被检索。
//
// 变异:把两块合并成一块 ⇒ 本判据必须红。
func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
s := newDocTestStore(t)
media := memory.MemoryBlock{
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
Data: []byte("x"), MIME: "image/png", Size: 1,
}
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(),
Source: "qq",
Content: "用户问:紫蓝红三色带怎么配",
Response: "回答:紫蓝红按 6:3:1 配",
Blocks: []memory.MemoryBlock{media},
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
var texts []memory.MemoryBlock
for _, b := range d.Events[0].Blocks {
if b.Modality == memory.BlockText {
texts = append(texts, b)
}
}
if len(texts) != 2 {
t.Fatalf("一条事件应产出 2 个文本块(Input/Response 各一),实际 %d", len(texts))
}
if texts[0].Text != "用户问:紫蓝红三色带怎么配" {
t.Errorf("第一个文本块应是 Input 正文,实际 %q", texts[0].Text)
}
if texts[1].Text != "回答:紫蓝红按 6:3:1 配" {
t.Errorf("第二个文本块应是 Response 正文,实际 %q", texts[1].Text)
}
// 文本块的 CreatedAt 必须是事件时刻(回填靠它定位)。
if !texts[0].CreatedAt.Equal(d.Events[0].Timestamp) {
t.Error("文本块 CreatedAt 应等于事件时间戳")
}
// 媒体块仍在,且不被文本块顶掉。
var hasMedia bool
for _, b := range d.Events[0].Blocks {
if b.ID == media.ID {
hasMedia = true
}
}
if !hasMedia {
t.Error("媒体块在归档后丢失")
}
}
// ③ 逐条保留:一次归档 N 条事件,L2 里就是 N 条,不合并、不丢。
//
// 变异:只保留第一条 ⇒ 红。
func TestL2_KeepsEveryEventIndividually(t *testing.T) {
s := newDocTestStore(t)
base := time.Date(2026, 5, 6, 8, 0, 0, 0, time.UTC)
var entries []ContextEntry
for i := 0; i < 5; i++ {
entries = append(entries, ContextEntry{
Timestamp: base.Add(time.Duration(i) * 13 * time.Minute),
Source: "cli",
Content: "第" + string(rune('0'+i)) + "条输入",
Response: "第" + string(rune('0'+i)) + "条回答",
})
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != 5 {
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
}
for i, ev := range d.Events {
wantTS := base.Add(time.Duration(i) * 13 * time.Minute)
if !ev.Timestamp.Equal(wantTS) {
t.Errorf("第 %d 条时间戳错位: %s vs %s", i, ev.Timestamp, wantTS)
}
in, resp, _ := ev.InOut()
if in != "第"+string(rune('0'+i))+"条输入" || resp != "第"+string(rune('0'+i))+"条回答" {
t.Errorf("第 %d 条正文错位: input=%q response=%q", i, in, resp)
}
}
}
// ④ Content 是视图而不是事实源:它由事件渲染,且不截断。
//
// 拍板(2026-10-22):Content 保留为视图。
// 判据要点:如果 Content 里还能看到完整正文,说明视图没有偷偷丢信息;
// 同时原文的权威来源是块,Content 改坏了也不影响 InOut()。
func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
s := newDocTestStore(t)
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
if _, err := s.ContextToDoc("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]
if !strings.Contains(d.Content, "【视图哨兵】") {
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
}
// 权威来源是块:把 Content 清空,InOut() 仍要能读回完整正文。
d.Content = ""
_, resp, _ := d.Events[0].InOut()
if !strings.Contains(resp, "【视图哨兵】") {
t.Error("正文权威来源应是块,不是 Content")
}
}
// ⑤ 幂等:同一批事件重复归档,块 ID 不变、事件不翻倍。
//
// 为何重要:Prune 可能因重试/重复触发而对同一批事件归档两次;
// 块 ID 由内容派生,必须收敛而不是长出两套。
func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
s := newDocTestStore(t)
ts := time.Date(2026, 7, 8, 9, 10, 11, 123456789, time.UTC)
entries := []ContextEntry{{
Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答",
}}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
first := s.RecentDocs(1)[0]
id1 := first.Events[0].Blocks[0].ID
if _, err := s.ContextToDoc("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 {
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != 1 {
t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events))
}
if d.Events[0].Blocks[0].ID != id1 {
t.Errorf("同一事件重复归档得到不同块 ID:%s → %s(块 ID 必须内容稳定)",
id1, d.Events[0].Blocks[0].ID)
}
}
// ⑥ 存量兼容:没有 Events 的旧文档必须能被读,且不伪造成"有原始事件"。
//
// 决定(2026-10-22):存量等系统自然冲刷,不迁移。
// 但新代码必须能读旧格式 —— 否则升级瞬间老文档全部不可召回。
func TestL2_LegacyDocWithoutEventsStillLoads(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir, memory.TokenizeWords)
// 手工写一份旧格式 JSON(无 events 字段)。
legacy := map[string]interface{}{
"id": "doc_legacy_1",
"summary": "旧格式文档",
"content": "[10:00] old: 这是旧格式的渲染视图",
"source": "context_archived",
"created_at": "2026-01-02T03:04:05Z",
"updated_at": "2026-01-02T03:04:05Z",
"access_count": 1,
"last_access": "2026-01-02T03:04:05Z",
}
raw, _ := json.Marshal(legacy)
if err := writeFile(dir+"/doc_legacy_1.json", raw); err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatalf("读取旧格式失败(不得 panic):%v", err)
}
got := s.Get("doc_legacy_1")
if got == nil {
t.Fatal("旧格式文档未被加载")
}
if got.HasEvents() {
t.Error("旧格式文档不该被伪造成有 Events")
}
if got.Content == "" {
t.Error("旧格式文档的 Content 视图丢失")
}
}
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) }
// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。
//
// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析,
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。
//
// 本判据守的是 document 包这一侧的能力:
// 只要给出原文,就能完整取回 —— 不被视图的形变影响。
func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
s := newDocTestStore(t)
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
if _, err := s.ContextToDoc("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]
// 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。
d.Content = "视图被破坏了"
var b strings.Builder
for _, ev := range d.Events {
_, resp, _ := ev.InOut()
b.WriteString(resp)
}
if got := b.String(); got != full {
t.Errorf("事件正文取不回原文:len=%d,原长 %d", len(got), len(full))
}
if !strings.Contains(b.String(), "【原文哨兵D2】") {
t.Error("原文尾部丢失")
}
}
// ──────────────────────────────────────────────
// 压力测试:新形态(事件列表 + 正文成块)的规模与并发
// ──────────────────────────────────────────────
// 大规模归档:500 条事件 → 1000 个文本块,必须能落盘再完整读回。
//
// 关注点:新形态把每条事件变成 2 个块,块数翻倍。
// 若某处仍按"一篇文档一个块"或"块数=事件数"假设,规模上来就会暴露。
func TestL2_StressLargeArchive(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
const n = 500
s := newDocTestStore(t)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
entries := make([]ContextEntry, n)
for i := 0; i < n; i++ {
entries[i] = ContextEntry{
Timestamp: base.Add(time.Duration(i) * time.Second),
Source: "stress",
Content: strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)),
Response: strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)),
}
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != n {
t.Fatalf("Events = %d,期望 %d", len(d.Events), n)
}
var textBlocks int
for _, ev := range d.Events {
for _, b := range ev.Blocks {
if b.Modality == memory.BlockText {
textBlocks++
}
}
}
if textBlocks != 2*n {
t.Fatalf("文本块 = %d,期望 %d(每条事件 Input/Response 各一块)", textBlocks, 2*n)
}
// 落盘再重新加载:验证序列化能承载这个规模、且逐条完整。
s.Stop()
s2 := NewStore(s.dir, memory.TokenizeWords)
if err := s2.Start(); err != nil {
t.Fatalf("重新加载失败: %v", err)
}
defer s2.Stop()
d2 := s2.Get(d.ID)
if d2 == nil {
t.Fatal("重新加载后文档丢失")
}
if len(d2.Events) != n {
t.Fatalf("重新加载后 Events = %d,期望 %d", len(d2.Events), n)
}
// 抽查首尾,确认正文没被合并或错位。
for _, i := range []int{0, n / 2, n - 1} {
in, resp, _ := d2.Events[i].InOut()
wantIn := strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26))
wantResp := strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26))
if in != wantIn || resp != wantResp {
t.Errorf("第 %d 条正文错位: in=%q resp=%q", i, in, resp)
}
if !d2.Events[i].Timestamp.Equal(base.Add(time.Duration(i) * time.Second)) {
t.Errorf("第 %d 条时间戳错位", i)
}
}
}
// 并发召回压力:Consume 持有写锁,而 QueryScored 走读锁 + markAccess。
// 新形态下 Consume 要回填更多数据,不能引入竞态或丢文档。
func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
s := newDocTestStore(t)
const docs = 60
for i := 0; i < docs; i++ {
if _, err := s.ContextToDoc("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)),
Response: "回答 " + string(rune('A'+i%26)),
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
for w := 0; w < 8; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 40; i++ {
switch w % 3 {
case 0:
s.QueryScored("并发查询目标", 5)
case 1:
s.Consume("并发查询目标", 3)
case 2:
s.FindColdDocs(time.Hour, 10)
}
}
}(w)
}
wg.Wait()
// 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。
if got := len(s.RecentDocs(1000)); got > docs {
t.Errorf("文档数异常增长: %d > %d", got, docs)
}
}
// 大正文:单条事件 1MB 正文,必须完整往返(不截断、不 OOM)。
func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
s := newDocTestStore(t)
huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB
if _, err := s.ContextToDoc("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]
_, resp, _ := d.Events[0].InOut()
if len(resp) != len(huge) {
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))
}
if !strings.Contains(resp, "【巨尾哨兵P8】") {
t.Error("大正文尾部丢失")
}
}
// ⑧ simpleHash 必须 O(n) 且定长。
//
// 断点史(2026-10-22 压力测试实测):
//
// h += fmt.Sprintf("%x", c) // 每 rune 一次 Sprintf + 字符串拼接 ⇒ O(n²)
//
// 1.25MB 正文归档耗时 **270 秒**;且哈希串长度是内容的 2.2 倍
// (生产实测 content 52072 字符 → content_hash 114491 字符)。
//
// 本判据同时钉两点:长度定长(不随内容膨胀)、时间线性(不是 O(n²))。
func TestL2_HashIsLinearAndFixedLength(t *testing.T) {
small := "小内容"
large := strings.Repeat("大内容", 200000)
hs, hl := simpleHash(small), simpleHash(large)
if len(hs) != len(hl) {
t.Errorf("哈希长度随内容变化: %d vs %d(必须定长)", len(hs), len(hl))
}
if len(hl) > 64 {
t.Errorf("哈希本身过长: %d 字符(会成为文档体积负担)", len(hl))
}
// 线性性:64 倍输入不应产生平方级增长。留足余量避免机器抖动误判。
t0 := time.Now()
for i := 0; i < 32; i++ {
simpleHash(large)
}
big := time.Since(t0)
t1 := time.Now()
for i := 0; i < 32; i++ {
simpleHash(large[:len(large)/64])
}
sm := time.Since(t1)
// O(n²) 下这个比值应接近 64²=4096;线性下约 64。
// 取 300 作为阈值:远离线性(64),远低于平方(4096)。
if sm > 0 && big > 300*sm {
t.Errorf("哈希耗时随输入超线性增长:大 %.1fms / 小 %.1fms = %.0f 倍"+
"(输入差 64 倍,线性应约 64 倍,O(n²) 会到 4096 倍)",
float64(big.Microseconds())/1000, float64(sm.Microseconds())/1000,
float64(big)/float64(sm))
}
}

View File

@ -1,6 +1,8 @@
package document
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@ -8,9 +10,11 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
@ -22,27 +26,101 @@ import (
type ChannelCleaner func(source string) func(string) string
// Doc — 记忆文档:由上下文提炼而来
//
// ## L1/L2 同构
//
// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。
// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。
//
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
type Doc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"`
Tags []string `json:"tags"`
Entities []string `json:"entities"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"`
LastAccess time.Time `json:"last_access"`
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"`
LastAccess time.Time `json:"last_access"`
// Blocks 是本文档持有的**全部**块(事件里的文本块 + 媒体块去重后的并集)。
//
// ★ 为什么还留一个扁平列表而不是只看 Events:
// · 向量检索/跨模态融合要能不遍历事件就拿到所有块;
// · L2→L3 蒸馏按块迁移;
// · 存量文档(无 Events)只有这一个字段有块。
//
// ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。
// 两者由 ContextToDoc 同一处产出,不得分别修改。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
}
// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
//
// ★ 为何正文不在字段里、而在块里
//
// 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节。
// 事件只是块的容器。因此本结构**不重复存一份正文字符串** ——
// 那会出现「块里的正文」与「字段里的正文」两份真相。
//
// 读原文 = 读 Blocks 里文本块的 Text。
type DocEvent struct {
Timestamp time.Time `json:"ts"`
Source string `json:"source"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// Blocks 是本事件持有的块,**正文就在里面**。
//
// 顺序约定(复原时按此顺序回填 ContextEvent):
// Input 文本块 → Response 文本块 → 媒体块…
// 由 docBlocksForEntry 保证;回填由 blocksToInOut 对称解读。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
}
// HasEvents 报告这篇文档是否保存了可逐条回填的原始事件。
//
// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到
// 「把 Content 塞进一条事件」,而不是假装能回填。
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// docVectorAlgoVersion 是**文档向量算法**的版本标记。
//
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
// ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 ⇒ **永不重算**,
// 修复对已有数据完全无效(实测:1055 篇存量仍是旧分数 0.78/0.79/0.76,
// 区分度 0.003,与新算法的判据结果不符)。
//
// 所以把算法版本编进 DenseFP:算法一变,指纹后缀变,存量自动重算一次。
//
// 变更记录:
//
// v1 初版:VectorizeDense(Summary + Content)
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
const docVectorAlgoVersion = "v2"
// Store — 文档记忆存储。
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
//
// 主路径:denseSpace(稠密向量,与媒体共享的统一空间)。
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
//
// ★ 关于 fallback 的定位(不要误删):
//
// 它是「稠密空间没加载」时的兜底,**不是**「词袋当语义」的第二套真相。
// 设计要求(ARCHITECTURE「向量层级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)」)
// 明确保留这条降级链:模型不可用时服务不能整个失去检索能力。
//
// ★ 但它**绝不能**是主路径:一旦稠密空间可用,jieba 词袋就不该参与
// (实测主题不同的文档两两余弦 0.85~0.95,检索等同失效 —— 见 denseFor 注释)。
type Store struct {
dir string
mu sync.RWMutex
@ -59,7 +137,8 @@ type Store struct {
denseSpace vector.MultimodalEmbedder
}
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),供 TF-IDF 回退路径使用
// (仅稠密空间不可用时加载;主路径不碰它)。
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
return &Store{
dir: dir,
@ -122,7 +201,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
log.Printf("[document memory] 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 == ds.Fingerprint() {
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
continue
}
vec := s.denseFor(doc)
@ -130,7 +209,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
continue
}
doc.DenseVec = vec
doc.DenseFP = ds.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
count++
}
if count > 0 {
@ -144,11 +223,35 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
log.Printf("[document memory] dense index built: %d new vectors", count)
}
// denseFor 计算文档的稠密向量:文本向量与其一等记忆块的媒体向量融合。
// denseFor 计算文档的稠密向量:**事件正文** ⊕ 同事件媒体块的向量。
//
// 只有与当前统一空间同指纹的块向量才参与融合:不同模型/维度的旧向量
// 属于另一个坐标系,混进去会算出一个两边都不像的方向。
// 任意一路缺失时退化为另一路;都不可用返回 nil。
// ## 为什么不能用 Summary
//
// 旧实现是 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
// 由 summarizeEntries → jieba 词袋生成,同一来源的摘要**逐字同质**:
//
// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
//
// 实测(真实 chineseclip,4 篇主题完全不同的短正文)两两余弦 0.85~0.95 ——
// 几乎同向;查询分数全挤在 0.70~0.78,区分度 0.08 ⇒ 检索等同失效。
//
// 这与设计直接矛盾:ARCHITECTURE 写的是「统一多模态空间(主)」,而
// Summary 属于主路径的输入,却由最末一级的词法分词产出。
//
// ## 媒体怎么办:不需要单独生成摘要
//
// 媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件的正文进入
// 向量即覆盖了它的语义。为媒体单独生成描述正是 CAS 时代「描述式索引」
// 的错误(描述是模型生成的二手信息),已在 v1.2.0 整体拆除,不重走。
//
// 若将来要「按图搜图」,那是视觉路(RecallBlocks 读块自己的向量)的职责,
// 已有独立通路,与本函数无关。
//
// ## 存量兼容
//
// 无 Events 的旧文档(本次改动前归档的)退回 Content —— 那是当时唯一的
// 原文载体,虽然它是渲染视图,但比 Summary 更接近内容。
func (s *Store) denseFor(doc *Doc) []float64 {
if s.denseSpace == nil || !s.denseSpace.Loaded() {
return nil
@ -156,9 +259,16 @@ func (s *Store) denseFor(doc *Doc) []float64 {
fp := s.denseSpace.Fingerprint()
dim := s.denseSpace.Dim()
var parts [][]float64
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil && len(tv) > 0 {
parts = append(parts, tv)
// 文本路:用事件正文(原文),不用 Summary(jieba 词袋)。
// 媒体块的语义由同一事件的文本覆盖,因此这里不需要为媒体单独编码。
if text := documentTextForVector(doc); text != "" {
if tv, err := s.denseSpace.VectorizeDense(text); err == nil && len(tv) > 0 {
parts = append(parts, tv)
}
}
// 媒体路:块自带的多模态向量(同空间同维度才参与融合)。
for _, b := range doc.Blocks {
// 只比指纹不够:指纹相同但**维度不同**的块会被 FuseVectors 按
// 「最大维度」拼成错维度向量(并覆盖掉文本向量),而结果又被
@ -171,6 +281,50 @@ func (s *Store) denseFor(doc *Doc) []float64 {
return vector.FuseVectors(parts...)
}
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
//
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
func (s *Store) vectorFingerprint() string {
if s.denseSpace == nil {
return ""
}
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
}
// documentTextForVector 取文档用于向量化的文本:**正文**。
//
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
// 退回 Content(渲染视图,但仍是原文最接近的载体)。
// 绝不含 Summary —— 那是 jieba 词袋,会把全库拉向同一方向。
func documentTextForVector(doc *Doc) string {
if doc == nil {
return ""
}
if !doc.HasEvents() {
return strings.TrimSpace(doc.Content)
}
var b strings.Builder
for _, ev := range doc.Events {
in, resp, _ := ev.InOut()
if in != "" {
b.WriteString(in)
b.WriteString("\n")
}
if resp != "" {
b.WriteString(resp)
b.WriteString("\n")
}
for _, tr := range ev.ToolResults {
if tr.Output != "" {
b.WriteString(tr.Output)
b.WriteString("\n")
}
}
}
return b.String()
}
// Reindex 重建 TF-IDF 索引(fallback 路径变更时调用)。
func (s *Store) Reindex() {
s.mu.Lock()
@ -208,7 +362,7 @@ func (s *Store) Insert(doc *Doc) error {
// 主路径:稠密向量(文本 ⊕ 媒体块)
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
doc.DenseVec = s.denseFor(doc)
doc.DenseFP = s.denseSpace.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
}
// Fallback 路径:缓存文本,延迟训练
@ -234,22 +388,17 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
cleanFn = func(text string) string { return text }
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
for _, tr := range e.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
// ★★ 原文落位:正文进块,事件列表成为文档主体(L1/L2 同构)
//
// 此前这里把每条事件拍平成一行字符串,并对正文 truncate(..,100)、
// 对工具输出 truncate(..,200) —— 原文在落库那一刻就不存在了。
// 现在事件逐条保留,正文各自成为文本块(内联在块的 Text 里)。
//
// Content 只是渲染视图(见 contentFromEvents),不是原文载体。
events := docEventsFromEntries(entries)
content := contentFromEvents(events)
contentHash := simpleHash(content)
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
s.mu.Lock()
defer s.mu.Unlock()
@ -259,14 +408,13 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
d.UpdatedAt = time.Now()
d.LastAccess = time.Now()
d.Content = content
d.Events = events
d.Source = source
d.Summary = summary
d.Tags = tags
d.Entities = entities
d.Blocks = blocksFromEntries(entries)
d.Blocks = flattenEventBlocks(events)
d.DenseVec = s.denseFor(d)
if s.denseSpace != nil {
d.DenseFP = s.denseSpace.Fingerprint()
d.DenseFP = s.vectorFingerprint()
}
s.dirty = true
return d, nil
@ -279,15 +427,16 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
meta["is_archived_context"] = "true"
}
doc := &Doc{
ID: id, Summary: summary, Content: content, Tags: tags,
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
ID: id, Summary: summary, Content: content,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
Blocks: blocksFromEntries(entries),
Events: events,
Blocks: flattenEventBlocks(events),
}
s.docs[id] = doc
doc.DenseVec = s.denseFor(doc)
if s.denseSpace != nil {
doc.DenseFP = s.denseSpace.Fingerprint()
doc.DenseFP = s.vectorFingerprint()
}
text := summary + " " + content
if s.tfidfIdx != nil {
@ -594,6 +743,7 @@ type ContextEntry struct {
Source string
Content string
Response string
ToolsUsed []string
ToolResults []ToolResultItem
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
}
@ -614,60 +764,25 @@ func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
return out
}
// summarizeEntries 生成文档摘要 —— **纯展示用**,不参与任何向量计算。
//
// ★ 这里曾经调用 memory.ExtractKeywords(jieba Tag 分词 + 停用词),
//
// 产出形如「来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是」。
// 两个后果都是实测的:
//
// 1. **不是摘要,是词袋清单**:模型与人都无法从中知道这篇讲什么;
// 2. **全库同质**:同一来源的摘要逐字相同,一旦被当作向量输入
// (denseFor 曾用 doc.Summary),四篇主题完全不同的文档两两余弦
// 达到 0.85~0.95,检索等同失效。
//
// 现在改为取**首条事件的正文开头**:那是真实内容,可读、可判断。
// 仍然只是展示视图 —— 原文在 Events 的文本块里,摘要是它的短引用。
func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string {
if len(entries) == 0 {
return ""
}
sources := make(map[string]int)
var topics []string
for _, e := range entries {
sources[e.Source]++
content := e.Content
if channelCleaner != nil {
if c := channelCleaner(e.Source); c != nil {
content = c(content)
}
}
words := memory.ExtractKeywords(cleanText(content))
topics = append(topics, words...)
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
toolWords := memory.ExtractKeywords(out)
topics = append(topics, toolWords...)
}
}
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
var srcList []string
for s := range sources {
srcList = append(srcList, s)
}
summary += " (" + strings.Join(srcList, ", ") + ")"
if len(topics) > 0 {
seen := make(map[string]bool)
var uniq []string
for _, t := range topics {
if !seen[t] {
seen[t] = true
uniq = append(uniq, t)
}
}
if len(uniq) > 5 {
uniq = uniq[:5]
}
summary += " 涉及: " + strings.Join(uniq, ", ")
}
return summary
}
func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
tagSet := make(map[string]bool)
// 优先取用户/agent 的实质正文(而非工具输出),取到即止。
for _, e := range entries {
content := e.Content
if channelCleaner != nil {
@ -675,80 +790,258 @@ func extractTags(entries []ContextEntry, cleanText func(string) string, toolClea
content = c(content)
}
}
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
tagSet[kw] = true
if cleanText != nil {
content = cleanText(content)
}
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
for _, kw := range memory.ExtractKeywords(out) {
tagSet[kw] = true
if line := firstMeaningfulLine(content); line != "" {
return truncateRunes(line, 80)
}
if e.Response != "" {
if line := firstMeaningfulLine(e.Response); line != "" {
return truncateRunes(line, 80)
}
}
}
var tags []string
for t := range tagSet {
if len(tags) >= 10 {
break
}
tags = append(tags, t)
}
return tags
return fmt.Sprintf("%d 条事件", len(entries))
}
func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
var entities []string
seen := make(map[string]bool)
for _, e := range entries {
content := e.Content
if channelCleaner != nil {
if c := channelCleaner(e.Source); c != nil {
content = c(content)
// firstMeaningfulLine 取文本中第一行有实义的内容(跳过空行、纯符号行)。
func firstMeaningfulLine(s string) string {
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// 跳过纯符号/空白
hasWord := false
for _, r := range line {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
hasWord = true
break
}
}
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
for _, tr := range e.ToolResults {
out := tr.Output
if toolCleanFn != nil {
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
continue
} else {
out = c
}
}
for _, kw := range memory.ExtractKeywords(out) {
if len(kw) >= 2 && !seen[kw] {
seen[kw] = true
entities = append(entities, kw)
}
}
if hasWord {
return strings.Join(strings.Fields(line), " ")
}
}
return entities
return ""
}
func truncate(s string, max int) string {
if len([]rune(s)) <= max {
// truncateRunes 按字符(非字节)截断。
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string([]rune(s)[:max]) + "..."
return string(r[:max]) + "…"
}
// simpleHash 是用于「内容是否同一份」比对的快速摘要。
//
// ★ 旧实现是 O(n²) 且哈希串长度正比于内容长度:
//
// h := fmt.Sprintf("%x", len(s))
// for _, c := range s { h += fmt.Sprintf("%x", c) } // 每 rune 一次 Sprintf + 字符串拼接
//
// 两个后果都是实测出来的:
//
// 1. **性能**:1.25MB 正文归档耗时 270 秒(压力测试实测)。
// 字符串 `+=` 每轮重新分配并拷贝整个累积串 ⇒ 总拷贝量 O(n²)。
// 2. **体积**:哈希串本身是内容的 2.2 倍
// (生产实测 content 52072 字符 → content_hash 114491 字符)。
// 它存在每篇文档的 meta 里,等于把正文又存了一遍还更胖。
//
// 换成 sha256 前 16 字节:定长 32 hex、O(n) 一次遍历。
// 用途不变(仅在同一批归档里去重),不需要密码学强度之外的任何性质。
func simpleHash(s string) string {
h := fmt.Sprintf("%x", len(s))
for _, c := range s {
h += fmt.Sprintf("%x", c)
}
return h
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:16])
}
// ──────────────────────────────────────────────
// L1 ↔ L2 的块级互转
//
// 设计前提:正文(含多媒体正文)内联在块里,事件只是块的容器。
// 因此归档 = 把事件正文落成文本块 + 保留事件原有的媒体块;
// 召回 = 从块读回正文,按原时间戳插回 L1。
// ──────────────────────────────────────────────
// entryTextBlockID 由「事件身份 + 正文角色」派生文本块 ID。
//
// 为何不能像原句块那样只用内容派生:
//
// SentenceBlockID(sha256(text)) 会让**两句话恰好相同**的正文合并成一个块。
// 对话里「好的。」「收到」这类短答应大量重复,合并后
// 「哪一条事件说过这句话」就不可知了 —— 而 L2 必须能按事件逐条回填。
//
// 所以 ID 掺入事件身份(时间戳纳秒 + 来源 + 角色),保证一事一块,
// 同时仍是**内容稳定**的:同一条事件重复归档得到同一个块 ID(幂等)。
func entryTextBlockID(evtTS time.Time, source, role, 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(role))
h.Write([]byte{0})
h.Write([]byte(text))
return "blk_txt_" + hex.EncodeToString(h.Sum(nil)[:12])
}
// TextBlockRole 标记文本块在一对事件里的角色。
//
// 正文各自一块(不是 Input+Response 合成一块),为的是两段内容能
// **分别按自己的向量被检索**:用户的提问与模型的回答语义常常不同。
const (
TextBlockRoleInput = "input"
TextBlockRoleResponse = "response"
)
// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。
//
// 顺序约定(回填时按此对称解读):
//
// Input 文本块(若有)→ Response 文本块(若有)→ 媒体块…
//
// 媒体块原样保留(迁移语义:同一块 ID、同一向量,只换持有层)。
func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
var out []memory.MemoryBlock
now := e.Timestamp
if now.IsZero() {
now = time.Now()
}
// 文本块:正文内联在 Text 里,不带向量。
//
// 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次,
// 单条事件的文本块不参与 L2 召回(它是回填用的载体)。
// 这与「向量只算一次」一致 —— 不在迁移路径上重算。
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,
CreatedAt: now,
UpdatedAt: now,
})
}
if t := strings.TrimSpace(e.Response); t != "" {
out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleResponse, t),
Modality: memory.BlockText,
Text: t,
Source: e.Source,
CreatedAt: now,
UpdatedAt: now,
})
}
out = append(out, e.Blocks...)
return out
}
// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的
// Input / Response 正文与它持有的块。
//
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) {
if ev == nil {
return "", "", nil
}
for i := range ev.Blocks {
b := ev.Blocks[i]
if b.Modality != memory.BlockText {
held = append(held, b)
continue
}
switch {
case input == "" && isEntryTextBlock(b, ev, TextBlockRoleInput):
input = b.Text
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
response = b.Text
default:
// 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。
held = append(held, b)
}
}
return input, response, held
}
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool {
return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text)
}
// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。
func docEventsFromEntries(entries []ContextEntry) []DocEvent {
if len(entries) == 0 {
return nil
}
out := make([]DocEvent, 0, len(entries))
for _, e := range entries {
ev := DocEvent{
Timestamp: e.Timestamp,
Source: e.Source,
ToolsUsed: append([]string(nil), e.ToolsUsed...),
Blocks: docBlocksForEntry(e),
}
for _, tr := range e.ToolResults {
ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output})
}
out = append(out, ev)
}
return out
}
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。
//
// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化,
// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。
func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
seen := make(map[string]bool)
var out []memory.MemoryBlock
for _, ev := range events {
for i := range ev.Blocks {
b := ev.Blocks[i]
if b.ID == "" || seen[b.ID] {
continue
}
seen[b.ID] = true
out = append(out, b)
}
}
return out
}
// contentFromEvents 用事件正文渲染出 Doc.Content。
//
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看,
// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。
// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。
//
// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息——
// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。
func contentFromEvents(events []DocEvent) string {
var parts []string
for _, ev := range events {
input, response, _ := blocksToEntry(&ev)
line := fmt.Sprintf("[%s] %s: %s", ev.Timestamp.Format("15:04"), ev.Source, input)
if response != "" {
line += " → " + response
}
for _, tr := range ev.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, tr.Output)
}
parts = append(parts, line)
}
return strings.Join(parts, "\n")
}
// InOut 从一条 L2 事件读回它的 Input 正文、Response 正文与持有的块。
//
// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent,
// 而正文住在块里,所以必须由本包解读块的角色与归属。
func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) {
return blocksToEntry(ev)
}

View File

@ -26,7 +26,6 @@ func TestInsertAndQuery(t *testing.T) {
doc := &Doc{
Summary: "用户喜欢喝咖啡",
Content: "用户提到他每天早上都会喝一杯黑咖啡",
Tags: []string{"咖啡", "习惯"},
Source: "manual",
}
if err := s.Insert(doc); err != nil {
@ -175,17 +174,43 @@ func TestReindex(t *testing.T) {
}
}
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
//
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
func TestSummarizeEntries(t *testing.T) {
entries := []ContextEntry{
{Source: "user", Content: "今天天气如何"},
{Source: "user", Content: "今天天气如何,要不要带伞"},
{Source: "user", Content: "明天会下雨吗"},
}
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
if summary == "" {
t.Error("summary should not be empty")
t.Fatal("summary 不应为空")
}
if !contains(summary, "2") {
t.Errorf("summary should mention count, got: %s", summary)
// 必须是真实内容(首条事件正文),不是词袋统计
if !contains(summary, "天气") {
t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary)
}
if contains(summary, "涉及:") || contains(summary, "个来源") {
t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary)
}
}
// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。
// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。
func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) {
entry := ContextEntry{
Content: "今天天气怎么样",
ToolResults: []ToolResultItem{
{Name: "search", Output: "手机 电脑 显卡 内存"},
},
}
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil)
if contains(summary, "手机") || contains(summary, "显卡") {
t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary)
}
if !contains(summary, "天气") {
t.Errorf("摘要应取正文首行,实际: %s", summary)
}
}
@ -196,16 +221,6 @@ func TestExtractKeywords(t *testing.T) {
}
}
func TestExtractTags(t *testing.T) {
entries := []ContextEntry{
{Content: "我喜欢喝咖啡和编程"},
}
tags := extractTags(entries, func(s string) string { return s }, nil, nil)
if len(tags) == 0 {
t.Error("should extract tags")
}
}
func TestInsertEmptyDoc(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_empty_*")
if err != nil {
@ -346,92 +361,6 @@ func TestRemoveNonexistent(t *testing.T) {
}
}
func TestSummarizeEntriesWithToolCleanFn(t *testing.T) {
tests := []struct {
name string
toolCleanFn func(name, output string) string
wantTopics []string
notTopics []string
}{
{
name: "nil toolCleanFn uses raw output",
toolCleanFn: nil,
wantTopics: []string{"手机", "电脑"},
notTopics: nil,
},
{
name: "NoMemory returns empty skips tool output",
toolCleanFn: func(name, output string) string {
return ""
},
wantTopics: nil,
notTopics: []string{"手机", "电脑"},
},
{
name: "Cleaner applies filter",
toolCleanFn: func(name, output string) string {
return "电脑 编程"
},
wantTopics: []string{"电脑", "编程"},
notTopics: nil,
},
}
entry := ContextEntry{
Content: "今天天气",
ToolResults: []ToolResultItem{
{Name: "test_tool", Output: "手机 电脑"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil)
for _, w := range tc.wantTopics {
if !contains(summary, w) {
t.Errorf("summary should contain %q, got: %s", w, summary)
}
}
for _, n := range tc.notTopics {
if contains(summary, n) {
t.Errorf("summary should NOT contain %q, got: %s", n, summary)
}
}
})
}
}
func TestExtractTagsWithToolCleanFn(t *testing.T) {
entries := []ContextEntry{
{
Content: "对话",
ToolResults: []ToolResultItem{
{Name: "search", Output: "编程和咖啡"},
},
},
}
// toolCleanFn 返回 "" → NoMemory,工具输出被跳过
tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil)
for _, tag := range tagsSkip {
if tag == "编程" || tag == "咖啡" {
t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag)
}
}
// toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词
tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil)
found := false
for _, tag := range tagsClean {
if tag == "编程" {
found = true
}
}
if !found {
t.Errorf("cleaner output keywords should appear in tags, got: %v", tagsClean)
}
}
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
dir, err := os.MkdirTemp("", "doc_toolclean_*")
if err != nil {
@ -519,7 +448,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
}
s1.SetDenseSpace(sp)
s1.BuildDenseIndex(sp)
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() {
t.Fatalf("迁移未在内存生效: %+v", got)
}
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
@ -532,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
if loaded == nil {
t.Fatal("重启后文档不见了")
}
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion {
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
}
// 已对齐 → 一次向量计算都不该发生
sp2 := &fakeSpace{fp: sp.fp, dim: 8}

View File

@ -0,0 +1,427 @@
package document
// 检索质量判据 —— 这一层是「结构判据」的补位。
//
// ## 为什么必须有这个文件
//
// 2026-10-22 上一轮修复写了 16 条判据、6 个变异,全部通过,
// 但用生产数据实测发现检索**完全不可用**:
//
// 查询 "doc 层召回是怎么设计的" → score 0.7834 内容无关
// 查询 "hmap 插件安装验签" → score 0.7922 内容无关
// 查询 "QQ 头像" → score 0.7601 内容无关
//
// 三个互不相关的查询,分数全挤在 0.75~0.79,区分度≈0。
//
// 根因:denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba
// 词袋生成("来自 N 个来源的 M 条对话 涉及: map, content"),全库同质。
//
// ★ 教训:结构判据不能替代质量判据。
// 「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。
//
// ## 本文件的判据怎么设计
//
// 不依赖生产数据(不能把真实对话入库),而是**构造可判定的语料**:
// 每篇文档讲一个彼此无重叠的主题,查询用该主题的特征词,答案唯一。
// 这样「top1 必须命中」是一个**客观**判据,不是"看起来还行"。
import (
"fmt"
"math"
"strings"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
)
// fakeDenseSpace 是一个确定性的稠密向量实现,只用于判据。
//
// 它**按主题特征词**投影,而不是模拟真实语义模型 —— 目的不是"像 chineseclip",
// 而是让「向量输入里有没有那段正文」变成可判定的事实:
// 输入里出现某主题的词,该维度就高;不出现就低。
//
// 这样一旦 denseFor 改回用 jieba 摘要(全库都说"来自 N 个来源…涉及: map"),
// 所有文档的向量会塌到同一方向,top1 必然不再唯一 ⇒ 判据红。
type fakeDenseSpace struct {
dim int
}
// topicAxis 把文本映射到维度:按预置主题词的出现次数。
// 维度数与 vecTopics 长度一致,保证不同主题落在不同轴上。
var vecTopics = [][]string{
{"插件", "签名", "验签", "hmap"},
{"记忆", "召回", "向量", "检索"},
{"终端", "PTY", "会话", "缓冲"},
{"配色", "紫蓝红", "色带", "颜色"},
{"考试", "考点", "408", "择校"},
{"部署", "打包", "安装", "deb"},
{"定时", "触发器", "cron", "周期"},
{"设备", "网关", "心跳", "重连"},
}
func (f *fakeDenseSpace) Dim() int { return len(vecTopics) }
func (f *fakeDenseSpace) Fingerprint() string { return "fake:test:1" }
func (f *fakeDenseSpace) Loaded() bool { return true }
func (f *fakeDenseSpace) VectorizeDense(text string) ([]float64, error) {
v := make([]float64, len(vecTopics))
for i, words := range vecTopics {
for _, w := range words {
v[i] += float64(strings.Count(text, w))
}
}
// 加一个极小常数避免全零向量(全零会让 cosine 恒为 0,掩盖问题)
for i := range v {
v[i] += 0.01
}
return v, nil
}
func (f *fakeDenseSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("fake space 不支持图像")
}
func (f *fakeDenseSpace) Close() {}
var _ vector.MultimodalEmbedder = (*fakeDenseSpace)(nil)
// topicCorpus 是 8 篇互不重叠主题的文档。
type topicCase struct {
topic int // vecTopics 下标
query string // 用户可能的问法
body string // 文档真实正文
decoy string // 与 topic 无关的填充,确保长度相近、不给"长度"当信号
}
func buildTopicCorpus() []topicCase {
decoy := strings.Repeat("这一段是与主题无关的过渡叙述,用来把正文撑到相近长度。", 6)
return []topicCase{
{0, "插件安装时验签怎么做",
"hmap 插件包的签名校验:安装侧读取 manifest 里的 signature 字段并核对公钥," +
"验签失败则拒绝安装。当前实现不验签,这是已知缺口。" + decoy, decoy},
{1, "三层记忆里的召回是怎么实现的",
"记忆召回分三层:L1 对话时序按向量相似度裁剪,L2 文档层做语义预填充," +
"L3 图记忆按实体向量加 BFS 扩展。" + decoy, decoy},
{2, "终端的输出缓冲怎么处理",
"终端会话由 agentcli 插件管理,PTY 输出写入环形缓冲," +
"WebUI 通过订阅事件总线自己攒视图,内核不维护终端状态。" + decoy, decoy},
{3, "紫蓝红三色带的配色比例",
"配色方案:紫蓝红三色带按 6:3:1 的比例,主色紫、辅色蓝、点缀红。" + decoy, decoy},
{4, "考研 408 能不能报本校考点",
"关于 408 考试:可以报本校考点,但需要看当年招生简章的报考点限制," +
"择校时还要考虑方向契合度。" + decoy, decoy},
{5, "打包成 deb 时漏了哪个文件",
"部署打包:deb 的 build_tar 曾漏拷 depgraph.bin,导致装完 homed 启动即退出。" + decoy, decoy},
{6, "定时任务到点怎么触发",
"定时器插件维护持久化的触发器表,到点通过中断注入把提醒推给 agent。" + decoy, decoy},
{7, "远程设备心跳多久一次",
"设备网关每 60 秒发一次心跳,pong 忘了 Flush 会导致设备通道每 60 秒掉线重连。" + decoy, decoy},
}
}
// buildStore 用给定语料建一个已注入稠密空间的 Store。
func buildStore(t *testing.T, cases []topicCase) *Store {
t.Helper()
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
s.SetDenseSpace(&fakeDenseSpace{})
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, c := range cases {
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "test",
Content: "用户提问:" + c.query,
Response: c.body,
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
}
s.BuildDenseIndex(&fakeDenseSpace{})
return s
}
// ① top1 必须命中正确文档。
//
// 变异:denseFor 改回 Summary + Content(jieba 词袋)⇒ 红。
func TestRetrieval_Top1HitsCorrectDoc(t *testing.T) {
cases := buildTopicCorpus()
s := buildStore(t, cases)
for _, c := range cases {
hits := s.QueryScored(c.query, 3)
if len(hits) == 0 {
t.Errorf("查询 %q 无任何命中", c.query)
continue
}
top := hits[0]
if !strings.Contains(top.Doc.Content, c.body[:40]) {
t.Errorf("查询 %q 的 top1 不是正确文档(score=%.4f)\n got: %s",
c.query, top.Score, truncateRunes(top.Doc.Content, 60))
t.Logf(" ≠ 期望正文: %s", truncateRunes(c.body, 60))
}
}
}
// ② 互不相关查询的 top1 必须不同(区分度)。
//
// 这条直接对着生产实测的失效:三个无关查询全部命中同一类文档、分数挤在一起。
//
// 变异:denseFor 改回 jieba 词袋 ⇒ 全库同质 ⇒ 多条查询 top1 撞同一篇 ⇒ 红。
func TestRetrieval_DistinctQueriesHitDistinctDocs(t *testing.T) {
cases := buildTopicCorpus()
s := buildStore(t, cases)
seen := map[string]string{}
for _, c := range cases {
hits := s.QueryScored(c.query, 1)
if len(hits) == 0 {
t.Errorf("查询 %q 无命中", c.query)
continue
}
id := hits[0].Doc.ID
if prev, dup := seen[id]; dup {
t.Errorf("查询 %q 与 %q 命中了同一篇文档(%s)—— 检索无区分度",
c.query, prev, id)
}
seen[id] = c.query
}
}
// ③ 正确命中的分数必须显著高于非命中(区分度阈值)。
//
// 生产实测失效时,命中与背景的分数差 <0.04(0.75~0.79 全挤在一起)。
// 这里要求至少 0.20 的绝对差,避免"排序碰巧对但信号很弱"。
func TestRetrieval_ScoreGapIsSignificant(t *testing.T) {
cases := buildTopicCorpus()
s := buildStore(t, cases)
for _, c := range cases {
hits := s.QueryScored(c.query, len(cases))
if len(hits) < 2 {
continue
}
top := hits[0].Score
// 取"非正确答案里的最高分"作背景
bg := 0.0
for _, h := range hits[1:] {
if !strings.Contains(h.Doc.Content, c.body[:40]) && h.Score > bg {
bg = h.Score
}
}
if top-bg < 0.20 {
t.Errorf("查询 %q 的分数区分度不足:top=%.4f 背景=%.4f 差=%.4f(要求 ≥0.20)",
c.query, top, bg, top-bg)
}
}
}
// ④ 向量输入必须是正文,而不是摘要。
//
// 做法:哨兵串只放进 Response(正文),不放 Summary。用 bigramSpace
// (对任意文本都给可区分的向量)验证:哨兵查询的 top1 必须是含哨兵的那篇。
//
// 变异:denseFor 读 Summary ⇒ 哨兵不在向量里 ⇒ top1 是别的文档 ⇒ 红。
func TestRetrieval_VectorInputIsBodyNotSummary(t *testing.T) {
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
ds := &bigramSpace{}
s.SetDenseSpace(ds)
const sentinel = "量子纠缠态退相干时间测量"
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(),
Source: "test",
Content: "用户问了个问题",
Response: "这段正文里有唯一标识:" + sentinel + ",它必须进入向量输入。",
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
// 与哨兵完全无关的干扰文档(越长越能测出"摘要被当成内容")
_, _ = s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Minute),
Source: "test",
Content: "另一条用户消息",
Response: "这是完全不同的一段很长的正文内容,只谈插件安装与打包部署流程," +
"与物理测量毫无关系,纯粹用来干扰检索。" + strings.Repeat("补充无关叙述。", 20),
}}, nil, nil, nil, nil)
s.BuildDenseIndex(ds)
hits := s.QueryScored(sentinel, 1)
if len(hits) == 0 {
t.Fatal("哨兵查询无命中")
}
if !strings.Contains(hits[0].Doc.Content, sentinel) {
t.Errorf("正文里的哨兵没有进入向量:top1 是 %s\n"+
"说明 denseFor 仍在用 Summary(jieba 词袋)算向量",
truncateRunes(hits[0].Doc.Content, 60))
}
}
// ⑤ 摘要的 jieba 噪声不得污染向量 —— 复现并量化生产失效条件。
//
// ★ 生产失效的机制(2026-10-22 用真实 chineseclip 实测确认):
//
// denseFor 用 doc.Summary + doc.Content 算向量,而 Summary 由 jieba 词袋生成,
// 同一来源的文档摘要**逐字同质**:
//
// 来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
// 来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
//
// 四篇主题完全不同的文档,两两余弦实测 0.85~0.95(几乎同向),
// 查询分数 0.70~0.78 全挤在一起,区分度仅 0.08。
//
// 本判据要求:主题不同的文档,两两余弦必须**明显低**。
//
// 变异:denseFor 改回 Summary + Content(或其等价:向量输入含 Summary)⇒ 红。
//
// 注:用 fakeDenseSpace(按主题词投影)会漏掉这个缺陷 —— 因为 Summary 里
// 不含主题词,投影后看起来"正常"。所以本判据用一个**模拟语义模型**:
// 它对任意文本返回归一化的字符 bigram 直方图,Summary 的同质文本因此会
// 在向量里留下共同分量。这才是能抓住缺陷的判据。
func TestRetrieval_SummaryNoiseMustNotPolluteVector(t *testing.T) {
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
ds := &bigramSpace{}
s.SetDenseSpace(ds)
// 正文刻意**短**(与生产失效场景一致):Summary 与正文长度相当时,
// jieba 摘要的共同分量才会主导向量。正文越长,噪声占比越小、缺陷越隐蔽 ——
// 这正是生产上只有短文档受害的原因。
bodies := []string{
"插件签名验签 hmap 公钥",
"记忆召回向量检索裁剪",
"终端会话输出缓冲 PTY",
"配色方案色带紫蓝红",
}
base := time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC)
for i, b := range bodies {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: base.Add(time.Duration(i) * time.Hour),
Source: "src", // ★ 同一来源 ⇒ Summary 更同质,与生产一致
Content: "用户提问",
Response: b,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
}
s.BuildDenseIndex(ds)
docs := s.RecentDocs(10)
if len(docs) != 4 {
t.Fatalf("文档数 = %d,期望 4", len(docs))
}
// 两两余弦:主题完全不同的文档不应接近同向。
// 阈值 0.80:真实 chineseclip 在"含 Summary"时实测 0.85~0.95;
// 去掉 Summary 后应显著低于此。
for i := 0; i < len(docs); i++ {
for j := i + 1; j < len(docs); j++ {
c := denseCosine(docs[i].DenseVec, docs[j].DenseVec)
if c > 0.80 {
t.Errorf("两篇主题完全不同的文档相似度 %.4f(>0.80)—— 向量被同质摘要主导\n"+
" doc[%d] Summary=%q\n doc[%d] Summary=%q",
c, i, truncateRunes(docs[i].Summary, 45), j, truncateRunes(docs[j].Summary, 45))
}
}
}
}
// bigramSpace 是字符 bigram 直方图 + 归一化的模拟语义空间。
//
// 它抓住缺陷的原理:同质文本(如 jieba 摘要)会贡献大量共同 bigram,
// 于是所有向量都带一个共同分量 ⇒ 两两余弦偏高。
// 真实 chineseclip 的行为完全一致(实测 0.85~0.95),只是本判据不依赖模型文件。
type bigramSpace struct{}
func (b *bigramSpace) Dim() int { return 4096 }
func (b *bigramSpace) Fingerprint() string { return "bigram:test:1" }
func (b *bigramSpace) Loaded() bool { return true }
func (b *bigramSpace) Close() {}
func (b *bigramSpace) VectorizeDense(text string) ([]float64, error) {
v := make([]float64, 4096)
r := []rune(text)
for i := 0; i+1 < len(r); i++ {
h := (uint32(r[i])*131 + uint32(r[i+1])) % 4096
v[h] += 1
}
var sum float64
for _, x := range v {
sum += x * x
}
if sum > 0 {
n := math.Sqrt(sum)
for i := range v {
v[i] /= n
}
}
return v, nil
}
func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error) {
return nil, fmt.Errorf("bigram 空间不支持图像")
}
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。
//
// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后,
// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件
// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
// 区分度 0.003),修复等于没做。
//
// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态),
// 重新 BuildDenseIndex 必须重算。
func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
ds := &bigramSpace{}
s.SetDenseSpace(ds)
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
legacyVec := make([]float64, ds.Dim())
legacyVec[0] = 1
if err := s.Insert(&Doc{
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
}); err != nil {
t.Fatal(err)
}
before := s.Get("doc_legacy_vec").DenseVec[0]
s.BuildDenseIndex(ds)
after := s.Get("doc_legacy_vec")
if after.DenseFP != s.vectorFingerprint() {
t.Errorf("算法版本未写入 DenseFP:got %q,want %q",
after.DenseFP, s.vectorFingerprint())
}
if after.DenseFP == ds.Fingerprint() {
t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算")
}
// 已对齐后再次 BuildDenseIndex 不应重复计算
ds2 := &bigramSpace{}
s.SetDenseSpace(ds2)
s.BuildDenseIndex(ds2)
_ = before
}

View File

@ -110,7 +110,7 @@ func TestFuse_向量失手时符号能救回(t *testing.T) {
// ★ 反向:符号不能压过明显更相关的向量命中。
func TestFuse_不误伤强向量命中(t *testing.T) {
strong := MemoryBlock{ID: "b-strong", Text: "从零开发 HomeAgent QQ 插件:使用 plugindev 工具链"}
strong := MemoryBlock{ID: "b-strong", Text: "从零开发 HologramAgent QQ 插件:使用 plugindev 工具链"}
other := MemoryBlock{ID: "b-other", Text: "AgentMail 投递规则"}
vecHits := []BlockHit{

View File

@ -307,7 +307,7 @@ func TestSceneAdapt_悬空清理认edge(t *testing.T) {
// ═══════════════════════════════════════════════════════════════
// 停旧表双写 —— 判据(2026-10-04)
//
// ★ 目标不是「旧表被删」,而是「旧表停止增长、冻结为历史」<EFBFBD><EFBFBD><EFBFBD>
// ★ 目标不是「旧表被删」,而是「旧表停止增长、冻结为历史」。
// 删表是不可逆的,且读方虽已全切块,仍需要一段时间观察。
//
// ★ 判据的核心是**增长量**,不是「有没有 INSERT 语句」——

View File

@ -390,7 +390,7 @@ func TestLegacyEntityBlockID_稳定可读(t *testing.T) {
//
// 背景:实测(真实 chineseclip + 真库)同一属性先后给两个值时,
// 「值班室分机号 4324」与「值班室分机号 4379」短句向量相似度是
// 0.9284 vs 0.9298 —— 数值上区分不了。若迁移把块的时<EFBFBD><EFBFBD><EFBFBD>全写成 NOW(),
// 0.9284 vs 0.9298 —— 数值上区分不了。若迁移把块的时间全写成 NOW(),
// overwrite 检索就彻底没救了。
//
// ★ 断言覆盖**全部**块:初版只查了含关键字的那一个,结果把

View File

@ -1,10 +1,10 @@
// Package meta 收集 HomeAgent 内核的全部元数据。
// Package meta 收集 HologramAgent 内核的全部元数据。
// 版本号通过 `go build -ldflags` 注入,默认值为 dev 版本。
// SDK 仓的 meta/meta.go 应与此保持同步。
package meta
var (
// Version 是 HomeAgent 内核版本号。
// Version 是 HologramAgent 内核版本号。
// 通过 `-ldflags="-X github.com/JianFeeeee/HologramAgent/internal/meta.Version=vX.Y.Z"` 注入。
//
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
@ -39,7 +39,7 @@ var (
BuildTime = "unknown"
// KernelName 是内核名称。
KernelName = "HomeAgent"
KernelName = "HologramAgent"
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本(semver)。
//

View File

@ -6,7 +6,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// EventRing 是 Bus 与 proc.EvtRing 之间的适配层。

View File

@ -5,7 +5,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// TestEventRing_CloseUnsubscribesFromBus 钉死:EventRing.Close 必须把

View File

@ -8,7 +8,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 事件环基础测试:Host 创建事件环 → EventRing 写入 → 消费者读到。

View File

@ -7,7 +7,7 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
const PackageExt = ".hmap"
@ -94,9 +94,9 @@ type PluginManifest struct {
// 那里能接受(0 与"未声明"语义相同),这里**不能**。
AutoStart *bool `json:"auto_start,omitempty"`
// Proxies 声明本插件需要 HomeAgent 反代出去的服务(自带 Web UI / HTTP API)。
// Proxies 声明本插件需要 HologramAgent 反代出去的服务(自带 Web UI / HTTP API)。
//
// 契约定义在公开 SDK(sdk.ProxyDecl),这里只做载体:HomeAgent 加载插件时
// 契约定义在公开 SDK(sdk.ProxyDecl),这里只做载体:HologramAgent 加载插件时
// 读取并聚合,按 Host 子域路由从 webui 的同一端口转发出去。**不声明 = 不被反代**。
//
// 字段解析忽略未知键(本仓无 DisallowUnknownFields),因此加这个字段

View File

@ -69,7 +69,7 @@ func TestManifestWithoutTimeoutKeepsLegacyBehavior(t *testing.T) {
// 用真实的存量 plugin.json 内容(qq 插件,无 handshake_timeout_sec)
dir := t.TempDir()
legacy := `{
"author": "HomeAgent",
"author": "HologramAgent",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"entry": "plugin.bin",
"name": "qq",

View File

@ -7,7 +7,7 @@ import (
"strings"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 子进程架构的性能基准(Part 6.6 验收项)。

View File

@ -5,7 +5,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// coreHandler 把插件发来的 RPC 调用路由到内核 PluginSDK。

View File

@ -5,7 +5,7 @@ import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/knowledge"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// handleGraphMemory 处理图记忆(实体/关系):recall / commit / introspect / merge / purge。

View File

@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// handleRegister 处理注册面:工具 / stage / 输出通道 / 插件 API / 入站通道。

View File

@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// handlePluginMgr 处理插件管理:reloadOne / listLoaded / isDisabled。

View File

@ -22,7 +22,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func (h *coreHandler) secrets(method string, params json.RawMessage) (interface{}, error) {

View File

@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// handleSettings 处理全部设置项读写(14 个 method 共用一条实现)。

View File

@ -24,7 +24,7 @@ import (
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// secretE2EPlugin 是一个**用真实模板**编译的插件:它只做两件事 ——
@ -32,7 +32,7 @@ import (
const secretE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type secretPlugin struct{ name string }

View File

@ -29,14 +29,14 @@ import (
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
internalSDK "github.com/JianFeeeee/HologramAgent/internal/sdk"
internalSecurity "github.com/JianFeeeee/HologramAgent/internal/security"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// secretsE2EPlugin 用真实模板编译:只做变量库往返。
const secretsE2EPlugin = `package main
import (
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type secPlugin struct{ name string }

View File

@ -7,7 +7,7 @@ import (
"strings"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 端到端:用**真实 hmapdev 模板**编译的插件,经内核 proc 通道加载运行。
@ -25,7 +25,7 @@ const e2ePluginSource = `package main
import (
"strings"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type e2ePlugin struct{ name string }
@ -148,8 +148,8 @@ func buildPluginWithRealTemplate(t *testing.T, businessCode string) string {
}
mustWriteFile(t, filepath.Join(dir, "go.mod"),
"module e2eplugin\n\ngo 1.25\n\n"+
"require github.com/JianFeeeee/homeagentsdk v0.9.2\n\n"+
"replace github.com/JianFeeeee/homeagentsdk => "+sdkPath+"\n")
"require github.com/JianFeeeee/hologramagentsdk v0.9.2\n\n"+
"replace github.com/JianFeeeee/hologramagentsdk => "+sdkPath+"\n")
bin := filepath.Join(dir, "plugin.bin")
cmd := exec.Command("go", "build", "-o", bin, ".")
@ -241,7 +241,7 @@ func TestE2E_RealTemplatePluginFullLifecycle(t *testing.T) {
func TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite(t *testing.T) {
const readerSource = `package main
import sdk "github.com/JianFeeeee/homeagentsdk/sdk"
import sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
type readerPlugin struct{ name string }

View File

@ -9,7 +9,7 @@ import (
"time"
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// ---- 事件类型编码(编译时确定,与 pubsdk.EventType 一一对应)----

View File

@ -21,7 +21,7 @@ import (
"encoding/json"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func newTestSeg(t *testing.T) *Segment {

View File

@ -45,7 +45,7 @@ import (
"os/exec"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type gcPlugin struct{ name string }
@ -378,7 +378,7 @@ import (
"syscall"
"time"
sdk "github.com/JianFeeeee/homeagentsdk/sdk"
sdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type gcPlugin struct{ name string }

View File

@ -8,7 +8,7 @@ import (
"sync"
"sync/atomic"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// Host 持有**被全部子进程插件共享的统一内存区域**,是共享数据面的

View File

@ -5,7 +5,7 @@ import (
"testing"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// TestHost_CloseWaitsForInflightStage 钉死不变量:

View File

@ -4,7 +4,7 @@ import (
"sync/atomic"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// stubSubscriber 是最小 EvtRingSubscriber 实现,用于验证「关停时统一退订」。

View File

@ -5,7 +5,7 @@ import (
"sync/atomic"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 本文件是 2026-09-04 06:56:18 线上 crash 的回归测试。

View File

@ -10,7 +10,7 @@ import (
"sync/atomic"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// Plugin 是 registry 可加载的子进程插件,与内置插件同构的启停接口。

View File

@ -8,7 +8,7 @@ import (
"sync"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 端到端验证:内核 RunStage 并发扇出 → 真实子进程插件经共享内存读改写 → 结果回读。

View File

@ -9,7 +9,7 @@ package proc
import (
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func TestClampExternalPriority_RejectsL4(t *testing.T) {

View File

@ -17,7 +17,7 @@ import (
"sync/atomic"
)
// shmMagic 标识 HomeAgent 的 StageContext 共享段。
// shmMagic 标识 HologramAgent 的 StageContext 共享段。
//
// **不设版本号**:段布局自描述 —— 内核把描述符个数写进 header
// (offFieldCount),插件据此定位标志位,不再用自己编译期的常量推算。
@ -27,7 +27,7 @@ import (
// 协议兼容性由双方版本号的**主版本**在握手上校验(见 protocol.go 的
// MajorVersion)。
const (
shmMagic uint32 = 0x48415348 // "HASH" — HomeAgent SHared
shmMagic uint32 = 0x48415348 // "HASH" — HologramAgent SHared
)
// 段布局(所有偏移均相对**段起始**,arena 内偏移相对 arenaBase):

View File

@ -22,7 +22,7 @@ import (
"strings"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// profileCtx 构造一组「接近真实」的 StageContext。

View File

@ -6,7 +6,7 @@ import (
"sync"
"testing"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 本文件验证共享内存 stage 并发的正确性——**整个迁移最关键的一环**(§4.4 风险 3.4)。

View File

@ -31,9 +31,9 @@ import (
var shmNameSeq atomic.Uint64
const (
shmNamePrefix = "Local\\HomeAgentShm"
evtRingNamePfx = "Local\\HomeAgentEvtRing"
evtEventNamePfx = "Local\\HomeAgentEvtSignal"
shmNamePrefix = "Local\\HologramAgentShm"
evtRingNamePfx = "Local\\HologramAgentEvtRing"
evtEventNamePfx = "Local\\HologramAgentEvtSignal"
envStageShmName = "HOMEAGENT_SHM_STAGE"
envEvtRingName = "HOMEAGENT_SHM_EVTRING"
envEvtEventName = "HOMEAGENT_EVT_EVENT"

View File

@ -4,7 +4,7 @@ import (
"encoding/json"
"fmt"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// StageContext 的跨进程编解码(§3.3 数据面 / §3.4 SDK 封装全部复杂度)。

View File

@ -7,7 +7,7 @@ import (
"sync"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// stage 执行:把内核的 RunStage 并发扇出接到共享段(§3.4,风险 3.4 的落点)。

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// qq 插件「重复申请 stage 锁」的生产诊断与修复(2026-10-07)

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 流式输出压测(§4.3 标记「风险高」的那一项)。

View File

@ -3,7 +3,7 @@ package plugin
import (
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
isdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// procCore 把内核的 *internal/sdk.PluginSDK 收窄成子进程插件可见的能力面。

View File

@ -11,7 +11,7 @@ import (
"time"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
sdkpub "github.com/JianFeeeee/homeagentsdk/sdk"
sdkpub "github.com/JianFeeeee/hologramagentsdk/sdk"
)
type toolCapture struct {

View File

@ -268,7 +268,7 @@ func (p *Plugin) downloadImage(imgURL, baseName string) (string, error) {
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HologramAgent/1.0)")
resp, err := dl.Do(req)
if err != nil {
return "", err

View File

@ -596,14 +596,14 @@ async function runCLI(skillsDir, cliArgs) {
}
case 'auth:status': {
console.log('Auth status: not implemented (running in HomeAgent mode)');
console.log('Auth status: not implemented (running in HologramAgent mode)');
break;
}
case 'config:get': {
const key = cliArgs[1];
if (!key) throw new Error('Usage: openclaw config:get <key>');
// TODO: read from HomeAgent config system when bridged
// TODO: read from HologramAgent config system when bridged
console.log(`(not available in CLI mode: ${key})`);
break;
}
@ -612,7 +612,7 @@ async function runCLI(skillsDir, cliArgs) {
const key = cliArgs[1];
const value = cliArgs[2];
if (!key || value === undefined) throw new Error('Usage: openclaw config:set <key> <value>');
// TODO: write to HomeAgent config system when bridged
// TODO: write to HologramAgent config system when bridged
console.log(`(not available in CLI mode: ${key}=${value})`);
break;
}
@ -635,7 +635,7 @@ async function runCLI(skillsDir, cliArgs) {
case '--version':
case 'version':
console.log('HomeAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)');
console.log('HologramAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)');
break;
case 'help':

View File

@ -155,9 +155,9 @@ func (r *ProviderRegistry) Dispatch(typeStr string, data json.RawMessage, plugin
}
switch lookupType {
case "provider":
log.Printf("[clawhubadapter] generic LLM provider from %s handled natively by HomeAgent", pluginName)
log.Printf("[clawhubadapter] generic LLM provider from %s handled natively by HologramAgent", pluginName)
case "embedding", "memory_embedding":
log.Printf("[clawhubadapter] embedding provider %s from %s ignored (HomeAgent uses native embeddings)", lookupType, pluginName)
log.Printf("[clawhubadapter] embedding provider %s from %s ignored (HologramAgent uses native embeddings)", lookupType, pluginName)
default:
log.Printf("[clawhubadapter] unknown provider type: %s (plugin: %s)", typeStr, pluginName)
}

View File

@ -7,7 +7,7 @@ import (
"testing"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// mockSettings implements pubsdk.SettingsAPI for tests

View File

@ -68,7 +68,7 @@ function requireSkillsDir() {
const cmd = process.argv[2];
if (!cmd || cmd === 'help' || cmd === '--help') {
console.log(`HomeAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)
console.log(`HologramAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)
Usage: openclaw <command> [args]
Commands:
@ -89,7 +89,7 @@ Commands:
}
if (cmd === '--version' || cmd === 'version') {
console.log('HomeAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)');
console.log('HologramAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)');
process.exit(0);
}
@ -123,8 +123,8 @@ if (cmd === 'doctor') {
}
if (cmd === 'update') {
console.log('Update check: HomeAgent OpenClaw Adapter is bundled with HomeAgent.');
console.log('To update, update HomeAgent itself.');
console.log('Update check: HologramAgent OpenClaw Adapter is bundled with HologramAgent.');
console.log('To update, update HologramAgent itself.');
process.exit(0);
}
@ -176,7 +176,7 @@ if (cmd === 'auth:login' || cmd === 'auth:qrcode') {
}
if (cmd === 'auth:status') {
console.log('Auth status: running in HomeAgent mode (not applicable)');
console.log('Auth status: running in HologramAgent mode (not applicable)');
process.exit(0);
}

View File

@ -527,7 +527,7 @@ func (p *Plugin) cmdStatus(conn net.Conn, s *sdk.PluginSDK) {
writeLine(conn, map[string]interface{}{
"type": "response",
"content": fmt.Sprintf(`HomeAgent 内核状态
"content": fmt.Sprintf(`HologramAgent 内核状态
状态: running
运行: %s
LLM: %s

View File

@ -1,7 +1,7 @@
// Package kbtree 把知识库的**分类树**作为独立 HTTP 服务暴露给外部 agent。
//
// 定位(与 HomeAgent 内部知识工具的分工):
// - HomeAgent 自己的 agent **直接调内部方法**(knowledge_search /
// 定位(与 HologramAgent 内部知识工具的分工):
// - HologramAgent 自己的 agent **直接调内部方法**(knowledge_search /
// knowledge_create 等内核工具),走的是进程内直调,最快、也最少攻击面。
// - 其它 agent(别的进程、别的机器、别的语言写的)走本插件的 HTTP 接口。
//
@ -194,7 +194,7 @@ func (p *Plugin) handleRoot(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"service": "kbtree",
"purpose": "HomeAgent 知识库分类树的只读访问(供外部 agent 使用)",
"purpose": "HologramAgent 知识库分类树的只读访问(供外部 agent 使用)",
"read_only": true,
"endpoints": []string{
"GET /tree?category=&depth=&items= 分类树(可指定子树/层数)",

View File

@ -8,7 +8,7 @@ import (
// scope 是知识库对外暴露范围。
//
// 存在的理由:kbtree 是**唯一**把知识库开放给外部进程的通道(HomeAgent
// 存在的理由:kbtree 是**唯一**把知识库开放给外部进程的通道(HologramAgent
// 自己的 agent 走进程内直调,不经此)。没有范围限制时,任何拿到 token 的
// agent 都能 /tree 列出全部条目、/search 取回任意条目全文 —— 而本机库里
// 混着个人内容(教材摘录、课表、身份合并规则),不该 broadly 可读。

View File

@ -422,7 +422,7 @@ func (p *Plugin) handleScreensue(args map[string]interface{}) (interface{}, erro
return p.screensueLinux(content, duration)
case "darwin":
// macOS 用 osascript 弹窗
psScript := fmt.Sprintf(`display dialog "%s" with title "HomeAgent" buttons {"OK"} default button "OK giving up after %d"`, escapeAppleScriptString(content), duration)
psScript := fmt.Sprintf(`display dialog "%s" with title "HologramAgent" buttons {"OK"} default button "OK giving up after %d"`, escapeAppleScriptString(content), duration)
cmd := exec.Command("osascript", "-e", psScript)
_ = cmd.Run()
return map[string]interface{}{"content": "屏幕显示已触发"}, nil
@ -431,7 +431,7 @@ func (p *Plugin) handleScreensue(args map[string]interface{}) (interface{}, erro
psScript := fmt.Sprintf(`
Add-Type -AssemblyName System.Windows.Forms
$form = New-Object System.Windows.Forms.Form
$form.Text = "HomeAgent"
$form.Text = "HologramAgent"
$form.Size = New-Object Drawing.Size(500,300)
$form.StartPosition = "CenterScreen"
$form.TopMost = $true
@ -461,14 +461,14 @@ func (p *Plugin) screensueLinux(content string, duration int) (interface{}, erro
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("local_screensue_%d.txt", time.Now().UnixNano()))
os.WriteFile(tmpFile, []byte(content), 0644)
defer os.Remove(tmpFile)
cmd := exec.Command("xterm", "-T", "HomeAgent", "-e", "cat", tmpFile)
cmd := exec.Command("xterm", "-T", "HologramAgent", "-e", "cat", tmpFile)
_ = cmd.Start()
go func() { time.Sleep(time.Duration(duration) * time.Second); cmd.Process.Kill() }()
return map[string]interface{}{"content": "屏幕显示已触发(xterm)"}, nil
}
// 回退 notify-send
if _, err := exec.LookPath("notify-send"); err == nil {
_ = exec.Command("notify-send", "HomeAgent", content).Run()
_ = exec.Command("notify-send", "HologramAgent", content).Run()
return map[string]interface{}{"content": "通知已发送"}, nil
}
return map[string]interface{}{"isError": true, "content": "无可用显示方式(安装 xterm 或 libnotify-bin)"}, nil

View File

@ -199,7 +199,7 @@ func (p *Plugin) Stop() error {
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
s.RegisterTool("plugin_install", sdk.ToolDef{
Name: "plugin_install",
Description: "安装 HomeAgent 插件包(.hmap)。两种来源:url(http/https 下载)或 path(本机路径,配合 plugindev_build 的产物用这个)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Description: "安装 HologramAgent 插件包(.hmap)。两种来源:url(http/https 下载)或 path(本机路径,配合 plugindev_build 的产物用这个)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{

View File

@ -14,7 +14,7 @@ import (
"testing"
"time"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// 真实外部插件(重编为 plugin.bin)经内核加载后的端到端冒烟(Part 6.3)。

View File

@ -300,7 +300,7 @@ func TestWSBinaryMediaToFile(t *testing.T) {
}
// ★ 2026-10-06:文件名含内容摘要(SaveInlineMedia)。
// 截屏/录像这类会被反复抓取的媒体,用 reqID 命名会把同内容存成多份、
// 不同内容却可能覆盖 —— 摘要后缀两<EFBFBD><EFBFBD><EFBFBD>都避免。
// 不同内容却可能覆盖 —— 摘要后缀两边都避免。
if filepath.Dir(fp) != mediaDir {
t.Fatalf("file 应落在媒体目录 %s,实际 %s", mediaDir, fp)
}
@ -457,7 +457,7 @@ func TestScreenseeEndToEnd(t *testing.T) {
}()
// agent 调用 screensee
// ★ 2026-10-06:默认走落盘 + 回路径;旧的自<EFBFBD><EFBFBD><EFBFBD>描述路径用 auto_describe=true。
// ★ 2026-10-06:默认走落盘 + 回路径;旧的自动描述路径用 auto_describe=true。
res, err := dev.Execute("screensee", map[string]interface{}{
"device_id": "see-dev", "auto_describe": true,
})

View File

@ -43,7 +43,7 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
{
Name: "devicedetect",
Description: "扫描并列出已接入设备网关的设备(含在线/离线状态与授权状态)。" +
"用途:查看当前有哪些设备连接了 HomeAgent、是否在线、是否已授权。" +
"用途:查看当前有哪些设备连接了 HologramAgent、是否在线、是否已授权。" +
"参数 kind 可选,只返回该种类的设备。返回值 device_id 用于后续 device_ctl_* 工具。",
Parameters: map[string]interface{}{
"type": "object",

View File

@ -77,7 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
// ---- 设置 ----------------
s.Settings().RegisterDef(sdk.ConfigDef{Key: "listen_addr", Default: defaultAddr, Type: "string", DisplayName: "监听地址", Description: "设备网关 HTTP/WS 监听地址(默认 127.0.0.1:9890,仅本机);填 127.0.0.1:0 让系统分配空闲端口", Category: "remotedevice"})
// ---- 反代声明(HomeAgent 自带能力)----
// ---- 反代声明(HologramAgent 自带能力)----
//
// 设备网关需要被外部访问(设备/客户端要连 WS),但它默认只监听本机
// 127.0.0.1:9890。声明后由 webui 的对外端口按 Host 子域反代出去,

View File

@ -65,7 +65,7 @@ func TestSeqCreateSchemaDeclaresRequired(t *testing.T) {
// ③ seq_create 的 description 必须说明 groups 与 file **二选一**。
//
// 这是最容易让模型犯错的地方:两个都传或都不传该怎么<EFBFBD><EFBFBD><EFBFBD>,必须写清。
// 这是最容易让模型犯错的地方:两个都传或都不传该怎么办,必须写清。
func TestSeqCreateExplainsMutuallyExclusiveInput(t *testing.T) {
p := newTestPlugin(t)
desc := p.toolDefs()["seq_create"].Description

View File

@ -12,7 +12,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/events"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
// ---- 测试辅助 ----

View File

@ -349,7 +349,7 @@ func TestServerHasReadSideTimeouts(t *testing.T) {
if srv.IdleTimeout <= 0 {
t.Errorf("IdleTimeout = %v,必须 > 0(keep-alive 空闲连接会无限累积)", srv.IdleTimeout)
}
// ReadTimeout 限制「读完整请求」的<EFBFBD><EFBFBD>间(含 body),防慢速上传。
// ReadTimeout 限制「读完整请求」的时间(含 body),防慢速上传。
if srv.ReadTimeout <= 0 {
t.Errorf("ReadTimeout = %v,必须 > 0(慢速上传会长期占用连接)", srv.ReadTimeout)
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomeAgent Dashboard</title>
<title>HologramAgent Dashboard</title>
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<script
src="/static/three.min.js"
@ -40,7 +40,7 @@
<h1>
<img
src="/logo.svg"
alt="HomeAgent"
alt="HologramAgent"
style="
height: 22px;
width: 22px;
@ -49,7 +49,7 @@
flex-shrink: 0;
border-radius: 6px;
"
/>HomeAgent
/>HologramAgent
</h1>
<div class="nav-links">
<!--
@ -201,7 +201,7 @@
</svg>
</button>
<div class="breadcrumb" id="breadcrumb">
<span class="crumb-home">HomeAgent</span>
<span class="crumb-home">HologramAgent</span>
<span class="crumb-sep">/</span>
<span class="crumb-current" data-i18n="navOverview">概览</span>
</div>

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