mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。
## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来
现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)
修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。
## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败
现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)
修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
`clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
实测:改完内建插件后,启动日志里兜底告警 **0 次**。
## ③ create 之后子不开工 ⇒ rounds 恒为 0
现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。
## 测试
- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。
## 真实二进制压力测试结果(修复后)
私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
`interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
This commit is contained in:
@ -174,9 +174,23 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
a.residentMu.Unlock()
|
||||
|
||||
child.Start()
|
||||
|
||||
// ⑥ create 即开工:把任务提示词作为**第一条排队输入**投给子。
|
||||
//
|
||||
// 为什么必须在这里投:TaskPrompt 只进子的系统提示词("你是谁、要做什么"),
|
||||
// 而**不会**让子跑起来 —— 实测现象是子启动后 rounds=0、永远待机
|
||||
// (日志 `[agent] r1 started, waiting for IO interrupts` 之后无事发生)。
|
||||
// 走排队输入(非中断):创建是"安排工作",不是"打断它正在做的事"。
|
||||
if strings.TrimSpace(opts.TaskPrompt) != "" {
|
||||
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
|
||||
map[string]interface{}{"content": opts.TaskPrompt})
|
||||
}
|
||||
return rc.info(), nil
|
||||
}
|
||||
|
||||
// residentParentSource 是"父给子投递"的输入来源名(子的视角里能看出是谁发的)。
|
||||
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
|
||||
|
||||
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
|
||||
func (a *Agent) residentInboundChannel(childID string) string {
|
||||
ch := "child/" + childID
|
||||
|
||||
@ -201,18 +201,30 @@ func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||||
spawnTestResident(t, parent, dir, "child-1")
|
||||
child := parent.residents["child-1"].agent
|
||||
|
||||
// 说明:create 会把任务提示词作为**第一条输入**投给子("create 即开工"),
|
||||
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
|
||||
waitFor(t, "任务提示词那一轮写入", func() bool {
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
return err == nil && len(table) >= 1
|
||||
})
|
||||
base, err := parent.ResidentTable("child-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := len(base)
|
||||
|
||||
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
|
||||
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
|
||||
return err == nil && len(table) == n+1 && !table[n].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)
|
||||
if table[n].InputCh != "sub/in" {
|
||||
t.Fatalf("处理表应记本轮 inputch,实际 %q", table[n].InputCh)
|
||||
}
|
||||
|
||||
// ② 子主动写 ⇒ 本轮不再自动写。
|
||||
@ -222,7 +234,7 @@ func TestResident_InputchTableAutoAndProactive(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(table) != 2 || !table[1].Proactive || !strings.Contains(table[1].Text, "第一阶段") {
|
||||
if len(table) != n+2 || !table[n+1].Proactive || !strings.Contains(table[n+1].Text, "第一阶段") {
|
||||
t.Fatalf("主动写优先的语义不成立:%+v", table)
|
||||
}
|
||||
}
|
||||
@ -428,6 +440,12 @@ func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||
t.Fatal("驻留子必须被识别为轻量内核")
|
||||
}
|
||||
|
||||
// 先等"create 即开工"那一轮(任务提示词)跑完,否则下面抓到的是它的请求,
|
||||
// 而不是我们注入了大段上下文之后的那一轮。
|
||||
waitFor(t, "任务提示词那一轮结束", func() bool {
|
||||
table, err := parent.ResidentTable("child-1")
|
||||
return err == nil && len(table) >= 1
|
||||
})
|
||||
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
|
||||
b := ComputeTokenBudget(child.provider, child.systemPrompt)
|
||||
if b.MaxContext <= b.ContextTokens {
|
||||
@ -445,9 +463,10 @@ func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
|
||||
|
||||
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
|
||||
|
||||
waitFor(t, "子发出 LLM 请求", func() bool {
|
||||
// 等**新的一轮**请求(首轮可能已经发过,必须严格等到注入之后那次)。
|
||||
waitFor(t, "子发出新一轮 LLM 请求", func() bool {
|
||||
n, _ := provider.chatText()
|
||||
return n >= 1
|
||||
return n >= 2
|
||||
})
|
||||
_, got := provider.chatText()
|
||||
if !strings.Contains(got, "最早的事件标记EARLY") {
|
||||
|
||||
@ -7,6 +7,7 @@ package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@ -57,10 +58,20 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||
id := strArg(tc, "id")
|
||||
tempPath := strArg(tc, "temp_path")
|
||||
if tempPath == "" {
|
||||
if a.dataDir == "" {
|
||||
return "创建驻留子需要 data_dir 或显式 temp_path"
|
||||
anchor := a.dataDir
|
||||
if anchor == "" {
|
||||
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
|
||||
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
|
||||
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
|
||||
if a.memory != nil && a.memory.Path() != "" {
|
||||
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
|
||||
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
|
||||
}
|
||||
}
|
||||
tempPath = filepath.Join(a.dataDir, "residents", id, "graph.db")
|
||||
if anchor == "" {
|
||||
return "创建驻留子需要 data_dir 或显式 temp_path(内核未接线 DataDir)"
|
||||
}
|
||||
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
|
||||
}
|
||||
info, err := a.SpawnResident(ResidentOptions{
|
||||
ID: id,
|
||||
|
||||
Reference in New Issue
Block a user