mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-20 17:08:09 +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:
@ -528,9 +528,12 @@ func main() {
|
||||
Personality: personality,
|
||||
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
|
||||
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
|
||||
PluginReg: pluginReg,
|
||||
PluginDir: cfg.Plugin.Dir,
|
||||
// DataDir:驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db)。
|
||||
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
|
||||
DataDir: cfg.Daemon.DataDir,
|
||||
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
|
||||
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
|
||||
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -303,6 +303,17 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// **兼容网**:插件应当显式 RegisterInputChannel 声明自己的入站通道;
|
||||
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
|
||||
// 例:cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
|
||||
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
|
||||
// (实测报 `划入 inputch cli: inputch 未注册`)。兜底要**留痕**,
|
||||
// 否则插件作者永远不知道该补一行 RegisterInputChannel。
|
||||
if _, ok := r.iom.LookupInputChannel(chName); !ok {
|
||||
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
|
||||
log.Printf("[plugin] %s 只声明了输出通道 %q,已按双向通道兜底登记 inputch;"+
|
||||
"若要明确意图请显式 RegisterInputChannel", name, chName)
|
||||
}
|
||||
r.noteChannel(name, chName, true)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -221,6 +221,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
p.notifyInterval = DefaultNotifyInterval
|
||||
}
|
||||
|
||||
// agentcli 通道:终端生命周期/输出事件经它注入 agent(见本文件 InjectText* 调用)。
|
||||
_ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
|
||||
s.RegisterTool("terminal_create", sdk.ToolDef{
|
||||
Name: "terminal_create",
|
||||
Description: "创建一个新的交互式终端会话。返回终端 ID,后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
|
||||
|
||||
@ -192,6 +192,9 @@ func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp *
|
||||
caps = 1
|
||||
}
|
||||
desc := fmt.Sprintf("OC channel %s (from %s)", chName, pn)
|
||||
// 通道名来自外部 OC 配置(动态):声明处**同时**登记 inputch ——
|
||||
// 该通道既收(InjectInputSync 见 plugin.go)又发(output_send__<通道>)。
|
||||
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{})
|
||||
s.RegisterOutputChannel(chName, caps, desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||
return sp.CallTool(chName, args)
|
||||
})
|
||||
|
||||
@ -59,6 +59,9 @@ func (p *Plugin) Name() string { return p.name }
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
|
||||
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
|
||||
_ = s.RegisterInputChannel("cli", sdk.ChannelDef{})
|
||||
s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
if payload != "" {
|
||||
|
||||
@ -152,8 +152,12 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
|
||||
log.Printf("[remotedevice] event from %s: %s", deviceID, evtType)
|
||||
if p.sdk != nil {
|
||||
// 设备通道 device/<id> 是动态的:设备首次上报时**懒登记** inputch
|
||||
// (Register 幂等),父 agent 才能把它划给驻留子。
|
||||
devCh := "device/" + deviceID
|
||||
_ = p.sdk.RegisterInputChannel(devCh, sdk.ChannelDef{})
|
||||
// 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道
|
||||
p.sdk.InjectInput("device/"+deviceID, "device/"+deviceID, "text", map[string]interface{}{"content": text})
|
||||
p.sdk.InjectInput(devCh, devCh, "text", map[string]interface{}{"content": text})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -63,6 +63,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
}
|
||||
|
||||
// timer 通道:定时器到点经它注入 agent(见本文件 InjectInterruptTextOpts 调用)。
|
||||
_ = s.RegisterInputChannel("timer", sdk.ChannelDef{})
|
||||
s.RegisterTool("timer_set", sdk.ToolDef{
|
||||
Name: "timer_set",
|
||||
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
|
||||
|
||||
@ -148,6 +148,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
// 能力位 7 = CapText|CapFile|CapImage;旧值 1 仅文本,agent 无法向 webui 发文件/图片
|
||||
// 入站通道:webui(控制台对话)与 http(外部 HTTP 注入),都由本插件注入输入。
|
||||
// http 通道还声明 NoMemory:外部抓来的内容不进记忆计算(见 handler 里的 NoMemory 注入)。
|
||||
_ = s.RegisterInputChannel("webui", sdk.ChannelDef{})
|
||||
_ = s.RegisterInputChannel("http", sdk.ChannelDef{NoMemory: true})
|
||||
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
|
||||
payload, _ := args["payload"].(string)
|
||||
rawType, _ := args["type"].(string)
|
||||
|
||||
Reference in New Issue
Block a user