mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert 交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。 - `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 字截断, 标记通常就在尾部)。 标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误 静默切断引用绑定,而全链路无人报错。 图片/音频读回字节拼 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 插件源码零改动通过类型检查。
This commit is contained in:
@ -255,6 +255,130 @@ func (a *Agent) bindSentenceMedia(sentenceIDs map[string]int64) int {
|
||||
return bound
|
||||
}
|
||||
|
||||
// sentenceWithMediaMarkers 保证句子文本里带上这些 digest 的媒体标记。
|
||||
//
|
||||
// 存在的理由:媒体的绑定链是 SentenceText → sentences 表 → sentence_id →
|
||||
// media_refs。模型只知道 digest(从 memory_recall 的「关联媒体」或对话里的
|
||||
// 媒体标记读到),不该要求它自己按内核格式拼标记——格式写错的后果是引用
|
||||
// 静默挂不上,模型也无从察觉。
|
||||
//
|
||||
// 已出现过的 digest 不重复追加:模型可能既写了标记又填了 media_digests。
|
||||
func (a *Agent) sentenceWithMediaMarkers(sentence string, digests []string) string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return sentence
|
||||
}
|
||||
present := make(map[string]bool)
|
||||
for _, d := range extractMediaDigests(sentence) {
|
||||
present[d] = true
|
||||
}
|
||||
|
||||
var add []string
|
||||
for _, d := range digests {
|
||||
if d == "" || present[shortDigest(d)] {
|
||||
continue
|
||||
}
|
||||
// 模型给的多半是短 digest(它在上下文里看到的就是短的),补全成完整
|
||||
// digest 才能进 media_refs 主键。补不上就跳过:内容可能已被 GC 清掉。
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型提交的 digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if line := a.mediaMarkerLine(full); line != "" {
|
||||
add = append(add, line)
|
||||
present[shortDigest(full)] = true
|
||||
}
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return sentence
|
||||
}
|
||||
if sentence == "" {
|
||||
return strings.Join(add, "\n")
|
||||
}
|
||||
return sentence + "\n" + strings.Join(add, "\n")
|
||||
}
|
||||
|
||||
// docMediaContext 为一篇文档产出媒体说明,供 doc_query 拼进工具返回值。
|
||||
//
|
||||
// 优先读 media_refs(权威:谁挂上去的就是谁),为空时退回解析正文标记——
|
||||
// 历史文档与经旧版路径写入的文档只有标记、没有引用。
|
||||
func (a *Agent) docMediaContext(docID, content string) string {
|
||||
if a.mediaStore == nil {
|
||||
return ""
|
||||
}
|
||||
digests, err := a.mediaStore.Refs(media.OwnerDocument, docID)
|
||||
if err != nil {
|
||||
log.Printf("[media] 读取文档 %s 的媒体引用失败: %v", docID, err)
|
||||
}
|
||||
if len(digests) == 0 {
|
||||
for _, short := range extractMediaDigests(content) {
|
||||
full, err := a.mediaStore.ResolvePrefix(short)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
digests = append(digests, full)
|
||||
}
|
||||
}
|
||||
var lines []string
|
||||
for _, d := range digests {
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines, ";")
|
||||
}
|
||||
|
||||
// resolveMediaDigests 把模型给的(多为短)digest 补全成完整 digest。
|
||||
//
|
||||
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest,也可能内容已被
|
||||
// 容量 GC 淘汰。挂一条对不上的引用比不挂更糟——digest 进了 media_refs 主键,
|
||||
// 错了则 DropOwner 永远匹配不到它,那是一条永久泄漏的引用。
|
||||
func (a *Agent) resolveMediaDigests(digests []string) []string {
|
||||
if a.mediaStore == nil || len(digests) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(digests))
|
||||
var out []string
|
||||
for _, d := range digests {
|
||||
full, err := a.mediaStore.ResolvePrefix(d)
|
||||
if err != nil {
|
||||
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
|
||||
continue
|
||||
}
|
||||
if seen[full] {
|
||||
continue
|
||||
}
|
||||
seen[full] = true
|
||||
out = append(out, full)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bindDocMedia 把一组完整 digest 挂到文档 owner 上,返回成功条数。
|
||||
//
|
||||
// 与 releaseDocMedia 成对:文档归档进 L3 时释放,文档写入时绑定。
|
||||
// 只绑不放会让磁盘只增不减,只放不绑会让 GC 误删仍被引用的内容。
|
||||
func (a *Agent) bindDocMedia(docID string, digests []string) int {
|
||||
if a.mediaStore == nil || docID == "" || len(digests) == 0 {
|
||||
return 0
|
||||
}
|
||||
bound := 0
|
||||
for _, d := range digests {
|
||||
if err := a.mediaStore.AddRef(d, media.OwnerDocument, docID); err != nil {
|
||||
log.Printf("[media] 文档引用绑定失败 (%s → doc %s): %v", shortDigest(d), docID, err)
|
||||
continue
|
||||
}
|
||||
bound++
|
||||
}
|
||||
if bound > 0 {
|
||||
log.Printf("[media] 文档 %s 绑定 %d 个媒体引用", docID, bound)
|
||||
}
|
||||
return bound
|
||||
}
|
||||
|
||||
// commitTriplesWithMedia 提交三元组并绑定句子里的媒体引用。
|
||||
//
|
||||
// 包一层是为了让所有「三元组入库」的调用点用同一条路径拿到媒体绑定,
|
||||
@ -357,19 +481,9 @@ func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
|
||||
}
|
||||
var parts []string
|
||||
for _, d := range digests {
|
||||
it, err := a.mediaStore.Stat(d)
|
||||
if err != nil || it == nil {
|
||||
continue
|
||||
if line := a.mediaMarkerLine(d); line != "" {
|
||||
parts = append(parts, line)
|
||||
}
|
||||
label := string(it.Kind)
|
||||
if it.MIME != "" {
|
||||
label = it.MIME
|
||||
}
|
||||
desc := it.Description
|
||||
if desc == "" {
|
||||
desc = "(未描述)"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("[%s %s] %s", label, shortDigest(d), desc))
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, ";")))
|
||||
|
||||
Reference in New Issue
Block a user