From f7c3a4e81d93e6b84bb9ea118140274295ef2c75 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 09:13:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20N1b=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E9=80=9A=E9=81=93=E6=8E=88=E6=9D=83=E9=9B=86?= =?UTF-8?q?=E5=90=88=EF=BC=88=E4=B8=89=E5=A4=84=E8=BF=87=E6=BB=A4=E4=B8=80?= =?UTF-8?q?=E8=87=B4=EF=BC=89+=20=E8=BE=93=E5=87=BA=E9=80=9A=E9=81=93?= =?UTF-8?q?=E2=86=92=E7=9B=AE=E6=A0=87=20agent=20=E7=9A=84=20inputch=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计:docs/zh/resident-subagent-design.md §4.5(里程碑 N1b)。 ## 输出通道授权集合(默认完整授权,父可收窄) `AgentConfig.AllowedOutputs`(nil/空 = 完整授权)。三处过滤点必须一致, 否则会出现「列表里看不到、按名字还能调」的裂缝: 1. **工具表**:不为未授权的通道生成 output_send__X(模型看不到就不会调) 2. **列表工具**:output_list_channels 只列授权的 3. **调用点**:凭名字直调未授权的输出门必须被拒(纵深防御) ## 输出通道 → 目标 agent 的 inputch 解析 `ChannelRegistry.BindOutputTarget / ResolveOutputTarget`: 把输出通道解析成「目标 agent + 目标 inputch」,这是"输出可寻址到具体 agent" (子→父、父→指定子)的**数据面**;真正的跨 agent 投递在里程碑 N4。 未登记的输出通道 ok=false —— 表示由传输层 device 自行处理(qq/webui 这类)。 已登记目标的输出通道,在 output_list_channels 里会标出「目标: / inputch <名字>」。 ## 验收 `internal/agent/core/output_grant_test.go`(3 项): - 默认完整授权:全部输出门生成 + 列表含全部 - 白名单收窄:三个过滤点同时生效(工具表 / 列表 / 直调被拒) - 目标解析:绑定/解析、未登记由传输层处理、列表标出目标、空名报错 全仓 go test ./... 37 包 ok / 0 FAIL。 --- docs/zh/resident-subagent-design.md | 18 ++- internal/agent/core/agent.go | 46 +++++++- internal/agent/core/output.go | 14 ++- internal/agent/core/output_grant_test.go | 138 +++++++++++++++++++++++ internal/agent/core/tooldefs.go | 5 + internal/agent/io/inputch.go | 53 ++++++++- 6 files changed, 265 insertions(+), 9 deletions(-) create mode 100644 internal/agent/core/output_grant_test.go diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index 4c2ad23..5ce87d6 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -112,7 +112,23 @@ - **子 → 主**:子直接打到主(经输出通道投进主的 inputch); - **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。 -### 4.5 通道的一等化(实现要求) +### 4.5 已经落地/待落地的两件事 + +**已落地(N1a)**:inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表 ++ 单工具多视图总览(`input_channels`,见 §4.6)。 + +**已落地(N1b)**: +- **输出通道授权集合**[已定:默认完整授权,父可收窄]: + `AgentConfig.AllowedOutputs`(nil/空 = 全部)。三处过滤点必须一致, + 否则会出现"列表里看不到、按名字还能调"的裂缝: + 1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调); + 2. **列表工具**:`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch"); + 3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。 +- **输出通道 → 目标 agent 的 inputch 解析**:`ChannelRegistry.BindOutputTarget` / + `ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。 + 这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。 + +### 4.5.1 通道的一等化(后续要求) 现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**, `inputChannels` 只是策略表(`ChannelDef`:NoMemory / Cleaner / ContextPolicy), diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index a9561b1..d38ff06 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -68,6 +68,9 @@ type Agent struct { // 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。 personaStore PersonaStore + // 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs)。 + allowedOutputs []string + // 插件注册表(用于 plgreload) pluginReg *plugin.Registry pluginDir string @@ -200,12 +203,17 @@ type AgentConfig struct { MultimodalSpace vector.MultimodalEmbedder // EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因, // 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。 - EmbeddingProvider string - EmbeddingError string - FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 - Personality *agentPkg.Personality - PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具) - PluginReg *plugin.Registry + EmbeddingProvider string + EmbeddingError string + FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认 + Personality *agentPkg.Personality + PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具) + PluginReg *plugin.Registry + // AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2)。 + // + // nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。 + // 父 agent 创建驻留子时用它收窄子的输出能力。 + AllowedOutputs []string PluginDir string DistillInterval time.Duration ArchiveInterval time.Duration // 冷文档归档间隔(L2→L3),0 则使用 DistillInterval @@ -296,6 +304,7 @@ func New(cfg AgentConfig) *Agent { mediaStore: cfg.MediaStore, personality: cfg.Personality, personaStore: cfg.PersonaStore, + allowedOutputs: cfg.AllowedOutputs, pluginReg: cfg.PluginReg, pluginDir: cfg.PluginDir, distillInterval: cfg.DistillInterval, @@ -344,6 +353,31 @@ func (a *Agent) Stop() { func (a *Agent) ID() types.AgentID { return a.id } +// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。 +// +// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。 +func (a *Agent) IsOutputAllowed(channel string) bool { + if len(a.allowedOutputs) == 0 { + return true + } + for _, c := range a.allowedOutputs { + if c == channel { + return true + } + } + return false +} + +// ResolveOutputTarget 解析输出通道的投递目标(agent + inputch)。 +// +// ok=false 表示该输出通道由传输层(device 通道,如 qq/webui)自行处理。 +func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) { + if a.io == nil || a.io.ChannelRegistry() == nil { + return agentIO.OutputTarget{}, false + } + return a.io.ChannelRegistry().ResolveOutputTarget(channel) +} + // isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。 // key=source+"|"+content;窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。 const duplicateInputWindow = 10 * time.Second diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 27ca455..dfd7f73 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -16,6 +16,11 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { if channel == "" || payload == "" || rawType == "" { return "工具名称格式: output_send__{channel},payload 和 type 不能为空" } + // 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。 + if !a.IsOutputAllowed(channel) { + return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel) + } + meta, _ := tc.Arguments["meta"].(string) caps := a.io.GetChannelCapabilities(channel) @@ -146,7 +151,14 @@ func (a *Agent) executeOutputListChannels() string { if ch.OutputCaps == 0 { continue } - parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description)) + if !a.IsOutputAllowed(ch.Name) { + continue + } + line := fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description) + if t, ok := a.ResolveOutputTarget(ch.Name); ok { + line += fmt.Sprintf("(目标: %s / inputch %s)", orDash(t.AgentID), orDash(t.InputCh)) + } + parts = append(parts, line) for _, t := range ch.Tools { parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description)) } diff --git a/internal/agent/core/output_grant_test.go b/internal/agent/core/output_grant_test.go new file mode 100644 index 0000000..00d3f66 --- /dev/null +++ b/internal/agent/core/output_grant_test.go @@ -0,0 +1,138 @@ +package core + +// N1b:输出通道授权集合 + 输出通道 → 目标 agent 的 inputch 解析。 +// +// 设计依据 docs/zh/resident-subagent-design.md §4.4(通道分配:不对称)与 R2 +// (插件与工具由父授权,**默认完整授权**)。 +// +// 三个过滤点必须一致,否则会出现"列表里看不到、但按名字还能调"的裂缝: +// ① 工具表(不为未授权的通道生成 output_send__X) +// ② 列表工具(output_list_channels 只列授权的) +// ③ 调用点(凭名字直调也必须被拒 —— 纵深防御) + +import ( + "strings" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" + agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io" +) + +// registerFakeOutput 注册一个假的输出通道(device 通道)。 +func registerFakeOutput(t *testing.T, a *Agent, name string) { + t.Helper() + if err := a.io.RegisterDevice(&mockOutputDevice{name: name, caps: agentIO.CapText}); err != nil { + t.Fatalf("注册测试通道 %s 失败: %v", name, err) + } +} + +func toolNames(a *Agent) []string { + var names []string + for _, t := range a.buildToolDefs() { + m, ok := t.(map[string]interface{}) + if !ok { + continue + } + fn, _ := m["function"].(map[string]interface{}) + if n, _ := fn["name"].(string); n != "" { + names = append(names, n) + } + } + return names +} + +func hasTool(names []string, want string) bool { + for _, n := range names { + if n == want { + return true + } + } + return false +} + +// 默认(未配置白名单)= 完整授权:所有输出通道都能用。 +func TestOutputGrant_DefaultIsFull(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + registerFakeOutput(t, a, "qq") + registerFakeOutput(t, a, "webui") + + if !a.IsOutputAllowed("qq") || !a.IsOutputAllowed("webui") { + t.Fatal("默认应为完整授权") + } + names := toolNames(a) + if !hasTool(names, "output_send__qq") || !hasTool(names, "output_send__webui") { + t.Fatalf("默认完整授权下应生成全部输出门,实际 %v", names) + } + if out := a.executeOutputListChannels(); !strings.Contains(out, "qq") || !strings.Contains(out, "webui") { + t.Fatalf("默认完整授权下列表应含全部通道:\n%s", out) + } +} + +// 白名单收窄:三个过滤点必须一致。 +func TestOutputGrant_NarrowedWhitelist(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + a.allowedOutputs = []string{"webui"} // 模拟父创建子时收窄 + registerFakeOutput(t, a, "qq") + registerFakeOutput(t, a, "webui") + + if a.IsOutputAllowed("qq") { + t.Fatal("白名单外的通道不应被授权") + } + if !a.IsOutputAllowed("webui") { + t.Fatal("白名单内的通道应被授权") + } + + // ① 工具表 + names := toolNames(a) + if hasTool(names, "output_send__qq") { + t.Fatalf("未授权的通道不该生成输出门工具:%v", names) + } + if !hasTool(names, "output_send__webui") { + t.Fatalf("已授权的通道应生成输出门工具:%v", names) + } + + // ② 列表工具 + out := a.executeOutputListChannels() + if strings.Contains(out, "qq") { + t.Fatalf("列表不应含未授权通道:\n%s", out) + } + if !strings.Contains(out, "webui") { + t.Fatalf("列表应含已授权通道:\n%s", out) + } + + // ③ 调用点(凭名字直调) + got := a.executeOutputSendTool(agentAPI.ToolCall{ + ID: "c1", Name: "output_send__qq", + Arguments: map[string]interface{}{"payload": "hi", "type": "text"}, + }) + if !strings.Contains(got, "未授权") { + t.Fatalf("未授权的输出门必须被拒,实际 %q", got) + } +} + +// 输出通道 → 目标 agent 的 inputch 的解析("输出可寻址到具体 agent")。 +func TestOutputTarget_Resolution(t *testing.T) { + a := newPreemptAgent(t, newPreemptProvider()) + reg := a.io.ChannelRegistry() + + if err := reg.BindOutputTarget("to-child-1", "child-1", "sub/in"); err != nil { + t.Fatal(err) + } + tgt, ok := a.ResolveOutputTarget("to-child-1") + if !ok || tgt.AgentID != "child-1" || tgt.InputCh != "sub/in" { + t.Fatalf("解析结果=%+v ok=%v", tgt, ok) + } + // 未登记的输出通道由传输层处理(如 qq/webui 这类 device 通道)。 + if _, ok := a.ResolveOutputTarget("qq"); ok { + t.Fatal("未登记目标解析的输出通道不应解析出 agent") + } + if err := reg.BindOutputTarget("", "x", "y"); err == nil { + t.Fatal("空输出通道名应报错") + } + + // 列表工具在已登记时带出目标,便于模型知道"这条通道发给谁"。 + registerFakeOutput(t, a, "to-child-1") + if out := a.executeOutputListChannels(); !strings.Contains(out, "child-1") { + t.Fatalf("已登记目标的输出通道应在列表里标出目标:\n%s", out) + } +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 1aeb8bc..7889faf 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -580,6 +580,11 @@ func (a *Agent) buildToolDefs() []interface{} { if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO { continue } + // 输出通道授权(设计 §4.4/R2):默认完整授权;父可用白名单收窄子的输出能力。 + // 未授权就不生成 output_send__X —— 模型看不到它,自然不会调。 + if !a.IsOutputAllowed(ch.Name) { + continue + } capStr := a.io.GetChannelCapabilities(ch.Name).String() desc := ch.Description if desc == "" { diff --git a/internal/agent/io/inputch.go b/internal/agent/io/inputch.go index 69bfbb5..f36e1a3 100644 --- a/internal/agent/io/inputch.go +++ b/internal/agent/io/inputch.go @@ -49,11 +49,25 @@ type InputChannel struct { type ChannelRegistry struct { mu sync.RWMutex channels map[string]InputChannel + // outputTargets 把**输出通道**解析成"目标 agent 的哪个 inputch"。 + // 这是"输出可寻址到具体 agent"的依据(子→父、父→指定子)。 + outputTargets map[string]OutputTarget +} + +// OutputTarget 是一个输出通道的投递目标。 +type OutputTarget struct { + // AgentID 是目标 agent("" = 本 agent / 由传输层通道 device 自行处理)。 + AgentID string `json:"agent_id,omitempty"` + // InputCh 是目标 agent 上接收它的 inputch("" = 与输出通道同名)。 + InputCh string `json:"inputch,omitempty"` } // NewChannelRegistry 构造一个空的 inputch 登记表。 func NewChannelRegistry() *ChannelRegistry { - return &ChannelRegistry{channels: make(map[string]InputChannel)} + return &ChannelRegistry{ + channels: make(map[string]InputChannel), + outputTargets: make(map[string]OutputTarget), + } } // Register 登记/更新一个 inputch。 @@ -144,6 +158,43 @@ func (r *ChannelRegistry) ListByOwner(agentID string) []InputChannel { return out } +// BindOutputTarget 登记"输出通道 → 目标 agent 的 inputch"的解析。 +// +// 例:父把子用的输出通道 "to-child-1" 绑到 (child-1, "sub/in"), +// 于是子经该通道发出的消息会投进 child-1 的 sub/in。 +func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) error { + if output == "" { + return errors.New("输出通道名不能为空") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.outputTargets == nil { + r.outputTargets = make(map[string]OutputTarget) + } + r.outputTargets[output] = OutputTarget{AgentID: agentID, InputCh: inputCh} + return nil +} + +// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false +// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。 +func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.outputTargets[output] + return t, ok +} + +// ListOutputTargets 返回全部已登记的目标解析(按输出通道名排序)。 +func (r *ChannelRegistry) ListOutputTargets() map[string]OutputTarget { + r.mu.RLock() + defer r.mu.RUnlock() + out := make(map[string]OutputTarget, len(r.outputTargets)) + for k, v := range r.outputTargets { + out[k] = v + } + return out +} + // Count 返回已注册 inputch 数量。 func (r *ChannelRegistry) Count() int { r.mu.RLock()