mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
## N3 生命周期(resident.go)
- `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核;
划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/<id>` 入站 inputch;
建独立 `IOManager`(共享通道登记表);启动子。
- `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。
- `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。
- `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。
## N4 跨 agent 投递
- 子→父:`notify_parent` → 投进父的 `child/<id>` inputch,优先级 **L3**。
- 父→子:`SendToResident` → 投进子的 inputch,优先级 **L4**;
`isKernelLevelSource` 泛化为"该 agent 的上级"(`AgentConfig.KernelSource`)⇒
只有父能在子的阶梯上产生 L4(子内部一律 ≤L3)。
- 子的 contextfull → 父侧 `raiseKernelInterrupt`(内核级事件,带子标识,父侧 L4)。
## N5 inputch 处理表
- 子持有;`inputch_note` 主动写**优先**,轮末 `autoRecordInputch` 兜底 ⇒ 每轮必有记录。
- 压缩时清表(表记的是被压掉那段窗口的逐轮处理)。
## N6 contextfull(判据修正)
❗初版判据是"拼好的 `f.Msgs` 估算 > 90% 窗口",**结构上永不成立**:
`buildMessages` 拿到的 `budget.ContextTokens` 由 `targetUsage = 0.8 × 窗口` 推出,
时间线**在拼进消息之前就被预算裁过**,`f.Msgs` 封顶在 ~80% 窗口。
(初版测试用一个比系统提示词还小的窗口才勉强越线 —— 那等于什么都没测。)
现判据 = **未裁剪的积累上下文**(`a.context.Recent(0)`)超过窗口 90%:
它超过就说明下一轮必须丢事件,这正是"上下文满"。
三处置:`CompressResident`(保留语义:`TrimKeepRecent` 保留最近 N 条 + 清表)/
`ReclaimResident`(取消语义:`ExportTriples` 读 temp → 父选出要保留的 → `Commit` 进 main → 取消该子)/
`DestroyResident`(立刻销毁并移除)。
## 工具面
`resident_agents`(父,单工具多动作:list/create/send/inspect/compress/reclaim/destroy)、
`notify_parent` + `inputch_note`(子)。声明条件式:父才有前者,子才有后两者。
## 验收
`resident_test.go` 五项:生命周期与不留孤儿、双向投递(含"子的主动消息不得以 L4 出现")、
处理表(自动写 vs 主动写优先)、contextfull + 三处置、
**压力 8 子 × 12 轮(父→子 L4 与普通输入各半)+ 双向汇报 + 父退出清理**。
全仓 go test ./... 37 包 ok / 0 FAIL;`-race`(agent/memory/plugin)干净;
压力 `-race -count=3` 通过。
368 lines
12 KiB
Go
368 lines
12 KiB
Go
package core
|
||
|
||
// 驻留子:生命周期(N3)、跨 agent 投递(N4)、inputch 处理表(N5)、contextfull(N6)。
|
||
//
|
||
// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||
)
|
||
|
||
// newRootWith 造一个带完整图记忆的父 agent,并指定它(以及它的驻留子)用的 provider。
|
||
func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
a := New(AgentConfig{
|
||
ID: "parent",
|
||
Provider: provider,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
Memory: main,
|
||
DataDir: dir,
|
||
})
|
||
a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理
|
||
t.Cleanup(func() { a.Stop(); main.Close() })
|
||
return a, main, dir
|
||
}
|
||
|
||
// newRootForResidents 是默认构造(正常窗口)。
|
||
func newRootForResidents(t *testing.T) (*Agent, *memory.GraphDB, string) {
|
||
t.Helper()
|
||
return newRootWith(t, &countingProvider{})
|
||
}
|
||
|
||
func spawnTestResident(t *testing.T, parent *Agent, dir, id string, inputChs ...string) ResidentInfo {
|
||
t.Helper()
|
||
info, err := parent.SpawnResident(ResidentOptions{
|
||
ID: id,
|
||
TaskPrompt: "盯住这个通道,有情况就汇报",
|
||
InputChs: inputChs,
|
||
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("创建驻留子失败: %v", err)
|
||
}
|
||
return info
|
||
}
|
||
|
||
// waitFor 轮询直到条件成立(测试里不使用 sleep 猜时序)。
|
||
func waitFor(t *testing.T, what string, fn func() bool) {
|
||
t.Helper()
|
||
waitForWithin(t, what, 5*time.Second, fn)
|
||
}
|
||
|
||
func waitForWithin(t *testing.T, what string, within time.Duration, fn func() bool) {
|
||
t.Helper()
|
||
deadline := time.Now().Add(within)
|
||
for time.Now().Before(deadline) {
|
||
if fn() {
|
||
return
|
||
}
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
t.Fatalf("等待超时:%s", what)
|
||
}
|
||
|
||
// ---- N3:生命周期 ----
|
||
|
||
func TestResident_LifecycleAndNoOrphans(t *testing.T) {
|
||
parent, _, dir := newRootForResidents(t)
|
||
|
||
// 父先注册两个 inputch(模拟插件注册),再把其中一个划给子。
|
||
reg := parent.io.ChannelRegistry()
|
||
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "qq"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
info := spawnTestResident(t, parent, dir, "child-1", "sub/in")
|
||
if info.State != "running" {
|
||
t.Fatalf("新建的驻留子状态=%q", info.State)
|
||
}
|
||
if list := parent.Residents(); len(list) != 1 || list[0].ID != "child-1" {
|
||
t.Fatalf("登记表=%+v", list)
|
||
}
|
||
|
||
// 划入生效:inputch 的归属变成子。
|
||
ch, ok := reg.Lookup("sub/in")
|
||
if !ok || ch.Owner != "child-1" {
|
||
t.Fatalf("划入未生效:%+v", ch)
|
||
}
|
||
// 未划入的仍是未分配。
|
||
if qq, _ := reg.Lookup("qq"); qq.Owner != "" {
|
||
t.Fatalf("未划入的 inputch 不该有归属:%+v", qq)
|
||
}
|
||
// 父的入站 inputch(接收该子的消息)登记在父名下。
|
||
if inbound, ok := reg.Lookup("child/child-1"); !ok || inbound.Owner != "parent" {
|
||
t.Fatalf("父的入站 inputch 未登记:%+v ok=%v", inbound, ok)
|
||
}
|
||
|
||
// 子的轻量内核:有共同面、没有整理面。
|
||
c1 := parent.residents["child-1"]
|
||
if c1.agent.memory != nil {
|
||
t.Fatal("驻留子不该有记忆整理面")
|
||
}
|
||
if c1.agent.graphMem() == nil {
|
||
t.Fatal("驻留子必须有图记忆共同面")
|
||
}
|
||
// 子的 L4 只属于父。
|
||
if !c1.agent.isKernelLevelSource("parent") {
|
||
t.Fatal("父必须是子的内核级来源(子的 L4 归父独占)")
|
||
}
|
||
if c1.agent.isKernelLevelSource("别人") {
|
||
t.Fatal("非父来源不得成为子的内核级来源")
|
||
}
|
||
|
||
// 销毁:出登记表、归还 inputch、temp 目录丢弃。
|
||
if err := parent.DestroyResident("child-1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(parent.Residents()) != 0 {
|
||
t.Fatal("销毁后登记表应为空")
|
||
}
|
||
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
|
||
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
|
||
}
|
||
if err := parent.DestroyResident("child-1"); err == nil {
|
||
t.Fatal("重复销毁应报错")
|
||
}
|
||
|
||
// **父退出 ⇒ 全部子销毁、不留孤儿**。
|
||
spawnTestResident(t, parent, dir, "c-a", "sub/in")
|
||
spawnTestResident(t, parent, dir, "c-b")
|
||
if n := parent.StopResidents(); n != 2 {
|
||
t.Fatalf("StopResidents 销毁 %d 个,期望 2", n)
|
||
}
|
||
if len(parent.Residents()) != 0 {
|
||
t.Fatal("父退出后登记表必须为空")
|
||
}
|
||
for _, id := range []string{"c-a", "c-b"} {
|
||
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
|
||
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// osStat 只是为了让"目录是否还存在"的断言可读(存在返回 nil 错误)。
|
||
func osStat(path string) (interface{}, error) {
|
||
_, err := os.Stat(path)
|
||
return nil, err
|
||
}
|
||
|
||
// ---- N4:跨 agent 投递 ----
|
||
|
||
func TestResident_DeliveryBothDirections(t *testing.T) {
|
||
parent, _, dir := newRootForResidents(t)
|
||
spawnTestResident(t, parent, dir, "child-1")
|
||
child := parent.residents["child-1"].agent
|
||
|
||
// 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。
|
||
if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
waitFor(t, "子收到 L4 中断", func() bool {
|
||
return child.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||
})
|
||
|
||
// 子 → 父:主动消息 ⇒ 父在 **L3** 上收到(不是 L4)。
|
||
// 注意 L3 的枚举值是 LevelInteractive(LevelMessage 是 L2)。
|
||
child.notifyParent("我这边发现了点东西")
|
||
waitFor(t, "父收到子的 L3 消息", func() bool {
|
||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= 1
|
||
})
|
||
if got := parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical]; got != 0 {
|
||
t.Fatalf("子的主动消息不得以 L4 出现在父的阶梯上(实际 %d 次)", got)
|
||
}
|
||
}
|
||
|
||
// ---- N5:inputch 处理表 ----
|
||
|
||
func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||
parent, _, dir := newRootForResidents(t)
|
||
spawnTestResident(t, parent, dir, "child-1")
|
||
child := parent.residents["child-1"].agent
|
||
|
||
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
|
||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"})
|
||
waitFor(t, "自动写处理表", func() bool {
|
||
table, err := parent.ResidentTable("child-1")
|
||
return err == nil && len(table) == 1 && !table[0].Proactive
|
||
})
|
||
table, err := parent.ResidentTable("child-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if table[0].InputCh != "sub/in" {
|
||
t.Fatalf("处理表应记本轮 inputch,实际 %q", table[0].InputCh)
|
||
}
|
||
|
||
// ② 子主动写 ⇒ 本轮不再自动写。
|
||
child.recordInputchNote("本轮我自己记:已完成第一阶段")
|
||
child.autoRecordInputch(&TaskFrame{Input: "第二轮"})
|
||
table, err = parent.ResidentTable("child-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(table) != 2 || !table[1].Proactive || !strings.Contains(table[1].Text, "第一阶段") {
|
||
t.Fatalf("主动写优先的语义不成立:%+v", table)
|
||
}
|
||
}
|
||
|
||
// ---- N6:contextfull + 三种处置 ----
|
||
|
||
func TestResident_ContextFullAndDispositions(t *testing.T) {
|
||
parent, main, dir := newRootForResidents(t)
|
||
|
||
// ① contextfull:把子的**积累上下文**(a.context,不是拼好的消息)灌到超过窗口 90%。
|
||
// 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。
|
||
spawnTestResident(t, parent, dir, "child-1")
|
||
rc := parent.residents["child-1"]
|
||
child := rc.agent
|
||
child.context.Append(ContextEvent{
|
||
Timestamp: time.Now(), Source: "sub/in",
|
||
Input: strings.Repeat("上下文填充", 8000), // 40000 字 ≈ 80000 token ≫ 8192×0.9
|
||
})
|
||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"})
|
||
|
||
// 父在 **L4** 上收到 contextfull(带子标识)—— 只推信号。
|
||
waitFor(t, "父收到 contextfull 的 L4 中断", func() bool {
|
||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
|
||
})
|
||
waitFor(t, "父的登记表显示子 contextfull", func() bool {
|
||
for _, r := range parent.Residents() {
|
||
if r.ID == "child-1" && r.ContextFull {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
|
||
// ② 压缩(保留语义):上下文变短 + 处理表清空 + 子继续存在。
|
||
child.recordInputchNote("压缩前的记录")
|
||
if _, err := parent.CompressResident("child-1"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(parent.Residents()) != 1 {
|
||
t.Fatal("压缩后子必须继续存在(压缩是保留语义)")
|
||
}
|
||
if table, _ := parent.ResidentTable("child-1"); len(table) != 0 {
|
||
t.Fatalf("压缩必须清理 inputch 处理表,实际 %d 条", len(table))
|
||
}
|
||
|
||
// ③ 回收(取消语义):父选中的 temp 记录合入 main,然后取消该子。
|
||
if _, _, err := child.graphMem().Commit([]memory.Triple{
|
||
{Subject: "子的发现", Relation: "指向", Object: "结论"},
|
||
}, "sess", 1); err != nil {
|
||
t.Fatalf("子写自己的 temp 应成功: %v", err)
|
||
}
|
||
if _, err := parent.ReclaimResident("child-1", reclaimKeepAll); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(parent.Residents()) != 0 {
|
||
t.Fatal("回收是取消语义:子不该继续存在")
|
||
}
|
||
res, err := main.Recall([]string{"子的发现"}, nil, 1, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
found := false
|
||
for _, e := range res.Entities {
|
||
if e.Name == "子的发现" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("回收应把选中的 temp 记录合入主记忆")
|
||
}
|
||
|
||
// ④ 销毁:随时可做、立刻移除。
|
||
spawnTestResident(t, parent, dir, "child-2")
|
||
if err := parent.DestroyResident("child-2"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(parent.Residents()) != 0 {
|
||
t.Fatal("销毁后不该还在登记表里")
|
||
}
|
||
}
|
||
|
||
// ---- N7:端到端 + 压力 ----
|
||
|
||
func TestResident_E2EAndStress(t *testing.T) {
|
||
parent, _, dir := newRootForResidents(t)
|
||
reg := parent.io.ChannelRegistry()
|
||
|
||
const nResidents = 8
|
||
const roundsEach = 12
|
||
ids := make([]string, 0, nResidents)
|
||
for i := 0; i < nResidents; i++ {
|
||
id := "sub-" + string(rune('a'+i))
|
||
ch := "sub/" + id + "/in"
|
||
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "sub"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
spawnTestResident(t, parent, dir, id, ch)
|
||
ids = append(ids, id)
|
||
}
|
||
|
||
// 压力:每个子灌 roundsEach 轮输入;其中一半走父→子的 L4 消息,一半走普通输入。
|
||
for _, id := range ids {
|
||
for r := 0; r < roundsEach; r++ {
|
||
// 内容必须唯一:内核会去重相同输入(去重路径不产生处理表记录)。
|
||
msg := fmt.Sprintf("%s 第 %d 轮", id, r)
|
||
if r%2 == 0 {
|
||
if err := parent.SendToResident(id, msg); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
} else {
|
||
parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text",
|
||
map[string]interface{}{"content": msg})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 全部子都必须活着,且每一轮都留下处理表记录(自动或主动)。
|
||
waitForWithin(t, "全部子完成各自轮次", 30*time.Second, func() bool {
|
||
for _, id := range ids {
|
||
table, err := parent.ResidentTable(id)
|
||
if err != nil || len(table) < roundsEach/2 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
})
|
||
|
||
// 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3)。
|
||
for _, id := range ids {
|
||
parent.residents[id].agent.notifyParent("压力汇报 " + id)
|
||
}
|
||
waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool {
|
||
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents)
|
||
})
|
||
|
||
// 父退出 ⇒ 全部子销毁、登记表清空(不留孤儿)。
|
||
if n := parent.StopResidents(); n != nResidents {
|
||
t.Fatalf("父退出应销毁 %d 个子,实际 %d", nResidents, n)
|
||
}
|
||
if len(parent.Residents()) != 0 {
|
||
t.Fatal("父退出后登记表必须为空")
|
||
}
|
||
t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报",
|
||
nResidents, roundsEach)
|
||
}
|