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)