mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
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:
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
138
internal/agent/core/output_grant_test.go
Normal file
138
internal/agent/core/output_grant_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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()
|
||||
|
||||
Reference in New Issue
Block a user