mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-09-20 17:08:01 +00:00
feat(sdk): 多模态贯通插件边界——媒体字段、媒体注入接口与并发修复
记忆系统在核心 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放: 插件把 Triple / Doc 交进来,媒体一律无处安放,且**不报错**。本版补上公开接口 侧缺失的表达能力。 ## 一、类型与接口(全部新增,无签名变更) - `Triple` += `SentenceText`、`MediaDigests` - `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment` - `TextEvent` += `Attachments` - `DocMemoryAPI` += `InsertWithMedia` - `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia` - `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有, 插件只能自己去拿 injector) `MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(内核按字节 去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索 可能命中几十份媒体,把字节全塞回来会撑爆跨进程消息。 媒体注入为什么不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用, 且媒体要等**下一条** tool message 才到模型手上。插件主动发起一轮带媒体的对话、 以及中断注入,需要各自的签名,且媒体在**本轮**就随消息发出。 `Triple.MediaDigests` 非空而 `SentenceText` 为空时,内核会用媒体标记本身充当句子 ——媒体引用挂在句子上,没有句子就无处挂起。插件只需填 digest,标记由内核拼: 要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。 ## 二、修掉两处并发竞态 `sdk/stress_test.go` 的 `-race` 实测报 11 处 DATA RACE,收敛到两个字段: 1. **`PluginSDK` 的 API 字段无锁**。写方是内核(加载/重载插件时依次注入 injector、memory、doc、llm…),读方是插件在 `Start()` 里起的后台 goroutine ——轮询、监听、定时器都要拿 injector 往管道注消息。生产表现是插件重载瞬间 偶发崩溃:读到半个接口值就 nil 解引用。 2. **`autoRestart` 标志无锁**。`SetAutoRestart` 的文档用法本身就是「外部连接建好 后再决定能否自动重启」,而连接建立通常在后台 goroutine;内核 registry 在另一个 goroutine 读 `AutoRestart()` 决定崩溃后重启策略。这对读写天然跨 goroutine。 加 `apiMu sync.RWMutex`。关键约定写进注释:**只在持锁期间取字段值,取完立刻 释放再调用**。持锁调用会把 `InjectInputSync`(阻塞到 agent 回复,可达数分钟) 与 `SetIOInjector` 串到一起,让插件重载卡死。 ## 三、压测(sdk/stress_test.go,13 例) SDK 是被多个 goroutine 同时使用的共享对象,单线程单测全绿不代表并发路径成立。 断言的是不变量而非吞吐: - 媒体注入高并发不丢不串——每次调用带唯一 tag,逐条校验文本与图片 URL 配对。 「不串」是重点:若实现里出现任何共享中间状态(把 blocks 暂存到字段再读出), 高并发下会出现 A 的文本配 B 的图,而两者单独看都「成功」了; - injector 热替换(含替换成 nil,即内核卸载 API 的真实状态); - stop / onRemove handler 恰好一次——契约是「执行后清空,幂等」,执行两次的后果 从重复写文件到 close 已关闭 channel 直接 panic; - `StageContext` 并发读改写无 lost update(媒体链路让 Extra 成为新热点, 而 map 并发写在 Go 里是直接 fatal,recover 接不住); - `OwnTools` scope 不跨插件泄漏; - 媒体类型 JSON 往返字节级一致(9 种长度,含 0/1/2/3 与 base64 分组边界) ——`[]byte` 在 JSON 里是 base64,往返不一致意味着图片静默损坏, 要到 CAS 校验 digest 时才发现,那时已无从追查; - `omitempty` 真的生效(读路径不能出现 `"data"` 键); - nil 依赖全部静默降级不 panic。 ## 四、工具链同步 - `proc_main.go.tmpl`:`procIO` 三个媒体方法、`procDocMemory.InsertWithMedia`。 模板不跟上的后果是**每个外部插件都编不过**(接口未实现),是硬失败; - `proc_runtime_test.go`:方法清单补 `io.injectMedia*` 与 `doc.insertWithMedia`。 漏接线时插件调 `InjectInputMedia` 会静默无效果——模板不发这个 RPC,内核也就 收不到,两边都不报错; - `yaegi/mocksdk`:与公开 SDK 对齐。它此前漂移严重且**没有任何代码对着它编译**, 所以漂移不会被编译器抓到:`Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation` —— 插件在 yaegi 调试期写 `Relation:` 报未知字段,写 `Predicate:` 则 编成 plugin.bin 时报错,两边都不对。 - README 中英双语补媒体接口文档与用法示例。 ## 兼容性 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,不调就不 受影响。17 个 example 插件源码零改动通过类型检查;用 SDK 0.9.2 编的旧 plugin.bin 在新内核上直接建链通过(握手校验的是 ProtocolVersion=1,不是 SDK 版本)。 媒体接口需要核心 1.1.1+(更早的核心没有对应 RPC,调用返回 unknown method)。 `CoreVersion` 保持 1.0.0:它是「SDK 能在其上运行」的下限,媒体是可选能力。
This commit is contained in:
80
README.md
80
README.md
@ -36,6 +36,7 @@ type Plugin interface {
|
||||
| 设置 | `Settings()` | 访问设置 API |
|
||||
| 事件 | `Events()` | 访问事件订阅器(外部插件仅订阅) |
|
||||
| 注入 | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | 向管道注入文本 |
|
||||
| 多模态注入 | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | 注入带图片/音频的输入(1.1.0 新增) |
|
||||
| 自动重启 | `SetAutoRestart(enabled)` / `AutoRestart()` | 控制崩溃自动重启 |
|
||||
|
||||
### 阶段钩子
|
||||
@ -106,6 +107,30 @@ type 枚举值:
|
||||
| `InjectInterruptText(source, channel, text)` | 注入中断文本,打断当前处理,路由到指定通道 |
|
||||
| `InjectTextNoMemory(source, channel, text)` | 注入文本,不记入内存,路由到指定通道 |
|
||||
|
||||
### 多模态注入(1.1.0 新增)
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `InjectInputMedia(source, channel, text, blocks)` | 注入带媒体的输入,异步 |
|
||||
| `InjectInputMediaSync(source, channel, text, blocks)` | 注入带媒体的输入并同步等待回复文本 |
|
||||
| `InjectInterruptMedia(source, channel, text, blocks)` | 注入带媒体的中断,可抢占当前处理 |
|
||||
|
||||
`blocks` 是 `[]sdk.ContentBlock`,与 `SetToolBlocks` 用同一类型:
|
||||
|
||||
```go
|
||||
s.InjectInputMedia("myplugin", "webui", "帮我看看这张图", []sdk.ContentBlock{{
|
||||
Type: "image_url",
|
||||
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||
}})
|
||||
```
|
||||
|
||||
与 `SetToolBlocks` 的区别:`SetToolBlocks` 只能在工具处理函数内部调用,媒体要等到
|
||||
下一条 tool message 才到模型手上;这三个方法是插件**主动发起一轮带媒体的对话**,
|
||||
媒体在本轮就随消息发给模型,并自动落进媒体存储、挂上媒体记忆引用。
|
||||
|
||||
媒体块里的 `data:` URL 会被内核落盘去重;`http(s)` URL 只透传给模型,不入库
|
||||
(入库需要内核发起网络请求,涉及超时、鉴权与 SSRF)。
|
||||
|
||||
`source` 标识来源,`channel` 指定目标输出通道。
|
||||
|
||||
### Triple 扩展字段
|
||||
@ -115,6 +140,61 @@ Triple 数据结构新增字段:
|
||||
- `Confidence` — 置信度(0.0~1.0)
|
||||
- `SubjectType` — 主体类型
|
||||
- `ObjectType` — 客体类型
|
||||
- `SentenceText` — 原始句子文本(1.1.0 新增),写入 `sentences` 表;媒体引用挂在句子上
|
||||
- `MediaDigests` — 关联的媒体 digest 列表(1.1.0 新增)
|
||||
|
||||
### 记忆里的媒体(1.1.0 新增)
|
||||
|
||||
媒体在纯文本记忆里以**标记**形式存在,格式 `[<mime> <短digest>] <描述>`:
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
|
||||
```
|
||||
|
||||
描述文本是持久的语义记忆(检索靠它),digest 是回到字节的钥匙(反查靠它)。
|
||||
标记由内核生成,插件不必自己拼——**填 digest 就够**。
|
||||
|
||||
#### 图记忆
|
||||
|
||||
```go
|
||||
s.Memory().Commit([]sdk.Triple{{
|
||||
Subject: "配色图", Relation: "包含", Object: "三色带",
|
||||
MediaDigests: []string{"a1b2c3d4e5f6"}, // 短 digest 即可,内核补全
|
||||
}})
|
||||
```
|
||||
|
||||
没给 `SentenceText` 时内核会用标记本身充当句子——媒体必须有句子落点,
|
||||
否则引用无从挂起。
|
||||
|
||||
#### 知识库
|
||||
|
||||
```go
|
||||
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||
Title: "带图笔记",
|
||||
Content: "正文",
|
||||
}, []sdk.MediaAttachment{
|
||||
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // 新内容,落盘去重
|
||||
{Digest: "a1b2c3d4e5f6"}, // 引用已有内容
|
||||
})
|
||||
```
|
||||
|
||||
`Insert` 保持原签名不变,正文里已有的标记同样会被挂成文档级引用。
|
||||
`Query` 返回的 `Doc` 带 `MediaDigests` 与 `Attachments`(mime + 描述,
|
||||
**不含字节**——一次检索可能命中几十份媒体)。删除文档时引用自动释放。
|
||||
|
||||
#### 文本记忆
|
||||
|
||||
```go
|
||||
s.TextMemory().Append(sdk.TextEvent{
|
||||
Role: "user", Content: "看这张图",
|
||||
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||
})
|
||||
```
|
||||
|
||||
`RecentEvents` 读回时正文里的标记会被反解成 `Attachments`。
|
||||
|
||||
媒体存储可在内核侧关闭(`core.memory.media.enabled=false`),此时以上接口
|
||||
全部退化为纯文本行为:不报错、不 panic,与本特性上线前一致。
|
||||
|
||||
### ToolDef 字段说明
|
||||
|
||||
|
||||
Reference in New Issue
Block a user