Files
HomeAgent/internal/agent/core/rtchild_r10_test.go
JianFeeeee a02feaaff7 refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。

## 1. 抽出 child 包(类型抽象 + 单一登记表)

新增 internal/agent/core/child/:
  child.go      Kind / Child(嵌入用)/ State / 残余与通道处置
  registry.go   单一 Registry + Entry 接口 + Cleanup 骨架
  kind.go       ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
  kinds.go      四种子 agent 的清单与迁移状态

此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。

现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。

## 2. rt 的输出改为异步(用户的原始诉求)

rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。

新增 outputpump.go(输出通道的异步壳):
  · Push 立即返回,交付由独立消费 goroutine 完成
  · 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
  · 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
  · Close 排空后退出,不吞最后半句
  · 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)

## 3. 工具钩子统一(本轮最大发现)

实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。

⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。

新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。

## 判据(均经变异验证)

  TestRTChild_OutputIsAsync              改回同步 ⇒ 红(实测确认)
  TestOutputPump_DropsOldestOnFull       改阻塞/无界 ⇒ 红
  TestOutputPump_CloseDrains             丢弃队列 ⇒ 红
  TestOutputPump_ResolvesDeviceEachTime  缓存指针 ⇒ 红
  TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
  TestRTChild_StageCanDenyTool           同上
  TestWorkerToolsGoThroughPluginStages   同上(变异实测确认)
  TestWorkerToolsStaySerial              改成并发 ⇒ 红
  TestNoChildTypeTablesInCore            源码扫描,禁类型专属表回潮
  TestRTChild_HasGraphMemory 等 5 条     rt 接入图记忆

## 顺带修复

  · rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
    memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
    默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
    闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
  · rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。

## 保留的语义(逐项确认仍在)

  驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
  三种通道处置:ChannelRelease / Suspend / Takeover
  残余 keep/drop + harvestResidentMemory
  child.Start() 的 7 个 goroutine

验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
2026-11-04 08:21:15 +08:00

168 lines
5.6 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 core
// R10 判据:rtchild 的工具面(用户裁定 R3 / R4)。
//
// R3:rt 流**不禁工具**;`askparent` 工具**保留**
// R4:提示词要求「调用工具前先用 outputch 告知用户稍等」
//
// `askparent` 在本仓的实际形态是 `notify_parent`(子侧向父发问的唯一通道,
// 见 tooldefs.go:874 的注册条件 parentID != "")。所以这里钉的是:
//
// 1. rtchild 的工具表里**有** notify_parent(且不是被剥掉的)
// 2. 它的回调**真的接线**(初版没接 ⇒ 模型一调就得到"没有上级")
// 3. 工具面**不因 rt 而收缩**(R3:不禁工具)
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
)
// rtToolNames 从 buildToolDefs 的结果里抽工具名。
func rtToolNames(defs []interface{}) map[string]bool {
out := map[string]bool{}
for _, t := range defs {
m, ok := t.(map[string]interface{})
if !ok {
continue
}
fn, ok := m["function"].(map[string]interface{})
if !ok {
continue
}
if name, ok := fn["name"].(string); ok {
out[name] = true
}
}
return out
}
// TestRTChild_AskparentToolPresent 钉住 R3 后半:`askparent`(= notify_parent)保留。
//
// 若有人"顺手"在 rtStripOutputTools 里把它也剥掉(它确实和输出有关),本判据必红。
func TestRTChild_AskparentToolPresent(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "ask")
rc := host.rtChildOf(id)
if rc == nil {
t.Fatal("实例不在登记表")
}
tools := rtStripOutputTools(rc.agent.buildToolDefs())
names := rtToolNames(tools)
// carrier 的 parentID != "" ⇒ notify_parent 应在子侧工具表里。
// 若这里没有,说明 rtchild 的 carrier 没被正确标记为"子"。
if !names["notify_parent"] {
t.Fatalf("rtchild 工具表里没有 notify_parent(askparent)—— 用户裁定 R3 要求保留\n已有工具:%v",
func() []string {
var ks []string
for k := range names {
ks = append(ks, k)
}
return ks
}())
}
// output_send__* 必须被剥掉(rt 输出走 outputch(rt) 流式管线)。
for name := range names {
if strings.HasPrefix(name, "output_send__") {
t.Fatalf("rtchild 工具表里仍有 %s —— 会与流式管线产生两套输出语义", name)
}
}
}
// TestRTChild_NotifyParentIsWired 钉住**接线**(这是初版的真实缺陷):
// notify_parent 的回调必须已接上,调用后不能返回"没有上级"。
//
// 初版 carrier 没设 notifyParent ⇒ notifyParentFrom 直接返回
// 「本 agent 没有上级,无法发送消息」——工具在、调用必失败。
func TestRTChild_NotifyParentIsWired(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "wire")
rc := host.rtChildOf(id)
if rc == nil || rc.agent.notifyParent == nil {
t.Fatal("rtchild 的 notifyParent 未接线 —— notify_parent 工具会永远失败")
}
// 走真实工具入口(不是直接调回调):这才是模型看到的那条路。
got := rc.agent.executeNotifyParent(agentAPI.ToolCall{
Name: "notify_parent",
Arguments: map[string]interface{}{"text": "我想问一下"},
})
if strings.Contains(got, "没有上级") {
t.Fatalf("notify_parent 走真实入口仍报「没有上级」:%s", got)
}
// 入站通道只登记一次:反复发问不应让登记表累积。
reg := host.io.ChannelRegistry()
before := reg.Count()
for i := 0; i < 5; i++ {
rc.agent.executeNotifyParent(agentAPI.ToolCall{
Name: "notify_parent",
Arguments: map[string]interface{}{"text": "再问一次"},
})
}
if after := reg.Count(); after != before {
t.Fatalf("连发 5 次 notify_parent 后登记表从 %d 涨到 %d —— 每次发问都在重复登记(写放大+脏数据)",
before, after)
}
}
// TestRTChild_ToolFaceNotNarrowedByRT 钉住 R3 前半:**rt 流不禁工具**。
//
// 判据取法:把 rtchild 的工具数量与"同一个 agent 不用 rt 时的工具数量"比 ——
// rt 只该剥掉 output_send__*,不该额外剥任何东西。
func TestRTChild_ToolFaceNotNarrowedByRT(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
id := rtSpawn(t, host, "face")
rc := host.rtChildOf(id)
all := rtToolNames(rc.agent.buildToolDefs())
kept := rtToolNames(rtStripOutputTools(rc.agent.buildToolDefs()))
// 只允许 output_send__* 与 output_list_channels 被剥。
for name := range all {
if kept[name] {
continue
}
if !strings.HasPrefix(name, "output_send__") && name != "output_list_channels" {
t.Fatalf("rt 额外剥掉了工具 %s —— 用户裁定 R3「rt 流不禁工具」", name)
}
}
// 至少要保留一些工具(否则"不禁工具"名存实亡)。
if len(kept) == 0 {
t.Fatal("rtchild 工具表为空 —— 等于禁了所有工具")
}
}
// TestRTChild_StopUnregistersInbound 钉住 Stop 时注销入站通道(不留脏登记)。
func TestRTChild_StopUnregistersInbound(t *testing.T) {
p := &rtBlockingProvider{}
host, _ := newRTHost(t, p)
reg := host.io.ChannelRegistry()
before := reg.Count()
id := rtSpawn(t, host, "unreg")
if reg.Count() <= before {
t.Fatal("创建后没有登记入站通道 —— 父侧收不到 rtchild 的发问")
}
if err := host.StopRTChild(id); err != nil {
t.Fatalf("停止: %v", err)
}
if got := reg.Count(); got != before {
t.Fatalf("停止后登记表 %d,期望回到 %d —— 入站通道未注销", got, before)
}
}
// 确保 agentIO 被使用(ChannelRegistry 类型在断言中通过接口访问)。
var _ = agentIO.CapText