mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-03 15:57:35 +00:00
## 上一版错在哪
上一版(1d007e1)给子 agent 加了 `Security: a.security`,判据也钉的是
「子持有**同一个** Runtime 实例」。
那只保证了**机制**到位,却把权限模型写错成了「子 = 父的完整权限」。
用户裁定:**应当是父 agent 为子 agent 声明策略**。
区别是实质的:
错的模型:父能调 cmd_* ⇒ 子也能调 cmd_*(子只受全局策略约束)
对的模型:父在创建子时决定这个子能调什么,内核持有并执行,子不能自己扩大
驻留子长期无人盯、rtchild 通话中一边听一边执行工具 —— 它们恰恰最需要
**比父更窄**的权限,而不是与父等权。
## 这不是新机制,是没接线的旧机制
`security.Runtime` 里早就有:
CheckToolFor(p Principal, tool) // 按主体判定
PrincipalPolicy{AllowTools, DenyTools, // 主体策略
HardPrivateTools}
DeclarePrincipalPolicy(scope, key, policy) // 声明入口
而 `codegraph_callers("CheckToolFor")` = **0 个生产调用方**
(与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
公开 SDK 也没暴露 `DeclarePrincipalPolicy`(插件暂时无法声明主体策略)。
## 改动
① `security`:新增 `RevokePrincipalPolicy(scope,key)` / `HasPrincipalPolicy(id)`。
**撤销是必须的**:主体 ID 是调用方给的名字(`create id="bot1"`),
子回收后同名重建是常规操作,旧策略留着 ⇒ 新一代用上一代父留的约束运行。
这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
全仓 0 处删除)是**同一种形态**。撤销只支持 scope="id" —— 允许撤销 tag
等于任何执行体销毁时都能抹掉全站策略。
② `agent/core`:`securityDecisionFor` 分两条路径
父(parentID == "") → CheckTool(tool) // 内核自身
子(parentID != "") → CheckToolFor(Principal{self, "resident"},
tool) // 叠加父的声明
CheckToolFor 内部**先**调 CheckTool,所以主体策略无法绕过全局黑名单。
主体 Kind 用 "resident"(IsInternal 为真):HardPrivateTools 不拦内部委派
(那条防的是用户主体偷看私人数据),但父声明的 Deny/Allow 仍然生效。
③ 创建点声明、销毁点撤销(生命周期对称)
SpawnResident → declareChildPolicy(opts.AllowedTools, opts.DenyTools)
SpawnRTChild → 同上
teardownResidentWith / StopRTChild / StopRTChildren → revokeChildPolicy
声明**必须在子开始执行之前**(rtLoop / child.Start 之前),否则存在一个
「子已跑工具、策略还没进内核」的窗口。
声明失败 ⇒ 回滚整个创建:一个「本该受限却无限」的执行体比创建失败危险得多。
④ 工具面:`resident_agents create` 与 `rt_children create` 新增
`allowed_tools` / `deny_tools`。描述里写明「**你为它声明**」而非「授权」,
因为模型很容易把白名单误读成额外授予。
## 判据(4 个变异全部实测咬住)
| 判据 | 变异 |
|---|---|
| TestParentDeclaresChildPolicy | 子退化为 CheckTool(不分主体)⇒ 红 |
| TestChildTeardownRevokesPolicy | teardown 不撤销 ⇒ 红;StopRTChildren 漏撤销 ⇒ 红 |
| TestPolicyDeclaredBeforeChildStarts | 声明挪到 rtLoop 之后 ⇒ 红 |
| TestPrincipalPolicyCanBeRevoked / TestRevokeRejectsTagScope | security 侧撤销语义 |
`TestParentDeclaresChildPolicy` 同时钉三件事:① 父声明的 deny 拦住子
② 同一条策略**不影响父**(主体隔离,不是全局策略)③ 撤销后子恢复。
`TestChildTeardownRevokesPolicy` 用源码断言单列,因为本仓反复吃亏在
「单数路径改了、复数路径漏了」—— `StopRTChild` 注销入站登记而
`StopRTChildren` 漏了,就是这么来的。真销毁需要完整记忆栈,而
「前置不满足就 skip」的判据等于没有。
## 实测(真实整机装配 + 真执行)
① 父 ExecuteTool(cmd_run) → 走到分派(未被拦)
② 父声明 deny=[cmd_run] 后,子的判定 → Allowed=false
reason="tool \"cmd_run\" is not permitted for this principal (deny \"cmd_run\")"
③ 父自己的判定 → 仍 Allowed(主体隔离)
④ 撤销后子的判定 → 恢复 Allowed
build / vet / go test ./...(62 ok / 0 fail)/ -race(kernelmod/all +
agent/core + security)全绿;gofmt 漂移清零。
## 仍未接线
- `ScanContent`(敏感词/注入预警)仍 0 生产调用方
- 公开 SDK 未暴露 `DeclarePrincipalPolicy`(插件无法声明主体策略)
- `OversizeToolReports` / `oversizeHintFor` 仍 0 消费者
541 lines
20 KiB
Go
541 lines
20 KiB
Go
package core
|
||
|
||
// 判据:**插件 → rtchild 的通路**。
|
||
//
|
||
// 这是本轮发现的最后一个真实缺口(比 R10 的 notify_parent 更关键):
|
||
//
|
||
// 插件只能调 Inject* 系列(SDK 里没有任何 rtchild 接口),
|
||
// 它们全部汇到 IOManager.deliverInput → routeInputByOwner。
|
||
// 而初版 routeInputByOwner 只认 a.residents(驻留子)——
|
||
// rt 通道的 Owner 是 rtchild id,查不到就**兜底给主 agent 处理**。
|
||
//
|
||
// 后果:插件的语音/画面输入永远打在父身上,rtchild 的轮次永远是 0。
|
||
// 这正是 resident-subagent-design.md 记的那个现场故障的同型形态
|
||
// (「子挂着 inputch=[timer],timer 的输入却打在父身上」)。
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HologramAgent/internal/agent/io"
|
||
)
|
||
|
||
// registerRTChannelWithOwner 注册一个 rt 通道并划给指定 rtchild。
|
||
func registerRTChannelWithOwner(t *testing.T, host *Agent, name, ownerID string) {
|
||
t.Helper()
|
||
reg := host.io.ChannelRegistry()
|
||
if err := reg.Register(agentIO.InputChannel{
|
||
Name: name, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||
}); err != nil {
|
||
t.Fatalf("注册 rt 通道: %v", err)
|
||
}
|
||
if err := reg.Assign(name, ownerID, 0); err != nil {
|
||
t.Fatalf("划给 %s: %v", ownerID, err)
|
||
}
|
||
}
|
||
|
||
// TestRTChannel_PluginInputReachesRTSlot 是这条通路的**核心判据**:
|
||
// 插件往 rt 通道注入输入,必须进 rtSlot 并真的跑起一轮。
|
||
//
|
||
// 若 routeInputByOwner 不识别 rtchild,输入会被兜底给主 agent 处理
|
||
// (宿主没有调度器,于是什么都不会发生)—— 本判据会超时变红。
|
||
func TestRTChannel_PluginInputReachesRTSlot(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
// 先注册一对 rt 通道;**划拨由 SpawnRTChild 自己做**(成对划拨)。
|
||
registerRTPair(t, host, "voice-in", "voice")
|
||
id, err := host.SpawnRTChild(RTChildOptions{ID: "voice-child", Input: "voice-in", Output: "voice"})
|
||
if err != nil {
|
||
t.Fatalf("创建 rtchild: %v", err)
|
||
}
|
||
|
||
// 插件注入:完全走真实路径(IOManager → deliverInput → router)。
|
||
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
|
||
map[string]interface{}{"content": "用户说话了"})
|
||
|
||
waitFor(t, "rtchild 被插件输入唤起", func() bool {
|
||
st, ok := host.RTChildStats(id)
|
||
return ok && st.Rounds >= 1
|
||
})
|
||
|
||
calls, _ := p.stats()
|
||
if calls == 0 {
|
||
t.Fatal("插件输入到达了 rtSlot 但没跑模型")
|
||
}
|
||
// 内容必须真的进入 history(不是空跑)。
|
||
tr, _ := host.RTTranscript(id)
|
||
if len(tr) == 0 {
|
||
t.Fatal("跑了一轮但 history 为空")
|
||
}
|
||
}
|
||
|
||
// TestRTChannel_PluginInputIsLatestWins 钉住:插件连发时也走**单槽覆盖**语义,
|
||
// 不会因为"走了路由"就退化成排队。
|
||
func TestRTChannel_PluginInputIsLatestWins(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
registerRTPair(t, host, "voice-in", "voice")
|
||
id, err := host.SpawnRTChild(RTChildOptions{ID: "burst-child", Input: "voice-in", Output: "voice"})
|
||
if err != nil {
|
||
t.Fatalf("创建 rtchild: %v", err)
|
||
}
|
||
time.Sleep(30 * time.Millisecond)
|
||
|
||
const burst = 40
|
||
for i := 0; i < burst; i++ {
|
||
host.io.InjectInputTo("voice-plugin", "voice-in", "text",
|
||
map[string]interface{}{"content": "连发语音"})
|
||
}
|
||
waitFor(t, "槽被消费", func() bool {
|
||
st, ok := host.RTChildStats(id)
|
||
return ok && !st.Pending
|
||
})
|
||
|
||
st, _ := host.RTChildStats(id)
|
||
|
||
// ★ 判据取**不变式**而不是"跑了多少轮"。
|
||
//
|
||
// 初版断言 rounds <= 3,在 -race 下稳定失败(实测跑了 13 轮):
|
||
// 竞态插桩让 emit 变慢,40 条在 rtLoop 起轮之前全部到达,于是
|
||
// 每完成一轮就立刻被下一条唤起 —— 那不是"队列退化",而是**正常的
|
||
// 最新者胜**(每一轮用的都是当时最新的一条,没有一条在等前一条跑完)。
|
||
//
|
||
// 真正该钉的是:**每条输入要么被处理过、要么被覆盖丢弃**,
|
||
// 不存在"既没处理也没记录丢弃"的第三条路。
|
||
if st.Dropped == 0 && st.Rounds < burst {
|
||
t.Fatalf("连发 %d 条:dropped=0 且只跑了 %d 轮 —— "+
|
||
"有输入凭空消失了(既没被处理,也没计入覆盖丢弃)", burst, st.Rounds)
|
||
}
|
||
// 槽最终必须被清空(没有积压 —— 这是"不排队"的直接证据)。
|
||
if st.Pending {
|
||
t.Fatal("连发结束后槽里仍有待处理输入 —— rt 通道在排队")
|
||
}
|
||
}
|
||
|
||
// TestRTChannel_NoOwnerStillFallsBackToMain 钉住边界:
|
||
// rt 通道**未划拨**时输入仍归父处理(不静默丢)。
|
||
//
|
||
// 这条防"为了让 rt 通路工作而把所有 rt 输入都截走"——那会让未分配的
|
||
// rt 通道输入凭空消失。
|
||
func TestRTChannel_NoOwnerStillFallsBackToMain(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
// 注册但**不划拨**(Owner == "")—— 这条判据测的是路由的兜底分支,
|
||
// 与 SpawnRTChild 的成对划拨无关,所以直接登记一条裸通道。
|
||
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||
Name: "unassigned", Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||
}); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
|
||
// 路由必须返回 false(不接管),让父的队列收下它。
|
||
if handled := host.routeInputByOwner(&agentIO.InputEvent{
|
||
Source: "voice-plugin", Type: "text", OutputChannel: "unassigned",
|
||
Payload: map[string]interface{}{"content": "无人认领"},
|
||
}, false); handled {
|
||
t.Fatal("未划拨的 rt 通道输入被截走了 —— 会静默丢输入")
|
||
}
|
||
}
|
||
|
||
// TestRTChannel_SDKRealtimeBecomesCapRT 钉住 SDK → 内核的**唯一一次**能力位转换:
|
||
// 插件在 ChannelDef 里声明 Realtime=true,登记后通道必须带上 CapRT,
|
||
// 从而**能**被 AssignRealtime 划拨。
|
||
//
|
||
// 若去掉 Register 里的映射,插件就无法登记 rt 通道 ——
|
||
// rtchild 永远拿不到插件的语音输入(整条链路断在第一步)。
|
||
func TestRTChannel_SDKRealtimeBecomesCapRT(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
|
||
// 插件侧声明(走 SDK 的 ChannelDef.Realtime)。
|
||
if err := host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||
Name: "sdk-rt", Plugin: "voice",
|
||
Def: agentIO.ChannelDef{Realtime: true},
|
||
}); err != nil {
|
||
t.Fatalf("注册: %v", err)
|
||
}
|
||
|
||
ch, ok := host.io.ChannelRegistry().Lookup("sdk-rt")
|
||
if !ok {
|
||
t.Fatal("通道未登记")
|
||
}
|
||
if !ch.Capabilities.Supports(agentIO.CapRT) {
|
||
t.Fatalf("Def.Realtime=true 但 Capabilities 没有 CapRT(= %v)—— "+
|
||
"插件声明的 rt 通道无法被划拨", ch.Capabilities)
|
||
}
|
||
|
||
// 端到端:该通道能被 rtchild 划拨(这是"插件能接入"的判定点)。
|
||
id, _ := host.SpawnRTChild(RTChildOptions{ID: "sdk-child", Input: "sdk-rt", Output: "voice"})
|
||
rc := host.rtChildren[id]
|
||
if err := rc.agent.AssignRealtime("sdk-rt"); err != nil {
|
||
t.Fatalf("插件声明的 rt 通道无法划给 rtchild:%v", err)
|
||
}
|
||
}
|
||
|
||
// ── 成对划拨 / 主 agent 无 rt 能力 / 销毁后悬空 ──
|
||
//
|
||
// 用户裁定(2026-10-30):
|
||
// 「主 agent 创建 rtchild,将 rt 通道划给 rtchild,开始工作
|
||
// (rt 通道必须**成对**划给子 agent)」
|
||
// 「主 agent 没有 rt 能力,rt 通道在 rtchild 销毁后应当**悬空**」
|
||
|
||
// registerRTPair 注册一对 rt 通道(input + output),供 Spawn 划拨。
|
||
func registerRTPair(t *testing.T, host *Agent, in, out string) {
|
||
t.Helper()
|
||
reg := host.io.ChannelRegistry()
|
||
for _, n := range []string{in, out} {
|
||
if err := reg.Register(agentIO.InputChannel{
|
||
Name: n, Plugin: "voice", Capabilities: agentIO.CapText | agentIO.CapRT,
|
||
}); err != nil {
|
||
t.Fatalf("注册 rt 通道 %s: %v", n, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSpawnRTChild_AssignsPairedChannels 钉住"成对划拨":
|
||
// SpawnRTChild 必须把 input 与 output **两条**都划给 rtchild,而不是只划一条。
|
||
//
|
||
// 变红条件:去掉 SpawnRTChild 里的 AssignRealtime 调用 ⇒ Owner 仍是 "" ⇒ 红。
|
||
func TestSpawnRTChild_AssignsPairedChannels(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in", "voice")
|
||
|
||
id, err := host.SpawnRTChild(RTChildOptions{ID: "paired", Input: "voice-in", Output: "voice"})
|
||
if err != nil {
|
||
t.Fatalf("创建 rtchild: %v", err)
|
||
}
|
||
|
||
reg := host.io.ChannelRegistry()
|
||
for _, n := range []string{"voice-in", "voice"} {
|
||
ch, ok := reg.Lookup(n)
|
||
if !ok {
|
||
t.Fatalf("通道 %s 未登记", n)
|
||
}
|
||
if ch.Owner != id {
|
||
t.Fatalf("通道 %s 的 Owner = %q,期望 %q —— "+
|
||
"rt 通道没有成对划给 rtchild", n, ch.Owner, id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSpawnRTChild_RejectsMissingHalf 钉住成对的**强制性**:
|
||
// 只给 input 或只给 output 都必须被拒(否则 rtchild 半残且不报错)。
|
||
func TestSpawnRTChild_RejectsMissingHalf(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in2", "voice2")
|
||
|
||
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-in", Output: "voice2"}); err == nil {
|
||
t.Fatal("只给 outputch 竟然创建成功 —— rtchild 会永远听不到用户")
|
||
}
|
||
if _, err := host.SpawnRTChild(RTChildOptions{ID: "no-out", Input: "voice-in2"}); err == nil {
|
||
t.Fatal("只给 inputch 竟然创建成功 —— rtchild 的输出无处可去")
|
||
}
|
||
// 被拒后**不得**在登记表里留下半残实例。
|
||
if got := host.RTChildren(); len(got) != 0 {
|
||
t.Fatalf("创建被拒但登记表里有 %v", got)
|
||
}
|
||
}
|
||
|
||
// TestSpawnRTChild_RejectsChannelOwnedByMainAgent 钉住「主 agent 没有 rt 能力」:
|
||
// 一条归属于主 agent 的通道不能被拿来当 rt 通道。
|
||
func TestSpawnRTChild_RejectsChannelOwnedByMainAgent(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in3", "voice3")
|
||
|
||
// 把 input 划给主 agent 自己(模拟"主 agent 占着这条通道")。
|
||
if err := host.io.ChannelRegistry().Assign("voice-in3", string(host.id), 0); err != nil {
|
||
t.Fatalf("划给主 agent: %v", err)
|
||
}
|
||
|
||
_, err := host.SpawnRTChild(RTChildOptions{ID: "steal", Input: "voice-in3", Output: "voice3"})
|
||
if err == nil {
|
||
t.Fatal("主 agent 自己占着的通道被划成 rt 通道 —— 主 agent 不持有 rt 能力")
|
||
}
|
||
}
|
||
|
||
// TestStopRTChild_ChannelsBecomeDangling 钉住用户裁定:
|
||
// rtchild 销毁后通道**悬空**(Owner 仍指向已销毁的 id),**不归还为未分配**。
|
||
//
|
||
// 为什么必须悬空而不是归还:
|
||
// - 归还为 "" ⇒ routeInputByOwner 会把它兜底给主 agent,而主 agent
|
||
// 没有 rt 能力 ⇒ 一条 rt 通道静默变成普通通道(不可查的坏状态)
|
||
// - 悬空 ⇒ 路由查不到该 owner,走既有"owner 无匹配 agent"兜底**并留日志**
|
||
// (可查的坏状态)
|
||
func TestStopRTChild_ChannelsBecomeDangling(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in4", "voice4")
|
||
|
||
id, err := host.SpawnRTChild(RTChildOptions{ID: "dangling", Input: "voice-in4", Output: "voice4"})
|
||
if err != nil {
|
||
t.Fatalf("创建: %v", err)
|
||
}
|
||
if err := host.StopRTChild(id); err != nil {
|
||
t.Fatalf("停止: %v", err)
|
||
}
|
||
|
||
reg := host.io.ChannelRegistry()
|
||
for _, n := range []string{"voice-in4", "voice4"} {
|
||
ch, ok := reg.Lookup(n)
|
||
if !ok {
|
||
continue // 注销也算一种处理;但插件声明的 rt 通道不该被注销
|
||
}
|
||
if ch.Owner == "" {
|
||
t.Fatalf("通道 %s 被归还为未分配 —— 主 agent 没有 rt 能力,"+
|
||
"归还会让它静默兜底到主 agent(应保持悬空)", n)
|
||
}
|
||
if ch.Owner != id {
|
||
t.Fatalf("通道 %s 的 Owner = %q,期望悬空保留 %q", n, ch.Owner, id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── rt_children 工具面(主 agent 创建 rtchild 的唯一入口) ──
|
||
|
||
// TestRTChildrenTool_ListEmpty 钉住空态提示可读。
|
||
func TestRTChildrenTool_ListEmpty(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
|
||
})
|
||
if !strings.Contains(got, "没有实时子 agent") {
|
||
t.Fatalf("空态提示不对:%q", got)
|
||
}
|
||
}
|
||
|
||
// TestRTChildrenTool_CreateRequiresBothChannels 钉住成对要求,
|
||
// 且失败提示必须**列出可用的 rt 通道**(否则模型只能瞎猜名字反复重试)。
|
||
func TestRTChildrenTool_CreateRequiresBothChannels(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in", "voice")
|
||
|
||
// 只给 input:必须拒,并列出可用通道。
|
||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children",
|
||
Arguments: map[string]interface{}{
|
||
"action": "create", "id": "c1", "inputch": "voice-in",
|
||
},
|
||
})
|
||
if !strings.Contains(got, "必须成对") {
|
||
t.Fatalf("只给 inputch 未被拒:%q", got)
|
||
}
|
||
if !strings.Contains(got, "voice-in") || !strings.Contains(got, "voice") {
|
||
t.Fatalf("失败提示没有列出可用 rt 通道(模型无法自我修正):%q", got)
|
||
}
|
||
}
|
||
|
||
// TestRTChildrenTool_FullLifecycle 是**端到端**:模型视角走一遍
|
||
// create → list → send → stop(这正是"主 agent 创建 rtchild 并开始工作")。
|
||
func TestRTChildrenTool_FullLifecycle(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
registerRTPair(t, host, "voice-in", "voice")
|
||
|
||
// ① create(成对)
|
||
out := host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children",
|
||
Arguments: map[string]interface{}{
|
||
"action": "create", "id": "call-1",
|
||
"inputch": "voice-in", "outputch": "voice",
|
||
},
|
||
})
|
||
if !strings.Contains(out, "已创建") {
|
||
t.Fatalf("create 失败:%q", out)
|
||
}
|
||
// 通道必须已成对划给它。
|
||
reg := host.io.ChannelRegistry()
|
||
for _, n := range []string{"voice-in", "voice"} {
|
||
if ch, _ := reg.Lookup(n); ch.Owner != "call-1" {
|
||
t.Fatalf("通道 %s 的 Owner = %q,期望 call-1", n, ch.Owner)
|
||
}
|
||
}
|
||
|
||
// ② list 能看到它
|
||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children", Arguments: map[string]interface{}{"action": "list"},
|
||
})
|
||
if !strings.Contains(out, "call-1") {
|
||
t.Fatalf("list 里看不到刚创建的实例:%q", out)
|
||
}
|
||
if !strings.Contains(out, "打断") {
|
||
t.Fatalf("list 提示没有说明 rt 的打断语义:%q", out)
|
||
}
|
||
|
||
// ③ send:打断式注入,真的跑起来
|
||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children",
|
||
Arguments: map[string]interface{}{
|
||
"action": "send", "id": "call-1", "text": "用户说:你好",
|
||
},
|
||
})
|
||
if !strings.Contains(out, "打断") {
|
||
t.Fatalf("send 没说明打断语义:%q", out)
|
||
}
|
||
// ★ 必须等**历史入账**,不能等 Rounds。
|
||
//
|
||
// Rounds 在 ChatStream **被调用时**就自增(请求刚构建完),而 assistant
|
||
// 那条消息是**流结束后**才追加进 history 的。等 Rounds 的话,随后的
|
||
// stop 可能读到 n==0,于是走"本次通话没有上下文需要交接"分支 ——
|
||
// 判据就会怪 stop 没提示 rt_transcript,而其实是它自己等早了。
|
||
// (同 rtchild_r7b_test 的坑,实测约 1/12 概率。)
|
||
waitFor(t, "rtchild 跑了一轮并写入历史", func() bool {
|
||
tr, ok := host.RTTranscript("call-1")
|
||
return ok && len(tr) > 0
|
||
})
|
||
|
||
// ④ stop:回收并交接
|
||
out = host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children", Arguments: map[string]interface{}{"action": "stop", "id": "call-1"},
|
||
})
|
||
if !strings.Contains(out, "已回收") {
|
||
t.Fatalf("stop 失败:%q", out)
|
||
}
|
||
if !strings.Contains(out, "rt_transcript") {
|
||
t.Fatalf("stop 没有告诉模型用 rt_transcript 取回原文:%q", out)
|
||
}
|
||
if got := host.RTChildren(); len(got) != 0 {
|
||
t.Fatalf("回收后登记表仍有 %v", got)
|
||
}
|
||
}
|
||
|
||
// TestRTChildrenTool_UnknownAction 钉住未知 action 响亮报错并列出可用值。
|
||
func TestRTChildrenTool_UnknownAction(t *testing.T) {
|
||
p := &rtCountingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
got := host.executeRTChildren(agentAPI.ToolCall{
|
||
Name: "rt_children", Arguments: map[string]interface{}{"action": "destroy"},
|
||
})
|
||
if !strings.Contains(got, "未知 action") || !strings.Contains(got, "list") {
|
||
t.Fatalf("未知 action 提示不可操作:%q", got)
|
||
}
|
||
}
|
||
|
||
// TestRTChildrenTool_NotVisibleToChild 钉住工具是**父专属**的:
|
||
// rtchild 自己不该看到 rt_children(否则它能无限自我复制)。
|
||
func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
|
||
p := &rtBlockingProvider{}
|
||
host, _ := newRTHost(t, p)
|
||
host.io.ChannelRegistry().Register(agentIO.InputChannel{
|
||
Name: "voice-in", Plugin: "test", Capabilities: agentIO.CapRT | agentIO.CapText,
|
||
})
|
||
id := rtSpawn(t, host, "no-recurse")
|
||
rc := host.rtChildren[id]
|
||
if rc == nil {
|
||
t.Fatal("实例不在登记表")
|
||
}
|
||
if _, ok := rtToolNames(rc.agent.buildToolDefs())["rt_children"]; ok {
|
||
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)
|
||
}
|
||
}
|