feat(shm): 媒体块入共享内存 + 实现 setToolBlocks 桩(§13.13)

两个问题叠在一起,先发现的是第二个:

1. **io.setToolBlocks 在内核侧是桩实现**——直接返回“多模态注入待共享段
   二进制通道落地”。也就是**子进程插件调 SetToolBlocks 必然失败**(模板
   只 log 一行),只有内置插件(multimodal)能用。之前还有测试把这个错误
   当作预期行为断言着。
2. 媒体块(injectMedia 系列 + setToolBlocks)把 blocks 内联在 RPC JSON 里,
   而本地生成的图/音频是 base64 data URL,一张图可达数 MB。

顺带修掉一条与设计相悖的旧注释:injectMediaParams 写着“data URL 已是
base64 文本、再套一层二进制不会更小,所以走 JSON”。那只算了体积,漏了
两件更重要的事——内联时整份 base64 要在 RPC 报文里再编码/再拷贝一遍;
以及内容本体不在共享段里,插件回调就无法就地改写,只能各持一份拷贝。
共享内存的意义是后者。

实现:
- injectMediaParams 加 BlocksRef;新增 resolveBlocks(共享优先、内联回退)
- 真正实现 MethodIOSetToolBlocks(空块明确报错,不静默成功)
- CoreSDK 接口加 SetToolBlocks,procCore 转调 internal/sdk
- 模板:SetToolBlocks / injectMedia 三兄弟经 putValueInArena 传 blocks_ref;
  同步调用用 mediaArgsOwned 拿释放函数,**不能在应答返回前释放槽**,
  否则内核读到已释放内存

验证(-race 全绿):
- TestCoreHandler_SetToolBlocksViaArena / Inline / EmptyRejected
- TestE2E_RealTemplateSetToolBlocksViaArena:真实 SDK 模板编译的插件,
  9000 字节 base64 图经 blocks_ref 完整送达

工具链已同步:/usr/local/bin/plugindev 重建为新协议(内嵌 blocks_ref),
回滚副本 plugindev.bak-20260910-224255。注意内核与全部插件必须同批替换
——只换一边会静默坏(新内核+旧插件=输出 payload 变空;新插件+旧内核=
媒体注入静默失效),已记入长期记忆。
This commit is contained in:
JianFeeeee
2026-09-10 22:45:19 +08:00
parent 2a4315cfe4
commit 15d912ef34
5 changed files with 241 additions and 24 deletions

View File

@ -101,6 +101,9 @@ type CoreSDK interface {
InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string
InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock)
// SetToolBlocks 注入媒体块,内核在下一条 tool message 携带§3.8)。
SetToolBlocks(blocks []pubsdk.ContentBlock)
SetAutoRestart(enabled bool)
}
@ -186,7 +189,11 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectInputMedia(p.Source, p.Channel, p.Text, p.Blocks)
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
h.sdk.InjectInputMedia(p.Source, p.Channel, p.Text, blocks)
return nil, nil
case MethodIOInjectMediaSync:
@ -194,7 +201,11 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
reply := h.sdk.InjectInputMediaSync(p.Source, p.Channel, p.Text, p.Blocks)
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
reply := h.sdk.InjectInputMediaSync(p.Source, p.Channel, p.Text, blocks)
return map[string]interface{}{"reply": reply}, nil
case MethodIOInjectInterruptMedia:
@ -202,7 +213,11 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := unmarshal(params, &p); err != nil {
return nil, err
}
h.sdk.InjectInterruptMedia(p.Source, p.Channel, p.Text, p.Blocks)
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
h.sdk.InjectInterruptMedia(p.Source, p.Channel, p.Text, blocks)
return nil, nil
// ---- 生命周期(原 case 8----
@ -573,10 +588,25 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
}
return nil, h.arenaFree(p.Ref)
// ---- 多模态注入C ABI 侧空实现)----
// ---- 多模态注入 ----
//
// 之前这里是桩:返回“待共享段二进制通道落地”。后果是**子进程插件调
// SetToolBlocks 必然失败**(模板只 log 一行),只有内置插件能用。
// 现在媒体块经共享内存传递,该能力对两种插件形态等价。
case MethodIOSetToolBlocks:
// Part 4 扩展:二进制落 arena、Slice 描述符回传§3.8)。
return nil, fmt.Errorf("%s: 多模态注入待共享段二进制通道落地", method)
var p injectMediaParams
if err := unmarshal(params, &p); err != nil {
return nil, err
}
blocks, err := h.resolveBlocks(p)
if err != nil {
return nil, err
}
if len(blocks) == 0 {
return nil, fmt.Errorf("%s: blocks 为空", method)
}
h.sdk.SetToolBlocks(blocks)
return nil, nil
}
return nil, fmt.Errorf("未知 method: %s", method)
@ -604,15 +634,37 @@ type injectParams struct {
TextRef SharedRef `json:"text_ref,omitempty"`
}
// injectMediaParams 是带媒体注入的参数。
// injectMediaParams 是带媒体注入/工具块注入的参数。
//
// blocks 走 JSON而非共享段二进制通道data URL 已是 base64 文本,
// 再套一层二进制传输不会更小, JSON 让这条路径与其他 method 一致。
// blocks 优先经共享内存传递BlocksRef。旧的注释说“data URL 已是 base64
// 文本、再套一层二进制不会更小,所以走 JSON”——那只算了体积,漏了两件更重要
// 的事:① 内联时整份 base64 要在 RPC 报文里再编码/再拷贝一遍(一张本地生图
// 可达数 MB② 内容本体不在共享段里,插件回调就无法就地改写,只能各自
// 持一份拷贝。共享内存的意义是后者。
//
// 没有 BlocksRef 时(直连 RPC 测试、arena 不可用)回退内联 Blocks。
type injectMediaParams struct {
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text"`
Blocks []pubsdk.ContentBlock `json:"blocks"`
Source string `json:"source"`
Channel string `json:"channel"`
Text string `json:"text,omitempty"`
Blocks []pubsdk.ContentBlock `json:"blocks,omitempty"`
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
}
// 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)
}
return blocks, nil
}
func unmarshal(params json.RawMessage, out interface{}) error {