feat(channel): N1a —— inputch 一等化(归属插件/归属 agent/容量/共享登记表)+ 单工具多视图总览

设计:docs/zh/resident-subagent-design.md §4.6(里程碑 N1a)。

## 用户要求

「父 agent 可以看到所有已注册的 inputch 以及 inputch 的划分情况,用**单工具多视图**方式构筑」

## 登记层(internal/agent/io/inputch.go)

inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个),
所以登记表以 inputch 为键,每条记录:

    名字 / 归属插件 / 归属 agent(被划给谁) / 容量 / 默认回程 outputch / 记忆策略(ChannelDef)

- 新增 `ChannelRegistry`,设计成**可共享对象**(`*ChannelRegistry`):
  根 agent 与驻留子共用同一份,"划入/授权"才有意义;`SetChannelRegistry` 注入。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
  保留已有 Owner/Capacity/Output(否则一次 reload 就把父做的划分清空)。
- `Assign` 语义按设计 R6 默认:**读写授权,不转移所有权**(Plugin 与 Owner 分别记录)。
- 原 `inputChannels map[string]ChannelDef` 被登记表取代;`GetInputChannelDef` 保持兼容。
- `plugin.Registry` 注册时带上**归属插件名**(此前完全无归属信息)。

## 总览工具(internal/agent/core/inputch.go)

单工具 **`input_channels`** + `view` 参数(不是一堆小工具):

    all(默认)= 全部已注册(带归属插件)
    mine       = 划给本 agent 的
    unassigned = 尚未划出的
    by_agent   = 划分情况总览(按归属分组)
    detail     = 单个 inputch 全字段(需 name)

未知 view **报错并列出可用值**(拼错不得被静默当成默认视图);登记表为空时明确说明。

## 验收

- `internal/agent/io/inputch_test.go`:归属记录、重载保划分、Assign/视图数据面、
  **跨 manager 共享登记表**、策略查询向后兼容 —— 5 项
- `internal/agent/core/inputch_test.go`:单工具多视图逐视图断言(含未知 view 与空表)—— 2 项
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/... ./internal/plugin/...` 干净
This commit is contained in:
JianFeeeee
2026-09-13 09:06:50 +08:00
parent 7545f5869c
commit 63b6f852aa
10 changed files with 787 additions and 27 deletions

View File

@ -96,13 +96,13 @@ type OutputEvent struct {
}
type IOManager struct {
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
interruptCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
inputChannels map[string]ChannelDef
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
interruptCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
channelReg *ChannelRegistry
// toolBlocks:插件工具注入多模态内容块,process.go 在下一条 tool message 时消费。
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
@ -112,11 +112,11 @@ type IOManager struct {
func NewIOManager() *IOManager {
return &IOManager{
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
interruptCh: make(chan *InputEvent, 64),
outputCh: make(chan *OutputEvent, 256),
inputChannels: make(map[string]ChannelDef),
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
interruptCh: make(chan *InputEvent, 64),
outputCh: make(chan *OutputEvent, 256),
channelReg: NewChannelRegistry(),
}
}
@ -444,26 +444,57 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
// RegisterInputChannel 注册输入通道的记忆行为
// RegisterInputChannel 注册一个 inputch(不带插件归属,兼容旧调用)。
//
// inputch 是**最基本的输入路由单位**;一个插件可以注册多个。
// 新代码请用 RegisterInputChannelFrom 以便登记归属插件(可追溯)。
func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) {
m.mu.Lock()
defer m.mu.Unlock()
m.inputChannels[name] = def
_ = m.RegisterInputChannelFrom("", name, def)
}
// UnregisterInputChannel 注销输入通道
// RegisterInputChannelFrom 注册一个 inputch 并登记归属插件。
func (m *IOManager) RegisterInputChannelFrom(plugin, name string, def ChannelDef) error {
return m.channelReg.Register(InputChannel{Name: name, Plugin: plugin, Def: def})
}
// UnregisterInputChannel 注销一个 inputch。
func (m *IOManager) UnregisterInputChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.inputChannels, name)
m.channelReg.Unregister(name)
}
// GetInputChannelDef 查询输入通道的记忆行为定义
// AssignInputChannel 把一个 inputch 划给某个 agent(见 ChannelRegistry.Assign)。
func (m *IOManager) AssignInputChannel(name, agentID string, capacity int) error {
return m.channelReg.Assign(name, agentID, capacity)
}
// SetChannelRegistry 注入一份**共享的**登记表(根 agent 与驻留子共用同一份)。
func (m *IOManager) SetChannelRegistry(r *ChannelRegistry) {
if r == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.channelReg = r
}
// ChannelRegistry 返回底层登记表(只读用途;可直接读 Views)。
func (m *IOManager) ChannelRegistry() *ChannelRegistry { return m.channelReg }
// InputChannels 返回全部已注册 inputch(按名字排序)。
func (m *IOManager) InputChannels() []InputChannel { return m.channelReg.List() }
// LookupInputChannel 查询单个 inputch 的完整登记记录。
func (m *IOManager) LookupInputChannel(name string) (InputChannel, bool) {
return m.channelReg.Lookup(name)
}
// GetInputChannelDef 查询 inputch 的记忆行为定义。
func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
def, ok := m.inputChannels[name]
return def, ok
ch, ok := m.channelReg.Lookup(name)
if !ok {
return ChannelDef{}, false
}
return ch.Def, true
}
func (m *IOManager) GetAllTools() []ToolDef {

View File

@ -0,0 +1,152 @@
package io
// inputch 是**最基本的输入路由单位**(设计见 docs/zh/resident-subagent-design.md §4)。
//
// 一个插件可以注册多个 inputch;每个 inputch 是彼此独立的路由单位:
// 可以被划给不同的 agent、可以分别限额。中断输入与排队输入**两类都从 inputch 进出**,
// 而"中断 vs 排队"是每条输入自己的类别 —— 不是 inputch 的属性。
//
// 本文件只放**登记表**(谁是注册者、划给了谁、容量多少、记忆策略是什么)。
// 路由本身发生在进内核之前:投递方决定"这条输入投给哪个 inputch"。
import (
"errors"
"sort"
"sync"
)
var (
// ErrInputChannelUnknown 表示引用了未注册的 inputch。
ErrInputChannelUnknown = errors.New("inputch 未注册")
// ErrInputChannelNameEmpty 表示 inputch 名为空。
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
)
// InputChannel 是一个 inputch 的完整登记记录。
type InputChannel struct {
// Name 是路由单位 id(全局唯一,如 "qq"、"webui"、"qq/device-2")。
Name string `json:"name"`
// Plugin 是注册它的插件名("插件可注册多个 inputch",归属可追溯)。
Plugin string `json:"plugin,omitempty"`
// Owner 是**被划给的 agent id**("" = 未分配,归根 agent/内核默认)。
Owner string `json:"owner,omitempty"`
// Capacity 是该 inputch 的队列容量(0 = 用内核默认值)。
Capacity int `json:"capacity,omitempty"`
// Output 是该 inputch 的默认回程输出通道("" = 由来源/调用方决定)。
//
// 注意:这不是"内核路由"—— 输出仍然是 agent 的主动调用;这里只是登记
// "这个 inputch 的回复默认该往哪个 outputch 走"的映射依据。
Output string `json:"output,omitempty"`
// Def 是记忆/上下文策略(沿用 ChannelDef:NoMemory / Cleaner / ContextPolicy)。
// Cleaner 是函数,故本字段不可序列化(json:"-")。
Def ChannelDef `json:"-"`
}
// ChannelRegistry 是 inputch 的登记表。
//
// 它被设计成**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
// 这样"划入/授权"才有意义;每个 IOManager 默认自带一份(向后兼容)。
type ChannelRegistry struct {
mu sync.RWMutex
channels map[string]InputChannel
}
// NewChannelRegistry 构造一个空的 inputch 登记表。
func NewChannelRegistry() *ChannelRegistry {
return &ChannelRegistry{channels: make(map[string]InputChannel)}
}
// Register 登记/更新一个 inputch。
//
// 重复登记(插件重载)时**保留已有的 Owner/Capacity/Output**,只更新
// Plugin 与 Def —— 否则一次插件重载就会把父 agent 做的划分抹掉。
func (r *ChannelRegistry) Register(ch InputChannel) error {
if ch.Name == "" {
return ErrInputChannelNameEmpty
}
r.mu.Lock()
defer r.mu.Unlock()
if r.channels == nil {
r.channels = make(map[string]InputChannel)
}
if old, ok := r.channels[ch.Name]; ok {
if ch.Owner == "" {
ch.Owner = old.Owner
}
if ch.Capacity == 0 {
ch.Capacity = old.Capacity
}
if ch.Output == "" {
ch.Output = old.Output
}
if ch.Plugin == "" {
ch.Plugin = old.Plugin
}
}
r.channels[ch.Name] = ch
return nil
}
// Unregister 注销一个 inputch。
func (r *ChannelRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.channels, name)
}
// Lookup 查询一个 inputch。
func (r *ChannelRegistry) Lookup(name string) (InputChannel, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
ch, ok := r.channels[name]
return ch, ok
}
// Assign 把一个 inputch **划给**某个 agent(可同时给定容量)。
//
// 语义(默认取值,见设计文档 R6):**读写授权**,不转移所有权 ——
// 登记表仍记录 Plugin(谁注册的)与 Owner(划给了谁)两件事。
func (r *ChannelRegistry) Assign(name, agentID string, capacity int) error {
r.mu.Lock()
defer r.mu.Unlock()
ch, ok := r.channels[name]
if !ok {
return ErrInputChannelUnknown
}
ch.Owner = agentID
if capacity > 0 {
ch.Capacity = capacity
}
r.channels[name] = ch
return nil
}
// List 返回全部已注册 inputch(按名字稳定排序)。
func (r *ChannelRegistry) List() []InputChannel {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]InputChannel, 0, len(r.channels))
for _, ch := range r.channels {
out = append(out, ch)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ListByOwner 返回划给某个 agent 的 inputch(owner == "" 时返回**未分配**的)。
func (r *ChannelRegistry) ListByOwner(agentID string) []InputChannel {
var out []InputChannel
for _, ch := range r.List() {
if ch.Owner == agentID {
out = append(out, ch)
}
}
return out
}
// Count 返回已注册 inputch 数量。
func (r *ChannelRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.channels)
}

View File

@ -0,0 +1,134 @@
package io
// inputch 登记表与划分(单工具多视图背后的数据面)。
//
// 设计依据 docs/zh/resident-subagent-design.md §4:
// inputch 是**最基本的输入路由单位**,由插件注册,一个插件可注册多个。
import "testing"
func TestChannelRegistry_RegisterKeepsAttribution(t *testing.T) {
r := NewChannelRegistry()
// 一个插件注册多个 inputch(最基本的输入路由单位)。
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
if err := r.Register(InputChannel{Name: "qq/device-2", Plugin: "qq"}); err != nil {
t.Fatal(err)
}
if n := r.Count(); n != 2 {
t.Fatalf("注册数=%d,期望 2(同一插件的多个 inputch 彼此独立)", n)
}
ch, ok := r.Lookup("qq/device-2")
if !ok || ch.Plugin != "qq" {
t.Fatalf("归属插件未记录:%+v ok=%v", ch, ok)
}
if _, ok := r.Lookup("nope"); ok {
t.Fatal("未注册的 inputch 不应查得到")
}
if err := r.Register(InputChannel{Name: ""}); err != ErrInputChannelNameEmpty {
t.Fatalf("空名应报错,实际 %v", err)
}
}
// 插件重载(重复登记)**不得抹掉划分**:Owner/Capacity/Output 必须保留。
func TestChannelRegistry_ReRegisterKeepsAllocation(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
if err := r.Assign("qq", "agent-1", 128); err != nil {
t.Fatal(err)
}
// 插件重载:只带 Plugin/Def,不带 Owner/Capacity。
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
ch, _ := r.Lookup("qq")
if ch.Owner != "agent-1" || ch.Capacity != 128 {
t.Fatalf("重载后划分被抹掉:owner=%q capacity=%d", ch.Owner, ch.Capacity)
}
if !ch.Def.NoMemory {
t.Fatal("重载应更新 Def")
}
}
func TestChannelRegistry_AssignAndViews(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
_ = r.Register(InputChannel{Name: "cli", Plugin: "cli"})
_ = r.Register(InputChannel{Name: "sub/in", Plugin: "sub"})
if err := r.Assign("qq", "root", 0); err != nil {
t.Fatal(err)
}
if err := r.Assign("sub/in", "child-1", 32); err != nil {
t.Fatal(err)
}
if err := r.Assign("nope", "x", 0); err != ErrInputChannelUnknown {
t.Fatalf("划分未注册的 inputch 应报错,实际 %v", err)
}
if got := len(r.ListByOwner("root")); got != 1 {
t.Fatalf("root 的 inputch 数=%d,期望 1", got)
}
child := r.ListByOwner("child-1")
if len(child) != 1 || child[0].Name != "sub/in" || child[0].Capacity != 32 {
t.Fatalf("child-1 的划分=%+v", child)
}
// 未分配的:cli。
un := r.ListByOwner("")
if len(un) != 1 || un[0].Name != "cli" {
t.Fatalf("未分配的 inputch=%+v,期望只有 cli", un)
}
// List 按名排序(视图输出稳定)。
all := r.List()
if len(all) != 3 || all[0].Name != "cli" || all[2].Name != "sub/in" {
t.Fatalf("List 未按名排序:%+v", all)
}
}
// 共享登记表:根 agent 与驻留子共用同一份,划分才有意义。
func TestChannelRegistry_SharedBetweenManagers(t *testing.T) {
shared := NewChannelRegistry()
root := NewIOManager()
child := NewIOManager()
root.SetChannelRegistry(shared)
child.SetChannelRegistry(shared)
if err := root.RegisterInputChannelFrom("sub", "sub/in", ChannelDef{}); err != nil {
t.Fatal(err)
}
if err := root.AssignInputChannel("sub/in", "child-1", 8); err != nil {
t.Fatal(err)
}
// 子在**自己**的 io 上就能看到这份划分。
ch, ok := child.LookupInputChannel("sub/in")
if !ok {
t.Fatal("共享登记表后,子应看得到根注册的 inputch")
}
if ch.Owner != "child-1" || ch.Capacity != 8 {
t.Fatalf("子看到的划分=%+v", ch)
}
// 注销也要跨 manager 生效。
root.UnregisterInputChannel("sub/in")
if _, ok := child.LookupInputChannel("sub/in"); ok {
t.Fatal("注销应跨 manager 生效")
}
}
// 记忆策略查询保持向后兼容(原 GetInputChannelDef 的语义)。
func TestChannelRegistry_DefLookupCompat(t *testing.T) {
m := NewIOManager()
m.RegisterInputChannel("qq", ChannelDef{NoMemory: true, ContextPolicy: "prune"})
def, ok := m.GetInputChannelDef("qq")
if !ok || !def.NoMemory || def.ContextPolicy != "prune" {
t.Fatalf("策略查询=%+v ok=%v", def, ok)
}
if _, ok := m.GetInputChannelDef("nope"); ok {
t.Fatal("未注册的 inputch 不应有策略")
}
}