mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-31 22:37:28 +00:00
## 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
195 lines
8.6 KiB
Go
195 lines
8.6 KiB
Go
package core
|
||
|
||
// 中断向量表 —— 中断源的**统一登记处**。
|
||
//
|
||
// ## 为什么需要它(这是中断子系统独立出来的真正目的)
|
||
//
|
||
// 拆分之前,"有哪些内核中断、各自什么语义"散落在三个裸调用点上:
|
||
//
|
||
// raiseKernelInterrupt("kernel", "kernel", "任务 #N panic…")
|
||
// raiseKernelInterrupt("kernel/overflow", "kernel", "上下文超页…")
|
||
// raiseKernelInterrupt("child/"+rc.id, "kernel", "驻留子已满…")
|
||
//
|
||
// 三者差异**只在 source 字符串**上,语义契约全靠注释与调用方自律。
|
||
// 最能说明问题的是 panic 的递归保护:
|
||
//
|
||
// if t.Class == TaskInterrupt && t.Level >= LevelCritical { return }
|
||
// // 否则同一个 panic 会自我放大成中断风暴
|
||
//
|
||
// 这条规则只属于"panic 中断",却硬编码在 reportTaskPanic 里。
|
||
// **新增一种内核中断时,没人会记得要不要也加递归保护**——这就是要向量化的理由。
|
||
//
|
||
// ## 边界
|
||
//
|
||
// 向量表属于**进入中断**的入口定义,因此住在中断管理器这一侧
|
||
// (用户裁定:中断管理器负责"进入中断")。它只回答"这次中断长什么样、
|
||
// 谁能产生它",**不参与**排队、优先级比较、恢复选择。
|
||
//
|
||
// ## 加一种新的内核中断
|
||
//
|
||
// 只改这一个文件:在 VectorID 里加常量、在 interruptVectors 里加一条。
|
||
// 规则(级别/前置判定/载荷构造/来源标识)随条目声明,不再靠调用方自律。
|
||
|
||
import "fmt"
|
||
|
||
// VectorID 是中断源的**稳定标识**。
|
||
//
|
||
// 与 source 字符串的区别:source 是给人和日志看的(如 "child/xxx"),
|
||
// 会随实例变化;VectorID 是稳定的枚举值,可被诊断面、指标、日志字段引用,
|
||
// 也可用来回答"现在一共登记了几种内核中断"。
|
||
type VectorID string
|
||
|
||
const (
|
||
// VectorTaskPanic:任务 panic。带递归保护(见 interruptVectors)。
|
||
VectorTaskPanic VectorID = "task_panic"
|
||
// VectorContextOverflow:上下文超页,需要提示模型"内容被移走了"。
|
||
VectorContextOverflow VectorID = "ctx_overflow"
|
||
// VectorChildContextFull:驻留子上下文已满,父据此决定压缩/回收/销毁。
|
||
VectorChildContextFull VectorID = "child_ctxfull"
|
||
// VectorRTChildClosed:rtchild 回收,父据此**自行选择**要保留哪些上下文。
|
||
VectorRTChildClosed VectorID = "rtchild_closed"
|
||
)
|
||
|
||
// vectorArgs 是一次中断向量调用的全部入参。
|
||
//
|
||
// 用显式结构体而不是 `any`:每种中断用哪些字段一眼可见,
|
||
// 且新增字段时编译器会提醒所有构造点。
|
||
type vectorArgs struct {
|
||
// Task 是发生 panic 的任务(VectorTaskPanic 使用)。
|
||
Task *Task
|
||
// Panic 是 recover() 取到的值(VectorTaskPanic 使用)。
|
||
Panic any
|
||
// ChildID 是驻留子标识(VectorChildContextFull 使用)。
|
||
ChildID string
|
||
// RTID 是 rtchild 标识(VectorRTChildClosed 使用)。
|
||
RTID string
|
||
// RTCount / RTChars 是本次交接的规模(VectorRTChildClosed 使用),
|
||
// 让主 agent 判断"值不值得去取全文"。
|
||
RTCount int
|
||
RTChars int
|
||
}
|
||
|
||
// interruptVector 是一个中断源的**完整契约**。
|
||
type interruptVector struct {
|
||
// ID 是稳定标识。
|
||
ID VectorID
|
||
// Level 是该源产生的中断级别。内核源恒为 L4(LevelCritical)。
|
||
Level Level
|
||
// Source 构造来源标识(写进 payload 的 interrupt_source)。
|
||
// 需要动态前缀的源(如 child/<id>)在闭包里拼。
|
||
Source func(a vectorArgs) string
|
||
// Guard 是登记前的**前置判定**,返回 false 表示本次不产生中断。
|
||
//
|
||
// 这是原先散落在调用方里的规则的收容处——例如 panic 的递归保护:
|
||
// 若 panic 的任务本身就是 L4 内核中断,就不能再产生新的 L4,
|
||
// 否则同一个 panic 会自我放大成中断风暴。
|
||
Guard func(a vectorArgs) bool
|
||
// Build 构造中断正文(模型能看到的唯一内容)。
|
||
Build func(a vectorArgs) string
|
||
}
|
||
|
||
// interruptVectors 是中断源的登记表。
|
||
//
|
||
// 判据(interrupt_vectors_test.go):
|
||
// - 表内 ID 必须与 interruptVector.ID 一致(防手滑)
|
||
// - 每条必须有 Source/Guard/Build,且 Level 必须是内核级
|
||
// - panic 条目必须能挡住"L4 panic 再产生 L4"(递归保护)
|
||
var interruptVectors = map[VectorID]interruptVector{
|
||
VectorTaskPanic: {
|
||
ID: VectorTaskPanic,
|
||
Level: LevelCritical,
|
||
Source: func(vectorArgs) string { return "kernel" },
|
||
Guard: func(a vectorArgs) bool {
|
||
// 递归保护是**结构性**的(原先硬编码在 reportTaskPanic 里):
|
||
// 若 panic 的任务本身就是 L4 内核中断,则不再产生新的 L4——
|
||
// 否则同一个 panic 会自我放大成中断风暴,与"内核事件"的语义相反。
|
||
if a.Task == nil {
|
||
return false
|
||
}
|
||
return !(a.Task.Class == TaskInterrupt && a.Task.Level >= LevelCritical)
|
||
},
|
||
Build: func(a vectorArgs) string {
|
||
return fmt.Sprintf("内核事件:任务 #%d 发生 panic:%v(该任务已被丢弃,调度器存活)",
|
||
a.Task.ID, a.Panic)
|
||
},
|
||
},
|
||
|
||
VectorContextOverflow: {
|
||
ID: VectorContextOverflow,
|
||
Level: LevelCritical,
|
||
Source: func(vectorArgs) string { return "kernel/overflow" },
|
||
Guard: func(vectorArgs) bool { return true },
|
||
// ★ 空中断:**不携带任何消息**(2026-10-25 按原始设计修正)。
|
||
//
|
||
// 原始设计里这个 L4 的用途是「**触发**语义相关性裁剪」——它本身
|
||
// 只是一次调度信号,不是一条给模型的通知。此前的实现把它变成了
|
||
// 「裁剪完事后发一条回执」(先在 raise 之前 pruneByQuery,再让
|
||
// Build 生成「已裁剪 N 条…」的说明文本),顺序与语义都反了。
|
||
//
|
||
// 现在:Build 返回空串 ⇒ payload 里没有 content ⇒ 任务是空中断;
|
||
// 裁剪由任务真正被调度执行时(rebalanceFrameContextPrefix)触发。
|
||
//
|
||
// 为什么必须带空串而不是省略 Build:lookupVector 的契约要求 Build
|
||
// 存在(否则调用方 nil panic),"没有正文"要显式表达成 ""。
|
||
Build: func(vectorArgs) string { return "" },
|
||
},
|
||
|
||
VectorRTChildClosed: {
|
||
ID: VectorRTChildClosed,
|
||
Level: LevelCritical,
|
||
Source: func(a vectorArgs) string { return "rtchild/" + a.RTID },
|
||
Guard: func(vectorArgs) bool { return true },
|
||
Build: func(a vectorArgs) string {
|
||
// ★ 父子协议同驻留子 contextfull:**只推信号,不塞正文**。
|
||
//
|
||
// 用户裁定(2026-10-30):「交还是类似驻留式子 agent 的 contextfull
|
||
// 中断,让主 agent **自行选择**保留的内容。」
|
||
//
|
||
// 所以这里**不**把通话原文灌进 payload —— 那等于替主 agent 做了
|
||
// "全都要"的决定,且会把几十万字符直接顶进它的上下文。正文由
|
||
// RTTranscript 按需拉取("推信号 + 拉状态"),与驻留子同一套协议。
|
||
return fmt.Sprintf("内核事件:实时子 agent %s 已回收,本次通话共 %d 条消息(约 %d 字符)。"+
|
||
"如需复盘,可调用 rt_transcript 取回原文并按需选取保留内容。", a.RTID, a.RTCount, a.RTChars)
|
||
},
|
||
},
|
||
|
||
VectorChildContextFull: {
|
||
ID: VectorChildContextFull,
|
||
Level: LevelCritical,
|
||
Source: func(a vectorArgs) string { return "child/" + a.ChildID },
|
||
Guard: func(vectorArgs) bool { return true },
|
||
Build: func(a vectorArgs) string {
|
||
// 父子协议:只推信号,细节靠"查看"拉状态面。
|
||
return fmt.Sprintf("内核事件:驻留子 %s 上下文已满(contextfull)。"+
|
||
"请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", a.ChildID)
|
||
},
|
||
},
|
||
}
|
||
|
||
// lookupVector 取一个向量;未登记时返回 false(**不静默降级**)。
|
||
//
|
||
// 未登记必须响亮失败而不是悄悄用默认级别——否则新增中断源时打错 ID
|
||
// 会变成"中断发了但没人认领",比不编译还难查。
|
||
func lookupVector(id VectorID) (interruptVector, bool) {
|
||
v, ok := interruptVectors[id]
|
||
return v, ok
|
||
}
|
||
|
||
// raiseVector 是登记一次内核中断的**统一入口**。
|
||
//
|
||
// 与直接调用 raiseKernelInterrupt 的区别:级别、来源标识、前置判定、
|
||
// 正文构造全部来自向量表,调用点只声明"哪一类中断 + 载荷"。
|
||
//
|
||
// 返回 true 表示中断真的占了 immediate 槽(会立即生效)。
|
||
func (a *Agent) raiseVector(id VectorID, args vectorArgs) bool {
|
||
v, ok := lookupVector(id)
|
||
if !ok {
|
||
// 未登记是**编码错误**:宁可响亮失败,也不要静默发出一个没人认领的中断。
|
||
panic(fmt.Sprintf("core: 未登记的中断向量 %q(请在 interrupt_vectors.go 里登记)", id))
|
||
}
|
||
if v.Guard != nil && !v.Guard(args) {
|
||
return false
|
||
}
|
||
return a.raiseKernelInterruptAt(v.Source(args), "kernel", v.Build(args), v.Level)
|
||
}
|