mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +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:
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user