Files
homeagent-sdk/tools/plugindev/proc_runtime_test.go
JianFeeeee ce5bff9275 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 能在其上运行」的下限,媒体是可选能力。
2026-09-06 10:03:36 +08:00

400 lines
14 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 main
import (
"go/parser"
"go/token"
"os"
"regexp"
"strings"
"testing"
)
// 子进程运行时模板的静态检查Part 3
//
// 为什么需要这些测试:模板是插件的运行时半身,它与内核 internal/plugin/proc/
// 的协议名、共享段布局、字段索引必须逐一对齐。任一处漂移都会导致
// 「插件编译通过但运行时读错字段」——比编译错误难查得多。
//
// 模板改为真实 .go 源文件(而非 raw string的直接收益就是这类检查可行。
func loadProcTemplate(t *testing.T) string {
t.Helper()
data, err := procTemplates.ReadFile("templates/proc_main.go.tmpl")
if err != nil {
t.Fatalf("读取内嵌模板: %v", err)
}
return string(data)
}
// stripComments 去掉源码中的注释(用空白填充以保持偏移),只留可执行代码。
func stripComments(t *testing.T, src string) string {
t.Helper()
fs := token.NewFileSet()
f, err := parser.ParseFile(fs, "proc_main.go", src, parser.ParseComments)
if err != nil {
t.Fatalf("解析模板: %v", err)
}
out := []byte(src)
for _, cg := range f.Comments {
s := fs.Position(cg.Pos()).Offset
e := fs.Position(cg.End()).Offset
for i := s; i < e && i < len(out); i++ {
if out[i] != '\n' {
out[i] = ' '
}
}
}
return string(out)
}
// 模板必须是合法 Go 源码。
func TestProcTemplate_ParsesAsGo(t *testing.T) {
src := loadProcTemplate(t)
fs := token.NewFileSet()
if _, err := parser.ParseFile(fs, "proc_main.go", src, parser.AllErrors); err != nil {
t.Fatalf("模板不是合法 Go 源码: %v", err)
}
}
// 模板必须提供 main(),且不得含 cgo 痕迹。
//
// 零 cgo 是迁移的核心收益之一§3.7 锁仲裁回内核后整个架构无 cgo
// 一旦有人往模板里加 import "C",交叉编译立刻退回需要目标平台 C 工具链。
func TestProcTemplate_HasMainAndNoCgo(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "func main()") {
t.Error("子进程模板必须有 main() 入口")
}
// 只检查代码,不检查注释——模板顶部的说明文字本身就提到了 C.CString/C.free
code := stripComments(t, src)
for _, forbidden := range []string{
`import "C"`,
"//export ",
"C.CString",
"C.GoString",
"C.free",
} {
if strings.Contains(code, forbidden) {
t.Errorf("模板不应含 cgo 痕迹 %q零 cgo 是迁移的核心收益)", forbidden)
}
}
}
// 模板引用的 method 名必须与内核 internal/plugin/proc/protocol.go 一致。
//
// 这里硬编码一份清单做对照:内核侧改了 method 名而模板没跟上时,
// 表现是插件调用返回「未知 method」测试能提前拦住。
func TestProcTemplate_CoversAllCoreMethods(t *testing.T) {
src := loadProcTemplate(t)
// 51 个 C ABI method id 平移后的名字§3.2),加 stage 锁仲裁 2 个
required := []string{
// 注册面
"tool.register", "stage.register", "output.register", "api.register", "input.register",
// IO 注入
"io.injectText", "io.injectInterrupt", "io.injectTextNoMem", "io.injectInputSync",
"io.setToolBlocks",
// 多模态注入1.1.0 新增)。漏接线的后果是插件调 InjectInputMedia 静默无效果:
// 模板不发这个 RPC内核也就永远收不到而两边都不报错。
"io.injectMedia", "io.injectMediaSync", "io.injectInterruptMedia",
// 生命周期
"lifecycle.autoRestart",
// 图记忆
"memory.recall", "memory.commit", "memory.introspect", "memory.merge", "memory.purge",
// 文档记忆
"doc.query", "doc.insert", "doc.remove", "doc.stats",
// 文档媒体1.1.0 新增)
"doc.insertWithMedia",
// 知识库
"knowledge.search", "knowledge.add", "knowledge.list",
// 文本记忆
"textmemory.append",
// 设置
"settings.get", "settings.set", "settings.registerDef",
"settings.getCore", "settings.setCore", "settings.listCore",
"settings.getPlugin", "settings.setPlugin", "settings.listPlugin",
"settings.list", "settings.defs", "settings.dump", "settings.plugins",
"settings.dataDir",
// LLM
"llm.listSources", "llm.setSource", "llm.currentSource",
// 社交图
"social.getPerson", "social.getNetwork", "social.getTrait",
"social.getRelations", "social.listPersons",
// 插件管理
"plugin.reloadOne", "plugin.listLoaded", "plugin.isDisabled",
// 共享段锁仲裁新增C ABI 下不存在此概念)
"stage.lock", "stage.unlock",
}
for _, m := range required {
if !strings.Contains(src, `"`+m+`"`) {
t.Errorf("模板缺少 core method %q内核已提供插件侧未接线", m)
}
}
}
// 模板必须处理内核发来的全部 7 个调用(原 C ABI 的 7 个 //export
func TestProcTemplate_HandlesAllKernelCalls(t *testing.T) {
src := loadProcTemplate(t)
for _, m := range []string{
"handshake",
"plugin.init", "plugin.start", "plugin.stop",
"tool.invoke", "stage.invoke", "output.invoke",
} {
if !strings.Contains(src, `case "`+m+`"`) {
t.Errorf("模板未处理内核调用 %q", m)
}
}
}
// 共享段布局常量必须与内核 internal/plugin/proc/shm.go 一致。
//
// 字段索引错位是最危险的漂移:插件会读到相邻字段的数据,
// 而两边都不报错(同为 []byte
func TestProcTemplate_ShmLayoutMatchesKernel(t *testing.T) {
src := loadProcTemplate(t)
// 与内核 shm.go 的 offXxx 常量对齐(值比较,不依赖 gofmt 的对齐空白)
layout := map[string]string{
"shmOffMagic": "0",
"shmOffVersion": "4",
"shmOffArenaBase": "8",
"shmOffArenaCap": "12",
"shmOffArenaUsed": "16",
"shmOffCtxBase": "20",
"shmOffSeq": "24",
// 与内核 stageFieldCount / sliceSize 对齐
"shmStageFieldCount": "18",
"shmSliceSize": "8",
"shmVersion": "1",
}
constRe := func(name, want string) bool {
// gofmt 会对齐常量块,故容许 name 与 = 之间有任意空白
re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*` + regexp.QuoteMeta(want) + `\b`)
return re.MatchString(src)
}
for name, want := range layout {
if !constRe(name, want) {
t.Errorf("共享段常量 %s 应为 %s须与内核 internal/plugin/proc/shm.go 一致)", name, want)
}
}
// 字段枚举顺序:内核 stageField 的前若干项
fieldOrder := []string{
"fRawMessage = iota", "fUserID", "fGroupID", "fLLMText",
"fReasoningContent", "fFinalText", "fResponse", "fPhase",
"fContextMsgs", "fToolCalls", "fToolResults", "fMemory",
"fTokenUsage", "fErrors",
"fExtraMediaBlocks", "fExtraMediaType", "fExtraInputSource", "fExtraOutputChannel",
}
idx := -1
for _, f := range fieldOrder {
at := strings.Index(src, f)
if at < 0 {
t.Fatalf("模板缺少字段常量 %s", f)
}
if at <= idx {
t.Errorf("字段常量 %s 的声明顺序与内核 stageField 枚举不一致", f)
}
idx = at
}
}
// stage 处理必须「拿锁 → 读 → handler → 只写脏字段 → 放锁」。
//
// 只写脏字段是消除 lost update 的核心:只读插件零写入,
// 不可能覆盖其他插件的改写(对照 C ABI 副本模型实测 35.8~36.8% 丢失)。
func TestProcTemplate_StageFlowUsesLockAndDirtyWrite(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{
"func handleStageInvoke(",
"stage.lock",
"readStageContext()",
"takeStageSnapshot(",
"writeStageDirty(",
"stage.unlock",
} {
if !strings.Contains(src, want) {
t.Errorf("stage 处理链路缺少 %q", want)
}
}
// 顺序检查:加锁必须在读取之前,写回必须在解锁之前
iLock := strings.Index(src, `callCoreVoid("stage.lock"`)
iRead := strings.Index(src, "readStageContext()")
iWrite := strings.Index(src, "writeStageDirty(sc, snap)")
if iLock < 0 || iRead < 0 || iWrite < 0 {
t.Fatal("stage 链路关键调用缺失")
}
// readStageContext 的定义在前,调用在后;取 handleStageInvoke 内的位置
stageFn := src[strings.Index(src, "func handleStageInvoke("):]
iLockFn := strings.Index(stageFn, `callCoreVoid("stage.lock"`)
iReadFn := strings.Index(stageFn, "readStageContext()")
iWriteFn := strings.Index(stageFn, "writeStageDirty(sc, snap)")
if !(iLockFn < iReadFn && iReadFn < iWriteFn) {
t.Error("stage 链路顺序应为 加锁 → 读取 → 写回")
}
}
// 快照必须存序列化字符串而非 Go 值。
//
// ❗ 这是修 C ABI 侧 11.3 时踩过的坑StageContext 的切片字段与读出的值
// 共享底层内容handler 原地改元素sc.ToolResults[0].Result = x
// 直接持有 Go 值的快照会跟着变,脏字段计算失效、修复静默失效。
func TestProcTemplate_SnapshotStoresSerializedStrings(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "strs map[int]string") ||
!strings.Contains(src, "jsons map[int]string") {
t.Error("stageSnapshot 必须存序列化字符串(切片共享底层数组,存 Go 值会让脏字段计算失效)")
}
if !strings.Contains(src, "json.Marshal(v)") {
t.Error("takeStageSnapshot 应对容器字段做 json.Marshal")
}
}
// arena 用尽必须显式报错不得静默截断§4.4 风险登记)。
func TestProcTemplate_ArenaExhaustionErrors(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "arena 空间不足") {
t.Error("shmWrite 在 arena 不足时必须报错,不得静默截断")
}
}
// 日志必须走 stderrstdout 是 RPC 通道,写日志会破坏 NDJSON 帧。
func TestProcTemplate_LogsToStderr(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "log.SetOutput(os.Stderr)") {
t.Error("日志必须走 stderr否则会破坏 stdout 的 RPC 帧")
}
}
// 请求必须在独立 goroutine 里处理。
//
// handler 内会反向调用内核并等应答;若在读循环里同步处理,
// 就没人读应答帧 → 死锁。
func TestProcTemplate_DispatchesRequestsConcurrently(t *testing.T) {
src := loadProcTemplate(t)
if !strings.Contains(src, "go handleKernelRequest(&req)") {
t.Error("请求须在独立 goroutine 处理handler 内反向调用内核,同步处理会死锁)")
}
}
// 协议与共享段版本不匹配必须拒绝,不得半兼容运行。
func TestProcTemplate_RejectsVersionMismatch(t *testing.T) {
src := loadProcTemplate(t)
for _, want := range []string{"协议版本不匹配", "共享段版本不匹配", "共享段魔数不匹配"} {
if !strings.Contains(src, want) {
t.Errorf("握手应校验并拒绝 %q", want)
}
}
}
// 全平台统一产出 plugin.bin。
//
// 这是三套独立 ABI 实现(.so/.dylib/.dll收敛为单一 RPC 实现的直接后果:
// 进程边界本身就是 ABI 边界,不存在平台特有的动态库扩展名。
// §9.2 记录的「Windows DLL 路径只下发 3 字段、无写回」随之消失——
// Windows 走的是与 Linux 完全相同的 RPC 实现。
func TestResolveBuild_AllPlatformsProduceBin(t *testing.T) {
for _, target := range []string{
"linux/amd64", "linux/arm64",
"darwin/amd64", "darwin/arm64",
"windows/amd64",
"freebsd/amd64",
} {
cfg, errMsg := resolveBuild(target)
if cfg == nil {
t.Fatalf("resolveBuild(%q) 失败: %s", target, errMsg)
}
if cfg.entryFile != procEntryFile {
t.Errorf("%s: 产物应为 %s实际 %s", target, procEntryFile, cfg.entryFile)
}
}
}
// lua 目标仍走解释器路径entry 字段唯一仍在使用的用途)。
func TestResolveBuild_LuaIsSeparatePath(t *testing.T) {
for _, target := range []string{"lua", ""} {
cfg, kind := resolveBuild(target)
if cfg != nil {
t.Errorf("%q 应返回 nil cfgLua 不经 Go 编译)", target)
}
if kind != "lua" {
t.Errorf("%q 应识别为 lua实际 %q", target, kind)
}
}
}
// 不支持的平台明确报错,不静默产出错误产物。
func TestResolveBuild_UnsupportedOSErrors(t *testing.T) {
cfg, errMsg := resolveBuild("plan9/amd64")
if cfg != nil {
t.Error("不支持的平台应返回 nil cfg")
}
if !strings.Contains(errMsg, "unsupported") {
t.Errorf("应给出 unsupported 提示,实际 %q", errMsg)
}
}
// bundle 产物在 zip 内按平台加后缀(全平台同名 plugin.bin 会相互覆盖)。
func TestBundleTargets_HavePlatformSuffixedEntries(t *testing.T) {
seen := map[string]bool{}
for _, bt := range allBundleTargets {
if seen[bt.entry] {
t.Errorf("zip 条目名重复: %s会相互覆盖", bt.entry)
}
seen[bt.entry] = true
if !strings.HasPrefix(bt.entry, procEntryFile+".") {
t.Errorf("bundle 条目 %q 应以 %s. 为前缀", bt.entry, procEntryFile)
}
}
if len(allBundleTargets) == 0 {
t.Error("bundle 目标表不应为空")
}
}
// C ABI 工具链残留必须彻底清除:不得再有 .so/.dylib/.dll 产物路径,
// 也不得再引用 c-shared 构建模式或 MinGW 探测。
func TestToolchain_NoCABIResiduals(t *testing.T) {
for _, f := range []string{"cmd_build.go", "templates.go", "cmd_init.go", "proc_runtime.go"} {
data, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读 %s: %v", f, err)
}
src := stripComments(t, string(data))
for _, forbidden := range []string{
"c-shared",
"CGO_ENABLED=1",
"detectWindowsCC",
"generateBridge",
"tmplLinuxBridge",
"tmplPluginInitC",
} {
if strings.Contains(src, forbidden) {
t.Errorf("%s 仍含 C ABI 残留 %q", f, forbidden)
}
}
}
}
// Go 插件的构建不再读 plg.json 的 entry 值。
//
// 这是「外部插件零改动」的关键17 个存量插件的 plg.json 都写着 "plugin.so"
// 若把 entry 当通道开关,迁移就得改 17 个文件。
func TestToolchain_IgnoresEntryForGoPlugins(t *testing.T) {
data, err := os.ReadFile("cmd_build.go")
if err != nil {
t.Fatalf("读 cmd_build.go: %v", err)
}
src := stripComments(t, string(data))
if strings.Contains(src, "isProcEntry") {
t.Error("isProcEntry 应已删除——Go 插件一律产出 plugin.bin不看 entry 值")
}
// entry 仅剩 Lua 判定这一处用途
if !strings.Contains(src, "luaEntryFile") {
t.Error("IsLua 应改用 luaEntryFile 常量")
}
}