mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 09:58:06 +00:00
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。
## mediaGCLoop:让容量上限不再形同虚设
CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。
现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
- 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
- gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
refcount 也是 0
## mediaDescribeLoop:描述才是能活过 GC 的那部分
blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。
复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。
三个刻意的决定:
- **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
不是这一轮——这一轮模型本来就直接看着图。
- **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
往返也要保证「描述 ↔ digest」的对应关系确定。
- **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
每 30s 最多处理 4 条,不跟对话抢额度。
失败处理分三类:
- 网络抖动/配额 → 不标记,下轮重试
- 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
- 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
content-missing,退出队列
## 顺带修掉 Pending 的一个真缺陷
测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。
这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。
## 测试
medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。
全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
agentPkg "gitcode.com/JianFeeeee/HomeAgent/internal/agent"
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
|
||
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
|
||
)
|
||
|
||
// ContextEvent 和 RelevanceContext 定义在 context.go
|
||
|
||
// Agent — 单 agent,不区分会话/实例
|
||
type Agent struct {
|
||
mu sync.Mutex
|
||
id types.AgentID
|
||
provider agentAPI.Provider
|
||
providerManager *agentAPI.ProviderManager
|
||
io *agentIO.IOManager
|
||
memory *memory.GraphDB
|
||
indexer *memory.Indexer
|
||
tracker *tracker.Tracker
|
||
context *RelevanceContext
|
||
systemPrompt string
|
||
ctx context.Context
|
||
cancel context.CancelFunc
|
||
|
||
// 文档记忆(第二层)
|
||
docStore *document.Store
|
||
|
||
// 知识库
|
||
knowledge *knowledge.Store
|
||
|
||
// 人物特质与关系网
|
||
social *social.SocialStore
|
||
|
||
// 文本记忆(原始对话日志)
|
||
textMem *text.Memory
|
||
|
||
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重,
|
||
// L0/L2/L3 只记 digest。为 nil 时全部媒体接线静默跳过——
|
||
// 它是记忆增强而非对话必需品,缺了不该让对话失败。
|
||
mediaStore *media.Store
|
||
// mediaGCInterval 为 0 时不跑 GC 循环(容量上限就仅在手动调 GC 时生效)。
|
||
mediaGCInterval time.Duration
|
||
// mediaGCMinAge 保护新入库媒体:刚 Put 还没来得及 AddRef 的项引用计数也是 0。
|
||
mediaGCMinAge time.Duration
|
||
// mediaDescribe 控制是否跑后台描述循环(要消耗视觉模型配额)。
|
||
mediaDescribe bool
|
||
|
||
// 人格设定
|
||
personality *agentPkg.Personality
|
||
|
||
// 插件注册表(用于 plgreload)
|
||
pluginReg *plugin.Registry
|
||
pluginDir string
|
||
|
||
// 定期心跳蒸馏
|
||
distillInterval time.Duration
|
||
|
||
// 三个独立心跳任务间隔
|
||
archiveInterval time.Duration // 冷文档归档
|
||
reviewInterval time.Duration // 关系复审
|
||
mergeInterval time.Duration // 实体合并检测
|
||
|
||
// 上下文裁剪:活跃上下文最大条数,超出按相关性裁剪
|
||
maxContextSize int
|
||
|
||
// 当前请求的输出通道(mutex 保护,process() 内独占)
|
||
currentOutputChannel string
|
||
|
||
// 阶段管道:插件消息流编辑
|
||
stageHost *StageHost
|
||
eventBus *events.Bus
|
||
pluginHealth *pluginHealthTracker
|
||
|
||
// 自循环输入通道:核心内部任务(记忆消歧、系统维护、子 Agent 通知),
|
||
// 不经过 IO 层。每条消息携带目标输出通道:
|
||
// "_consolidation_" = 记忆整理(无记忆路径,不写入上下文、不 emit 响应)
|
||
// 其他 = 正常处理(写入上下文、emit 响应到该通道)
|
||
selfInputCh chan selfInputMsg
|
||
|
||
// 子任务异步执行
|
||
childMu sync.Mutex
|
||
childNextID int64
|
||
childResults map[string]string
|
||
childRunning map[string]bool // 运行中的子任务(child_result 查询时区分'运行中'与'不存在')
|
||
|
||
// 高优先级打断通道:interceptLoop 注入,process() 在工具循环轮次间非阻塞读取
|
||
interceptCh chan *agentIO.InputEvent
|
||
|
||
// 进行中的 LLM 请求取消函数,interceptLoop 可调用以在请求中打断
|
||
cancelLLM context.CancelFunc
|
||
llmMu sync.Mutex
|
||
|
||
// 模型思考模式(thinking/reasoning)
|
||
thinkingEnabled bool
|
||
|
||
pendingToolBlocks []interface{} // 插件工具通过 SetToolBlocks 注入的多模态块(type agentAPI.ContentBlock),process.go 消费后追加到 tool message
|
||
|
||
// 启动时间
|
||
startTime time.Time
|
||
|
||
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
|
||
pendingMedia map[string]interface{}
|
||
|
||
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
|
||
//
|
||
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
|
||
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||
// 与 pendingMedia 同受 a.mu 保护。
|
||
pendingMediaDigests []string
|
||
|
||
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
|
||
interruptInput bool
|
||
|
||
// 非文本输入处理配置
|
||
inputCfg types.InputProcessingConfig
|
||
|
||
// noMergeMarkets 记录被标记"禁止合并"的实体对,key="entityA||entityB"(字典序),
|
||
// 每次 reorgGraph 扫描到对应实体对时计数减一,归零后自动移除。
|
||
noMergeMarkers map[string]int
|
||
noMergeMu sync.Mutex
|
||
|
||
// 输入去重:防 webui/GUI 断线重连导致的消息重放
|
||
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
|
||
lastInput map[string]time.Time
|
||
lastInputMu sync.Mutex
|
||
|
||
// 词嵌入模型,用于实体语义相似度计算
|
||
embedder *memory.StaticEmbedder
|
||
|
||
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
|
||
skillIndex SkillIndexProvider
|
||
}
|
||
|
||
// SkillIndexProvider 提供已加载技能的精炼索引,供 buildSystemPrompt 注入。
|
||
// 实现方(skillmgr)需线程安全并快速返回(每次 LLM 调用都会调用)。
|
||
type SkillIndexProvider interface {
|
||
// SkillIndex 返回多行文本的技能索引,每行形如 "name vX.Y - description";
|
||
// 无技能时返回空串。
|
||
SkillIndex() string
|
||
}
|
||
|
||
type AgentConfig struct {
|
||
ID types.AgentID
|
||
SystemPrompt string
|
||
Provider agentAPI.Provider
|
||
ProviderManager *agentAPI.ProviderManager
|
||
IO *agentIO.IOManager
|
||
Memory *memory.GraphDB
|
||
Indexer *memory.Indexer
|
||
Tracker *tracker.Tracker
|
||
|
||
DocStore *document.Store
|
||
Knowledge *knowledge.Store
|
||
SocialStore *social.SocialStore
|
||
TextMemory *text.Memory
|
||
MediaStore *media.Store
|
||
MediaGCInterval time.Duration
|
||
MediaGCMinAge time.Duration
|
||
MediaDescribe bool
|
||
Personality *agentPkg.Personality
|
||
PluginReg *plugin.Registry
|
||
PluginDir string
|
||
DistillInterval time.Duration
|
||
ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval
|
||
ReviewInterval time.Duration // 关系复审间隔,0 则使用 DistillInterval
|
||
MergeInterval time.Duration // 实体合并检测间隔,0 则使用 DistillInterval
|
||
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
|
||
ContextSavePath string // 上下文持久化路径,空则不持久化
|
||
EmbeddingModelPath string // 预训练词嵌入模型路径(word2vec 文本格式),空则不使用
|
||
Embedder *memory.StaticEmbedder // 共享词嵌入实例;nil 时按 EmbeddingModelPath 自建
|
||
StageHost *StageHost
|
||
EventBus *events.Bus
|
||
ThinkingEnabled bool
|
||
|
||
SkillIndexProvider SkillIndexProvider
|
||
|
||
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
|
||
}
|
||
|
||
func New(cfg AgentConfig) *Agent {
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
if cfg.DistillInterval <= 0 {
|
||
cfg.DistillInterval = 30 * time.Minute
|
||
}
|
||
if cfg.ArchiveInterval <= 0 {
|
||
cfg.ArchiveInterval = cfg.DistillInterval
|
||
}
|
||
if cfg.ReviewInterval <= 0 {
|
||
cfg.ReviewInterval = cfg.DistillInterval
|
||
}
|
||
if cfg.MergeInterval <= 0 {
|
||
cfg.MergeInterval = cfg.DistillInterval
|
||
}
|
||
if cfg.MaxContextSize <= 0 {
|
||
cfg.MaxContextSize = 30
|
||
}
|
||
|
||
embedder := cfg.Embedder
|
||
if embedder == nil {
|
||
embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
|
||
}
|
||
if cfg.DocStore != nil {
|
||
cfg.DocStore.SetVectorizer(embedder)
|
||
cfg.DocStore.ReindexWithVectorizer(embedder)
|
||
}
|
||
if cfg.Knowledge != nil {
|
||
cfg.Knowledge.SetVectorizer(embedder)
|
||
cfg.Knowledge.ReindexWithVectorizer(embedder)
|
||
}
|
||
|
||
rc := NewRelevanceContext(cfg.ContextSavePath, embedder)
|
||
if cfg.StageHost != nil {
|
||
rc.SetToolDefLookup(cfg.StageHost.ToolDef)
|
||
}
|
||
if cfg.IO != nil {
|
||
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
|
||
}
|
||
|
||
return &Agent{
|
||
id: cfg.ID,
|
||
startTime: time.Now(),
|
||
provider: cfg.Provider,
|
||
providerManager: cfg.ProviderManager,
|
||
io: cfg.IO,
|
||
memory: cfg.Memory,
|
||
indexer: cfg.Indexer,
|
||
tracker: cfg.Tracker,
|
||
context: rc,
|
||
systemPrompt: cfg.SystemPrompt,
|
||
ctx: ctx,
|
||
cancel: cancel,
|
||
docStore: cfg.DocStore,
|
||
knowledge: cfg.Knowledge,
|
||
social: cfg.SocialStore,
|
||
textMem: cfg.TextMemory,
|
||
mediaStore: cfg.MediaStore,
|
||
mediaGCInterval: cfg.MediaGCInterval,
|
||
mediaGCMinAge: cfg.MediaGCMinAge,
|
||
mediaDescribe: cfg.MediaDescribe,
|
||
personality: cfg.Personality,
|
||
pluginReg: cfg.PluginReg,
|
||
pluginDir: cfg.PluginDir,
|
||
distillInterval: cfg.DistillInterval,
|
||
archiveInterval: cfg.ArchiveInterval,
|
||
reviewInterval: cfg.ReviewInterval,
|
||
mergeInterval: cfg.MergeInterval,
|
||
maxContextSize: cfg.MaxContextSize,
|
||
stageHost: cfg.StageHost,
|
||
skillIndex: cfg.SkillIndexProvider,
|
||
eventBus: cfg.EventBus,
|
||
selfInputCh: make(chan selfInputMsg, 64),
|
||
childResults: make(map[string]string),
|
||
childRunning: make(map[string]bool),
|
||
interceptCh: make(chan *agentIO.InputEvent, 64),
|
||
pluginHealth: newPluginHealthTracker(),
|
||
thinkingEnabled: cfg.ThinkingEnabled,
|
||
inputCfg: cfg.InputProcessing,
|
||
embedder: embedder,
|
||
noMergeMarkers: make(map[string]int),
|
||
lastInput: make(map[string]time.Time),
|
||
}
|
||
}
|
||
|
||
// SetSkillIndexProvider 注入技能索引提供者(skillmgr 插件加载后由 main 接线)。
|
||
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
|
||
|
||
func (a *Agent) Start() {
|
||
go a.eventLoop()
|
||
go a.interceptLoop()
|
||
go a.distillLoop()
|
||
go a.archiveLoop()
|
||
go a.mergeLoop()
|
||
go a.reviewLoop()
|
||
go a.mediaGCLoop()
|
||
go a.mediaDescribeLoop()
|
||
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
|
||
}
|
||
|
||
func (a *Agent) Stop() {
|
||
a.cancel()
|
||
}
|
||
|
||
func (a *Agent) ID() types.AgentID { return a.id }
|
||
|
||
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
|
||
// key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
|
||
const duplicateInputWindow = 10 * time.Second
|
||
|
||
func (a *Agent) isDuplicateInput(source, content string) bool {
|
||
a.lastInputMu.Lock()
|
||
defer a.lastInputMu.Unlock()
|
||
now := time.Now()
|
||
key := source + "|" + content
|
||
if last, ok := a.lastInput[key]; ok && now.Sub(last) < duplicateInputWindow {
|
||
a.lastInput[key] = now
|
||
return true
|
||
}
|
||
a.lastInput[key] = now
|
||
// 顺带清理过期项,防止 map 无限增长
|
||
for k, t := range a.lastInput {
|
||
if now.Sub(t) > duplicateInputWindow {
|
||
delete(a.lastInput, k)
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IsDuplicateInput 导出包装,供测试验证去重行为。
|
||
func (a *Agent) IsDuplicateInput(source, content string) bool {
|
||
return a.isDuplicateInput(source, content)
|
||
}
|
||
|
||
// SelfInputChan 返回自循环输入通道(只读,供内部测试验证)
|
||
func (a *Agent) SelfInputChan() <-chan selfInputMsg {
|
||
return a.selfInputCh
|
||
}
|
||
|
||
// injectSelf 向自循环通道发送记忆整理类内部任务(无记忆路径)。
|
||
// 线程安全,不阻塞发送者(通道缓冲 64)。
|
||
func (a *Agent) injectSelf(task string) {
|
||
a.injectSelfChannel(selfInputMsg{
|
||
text: task,
|
||
channel: channelConsolidation,
|
||
})
|
||
}
|
||
|
||
// injectSelfChannel 向自循环通道发送一条带目标通道标志的消息。
|
||
// channel == "_consolidation_" 走无记忆整理路径;其他值走正常处理路径。
|
||
func (a *Agent) injectSelfChannel(msg selfInputMsg) {
|
||
select {
|
||
case a.selfInputCh <- msg:
|
||
default:
|
||
log.Printf("[agent] self input channel full, dropping task: %s", truncateStr(msg.text, 80))
|
||
}
|
||
}
|