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()