mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 ## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,全塞回去会把跨进程消息撑爆。 媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体 要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断 注入,需要自己的签名,且媒体在**本轮**就送到模型。 ## 二、内核桥接层:原先在静默裁字段 `internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil: - 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit` 而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上; - 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用, 于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减 (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。 规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接 层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方 写下的标记。 标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`, 之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。 ## 三、跨进程链路:不接线就是全体外部插件编译失败 `go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。 公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。 六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、 `corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧 实现、以及三个测试替身。 ## 四、统一输入主干:把模态从「函数选择」降级为「字段」 `processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史 产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` + `mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看 `stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**, 不是输入的**种类**。 媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、 `_consolidation_` 路由、正确的 `EventRawInput`。 最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而 `webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。 **用户发的图从来没出现在 WebUI 聊天记录里。** `media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`: 字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。 ## 五、模型可调用的三个工具 `memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节; 连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加 `media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 ## 六、WebUI 上传走真实媒体链路 图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。 此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回 文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type 时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。 ## 测试 - `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**) - `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体) - `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测) 压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart` 无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine 注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定 只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类 阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。 测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表 是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名 时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。 存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。 17 个 example 插件源码零改动通过类型检查。
179 lines
6.0 KiB
Go
179 lines
6.0 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
|
||
)
|
||
|
||
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储(CAS),
|
||
// 并让 L0 的 ContextEvent 记住它们的 digest。
|
||
//
|
||
// 为何需要这一层:媒体进入对话有两条路,两条都只把**文字**留给记忆——
|
||
//
|
||
// 1. 用户直接发图 → processInput/resolveInput → mediaToBlocks
|
||
// ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
|
||
// base64 随 message 数组发给模型后就丢了。
|
||
// 2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
|
||
// ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。
|
||
//
|
||
// 于是下一轮对话起,模型能看到的只有一句路径或一句 alt。那个文件被删、
|
||
// 被覆盖,或者本来就是 /tmp 下的临时产物,连线索都断了。
|
||
//
|
||
// 现在两条路都在同一处收口:从 ContentBlock 的 data URL 取出字节存进 CAS,
|
||
// digest 挂到当轮 ContextEvent 上;事件被 Prune 归档进 L2 时引用随之转移。
|
||
|
||
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS,返回 digest 列表。
|
||
//
|
||
// 只处理 data URL:http(s) URL 拿不到字节就无法做内容寻址,
|
||
// 而"下载它再存"会把一次对话变成一次网络请求(超时、鉴权、SSRF 全来了),
|
||
// 不在本层解决。
|
||
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
|
||
if a.mediaStore == nil || len(blocks) == 0 {
|
||
return nil
|
||
}
|
||
|
||
var digests []string
|
||
for _, b := range blocks {
|
||
var url string
|
||
switch {
|
||
case b.ImageURL != nil && b.ImageURL.URL != "":
|
||
url = b.ImageURL.URL
|
||
case b.AudioURL != nil && b.AudioURL.URL != "":
|
||
url = b.AudioURL.URL
|
||
default:
|
||
continue
|
||
}
|
||
|
||
mime, data, ok := media.ParseDataURL(url)
|
||
if !ok {
|
||
continue // http(s) URL 或格式不认,跳过
|
||
}
|
||
|
||
d, err := a.mediaStore.Put(data, media.Item{
|
||
MIME: mime,
|
||
Tool: tool,
|
||
})
|
||
if err != nil {
|
||
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
|
||
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
|
||
continue
|
||
}
|
||
digests = append(digests, d)
|
||
}
|
||
return digests
|
||
}
|
||
|
||
// stageMediaDigests 累积本轮捕获的 digest,等 ContextEvent 建好后一起挂上。
|
||
//
|
||
// 为何要缓存而不是当场 AddRef:媒体在 process() 执行期间被捕获,而承载它的
|
||
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
|
||
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
|
||
func (a *Agent) stageMediaDigests(digests ...string) {
|
||
if len(digests) == 0 {
|
||
return
|
||
}
|
||
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
|
||
}
|
||
|
||
// drainMediaDigests 取出并清空本轮累积的 digest。
|
||
func (a *Agent) drainMediaDigests() []string {
|
||
if len(a.pendingMediaDigests) == 0 {
|
||
return nil
|
||
}
|
||
out := a.pendingMediaDigests
|
||
a.pendingMediaDigests = nil
|
||
return out
|
||
}
|
||
|
||
// bindEventMedia 把 digest 列表登记到某个 ContextEvent 上。
|
||
//
|
||
// 双向落地:evt.Media 让事件自己记得引了哪些媒体(随 context.json 持久化),
|
||
// media_refs 表让 CAS 侧知道谁在引用(GC 据此判断能不能清)。
|
||
// 两边都写才闭环——只写一边的话,要么 GC 会误删仍被记忆引用的内容,
|
||
// 要么孤儿永远清不掉。
|
||
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
|
||
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
|
||
return
|
||
}
|
||
if evt.ID == "" {
|
||
evt.ID = newEventID()
|
||
}
|
||
for _, d := range digests {
|
||
if err := a.mediaStore.AddRef(d, media.OwnerContext, evt.ID); err != nil {
|
||
log.Printf("[media] AddRef 失败 (%s → %s): %v", shortDigest(d), evt.ID, err)
|
||
continue
|
||
}
|
||
evt.Media = append(evt.Media, d)
|
||
}
|
||
}
|
||
|
||
// mediaSummaryForEvent 给已有描述的媒体生成一行文字,供写进 ContextEvent.Input。
|
||
//
|
||
// 这是方案 C 的落点:**描述文本才是持久语义记忆,blob 只是缓存**。
|
||
// blob 可能被容量 GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,
|
||
// 让"那张紫蓝红三色带图"在几个月后仍然可被检索到。
|
||
func (a *Agent) mediaSummaryForEvent(digests []string) string {
|
||
if a.mediaStore == nil || len(digests) == 0 {
|
||
return ""
|
||
}
|
||
var lines []string
|
||
for _, d := range digests {
|
||
if line := a.mediaMarkerLine(d); line != "" {
|
||
lines = append(lines, line)
|
||
}
|
||
}
|
||
if len(lines) == 0 {
|
||
return ""
|
||
}
|
||
return "媒体内容:\n" + strings.Join(lines, "\n")
|
||
}
|
||
|
||
// mediaMarkerLine 为一份媒体生成一行标记文本 `[<mime> <短digest>] <描述>`。
|
||
//
|
||
// 这是媒体标记格式的唯一生成处。此前 mediaSummaryForEvent 与
|
||
// mediaContextForSentences 各拼一份,改动截断长度或分隔符时只改一处,
|
||
// 另一处写出的标记就再也解析不回来——而解析失败是静默的(引用挂不上)。
|
||
//
|
||
// 查不到返回空串:媒体可能已被容量 GC 淘汰,此时不该造出一条指向虚无的标记。
|
||
func (a *Agent) mediaMarkerLine(digest string) string {
|
||
if a.mediaStore == nil {
|
||
return ""
|
||
}
|
||
it, err := a.mediaStore.Stat(digest)
|
||
if err != nil || it == nil {
|
||
return ""
|
||
}
|
||
label := string(it.Kind)
|
||
if it.MIME != "" {
|
||
label = it.MIME
|
||
}
|
||
desc := it.Description
|
||
if desc == "" {
|
||
// 「已入库但还没描述」与「压根没有媒体」必须可区分:描述由后台循环
|
||
// 异步补齐,占位符保证补齐前这份媒体也不会从文本里消失。
|
||
desc = "(未描述)"
|
||
}
|
||
return fmt.Sprintf("[%s %s] %s", label, shortDigest(digest), desc)
|
||
}
|
||
|
||
// newEventID 生成 ContextEvent 的稳定标识。
|
||
//
|
||
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
|
||
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
|
||
// a.mu 串行化 Append,不存在同纳秒两条。
|
||
func newEventID() string {
|
||
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
|
||
}
|
||
|
||
func shortDigest(d string) string {
|
||
if len(d) > 12 {
|
||
return d[:12]
|
||
}
|
||
return d
|
||
}
|