Files
HomeAgent/internal/agent/core/commondev_premise_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

228 lines
8.7 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
// 刀 0:验证 commondev 架构赖以成立的前提。
//
// 设计 docs/zh/commondev-agent-runtime-design.md §2.2 / §2.3 / §2.5。
//
// 整套「插件注册通道 → 主 agent 建驻留子并划入 → 子调 output_send__ 下任务」
// 的架构,建立在**两个**此前只有读码结论、从未跑过的事实上:
//
// P1. 驻留子能调到**外部插件**注册的工具(若不能,子无法下任务给 pi)
// P2. 驻留子能用**外部插件**注册的输出门 output_send__<ch>(若不能,通道白注册)
//
// 这两条不成立,上层机制全是纸面的 —— 所以先验它,再写插件。
//
// 注册路径与真实插件一致:工具走 StageHost,输出通道走 IOManager.RegisterDevice
// (插件经 proc 桥最终也落到这两处,见 internal/plugin/registry.go:305-320)。
import (
"strings"
"testing"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// 与 commondev 真实用法一致的通道名:inputch 可含斜杠,outputch 必须合规
// (会被拼成 output_send__<name>,见 SDK RegisterOutputChannel 注释)。
const (
probeInputCh = "commondev/br-1"
probeOutCh = "commondev_br-1"
probeTool = "commondev_probe"
probeOutGate = "output_send__" + probeOutCh
)
// probeDevice 模拟插件注册的输出通道(与内部 channelDevice 同构)。
type probeDevice struct {
name string
caps agentIO.OutputCapability
handler sdk.ToolHandler
got *string
}
func (d *probeDevice) Name() string { return d.name }
func (d *probeDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *probeDevice) Description() string { return "探针通道:给 pi 投递任务" }
func (d *probeDevice) Start() error { return nil }
func (d *probeDevice) Stop() error { return nil }
func (d *probeDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *probeDevice) Tools() []agentIO.ToolDef { return nil }
func (d *probeDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{NoMemory: true} }
func (d *probeDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
if d.got != nil {
*d.got = payload // 记录插件 handler 真的收到了什么
}
return d.handler(args)
}
// registerProbePlugin 模拟 commondev 插件注册工具与通道。
// delivered 非 nil 时记录插件 handler 实际收到的 payload(证明"包一下"真的发生)。
func registerProbePlugin(t *testing.T, a *Agent, delivered *string) {
t.Helper()
// ① 插件工具:子要能调它(P1)
if err := a.stageHost.RegisterTool(probeTool, sdk.ToolDef{
Name: probeTool,
Description: "探针:插件注册的普通工具",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, func(args map[string]interface{}) (interface{}, error) {
return "plugin-tool-ok", nil
}); err != nil {
t.Fatalf("插件工具注册失败: %v", err)
}
// ② 插件输出通道:子要能用 output_send__commondev_br-1(P2)
if err := a.io.RegisterDevice(&probeDevice{
name: probeOutCh,
caps: agentIO.CapText,
handler: func(args map[string]interface{}) (interface{}, error) {
return "已投递", nil
},
got: delivered,
}); err != nil {
t.Fatalf("插件输出通道注册失败: %v", err)
}
// ③ inputch:pi → 子 的回投入口
if err := a.io.RegisterInputChannelFrom("commondev", probeInputCh, agentIO.ChannelDef{NoMemory: true}); err != nil {
t.Fatalf("inputch 注册失败: %v", err)
}
}
// TestP1_ResidentSeesExternalPluginTools 钉住 P1:
// 驻留子的工具表里必须有**外部插件**注册的工具,且真的能执行。
//
// 若不成立:子无法下任务给 pi ⇒ 整套架构作废(必须改为父代投)。
func TestP1_ResidentSeesExternalPluginTools(t *testing.T) {
parent, _, dir := newRootForResidents(t)
registerProbePlugin(t, parent, nil)
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
child := residentOf(t, parent, info.ID)
// ① 工具表(模型看得到的)里要有
names := childToolNames(child)
if !hasToolNamed(names, probeTool) {
t.Fatalf("P1 不成立:驻留子看不到外部插件工具 %s\n看到的是: %v", probeTool, names)
}
t.Logf("P1a ✅ 驻留子工具表含插件工具(工具总数 %d)", len(names))
// ② 且真的能执行(能看到 ≠ 能调到)
got := child.executeToolCall(agentAPI.ToolCall{
ID: "p1", Name: probeTool, Arguments: map[string]interface{}{},
}, probeInputCh)
if !strings.Contains(got, "plugin-tool-ok") {
t.Fatalf("P1 不成立:工具在表里但调不通: %s", got)
}
t.Log("P1b ✅ 且可执行(plugin-tool-ok)")
}
// TestP2_ResidentCanUseExternalPluginOutputGate 钉住 P2:
// 驻留子必须能通过 output_send__commondev_br-1 打到**插件注册的通道 handler**。
//
// 若不成立:通道注册了但子用不了 ⇒ 投递路径断掉。
func TestP2_ResidentCanUseExternalPluginOutputGate(t *testing.T) {
parent, _, dir := newRootForResidents(t)
var delivered string
registerProbePlugin(t, parent, &delivered)
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
child := residentOf(t, parent, info.ID)
// ① 输出门工具存在(未授权则工具表里根本不会有)
names := childToolNames(child)
if !hasToolNamed(names, probeOutGate) {
t.Fatalf("P2 不成立:驻留子看不到 %s\n看到的是: %v", probeOutGate, names)
}
t.Logf("P2a ✅ 驻留子有 %s", probeOutGate)
// ② 调用它 —— 必须真的打到插件 handler
//
// 期望返回 "ok":`output.go:94` 只在 `dev.Execute()` **成功**时返回它。
// ⚠️ 不能断言返回插件 handler 的文本:内核对成功的回执刻意**极简**
//(rich 回执会驱动模型继续调 output_send,是 output loop 的根源)。
// 所以"插件真的收到了"要由 handler 自己记下来(delivered)来证。
got := child.executeToolCall(agentAPI.ToolCall{
ID: "p2", Name: probeOutGate,
Arguments: map[string]interface{}{"payload": "给 pi 的任务:修 bug", "type": "text"},
}, probeInputCh)
if strings.Contains(got, "不存在") || strings.Contains(got, "未授权") {
t.Fatalf("P2 不成立:输出门不可用: %s", got)
}
if got != "ok" {
t.Fatalf("P2 不成立:输出门应返回 ok(dev.Execute 成功),实际: %q", got)
}
// ★ 真正的判据:插件 handler 收到的 payload
if delivered != "给 pi 的任务:修 bug" {
t.Fatalf("P2 不成立:插件 handler 收到的 payload 不符: %q", delivered)
}
t.Logf("P2b ✅ 输出门打到插件 handler,payload 完整传达(%d 字节)", len(delivered))
}
// TestP3_ChannelNameSlashIsIllegalForOutputGate 钉住命名约束(文档 §2.2):
// outputch 带斜杠会让 output_send__<name> 不合法 ⇒ 工具名违规。
//
// 这条是**反例**:证明"inputch 可用斜杠、outputch 不行"不是猜想。
func TestP3_ChannelNameSlashIsIllegalForOutputGate(t *testing.T) {
parent, _, dir := newRootForResidents(t)
registerProbePlugin(t, parent, nil)
info := spawnTestResident(t, parent, dir, "dev-br-1", probeInputCh)
child := residentOf(t, parent, info.ID)
names := childToolNames(child)
// 合法命名:有
if !hasToolNamed(names, probeOutGate) {
t.Fatalf("下划线命名应可用,但没有 %s", probeOutGate)
}
// 非法命名:绝不能出现在工具表里
for _, n := range names {
if strings.HasPrefix(n, "output_send__") && strings.Contains(n, "/") {
t.Fatalf("发现带斜杠的输出门工具名 %q —— 会触发上游 400", n)
}
}
t.Log("P3 ✅ 输出门命名合规(无斜杠)")
}
// ---------------- 测试辅助 ----------------
// residentOf 取父登记表里的驻留子(不存在则 fatal)。
func residentOf(t *testing.T, parent *Agent, id string) *Agent {
t.Helper()
rc := parent.residentOf(id)
if rc == nil || rc.agent == nil {
t.Fatalf("驻留子 %s 不存在", id)
}
return rc.agent
}
// childToolNames 取某个 agent 当前工具表里的名字。
func childToolNames(a *Agent) []string {
var out []string
for _, raw := range a.buildToolDefs() {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
fn, _ := m["function"].(map[string]interface{})
if fn == nil {
continue
}
if name, _ := fn["name"].(string); name != "" {
out = append(out, name)
}
}
return out
}
func hasToolNamed(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}