mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-15 12:15:11 +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:
86
README_EN.md
86
README_EN.md
@ -36,6 +36,7 @@ The SDK instance injected via `Start(sdk *PluginSDK)` provides:
|
||||
| Settings | `Settings()` | Access settings API |
|
||||
| Events | `Events()` | Access event subscriber (subscribe-only for external plugins) |
|
||||
| Inject | `InjectText(source, channel, text)` / `InjectInterruptText(source, channel, text)` / `InjectTextNoMemory(source, channel, text)` | Inject text into the agent pipeline |
|
||||
| Media inject | `InjectInputMedia(source, channel, text, blocks)` / `InjectInputMediaSync(...)` / `InjectInterruptMedia(...)` | Inject input carrying images/audio (added in 1.1.0) |
|
||||
| Auto-Restart | `SetAutoRestart(enabled)` / `AutoRestart()` | Control automatic restart on crash |
|
||||
|
||||
### Stage Hooks
|
||||
@ -106,6 +107,32 @@ Type enum values:
|
||||
| `InjectInterruptText(source, channel, text)` | Inject interrupt text, interrupt current processing, route to specified channel |
|
||||
| `InjectTextNoMemory(source, channel, text)` | Inject text without memory recording, route to specified channel |
|
||||
|
||||
### Multimodal Injection (added in 1.1.0)
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `InjectInputMedia(source, channel, text, blocks)` | Inject media-bearing input, asynchronous |
|
||||
| `InjectInputMediaSync(source, channel, text, blocks)` | Inject media-bearing input and wait for the reply text |
|
||||
| `InjectInterruptMedia(source, channel, text, blocks)` | Inject a media-bearing interrupt that can preempt current processing |
|
||||
|
||||
`blocks` is `[]sdk.ContentBlock`, the same type `SetToolBlocks` takes:
|
||||
|
||||
```go
|
||||
s.InjectInputMedia("myplugin", "webui", "take a look at this", []sdk.ContentBlock{{
|
||||
Type: "image_url",
|
||||
ImageURL: &sdk.ImageURL{URL: "data:image/png;base64," + b64, Detail: "auto"},
|
||||
}})
|
||||
```
|
||||
|
||||
How this differs from `SetToolBlocks`: that one is only callable inside a tool handler and
|
||||
its media reaches the model with the *next* tool message. These three let a plugin
|
||||
**initiate a turn that carries media** — the media goes out with this turn's message and is
|
||||
automatically stored in the media store with a memory reference attached.
|
||||
|
||||
`data:` URLs in the blocks are stored and deduplicated by the kernel; `http(s)` URLs are
|
||||
passed to the model only and never stored (storing them would require the kernel to make
|
||||
network requests, bringing timeouts, auth and SSRF into scope).
|
||||
|
||||
`source` identifies the origin, `channel` specifies the target output channel.
|
||||
|
||||
### Triple Extended Fields
|
||||
@ -115,6 +142,65 @@ The Triple data structure includes additional fields:
|
||||
- `Confidence` — confidence score (0.0–1.0)
|
||||
- `SubjectType` — subject type
|
||||
- `ObjectType` — object type
|
||||
- `SentenceText` — the original sentence (added in 1.1.0), written to the `sentences` table; media references hang off the sentence
|
||||
- `MediaDigests` — associated media digests (added in 1.1.0)
|
||||
|
||||
### Media in Memory (added in 1.1.0)
|
||||
|
||||
Inside plain-text memory, media is represented as a **marker** of the form
|
||||
`[<mime> <short digest>] <description>`:
|
||||
|
||||
```
|
||||
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
|
||||
```
|
||||
|
||||
The description is the durable semantic memory (retrieval uses it); the digest is the key
|
||||
back to the bytes (reverse lookup uses it). Markers are generated by the kernel — a plugin
|
||||
never has to assemble one, it just **supplies the digest**.
|
||||
|
||||
#### Graph memory
|
||||
|
||||
```go
|
||||
s.Memory().Commit([]sdk.Triple{{
|
||||
Subject: "palette", Relation: "contains", Object: "three-band",
|
||||
MediaDigests: []string{"a1b2c3d4e5f6"}, // short digest is fine, the kernel resolves it
|
||||
}})
|
||||
```
|
||||
|
||||
With no `SentenceText`, the kernel uses the marker itself as the sentence — media must have
|
||||
a sentence to hang off, otherwise the reference has nowhere to attach.
|
||||
|
||||
#### Knowledge base
|
||||
|
||||
```go
|
||||
s.DocMemory().InsertWithMedia(&sdk.Doc{
|
||||
Title: "illustrated note",
|
||||
Content: "body",
|
||||
}, []sdk.MediaAttachment{
|
||||
{MIME: "image/png", Data: pngBytes, Name: "chart.png"}, // new content, stored and deduped
|
||||
{Digest: "a1b2c3d4e5f6"}, // reference existing content
|
||||
})
|
||||
```
|
||||
|
||||
`Insert` keeps its original signature; markers already present in the body are bound as
|
||||
document-level references too. `Query` fills `MediaDigests` and `Attachments` (mime plus
|
||||
description, **no bytes** — one query can match dozens of media items). Removing a document
|
||||
releases its references.
|
||||
|
||||
#### Text memory
|
||||
|
||||
```go
|
||||
s.TextMemory().Append(sdk.TextEvent{
|
||||
Role: "user", Content: "look at this",
|
||||
Attachments: []sdk.MediaAttachment{{MIME: "image/png", Data: pngBytes}},
|
||||
})
|
||||
```
|
||||
|
||||
`RecentEvents` decodes markers in the body back into `Attachments`.
|
||||
|
||||
The media store can be disabled kernel-side (`core.memory.media.enabled=false`); all of the
|
||||
above then degrades to plain-text behaviour — no errors, no panics, identical to how it
|
||||
behaved before this feature shipped.
|
||||
|
||||
### ToolDef Field Reference
|
||||
|
||||
|
||||
Reference in New Issue
Block a user