Files
HomeAgent/internal/plugin/proc/protocol.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

226 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 "encoding/json"
// RPC 协议定义控制面§3.2 method id 平移为 method 名)。
//
// 帧格式:**换行分隔的 JSON**NDJSON双向复用同一对 stdio 管道。
// 内核 → 插件 stdin :请求 / 响应
// 插件 → 内核 stdout请求 / 响应
//
// 为什么不用 length-prefixed 二进制帧:工具调用结果中位数仅 93B§2.5
// JSON 序列化 3-8 µs 对比 LLM 单轮 2-8 秒占 0.0001%,可读性与可调试性更值。
// 大 payload多媒体二进制走共享内存 arena不进 RPC 帧§3.3 实验 1018-22x
// 协议版本:与共享段版本独立演进。
// 插件握手时上报,内核校验——不匹配显式拒绝,避免半兼容导致的诡异行为。
const ProtocolVersion = 1
// Direction 无需显式字段:靠 Method 是否为空区分请求与响应
// (与 clawhubadapter/sidecar 的成熟做法一致)。
// Request 是一次 RPC 调用。
//
// ID 语义:
// - ID > 0 :需要响应,调用方在 pending 表等待
// - ID == 0 通知fire-and-forget被调方不得回响应
//
// 通知用于事件投递等不关心结果的路径§2.4 约束 B内核发通知绝不等待消费者
type Request struct {
ID uint64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// Response 是对 Request 的应答。Error 非空表示失败。
type Response struct {
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// ---- kernel → plugin内核调用插件对应今日 7 个 //export----
const (
// MethodPluginInit 传插件名与配置,插件构造实例但不启动。
MethodPluginInit = "plugin.init"
// MethodPluginStart 插件注册工具/阶段/通道(其间会反向发起大量 core.* 调用)。
MethodPluginStart = "plugin.start"
// MethodPluginStop 优雅停止:插件侧先跑 RunStopHandlers 再 Stop()。
MethodPluginStop = "plugin.stop"
// MethodToolInvoke 执行插件工具。
MethodToolInvoke = "tool.invoke"
// MethodStageInvoke 执行阶段处理器。数据经共享段传递,参数只带阶段名与段世代号。
MethodStageInvoke = "stage.invoke"
// MethodOutputInvoke 经插件输出通道发送。
MethodOutputInvoke = "output.invoke"
// MethodHandshake 建链首帧交换协议版本、SDK 版本、共享段规格。
MethodHandshake = "handshake"
)
// ---- plugin → kernel51 个 method id 平移§3.2----
//
// 编号本身扔掉:不再维护"下一个可用 id 是 52",加能力不用改两边常量表,
// 也不再出现 47 夹在 7 和 8 之间的历史痕迹。
const (
// 注册面(原 case 1/2/3/4/46
MethodToolRegister = "tool.register" // 1 CORE_REGISTER_TOOL
MethodStageRegister = "stage.register" // 2 CORE_REGISTER_STAGE
MethodOutputRegister = "output.register" // 3 CORE_REGISTER_OUTPUT_CH
MethodAPIRegister = "api.register" // 4 CORE_REGISTER_PLUGIN_API
MethodInputRegister = "input.register" // 46 CORE_REGISTER_INPUT_CH
// IO 注入(原 case 5/6/7/47
MethodIOInjectText = "io.injectText" // 5 CORE_INJECT_TEXT
MethodIOInjectInterrupt = "io.injectInterrupt" // 6 CORE_INJECT_INTERRUPT_TEXT
MethodIOInjectTextNoMem = "io.injectTextNoMem" // 7 CORE_INJECT_TEXT_NO_MEMORY
MethodIOInjectSync = "io.injectInputSync" // 47 CORE_INJECT_INPUT_SYNC
// 带媒体的注入blocks 随参数 JSON 一并过来,内核侧转成
// payload["media_blocks"],由 resolveInput 归一进统一输入主干。
// 与 SetToolBlocks 的区别:这三个是「主动发起一轮带图的对话」,
// 后者是「工具返回值里带图」,只能在工具调用内部用。
MethodIOInjectMedia = "io.injectMedia"
MethodIOInjectMediaSync = "io.injectMediaSync"
MethodIOInjectInterruptMedia = "io.injectInterruptMedia"
// MethodIOSetToolBlocks 多模态注入——今日 C ABI 侧是空实现§1.4
// 子进程下二进制落 arena、描述符回传首次真正可用。
MethodIOSetToolBlocks = "io.setToolBlocks"
// 生命周期(原 case 8
MethodLifecycleAutoRestart = "lifecycle.autoRestart" // 8 CORE_SET_AUTO_RESTART
// 图记忆(原 case 9/10/11/12/13
MethodMemoryRecall = "memory.recall" // 9
MethodMemoryCommit = "memory.commit" // 10
MethodMemoryIntrospect = "memory.introspect" // 11
MethodMemoryMerge = "memory.merge" // 12
MethodMemoryPurge = "memory.purge" // 13
// 文档记忆(原 case 14/32/33/34
MethodDocQuery = "doc.query" // 14
MethodDocInsert = "doc.insert" // 32
MethodDocRemove = "doc.remove" // 33
MethodDocStats = "doc.stats" // 34
// MethodDocInsertMedia 写入文档并关联媒体(附件带 data 则落盘去重,
// 只带 digest 则引用已有内容)。
MethodDocInsertMedia = "doc.insertWithMedia"
// 知识库(原 case 15/35/36
MethodKnowledgeSearch = "knowledge.search" // 15
MethodKnowledgeAdd = "knowledge.add" // 35
MethodKnowledgeList = "knowledge.list" // 36
// 文本记忆(原 case 41
MethodTextMemoryAppend = "textmemory.append" // 41
// 设置(原 case 16/17/18/26/27/28/29/30/31/42/43/44/45/51
MethodSettingsGet = "settings.get" // 16
MethodSettingsSet = "settings.set" // 17
MethodSettingsRegisterDef = "settings.registerDef" // 18
MethodSettingsGetCore = "settings.getCore" // 26
MethodSettingsSetCore = "settings.setCore" // 27
MethodSettingsListCore = "settings.listCore" // 28
MethodSettingsGetPlugin = "settings.getPlugin" // 29
MethodSettingsSetPlugin = "settings.setPlugin" // 30
MethodSettingsListPlugin = "settings.listPlugin" // 31
MethodSettingsList = "settings.list" // 42
MethodSettingsDefs = "settings.defs" // 43
MethodSettingsDump = "settings.dump" // 44
MethodSettingsPlugins = "settings.plugins" // 45
MethodSettingsDataDir = "settings.dataDir" // 51
// LLM 源(原 case 19/20/37
MethodLLMListSources = "llm.listSources" // 19
MethodLLMSetSource = "llm.setSource" // 20
MethodLLMCurrentSource = "llm.currentSource" // 37
// 社交图(只读,原 case 21/22/38/39/40
MethodSocialGetPerson = "social.getPerson" // 21
MethodSocialGetNetwork = "social.getNetwork" // 22
MethodSocialGetTrait = "social.getTrait" // 38
MethodSocialGetRelation = "social.getRelations" // 39
MethodSocialListPersons = "social.listPersons" // 40
// 事件(原 case 23/24 —— 今日均为空实现「给不了」,
// 子进程下经事件环 + eventfd 首次真正可用,见 §3.6/§3.8
MethodEventsSubscribe = "events.subscribe" // 23
MethodEventsUnsubscribe = "events.unsubscribe" // 24
// 插件管理(原 case 48/49/50
MethodPluginReloadOne = "plugin.reloadOne" // 48
MethodPluginListLoaded = "plugin.listLoaded" // 49
MethodPluginIsDisabled = "plugin.isDisabled" // 50
// 共享段锁仲裁(新增,无对应 method id —— C ABI 下不存在跨进程锁概念)
MethodStageLock = "stage.lock"
MethodStageUnlock = "stage.unlock"
)
// 原 case 25CORE_FREE_STRING无对应 RPC method
// C ABI 下需要显式释放跨边界字符串,进程模型下由各自 GC 管理,概念消失。
// HandshakeParams 是内核 → 插件的建链首帧:告知内核侧规格。
type HandshakeParams struct {
Protocol int `json:"protocol"` // 内核支持的协议版本
CoreVersion string `json:"core_version"` // 内核版本(诊断用)
PluginName string `json:"plugin_name"` // 内核分配的插件名
ShmVersion uint32 `json:"shm_version"`
ShmSize int `json:"shm_size"`
// EvtRingSize 是事件环段大小0 表示不支持事件环)。插件据此 mmap fd 4。
EvtRingSize int `json:"evt_ring_size,omitempty"`
}
// HandshakeResult 是插件 → 内核的建链应答:上报自身信息。
type HandshakeResult struct {
Protocol int `json:"protocol"` // 必须等于 ProtocolVersion
SDKVersion string `json:"sdk_version"` // 插件编译时链接的公开 SDK 版本
PluginName string `json:"plugin_name"`
PID int `json:"pid"`
}
// StageInvokeParams 是 stage.invoke 的参数。
//
// **注意:不含 StageContext 数据本身**——数据在共享段,此处只带定位信息。
// 这是共享内存数据面的意义:并发改写同一份状态,而非各持副本
// (副本模型实测 35.8~36.8% lost update§8.4)。
type StageInvokeParams struct {
Stage string `json:"stage"`
// Seq 是内核写入共享段后的世代号,插件读到的 seq 应 >= 此值。
Seq uint64 `json:"seq"`
}
// StageInvokeResult 是插件执行 stage 后的应答。
type StageInvokeResult struct {
// DirtyFields 是插件实际写回共享段的字段数0 表示只读插件。
// 内核据此判断是否需要重读共享段,也用于诊断"谁改了什么"。
DirtyFields int `json:"dirty_fields"`
// Seq 是插件写回后的世代号。
Seq uint64 `json:"seq"`
}
// ToolInvokeParams / ToolInvokeResult工具调用原 go_invoke_tool
type ToolInvokeParams struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args,omitempty"`
}
type ToolInvokeResult struct {
Result interface{} `json:"result,omitempty"`
}
// OutputInvokeParams输出通道发送原 go_invoke_output
//
// 与 C ABI 路径的关键差异:**可同步等待真实结果**。
// C ABI 下因 cgo 不可嵌套,只能异步 fire-and-forget导致 output_send
// 永远返回成功§9.4,现网 2 次消息发不出而模型以为成功)。
// 进程模型下 RPC 天然可等应答,该缺陷从根上消失。
type OutputInvokeParams struct {
Channel string `json:"channel"`
Args map[string]interface{} `json:"args,omitempty"`
}
// PluginInitParams插件构造参数原 case init_plugin
type PluginInitParams struct {
Name string `json:"name"`
Config map[string]interface{} `json:"config,omitempty"`
}