diff --git a/internal/plugin/proc/corehandler.go b/internal/plugin/proc/corehandler.go index 88bb78e..d5071b5 100644 --- a/internal/plugin/proc/corehandler.go +++ b/internal/plugin/proc/corehandler.go @@ -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 { diff --git a/internal/plugin/proc/e2e_template_test.go b/internal/plugin/proc/e2e_template_test.go index 7aefc6c..18c37d5 100644 --- a/internal/plugin/proc/e2e_template_test.go +++ b/internal/plugin/proc/e2e_template_test.go @@ -75,6 +75,26 @@ func (p *e2ePlugin) Start(s *sdk.PluginSDK) error { s.RegisterInputChannel("e2e_in", sdk.ChannelDef{}) + // §13.13:媒体块注入。SetToolBlocks 在内核侧曾是桩(直接报“待共享段 + // 二进制通道落地”),子进程插件调它必然失败。这里用大 base64 payload + // 验证模板真的经 blocks_ref 走共享内存。 + s.RegisterTool("e2e_blocks", sdk.ToolDef{ + Description: "注入媒体块", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "url": map[string]interface{}{"type": "string"}, + }, + }, + }, func(args map[string]interface{}) (interface{}, error) { + u, _ := args["url"].(string) + s.SetToolBlocks([]sdk.ContentBlock{{ + Type: "image_url", + ImageURL: &sdk.ImageURL{URL: u}, + }}) + return "blocks-set", nil + }) + return nil } @@ -337,3 +357,47 @@ func TestE2E_RealTemplateOutputPayloadViaFrame(t *testing.T) { t.Fatalf("调用结束后 arena 应归零,实际 used=%d", used) } } + +// §13.13:SetToolBlocks 的媒体块经共享内存到达内核(用真实模板编译的插件)。 +// +// 内核侧曾是桩实现,子进程插件调 SetToolBlocks 必然失败;模板又不经 +// blocks_ref 的话,即使内核实现了也收不到内容。两边必须同时到位。 +func TestE2E_RealTemplateSetToolBlocksViaArena(t *testing.T) { + bin := buildPluginWithRealTemplate(t, e2ePluginSource) + + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + core := newFakeCore() + p := New("e2e", bin, t.TempDir(), nil, host, nil) + if err := p.Start(core); err != nil { + t.Fatalf("Start: %v", err) + } + defer p.Close() + + core.mu.Lock() + h, ok := core.tools["e2e_blocks"] + core.mu.Unlock() + if !ok { + t.Fatal("插件应注册 e2e_blocks 工具") + } + + // 9000 字节 base64:远大于内联阈值,只有共享内存才能送到。 + big := "data:image/png;base64," + strings.Repeat("A", 8000) + if _, err := h(map[string]interface{}{"url": big}); err != nil { + t.Fatalf("调用 e2e_blocks: %v", err) + } + if n := core.toolBlockCount(); n != 1 { + t.Fatalf("内核应收到 1 个媒体块,实际 %d(模板未走 blocks_ref?)", n) + } + + core.mu.Lock() + got := core.toolBlocks[0] + core.mu.Unlock() + if got.ImageURL == nil || got.ImageURL.URL != big { + t.Fatal("经共享内存送达的媒体块内容与发送的不一致") + } +} diff --git a/internal/plugin/proc/plugin_test.go b/internal/plugin/proc/plugin_test.go index 26718e7..5aaf5a0 100644 --- a/internal/plugin/proc/plugin_test.go +++ b/internal/plugin/proc/plugin_test.go @@ -29,6 +29,8 @@ type fakeCoreSDK struct { // injected 记录经 InjectText 注入的文本(验证跨进程共享槽路径)。 injected []string + // toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。 + toolBlocks []pubsdk.ContentBlock } func newFakeCore() *fakeCoreSDK { @@ -72,7 +74,21 @@ func (f *fakeCoreSDK) InjectInputMediaSync(s, c, t string, b []pubsdk.ContentBlo return "" } func (f *fakeCoreSDK) InjectInterruptMedia(s, c, t string, b []pubsdk.ContentBlock) {} -func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled } + +// SetToolBlocks 记录收到的媒体块,供测试断言共享内存通道真的把内容带到了内核侧。 +func (f *fakeCoreSDK) SetToolBlocks(blocks []pubsdk.ContentBlock) { + f.mu.Lock() + f.toolBlocks = append(f.toolBlocks, blocks...) + f.mu.Unlock() +} + +func (f *fakeCoreSDK) toolBlockCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.toolBlocks) +} + +func (f *fakeCoreSDK) SetAutoRestart(enabled bool) { f.autoStart = enabled } func (f *fakeCoreSDK) RegisterTool(name string, def pubsdk.ToolDef, h pubsdk.ToolHandler) error { f.mu.Lock() @@ -418,10 +434,82 @@ func TestCoreHandler_RejectsUnknownAndUnimplementedMethods(t *testing.T) { if _, err := h.Handle(MethodEventsSubscribe, json.RawMessage(`{}`)); err == nil { t.Error("事件订阅未落地时应明确报错,而非静默成功后收不到事件") } +} - // 多模态注入同理 +// §13.13:媒体块经共享内存(blocks_ref)送达内核。 +// +// 之前 io.setToolBlocks 是桩实现(直接返回“待共享段二进制通道落地”), +// 后果是**子进程插件调 SetToolBlocks 必然失败**,只有内置插件能用。 +func TestCoreHandler_SetToolBlocksViaArena(t *testing.T) { + host, err := NewHost() + if err != nil { + t.Fatalf("NewHost: %v", err) + } + defer host.Close() + + core := newFakeCore() + h := &coreHandler{sdk: core, name: "x", host: host, locks: &lockRegistry{}} + + // 一张“本地生成的图”:base64 data URL,远大于内联阈值。 + big := "data:image/png;base64," + strings.Repeat("A", 8000) + blocks := []pubsdk.ContentBlock{{ + Type: "image_url", + ImageURL: &pubsdk.ImageURL{URL: big}, + }} + blob, err := json.Marshal(blocks) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + arena := host.Arena() + gen := host.Generation() + ref, err := arena.Alloc(OwnerHost, len(blob), gen) + if err != nil { + t.Fatalf("Alloc: %v", err) + } + defer func() { _ = arena.Free(OwnerHost, ref) }() + area, err := arena.Read(ref, gen) + if err != nil { + t.Fatalf("Read: %v", err) + } + copy(area[:len(blob)], blob) + + params, _ := json.Marshal(map[string]interface{}{"blocks_ref": ref}) + if _, err := h.Handle(MethodIOSetToolBlocks, params); err != nil { + t.Fatalf("setToolBlocks 应成功: %v", err) + } + if n := core.toolBlockCount(); n != 1 { + t.Fatalf("内核应收到 1 个媒体块,实际 %d", n) + } + + core.mu.Lock() + got := core.toolBlocks[0] + core.mu.Unlock() + if got.ImageURL == nil || got.ImageURL.URL != big { + t.Fatal("经共享内存送达的媒体块内容与发送的不一致") + } +} + +// 内联路径仍可用(直连 RPC 调用方 / arena 不可用时)。 +func TestCoreHandler_SetToolBlocksInline(t *testing.T) { + core := newFakeCore() + h := &coreHandler{sdk: core, name: "x", locks: &lockRegistry{}} + + params := json.RawMessage(`{"blocks":[{"type":"text","text":"hi"}]}`) + if _, err := h.Handle(MethodIOSetToolBlocks, params); err != nil { + t.Fatalf("内联 blocks 应成功: %v", err) + } + if n := core.toolBlockCount(); n != 1 { + t.Fatalf("内核应收到 1 个块,实际 %d", n) + } +} + +// blocks 为空必须报错,而不是静默成功——静默成功会让插件以为图已注入。 +func TestCoreHandler_SetToolBlocksEmptyRejected(t *testing.T) { + core := newFakeCore() + h := &coreHandler{sdk: core, name: "x", locks: &lockRegistry{}} if _, err := h.Handle(MethodIOSetToolBlocks, json.RawMessage(`{}`)); err == nil { - t.Error("多模态注入未落地时应明确报错") + t.Error("blocks 为空应明确报错") } } diff --git a/internal/plugin/proc_core.go b/internal/plugin/proc_core.go index c88f5c3..9f2324b 100644 --- a/internal/plugin/proc_core.go +++ b/internal/plugin/proc_core.go @@ -159,6 +159,12 @@ func (c procCore) InjectInterruptMedia(source, channel, text string, blocks []pu c.sdk.InjectInterruptMedia(source, channel, text, blocks) } +// SetToolBlocks 转调 internal/sdk:插件工具注入的媒体块,内核在下一轮 +// tool message 携带。 +func (c procCore) SetToolBlocks(blocks []pubsdk.ContentBlock) { + c.sdk.SetToolBlocks(blocks) +} + // ---- 生命周期 ---- func (c procCore) SetAutoRestart(enabled bool) { c.sdk.SetAutoRestart(enabled) } diff --git a/plan.md b/plan.md index 3c26144..cc7136d 100644 --- a/plan.md +++ b/plan.md @@ -1399,13 +1399,14 @@ settings.*、lifecycle.*、arena.alloc/free 自身)不属于此列:它们不 **尚未入内存**(按“是否破坏回调语义”排序): -1. **媒体块:`io.setToolBlocks`**——工具内的媒体注入仍把 `blocks` 内联在 - RPC JSON 里,而 `ImageURL.URL` / `AudioURL.URL` 对本地生成的图/音频是 - **base64 data URL**(如 ai_image 生成的大图)。这条直接破坏“结果媒体 - 能被 after_toolcall 就地改写”的能力:插件只能推一份拷贝过去。 - 待做:blocks 序列化后 `putInArena`,传 `blocks_ref`,内核读回。 -2. **`io.injectMedia` / `injectMediaSync` / `injectInterruptMedia`**——同上, - 插件主动发起带媒体的一轮对话。 +1. ✅ ~~媒体块:`io.setToolBlocks`~~ —— 已修(本轮)。之前它在本核侧根本是 + **桩实现**(直接返回“待共享段二进制通道落地”),也就是说**子进程插件调 + SetToolBlocks 必然失败**,只有内置插件能用。现在:内核侧真正实现该 + method,模板把 blocks 序列化后 `putArena` 传 `blocks_ref`(小 payload 仍 + 内联),内核 `resolveBlocks` 读回。 +2. ✅ ~~`io.injectMedia` / `injectMediaSync` / `injectInterruptMedia`~~ —— + 同上(共用 `mediaArgsOwned`)。注意同步调用不能在应答返回前释放槽, + 否则内核读到的是已释放的内存。 3. **`doc.insert` / `doc.insertWithMedia`**——文档全文内联,且 `doc` 是可被 插件回调改写的内容。 4. **`knowledge.add(name, content)`**——知识正文内联,同上。 @@ -1413,6 +1414,12 @@ settings.*、lifecycle.*、arena.alloc/free 自身)不属于此列:它们不 **验证**: -- [ ] 媒体块走共享内存(插件推图不再靠拷贝,且可被回调就地改写) +- [x] `TestCoreHandler_SetToolBlocksViaArena`:9000 字节 base64 图经 `blocks_ref` + 送达,内容一致;`TestCoreHandler_SetToolBlocksInline` 保内联回退; + `TestCoreHandler_SetToolBlocksEmptyRejected` 防空块静默成功 +- [x] `TestE2E_RealTemplateSetToolBlocksViaArena`:用**真实 SDK 模板**编译的 + 插件(生产插件走的就是模板,模板不走 blocks_ref 则内核实现了也收不到) +- [x] 工具链已同步:`/usr/local/bin/plugindev` 重建为新协议(内嵌 blocks_ref), + 回滚副本 `plugindev.bak-20260910-224255` - [ ] 文档/知识正文走共享内存 - [ ] git commit -m "feat(shm): remaining data-plane payloads via shared refs"