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 插件源码零改动通过类型检查。
275 lines
10 KiB
Go
275 lines
10 KiB
Go
package proc
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 权限梯度:外部插件可调用哪些内核 method(§3.8)。
|
||
//
|
||
// 迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
|
||
// **意外产物**——C 结构体不好传函数指针,于是这些能力自然到不了插件侧。
|
||
// 那是运气,不是策略:任何人给 dispatch 加个 case 就能捅穿。
|
||
//
|
||
// 迁移后要变成**显式声明并强制的策略**,分三道闸:
|
||
//
|
||
// 1. 类型层(internal/plugin/proc_core.go):procCore 用命名字段持有内核 SDK,
|
||
// 不嵌入 —— 未在收窄面显式写出的方法根本不存在,编译期就拿不到。
|
||
// 2. 能力集(本文件):method 划入 capability 组,manifest 未声明的组被拒。
|
||
// 3. RPC 边界:被拒时返回**明确错误**而非静默忽略——插件作者能立刻知道
|
||
// 「这个能力没给我」,而不是调用成功但什么也没发生。
|
||
//
|
||
// 第 3 条针对的是一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现,
|
||
// 返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」)。
|
||
|
||
// Capability 是一组相关 method 的权限单元。
|
||
//
|
||
// 粒度选择:按**能力域**而非单个 method 划分。逐 method 授权看似更精细,
|
||
// 但插件作者要在 manifest 里列 60 个名字,且内核加 method 时所有 manifest 都得改。
|
||
type Capability string
|
||
|
||
const (
|
||
// CapCore 是无需声明即可用的基础能力:注册自身工具/阶段/通道、
|
||
// 读写自己的配置、共享段锁仲裁。没有这些插件无法工作。
|
||
CapCore Capability = "core"
|
||
|
||
// CapIO 注入输入到 agent 主循环(可影响对话流)。
|
||
CapIO Capability = "io"
|
||
|
||
// CapMemory 图记忆读写。
|
||
CapMemory Capability = "memory"
|
||
|
||
// CapDocMemory 文档记忆读写。
|
||
CapDocMemory Capability = "doc_memory"
|
||
|
||
// CapKnowledge 知识库读写。
|
||
CapKnowledge Capability = "knowledge"
|
||
|
||
// CapTextMemory 文本记忆追加。
|
||
CapTextMemory Capability = "text_memory"
|
||
|
||
// CapLLM 切换 LLM 源(影响全局行为)。
|
||
CapLLM Capability = "llm"
|
||
|
||
// CapSocial 社交图读取。
|
||
CapSocial Capability = "social"
|
||
|
||
// CapEvents 订阅内核事件。
|
||
CapEvents Capability = "events"
|
||
|
||
// CapPluginMgr 管理其他插件(重载/查询禁用状态)。
|
||
//
|
||
// 这是**最敏感**的一组:能重载其他插件意味着能间接影响它们的状态。
|
||
CapPluginMgr Capability = "plugin_mgr"
|
||
|
||
// CapCrossPluginSettings 读写**其他插件**的配置与内核核心配置。
|
||
//
|
||
// 与 CapCore 里的「读写自己的配置」区分开:跨插件配置读写能改别人的行为,
|
||
// 核心配置读写能改内核行为。
|
||
CapCrossPluginSettings Capability = "settings_cross"
|
||
)
|
||
|
||
// methodCapability 把每个 method 映射到所需能力。
|
||
//
|
||
// ❗ 新增 method 时必须在此登记,否则 capabilityOf 返回 CapCore
|
||
// (最宽松),等于绕过权限检查。checkAllMethodsClassified 测试守着这一点。
|
||
var methodCapability = map[string]Capability{
|
||
// ---- 基础能力(无需声明)----
|
||
MethodHandshake: CapCore,
|
||
MethodToolRegister: CapCore,
|
||
MethodStageRegister: CapCore,
|
||
MethodOutputRegister: CapCore,
|
||
MethodAPIRegister: CapCore,
|
||
MethodInputRegister: CapCore,
|
||
MethodStageLock: CapCore,
|
||
MethodStageUnlock: CapCore,
|
||
// 自身配置读写与元信息属基础能力
|
||
MethodSettingsGet: CapCore,
|
||
MethodSettingsSet: CapCore,
|
||
MethodSettingsList: CapCore,
|
||
MethodSettingsDefs: CapCore,
|
||
MethodSettingsRegisterDef: CapCore,
|
||
MethodSettingsDataDir: CapCore,
|
||
// 生命周期自述(插件声明自己是否可自动重启)
|
||
MethodLifecycleAutoRestart: CapCore,
|
||
// 多模态内容块注入是工具返回值的一部分,不越权
|
||
MethodIOSetToolBlocks: CapCore,
|
||
|
||
// ---- IO 注入 ----
|
||
MethodIOInjectText: CapIO,
|
||
MethodIOInjectInterrupt: CapIO,
|
||
MethodIOInjectTextNoMem: CapIO,
|
||
MethodIOInjectSync: CapIO,
|
||
// 带媒体的注入与纯文本注入同一权限组:能不能发起一轮对话是 IO 能力,
|
||
// 带不带图不改变这个判断。
|
||
MethodIOInjectMedia: CapIO,
|
||
MethodIOInjectMediaSync: CapIO,
|
||
MethodIOInjectInterruptMedia: CapIO,
|
||
|
||
// ---- 图记忆 ----
|
||
MethodMemoryRecall: CapMemory,
|
||
MethodMemoryCommit: CapMemory,
|
||
MethodMemoryIntrospect: CapMemory,
|
||
MethodMemoryMerge: CapMemory,
|
||
MethodMemoryPurge: CapMemory,
|
||
|
||
// ---- 文档记忆 ----
|
||
MethodDocQuery: CapDocMemory,
|
||
MethodDocInsert: CapDocMemory,
|
||
MethodDocRemove: CapDocMemory,
|
||
MethodDocStats: CapDocMemory,
|
||
// 带媒体写入与普通写入同权限:都是往文档记忆里写东西。
|
||
MethodDocInsertMedia: CapDocMemory,
|
||
|
||
// ---- 知识库 ----
|
||
MethodKnowledgeSearch: CapKnowledge,
|
||
MethodKnowledgeAdd: CapKnowledge,
|
||
MethodKnowledgeList: CapKnowledge,
|
||
|
||
// ---- 文本记忆 ----
|
||
MethodTextMemoryAppend: CapTextMemory,
|
||
|
||
// ---- LLM ----
|
||
MethodLLMListSources: CapLLM,
|
||
MethodLLMSetSource: CapLLM,
|
||
MethodLLMCurrentSource: CapLLM,
|
||
|
||
// ---- 社交图 ----
|
||
MethodSocialGetPerson: CapSocial,
|
||
MethodSocialGetNetwork: CapSocial,
|
||
MethodSocialGetTrait: CapSocial,
|
||
MethodSocialGetRelation: CapSocial,
|
||
MethodSocialListPersons: CapSocial,
|
||
|
||
// ---- 事件 ----
|
||
MethodEventsSubscribe: CapEvents,
|
||
MethodEventsUnsubscribe: CapEvents,
|
||
|
||
// ---- 插件管理 ----
|
||
MethodPluginReloadOne: CapPluginMgr,
|
||
MethodPluginListLoaded: CapPluginMgr,
|
||
MethodPluginIsDisabled: CapPluginMgr,
|
||
|
||
// ---- 跨插件 / 核心配置 ----
|
||
MethodSettingsGetCore: CapCrossPluginSettings,
|
||
MethodSettingsSetCore: CapCrossPluginSettings,
|
||
MethodSettingsListCore: CapCrossPluginSettings,
|
||
MethodSettingsGetPlugin: CapCrossPluginSettings,
|
||
MethodSettingsSetPlugin: CapCrossPluginSettings,
|
||
MethodSettingsListPlugin: CapCrossPluginSettings,
|
||
MethodSettingsDump: CapCrossPluginSettings,
|
||
MethodSettingsPlugins: CapCrossPluginSettings,
|
||
}
|
||
|
||
// withheldCapabilities 是**刻意不提供给外部插件**的内核内部机制(§3.8 最后一行)。
|
||
//
|
||
// 这些没有对应的 method 常量——不是"忘了加",是决定不加。
|
||
// 列在这里是为了让决策可见:读代码的人能看到边界在哪,而不是从
|
||
// 「protocol.go 里没有」这个负面事实去推断。
|
||
//
|
||
// 类型层已经挡住了(procCore 不暴露这些访问器),本表是文档 + 测试锚点。
|
||
var withheldCapabilities = map[string]string{
|
||
"SelftestAPI": "虚拟实例自检 —— 能构造内核实例,等于绕过全部权限边界",
|
||
"SupervisorAPI": "进程监管 —— 能启停 worker,等于控制内核生命周期",
|
||
"TrackerAPI": "变更追踪 —— 内核 overlay 文件系统的内部机制",
|
||
"StatusAPI": "内核状态面 —— 暴露内部运行时细节",
|
||
"AdapterAPI": "LLM 适配器管理 —— 能改写请求/响应链路",
|
||
"ConfigAPI": "内核配置对象 —— 与 settings 的受控读写不同,这是直接持有",
|
||
"ToolAPI": "工具表直接操作 —— 能注销其他插件的工具(注册自己的工具走 tool.register,那是 core)",
|
||
"IndexerAPI": "记忆索引器 —— 内核记忆管线的内部组件",
|
||
"OutputChanRaw": "输出通道原始消费 —— 已由 output.invoke 的声明式注册替代",
|
||
"EventPublish": "事件发布 —— 只给订阅(events.subscribe),不给伪造内核事件",
|
||
}
|
||
|
||
// capabilityOf 返回 method 所需能力。
|
||
//
|
||
// 未登记的 method 返回 (CapCore, false):ok=false 让调用方能区分
|
||
// 「明确划为基础能力」与「漏登记」,测试据此拦住漏登记。
|
||
func capabilityOf(method string) (Capability, bool) {
|
||
cap, ok := methodCapability[method]
|
||
if !ok {
|
||
return CapCore, false
|
||
}
|
||
return cap, true
|
||
}
|
||
|
||
// capabilitySet 是某个插件被授予的能力集合。
|
||
type capabilitySet struct {
|
||
granted map[Capability]bool
|
||
// unrestricted 为真时跳过检查(未声明 capabilities 的插件,向后兼容)。
|
||
unrestricted bool
|
||
}
|
||
|
||
// newCapabilitySet 从 manifest 声明构造能力集。
|
||
//
|
||
// **空声明 = 不受限**,而不是「只有 core」。理由:17 个存量插件的 plugin.json
|
||
// 都没有 capabilities 字段,若空声明当作最小权限,它们会全部失去 IO 注入、
|
||
// 记忆读写等能力而**静默降级**——这违反「外部插件零改动」的硬约束。
|
||
//
|
||
// 收紧的路径是让插件显式声明,而非默默拒绝老插件。
|
||
func newCapabilitySet(declared []string) *capabilitySet {
|
||
if len(declared) == 0 {
|
||
return &capabilitySet{unrestricted: true}
|
||
}
|
||
s := &capabilitySet{granted: map[Capability]bool{CapCore: true}}
|
||
for _, d := range declared {
|
||
s.granted[Capability(strings.TrimSpace(d))] = true
|
||
}
|
||
return s
|
||
}
|
||
|
||
// allows 判断是否允许调用某 method。
|
||
func (s *capabilitySet) allows(method string) (bool, Capability) {
|
||
cap, registered := capabilityOf(method)
|
||
if !registered {
|
||
// 漏登记的 method 按基础能力放行(保守:不因内核疏漏拦住插件),
|
||
// 但由测试保证这种情况不存在。
|
||
return true, CapCore
|
||
}
|
||
if s == nil || s.unrestricted {
|
||
return true, cap
|
||
}
|
||
if cap == CapCore {
|
||
return true, cap
|
||
}
|
||
return s.granted[cap], cap
|
||
}
|
||
|
||
// KnownCapabilities 返回全部可声明的能力名(供 manifest 校验与文档生成)。
|
||
func KnownCapabilities() []string {
|
||
seen := map[Capability]bool{}
|
||
for _, c := range methodCapability {
|
||
seen[c] = true
|
||
}
|
||
out := make([]string, 0, len(seen))
|
||
for c := range seen {
|
||
if c == CapCore {
|
||
continue // core 无需声明
|
||
}
|
||
out = append(out, string(c))
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// WithheldCapabilities 返回刻意不提供的能力清单(供文档与诊断)。
|
||
func WithheldCapabilities() map[string]string {
|
||
out := make(map[string]string, len(withheldCapabilities))
|
||
for k, v := range withheldCapabilities {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// errCapabilityDenied 构造被拒错误。
|
||
//
|
||
// 消息包含三要素:被拒的 method、缺的能力名、如何补救。
|
||
// 静默忽略或含糊的「失败」会让插件作者以为是自己参数错了。
|
||
func errCapabilityDenied(plugin, method string, cap Capability) error {
|
||
return fmt.Errorf(
|
||
"插件 %s 调用 %s 被拒:缺少 %q 能力。"+
|
||
"请在 plugin.json 的 capabilities 数组中声明它(可用能力:%s)",
|
||
plugin, method, cap, strings.Join(KnownCapabilities(), ", "))
|
||
}
|