mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +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 插件源码零改动通过类型检查。
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package proc
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// 权限梯度测试(§3.8)。
|
||
//
|
||
// 守住的核心性质:外部插件拿不到内核内部机制,不是因为 C ABI 传不了
|
||
// 函数指针(那是运气),而是因为这里**显式声明并强制**了边界。
|
||
|
||
// 每个 method 都必须登记能力归属。
|
||
//
|
||
// ❗ 这是本文件最重要的测试:漏登记的 method 会按 CapCore 放行,
|
||
// 等于绕过整套权限检查。新增 method 时忘了登记,这里会当场报出来。
|
||
func TestCapability_AllMethodsClassified(t *testing.T) {
|
||
// 与 protocol.go 的 method 常量对齐。内核→插件的 7 个调用不经 Handle,
|
||
// 故不需要能力归属。
|
||
kernelToPlugin := map[string]bool{
|
||
MethodPluginInit: true,
|
||
MethodPluginStart: true,
|
||
MethodPluginStop: true,
|
||
MethodToolInvoke: true,
|
||
MethodStageInvoke: true,
|
||
MethodOutputInvoke: true,
|
||
}
|
||
|
||
// 插件→内核的全部 method(手工清单,与 protocol.go 对照)
|
||
pluginToKernel := []string{
|
||
MethodHandshake,
|
||
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
|
||
MethodAPIRegister, MethodInputRegister,
|
||
MethodIOInjectText, MethodIOInjectInterrupt, MethodIOInjectTextNoMem,
|
||
MethodIOInjectSync, MethodIOSetToolBlocks,
|
||
MethodIOInjectMedia, MethodIOInjectMediaSync, MethodIOInjectInterruptMedia,
|
||
MethodLifecycleAutoRestart,
|
||
MethodMemoryRecall, MethodMemoryCommit, MethodMemoryIntrospect,
|
||
MethodMemoryMerge, MethodMemoryPurge,
|
||
MethodDocQuery, MethodDocInsert, MethodDocRemove, MethodDocStats,
|
||
MethodDocInsertMedia,
|
||
MethodKnowledgeSearch, MethodKnowledgeAdd, MethodKnowledgeList,
|
||
MethodTextMemoryAppend,
|
||
MethodSettingsGet, MethodSettingsSet, MethodSettingsRegisterDef,
|
||
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
|
||
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
|
||
MethodSettingsList, MethodSettingsDefs, MethodSettingsDump,
|
||
MethodSettingsPlugins, MethodSettingsDataDir,
|
||
MethodLLMListSources, MethodLLMSetSource, MethodLLMCurrentSource,
|
||
MethodSocialGetPerson, MethodSocialGetNetwork, MethodSocialGetTrait,
|
||
MethodSocialGetRelation, MethodSocialListPersons,
|
||
MethodEventsSubscribe, MethodEventsUnsubscribe,
|
||
MethodPluginReloadOne, MethodPluginListLoaded, MethodPluginIsDisabled,
|
||
MethodStageLock, MethodStageUnlock,
|
||
}
|
||
|
||
for _, m := range pluginToKernel {
|
||
if kernelToPlugin[m] {
|
||
continue
|
||
}
|
||
if _, ok := capabilityOf(m); !ok {
|
||
t.Errorf("method %q 未登记能力归属 —— 会按 CapCore 放行,绕过权限检查", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 未声明 capabilities 的插件不受限(存量插件向后兼容)。
|
||
//
|
||
// 若空声明当作最小权限,17 个存量插件会全部失去 IO 注入/记忆读写而静默降级。
|
||
func TestCapability_EmptyDeclarationIsUnrestricted(t *testing.T) {
|
||
s := newCapabilitySet(nil)
|
||
for _, m := range []string{
|
||
MethodIOInjectText, MethodMemoryCommit, MethodPluginReloadOne,
|
||
MethodSettingsSetCore, MethodEventsSubscribe,
|
||
} {
|
||
if ok, _ := s.allows(m); !ok {
|
||
t.Errorf("未声明 capabilities 时 %q 应放行(存量插件兼容)", m)
|
||
}
|
||
}
|
||
|
||
s2 := newCapabilitySet([]string{})
|
||
if ok, _ := s2.allows(MethodMemoryCommit); !ok {
|
||
t.Error("空数组也应视为不受限")
|
||
}
|
||
}
|
||
|
||
// 声明了能力后,未声明的组被拒。
|
||
func TestCapability_DeclaredSetRestrictsOthers(t *testing.T) {
|
||
// 只声明 io:能注入,但不能碰记忆/插件管理/核心配置
|
||
s := newCapabilitySet([]string{"io"})
|
||
|
||
allowed := []string{MethodIOInjectText, MethodIOInjectSync}
|
||
for _, m := range allowed {
|
||
if ok, _ := s.allows(m); !ok {
|
||
t.Errorf("声明 io 后 %q 应放行", m)
|
||
}
|
||
}
|
||
|
||
denied := map[string]Capability{
|
||
MethodMemoryCommit: CapMemory,
|
||
MethodKnowledgeAdd: CapKnowledge,
|
||
MethodPluginReloadOne: CapPluginMgr,
|
||
MethodSettingsSetCore: CapCrossPluginSettings,
|
||
MethodEventsSubscribe: CapEvents,
|
||
MethodLLMSetSource: CapLLM,
|
||
MethodTextMemoryAppend: CapTextMemory,
|
||
MethodDocInsert: CapDocMemory,
|
||
MethodSocialGetPerson: CapSocial,
|
||
}
|
||
for m, wantCap := range denied {
|
||
ok, gotCap := s.allows(m)
|
||
if ok {
|
||
t.Errorf("未声明 %q 时 %q 应被拒", wantCap, m)
|
||
}
|
||
if gotCap != wantCap {
|
||
t.Errorf("%q 的能力归属 = %q,期望 %q", m, gotCap, wantCap)
|
||
}
|
||
}
|
||
}
|
||
|
||
// core 能力始终可用,无需声明。
|
||
//
|
||
// 没有它插件无法注册工具、读写自己的配置、参与 stage 锁仲裁——
|
||
// 即完全无法工作。
|
||
func TestCapability_CoreAlwaysAllowed(t *testing.T) {
|
||
s := newCapabilitySet([]string{"io"}) // 只声明 io
|
||
|
||
for _, m := range []string{
|
||
MethodHandshake,
|
||
MethodToolRegister, MethodStageRegister, MethodOutputRegister,
|
||
MethodInputRegister, MethodAPIRegister,
|
||
MethodStageLock, MethodStageUnlock,
|
||
MethodSettingsGet, MethodSettingsSet, MethodSettingsList,
|
||
MethodSettingsDefs, MethodSettingsRegisterDef, MethodSettingsDataDir,
|
||
MethodLifecycleAutoRestart,
|
||
MethodIOSetToolBlocks,
|
||
} {
|
||
if ok, _ := s.allows(m); !ok {
|
||
t.Errorf("core 能力 %q 应始终放行", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 自身配置读写属 core,跨插件/核心配置需显式声明。
|
||
//
|
||
// 这个区分是有意的:读写自己的配置是插件正常工作所需;
|
||
// 读写别人的配置能改别人行为,读写核心配置能改内核行为。
|
||
func TestCapability_SettingsScopeSeparation(t *testing.T) {
|
||
s := newCapabilitySet([]string{}) // 不受限,先确认归属正确
|
||
|
||
own := []string{MethodSettingsGet, MethodSettingsSet, MethodSettingsList}
|
||
for _, m := range own {
|
||
if cap, _ := capabilityOf(m); cap != CapCore {
|
||
t.Errorf("%q 应属 core(自身配置),实际 %q", m, cap)
|
||
}
|
||
}
|
||
|
||
cross := []string{
|
||
MethodSettingsGetCore, MethodSettingsSetCore, MethodSettingsListCore,
|
||
MethodSettingsGetPlugin, MethodSettingsSetPlugin, MethodSettingsListPlugin,
|
||
MethodSettingsDump, MethodSettingsPlugins,
|
||
}
|
||
for _, m := range cross {
|
||
if cap, _ := capabilityOf(m); cap != CapCrossPluginSettings {
|
||
t.Errorf("%q 应属 settings_cross,实际 %q", m, cap)
|
||
}
|
||
}
|
||
_ = s
|
||
}
|
||
|
||
// 被拒时错误消息必须可操作:说清缺什么、怎么补。
|
||
//
|
||
// 针对的是 C ABI 时代的一类真实故障:case 23/24 返回成功但永远收不到事件,
|
||
// 插件作者无从得知。
|
||
func TestCapability_DeniedErrorIsActionable(t *testing.T) {
|
||
err := errCapabilityDenied("demo", MethodMemoryCommit, CapMemory)
|
||
msg := err.Error()
|
||
|
||
for _, want := range []string{
|
||
"demo", // 哪个插件
|
||
MethodMemoryCommit, // 哪个调用
|
||
string(CapMemory), // 缺什么能力
|
||
"capabilities", // 在哪声明
|
||
"plugin.json", // 声明在哪个文件
|
||
} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Errorf("错误消息应含 %q,实际: %s", want, msg)
|
||
}
|
||
}
|
||
|
||
// 还应列出可用能力名,避免作者猜
|
||
if !strings.Contains(msg, string(CapEvents)) {
|
||
t.Errorf("错误消息应列出可选能力(如 %q),实际: %s", CapEvents, msg)
|
||
}
|
||
}
|
||
|
||
// coreHandler 在 Handle 入口强制权限,被拒的调用不进 switch。
|
||
func TestCapability_HandleEnforcesAtRPCBoundary(t *testing.T) {
|
||
core := newFakeCore()
|
||
h := &coreHandler{
|
||
sdk: core,
|
||
name: "restricted",
|
||
caps: newCapabilitySet([]string{"io"}), // 不含 memory
|
||
}
|
||
|
||
_, err := h.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`))
|
||
if err == nil {
|
||
t.Fatal("未声明 memory 能力时 memory.commit 应被拒")
|
||
}
|
||
if !strings.Contains(err.Error(), "被拒") {
|
||
t.Errorf("应是权限拒绝错误,实际: %v", err)
|
||
}
|
||
|
||
// 已声明的能力照常走到 switch(这里 memory 为 nil,会返回 errUnavailable,
|
||
// 但错误类型不同——证明请求进了 switch 而非被权限拦下)
|
||
h2 := &coreHandler{
|
||
sdk: core,
|
||
name: "allowed",
|
||
caps: newCapabilitySet([]string{"memory"}),
|
||
}
|
||
_, err2 := h2.Handle(MethodMemoryCommit, json.RawMessage(`{"triples":[]}`))
|
||
if err2 != nil && strings.Contains(err2.Error(), "被拒") {
|
||
t.Errorf("声明了 memory 后不应被权限拒绝,实际: %v", err2)
|
||
}
|
||
}
|
||
|
||
// 刻意不提供的内核内部机制必须有明确记录。
|
||
//
|
||
// 这些没有对应 method 常量——不是忘了加,是决定不加。
|
||
// 列表存在本身就是「这是策略而非疏漏」的证据。
|
||
func TestCapability_WithheldListIsDocumented(t *testing.T) {
|
||
withheld := WithheldCapabilities()
|
||
|
||
// §3.8 明确列为「不提供」的
|
||
for _, name := range []string{"SelftestAPI", "SupervisorAPI", "TrackerAPI"} {
|
||
reason, ok := withheld[name]
|
||
if !ok {
|
||
t.Errorf("%s 应在 withheld 清单中(§3.8 明确不提供)", name)
|
||
continue
|
||
}
|
||
if reason == "" {
|
||
t.Errorf("%s 缺少不提供的理由", name)
|
||
}
|
||
}
|
||
|
||
// 每一项都必须有理由,否则读代码的人无从判断边界为何在此
|
||
for name, reason := range withheld {
|
||
if strings.TrimSpace(reason) == "" {
|
||
t.Errorf("withheld 项 %q 缺少理由", name)
|
||
}
|
||
}
|
||
|
||
// 这些能力不应被任何 method 暴露。
|
||
//
|
||
// 匹配用的是去掉 API 后缀的词根 + 词边界,而非直接子串:
|
||
// 直接子串匹配会把 tool.register / io.setToolBlocks 误判为泄露 ToolAPI,
|
||
// 而那两个是合法开放的(注册自己的工具、设置自己工具的返回块)。
|
||
// 真正要拦的是形如 "tool.unregister" / "tracker.diff" 这类新增的越权 method。
|
||
forbiddenPrefixes := map[string]string{
|
||
"selftest.": "SelftestAPI",
|
||
"supervisor.": "SupervisorAPI",
|
||
"tracker.": "TrackerAPI",
|
||
"status.": "StatusAPI",
|
||
"adapter.": "AdapterAPI",
|
||
"config.": "ConfigAPI",
|
||
"indexer.": "IndexerAPI",
|
||
"outputchan.": "OutputChanRaw",
|
||
"events.publish": "EventPublish",
|
||
}
|
||
for m := range methodCapability {
|
||
lower := strings.ToLower(m)
|
||
for prefix, capName := range forbiddenPrefixes {
|
||
if strings.HasPrefix(lower, prefix) {
|
||
t.Errorf("method %q 暴露了刻意不提供的能力 %q", m, capName)
|
||
}
|
||
}
|
||
// 工具表直接操作:注册自己的工具合法,注销别人的不合法
|
||
if strings.Contains(lower, "unregister") {
|
||
t.Errorf("method %q 暴露了工具注销能力(ToolAPI,刻意不提供)", m)
|
||
}
|
||
}
|
||
}
|
||
|
||
// KnownCapabilities 不含 core(无需声明),且与 methodCapability 一致。
|
||
func TestCapability_KnownListExcludesCore(t *testing.T) {
|
||
known := KnownCapabilities()
|
||
for _, k := range known {
|
||
if k == string(CapCore) {
|
||
t.Error("KnownCapabilities 不应含 core(无需声明)")
|
||
}
|
||
}
|
||
|
||
// 每个非 core 能力都应可声明
|
||
declared := map[string]bool{}
|
||
for _, k := range known {
|
||
declared[k] = true
|
||
}
|
||
for _, cap := range methodCapability {
|
||
if cap == CapCore {
|
||
continue
|
||
}
|
||
if !declared[string(cap)] {
|
||
t.Errorf("能力 %q 在 methodCapability 中使用但不在 KnownCapabilities 里", cap)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 未知 method 走 Handle 的兜底分支,不因权限检查提前返回误导性错误。
|
||
func TestCapability_UnknownMethodFallsThrough(t *testing.T) {
|
||
h := &coreHandler{
|
||
sdk: newFakeCore(),
|
||
name: "demo",
|
||
caps: newCapabilitySet([]string{"io"}),
|
||
}
|
||
_, err := h.Handle("nonexistent.method", nil)
|
||
if err == nil {
|
||
t.Fatal("未知 method 应报错")
|
||
}
|
||
// 应是「未知 method」而非「权限被拒」——否则作者会以为是漏声明能力
|
||
if strings.Contains(err.Error(), "被拒") {
|
||
t.Errorf("未知 method 不应报权限错误,实际: %v", err)
|
||
}
|
||
}
|