mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -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 {
|
||||
|
||||
@ -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("经共享内存送达的媒体块内容与发送的不一致")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 为空应明确报错")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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) }
|
||||
|
||||
23
plan.md
23
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"
|
||||
|
||||
Reference in New Issue
Block a user