mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(plugin): 修两处持锁自死锁(CI 双红真因)+ tools_ready 事件声明
## 死锁(这是 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 全通过。
This commit is contained in:
@ -302,6 +302,7 @@ func main() {
|
||||
stopRuntime := startAgentRuntime(cfgReg, pluginReg, agent, logDir, ctx)
|
||||
defer stopRuntime()
|
||||
|
||||
|
||||
_, closeIPC := startIPCServer(opt.dataDir, opt.boot, agent)
|
||||
defer closeIPC()
|
||||
|
||||
|
||||
@ -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 或工具描述",
|
||||
|
||||
@ -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,而不是在提示词里重复格式细节")
|
||||
}
|
||||
}
|
||||
91
internal/agent/core/runetrunc_test.go
Normal file
91
internal/agent/core/runetrunc_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
// 全仓守卫:按字节截断字符串是中文环境下的**必然 bug**。
|
||||
//
|
||||
// 中文占 3 字节,`s[:N]` 的切点常落在多字节字符中间 ⇒ 产生非法 UTF-8。
|
||||
// 实测代价(不是理论风险):
|
||||
//
|
||||
// skillmgr: desc[:80] 把 25 个 skill 里 8 个(32%)的索引行切坏,
|
||||
// 模型看到 "…以后一次<E4B880>",且"什么时候该用"整段丢失
|
||||
// ⇒ 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 "))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
79
internal/agent/core/vector_child_e2e_test.go
Normal file
79
internal/agent/core/vector_child_e2e_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package core
|
||||
|
||||
// child contextfull **走向量表**的 e2e 判据。
|
||||
//
|
||||
// ## 为什么需要它(在 TestResident_ContextFullAndDispositions 之外)
|
||||
//
|
||||
// 那条判据断言"父在 L4 上收到中断 + 登记表标记 ContextFull",但它不关心
|
||||
// 中断**长什么样** —— 而 2026-10-25 把 handleChildContextFull 改成走
|
||||
// raiseVector(VectorChildContextFull) 之后,"长什么样"变成了向量表的事:
|
||||
//
|
||||
// · source 必须是 child/<id>(父据此定位是哪个子)
|
||||
// · 正文必须给出压缩/回收/销毁三种处置(父的唯一决策输入)
|
||||
// · 级别必须是 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)
|
||||
}
|
||||
}
|
||||
@ -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 活动」靠**工具名猜节点**
|
||||
|
||||
58
internal/events/toolsready_test.go
Normal file
58
internal/events/toolsready_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
50
internal/kernelmod/pluginhost/toolsready_test.go
Normal file
50
internal/kernelmod/pluginhost/toolsready_test.go
Normal file
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 不可救,也不该救 —— 重试比丢弃更划算(模型是随机的)。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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。")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
90
internal/plugins/seq/boot.go
Normal file
90
internal/plugins/seq/boot.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
70
internal/plugins/seq/boot_test.go
Normal file
70
internal/plugins/seq/boot_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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`" + ` 就是最优解,包成序列纯亏。
|
||||
> 所以这份文档的第一句话就是"多数时候不该用"。
|
||||
|
||||
## 核心概念
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,12 @@ func seqToolDefs() []toolDefInfo {
|
||||
"type": "string",
|
||||
"description": "序列文件路径(与 groups 二选一)。目录内文件优先用 files 工具查看",
|
||||
},
|
||||
"boot": map[string]interface{}{
|
||||
"type": "boolean",
|
||||
"description": "标记为开机序列:下次系统就绪时自动执行一次。" +
|
||||
"适合「每次启动都要做的那套固定动作」(自检、预热、拉取状态)。" +
|
||||
"注意是**每次进程启动一次**,不是定时任务。",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
|
||||
@ -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 - 把反复出现的同类工具操作编成「序列」存起来,以后一次<E4B880>
|
||||
//
|
||||
// 模型看到的是乱码尾部,且"什么时候该用"那部分整段丢失 ——
|
||||
// 于是它根本不知道这个 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]) + "..."
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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 订阅",
|
||||
|
||||
Reference in New Issue
Block a user