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:
JianFeeeee
2026-09-06 09:51:31 +08:00
parent a72b24c4a6
commit 9bab3a74c8
28 changed files with 3229 additions and 267 deletions

View File

@ -25,13 +25,18 @@ type Relation struct {
}
// Triple represents a subject-relation-object triple for the knowledge graph.
//
// SentenceText 是这条三元组的原句,会写进 sentences 表;媒体引用挂在句子上,
// 所以 MediaDigests 非空时内核会保证句子存在(不给就自动合成一句)。
type Triple struct {
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
Subject string `json:"subject"`
Relation string `json:"relation"`
Object string `json:"object"`
Confidence float64 `json:"confidence,omitempty"`
SubjectType string `json:"subject_type,omitempty"`
ObjectType string `json:"object_type,omitempty"`
SentenceText string `json:"sentence_text,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
}
// TextMemoryAPI provides access to chronological text event storage.
@ -40,27 +45,52 @@ type TextMemoryAPI interface {
}
// TextEvent represents a single text memory event.
// MediaAttachment 描述一份与记忆关联的媒体。
//
// 两个方向共用一个类型:
// - 写入(InsertWithMedia):给 Data + MIME 就是新内容;只给 Digest 则是引用已有内容。
// - 读出(Query):内核只填 Digest/MIME/Description,**不回 Data**——
// 一次检索可能命中几十张图,把字节全塞回插件会把 ABI 消息撑爆。
// 需要字节时拿 Digest 单独取。
type MediaAttachment struct {
Digest string `json:"digest,omitempty"`
MIME string `json:"mime,omitempty"`
Data []byte `json:"data,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
type TextEvent struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
Channel string `json:"channel,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// DocMemoryAPI provides access to the document vector store.
type DocMemoryAPI interface {
Query(text string, topK int) []*Doc
Insert(doc *Doc) error
// InsertWithMedia 写入文档并关联媒体。attachments 里带 Data 的会落进
// 内容寻址存储(相同字节只存一份),只带 Digest 的直接引用已有内容。
// 插件无需自己拼标记:内核会把 `[mime <短 digest>] <描述>` 补进 Content,
// 让向量检索和后续蒸馏都能看到这份媒体。
InsertWithMedia(doc *Doc, attachments []MediaAttachment) error
Remove(id string)
Stats() map[string]interface{}
}
// Doc represents a document in the document store.
//
// MediaDigests / Attachments 在 Query 返回时由内核填充(仅元数据,不带字节)。
type Doc struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
MediaDigests []string `json:"media_digests,omitempty"`
Attachments []MediaAttachment `json:"attachments,omitempty"`
}
// SocialAPI provides read-only access to the social graph (person profiles and relationships).
@ -75,9 +105,9 @@ type SocialAPI interface {
// PersonProfile represents a person's complete profile (traits + social relations).
type PersonProfile struct {
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
Name string `json:"name"`
Traits map[string]string `json:"traits,omitempty"`
Relations []SocialRelation `json:"relations,omitempty"`
}
// SocialRelation represents a social relationship between two persons.