Files
HomeAgent/internal/plugin/proc/capability.go
JianFeeeee 687e5655bc feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 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 插件源码零改动通过类型检查。
2026-09-06 09:51:31 +08:00

275 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proc
import (
"fmt"
"sort"
"strings"
)
// 权限梯度:外部插件可调用哪些内核 method§3.8)。
//
// 迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
// **意外产物**——C 结构体不好传函数指针,于是这些能力自然到不了插件侧。
// 那是运气,不是策略:任何人给 dispatch 加个 case 就能捅穿。
//
// 迁移后要变成**显式声明并强制的策略**,分三道闸:
//
// 1. 类型层internal/plugin/proc_core.goprocCore 用命名字段持有内核 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(), ", "))
}