From bcf45498a0608ed903af2d0a940d7a5b86815b6e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 30 Oct 2026 18:09:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(skill):=20=E8=A1=A5=20kernel-rtchild=20+?= =?UTF-8?q?=20=E4=B8=A4=E6=9D=A1=E5=88=A4=E6=8D=AE=E9=94=81=E4=BD=8F=20rt?= =?UTF-8?q?=5Fchildren=20=E9=9D=99=E9=BB=98=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## kernel-rtchild skill `resident_agents` 有 `resident-subagent-hygiene` skill,而 `rt_children`/`rt_transcript` 这一对整个**没有 skill** —— 模型只能靠 工具描述猜语义。 最容易踩的坑:**工具执行期不可打断**。模型一旦进入工具执行期就听不见 新输入,所以调工具前必须先用 outputch 说一句"稍等",否则用户会觉得 "它没在听我说话",而实际上它只是卡在一个 3 秒的命令里。 skill 覆盖:三种子 agent 的分界、通道必须成对且两侧声明 rt、打断语义、 只出文本(TTS 是插件的活)、窗口裁剪但原文保留、回收后用 rt_transcript 分段取、闲置零 token 但必须 stop、四条回收判据、五种常见错误对照表。 ## 两条判据(都来自一次真实事故) 生产 e2e 时我用 `input="..."`(工具声明的是 `inputch`)创建 rtchild: `strArg` 只查精确键名 ⇒ 读到空串 ⇒ **本应报"必须成对给出",但它成功了**, 还把 output 通道划给了新子。之后灌语音,该子轮次永远是 0。 症状是"通道注册了、插件活着、推送 ok、但子没反应", 与真因(参数名拼错)隔了三层。 - `WrongParamNameIsRejectedNotSilent`:写错必须失败、必须指名正确参数名、 必须无副作用(不建子、不动通道归属) - `PairAssignmentIsAtomic`:成对分配第二个失败时第一个必须回滚 (原来那句 `rollback()` 没有判据覆盖,是死代码) 变异验证:让代码对 `input`/`output` 静默兜底 ⇒ 第一条变红 (`参数名写错却创建成功了:[typo-child] —— 这会得到一个收不到输入的哑子`)。 go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE --- internal/agent/core/rtchild_route_test.go | 100 ++++++++++++++++++ .../skillmgr/builtin/kernel-rtchild/SKILL.md | 100 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 internal/plugins/skillmgr/builtin/kernel-rtchild/SKILL.md diff --git a/internal/agent/core/rtchild_route_test.go b/internal/agent/core/rtchild_route_test.go index 7775aef6..714d95d7 100644 --- a/internal/agent/core/rtchild_route_test.go +++ b/internal/agent/core/rtchild_route_test.go @@ -437,3 +437,103 @@ func TestRTChildrenTool_NotVisibleToChild(t *testing.T) { t.Fatal("rtchild 自己能看到 rt_children —— 会形成无界自我复制") } } + +// TestRTChildrenTool_WrongParamNameIsRejectedNotSilent 钉住一次**真实事故**。 +// +// ## 事故现场(2026-10-30,生产实例 e2e) +// +// 我用 `rt_children action="create" input="rtvoice-in" output="rtvoice"` +// 创建 rtchild —— 参数名写成了 `input`/`output`,而工具声明的是 +// `inputch`/`outputch`。 +// +// 结果:`strArg(tc,"inputch")` 拿到空串,本应报"必须成对给出", +// **但它成功了**,而且 `rtvoice`(输出通道)被划给了新 rtchild。 +// 随后 `rtvoice_say` 往 `rtvoice-in` 灌语音,rtchild 轮次永远是 0 —— +// 症状是"通道注册了、插件活着、推送 ok,但子没反应", +// 与真因(参数名拼错)隔了三层。 +// +// ## 本条判据钉什么 +// +// **参数名写错必须失败,且错误信息要能让人改对**: +// 指出缺哪个参数名,并列出当前可用的 rt 通道。 +// +// 只断言"返回里有错"不够 —— 那可能只是碰巧提到"创建失败"。 +// 所以同时断言: +// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么 +// ② **没有**创建出 rtchild(不能半成功后失败) +// ③ 通道归属没被改动(不能"创建失败但通道已经被占了") +func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) { + p := &rtCountingProvider{} + host, _ := newRTHost(t, p) + registerRTPair(t, host, "voice-in", "voice") + + // 参数名照抄我当时的错误写法:input/output 而不是 inputch/outputch。 + out := host.executeRTChildren(agentAPI.ToolCall{ + Name: "rt_children", + Arguments: map[string]interface{}{ + "action": "create", + "id": "typo-child", + "input": "voice-in", // ← 错的参数名 + "output": "voice", // ← 错的参数名 + }, + }) + + // ① 必须报错,且指名正确参数名 + if !strings.Contains(out, "inputch") || !strings.Contains(out, "outputch") { + t.Fatalf("参数名写错时必须指出正确的参数名(inputch/outputch),实际:%q", out) + } + // ② 不能创建出来 + if got := host.RTChildren(); len(got) != 0 { + t.Fatalf("参数名写错却创建成功了:%v —— 这会得到一个收不到输入的哑子", got) + } + + // ③ 通道归属不能被改动("失败了但通道已被占"是最难查的那种) + reg := host.io.ChannelRegistry() + for _, name := range []string{"voice-in", "voice"} { + ch, ok := reg.Lookup(name) + if !ok { + t.Fatalf("通道 %s 不见了", name) + } + if ch.Owner != "" { + t.Fatalf("创建失败后通道 %s 的归属被改成了 %q —— 失败必须无副作用", + name, ch.Owner) + } + } +} + +// TestRTChildrenTool_PairAssignmentIsAtomic 钉住成对分配的原子性: +// 第二个通道分配失败时,第一个必须回滚。 +// +// 依据是 rtchild.go 里那句 `rollback() // 把已经划上的 Input 也恢复回去,别留半残`。 +// 没有本条判据的话,那个 rollback 是死代码 —— 改坏了也没人知道。 +func TestRTChildrenTool_PairAssignmentIsAtomic(t *testing.T) { + p := &rtCountingProvider{} + host, _ := newRTHost(t, p) + // 只注册 rt 输入通道,**故意不给** outputch —— 第二个分配必定失败。 + if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{ + Name: "only-in", Plugin: "test", + Capabilities: agentIO.CapText | agentIO.CapRT, + }); err != nil { + t.Fatal(err) + } + + out := host.executeRTChildren(agentAPI.ToolCall{ + Name: "rt_children", + Arguments: map[string]interface{}{ + "action": "create", "id": "half", + "inputch": "only-in", "outputch": "no-such-channel", + }, + }) + if !strings.Contains(out, "创建 rtchild 失败") { + t.Fatalf("应当创建失败,实际:%q", out) + } + // ★ 关键:第一个通道必须被回滚,不能留半残。 + ch, ok := host.io.ChannelRegistry().Lookup("only-in") + if !ok { + t.Fatal("only-in 不见了") + } + if ch.Owner != "" { + t.Fatalf("成对分配失败后 only-in 仍归 %q —— 回滚没生效,留下半残绑定", + ch.Owner) + } +} diff --git a/internal/plugins/skillmgr/builtin/kernel-rtchild/SKILL.md b/internal/plugins/skillmgr/builtin/kernel-rtchild/SKILL.md new file mode 100644 index 00000000..599b2481 --- /dev/null +++ b/internal/plugins/skillmgr/builtin/kernel-rtchild/SKILL.md @@ -0,0 +1,100 @@ +--- +name: kernel-rtchild +description: Realtime child for live calls; inputs interrupt, output is text only. +version: 1.0.0 +--- + +# 实时子 agent(rtchild) + +rtchild 是**第三种**子 agent,只为一件事存在:**通话**。 +它不是"更快的驻留子",也不是"带语音的子任务"——如果场景不是 +"用户正在说话、你必须立刻接",就不要用它。 + +## 1. 三个子 agent 的分界 + +| 类型 | 工具 | 生命周期 | 适用 | +| --- | --- | --- | --- | +| 一次性子任务 | `spawn_child` | 一轮内跑完就没 | 单一明确的小活 | +| 驻留子 | `resident_agents` | 长期存在,有自己的调度器/记忆 | 长期派驻、持续盯一条线 | +| **rtchild** | `rt_children` | 一次通话 | **实时对话**,输入随时打断 | + +选错的代价不是"慢一点":把通话场景做成驻留子,用户每句话都要排队等前一轮 +跑完,主观感受是"这个助手反应特别慢";把长期任务做成 rtchild,它会被 +每一句新输入反复打断,永远做不完。 + +## 2. 通道必须成对,且两侧都要声明 rt + +```text +inputch(rt) ← 采集层喂进来的话 +outputch(rt) → 你说的文本(内核只出文本,TTS 由插件做) +``` + +- **必须成对**:只给一侧会被拒(两侧都会校验 `Realtime`)。 +- **两侧都要 `Realtime: true`**:普通通道也能划给 rtchild,但**承载不了 rt 流**。 + 这条以前被跨进程白名单丢过——插件声明了、内核收到 false、报错却指向 + "创建 rtchild 失败",与真因隔两层。 +- 划进去必须**验一次**:投一条输入后看 `list` 里轮次是否 +1。没动就是没划对。 + +## 3. 打断是默认语义,不是例外 + +往 rt 输入通道投递**就是打断**:当前轮的 LLM 流被取消,新输入立刻开新一轮。 +不排队、不重排、不重放残留。 + +**唯一不被打断的窗口是工具执行期。** 工具跑到一半被取消会留下半完成的 +副作用(文件写一半、命令跑到一半),所以那里挂起取消、等它跑完。 + +⇒ 由此得出一条必须遵守的规矩: + +> **调工具之前,先用 outputch 说一句"稍等"。** +> +> 模型一旦进入工具执行期就听不见用户的新输入了。不说这句,用户会觉得 +> "它没在听我说话"——而实际上它只是卡在一个 3 秒的命令里。 + +## 4. 输出只有文本 + +`outputch(rt)` 收到的永远是文本。**内核不做音频合成**: + +- TTS / 字幕 / 播放 → 插件自己的事 +- 不要指望内核返回音频,也不要为了"发语音"去挂 `audio` 能力位 + +## 5. 上下文:窗口裁剪,但原文不丢 + +rtchild 用的是**普通上下文 + 窗口**(没有 rt 专用的分页/锚点那一套): + +- 旧内容**不再进模型**(否则窗口撑爆,且早年的寒暄会污染当前对话) +- 旧内容**原文仍在**(不删,只是不进视图) +- 裁剪**从不切开 tool call 与它的结果**(切开会留下无主的 tool result) + +## 6. 回收:原文交给你,取舍由你定 + +`rt_children action="stop"` 会把这场通话**作为一条 L4 中断**交给你, +并告诉你共多少条消息。 + +★ **不要**直接把整段塞进上下文——那正是窗口爆掉的原因。 +用 `rt_transcript(id, offset, limit)` **分段**取,只留你真正需要的。 + +如果 `stop` 回的是"本次通话没有上下文需要交接",那是**真的没有** +(该子还没产生任何消息),此时不该再调 `rt_transcript`。 + +## 7. 闲置零 token + +没有输入时不跑任何 LLM 调用。所以通话结束**必须 stop**—— +挂着的 rtchild 虽然不烧 token,但它占着 rt 通道、占着登记表, +而且你迟早会忘了它。 + +## 8. 回收判据(出现任一条,当轮就 stop) + +1. **通话结束**:用户挂了/说了再见/话题收口 → stop,并按第 6 条取原文。 +2. **通道没了**:插件卸载、对应通道注销 → stop,否则是个收不到活的僵尸。 +3. **建错了**(输入通道填成输出通道名等)→ 立刻 stop 重建, + 别留着调试——它会把本该给别人的输入吃掉。 + +## 9. 常见错误 + +| 现象 | 真因 | +| --- | --- | +| create 报"通道不能承载 rt 流" | 该通道没声明 `Realtime`,或用了只注册了一侧的通道 | +| create 成功但 list 里轮次永远 0 | inputch 名填错了(常见:把 outputch 名当 inputch 用) | +| 用户说"它不听我说话" | 工具执行期不可打断,且模型没先说"稍等" | +| 期望收到音频却只有文本 | 内核只出文本,TTS 是插件的事 | +| stop 后拿不到原文 | 顺着提示用 `rt_transcript` 分段取,不要期待整段自动进上下文 |