Files
HomeAgent/internal/agent/core/selfexcite_test.go
JianFeeeee 55fa2685ae feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由
security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
2026-10-26 20:55:25 +08:00

177 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
// coldcontext_query 的**自激震荡**判据。
//
// ## 回路长什么样(用户 2026-10-22 指出)
//
// coldcontext_query
// ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
// │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
// ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
// │ → Prune 因相关而保留它们(挤掉新事件)
// │ → 下一轮 Prune 又把它们归档回 L2
// └─ ⇒ 下次 coldcontext_query 再召回同一批:块在 L1↔L2 之间反复搬迁
//
// 两个入口都必须关掉:
// ① ToolDef.NoMemory —— coldcontext_query 声明,工具输出不进计算层
// ② ContextEvent.NoMemory —— restoreDocEvents 置位,回填事件不再归档
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ① coldcontext_query 必须声明 NoMemory(工具输出不进记忆计算)。
//
// 变异:去掉 toolDefOptions{noMemory:true} ⇒ 红。
func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) {
// 工具定义层面:声明必须可查(不依赖工具表是否已构造)
if !noMemoryOf("coldcontext_query") {
t.Error("coldcontext_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," +
"再参与向量化/蒸馏就是把同一内容重新注入记忆")
}
// 计算层层面:带 coldcontext_query 输出的工具事件,其向量化文本不得含该输出
const sentinel = "这是被召回事件的正文片段哨兵"
evt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: "查一下历史",
ToolResults: []ToolResultItem{
{Name: "coldcontext_query", Output: "已召回:" + sentinel},
},
}
// stageHost 在测试环境为 nil:传 nil lookup,只走内置声明表那条路
text := textForVector(&evt, nil, nil)
if strings.Contains(text, sentinel) {
t.Errorf("coldcontext_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text)
}
}
// ② 回填事件必须带 NoMemory(不参与计算),但**必须照常归档**(数据不丢)。
//
// ★ 这里曾经写反过:要求"不得被 Prune 再归档回 L2"。
//
// coldcontext_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: "然后被 coldcontext_query 取回",
}})
// 召回(Consume 会把 L2 副本删掉)
out := a.executeDocTool(agentAPI.ToolCall{
Name: "coldcontext_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: "coldcontext_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
}