Files
HomeAgent/internal/agent/io/inputch.go
JianFeeeee eb1deab5dd feat(agent): rtchild 实时子 agent + 四类 agent 独立模型源 + 残余任务四态
## rtchild(R1–R13)

第三种、**独立**的子 agent 类型,面向语音/视频通话:内核只要文本流,
不定音频(TTS/播放由插件负责)。

- R1  CapRT 能力位 + AgentConfig.IsRealtime;AssignRealtime 两侧校验
- R2  rtSlot 单槽覆盖(latest-wins,从不排队)
- R3  独立 ctx + rtLoop + SpawnRTChild/StopRTChild
- R4  ChatStream 逐块 → outputch(rt).Execute(text)
- R5  打断 = 原地截断,不重试
- R6  工具执行期不可打断(suppressedCancels 让守卫可观测)
- R7  普通上下文 + 窗口裁剪(旧内容不进模型,**原文保留**)
- R9  CloseRTChild → L4 信号 + rt_transcript 按需分页取
- R10 notify_parent(L4,注册一次,停即注销)
- R11 闲置零 token
- R13 rt_children 管理工具(父专属)

刻意**不做** rtPage/anchor/degraded_resets(过设计,已裁定);
不用四级中断阶梯(preemptCooldown+同级不抢占会让 ~75% 的 rt 打断
静默退化为排队)。

## 四类 agent 各自可配模型源

core.agent.llm.{main,resident,rt,child}_provider,语义是**优先不是独占**:
该源排到候选链最前,其余源仍作回退。硬锁定会让"首选源抖一下"变成
"这个 agent 彻底不工作",而多源回退链正是本仓为此建立的机制。
名字写错 ⇒ 回退默认源 + 记日志(不静默)。子 agent 未配置时继承父的。

★ 顺带修掉一个真缺陷:spawn.go 原来直接 a.provider.Chat(),
**普通子 agent 完全绕过 provider 回退链** —— 它是唯一绕过的地方:
默认源抖一次整条子任务就"执行失败",而主 agent 同刻能自动换源。

## 驻留子残余任务四态 + 通道三态

残余任务:keep / takeover(接手,keep 的明确别名)/ stash(暂存到各自
原通道)/ drop。stash 必须**逐条按各自 OutputChannel 归位** —— 一个子
可能同时收 qq 和 webui,混存会让"接回 qq 的人"拿到本该给 webui 的活。

通道:release(零值,向后兼容)/ suspend(挂起,暂存保留)/ takeover
(接回父并立即释放暂存)。原来是无条件 release,于是只有两种坏结局:
通道一直没人分配(消息无人处理且无提示),或刚好被别的 agent 抢到。

一次定完不留窗口:FinishResidentWith → DestroyResidentWith →
teardownResidentWith。任一环退回不带 disp 的旧函数,disp 就被丢掉、
通道被无条件释放 —— 而 stash 的暂存正挂在那条通道上(实际踩过)。

## 修复"手写字段白名单"丢值(7 处)

同一类缺陷:不传整个结构体,而是手写一份"允许通过的字段清单"。
危险在于**写漏了不报错** —— 字段静默归零,故障出现在很远的地方。

- corehandler.go outputRegister: ChannelDef{NoMemory,Cleaner} → 整体传
- pubSdkInjectOpts: 逐字段摊平丢 ScenePolicy/SecurityProfile → 收整个 injectParams
- injectParams/injectMediaParams: 结构本身缺字段 → 补齐 + asInjectParams()
- 配套反射判据 TestSegment_RoundTripEveryField(取代手写断言,
  原判据漏了 Phase/LLMText/FinalText/ToolCalls/ExtraKeyMediaBlocks/ExtraBag)

## 压缩偏离设计

CompressResident 原为截断,与设计(LLM 摘要替换)不符。改走
PlanCompaction → summarizeForCompaction(用子自己的 provider)→
ApplyCompaction,失败回退 TrimKeepRecent。顺带修 ApplyCompaction 的
锁重入 panic 与双重解锁。

并修一处真实生产数据竞争:ctxFullSignaled 由父 goroutine 写、
子调度 goroutine 读,加 ctxFullMu。

## 判据

新增约 70 条。全部验证过能变红(变异测试),包括:
- TestSegment_RoundTripEveryField 的防腐蚀:fullStageContext 反射断言
  每个字段非零,否则新字段永远不会被覆盖("假绿")
- TestPreferredProvider_PutsSourceFirstAndKeepsFallbacks 成对断言
  "在首位且回退仍在"—— 只断言前者的话,硬锁定也会绿

## 判据自身的坑(两次偶发红,都不是产品缺陷)

1. SpawnResident 末尾 Start(),子会在两次 enqueue 之间取走一条
   → "暂存 0 条"。修法:cancel + waitSchedulerStopped。
   (offload_test.go 早有记录,我没先读就重踩了一遍)
2. TaskPrompt 异步投递,可能在清空队列之后才到 → "多 1 条"。
   靠探针打印内容才认出是 TaskPrompt。修法:本文件用 spawnQuietResident。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 包 ok / 0 FAIL / 0 RACE
2026-10-30 17:00:47 +08:00

340 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package io
// inputch 是**最基本的输入路由单位**(设计见 docs/zh/resident-subagent-design.md §4)。
//
// 一个插件可以注册多个 inputch;每个 inputch 是彼此独立的路由单位:
// 可以被划给不同的 agent、可以分别限额。中断输入与排队输入**两类都从 inputch 进出**,
// 而"中断 vs 排队"是每条输入自己的类别 —— 不是 inputch 的属性。
//
// 本文件只放**登记表**(谁是注册者、划给了谁、容量多少、记忆策略是什么)。
// 路由本身发生在进内核之前:投递方决定"这条输入投给哪个 inputch"。
import (
"errors"
"fmt"
"sort"
"sync"
)
var (
// ErrInputChannelUnknown 表示引用了未注册的 inputch。
ErrInputChannelUnknown = errors.New("inputch 未注册")
// ErrInputChannelNameEmpty 表示 inputch 名为空。
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
// ErrChannelNotRealtime 表示该通道没有声明 CapRT 能力(不能承载 rt 流)。
//
// 两侧校矩的另一侧("目标 agent 是不是 rtchild")在 core 包,
// 因为 agent 类型属于 core 而非 io(plan.md 十一.三 F)。
ErrChannelNotRealtime = errors.New("通道未声明 rt 能力(CapRT)")
)
// 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:"-"`
// Capabilities 是本通道的**能力位**(rt 流 / 文本 / 音频…)。
//
// ★ 为何不放进 Def:Def 是公开 SDK 的 ChannelDef 别名
// (`type ChannelDef = pubsdk.ChannelDef`),往里面加字段等于改 SDK 契约;
// 而能力位是**通道实例**的属性,属于内核侧的登记记录。
//
// ★ 它声明的是“本通道**能承载**什么”,不是“内核要产出什么”:
// 例如 CapRT 表示“能承载实时流(新值作废旧值)”,CapAudio 表示
// “本出口会把文本转成声音”——都是插件侧的实现能力声明。
Capabilities OutputCapability `json:"capabilities,omitempty"`
}
// ChannelRegistry 是 inputch 的登记表。
//
// 它被设计成**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
// 这样"划入/授权"才有意义;每个 IOManager 默认自带一份(向后兼容)。
type ChannelRegistry struct {
mu sync.RWMutex
channels map[string]InputChannel
// stash 是**挂在通道上**的暂存区:驻留子销毁时选 ResidualStash 的残余任务
// 留在这里,等该通道被接回(Assign 给新 owner)时放出。
//
// ★ 为什么挂在通道而不是 agent:这些任务此刻**不属于任何 agent**
// (子已销毁、父没接手)。挂在通道上才符合"暂存在对应通道"的语义,
// 也让"谁接回通道谁拿到活"自动成立 —— 不需要额外的转交逻辑。
stash map[string][]*InputEvent
// 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),
outputTargets: make(map[string]OutputTarget),
stash: make(map[string][]*InputEvent),
}
}
// 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)
}
// ★ Def.Realtime ⇒ 映射到 Capabilities 的 CapRT 位(SDK 声明 → 内核能力位)。
//
// 为什么在这里映射而不是让插件直接填能力位:公开 SDK 的 ChannelDef 是
// 插件能碰到的唯一结构(插件不依赖内核),能力位(OutputCapability)是
// 内核侧类型。两侧各自表达同一件事,由登记表做**唯一一次**转换。
// 判据 TestRTChannel_SDKRealtimeBecomesCapRT 钉它。
if ch.Def.Realtime {
ch.Capabilities |= CapRT
}
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。
//
// 同时清理指向它的输出目标登记(outputTargets):否则通道被注销后,
// ResolveOutputTarget 仍会把它解析成一个已不存在的 inputch。
// 两种指向都要清:显式绑定到该 inputch 的,以及“回退到同名通道”的
// (InputCh == "" 表示与输出通道同名)。
func (r *ChannelRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.channels, name)
for out, t := range r.outputTargets {
if t.InputCh == name || (t.InputCh == "" && out == name) {
delete(r.outputTargets, out)
}
}
}
// 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 {
_, err := r.AssignAndDrainStash(name, agentID, capacity)
return err
}
// AssignAndDrainStash 与 Assign 相同,但**同时返回该通道上的暂存任务**。
//
// ## 为什么需要它(用户 2026-10-30 要求)
//
// 「原本驻留子持有的通道也应该支持**挂起或接回**主 agent」。
//
// 挂起 = `Assign(name, "", 0)`(Owner 清空,通道仍登记但无人处理);
// 接回 = `AssignAndDrainStash(name, newOwner, 0)`。
//
// 接回时必须把暂存任务一并交出:如果只改 Owner 不取暂存,
// 那批任务会永远躺在通道上 —— 通道看起来"已经接回来了",
// 但活没人做,且没有任何报错。**这是本轮最容易埋的静默缺陷。**
func (r *ChannelRegistry) AssignAndDrainStash(name, agentID string, capacity int) ([]*InputEvent, error) {
r.mu.Lock()
ch, ok := r.channels[name]
if !ok {
r.mu.Unlock()
return nil, ErrInputChannelUnknown
}
ch.Owner = agentID
if capacity > 0 {
ch.Capacity = capacity
}
r.channels[name] = ch
// 接回给某个具体 agent 时才释放暂存;挂起(agentID=="")时保持不动 ——
// 挂起的语义正是"先放着,等有人接"。
var drained []*InputEvent
if agentID != "" {
drained = r.stash[name]
delete(r.stash, name)
}
r.mu.Unlock()
return drained, 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
}
// 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
}
// UnbindOutputTarget 取消一个输出通道的目标登记。
//
// 与 BindOutputTarget 成对:输出通道可能是**随资源生灭**的(如远程设备
// 一台设备一个通道),设备掉线后必须解绑,否则 ResolveOutputTarget 会一直
// 把消息路由到一个已不存在的 agent/inputch 上——与 inputch 登记泄漏同一类问题。
// 未登记时是 no-op(幂等),方便清理路径无脑调用。
func (r *ChannelRegistry) UnbindOutputTarget(output string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.outputTargets, output)
}
// 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()
defer r.mu.RUnlock()
return len(r.channels)
}
// Stash 把若干条输入事件**暂存到指定通道**(驻留子销毁时用)。
//
// 与"转回父队列"的区别:这些任务此刻不属于任何 agent —— 子已销毁、父没接手。
// 挂在通道上,于是"谁接回这条通道,谁就拿到这批活"自动成立,
// 不需要额外的转交逻辑。这正是用户要求的「暂存在对应通道」。
//
// 通道不存在时返回错误而不是静默丢弃:暂存到一条不存在的通道,
// 等于丢了一条还让人以为存住了。
func (r *ChannelRegistry) Stash(channel string, events []*InputEvent) error {
if len(events) == 0 {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.channels[channel]; !ok {
return fmt.Errorf("通道 %s 不存在,无法暂存残余任务", channel)
}
r.stash[channel] = append(r.stash[channel], events...)
return nil
}
// TakeStash 取走并清空某通道上的全部暂存任务。
//
// 取走即清空:调用方(Assign 的接回路径,或显式调用)负责把它们放出去。
// 返回值可能是 nil(无暂存)。
func (r *ChannelRegistry) TakeStash(channel string) []*InputEvent {
r.mu.Lock()
defer r.mu.Unlock()
evs := r.stash[channel]
delete(r.stash, channel) // 取出即清空,避免重复释放
return evs
}
// StashLen 返回某通道暂存的条数(用于 ResidentInfo/控制面展示)。
//
// ★ 必须有:暂存如果不可见,它就和"丢弃"没有区别 ——
// 用户需要能查到"这批活还在通道上等着"。
func (r *ChannelRegistry) StashLen(channel string) int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.stash[channel])
}
// StashTotal 返回全部通道上的暂存总条数。
func (r *ChannelRegistry) StashTotal() int {
r.mu.RLock()
defer r.mu.RUnlock()
n := 0
for _, evs := range r.stash {
n += len(evs)
}
return n
}