mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +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 插件源码零改动通过类型检查。
189 lines
6.1 KiB
Go
189 lines
6.1 KiB
Go
package plugin
|
||
|
||
import (
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin/proc"
|
||
isdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||
)
|
||
|
||
// procCore 把内核的 *internal/sdk.PluginSDK 收窄成子进程插件可见的能力面。
|
||
//
|
||
// ❗ **必须用命名字段,不能嵌入** `*isdk.PluginSDK`:嵌入会让全部方法被提升,
|
||
// 外部插件通道就能经类型断言拿到 Supervisor()/Tracker()/Adapter()/Indexer()
|
||
// 这些内核内部机制——权限梯度退化成纸面约定。命名字段下只有下面显式写出的
|
||
// 方法存在,这才是 §3.8 说的「从 C ABI 表达能力的意外产物变成显式声明并强制的策略」。
|
||
//
|
||
// 另一个必要性:internal/sdk 的接口是公开 SDK 的**超集**(isdk.KnowledgeAPI
|
||
// 内嵌 pubsdk.KnowledgeAPI 再加 Stats()/Remove(),isdk.MemoryAPI 加 GraphData(),
|
||
// isdk.LLMAPI 加 Chat()/ReloadFromConfig()),Go 方法签名精确匹配下
|
||
// *isdk.PluginSDK 本就不满足 proc.CoreSDK。
|
||
//
|
||
// 内置插件走的仍是原路径(直接持 *isdk.PluginSDK,拿到全量接口),不受影响。
|
||
type procCore struct {
|
||
sdk *isdk.PluginSDK
|
||
}
|
||
|
||
// newProcCore 包装内核 SDK 供子进程插件使用。
|
||
func newProcCore(s *isdk.PluginSDK) procCore { return procCore{sdk: s} }
|
||
|
||
func (c procCore) PluginName() string { return c.sdk.PluginName() }
|
||
|
||
// ---- 能力访问器:内部超集接口 → 公开 SDK 接口 ----
|
||
//
|
||
// nil 保护是必要的:corehandler 用 `if xxx == nil` 判断能力不可用并返回
|
||
// errUnavailable,若把「类型化的 nil」透过去,判空会失效——插件收到的是
|
||
// panic 而不是"能力不可用"。
|
||
|
||
func (c procCore) Settings() pubsdk.SettingsAPI {
|
||
if s := c.sdk.Settings(); s != nil {
|
||
return s
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) Memory() pubsdk.MemoryAPI {
|
||
if m := c.sdk.Memory(); m != nil {
|
||
return m
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) TextMemory() pubsdk.TextMemoryAPI {
|
||
if m := c.sdk.TextMemory(); m != nil {
|
||
return m
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) DocMemory() pubsdk.DocMemoryAPI {
|
||
if m := c.sdk.DocMemory(); m != nil {
|
||
return m
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) Knowledge() pubsdk.KnowledgeAPI {
|
||
if k := c.sdk.Knowledge(); k != nil {
|
||
return k
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) LLM() pubsdk.LLMAPI {
|
||
if l := c.sdk.LLM(); l != nil {
|
||
return l
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) Social() pubsdk.SocialAPI {
|
||
if s := c.sdk.Social(); s != nil {
|
||
return s
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c procCore) PluginMgr() pubsdk.PluginMgrAPI {
|
||
if m := c.sdk.PluginMgr(); m != nil {
|
||
return m
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- 注册面 ----
|
||
|
||
func (c procCore) RegisterTool(name string, def pubsdk.ToolDef, handler pubsdk.ToolHandler) error {
|
||
return c.sdk.RegisterTool(name, def, handler)
|
||
}
|
||
|
||
func (c procCore) RegisterStage(stage pubsdk.Stage, handler pubsdk.StageHandler, scope ...pubsdk.StageScope) {
|
||
c.sdk.RegisterStage(stage, handler, scope...)
|
||
}
|
||
|
||
func (c procCore) RegisterPluginAPI(name string) error {
|
||
return c.sdk.RegisterPluginAPI(name)
|
||
}
|
||
|
||
func (c procCore) RegisterOutputChannel(name string, caps int, desc string, def pubsdk.ChannelDef, handler pubsdk.ToolHandler) error {
|
||
return c.sdk.RegisterOutputChannel(name, caps, desc, def, handler)
|
||
}
|
||
|
||
func (c procCore) RegisterInputChannel(name string, def pubsdk.ChannelDef) error {
|
||
return c.sdk.RegisterInputChannel(name, def)
|
||
}
|
||
|
||
// ---- IO 注入 ----
|
||
|
||
func (c procCore) InjectText(source, channel, text string) {
|
||
c.sdk.InjectText(source, channel, text)
|
||
}
|
||
|
||
func (c procCore) InjectInterruptText(source, channel, text string) {
|
||
c.sdk.InjectInterruptText(source, channel, text)
|
||
}
|
||
|
||
func (c procCore) InjectTextNoMemory(source, channel, text string) {
|
||
c.sdk.InjectTextNoMemory(source, channel, text)
|
||
}
|
||
|
||
// InjectInputSync 收窄为公开 SDK 的三参数文本形态。
|
||
//
|
||
// internal/sdk.PluginSDK 的同名方法是 (source, channel, eventType, payload)
|
||
// → *agentIO.OutputEvent,暴露了内核 IO 事件结构;外部插件只该看到回复文本。
|
||
// 取值方式与 C ABI 路径一致(internal/plugin/cabi/loader.go 的 case 47)。
|
||
func (c procCore) InjectInputSync(source, channel, text string) string {
|
||
out := c.sdk.InjectInputSync(source, channel, "text", map[string]interface{}{
|
||
"content": text,
|
||
})
|
||
if out == nil {
|
||
return ""
|
||
}
|
||
reply, _ := out.Payload["content"].(string)
|
||
return reply
|
||
}
|
||
|
||
// ---- 带媒体的 IO 注入 ----
|
||
//
|
||
// 三个方法都直接转调 internal/sdk 的同名方法:那一层已经是三参数 + blocks
|
||
// 的公开形态,不像 InjectInputSync 需要收窄。
|
||
|
||
func (c procCore) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||
c.sdk.InjectInputMedia(source, channel, text, blocks)
|
||
}
|
||
|
||
func (c procCore) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
|
||
return c.sdk.InjectInputMediaSync(source, channel, text, blocks)
|
||
}
|
||
|
||
func (c procCore) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||
c.sdk.InjectInterruptMedia(source, channel, text, blocks)
|
||
}
|
||
|
||
// ---- 生命周期 ----
|
||
|
||
func (c procCore) SetAutoRestart(enabled bool) { c.sdk.SetAutoRestart(enabled) }
|
||
|
||
// 编译期确认收窄面正好满足子进程插件的能力契约。
|
||
var _ proc.CoreSDK = procCore{}
|
||
|
||
// procPluginAdapter 把 *proc.Plugin 适配到 registry 的 sdk.Plugin 接口。
|
||
//
|
||
// 两者只差 Start 的参数类型:registry 传 *isdk.PluginSDK(全量能力),
|
||
// 而子进程插件只该拿到收窄后的 proc.CoreSDK。转接在此发生,
|
||
// 权限收窄就成了**类型系统强制**的事,而不是约定(§3.8)。
|
||
//
|
||
// Name/Stop/Close 经嵌入指针提升;Close 对 registry.closeDynamic 可见,
|
||
// 故重载时能真正 kill 子进程——对比 cabi 路径的 Close 只做 dlclose,
|
||
// 而 dlclose 对 Go c-shared 是 no-op(§1.1,热重载静默失效的根因)。
|
||
type procPluginAdapter struct {
|
||
*proc.Plugin
|
||
}
|
||
|
||
// Start 把内核全量 SDK 收窄成子进程可见的能力面后启动进程。
|
||
func (a procPluginAdapter) Start(s *isdk.PluginSDK) error {
|
||
return a.Plugin.Start(newProcCore(s))
|
||
}
|
||
|
||
// 编译期确认适配器满足 registry 的插件接口。
|
||
var _ isdk.Plugin = procPluginAdapter{}
|