From 6e6035141a15d1ba179b941d3658ed2e7f86fbab Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 13 Sep 2026 11:38:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(resident/plugin):=20=E4=BA=8C=E8=BF=9B?= =?UTF-8?q?=E5=88=B6=E7=BA=A7=E5=8E=8B=E6=B5=8B=E6=9A=B4=E9=9C=B2=E7=9A=84?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E7=9C=9F=E9=97=AE=E9=A2=98=EF=BC=88DataDir?= =?UTF-8?q?=20=E6=BC=8F=E6=8E=A5=E7=BA=BF=20/=20inputch=20=E6=9C=AA?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=20/=20create=20=E4=B8=8D=E5=BC=80=E5=B7=A5?= =?UTF-8?q?=EF=BC=89+=20=E9=80=9A=E9=81=93=E5=8F=8C=E5=90=91=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E8=B4=AF=E7=A9=BF=E5=85=A8=E9=83=A8=E5=86=85=E5=BB=BA?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时, 下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载, 所以全绿也照样漏。 ## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来 现象:模型调用 `resident_agents` 成功,但结果是 `创建驻留子需要 data_dir 或显式 temp_path`。 根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`, 而驻留子的 temp 图库需要 `/residents//graph.db` 这个锚点。 (单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。) 修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** —— data_dir 为空时从主图库路径反推(`/memory/graph.db` ⇒ ``), 失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。 ## ② 插件通道没登记为 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/` 懒登记,幂等)。 - `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 目录被清除、无残留进程。 --- cmd/homed/main.go | 9 ++++-- internal/agent/core/resident.go | 14 ++++++++++ internal/agent/core/resident_test.go | 31 +++++++++++++++++---- internal/agent/core/resident_tools.go | 17 +++++++++-- internal/plugin/registry.go | 11 ++++++++ internal/plugins/agentcli/plugin.go | 2 ++ internal/plugins/clawhubadapter/registry.go | 3 ++ internal/plugins/cli/plugin.go | 3 ++ internal/plugins/remotedevice/plugin.go | 6 +++- internal/plugins/timer/plugin.go | 2 ++ internal/plugins/webui/plugin.go | 4 +++ 11 files changed, 89 insertions(+), 13 deletions(-) diff --git a/cmd/homed/main.go b/cmd/homed/main.go index d3eea1e..3ea5957 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -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 图库锚点(/residents//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), diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index a06b85b..597e2a5 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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 diff --git a/internal/agent/core/resident_test.go b/internal/agent/core/resident_test.go index a4219b5..a451729 100644 --- a/internal/agent/core/resident_test.go +++ b/internal/agent/core/resident_test.go @@ -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") { diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go index f746cc5..8836947 100644 --- a/internal/agent/core/resident_tools.go +++ b/internal/agent/core/resident_tools.go @@ -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 == "" { + // 兜底:从**主图库路径**推导(/memory/graph.db ⇒ )。 + // 为什么不静默失败:这条路径只在"配置漏接线"时走到, + // 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。 + 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, diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 1559a2f..9682f61 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -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 } diff --git a/internal/plugins/agentcli/plugin.go b/internal/plugins/agentcli/plugin.go index abb1a3f..d9f611a 100644 --- a/internal/plugins/agentcli/plugin.go +++ b/internal/plugins/agentcli/plugin.go @@ -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 等。" + diff --git a/internal/plugins/clawhubadapter/registry.go b/internal/plugins/clawhubadapter/registry.go index ec87505..e6b4311 100644 --- a/internal/plugins/clawhubadapter/registry.go +++ b/internal/plugins/clawhubadapter/registry.go @@ -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) }) diff --git a/internal/plugins/cli/plugin.go b/internal/plugins/cli/plugin.go index 5e1a5f9..199c23e 100644 --- a/internal/plugins/cli/plugin.go +++ b/internal/plugins/cli/plugin.go @@ -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 != "" { diff --git a/internal/plugins/remotedevice/plugin.go b/internal/plugins/remotedevice/plugin.go index 7193c5f..865f289 100644 --- a/internal/plugins/remotedevice/plugin.go +++ b/internal/plugins/remotedevice/plugin.go @@ -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/ 是动态的:设备首次上报时**懒登记** 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}) } }) diff --git a/internal/plugins/timer/plugin.go b/internal/plugins/timer/plugin.go index 4b48967..8fbc78e 100644 --- a/internal/plugins/timer/plugin.go +++ b/internal/plugins/timer/plugin.go @@ -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。", diff --git a/internal/plugins/webui/plugin.go b/internal/plugins/webui/plugin.go index d4bfb94..2ef7642 100644 --- a/internal/plugins/webui/plugin.go +++ b/internal/plugins/webui/plugin.go @@ -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)