feat(channel): N1b —— 输出通道授权集合(三处过滤一致)+ 输出通道→目标 agent 的 inputch 解析

设计: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 里会标出「目标: <agent> / inputch <名字>」。

## 验收

`internal/agent/core/output_grant_test.go`(3 项):
- 默认完整授权:全部输出门生成 + 列表含全部
- 白名单收窄:三个过滤点同时生效(工具表 / 列表 / 直调被拒)
- 目标解析:绑定/解析、未登记由传输层处理、列表标出目标、空名报错

全仓 go test ./... 37 包 ok / 0 FAIL。
This commit is contained in:
JianFeeeee
2026-09-13 09:13:19 +08:00
parent a2dcd96fee
commit f7c3a4e81d
6 changed files with 265 additions and 9 deletions

View File

@ -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→L30 则使用 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

View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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 == "" {