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

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