feat(shm): 文档/知识正文入共享内存 + 协议版本 bump 到 2(§13.13)

## 数据面补齐

- doc.insert / doc.insertWithMedia:新增 doc_ref / attachments_ref,
  模板序列化后 putValueInArena
- knowledge.add:新增 content_ref(内容是 JSON 字符串,读出后再解一层)
- 抽出通用 resolveJSONRef(resolveBlocks 也改用它),三处共用一套
  “共享优先、内联回退”逻辑

## 协议版本 bump:让错配显式失败,而不是静默坏

这是本轮更重要的部分。§13.6/§13.13 改了内核→插件 payload 的承载方式,
两种错配都不会报错、只会静默失效:

- v1 插件只读内联 args(tool/cleaner/output)→ 遇到 v2 内核拿到空参数
- v2 插件发 blocks_ref → v1 内核反序列化时静默忽略(旧内核
  io.setToolBlocks 还是桩实现)

现场表现为“输出变空 / 图注入没反应”,极难定位。所以把 ProtocolVersion
与模板 procProtocolVersion 一起 bump 到 2:双方都是等值校验,v1 插件遇上
v2 内核会在建链时明确报“协议版本不匹配…请用配套 plugindev 重编”。

测试里把“错误必须带出重编指令”也断言上了——生产上碰到它的现场就是
“只更新了内核没重编插件”,光报“不匹配”定位不到行动。

testdata 8 个插件的 protocol 同步更新(badprotoplugin 仍用 999 验证拒绝)。
工具链已重建并安装(协议 2,内嵌 blocks_ref/doc_ref/content_ref),
回滚副本 plugindev.bak-20260910-232544。

验证:-race 全绿。新增 KnowledgeAddViaArena(12000B 正文)、
KnowledgeAddInline、DocInsertViaArena、SetToolBlocks 三例 +
真实模板 e2e + 协议不匹配断言强化。

§13.13 第 5 条(反向大结果)范围更大——需把内核→插件的应答路径整体改成
“大结果写段 + 返回 ref”,涉及 callCore 的返回处理与 doc.query/llm.chat 等
所有读大结果的 method。已在 plan.md 标注未做,不冒充完成。
This commit is contained in:
JianFeeeee
2026-09-10 23:26:16 +08:00
parent 15d912ef34
commit 50ba4a64cb
13 changed files with 252 additions and 37 deletions

View File

@ -340,11 +340,16 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return nil, errUnavailable("doc memory")
}
var p struct {
Doc *pubsdk.Doc `json:"doc"`
Doc *pubsdk.Doc `json:"doc,omitempty"`
DocRef SharedRef `json:"doc_ref,omitempty"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 文档全文可达几十 KB数 MB优先走共享内存。
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
return nil, err
}
if p.Doc == nil {
return nil, fmt.Errorf("doc.insert: 缺少 doc 字段")
}
@ -356,12 +361,21 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return nil, errUnavailable("doc memory")
}
var p struct {
Doc *pubsdk.Doc `json:"doc"`
Attachments []pubsdk.MediaAttachment `json:"attachments"`
Doc *pubsdk.Doc `json:"doc,omitempty"`
Attachments []pubsdk.MediaAttachment `json:"attachments,omitempty"`
DocRef SharedRef `json:"doc_ref,omitempty"`
AttachRef SharedRef `json:"attachments_ref,omitempty"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 文档正文 + 附件(含媒体二进制/data URL都优先走共享内存。
if err := h.resolveJSONRef(p.DocRef, &p.Doc); err != nil {
return nil, err
}
if err := h.resolveJSONRef(p.AttachRef, &p.Attachments); err != nil {
return nil, err
}
if p.Doc == nil {
return nil, fmt.Errorf("doc.insertWithMedia: 缺少 doc 字段")
}
@ -421,12 +435,18 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return nil, errUnavailable("knowledge")
}
var p struct {
Name string `json:"name"`
Content string `json:"content"`
Name string `json:"name"`
Content string `json:"content,omitempty"`
ContentRef SharedRef `json:"content_ref,omitempty"`
}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
// 知识正文可达数十 KB优先走共享内存。内容是 JSON 字符串,
// 所以从 ref 读出后需再解一层。
if err := h.resolveJSONRef(p.ContentRef, &p.Content); err != nil {
return nil, err
}
return nil, kn.Add(p.Name, p.Content)
case MethodKnowledgeList:
@ -651,18 +671,32 @@ type injectMediaParams struct {
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
}
// resolveJSONRef 若 ref 非零则从共享内存读取并 JSON 反序列化到 out
// ref 为零时不动 out调用方已填的内联值生效
//
// 供「大 payload 优先走共享内存、否则内联」的字段对共用。
func (h *coreHandler) resolveJSONRef(ref SharedRef, out interface{}) error {
if ref.IsZero() {
return nil
}
data, err := h.host.Arena().Read(ref, h.host.Generation())
if err != nil {
return fmt.Errorf("读取共享内容失败: %w", err)
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("解析共享内容失败: %w", err)
}
return nil
}
// resolveBlocks 取出媒体块:优先共享内存,否则内联。
func (h *coreHandler) resolveBlocks(p injectMediaParams) ([]pubsdk.ContentBlock, error) {
if p.BlocksRef.IsZero() {
return p.Blocks, nil
}
data, err := h.host.Arena().Read(p.BlocksRef, h.host.Generation())
if err != nil {
return nil, fmt.Errorf("读取共享媒体块失败: %w", err)
}
var blocks []pubsdk.ContentBlock
if err := json.Unmarshal(data, &blocks); err != nil {
return nil, fmt.Errorf("解析共享媒体块失败: %w", err)
if err := h.resolveJSONRef(p.BlocksRef, &blocks); err != nil {
return nil, err
}
return blocks, nil
}

View File

@ -31,6 +31,9 @@ type fakeCoreSDK struct {
injected []string
// toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。
toolBlocks []pubsdk.ContentBlock
// 文档/知识:验证大正文经 doc_ref / content_ref 走共享内存。
docMem *fakeDocMemory
knowledge *fakeKnowledge
}
func newFakeCore() *fakeCoreSDK {
@ -49,8 +52,8 @@ func (f *fakeCoreSDK) PluginName() string { return "fake" }
func (f *fakeCoreSDK) Settings() pubsdk.SettingsAPI { return nil }
func (f *fakeCoreSDK) Memory() pubsdk.MemoryAPI { return nil }
func (f *fakeCoreSDK) TextMemory() pubsdk.TextMemoryAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return nil }
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return nil }
func (f *fakeCoreSDK) DocMemory() pubsdk.DocMemoryAPI { return f.docMem }
func (f *fakeCoreSDK) Knowledge() pubsdk.KnowledgeAPI { return f.knowledge }
func (f *fakeCoreSDK) LLM() pubsdk.LLMAPI { return nil }
func (f *fakeCoreSDK) Social() pubsdk.SocialAPI { return nil }
func (f *fakeCoreSDK) PluginMgr() pubsdk.PluginMgrAPI { return nil }
@ -88,6 +91,58 @@ func (f *fakeCoreSDK) toolBlockCount() int {
return len(f.toolBlocks)
}
// fakeDocMemory 只实现测试需要的部分,记录 Insert 收到的文档。
type fakeDocMemory struct {
mu sync.Mutex
got *pubsdk.Doc
}
func (f *fakeDocMemory) Query(string, int) []*pubsdk.Doc { return nil }
func (f *fakeDocMemory) Insert(doc *pubsdk.Doc) error {
f.mu.Lock()
f.got = doc
f.mu.Unlock()
return nil
}
func (f *fakeDocMemory) InsertWithMedia(doc *pubsdk.Doc, _ []pubsdk.MediaAttachment) error {
return f.Insert(doc)
}
func (f *fakeDocMemory) Remove(string) {}
func (f *fakeDocMemory) Stats() map[string]interface{} { return nil }
// fakeKnowledge 只实现测试需要的部分,记录 Add 收到的正文。
type fakeKnowledge struct {
mu sync.Mutex
name string
body string
}
func (f *fakeKnowledge) Search(string, int) ([]*pubsdk.Knowledge, error) { return nil, nil }
func (f *fakeKnowledge) Add(name, content string) error {
f.mu.Lock()
f.name, f.body = name, content
f.mu.Unlock()
return nil
}
func (f *fakeKnowledge) List() ([]string, error) { return nil, nil }
// arenaPutForTest 把一段字节放进 arena 并返回引用(测试用)。
func arenaPutForTest(t *testing.T, host *Host, blob []byte) SharedRef {
t.Helper()
arena := host.Arena()
gen := host.Generation()
ref, err := arena.Alloc(OwnerHost, len(blob), gen)
if err != nil {
t.Fatalf("Alloc: %v", err)
}
area, err := arena.Read(ref, gen)
if err != nil {
t.Fatalf("Read: %v", err)
}
copy(area[:len(blob)], blob)
return ref
}
func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled }
func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error {
@ -513,6 +568,99 @@ func TestCoreHandler_SetToolBlocksEmptyRejected(t *testing.T) {
}
}
// §13.13知识正文经共享内存content_ref送达内核。
//
// 正文可达数十 KB内联时整份要在 RPC 报文里再编码再拷贝一遍,且内容本体
// 不在共享段里,插件回调无法就地改写。
func TestCoreHandler_KnowledgeAddViaArena(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
kn := &fakeKnowledge{}
core.knowledge = kn
h := &coreHandler{sdk: core, name: "x", host: host, locks: &lockRegistry{}}
content := strings.Repeat("知识正文", 3000) // 12000 字节
blob, err := json.Marshal(content)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
ref := arenaPutForTest(t, host, blob)
defer func() { _ = host.Arena().Free(OwnerHost, ref) }()
params, _ := json.Marshal(map[string]interface{}{"name": "n", "content_ref": ref})
if _, err := h.Handle(MethodKnowledgeAdd, params); err != nil {
t.Fatalf("knowledge.add 应成功: %v", err)
}
kn.mu.Lock()
got, gotName := kn.body, kn.name
kn.mu.Unlock()
if got != content {
t.Fatalf("经共享内存送达的正文不一致got len=%d want len=%d", len(got), len(content))
}
if gotName != "n" {
t.Fatalf("name 传错: %q", gotName)
}
}
// 内联回退仍可用(直连 RPC 调用方 / arena 不可用)。
func TestCoreHandler_KnowledgeAddInline(t *testing.T) {
core := newFakeCore()
kn := &fakeKnowledge{}
core.knowledge = kn
h := &coreHandler{sdk: core, name: "x", locks: &lockRegistry{}}
params := json.RawMessage(`{"name":"n","content":"短正文"}`)
if _, err := h.Handle(MethodKnowledgeAdd, params); err != nil {
t.Fatalf("内联 knowledge.add 应成功: %v", err)
}
kn.mu.Lock()
got := kn.body
kn.mu.Unlock()
if got != "短正文" {
t.Fatalf("内联正文不一致: %q", got)
}
}
// §13.13文档正文经共享内存doc_ref送达内核。
func TestCoreHandler_DocInsertViaArena(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
core := newFakeCore()
dm := &fakeDocMemory{}
core.docMem = dm
h := &coreHandler{sdk: core, name: "x", host: host, locks: &lockRegistry{}}
doc := &pubsdk.Doc{Title: "标题", Content: strings.Repeat("正文", 5000)}
blob, err := json.Marshal(doc)
if err != nil {
t.Fatalf("Marshal: %v", err)
}
ref := arenaPutForTest(t, host, blob)
defer func() { _ = host.Arena().Free(OwnerHost, ref) }()
params, _ := json.Marshal(map[string]interface{}{"doc_ref": ref})
if _, err := h.Handle(MethodDocInsert, params); err != nil {
t.Fatalf("doc.insert 应成功: %v", err)
}
dm.mu.Lock()
got := dm.got
dm.mu.Unlock()
if got == nil || got.Content != doc.Content {
t.Fatal("经共享内存送达的文档正文不一致")
}
}
// stage 锁在无进行中 stage 时申请应被拒绝(防止插件在 stage 外乱加锁)。
func TestCoreHandler_StageLockOutsideStageRejected(t *testing.T) {
h := &coreHandler{sdk: newFakeCore(), name: "x", locks: &lockRegistry{}}

View File

@ -316,6 +316,10 @@ func TestProcess_ConcurrentCallsRouteCorrectly(t *testing.T) {
}
// 协议版本不匹配必须显式拒绝,不能半兼容运行。
//
// v2 引入的必要性就靠这条v1 插件(只读内联 args遇上 v2 内核,跟 v2 插件
// 发 blocks_ref 给 v1 内核,都会静默失效、不报任何错。只有在这里显式拦下,
// 那双错配才会变成一条带修复指令的启动失败。
func TestProcess_ProtocolMismatchRejected(t *testing.T) {
bin := buildTestPlugin(t, "badprotoplugin.go")
_, err := Spawn("badproto", bin, Options{Handler: noopHandler})
@ -325,6 +329,11 @@ func TestProcess_ProtocolMismatchRejected(t *testing.T) {
if !strings.Contains(err.Error(), "协议版本不匹配") {
t.Errorf("错误应说明版本不匹配,实际: %v", err)
}
// 运维可读性:光报“不匹配”不能定位到行动。生产上碰到它的现场是
// “只更新了内核没重编插件”,所以错误里必须带出这条修复指令。
if !strings.Contains(err.Error(), "plugindev") || !strings.Contains(err.Error(), "重编") {
t.Errorf("错误应给出重编插件的修复指令,实际: %v", err)
}
}
func TestProcess_SpawnRequiresHandler(t *testing.T) {

View File

@ -14,7 +14,15 @@ import "encoding/json"
// 协议版本:与共享段版本独立演进。
// 插件握手时上报,内核校验——不匹配显式拒绝,避免半兼容导致的诡异行为。
const ProtocolVersion = 1
//
// v2§13.6 / §13.13):内核→插件的 payload 改为调用帧承载。
// v1 插件只读内联 args遇上 v2 内核会拿到空参数v2 插件发 blocks_ref
// v1 内核反序列化时静默忽略(旧内核 io.setToolBlocks 还是桩)。两种错配
// 都不会报错,只会静默失效——所以必须 bump 版本,让它在握手上就**显式**失败。
//
// 部署纪律:内核与全部插件必须同批重建、同批安装;改协议就要改这个常量,
// 不得依赖“两边大致兼容”。
const ProtocolVersion = 2
// Direction 无需显式字段:靠 Method 是否为空区分请求与响应
// (与 clawhubadapter/sidecar 的成熟做法一致)。

View File

@ -187,7 +187,7 @@ func main() {
shm = m[ctxOff : ctxOff+ctxSize]
}
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test",
"protocol": 2, "sdk_version": "test",
"plugin_name": "append-" + tag, "pid": os.Getpid(),
}})

View File

@ -95,7 +95,7 @@ func main() {
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1,
"protocol": 2,
"sdk_version": "test",
"plugin_name": "cb",
"pid": os.Getpid(),

View File

@ -40,7 +40,7 @@ func main() {
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test", "plugin_name": "crash", "pid": os.Getpid(),
"protocol": 2, "sdk_version": "test", "plugin_name": "crash", "pid": os.Getpid(),
}})
case "tool.invoke":
// 模拟插件 bug直接 panic进程带非零码退出

View File

@ -43,7 +43,7 @@ func main() {
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1,
"protocol": 2,
"sdk_version": "test",
"plugin_name": "echo",
"pid": os.Getpid(),

View File

@ -57,7 +57,7 @@ func main() {
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test", "plugin_name": "fork", "pid": os.Getpid(),
"protocol": 2, "sdk_version": "test", "plugin_name": "fork", "pid": os.Getpid(),
}})
case "tool.invoke":
// 不回应答,直接退出:模拟插件突然死亡(崩溃/被 kill

View File

@ -42,7 +42,7 @@ func main() {
switch req.Method {
case "handshake":
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test", "plugin_name": "hang", "pid": os.Getpid(),
"protocol": 2, "sdk_version": "test", "plugin_name": "hang", "pid": os.Getpid(),
}})
case "tool.invoke":
// 永久卡住,永不回应答

View File

@ -139,7 +139,7 @@ func main() {
shm = m[ctxOff : ctxOff+ctxSize]
}
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test", "plugin_name": "readonly", "pid": os.Getpid(),
"protocol": 2, "sdk_version": "test", "plugin_name": "readonly", "pid": os.Getpid(),
}})
case "plugin.init":

View File

@ -354,7 +354,7 @@ func main() {
shm = m[ctxOff : ctxOff+ctxSize]
}
send(response{ID: req.ID, Result: map[string]interface{}{
"protocol": 1, "sdk_version": "test", "plugin_name": "stage", "pid": os.Getpid(),
"protocol": 2, "sdk_version": "test", "plugin_name": "stage", "pid": os.Getpid(),
}})
case "plugin.init":
@ -540,12 +540,12 @@ func main() {
args := p.Args
if !p.Frame.IsZero() {
if blob := frameInput(p.Frame, p.ArgsLen); len(blob) > 0 {
var decoded map[string]interface{}
if err := json.Unmarshal(blob, &decoded); err != nil {
send(response{ID: id, Error: "解析输出参数: " + err.Error()})
return
}
args = decoded
var decoded map[string]interface{}
if err := json.Unmarshal(blob, &decoded); err != nil {
send(response{ID: id, Error: "解析输出参数: " + err.Error()})
return
}
args = decoded
}
}
payload, _ := args["payload"].(string)

34
plan.md
View File

@ -1407,19 +1407,35 @@ settings.*、lifecycle.*、arena.alloc/free 自身)不属于此列:它们不
2. ~~`io.injectMedia` / `injectMediaSync` / `injectInterruptMedia`~~ ——
同上共用 `mediaArgsOwned`)。注意同步调用不能在应答返回前释放槽
否则内核读到的是已释放的内存
3. **`doc.insert` / `doc.insertWithMedia`**——文档全文内联 `doc` 是可被
插件回调改写的内容
4. **`knowledge.add(name, content)`**——知识正文内联同上
3. ~~`doc.insert` / `doc.insertWithMedia`~~ —— 已修新增 `doc_ref` /
`attachments_ref`模板序列化后 `putValueInArena`
4. ~~`knowledge.add(name, content)`~~ —— 已修新增 `content_ref`
内容是 JSON 字符串读出后需再解一层)。
5. **反向结果**插件反向调内核读大结果时仍内联正向已有 `ResultRef`)。
这条范围比前四条大需要把整个内核插件的 Rust 应答路径改成
大结果写段 + 返回 ref”,涉及 `callCore` 的返回处理与所有读大结果的
method`doc.query` / `llm.chat` )。未做
**协议版本已 bump 到 2**(§13.613.13 payload 承载变更)。
不再靠文档提醒而是让错配在握手上**显式失败**
- 内核 `proc.ProtocolVersion = 2`模板 `procProtocolVersion = 2`
- 双方都是等值校验 v1 插件遇上 v2 内核会在建链时报
协议版本不匹配请用配套 plugindev 重编”(带修复指令
- 没有这个 bump 的话v1 插件只读内联 args遇到 v2 内核会拿到**空参数**
v2 插件发 blocks_refv1 内核反序列化时**静默忽略**旧内核
`io.setToolBlocks` 还是桩)。两种都是静默失效现场极难定位
**验证**
- [x] `TestCoreHandler_SetToolBlocksViaArena`9000 字节 base64 图经 `blocks_ref`
送达内容一致`TestCoreHandler_SetToolBlocksInline` 保内联回退
`TestCoreHandler_SetToolBlocksEmptyRejected` 防空块静默成功
- [x] `TestCoreHandler_SetToolBlocksViaArena` / `Inline` / `EmptyRejected`
- [x] `TestCoreHandler_KnowledgeAddViaArena` / `Inline`12000 字节正文经
`content_ref` 送达内容一致
- [x] `TestCoreHandler_DocInsertViaArena`正文经 `doc_ref` 送达
- [x] `TestE2E_RealTemplateSetToolBlocksViaArena`**真实 SDK 模板**编译的
插件生产插件走的就是模板模板不走 blocks_ref 则内核实现了也收不到
- [x] 工具链已同步`/usr/local/bin/plugindev` 重建为新协议内嵌 blocks_ref
回滚副本 `plugindev.bak-20260910-224255`
- [ ] 文档/知识正文走共享内存
- [x] `TestProcess_ProtocolMismatchRejected` 加断言错误必须含重编指令
- [x] 工具链已同步`/usr/local/bin/plugindev` = 协议 2内嵌 blocks_ref /
doc_ref / content_ref回滚副本 `plugindev.bak-20260910-232544`
- [ ] 反向结果入内存 5
- [ ] git commit -m "feat(shm): remaining data-plane payloads via shared refs"