From 8537577123e0a1bd7161984750bb6725df29e6f0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 15:09:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(resident):=20=E9=A9=BB=E7=95=99=E5=AD=90?= =?UTF-8?q?=E7=BB=A7=E6=89=BF=E7=88=B6=E7=9A=84=E8=BE=93=E5=87=BA=E9=80=9A?= =?UTF-8?q?=E9=81=93=20=E2=80=94=E2=80=94=20=E4=BF=AE=E3=80=8C=E5=AD=90?= =?UTF-8?q?=E4=BE=A7=20childIO=20=E7=A9=BA=E5=A3=B3=E3=80=81=E5=AD=90?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E5=8F=91=E6=B6=88=E6=81=AF=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现场(用户在线上跑驻留子联调,日志实录): 父 agent 侧「通道装载完整」,子 `demo-resident` 侧 `childIO` 是**空壳**: 子的 `output_list_channels` 为空、`output_send__<通道>` 一律被判 「通道 [X] 不存在或不可用」,连 `output_send__*` 工具都不生成 ⇒ 子不会发消息。 根因:**输出通道在 io 层就是 Device**,而它们由插件登记在**父**的 `IOManager` 上。 `SpawnResident` 给子建的是全新 `IOManager`(它确实该有自己的输入入口与 outputCh), 却只共享了 inputch 登记表,**没有继承设备/输出通道视图**: - `executeOutputSendTool` → `a.io.GetChannelCapabilities(ch)` 查的是 `devices[ch]` ⇒ 0 - 投递路径 `a.io.GetDevice(ch).Execute("output", …)` ⇒ nil - 工具面 `tooldefs.go` 从 `a.io.ListChannels()` 生成 `output_send__*` ⇒ 空 改法:给 `IOManager` 增加**上级回退**(`SetParentIO`)——驻留子创建时把自己的 io 挂到 父的 io 上,`GetDevice` / `GetChannelCapabilities` / `ListChannels` / `ExecuteTool` 在自己没有时回退到上级。 为什么是**实时回退**而不是创建时复制快照:设备随资源生灭(远程设备上线/掉线以分钟计, 现场日志 60 秒一个来回),复制出来的表转瞬即过期;而回退永远与父一致。 **授权不受影响**:回退只解决"看得见",能不能用仍由各自的 `AllowedOutputs` 白名单把关 (`executeOutputSendTool` 的授权闸 + 工具生成时的过滤都在白名单之后); 自己的登记优先,子可以覆盖/屏蔽同名通道。 判据(新增 5 条): - io 层:无上级时行为与以前完全一致 / 挂上级后看得见 / **实时**(父新登记立刻可见、 注销立刻不可见)/ 同名自己的优先且不重复列出 / `ExecuteTool` 同样回退 - 内核层:子看得见父通道 + 真能发出(父通道收到 1 次 output)/ 白名单外被拒且未送达 / 子工具面只生成授权通道(含 `_help`)/ 父后登记的通道立刻可见 / 默认即完整授权 --- internal/agent/core/resident.go | 8 +- internal/agent/core/resident_output_test.go | 145 ++++++++++++++++++++ internal/agent/io/channel.go | 84 ++++++++++-- internal/agent/io/parentio_test.go | 102 ++++++++++++++ 4 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 internal/agent/core/resident_output_test.go create mode 100644 internal/agent/io/parentio_test.go diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 597e2a5..caaabf0 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -132,11 +132,17 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) { } } - // ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表。 + // ③ 子的 io:**独立**的 IOManager(自己的输入通道入口),但共享通道登记表, + // 并把父的 io 挂成"上级"——**输出通道(io 里的 Device)由插件登记在父的 io 上**, + // 子若不继承这张视图,`output_send__<通道>` 一律被判"通道不存在或不可用"、 + // `output_list_channels` 为空、连 `output_send__*` 工具都不会生成 + // (现场联调:父侧通道装载完整、子侧 childIO 空壳)。 + // 回退是实时的(设备随资源生灭),授权仍由 opts.AllowedOutputs 白名单把关。 childIO := agentIO.NewIOManager() if reg := a.io.ChannelRegistry(); reg != nil { childIO.SetChannelRegistry(reg) } + childIO.SetParentIO(a.io) parentID := string(a.id) child := New(AgentConfig{ diff --git a/internal/agent/core/resident_output_test.go b/internal/agent/core/resident_output_test.go new file mode 100644 index 0000000..4211d39 --- /dev/null +++ b/internal/agent/core/resident_output_test.go @@ -0,0 +1,145 @@ +package core + +import ( + "path/filepath" + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// outputTestDevice 是最小的输出通道替身(io 里输出通道就是 Device)。 +type outputTestDevice struct { + name string + sent []map[string]interface{} +} + +func (d *outputTestDevice) Name() string { return d.name } +func (d *outputTestDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput } +func (d *outputTestDevice) Description() string { return "测试输出通道" } +func (d *outputTestDevice) Tools() []agentIO.ToolDef { return nil } +func (d *outputTestDevice) Start() error { return nil } +func (d *outputTestDevice) Stop() error { return nil } +func (d *outputTestDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText } +func (d *outputTestDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} } +func (d *outputTestDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) { + d.sent = append(d.sent, map[string]interface{}{"tool": tool, "args": args}) + return map[string]interface{}{"status": "sent"}, nil +} + +func outputSendTool(name, payload string) agentAPI.ToolCall { + return agentAPI.ToolCall{ + Name: "output_send__" + name, + Arguments: map[string]interface{}{"payload": payload, "type": "text"}, + } +} + +// 驻留子必须能看见并使用**父**登记的输出通道。 +// +// 现场缺陷(联调实录):父侧通道装载完整、子侧 childIO 空壳 —— +// 子调 output_send__X 被 `GetChannelCapabilities` 判 0 ⇒ +// 「通道 [X] 不存在或不可用。可用输出工具列表见 output_list_channels」, +// 而 output_list_channels 也是空的。根因是子的 io 是新建的、设备表为空, +// 而输出通道(io 的 Device)由插件登记在父的 io 上。 +func TestResident_InheritsParentOutputChannels(t *testing.T) { + parent, _, dir := newRootForResidents(t) + defer parent.Stop() + + fake := &outputTestDevice{name: "fakeout"} + other := &outputTestDevice{name: "other"} + if err := parent.io.RegisterDevice(fake); err != nil { + t.Fatal(err) + } + if err := parent.io.RegisterDevice(other); err != nil { + t.Fatal(err) + } + + if _, err := parent.SpawnResident(ResidentOptions{ + ID: "r-out", + TaskPrompt: "有情况就发到 fakeout", + // 白名单只放行一个:验证"继承可见"不等于"绕过授权" + AllowedOutputs: []string{"fakeout"}, + TempPath: filepath.Join(dir, "residents", "r-out", "graph.db"), + }); err != nil { + t.Fatalf("创建驻留子失败: %v", err) + } + child := parent.residents["r-out"].agent + + // ① 看得见:修复前这里是 0(childIO 空壳) + if caps := child.io.GetChannelCapabilities("fakeout"); caps == 0 { + t.Fatal("驻留子看不见父的输出通道(childIO 空壳)") + } + // ② 发得出去:真走 dev.Execute("output", ...) + if out := child.executeOutputSendTool(outputSendTool("fakeout", "子发来的消息")); out != "ok" { + t.Fatalf("子发送应成功,得到 %q", out) + } + if len(fake.sent) != 1 { + t.Fatalf("父通道应收到 1 次输出,得到 %d", len(fake.sent)) + } + + // ③ 授权闸不被回退绕过:白名单外的通道照样拒绝 + if out := child.executeOutputSendTool(outputSendTool("other", "越权")); !strings.Contains(out, "未授权") { + t.Fatalf("白名单外的通道应被拒,得到 %q", out) + } + if len(other.sent) != 0 { + t.Fatal("越权输出不应真的送达") + } + + // ④ 工具面一致:子应生成 output_send__fakeout(含配套 _help), + // 而**不生成**白名单外通道的工具 —— 模型看不到就不会去调。 + var names []string + for _, td := range child.buildToolDefs() { + entry, _ := td.(map[string]interface{}) + fn, _ := entry["function"].(map[string]interface{}) + if n, _ := fn["name"].(string); strings.HasPrefix(n, "output_send__") { + names = append(names, n) + } + } + has := func(want string) bool { + for _, n := range names { + if n == want { + return true + } + } + return false + } + if !has("output_send__fakeout") || !has("output_send__fakeout_help") { + t.Fatalf("子缺少授权通道的输出工具,得到 %v", names) + } + for _, n := range names { + if strings.HasPrefix(n, "output_send__other") { + t.Fatalf("白名单外的通道不该生成工具,得到 %v", names) + } + } + + // ⑤ 实时性:父之后新登记的通道,子立刻可见(设备随资源生灭) + late := &outputTestDevice{name: "late"} + if err := parent.io.RegisterDevice(late); err != nil { + t.Fatal(err) + } + if caps := child.io.GetChannelCapabilities("late"); caps == 0 { + t.Fatal("父新登记的通道未实时反映到子(说明是快照而非回退)") + } +} + +// 默认授权(AllowedOutputs 空)= 完整授权:子用父的全部输出通道。 +func TestResident_DefaultOutputsAreFull(t *testing.T) { + parent, _, dir := newRootForResidents(t) + defer parent.Stop() + + dev := &outputTestDevice{name: "anywhere"} + if err := parent.io.RegisterDevice(dev); err != nil { + t.Fatal(err) + } + if _, err := parent.SpawnResident(ResidentOptions{ + ID: "r-full", TaskPrompt: "待命", + TempPath: filepath.Join(dir, "residents", "r-full", "graph.db"), + }); err != nil { + t.Fatalf("创建驻留子失败: %v", err) + } + child := parent.residents["r-full"].agent + if out := child.executeOutputSendTool(outputSendTool("anywhere", "默认授权")); out != "ok" { + t.Fatalf("默认应完整授权,得到 %q", out) + } +} diff --git a/internal/agent/io/channel.go b/internal/agent/io/channel.go index 1f11fbb..9a75461 100644 --- a/internal/agent/io/channel.go +++ b/internal/agent/io/channel.go @@ -104,6 +104,20 @@ type IOManager struct { nextReqID int64 channelReg *ChannelRegistry + // parent 是"上级 IOManager"(驻留子的轻量内核指向父的内核)。 + // + // 为什么需要:**输出通道在 io 层就是 Device**,而它们是由插件登记在**父**的 + // io 上的。驻留子有自己的 IOManager(自己的输入入口、自己的 outputCh), + // 若只看自己那张空表,`output_send__<通道>` 会被判"通道不存在或不可用", + // `output_list_channels` 是空的,`output_send__*` 工具也不会生成 + // —— 现场表现就是"驻留子不会说话/不会发消息"(联调实录:父侧通道装载完整、 + // 子侧 childIO 空壳)。 + // + // 用**实时回退**而不是创建时复制快照:设备会随资源生灭(远程设备上线/掉线 + // 以分钟计),复制出来的表转瞬就过期。授权由各自的 AllowedOutputs 白名单把关, + // 回退只解决"看得见",不解决"能不能用"。 + parent *IOManager + // toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。 // 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。 toolBlocksMu sync.Mutex @@ -120,6 +134,31 @@ func NewIOManager() *IOManager { } } +// SetParentIO 设置上级 IOManager(nil 表示无上级,行为与以前完全一致)。 +// 见 parent 字段的说明:用于驻留子继承父的输出通道/设备视图。 +func (m *IOManager) SetParentIO(p *IOManager) { + m.mu.Lock() + m.parent = p + m.mu.Unlock() +} + +// lookupDevice 查设备:自己的登记优先,其次回退到上级。 +// +// 先在自己锁内取快照再查上级,**不跨锁调用**(避免锁序问题)。 +func (m *IOManager) lookupDevice(name string) Device { + m.mu.RLock() + dev, ok := m.devices[name] + parent := m.parent + m.mu.RUnlock() + if ok { + return dev + } + if parent != nil { + return parent.GetDevice(name) + } + return nil +} + func (m *IOManager) UnregisterDevice(name string) { m.mu.Lock() defer m.mu.Unlock() @@ -158,9 +197,7 @@ func (m *IOManager) RegisterDevice(dev Device) error { } func (m *IOManager) GetDevice(name string) Device { - m.mu.RLock() - defer m.mu.RUnlock() - return m.devices[name] + return m.lookupDevice(name) } func (m *IOManager) StartAll() error { @@ -542,6 +579,15 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i m.mu.RUnlock() if len(candidates) == 0 { + // 自己没这个设备工具 → 看上级(驻留子的设备工具都在父的 io 上)。 + m.mu.RLock() + parent := m.parent + m.mu.RUnlock() + if parent != nil { + if ret, err := parent.ExecuteTool(name, args); err == nil { + return ret, nil + } + } return nil, fmt.Errorf("tool %s not found", name) } defer func() { @@ -574,10 +620,22 @@ type ChannelInfo struct { func (m *IOManager) ListChannels() []ChannelInfo { m.mu.RLock() - defer m.mu.RUnlock() + own := make(map[string]Device, len(m.devices)) + for name, dev := range m.devices { + own[name] = dev + } + parent := m.parent + m.mu.RUnlock() + // 自己的登记优先(子侧可覆盖/屏蔽同名通道),随后并入上级的可见通道。 + // 去重按**名字**:同名即视为同一个通道,不重复列举。 + seen := make(map[string]bool, len(own)) var list []ChannelInfo - for _, dev := range m.devices { + appendDev := func(dev Device) { + if seen[dev.Name()] { + return + } + seen[dev.Name()] = true list = append(list, ChannelInfo{ Name: dev.Name(), Type: dev.Type(), @@ -586,13 +644,23 @@ func (m *IOManager) ListChannels() []ChannelInfo { OutputCaps: dev.OutputCapabilities(), }) } + for _, dev := range own { + appendDev(dev) + } + if parent != nil { + for _, ch := range parent.ListChannels() { + if seen[ch.Name] { + continue + } + seen[ch.Name] = true + list = append(list, ch) + } + } return list } func (m *IOManager) GetChannelCapabilities(channel string) OutputCapability { - m.mu.RLock() - defer m.mu.RUnlock() - if dev, ok := m.devices[channel]; ok { + if dev := m.lookupDevice(channel); dev != nil { return dev.OutputCapabilities() } return 0 diff --git a/internal/agent/io/parentio_test.go b/internal/agent/io/parentio_test.go new file mode 100644 index 0000000..915c307 --- /dev/null +++ b/internal/agent/io/parentio_test.go @@ -0,0 +1,102 @@ +package io + +import "testing" + +// 上级回退:驻留子的轻量内核有自己的 IOManager,但输出通道(io 里的 Device) +// 是插件登记在**父**的 io 上的。子若看不见它们,`output_send__<通道>` 会被判 +// "通道不存在或不可用"、`output_list_channels` 为空 —— 现场联调实录 +// 「父侧通道装载完整、子侧 childIO 空壳」。 +func TestIOManagerParentFallback(t *testing.T) { + parent := NewIOManager() + if err := parent.RegisterDevice(&mockDevice{name: "qq", devType: DeviceOutput, caps: CapText}); err != nil { + t.Fatal(err) + } + + child := NewIOManager() + // 未挂上级时行为与以前完全一致(不能悄悄多出通道) + if got := child.GetChannelCapabilities("qq"); got != 0 { + t.Fatalf("无上级时不应看见父的通道,得到 %v", got) + } + if n := len(child.ListChannels()); n != 0 { + t.Fatalf("无上级时通道数应为 0,得到 %d", n) + } + + child.SetParentIO(parent) + if got := child.GetChannelCapabilities("qq"); got != CapText { + t.Fatalf("挂上级后应看见父通道能力 CapText,得到 %v", got) + } + if dev := child.GetDevice("qq"); dev == nil || dev.Name() != "qq" { + t.Fatalf("GetDevice 未回退到父: %v", dev) + } + if n := len(child.ListChannels()); n != 1 { + t.Fatalf("ListChannels 未回退到父,得到 %d 条", n) + } + + // **实时**回退而非快照:父后来登记的通道,子立刻可见。 + // (设备随资源生灭 —— 远程设备上线/掉线以分钟计,快照一分钟就过期) + if err := parent.RegisterDevice(&mockDevice{name: "newdev", devType: DeviceOutput, caps: CapImage}); err != nil { + t.Fatal(err) + } + if got := child.GetChannelCapabilities("newdev"); got != CapImage { + t.Fatalf("子应实时看见父新登记的通道,得到 %v", got) + } + + // 父掉线注销后,子也立刻看不见(不是复制出来的旧表) + parent.UnregisterDevice("newdev") + if got := child.GetChannelCapabilities("newdev"); got != 0 { + t.Fatalf("父注销后子不应再看见,得到 %v", got) + } +} + +// 自己的登记优先:子可以覆盖/屏蔽同名通道,父的登记不会重复列出。 +func TestIOManagerOwnDeviceWins(t *testing.T) { + parent := NewIOManager() + if err := parent.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapText}); err != nil { + t.Fatal(err) + } + child := NewIOManager() + child.SetParentIO(parent) + if err := child.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapImage}); err != nil { + t.Fatal(err) + } + + if got := child.GetChannelCapabilities("ch"); got != CapImage { + t.Fatalf("同名时自己的登记应优先,得到 %v", got) + } + list := child.ListChannels() + if len(list) != 1 { + t.Fatalf("同名通道不应重复列出,得到 %d 条", len(list)) + } + if list[0].OutputCaps != CapImage { + t.Fatalf("列出的应是子自己的那条,得到 %v", list[0].OutputCaps) + } +} + +// 设备工具(io.ExecuteTool)同样回退:子的设备工具都在父的 io 上。 +func TestIOManagerExecuteToolFallsBackToParent(t *testing.T) { + parent := NewIOManager() + called := 0 + if err := parent.RegisterDevice(&mockDevice{ + name: "dev", devType: DeviceIO, + tools: []ToolDef{{Name: "dev_do", Description: "干点什么"}}, + executeFn: func(tool string, args map[string]interface{}) (interface{}, error) { + called++ + return "parent-done", nil + }, + }); err != nil { + t.Fatal(err) + } + + child := NewIOManager() + if _, err := child.ExecuteTool("dev_do", nil); err == nil { + t.Fatal("无上级时不该能执行父的设备工具") + } + child.SetParentIO(parent) + got, err := child.ExecuteTool("dev_do", map[string]interface{}{"x": 1}) + if err != nil { + t.Fatalf("应回退到父执行: %v", err) + } + if got != "parent-done" || called != 1 { + t.Fatalf("执行结果=%v called=%d", got, called) + } +}