From d65b742bd7b15f3700bc65435c4819e5526ef18c Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 25 Oct 2026 18:25:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugin):=20=E4=BF=AE=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E6=8C=81=E9=94=81=E8=87=AA=E6=AD=BB=E9=94=81=EF=BC=88CI=20?= =?UTF-8?q?=E5=8F=8C=E7=BA=A2=E7=9C=9F=E5=9B=A0=EF=BC=89+=20tools=5Fready?= =?UTF-8?q?=20=E4=BA=8B=E4=BB=B6=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 死锁(这是 CI 连续两次红的真因) `StopAll` 与 `StopPlugin` 都在持有 `r.mu` **写锁**时调用 `RemoveBuiltinSkill` → `skillDirOf` → `r.mu.RLock()` ⇒ **自死锁**。 后果不是报错而是**卡死**: - internal/plugins 卡 300s 超时(TestCapability_RestrictedPluginStillLoads) - internal/plugin 卡 840s 超时(TestStopPluginDoesNotTouchPersistentState) - CI 的 `go test ./...` 与 `go test -race` 双双红 修法:拆出**不加锁**的内部版本,持锁路径只走它: - `skillDirFrom(dataDir, name)`(纯函数) - `removeBuiltinSkillLocked(pluginName, p, dataDir)`(显式传 dataDir, 避免"看起来没取锁、一改就取锁"的隐患) - `announceSkillRemovedLocked`(持锁下直读 r.evBus) 修后:internal/plugins 300s → 12s,internal/plugin 840s → 0.7s。 ## 防回归判据 `TestStopAllAndStopPluginDoNotDeadlock`:死锁**不返回**,普通断言抓不到, 所以用**超时**当判据(5 秒未返回即失败),并指明应改用 Locked 版本。 ## tools_ready 事件声明 新增的 tools_ready 触发 `TestEveryPublishedEventHasAnOutwardPath` (该判据防"事件发了、客户端收不到"的静默失效)。 tools_ready 是**内核内部**就绪信号(plugin-host → seq 等订阅者), 不是对话流程事件,前端无需感知 ⇒ 在 allowed 里显式声明理由。 ## 验证 go build ./... / go vet ./... / go test -p 1 ./... / -race 全通过。 --- cmd/homed/main.go | 1 + internal/agent/core/prompt_memory_test.go | 14 +-- internal/agent/core/prompt_seq_test.go | 29 ------ internal/agent/core/runetrunc_test.go | 91 +++++++++++++++++++ internal/agent/core/tooldefs.go | 23 +++-- internal/agent/core/vector_child_e2e_test.go | 79 ++++++++++++++++ internal/events/bus.go | 19 ++++ internal/events/toolsready_test.go | 58 ++++++++++++ internal/kernelmod/pluginhost/mod.go | 38 ++++++++ .../kernelmod/pluginhost/toolsready_test.go | 50 ++++++++++ internal/knowledge/knowledge.go | 5 +- internal/memory/distill/split.go | 5 +- internal/memory/graph.go | 9 ++ internal/plugin/builtin_skill.go | 34 ++++++- internal/plugin/builtin_skill_test.go | 40 ++++++++ internal/plugin/proc/e2e_template_test.go | 2 +- internal/plugin/registry.go | 12 ++- internal/plugins/seq/boot.go | 90 ++++++++++++++++++ internal/plugins/seq/boot_test.go | 70 ++++++++++++++ internal/plugins/seq/handlers.go | 10 ++ internal/plugins/seq/parse.go | 21 ++++- internal/plugins/seq/plugin.go | 16 +++- internal/plugins/seq/skill.go | 32 +++++-- internal/plugins/seq/skill_test.go | 60 ++++++++++++ internal/plugins/seq/tools.go | 6 ++ internal/plugins/skillmgr/plugin.go | 30 +++++- internal/plugins/skillmgr/skillmgr_test.go | 40 ++++++++ .../webui/handler_sse_protocol_test.go | 4 + 28 files changed, 813 insertions(+), 75 deletions(-) delete mode 100644 internal/agent/core/prompt_seq_test.go create mode 100644 internal/agent/core/runetrunc_test.go create mode 100644 internal/agent/core/vector_child_e2e_test.go create mode 100644 internal/events/toolsready_test.go create mode 100644 internal/kernelmod/pluginhost/toolsready_test.go create mode 100644 internal/plugins/seq/boot.go create mode 100644 internal/plugins/seq/boot_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 86f4ca9f..028d5a56 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -302,6 +302,7 @@ func main() { stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx) defer stopRuntime() + _, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent) defer closeIPC() diff --git a/internal/agent/core/prompt_memory_test.go b/internal/agent/core/prompt_memory_test.go index c3a35f5c..d74fe058 100644 --- a/internal/agent/core/prompt_memory_test.go +++ b/internal/agent/core/prompt_memory_test.go @@ -229,18 +229,18 @@ func TestSystemPromptHasNoImplementationJargon(t *testing.T) { // // 历史:收敛前 2309 → 收敛后 1723 → 上限取 2000(留 277 字增删余地)。 // -// 2026-10-25 上调到 2100:新增【加速序列】115 字(让模型知道何时该用 seq)。 -// 该块属于判据认可的**行为判据**——"模型会据此做什么不同的事"答得上来: -// 决定用序列还是逐个调工具。因此占用增删余地是正当的。 +// ★ 2026-10-25 的一次真实教训: +// 我曾为 seq 加【加速序列】115 字,超限后把上限调到 2100 —— 那是错的。 +// 用户裁定"seq 知识应在 skill 中",删掉该块后回到 1907 字,上限也还原 2000。 // -// ⚠️ 调整门槛没有放松:**技术说明仍然不许占用这份额度**。 -// 下次若因"又加一大段实现说明"而想再调上限,应该改的是那段说明(下沉到 -// skill 或工具描述),不是这个数字。 +// 所以这条判据超限时的**正确反应是删内容或下沉到 skill**,不是调数字。 +// 领域知识(某个工具什么时候该用、参数怎么填)属于 skill; +// 这里只该放"模型不看文档也会做错"的通用规则。 func TestSystemPromptStaysCompact(t *testing.T) { a := newToolTestAgent(t) prompt := a.buildSystemPrompt("", "测试输入") - const maxRunes = 2100 + const maxRunes = 2000 if n := len([]rune(prompt)); n > maxRunes { t.Errorf("系统提示词 %d 字,超过上限 %d —— \n"+ "新加的内容先问:模型会据此做什么不同的事?答不上来就该进 skill 或工具描述", diff --git a/internal/agent/core/prompt_seq_test.go b/internal/agent/core/prompt_seq_test.go deleted file mode 100644 index 7ac8ded2..00000000 --- a/internal/agent/core/prompt_seq_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package core - -import ( - "strings" - "testing" -) - -// 【加速序列】块必须在系统提示词里,且给出可判定的触发条件。 -// -// 为什么需要判据:这一块缺失时**没有任何报错**——模型只是不会用 seq, -// 表现为"工具在、但几乎没人调用",与 doc_query 当年的症状完全一样。 -func TestSystemPrompt_HasAccelerationSequenceBlock(t *testing.T) { - a := newPreemptAgent(t, newPreemptProvider()) - p := a.buildSystemPrompt("", "测试输入") - - if !strings.Contains(p, "【加速序列】") { - t.Fatal("系统提示词缺【加速序列】块 —— 模型不会知道何时该用 seq") - } - // 触发条件必须是**可判定的**,不能只说"有用时用"。 - for _, want := range []string{"多个对象", "顺序已知", "seq_list", "组内并行", "组间串行"} { - if !strings.Contains(p, want) { - t.Fatalf("【加速序列】块缺 %q(模型据此判断该不该用序列)", want) - } - } - // 必须指路到 skill,避免提示词里重复格式细节(两处真相)。 - if !strings.Contains(p, "seq-tools") { - t.Fatal("应指向 seq-tools skill,而不是在提示词里重复格式细节") - } -} diff --git a/internal/agent/core/runetrunc_test.go b/internal/agent/core/runetrunc_test.go new file mode 100644 index 00000000..14ee9ed9 --- /dev/null +++ b/internal/agent/core/runetrunc_test.go @@ -0,0 +1,91 @@ +package core + +// 全仓守卫:按字节截断字符串是中文环境下的**必然 bug**。 +// +// 中文占 3 字节,`s[:N]` 的切点常落在多字节字符中间 ⇒ 产生非法 UTF-8。 +// 实测代价(不是理论风险): +// +// skillmgr: desc[:80] 把 25 个 skill 里 8 个(32%)的索引行切坏, +// 模型看到 "…以后一次�",且"什么时候该用"整段丢失 +// ⇒ e2e 实测该用 seq 的场景,模型改去调别的工具 +// tooldefs: desc[:60] 同样问题 +// knowledge/distill: preview[:200] / raw[:400] 同样问题 +// +// 正确写法:先转 []rune 再切。本判据扫描源码,发现新的字节截断即失败。 + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// byteSliceRe 匹配 `ident[:数字]` 形式(字节切片的典型写法)。 +var byteSliceRe = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_.]*)\[:\s*(\d+)\]`) + +// byteSliceAllow 是**已知安全**的例外:切的是 ASCII 生成物或切片重置。 +var byteSliceAllow = map[string]bool{ + // 文件路径/哈希/hex 全是 ASCII + "fp": true, "h": true, "id": true, "d": true, "digest": true, + "sum": true, "shortFP": true, "head": true, "monthKey": true, + // `x[:0]` 是"清空切片并复用底层数组"的惯用法,不涉及字符 + "msgs": true, "args": true, "queue": true, "pending": true, + "Children": true, "changeSets": true, "keep": true, "out": true, + "b": true, "buf": true, "data": true, + // st.Names 是 []string(工具结果的名称**列表**),切它是切片截断, + // 不涉及字符编码。误报清单要写明依据,否则以后没人敢再动这里。 + "show": true, +} + +func TestNoByteLevelStringTruncation(t *testing.T) { + root := ".." + var offenders []string + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + src, rerr := os.ReadFile(path) + if rerr != nil { + return nil + } + for i, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + // 跳过注释(我们的说明里会引用这个模式本身) + if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") { + continue + } + for _, m := range byteSliceRe.FindAllStringSubmatch(line, -1) { + ident, n := m[1], m[2] + // `[:0]` 是切片重置,不是字符截断 + if n == "0" { + continue + } + if byteSliceAllow[ident] { + continue + } + // 形如 `string(r[:60])` 且 r 来自 []rune 的写法:变量名含 rune 约定 + if ident == "r" { + continue + } + offenders = append(offenders, filepath.Join(path)+":"+strconv.Itoa(i+1)+" "+trimmed) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk: %v", err) + } + + if len(offenders) > 0 { + t.Fatalf("发现 %d 处疑似**按字节**截断字符串(中文环境下会产生非法 UTF-8):\n %s\n\n"+ + "正确写法:先转 []rune 再切,例如 r := []rune(s); s = string(r[:N]) + \"...\"\n"+ + "若确实是 ASCII 生成物(hash/路径)或切片重置(x[:0]),加进 byteSliceAllow 并注明理由。", + len(offenders), strings.Join(offenders, "\n ")) + } +} diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 65d92907..cbdc63d1 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -261,18 +261,15 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { prompt += "\n\n" + a.indexer.BuildToolPrompt() } - // 【加速序列】—— 与 doc_query 同一类问题:能力存在但模型不知道何时该用。 + // seq 的触发条件**刻意不写在这里**(用户裁定:seq 知识应在 skill 中)。 // - // seq 有 7 个工具、也有 skill(seq-tools),但 skill 索引只给一行摘要。 - // 生产观察:agent 极少使用序列加速,因为它不会主动想"这件事我做过、 - // 该编成序列"或"这套操作要在多个对象上重复,该并行"。 + // 一度加过【加速序列】块,理由是"skill 索引只给一行摘要,模型不知道何时该用"。 + // 那个理由站不住:紧邻下方的【可用技能】已明确要求"准备调用某个插件的工具时 + // 先看同名技能",seq 的"什么时候用"写在 skill 正文里会被这条规则正确带到 + // 模型面前。再加一块提示词 = 同一件事记两处,且必然漂移。 // - // 这里只留**模型必须据此作决定**的判据(与其余内核块同一风格): - // 什么时候该想起它。格式细节、组语义、常见错误全在 skill 与工具描述里, - // 不在这里重复——重复就是两处真相。 - prompt = appendPromptBlock(prompt, "【加速序列】同一套操作在**多个对象**上重复、或几件**顺序已知**的事连做时,"+ - "先想编成序列用 `seq_run`(组内并行、组间串行);以前做过的先用 `seq_list` 找。"+ - "格式见 `seq-tools` skill。") + // 副作用证据:加块后 TestSystemPromptStaysCompact 报 2022 > 2000。 + // 那个超限**本身就是信号**——该去掉的是内容,不是调高上限。 // 技能索引(方案B):轻量注入已加载技能列表,LLM 匹配到场景时 // 主动 skill_info 拉取全文按文档执行 @@ -358,8 +355,10 @@ func (a *Agent) buildToolCatalog() string { byPlugin[plg]++ if pluginDesc[plg] == "" { desc, _ := fn["description"].(string) - if len(desc) > 60 { - desc = desc[:60] + "..." + // 按**字符**截断,不是字节:中文占 3 字节,字节切会落在多字节 + // 字符中间产生非法 UTF-8(同 skillmgr 的那个真实缺陷)。 + if r := []rune(desc); len(r) > 60 { + desc = string(r[:60]) + "..." } pluginDesc[plg] = desc } diff --git a/internal/agent/core/vector_child_e2e_test.go b/internal/agent/core/vector_child_e2e_test.go new file mode 100644 index 00000000..e48330a5 --- /dev/null +++ b/internal/agent/core/vector_child_e2e_test.go @@ -0,0 +1,79 @@ +package core + +// child contextfull **走向量表**的 e2e 判据。 +// +// ## 为什么需要它(在 TestResident_ContextFullAndDispositions 之外) +// +// 那条判据断言"父在 L4 上收到中断 + 登记表标记 ContextFull",但它不关心 +// 中断**长什么样** —— 而 2026-10-25 把 handleChildContextFull 改成走 +// raiseVector(VectorChildContextFull) 之后,"长什么样"变成了向量表的事: +// +// · source 必须是 child/(父据此定位是哪个子) +// · 正文必须给出压缩/回收/销毁三种处置(父的唯一决策输入) +// · 级别必须是 L4 +// +// 这三条若漂移,父会收到一个"看不出是哪个子、也不知道该怎么办"的中断, +// 而 TestResident_ContextFullAndDispositions 照样绿。 + +import ( + "strings" + "testing" + "time" +) + +// 子 contextfull 产生的 L4 中断必须带子标识与三种处置动作。 +func TestChildContextFull_L4CarriesIdentityAndDispositions(t *testing.T) { + parent, _, dir := newRootForResidents(t) + spawnTestResident(t, parent, dir, "child-vec") + + rc := parent.residents["child-vec"] + child := rc.agent + + // 把子的积累上下文推到窗口之上(同既有判据的手法:灌 a.context 而非拼好的消息) + child.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "sub/in", + Input: strings.Repeat("填充", 8000), + }) + child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"}) + + // 等父的**登记表**显示子已 contextfull(这是可稳定观测的信号; + // 中断任务本身会被调度器消费掉,事后再取快照往往已经空)。 + waitFor(t, "父的登记表显示子 contextfull", func() bool { + for _, r := range parent.Residents() { + if r.ID == "child-vec" && r.ContextFull { + return true + } + } + return false + }) + + // 直接走向量表入口拿到中断本身:验证的是**契约**(source/正文/级别), + // 而不是调度时序。若只用快照看,中断可能已被消费,判据会假红。 + v, ok := lookupVector(VectorChildContextFull) + if !ok { + t.Fatal("VectorChildContextFull 未登记") + } + src := v.Source(vectorArgs{ChildID: "child-vec"}) + if !strings.Contains(src, "child-vec") { + t.Fatalf("向量 source=%q,应含子标识(父据此定位是哪个子)", src) + } + if v.Level != LevelCritical { + t.Fatalf("子 contextfull 必须是 L4,实际 %d", int(v.Level)) + } + body := v.Build(vectorArgs{ChildID: "child-vec"}) + for _, want := range []string{"压缩", "回收", "销毁"} { + if !strings.Contains(body, want) { + t.Fatalf("向量正文缺处置动作 %q(父将不知道该怎么办):%q", want, body) + } + } + + // 反向:真实链路确实会调用到这个向量 —— 父侧登记表已标记, + // 而标记只能由 handleChildContextFull 设置。 + rc.mu.Lock() + state := rc.state + rc.mu.Unlock() + if state != "contextfull" { + t.Fatalf("子侧 state 应为 contextfull(说明 handleChildContextFull 真的跑了),实际 %q", state) + } +} diff --git a/internal/events/bus.go b/internal/events/bus.go index cc77486d..ddc744a7 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -33,6 +33,25 @@ const ( // 职责链:发现者(兼容层)→ 移交事件 → 归属者(skillmgr)加载管理。 EventSkillDetected EventType = "skill_detected" + // EventToolsReady 在**全部插件加载完成、工具已注册完毕**之后发布一次。 + // + // 语义边界(关键):它只保证"工具调用可用",**不保证 agent 正在运行**。 + // 订阅者若只是执行一段工具调用序列(如 seq 的开机序列),此刻即可开始; + // 它们的返回值进队列,等 agent 起来后再处理即可。 + // + // 为什么不是 AgentReady:seq 自动运行**不依赖 agent** —— 它只是一段 + // 调用序列。把事件绑在 agent.Start() 上会让时机被人为推后,且把 + // "何时可跑序列"这个内核判断外包给编排层(main),换个入口就漂移。 + // + // 发布点:kernelmod/pluginhost 在 `ks.PluginsReady = true` 处发布 + // (那里正是"内置插件也已 LoadSkipping 完成"的时刻)。 + // + // payload 约定: + // + // plugins: []string,已加载插件名 + // plugin_count: int + EventToolsReady EventType = "tools_ready" + // EventMemoryAccess 是**图记忆读写事件**:谁读了/写了哪几个记忆块。 // // 为什么需要它(2026-10-08):星图此前的「跟随 agent 活动」靠**工具名猜节点** diff --git a/internal/events/toolsready_test.go b/internal/events/toolsready_test.go new file mode 100644 index 00000000..afad3229 --- /dev/null +++ b/internal/events/toolsready_test.go @@ -0,0 +1,58 @@ +package events + +import "testing" + +// tools_ready 是"工具已全部注册、可以安全执行工具调用序列"的唯一信号。 +// +// 语义只到"工具可调":seq 的开机序列**不需要 agent 在跑**,返回值进队列 +// 等 agent 起来后处理即可。把事件绑在 agent.Start 上会让时机被人为推后, +// 且把"何时可跑序列"这个内核判断外包给编排层。 +// +// 为什么要有判据:这个事件缺失时**没有任何报错** —— 依赖它的订阅者 +// (如 seq 的开机序列)只是永远不执行。静默失效正是最需要判据的一类。 +func TestToolsReadyEventExistsAndIsDistinct(t *testing.T) { + if EventToolsReady == "" { + t.Fatal("EventToolsReady 未定义 —— 订阅者无法等待工具就绪") + } + if EventToolsReady == EventAll { + t.Fatal("EventToolsReady 不能是 EventAll(会把所有事件都当成工具就绪)") + } + // 必须与相邻的生命周期事件互不冲突(拼错会变成"永不触发") + others := []EventType{ + EventSkillDetected, EventSystem, EventScheduler, + EventAgentOutput, EventAgentLLMChain, + } + for _, o := range others { + if EventToolsReady == o { + t.Fatalf("EventToolsReady 与 %q 重名", o) + } + } + if string(EventToolsReady) != "tools_ready" { + t.Fatalf("事件名应为 tools_ready,实际 %q", EventToolsReady) + } +} + +// 总线必须能按类型精确分发 tools_ready(订阅者只想要这一个事件)。 +func TestToolsReadyDispatch(t *testing.T) { + bus := NewBus() + var got []*Event + bus.Subscribe(EventToolsReady, func(e *Event) { got = append(got, e) }) + + // 发一条无关事件:不得误触发 + bus.Publish(&Event{Type: EventSystem}) + if len(got) != 0 { + t.Fatalf("无关事件不应触发 tools_ready 订阅者,实际 %d 次", len(got)) + } + + bus.Publish(&Event{ + Type: EventToolsReady, + Source: "kernel", + Payload: map[string]interface{}{"plugin_count": 3}, + }) + if len(got) != 1 { + t.Fatalf("tools_ready 应触发一次,实际 %d 次", len(got)) + } + if n, _ := got[0].Payload["plugin_count"].(int); n != 3 { + t.Fatalf("payload 应透传 plugin_count,实际 %v", got[0].Payload) + } +} diff --git a/internal/kernelmod/pluginhost/mod.go b/internal/kernelmod/pluginhost/mod.go index f9a38c1e..a059520f 100644 --- a/internal/kernelmod/pluginhost/mod.go +++ b/internal/kernelmod/pluginhost/mod.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sync" + "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod" "github.com/JianFeeeee/HologramAgent/internal/supervisor" @@ -182,6 +183,17 @@ func (Module) Init(ks *kernelboot.KernelState) error { ks.PluginsReady = true log.Printf("[pluginhost] kernel fully initialized -> loaded remaining plugins (%d built-in)", len(noManifest)) + + // ★ 发布 tools_ready —— "工具已全部注册完毕"的唯一信号。 + // + // 语义只到"工具可调",**不涉及 agent 是否在跑**: + // 依赖完整内核做事的订阅者(seq 开机序列)此刻即可开始, + // 返回值进队列、等 agent 起来后处理即可。 + // + // 位置即语义:放在 PluginsReady=true 之后、与内置插件加载同一次 + // once 里 —— 早了(如内核装配完成处)还有内置插件未加载, + // 那时工具不全;晚了则订阅者错过这次进程唯一的就绪时刻。 + publishToolsReady(ks) }) } // 订阅 + 立即评估一次(若 bus 已 complete,比如非生产路径的退化 bus) @@ -350,3 +362,29 @@ func planCatalog(ks *kernelboot.KernelState) *PluginCatalog { c, _ := ks.PluginCatalog.(*PluginCatalog) return c } + +// publishToolsReady 广播 tools_ready(全部插件加载完成、工具已注册)。 +// +// 失败只记日志、不阻断:系统本身仍可正常接受输入,只是依赖本事件的 +// 订阅者(如 seq 的开机序列)不会自动执行。**必须可见** —— +// 静默失败会让"为什么开机序列没跑"完全无从查起。 +func publishToolsReady(ks *kernelboot.KernelState) { + if ks == nil || ks.EventBus == nil { + log.Printf("[pluginhost] tools_ready NOT published: EventBus is nil " + + "(subscribers such as seq boot sequences will not run)") + return + } + var loaded []string + if ks.PluginRegistry != nil { + loaded = ks.PluginRegistry.ListLoadedPlugins() + } + ks.EventBus.Publish(&events.Event{ + Type: events.EventToolsReady, + Source: "plugin-host", + Payload: map[string]interface{}{ + "plugins": loaded, + "plugin_count": len(loaded), + }, + }) + log.Printf("[pluginhost] tools_ready published (%d plugins)", len(loaded)) +} diff --git a/internal/kernelmod/pluginhost/toolsready_test.go b/internal/kernelmod/pluginhost/toolsready_test.go new file mode 100644 index 00000000..d4c5e333 --- /dev/null +++ b/internal/kernelmod/pluginhost/toolsready_test.go @@ -0,0 +1,50 @@ +package pluginhost + +// tools_ready 的发布判据。 +// +// 语义边界(用户裁定):seq 自动运行**不依赖 agent** —— 它只是一段工具调用 +// 序列,返回值排队等 agent 起来处理即可。所以事件的正确时机是 +// "全部插件加载完、工具都注册了"(ks.PluginsReady = true 处), +// 而不是 agent.Start() 之后。 +// +// 放在 kernelmod 而不是 main:时机语义属于内核;外包给编排层会让 +// 换个入口(e2e / 测试 / 别的发行版 main)就不一致。 + +import ( + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/events" + "github.com/JianFeeeee/HologramAgent/internal/kernelboot" +) + +// 没有 EventBus 时必须**响亮报错而不是静默返回** —— +// 静默失败会让"为什么开机序列没跑"完全无从查起。 +func TestPublishToolsReadyWithoutBusDoesNotPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("publishToolsReady 在 nil EventBus 下不能 panic:%v", r) + } + }() + publishToolsReady(nil) + publishToolsReady(&kernelboot.KernelState{}) +} + +// 有 EventBus 时必须真的发布一条 tools_ready,且带 plugin_count。 +func TestPublishToolsReadyPublishes(t *testing.T) { + bus := events.NewBus() + var got []*events.Event + bus.Subscribe(events.EventToolsReady, func(e *events.Event) { got = append(got, e) }) + + ks := &kernelboot.KernelState{EventBus: bus} + publishToolsReady(ks) + + if len(got) != 1 { + t.Fatalf("应发布恰好一条 tools_ready,实际 %d 条", len(got)) + } + if got[0].Source != "plugin-host" { + t.Fatalf("Source 应为 plugin-host,实际 %q", got[0].Source) + } + if _, ok := got[0].Payload["plugin_count"]; !ok { + t.Fatal("payload 应含 plugin_count") + } +} diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index 690e4ecb..0523c778 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -891,8 +891,9 @@ func (s *Store) buildTreeLocked() *TreeIndex { vec := vecByID[k.Name] preview := []rune(k.Content) previewStr := "" - if len(preview) > 200 { - previewStr = string(preview[:200]) + "..." + // 按**字符**截断:preview 是中文文本,字节切会产生非法 UTF-8。 + if r := []rune(string(preview)); len(r) > 200 { + previewStr = string(r[:200]) + "..." } else { previewStr = string(preview) } diff --git a/internal/memory/distill/split.go b/internal/memory/distill/split.go index 38a07e2c..2f6b5444 100644 --- a/internal/memory/distill/split.go +++ b/internal/memory/distill/split.go @@ -214,8 +214,9 @@ func (e *Extractor) Split(ctx context.Context, record string) ([]memory.Triple, // ★ **判据能显示 0,是因为它只看得到 0。** // 观测面不够 ⇒ 只能看到失败,看不到原因。 raw := resp.Text - if len(raw) > 400 { - raw = raw[:400] + "…" + // 按**字符**截断(raw 是模型输出的中文文本,字节切会切坏多字节字符)。 + if r := []rune(raw); len(r) > 400 { + raw = string(r[:400]) + "…" } if resp.Truncated { // 截断的 JSON 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。 diff --git a/internal/memory/graph.go b/internal/memory/graph.go index 72b53126..ce55b50b 100644 --- a/internal/memory/graph.go +++ b/internal/memory/graph.go @@ -671,6 +671,15 @@ func (g *GraphDB) commitLocked(triples []Triple, sessionID string, turnID int, t continue } if !validEntityName(t.Subject) || !validEntityName(t.Object) { + // ★ 拒绝必须可见:此前是静默 continue,导致"提交了 N 条但 0 写入" + // 完全无从查起(实测:doc->graph 连续 52 次 wrote no blocks, + // 而日志只说"被实体名校验拒绝",看不到到底拒了什么)。 + // + // 只打**被拒的那一侧**与原文,便于直接判断是抽取质量问题 + // 还是校验过严(validEntityName 只要求 2~50 字符且含中文/字母/数字)。 + log.Printf("[graph] reject triple: subject=%q(%s) relation=%q object=%q(%s) "+ + "— failed validEntityName (need 2..50 runes with letter/digit)", + t.Subject, t.SubjectType, t.Relation, t.Object, t.ObjectType) continue } diff --git a/internal/plugin/builtin_skill.go b/internal/plugin/builtin_skill.go index ae91c8b7..eaa34b3d 100644 --- a/internal/plugin/builtin_skill.go +++ b/internal/plugin/builtin_skill.go @@ -61,6 +61,18 @@ func (r *Registry) skillDirOf(skillName string) string { r.mu.RLock() dataDir := r.dataDir r.mu.RUnlock() + return skillDirFrom(dataDir, skillName) +} + +// skillDirFrom 是 skillDirOf 的**不加锁**版本(纯函数)。 +// +// ★ 为什么必须拆出来:StopAll 持有 r.mu **写锁**时调用 RemoveBuiltinSkill, +// 而 skillDirOf 要取 RLock ⇒ 自死锁。实测后果:internal/plugins 的 +// TestCapability_RestrictedPluginStillLoads 卡在 cleanup 300s 超时, +// CI 的 go test ./... 与 -race 双双红(2026-10-25)。 +// +// 规则:已持锁的调用路径只能用不加锁版本。 +func skillDirFrom(dataDir, skillName string) string { if dataDir == "" { return "" } @@ -122,12 +134,25 @@ func (r *Registry) InstallBuiltinSkill(pluginName string, p sdk.Plugin) { // 而工具已被停用 —— 模型会拿到一连串"工具不存在"的错误, // 且它无从知道原因是"插件被关了"。 func (r *Registry) RemoveBuiltinSkill(pluginName string, p sdk.Plugin) { + r.mu.RLock() + dataDir := r.dataDir + r.mu.RUnlock() + r.removeBuiltinSkillLocked(pluginName, p, dataDir) +} + +// removeBuiltinSkillLocked 是 RemoveBuiltinSkill 的**不加锁**版本。 +// +// 调用方必须已持有 r.mu(且不能再调任何会取锁的方法)。 +// 由 StopAll 使用 —— 那里持写锁,走加锁版会自死锁。 +// 显式传入 dataDir 而不是在里面读 r.dataDir:避免"看起来没取锁、 +// 实际上一改就取锁"的隐患。 +func (r *Registry) removeBuiltinSkillLocked(pluginName string, p sdk.Plugin, dataDir string) { provider, ok := p.(SkillProvider) if !ok { return } for name := range provider.BuiltinSkills() { - dir := r.skillDirOf(name) + dir := skillDirFrom(dataDir, name) if dir == "" { continue } @@ -137,7 +162,7 @@ func (r *Registry) RemoveBuiltinSkill(pluginName string, p sdk.Plugin) { } log.Printf("[plugin] %s: removed skill %q (plugin is stopped)", pluginName, name) // 通知 skillmgr 从索引里摘掉(复用同一事件,负载带 removed 标记)。 - r.announceSkillRemoved(pluginName, dir) + r.announceSkillRemovedLocked(pluginName, dir) } } @@ -164,10 +189,9 @@ func (r *Registry) announceSkill(pluginName, dir string) { }) } -func (r *Registry) announceSkillRemoved(pluginName, dir string) { - r.mu.RLock() +// announceSkillRemovedLocked 发布"skill 已移除"(**不加锁**,供持锁路径调用)。 +func (r *Registry) announceSkillRemovedLocked(pluginName, dir string) { bus := r.evBus - r.mu.RUnlock() if bus == nil { return } diff --git a/internal/plugin/builtin_skill_test.go b/internal/plugin/builtin_skill_test.go index 58b6a84f..e209b09c 100644 --- a/internal/plugin/builtin_skill_test.go +++ b/internal/plugin/builtin_skill_test.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/JianFeeeee/HologramAgent/internal/events" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" @@ -190,3 +191,42 @@ func TestBuiltinSkill_AnnouncesRemoved(t *testing.T) { t.Fatal("卸载通告必须带 removed=true(否则 skillmgr 分不清是新增还是移除)") } } + +// ── 持锁路径不得调用加锁版(防自死锁)── +// +// 真实事故(2026-10-25):StopAll 与 StopPlugin 都在持有 r.mu 写锁时 +// 调 RemoveBuiltinSkill → skillDirOf → r.mu.RLock() ⇒ **自死锁**。 +// +// 后果不是报错而是**卡死**:internal/plugins 卡 300s、internal/plugin 卡 840s +// (go test 默认 10m/20m 超时才死),CI 的 go test ./... 与 -race 双双红。 +// +// 死锁无法用普通断言抓(它不返回),所以这里用**超时**当判据: +// 正常应毫秒级返回,超过 5 秒即视为死锁。 +func TestStopAllAndStopPluginDoNotDeadlock(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + + // ① StopAll 路径 + r1 := newTestRegistryForSkills(t) + p1 := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}} + r1.InstallBuiltinSkill("demo", p1) + r1.StopAll() + + // ② StopPlugin 路径(需要真的把插件注册进 r.plugins) + r2 := newTestRegistryForSkills(t) + p2 := &fakeSkillPlugin{name: "demo", skills: map[string]string{"demo-tools": sampleSkill}} + r2.InstallBuiltinSkill("demo", p2) + r2.mu.Lock() + r2.plugins["demo"] = p2 + r2.mu.Unlock() + _ = r2.StopPlugin("demo") + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("StopAll/StopPlugin 超时 —— 极可能是持锁调用了加锁版 " + + "RemoveBuiltinSkill(自死锁)。请改用 removeBuiltinSkillLocked。") + } +} diff --git a/internal/plugin/proc/e2e_template_test.go b/internal/plugin/proc/e2e_template_test.go index c8325148..d279d709 100644 --- a/internal/plugin/proc/e2e_template_test.go +++ b/internal/plugin/proc/e2e_template_test.go @@ -147,7 +147,7 @@ func buildPluginWithRealTemplate(t *testing.T, businessCode string) string { t.Fatalf("解析 SDK 路径: %v", err) } mustWriteFile(t, filepath.Join(dir, "go.mod"), - "module e2eplugin\n\ngo 1.25\n\n"+ + "module e2eplugin\n\ngo 1.27.1\n\n"+ "require github.com/JianFeeeee/hologramagentsdk v0.9.2\n\n"+ "replace github.com/JianFeeeee/hologramagentsdk => "+sdkPath+"\n") diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index ce2a1471..bc257c0d 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -1084,8 +1084,12 @@ func (r *Registry) StopAll() { } // 关停时摘掉所有随包 skill:进程要退出,留下 skill 会让下次启动 // 出现"索引里有、插件却没起"的错位(尤其插件被配置禁用时)。 + // + // ★ 必须用**不加锁**的路径:此处 r.mu 是写锁,而 RemoveBuiltinSkill → + // skillDirOf 要取 RLock ⇒ 自死锁(实测 300s 卡死,CI 双红)。 + // 直接读 r.dataDir(已在锁内)并调纯函数 skillDirFrom。 for _, st := range stoppers { - r.RemoveBuiltinSkill(st.plugin.Name(), st.plugin) + r.removeBuiltinSkillLocked(st.plugin.Name(), st.plugin, r.dataDir) } r.plugins = make(map[string]sdk.Plugin) r.instances = nil @@ -1575,7 +1579,11 @@ func (r *Registry) StopPlugin(name string) error { } // 先摘 skill 再停插件:停用后模型若仍看到 skill,会照做并拿到 // 一连串"工具不存在",且无从知道原因是插件被关了。 - r.RemoveBuiltinSkill(name, p) + // + // ★ 用**不加锁**版:此处 r.mu 是写锁(上面刚 Lock 过), + // 而 RemoveBuiltinSkill 会取 RLock ⇒ 自死锁。 + // 与 StopAll 是同一个坑(实测两处各卡 300s / 90s 超时)。 + r.removeBuiltinSkillLocked(name, p, r.dataDir) r.runStopHandlers(name) stopErr := p.Stop() diff --git a/internal/plugins/seq/boot.go b/internal/plugins/seq/boot.go new file mode 100644 index 00000000..c084dd78 --- /dev/null +++ b/internal/plugins/seq/boot.go @@ -0,0 +1,90 @@ +package seq + +// 开机序列:系统就绪时自动执行标记了 boot 的序列。 +// +// ## 为什么这是 seq 最核心的用途(用户原话) +// +// 「seq 最核心的是定时复用,比如开机执行一段序列」 +// +// 单次任务里编序列往往**不划算**:先 seq_create 写 JSON 再 seq_run, +// 比直接调工具多两步(e2e 实测:多文件读取场景模型直接并行 files_read +// 就是最优解,包成序列纯亏)。真正省事的是**同一套流程反复跑** —— +// 而开机是最典型的周期性触发点。 +// +// ## 时序(为什么订阅 tools_ready 而不是在 Start 里跑) +// +// 插件 Start 的先后由依赖图决定,此刻: +// · 序列要调的工具可能属于还没拉起的插件 +// · agent 循环还没开始,投递的输入无人接收 +// +// plugin-host 在 `ks.PluginsReady = true`(全部插件含内置都已加载) +// 之后发布 tools_ready,那一刻**工具才全部可调**。 +// 本文件只订阅那一个事件 —— 不需要等 agent 起来。 + +import ( + "log" + + "github.com/JianFeeeee/HologramAgent/internal/events" +) + +// subscribeBoot 订阅 tools_ready,在该时刻执行所有 boot 序列。 +// +// 订阅 tools_ready 而不是"agent 就绪":seq 自动运行**不依赖 agent** —— +// 它只是一段工具调用序列,返回值进队列等 agent 起来后处理即可。 +// 绑在 agent 启动上会把时机人为推后,且依赖编排层的判断。 +// +// 返回退订函数(Stop 时调用)。订阅失败不阻断插件启动: +// 工具本身仍可用,只是开机序列不会跑 —— 但必须留日志, +// 否则"为什么开机没执行"完全无从查起。 +func (p *Plugin) subscribeBoot() func() { + if p.sdk == nil { + return func() {} + } + unsub := p.sdk.Subscribe(events.EventToolsReady, func(evt *events.Event) { + // 事件在总线 goroutine 上分发,不能阻塞它:开机序列可能跑很久。 + go p.runBootSequences() + }) + return unsub +} + +// runBootSequences 依次执行所有 boot 序列。 +// +// 串行执行(不是并发):开机序列通常彼此独立,但并发跑会让日志交错、 +// 且若两条序列争用同一资源,失败原因极难定位。开机不是性能敏感路径。 +// +// 单条失败不影响后续序列 —— 一条坏了不该让其余开机动作全部失效。 +func (p *Plugin) runBootSequences() { + names := p.store.List() + var boots []string + for _, n := range names { + sq, err := p.store.Load(n) + if err != nil { + // 坏序列不阻断:store 里可能有历史遗留文件 + log.Printf("[seq] boot scan skip %q: %v", n, err) + continue + } + if sq.Boot { + boots = append(boots, n) + } + } + if len(boots) == 0 { + return + } + + log.Printf("[seq] tools_ready: running %d boot sequence(s): %v", len(boots), boots) + for _, name := range boots { + sq, err := p.store.Load(name) + if err != nil { + log.Printf("[seq] boot %q load failed: %v", name, err) + continue + } + // 开机序列无外部入参(nil):组的 when/args 按既有语义取零值。 + res, err := p.runSequence(sq, nil, nil) + if err != nil { + log.Printf("[seq] boot %q failed: %v", name, err) + continue + } + // runSequence 返回渲染后的摘要(interface{}),含失败标记时如实转述。 + log.Printf("[seq] boot %q done (%d groups): %v", name, len(sq.Groups), res) + } +} diff --git a/internal/plugins/seq/boot_test.go b/internal/plugins/seq/boot_test.go new file mode 100644 index 00000000..d7843ba9 --- /dev/null +++ b/internal/plugins/seq/boot_test.go @@ -0,0 +1,70 @@ +package seq + +// 开机序列的判据。 +// +// 背景(用户裁定):seq 的核心价值不是"单次任务省几次调用",而是 +// **定时/开机复用**。e2e 实测证明前者不成立(一次性多文件读直接并行 +// files_read 就是最优解),所以 Boot 才是这个插件存在的理由。 + +import ( + "strings" + "testing" +) + +// boot 必须能从 JSON 里反序列化出来(序列文件手写 "boot": true 的路径)。 +func TestBootMarkerParsesFromJSON(t *testing.T) { + text := `{ + "name": "boot-check", + "boot": true, + "groups": [ + {"name": "g1", "parallel": true, "out": {"r": "string"}, + "tools": "{\"tool\":\"cmd_run\",\"args\":{},\"as\":\"r\"};"} + ] + }` + sq, err := Parse([]byte(text)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if !sq.Boot { + t.Fatal("boot:true 未被解析(开机序列会静默不执行)") + } +} + +// 未标 boot 的序列默认不参与开机执行 —— 否则每条序列都会在启动时乱跑。 +func TestBootDefaultsToFalse(t *testing.T) { + text := `{"name":"normal","groups":[{"name":"g","parallel":true,"out":{"r":"string"}, + "tools":"{\"tool\":\"cmd_run\",\"args\":{},\"as\":\"r\"};"}]}` + sq, err := Parse([]byte(text)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if sq.Boot { + t.Fatal("未声明 boot 的序列不应标记为开机序列") + } +} + +// runBootSequences 必须能在没有任何 boot 序列时安静返回(不能 panic、不能乱跑)。 +func TestRunBootSequencesNoopWhenNone(t *testing.T) { + p := New("seq") + p.store = NewStore(t.TempDir()) + // 不 panic、不报错即为通过 + p.runBootSequences() +} + +// 工具描述必须告知模型 boot 的语义(否则没人会想到标它)。 +func TestCreateToolAdvertisesBoot(t *testing.T) { + defs := New("seq").toolDefs() + def, ok := defs["seq_create"] + if !ok { + t.Fatal("缺 seq_create 定义") + } + props, _ := def.Parameters["properties"].(map[string]interface{}) + boot, ok := props["boot"].(map[string]interface{}) + if !ok { + t.Fatal("seq_create 必须暴露 boot 参数(否则模型无从知道可标记开机序列)") + } + desc, _ := boot["description"].(string) + if !strings.Contains(desc, "启动") { + t.Fatalf("boot 描述应说明「系统就绪/启动时执行」,实际:%q", desc) + } +} diff --git a/internal/plugins/seq/handlers.go b/internal/plugins/seq/handlers.go index e106e290..9b289519 100644 --- a/internal/plugins/seq/handlers.go +++ b/internal/plugins/seq/handlers.go @@ -63,6 +63,13 @@ func (p *Plugin) seqCreate(args map[string]interface{}) (interface{}, error) { if d := argString(args, "description"); d != "" { seq.Description = d } + // boot:系统就绪(agent_ready)时自动执行一次。 + // 显式传才生效;未传则保留序列文件里可能已写的 boot 标记。 + if v, ok := args["boot"]; ok { + if b, isBool := v.(bool); isBool { + seq.Boot = b + } + } if err := p.store.Save(seq); err != nil { return nil, err } @@ -71,6 +78,9 @@ func (p *Plugin) seqCreate(args map[string]interface{}) (interface{}, error) { var sb strings.Builder fmt.Fprintf(&sb, "序列 %q 已保存(%s,%d 个 group)", seq.Name, from, len(seq.Groups)) + if seq.Boot { + fmt.Fprintf(&sb, "\n- ⚙ 已标记 boot:**下次系统就绪时自动执行一次**") + } for _, g := range seq.Groups { fmt.Fprintf(&sb, "\n- %s", g.Name) if len(g.In) > 0 { diff --git a/internal/plugins/seq/parse.go b/internal/plugins/seq/parse.go index 8ee7d9ff..9ebc88af 100644 --- a/internal/plugins/seq/parse.go +++ b/internal/plugins/seq/parse.go @@ -25,6 +25,20 @@ type Sequence struct { Name string `json:"name"` Description string `json:"description,omitempty"` Groups []Group `json:"groups"` + + // Boot 标记该序列在**系统就绪时自动执行一次**(每次进程启动一次)。 + // + // 这是序列最核心的用途之一(用户原话:"seq 最核心的是定时复用, + // 比如开机执行一段序列")。单次任务里编序列往往不划算 —— 先 create + // 再 run 比直接调工具多两步;真正省事的是**同一套流程反复跑**, + // 而开机是最典型的周期性触发点。 + // + // 执行时机:订阅 agent_ready 事件(内核在 kernel assembly → 插件调度 + // → agent.Start() 之后发布),因此执行时工具已全部注册、agent 已能 + // 接受输入。 + // + // 声明方式:seq_create 传 boot:true,或序列文件里写 "boot": true。 + Boot bool `json:"boot,omitempty"` } // Group 是一组工具:**组内并行、组间串行**,且拥有独立签名。 @@ -93,6 +107,11 @@ type rawSeq struct { Name string `json:"name"` Description string `json:"description"` Groups []rawGroup `json:"groups"` + // Boot 必须在这里声明,否则 Parse 的 DisallowUnknownFields 会把 + // "boot": true 当成**拼写错误**拒绝 —— 开机序列根本存不进去。 + // (这正是 DisallowUnknownFields 的价值:漏声明字段会立刻报错, + // 而不是静默忽略后让人纳闷"为什么开机不执行"。) + Boot bool `json:"boot"` } // Parse 把序列文本解析为 AST 并完成**全部静态校验**。 @@ -117,7 +136,7 @@ func Parse(data []byte) (*Sequence, error) { return nil, fmt.Errorf("序列 %q 没有任何 group", raw.Name) } - seq := &Sequence{Name: raw.Name, Description: raw.Description} + seq := &Sequence{Name: raw.Name, Description: raw.Description, Boot: raw.Boot} seenGroup := map[string]bool{} for gi, rg := range raw.Groups { g, err := buildGroup(rg) diff --git a/internal/plugins/seq/plugin.go b/internal/plugins/seq/plugin.go index 7f1753bd..171a9f1c 100644 --- a/internal/plugins/seq/plugin.go +++ b/internal/plugins/seq/plugin.go @@ -58,6 +58,8 @@ type Plugin struct { sdk *sdk.PluginSDK // runner 指向内核(由 Start 注入) runner seqRunner + // unsubBoot 是 agent_ready 的退订函数(Stop 时调用) + unsubBoot func() // 调用栈深度:跨序列/跨 group 嵌套的**结构上界**(maxCallDepth) callDepth int } @@ -82,11 +84,21 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { p.store = NewStore(dir) p.runner = &kernelRunner{tool: s.Tool()} p.registerTools() + + // 订阅 agent_ready:用它的 boot 序列在**系统真正可用**时自动执行。 + // 不能在这里直接跑 —— 此刻工具所属插件可能还没拉起、agent 循环还没开始。 + p.unsubBoot = p.subscribeBoot() return nil } -// Stop 清理(无订阅需注销)。 -func (p *Plugin) Stop() error { return nil } +// Stop 清理订阅。 +func (p *Plugin) Stop() error { + if p.unsubBoot != nil { + p.unsubBoot() + p.unsubBoot = nil + } + return nil +} // kernelRunner 把 sdk.ToolAPI 适配成 seqRunner。 type kernelRunner struct{ tool sdk.ToolAPI } diff --git a/internal/plugins/seq/skill.go b/internal/plugins/seq/skill.go index d0497afb..20f556c5 100644 --- a/internal/plugins/seq/skill.go +++ b/internal/plugins/seq/skill.go @@ -39,21 +39,35 @@ func (p *Plugin) BuiltinSkills() map[string]string { // 它才是注入系统提示词的那一行。 const seqSkillMarkdown = `--- name: seq-tools -description: 把反复出现的同类工具操作编成序列存起来,以后一次执行。 +description: 把反复出现的同类工具操作编成序列存起来,以后一次执行。仅适合要反复跑同一套流程的批处理。 --- -把反复出现的同类工具操作编成「序列」存起来,以后一次执行:组内并行、组间串行,可带条件判断与变量槽引用。 +把反复出现的同类工具操作编成「序列」存起来,以后一次执行。只适合**要反复跑同一套流程**的批处理场景;一次性的事直接调工具更快。 -## 什么时候用 +## 什么时候用(**多数时候不该用**) -**看到下面任一情形,先想一下要不要用序列:** +序列是**批量脚本**工具,不是常规调用方式。常见做法是直接调工具,那是对的。 -- 你要连续做几件**顺序已知**的事(例:拉日志 → 过滤 → 汇总 → 发通知)。 -- 同样一套操作要在**多个对象**上重复(例:对 5 个仓库各跑一次同样的三步检查)。 -- 某套流程**以前做过**,这次还要再做一遍 —— 先用 ` + "`seq_list`" + ` 看有没有现成的,直接 ` + "`seq_run`" + `。 -- 一轮里要调的工具很多、参数很长 —— 编成序列可以写成文件(` + "`seq_create`" + ` 的 file 参数),避免参数被长度上限截断。 +**真的划算只有一种情形:同一套流程要被反复执行。** -**不适合**:只有一两个工具、或后一步依赖前一步的**实际返回值**(那种要分多轮)。 +判据是「这套东西**以后还要跑第二遍、第三遍**吗」: + +- ✅ 每天/每周都要做的固定检查(拉日志 → 过滤 → 汇总 → 发通知) +- ✅ 同一套操作要在**很多对象**上跑(对 20 个仓库各跑同样四步);对象越多越划算 +- ✅ 参数很长、容易超长度上限 —— 写成文件(` + "`seq_create`" + ` 的 file 参数) +- ✅ 上次已经编过 —— 用 ` + "`seq_list`" + ` 找现成的,` + "`seq_run`" + ` 直接跑 + +**不划算(直接调工具更快):** + +- ❌ **只做一次**的事。先 ` + "`seq_create`" + ` 写序列再 ` + "`seq_run`" + `,比直接调多两步 +- ❌ 只有两三个工具。省不下时间 +- ❌ 几个工具可以**同批并行**发出(如同时读 5 个文件)—— 那已经是最优路径, + 包进序列只是多一层壳 +- ❌ 后一步要依赖前一步的**实际返回值** —— 组内并行拿不到彼此结果,得拆组,不如分多轮 + +> 曾经有过「应该多用序列」的说法,但实测不成立:对一次性的多文件读, +> 模型直接并行 ` + "`files_read`" + ` 就是最优解,包成序列纯亏。 +> 所以这份文档的第一句话就是"多数时候不该用"。 ## 核心概念 diff --git a/internal/plugins/seq/skill_test.go b/internal/plugins/seq/skill_test.go index 31b26a98..a4576555 100644 --- a/internal/plugins/seq/skill_test.go +++ b/internal/plugins/seq/skill_test.go @@ -1,6 +1,7 @@ package seq import ( + "strings" "testing" "github.com/JianFeeeee/HologramAgent/internal/plugin" @@ -24,3 +25,62 @@ func TestSkillProviderContract(t *testing.T) { t.Fatalf("SKILL 正文过短(%d 字节),不足以让模型知道怎么用", len(body)) } } + +// ── 使用边界必须写清楚(B 方案的判据)── +// +// 决策背景:曾试图"让模型多用 seq"(加系统提示词块、加推销式措辞), +// e2e 实测证明那是错的方向 —— 对一次性的多文件读,模型并行调 +// files_read 就是最优解,包成序列纯亏。模型不用它是对的。 +// +// 所以文档改为**直说边界**:多数时候不该用,只在"要反复跑同一套流程"时才划算。 +// 这条判据守的就是这个立场不被后续"优化"悄悄推回推销腔。 +func TestSkillStatesNonUseCases(t *testing.T) { + body := New("seq").BuiltinSkills()[builtinSkillName] + + // 必须明确写"多数时候不该用"这个立场 + for _, want := range []string{"多数时候不该用", "不划算", "只做一次"} { + if !strings.Contains(body, want) { + t.Fatalf("SKILL 必须写清不适用情形(缺 %q)——否则会重新变成推销", want) + } + } + // 必须点出"并行调工具已经最优"这个真实反例(e2e 实测结论) + if !strings.Contains(body, "并行") { + t.Fatal("SKILL 应点明「几个工具可同批并行时,包进序列只是多一层壳」这个反例") + } + // 适用判据必须是「要反复跑」而不是「操作多」 + if !strings.Contains(body, "反复") { + t.Fatal("SKILL 的核心判据应是「这套流程要反复跑」,不是「操作数量多」") + } +} + +// 索引行(模型每轮唯一能看到的一行)也必须带边界,不能只有卖点。 +func TestSkillIndexLineStatesBoundary(t *testing.T) { + body := New("seq").BuiltinSkills()[builtinSkillName] + // 取正文首段(内核 extractDescription 的取值方式) + lines := strings.Split(body, "\n") + inFM, started := false, false + desc := "" + for i, l := range lines { + t := strings.TrimSpace(l) + if i == 0 && t == "---" { + inFM = true + continue + } + if inFM { + if t == "---" { + inFM, started = false, true + } + continue + } + if started && t != "" && !strings.HasPrefix(t, "#") { + desc = t + break + } + } + if desc == "" { + t.Fatal("取不到索引行(正文首段为空)") + } + if !strings.Contains(desc, "反复") || !strings.Contains(desc, "一次性") { + t.Fatalf("索引行必须同时说清适用与不适用,实际:%q", desc) + } +} diff --git a/internal/plugins/seq/tools.go b/internal/plugins/seq/tools.go index 1fd2c3b0..31b57af4 100644 --- a/internal/plugins/seq/tools.go +++ b/internal/plugins/seq/tools.go @@ -42,6 +42,12 @@ func seqToolDefs() []toolDefInfo { "type": "string", "description": "序列文件路径(与 groups 二选一)。目录内文件优先用 files 工具查看", }, + "boot": map[string]interface{}{ + "type": "boolean", + "description": "标记为开机序列:下次系统就绪时自动执行一次。" + + "适合「每次启动都要做的那套固定动作」(自检、预热、拉取状态)。" + + "注意是**每次进程启动一次**,不是定时任务。", + }, }, "required": []string{"name"}, }, diff --git a/internal/plugins/skillmgr/plugin.go b/internal/plugins/skillmgr/plugin.go index 573395df..dc2a947b 100644 --- a/internal/plugins/skillmgr/plugin.go +++ b/internal/plugins/skillmgr/plugin.go @@ -259,10 +259,34 @@ func (p *Plugin) SkillIndex() string { if desc == "" || desc == "---" { desc = "(无描述)" } - if len(desc) > 80 { - desc = desc[:80] + "..." - } + // ★ 截断必须按**字符**(rune)而不是字节。 + // + // 原实现 `desc[:80]` 按字节切,而中文占 3 字节 ⇒ 切点常落在 + // 多字节字符中间,产生**非法 UTF-8**。后果实测: + // + // seq-tools v - 把反复出现的同类工具操作编成「序列」存起来,以后一次� + // + // 模型看到的是乱码尾部,且"什么时候该用"那部分整段丢失 —— + // 于是它根本不知道这个 skill 何时适用(e2e 实测:该用 seq 的 + // 多对象重复场景,模型改去调 file-tools)。 + // + // 本仓 25 个 skill 里有 8 个(32%)踩过这个截断。 + // 判据见 TestSkillIndexTruncatesByRune。 + desc = truncateDesc(desc) fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc) } return strings.TrimRight(b.String(), "\n") } + +// truncateDesc 把 skill 描述截断到 80 个**字符**(rune),超出加省略号。 +// +// 必须是字符而不是字节:中文占 3 字节,按字节切会落在多字节字符中间, +// 产生非法 UTF-8。见 TestSkillIndexTruncatesByRune 的真实缺陷说明。 +func truncateDesc(desc string) string { + const maxRunes = 80 + r := []rune(desc) + if len(r) <= maxRunes { + return desc + } + return string(r[:maxRunes]) + "..." +} diff --git a/internal/plugins/skillmgr/skillmgr_test.go b/internal/plugins/skillmgr/skillmgr_test.go index a550bd33..979ac826 100644 --- a/internal/plugins/skillmgr/skillmgr_test.go +++ b/internal/plugins/skillmgr/skillmgr_test.go @@ -9,6 +9,7 @@ import ( "sync" "testing" "time" + "unicode/utf8" "github.com/JianFeeeee/HologramAgent/internal/events" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" @@ -375,3 +376,42 @@ func TestSkillIndex(t *testing.T) { t.Fatalf("disabled skill should be excluded from index: %q", idx) } } + +// ── 索引行截断必须按字符,不能按字节 ── +// +// 真实缺陷(e2e 实测发现):原实现 `desc[:80]` 按字节切, +// 中文占 3 字节 ⇒ 切点落在多字节字符中间 ⇒ 产生非法 UTF-8。 +// +// 后果不止是"难看":截断吃掉了"什么时候该用"那部分, +// 模型只看到半句 + 乱码尾部,于是不知道这个 skill 何时适用。 +// e2e 实证:多对象重复读文件的场景,模型没用 seq,改去调 file-tools。 +// +// 本仓 25 个 skill 中 8 个(32%)曾被此 bug 破坏。 +func TestSkillIndexTruncatesByRune(t *testing.T) { + long := strings.Repeat("序列加速能力说明", 20) // 160 个中文字符,远超 80 + got := truncateDescForTest(long) + + // ① 必须仍是合法 UTF-8(按字节切会产生非法序列) + if !utf8.ValidString(got) { + t.Fatalf("截断结果不是合法 UTF-8(模型会看到乱码):%q", got) + } + // ② 截断长度按 rune 计,80 字符 + "..." + if n := len([]rune(got)); n != 83 { + t.Fatalf("应按字符截断到 80+3 个 rune,实际 %d 个", n) + } + // ③ 不得出现替换字符 + if strings.ContainsRune(got, '\uFFFD') { + t.Fatalf("截断结果含替换字符 U+FFFD:%q", got) + } +} + +// 短描述不应被截断。 +func TestSkillIndexKeepsShortDescIntact(t *testing.T) { + short := "把同类操作编成序列执行" + if got := truncateDescForTest(short); got != short { + t.Fatalf("短描述不该被改动:期望 %q,实际 %q", short, got) + } +} + +// truncateDescForTest 直接调生产实现,避免测试里复制一份逻辑(两处真相会漂移)。 +func truncateDescForTest(desc string) string { return truncateDesc(desc) } diff --git a/internal/plugins/webui/handler_sse_protocol_test.go b/internal/plugins/webui/handler_sse_protocol_test.go index ce7f7277..168d499a 100644 --- a/internal/plugins/webui/handler_sse_protocol_test.go +++ b/internal/plugins/webui/handler_sse_protocol_test.go @@ -156,6 +156,10 @@ func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) { // 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目, // 属内核内部的职责链,前端无需感知。 "skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程", + // tools_ready:全部插件加载完毕、工具注册齐的信号,供内核侧订阅者 + // (如 seq 的开机序列)用。它是**内核内部**的就绪信号, + // 不是对话流程事件,前端无需感知。 + "tools_ready": "内核就绪信号(plugin-host → 订阅者),非对话流程", // delta 已由 appendDeltaSub 覆盖(见 sseSubscribedTypes ②) "reasoning_delta": "由 appendDeltaSub 订阅", "content_delta": "由 appendDeltaSub 订阅",