Files
HomeAgent/internal/agent/core/resident_output_test.go
JianFeeeee 8537577123 fix(resident): 驻留子继承父的输出通道 —— 修「子侧 childIO 空壳、子不会发消息」
现场(用户在线上跑驻留子联调,日志实录):
  父 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`)/ 父后登记的通道立刻可见 / 默认即完整授权
2026-09-13 15:09:40 +08:00

146 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// ① 看得见:修复前这里是 0childIO 空壳)
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)
}
}