|
|
0c959e3ff3
|
fix(rtchild): pump 裸读写 data race —— 锁贯彻到底
## 缺陷(-race 实测)
rtChild 里 pump 是**唯一**"构造后被改写、又有并发读者"的字段,
但它的读写绕过了 rc.mu:
Dispose(StopRTChild 的 goroutine) rc.pump = nil ← 写
rtEmit (rtLoop 的 goroutine) rc.pump ← 读
-race 栈:
Read at … core.(*Agent).rtEmit rtchild.go:1275
Previous write … core.(*rtChild).Dispose rtchild.go:377
## 为什么之前没被发现
TestRTChild_E2E_Sustained 覆盖同一个窗口,但它是**偶发命中**:
全量负载下 5 次中 1 次,单跑 20 次 0 次,基线 af17af5 全量 0 次。
这种形状在 CI 里等于没有 —— 红了是运气,绿了不代表安全。
## 修法:锁贯彻到底(不是只补这一处)
rtChild 全部字段逐个判定过:
字段 构造后写? 遵守锁规?
pump 是(Dispose) ★ 本提交修:读写都持 rc.mu
light/mainRO 是(Dispose) ✓ 无并发读者,只在 Dispose 内用
slot/input/output/agent 否 ✓ 只读,不需要锁
history 是(append) ✓ 三处访问全部持锁(原有)
round/lastSeq/curCancel/critical/suppressedCancels ✓ 原本就持锁
即:pump 是唯一的漏网者,其余要么本已正确、要么根本没有并发写。
## 两个顺序要求(不只是"加个锁")
① **取走并置 nil 在锁内,Close 在锁外**
pump.Close() 内部 wg.Wait() 等消费 goroutine 收尾;持锁等待可能与
另一个持锁路径交叉。而"置 nil"必须与读者的"读"互斥,否则读者会
拿到一个已被清掉的指针。
② **rtEmit 锁内取、锁外 Push**
Push 在队列满时会阻塞,持锁会卡死 Dispose 的置 nil。
## 判据(2 条,2 次变异全部实测变红)
TestRTPumpAccessIsLocked
一边不停 emit、一边 Dispose,用 -race 判定。
变异 A(rtEmit 裸读) ⇒ 连跑 3 次全部命中 DATA RACE
变异 B(Dispose 裸写) ⇒ 第 1 次命中 DATA RACE
TestRTPumpAfterDisposeIsNil
钉住顺序语义:Dispose 之后 rc.pump 必须为 nil(否则 emit 会推
送到已关闭的 pump),且兜底路径不 panic。
## 判据第一版是假的(记下来)
第一版读者写的是 `for i := 0; i < 500; i++` —— 500 次 emit 微秒级跑完,
而写者 sleep 1ms 才动手,两者**根本不重叠**。变异 B(裸写)连跑 6 次
都不红。改成"持续跑到写者宣布结束"之后才真正抓到。
这正是「判据必须能变红」的意义:不跑变异就永远不知道它是真判据还是摆设。
## 判据的边界(诚实说明)
这条判据的"红"依赖 -race 检测器。不带 -race 时它只能发现 panic/死锁。
所以必须与 `go test -race` 一起跑 —— 提交纪律第 4 条已要求全量 -race,
这里只是把「哪个窗口最危险」钉成可复跑的形态。
## 验证(提交纪律四项全绿)
go build ./... 0
go vet ./... 0
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 -race 0 竞态,62 包全过
干净检出(worktree) build + TestRTPump 全绿
|
2026-11-04 16:53:13 +08:00 |
|
|
|
8fa6f36982
|
fix(agent): 插件崩溃熔断从不生效 —— 冷却锚点用了从未赋值的字段
## 缺陷(写上一个提交的判据时撞出来的)
recordCrash 标记 Unhealthy=true,但 isHealthy 的冷却判据是:
time.Since(r.LastReload) > reloadCooldown
而 LastReload **只在 markReloaded 里被赋值**(即"重载成功"之后)。
插件刚崩、从未重载时它是**零值**:
time.Since(零值) ≈ 2000 年 >> 30s
⇒ 判"冷却期早已过去" ⇒ 立刻把 Unhealthy 置回 false
实测(连打 3 次 recordCrash):
crash #1 → needReload=false healthy=true
crash #2 → needReload=false healthy=true
crash #3 → needReload=true healthy=true ← 刚标完就自愈
## 影响
三处「插件不健康则跳过执行」全是**死分支**:
task.go:1065 stepToolBegin 中的跳过
task.go:1135 (上一提交刚补的那个分支的另一处同构)
toolbatch.go:155 并发批次中的跳过
即:一个正在崩溃循环的插件会被**持续调用**,每次调用再崩一次。
recordCrash 返回的 needReload=true 也没意义 —— 下一句 isHealthy 就说它健康了。
pendingReloads 用同一个零值字段 ⇒ autoReloadPlugins 每次都会尝试重载
**刚崩的**插件,很可能立刻再崩(崩溃循环)。
## 修法:冷却锚点改为 LastCrash
语义上「熔断」是"刚崩完先别用它,等 30s 再看" —— 观察窗口应锚在
**最近一次崩溃**,而不是"最近一次重载成功"。
isHealthy 锚点 LastReload → LastCrash
pendingReloads 锚点 LastReload → LastCrash
## 顺带删掉冗余
unhealthyPlugins() 零生产调用方(全仓 grep 只有定义本身)
record.FirstCrash 只写不读(写它的唯一目的是被下面这个判断用,
而那个判断早就改成只看 LastCrash 了)
## 判据(5 条,3 次变异全部实测变红)
TestCircuitBreakerActuallyOpens ★ 主判据
变异:锚点改回 LastReload ⇒ "★ 连续 3 次崩溃后插件仍被判健康"
TestCircuitBreakerWaitsForCooldownBeforeReload
变异:pendingReloads 锚点改回 LastReload ⇒
"★ 刚崩完就进了重载队列(应等冷却期):[p]"
TestCircuitBreakerRecoversAfterCooldown
变异:删掉恢复分支 ⇒ "★ 冷却期过后仍未恢复健康 ——
插件会被永久停用(比不熔断更糟)"
TestCircuitBreakerUnknownPluginIsHealthy 从未崩过的插件不记账
TestCircuitBreakerWindowResets 窗口外的旧崩溃不该累加
## 判据本身的两个坑(已避开)
① 只断言 recordCrash 的返回值 —— 它本来就返回 true,测了等于没测。
必须断言 **isHealthy 的结论**,那才是被熔断逻辑消费的东西。
② 不测"冷却之后会恢复" —— 那样把代码改成恒 return false 也能过,
而那就成了永久禁用插件(比不熔断更糟)。
## 连带:deniedpair_test 的前置条件改回真实状态机
上一提交里,第二条判据只能**手工置 Unhealthy**(因为 recordCrash 标完
立刻被自愈撤销)。现在熔断真生效了,改回用 recordCrash 打满阈值 ——
判据依赖公开状态机而不是私有字段。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestCircuitBreaker/TestDenied 全绿
|
2026-11-04 16:34:46 +08:00 |
|
|
|
c9be04fdac
|
fix(agent): 被拒绝/被跳过的工具没落 ToolResults —— call 与 result 失配
## 根因(4ab888c 的自检抓出来的)
4ab888c 在发送前加了配对自检,生产日志立刻指出真正来源:
★ dropped 1 unpaired tool message(s) before send (names: [qq_call_state])
顺着工具名查 context.json,脏帧是决定性的:
[42] tool_calls = [qq_call_state, output_send__qq]
tool_results = [output_send__qq] ← call_state 从未落盘
该帧自己的记录写了原因:qq_call_state 被 QQ 插件的来源权限策略拦下
(判定为"非所有者私聊")。
## 缺陷位置
stepToolBegin 的顺序是「先记 call,再判能不能执行」:
f.appendToolCallRecord(tc) // ← 先落 ToolCalls
if a.runStage(sdk.StageBeforeToolcall, …) {
f.Msgs 补一条 role=tool // ← 模型能看到
f.ToolIdx++
return outcomeContinue // ← 但没写 ToolResults
}
同一函数里还有一处同构分支(插件崩溃态跳过执行),同样只补 f.Msgs。
task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
两者都由 appendToolCallRecord 同步追加」—— 这个不变量在**提前返回路径**
上被违反了。
## 为什么这会变成会话级故障
ContextEvent{ToolCalls: 2, ToolResults: 1}
⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
⇒ 上游硬拒绝(HTTP 400):
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages…)
关键在于脏数据**持久化进 context.json**,之后每一轮都带着它,直到自检剔掉。
## 修法
新增 appendToolResultRecord(与 appendToolCallRecord 成对的公开入口),
在两处提前返回各补一行。
判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型确实
从 f.Msgs 看到了它。落 context 的那份必须与发给模型的一致。
并发路径(stepToolBatch)没有这个问题 —— runOneTool 只返回结果,
join 后的顺序收尾段无条件写 ToolResults(含被拒绝项)。
## 判据(2 条,2 次变异全部实测变红)
TestDeniedToolStillRecordsResult
注册一个真的会拒绝的 before_toolcall 钩子,跑完整一轮,
断言落进 context 的每个 call 都有 result,且拒绝原因文本一致。
变异:删掉拒绝分支那行 ⇒ "★ call call_deny_1 (denied_tool) 没有对应的
ToolResults"(实测红)
TestUnhealthyPluginSkipStillRecordsResult
钉住第二条同构分支。
变异:删掉跳过分支那行 ⇒ "★ 被跳过的工具 call_unhealthy_1 没落
ToolResults —— 同型失配,上游 400"(实测红)
两条都走真实链路(真的调 runStage / 真的置崩溃态),不是只测
appendToolResultRecord 这个函数本身。
## 顺带发现一个独立缺陷(未修,不在本次范围)
写第二条判据时发现:插件崩溃熔断**实际从不生效**。
recordCrash 标记 Unhealthy=true,但从不设置 LastReload
⇒ isHealthy 的冷却判据 time.Since(r.LastReload) > reloadCooldown
在 LastReload 为零值时恒为真(Since 是巨大的正数)
⇒ 判定"冷却期早已过去" ⇒ 立刻自愈
实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
紧接着 isHealthy 返回 true。
后果:task.go:1065/1135 的「插件崩溃态跳过执行」分支实际走不到。
这条应单独修并单独钉,本提交的判据用直达状态绕过它(注释已写明原因)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestDenied/TestUnhealthy 全绿
|
2026-11-04 16:24:49 +08:00 |
|
|
|
4ab888ce06
|
fix(agent): 发送前自检 tool_call 配对 —— 止损并定位上游 400
## 为什么还要修(前面两个修复都不够)
今天已经修了两处:
263b651 并发批次 stepToolBatch 漏记 ToolCalls
0ed3c89 L1 预算截断切开 tool 配对
并用生产 context.json 验证历史已干净:
53 个事件,0 个"结果多于调用"的帧
但 A2A **新会话**(a2a_1793778753015670668)仍然报同一个 400:
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following...)
同时日志显示 `ratio=0.28`(远低于 1.25 阈值)、`skip oversized frame` 计数 0
⇒ 既不是裁剪,也不是预算截断。
## 定位:问题在 f.Msgs,不在 L1 历史
`f.Msgs` 是**多条路径**拼出来的:
prepare 重建 buildMessages
中断恢复 rebaseFramePrefix(重建 prefix + 接回 tail)
批末附加 flushBatchAttachments
占位补全 dropContinuationPlaceholders / continuation
任何一条漏掉配对,最终都表现为同一个 400,而协议**不告诉你是哪条 call**。
所以把自检放在唯一一次组装 provider 请求的位置(stepLLM,task.go:750),
而不是散在各条路径上。
## 为什么剔 call 而不是补占位
补一条 "tool result: (missing)" 会让模型看到它**从未调用过**的结果,
可能据此继续推理 —— 那是编造事实。剔掉 call 只是少了一次尝试,
模型可以重来,语义安全。
被剔的 call 记录工具名与数量,日志里能看出是哪一轮出的问题。
## 判据(5 条,3 次变异全部实测变红)
DropsCallWithoutResult 主方向:没有结果的 call 必须剔
变异:直接 return msgs ⇒ "★ 未配对的 call c2 仍在消息里 —— 上游必然 400"
DropsOrphanToolResult 反方向:找不到 call 的孤立 tool 必须删
变异:删掉第二遍循环 ⇒ 红
DropsEmptyAssistantShell call 全剔且正文为空 ⇒ 整条删
变异:去掉该分支 ⇒ 红
KeepsContentWhenAllCallsDropped
call 全剔但**有正文**时保留正文(那是模型说的话,不能丢)
LeavesValidSequenceUntouched
合法序列必须逐条不变 —— 修正逻辑不能引入新问题
只测一个方向会放过另一半失配,所以两个方向都钉。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + TestDropUnpaired 全绿
## 下一步
这条是**止损 + 定位**,不是根因修复。部署后如果日志里出现
"dropped N unpaired tool message(s)",那 N 与工具名就指向真正漏配对的
那条路径,据此再修上游。
|
2026-11-04 15:58:22 +08:00 |
|
|
|
af17af5082
|
chore: 规划书不进仓库(与 plan.md / kernelplan.md 同惯例)
## 为什么
规划/设计在交接前会反复改。入库后有两个实际问题:
· 每改一次就是一条"改文档"提交,把真实代码提交埋掉
· 规划与实现必然漂移,读了仓库里的人会拿到过期结论
## 既有惯例
本仓早就是这样处理的,不是新立的规矩:
/plan.md 曾在 7901651「移除过程文档」被清出
/kernelplan.md 从未入库
两者都在 .gitignore 里,都只存在于工作区
## 做法
· docs/zh/dshplgadapter-plan.md 从索引移除(文件本身留在工作区)
· 挪到仓库根 dshplgadapter-plan.md,与 plan.md / kernelplan.md 同位置
· .gitignore 加 /dshplgadapter-plan.md,并写明理由
实现落地后,结论会进 docs/zh/ 下的正式设计文档 —— 那里才是该留档的地方。
## 影响
内核仓历史里 e8d07cd、82d08eb 两个提交只含这份规划书,现已从工作树移除。
如需追查当时的结论,git show e8d07cd 仍可读到(历史不重写)。
|
2026-11-04 15:54:56 +08:00 |
|
|
|
1b6cd57dca
|
fix(skillmgr): 内核旧版 skill 永不更新 —— 无 stamp 不等于"用户改过"
## 缺陷(实测,非推测)
给 kernel-plugins 补上 `skill_info(name, section=...)` 的用法说明后,
改了嵌入内容,但**生产上永远不会更新**:
/home/hologramagent/skills/kernel-plugins/ mtime 2026-10-26,无 .builtin-sha256
嵌入版本 1.1.0 vs 磁盘版本 1.0.0
`materializeBuiltinSkills` 的判断链是:
builtinSkillUnmodified(dir) → 读 .builtin-sha256
没有 stamp ⇒ false
⇒ continue("用户改过或无法确认,保留人的版本")
而 stamp 机制是 387ae94(今天早些时候)引入的。生产机上这份 skill
是**更早的内核**写的 —— 它没有 stamp,于是被永久判为"来历不明"。
后果不只是这一份文档:任何在 stamp 机制之前部署的机器,其内核自带
skill 都停在旧版。而 kernel-* 系列正是教模型怎么用工具/记忆/终端的
文档 —— 内核改进了文档,模型永远看不到。
## 修法:用 frontmatter version 区分两种"没有 stamp"
原判据把两件事混成一件:
用户改过措辞 ⇒ 必须保留
内核在 stamp 之前写的 ⇒ 应该升级
新增 builtinSkillOutdated(dir, embedded):只在**没有 stamp** 时被调用,
判据是磁盘 frontmatter 的 `version` 严格低于内嵌版本。
安全性分析(写在代码注释里):
人改措辞 通常不动 version ⇒ 不低于 ⇒ 保留 ✓
人把 version 改高 ⇒ 保留 ✓
内核旧版(无 stamp) ⇒ 升级 ✓(这正是要修的)
解析不出版本号 ⇒ 返回 false ⇒ 保留(保守)
误升级的唯一条件是"人把 version 改低",而那本身就意味着放弃内核版本。
原有的四条保守判据(含 NoStampIsConservative)全部保持绿。
## 判据抓到了我自己写的 bug
TestCompareVersion 立刻红了:
compareVersion("1.0","1.0.0")=-1,期望 0
原因:缺失段留成空串 ⇒ Atoi("") 失败 ⇒ 落到字符串比较 ⇒ `"" < "0"`
⇒ 版本号少写一段的 skill 会被无谓地重复升级。改为缺失段视为 "0"。
## 变异验证
builtinSkillOutdated 恒 return false(= 修复前行为)
⇒ TestMaterializeBuiltinSkills_UpgradesStaleKernelVersion 红:
"★ 没有 stamp 的内核旧版(version 0.0.1)没被升级"
compareVersion 缺失段改回空串
⇒ TestCompareVersion 红:"compareVersion(\"1.0\",\"1.0.0\")=-1,期望 0"
## 顺带:kernel-plugins v1.1.0
补一节「Reading a skill without burning context」,说明:
- 长文档返回目录 + 开头 30 行,目录每行带 (N 行)
- `section` 可按编号或标题子串取,大小写不敏感,容忍 `##` 前缀
- 顶层章节返回整棵子树
- 未命中会列出全部可用章节
- 章节边界尊重围栏代码块
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(worktree) build + skillmgr 全绿
(包数 63 → 62 是因 clawhubadapter 已在 a0077a2 迁出为外部插件,与本改动无关)
|
2026-11-04 15:50:39 +08:00 |
|
|
|
82d08ebc45
|
docs(plan): 修正 dshplgadapter 规划书的仓库引用 + 补交接须知
## 原稿的四类引用错误(交给别人会直接卡住)
① 说 clawhubadapter 在 internal/plugins/ —— 已迁出,那里没有了(a0077a2)
② 指向 /home/program/clawhubadapter-plugin/ —— 本机路径,别人打不开
③ 指向 /opt/agentmail/plugins/dsh-mail-bridge/ —— 本机生产拷贝
④ ★ 完全没提 deepseek-ai/deepseek-harness —— 漏掉唯一的 dsh 官方文档源
第 ④ 条最严重:dsh / dsh-tools / cordis 三个包的 package.json 的
repository 字段全指向那个 monorepo(公开,master,2026-11-03 仍在更新),
里面有 docs/cordis-tutorial/01..07 的**七步中文教程**、tool-execution-pipeline
(tools/execute 异步链的权威说明)、cordis-primer、api/、cookbook/ ——
正是阶段 1-3 的直接依据,原稿一条都没有。
## 现在的引用(逐个实测 HTTP 200)
github.com/JianFeeeee/HologramAgent 内核仓
github.com/JianFeeeee/hologramagentsdk SDK 仓
github.com/JianFeeeee/clawhubadapter 参考插件(原型)
github.com/deepseek-ai/deepseek-harness dsh 官方
github.com/QIKO0721/dsh-mail-bridge mail-bridge 公开镜像
只剩两处本机路径,且都写了适用条件、标明不是阻塞项
(dsh 的 ~/.dsh/profiles 固定布局;mail-bridge 的本机生产拷贝)。
## 新增 §9 交接须知
给没有本机环境、没有上下文的人:
· 9.1 需要的 6 样东西 + 哪些阻塞(只有 mail-bridge 一项依赖特定机器)
· 9.2 开工前的环境自检命令(含期望输出)
· 9.3 三条别再踩的坑(go build 不成立 / NewPluginFactory / stdout 只能 JSON)
· 9.5 一个前置决策:dsh 插件能跑任意 Node 代码 = 给本机执行权限,
必须在阶段 1 前决定,不能"先跑通再说"
· 9.6 已验证 vs 推测的诚实标注
9.6 是本轮最重要的补充。四条"推测"(裸 Context 承载插件、fd3 通道、
defineTool schema 无损转换、异步链不死锁)就是阶段 0 存在的理由 ——
不标出来,接手人会把推测当事实,直接跳到阶段 1。
## 结构校验
表格 0 处列数不一致;代码围栏 38 个(偶数,配对完整);766 行。
|
2026-11-04 15:49:40 +08:00 |
|
|
|
e8d07cd8a0
|
docs(plan): dshplgadapter 规划书 —— 按外部插件形态重写
## 方向
把 dsh(DeepSeek Harness)的 Cordis 插件生态接入 HologramAgent,
参考原型是刚迁出的 clawhubadapter(两者同构:兼容层 + sidecar + 转译注册)。
## 本文件在被推翻过一次,纠正记在正文里
初版结论是「必须做成内置插件」,依据是「外部插件发不了事件,所以
skill 移交只能靠概率」。该依据实测被推翻:
· 外部插件自带 skill 由 pluginmgr 在安装/启动时移交,与插件发不发事件无关
· clawhubadapter 已实际迁成外部插件并端到端跑通
所以 v2 改成外部插件,并把原因写进 §0 结论 2(保留纠错痕迹,
下次有人拿旧结论当依据时会看到为什么它不成立)。
## 内容的依据
全部来自实测,不凭记忆:
dsh 0.2.1-rc.2 / cordis 4.0.5 / dsh-tools 的 defineTool 签名
/opt/agentmail/plugins/dsh-mail-bridge/src/index.ts(2586 行生产代码,
是 dsh 插件侧 API 最可靠的文档)
third_party/hologramagentsdk 的 capability-boundary.md 与 proc_main.go.tmpl
(外部插件实际拿到什么能力,全在那一个文件里)
## 最该先做的一件事
不是写代码,是探针 0.1:验证 fd 3 做协议通道。
理由:dsh 生态插件大量用 console.log,而子进程 stdout 是 JSON-RPC 通道。
本仓为这个坑付过代价(生产累计 1038 条「非法 JSON 帧」报错,定位真因
花了一整轮)。clawhubadapter 靠「模拟器把 logger 接到 stderr」躲过去,
但那只管模拟器内的调用,插件直接 process.stdout.write 仍会漏。
这条不解决就不该进阶段 1。
|
2026-11-04 15:24:00 +08:00 |
|
|
|
a0077a2bad
|
refactor(plugin): clawhubadapter 迁出为外部插件
## 为什么迁
它是唯一还留在内核仓的「兼容层」插件,而它的全部工作都是对接**外部生态**
(ClawHub/OpenClaw 的包格式),跟内核版本无关。留在仓里有两个实际问题:
· 外部生态的新包格式跟进要走内核发版
· 它必须跟着内核一起重新编译,而它并不依赖内核内部实现
## 迁移改动(逻辑一行没动,只换接口形态)
package clawhubadapter → package main
init()+RegisterFactory → NewPluginFactory(proc 运行时按名调用)
internal/sdk → hologramagentsdk/sdk
internal/safeclose.Safe → 自写 safeClose[T](泛型覆盖 3 种 channel)
InjectInputSync(4 参) → InjectInputSync(3 参, 返回 string)
s.Events().Publish → 删除(外部插件拿不到,也不需要)
## 为什么删掉 Events().Publish 而不是找替代
原实现靠它把纯 SKILL 条目移交 skillmgr。查清后发现**不需要**:
pluginmgr 在安装与每次启动时扫 `<插件目录>/skill/` 自己发 skill_detected
(internal/plugins/pluginmgr/skill_handout.go)。外部插件带 skill 走
工程根 skill/ 目录,与插件能不能发事件是两件事。
链路上没有一行来自插件自身,实测日志:
[pluginmgr] clawhubadapter: handed off skill clawhub-tools to skillmgr
[skillmgr] registered via skill_detected event: clawhub-tools
[plugin] loaded SKILL: clawhub-tools v1.0.0
## 验证
迁移工程 /home/program/clawhubadapter-plugin/clawhubadapter
go vet / gofmt 干净
go test -race ok(20 条原有 + 4 条外部形态判据)
hmapdev build packaged clawhubadapter_bundle.hmap
变异验证 4 条判据全部真红
隔离实例端到端(真实安装路径,非模拟):
registering tool: clawhubadapter_* × 10
[plugin] loaded: clawhubadapter
[plugin] loaded SKILL: clawhub-tools v1.0.0
内核仓:go build / vet / gofmt / go test -race 全绿,62 包
## 生产切换的注意点
本 commit 只删源码,**不改生产**。生产 homed 里仍编着内置版;
要真切换必须先装 .hmap 再重编 homed,否则等于把这个插件弄没了。
|
2026-11-04 15:23:53 +08:00 |
|
|
|
67be114647
|
feat(skillmgr): skill_info 支持按章节读取 —— 长 SKILL 不再一次吞一万 token
## 问题
skill_info 此前**整份返回** SKILL.md。生产最坏一例:
commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token
模型为查「怎么开 PR」(§14,21 行)必须把 hmap 插件开发纪律(320 行)
和红牌一起读进上下文。一次误读一万 token,还挤掉了真正要用的历史。
拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了
"单文档内部按需读" —— 本提交补的就是后者。
## 改法
skill_info(name, section=...) 只返回匹配的章节。不传 section 时:
短文档(<=200 行) 直接返回全文(加目录只是噪音)
长文档(>200 行) 返回「目录 + 开头 30 行」,模型据此决定读哪节
目录每行都标 `(N 行)` —— 行数不是装饰,它是模型在"一次吞一万 token"
和"先读目录"之间做选择的唯一依据。
## 三个实现要点
① **切分必须感知围栏代码块**
SKILL.md 里有大量 shell 注释:
```bash
# 期望 200;401/403 = 凭据无效
curl -s ...
```
硬按 `#` 切会把一条 curl 示例拆成两半 —— 模型拿到的是无法执行的
碎片,比不给还糟。parseSections 跟踪 ``` / ~~~ 围栏,块内不切。
② **返回行号区间,不复制字符串**
章节内容的唯一事实来源仍是文件本身,目录与正文用同一套边界计算,
不会漂移成两份。
③ **点名顶层章节给整棵子树**
问 `## Setup` 时要的是 §11–§16 全部,不是只有标题那一行。
边界 = 下一个**同级或更浅**的标题。
## 判据(均经变异验证)
TestSectionsRebuildEqualsOriginal ★ 主判据
所有区间拼接必须逐字节等于原文
一次同时抓漏行 / 重行 / 错位三类缺陷
变异:end 改 heads[i+1].line-1 ⇒ "原文 16 行,拼回 13 行"(实测红)
TestSectionsShellCommentIsNotHeading
变异:删掉 fence 判断 ⇒ "代码块内的 shell 注释被当成标题:
\"期望 200;401/403 = 凭据无效\"(行 4)"(实测红)
TestResolveSectionReturnsWholeSubtree
变异:`<=` 改 `<` ⇒ "Setup 越界吃进了 Usage 的内容"(实测红)
TestResolveSectionToleratesHashPrefix
模型常把 `## Setup: git 操作` 整行传进来,必须容错
TestRenderTOCListsLevels
目录只列到 maxLevel,且必须带行数
## 判据本身最容易写废的地方(已避开)
① 用人造短文档测切分 —— 那种文档没有 shell 注释、没有嵌套层级、
没有围栏代码块,测过了不代表能切真实文件。
主判据**用生产那份 983 行的真实文件**。
② 只断言"切出 N 个章节" —— 数量对不代表边界对。
## 真实数据验证
文档 983 行 → 切出 46 个章节(## 顶层 7 个)
区间拼接 == 原文(逐字节)
section="审批" → 21 行(§16)
section="Setup" → 123 行(含 §11–§16 全部子节)
section="hmap" → 320 行
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(worktree) build + skillmgr 全绿
|
2026-11-04 15:06:04 +08:00 |
|
|
|
0ed3c894f5
|
fix(agent): L1 预算截断切开 tool 配对 —— 这才是上游 400 的真因
## 上一个提交修的是另一半,这是主因
263b651 修了"并发批次漏记 ToolCalls"(历史脏数据)。但**新会话仍然报同样的错**,
说明还有一条独立的路径在制造非法序列。
## 定位过程
① 先读内核自己写的日志(homed_<时间>.log):
overflow check #1: ratio=1.21 events=41 (threshold=1.25)
上下文**没触发裁剪**(1.21 < 1.25)——所以不是裁剪切断配对。
② 于是直接拿生产 context.json 的真实数据跑渲染,按 call_id 统计配对:
budget=480000 → 469 calls / 386 answered / **83 dangling**
budget=100000 → 469 calls / 40 answered / 429 dangling
**默认预算下就已经在切断。**
## 根因
renderL1Messages 的预算截断是**逐条消息**累加的:
for _, m := range msgs {
if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
return out // ← 可能切在 assistant(tool_calls) 之后、tool 之前
}
out = append(out, m)
}
`msgs` 是一组 [user?, assistant(tool_calls), tool, tool, ...]。`return` 落在
组中间 ⇒ 序列里留下一个永远等不到结果的 tool_calls ⇒ 上游 400。
这与 InactivePages 的约束是同一件事(那边注释已写着"剔掉 call 却留下 result
就是造一个必崩的帧")—— **整组要么全留,要么全丢**。
## 修法:以事件(消息组)为截断单位 + 跳过失控帧
① 组内完整:groupCost 一次算完,整组 append 或整组不 append
② 单组超过预算 1/4 视为**失控帧**(实测见过 468 个 tool_call 的单帧,
51 万 token),此时跳过它继续往后渲染 —— 否则它会把之后的全部
历史一起吞掉(实测 49 个事件只剩 8 个,上下文几乎清空)
② 的判据只看"这一组自己是否失控",**不看 out 是否为空**:
第一版写成 `len(out)==0 || groupCost*4 <= maxTokens`,于是失控帧恰好
排在第一位时仍然 return —— 判据实测抓到"渲染出 0 条消息"。
## 判据(均经变异验证)
TestRenderBudgetNeverSplitsToolPairing
扫 8 个预算档位,任何档位都不得有悬空调用
变异:改回逐条累加 ⇒ budget=50 时 "call ca0 无配对结果"(实测红)
TestRenderOversizedFrameDoesNotDropNewerHistory
失控帧之后的新对话必须仍然可见,且跳过时不产生悬空
变异:去掉"跳过"分支 ⇒ "渲染出 0 条消息"(实测红)
## 用生产真实数据验证修复效果
修复前:budget=358553 → 14 条消息(5 assistant / 1 tool / 8 user)
修复后:budget=358553 → 236 条消息(41 assistant / 147 tool / 48 user)
dangling=0,四个预算档位均为 0
即:不再有非法序列,且上下文不再被单帧吞空。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + TestRender* 全绿
|
2026-11-04 14:15:33 +08:00 |
|
|
|
263b651bf0
|
fix(agent): 并发批次漏记 ToolCalls —— 修上游 400 与 A2A 会话不可用
## 故障现象
生产 A2A 只回一句错误:
An assistant message with 'tool_calls' must be followed by tool messages
responding to each 'tool_call_id'. (insufficient tool messages following
tool_calls message)
所有 provider 全部失败,那条会话彻底不可用。
## 排查方法:读内核自己写的日志
内核每次启动写一份 homed_<时间>.log。在其中看到:
overflow check #1: ratio=1.21 events=41 (threshold=1.25)
上下文**没有**触发裁剪(1.21 < 1.25)——所以不是裁剪切断配对。
于是查 context.json 的实际数据,按 call_id 配对统计,发现 4 个事件
出现"结果多于调用":
[17] calls=18 results=20
[22] calls= 1 results= 3
[28] calls=14 results=20
[35] calls= 5 results= 7
## 根因
帧里有两条执行路径,各自记录:
stepToolBegin → appendToolCallRecord(tc) ← 记 ToolCalls + ToolsUsed
stepToolExec → append ToolResults (串行路径,两条都记)
stepToolBatch → append ToolResults (并发路径,只记结果!)
并发路径(stepToolBatch)**从不调 appendToolCallRecord**。
于是落进 ContextEvent 的数据里 ToolResults 多于 ToolCalls。渲染时
每条 result 都生成一条 role="tool" 消息,而配对的 assistant(tool_calls)
里没有这个 call_id ⇒ 上游 400 ⇒ 所有 provider 失败。
同时还漏了 ToolsUsed(它与 ToolCalls 在 appendToolCallRecord 里同处维护):
并发批次的工具从未被记进"用过的工具",记忆/统计/页化都在少算。
## 为什么不是本次内核升级引入
对比日志:ba8a33d(升级前)今天也有 3 次同样错误。它是既有缺陷,
只是并发批次越多越容易触发。本次排查顺带定位并修掉。
## 修法
在 join 之后的**顺序收尾段**补一行 appendToolCallRecord:
f.appendToolCallRecord(f.CurTool)
f.ToolResults = append(...)
为什么放这里而不是 fan-out 里:函数注释已明确 f.Msgs / f.ToolResults
在 goroutine 期间一律不碰。这里是按索引串行的收尾段,与单步路径语义一致。
## 判据 TestBatchContextEventPairsCallsWithResults
三条:调用记录齐全、每条结果有配对、每个调用有结果(双向)。
★ 已有 TestBatchToolCallIDsAllPaired 没抓到,因为它只看 f.Msgs ——
而 Msgs 里的 assistant 消息由 ensureBatchAssistant 单独写入(含全部
PendingTools),不经过 f.ToolCalls。所以 Msgs 侧配平、落盘侧不配平。
判据必须落在**要落盘的那份数据**上。
## 变异验证(两次才对)
第一次:删掉修复行 ⇒ 判据**没红**。原因:测试里的工具没声明
ParallelSafe,batchRunnable 判 false ⇒ 走串行路径(stepToolExec),
根本没测到目标代码。
第二次:给工具加 ParallelSafe + 断言 ConcurrencySafeOf 为真
(确保真的走并发),再删修复行 ⇒
★ f.ToolCalls 只有 0 个(map[]),期望 2 个
这才是有效验证。教训:**判据必须断言自己走到了目标路径**,
否则"删掉实现仍绿"会被误读成"实现不必要"。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + TestBatch* 全绿
|
2026-11-04 14:02:58 +08:00 |
|
|
|
a8ce5ece30
|
feat(agentcli): 补 skill 文档 —— 终端 vs 一次性命令的边界
## 为什么补这个
真实生产事故:agent 把 terminal_create 当成一次性命令执行器用。
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "=== 已安装的 dsh 包 ===" && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
三层成因:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run,需先 plugin_start cmd
② terminal_create 的 command 参数描述写着"要执行的命令",语义上像"跑一条命令"
③ 没有文档说清"一次性命令该用 cmd_run、终端是给要持续输入的程序用的"
①③ 已分别修掉(cmd-tools skill + 本文件)。② 是设计上的引力:
参数名叫 command、描述说"要执行的命令",模型自然会塞复合命令进去。
文档要正面抵消。
## 实现方式
按 seq 的既有模式:内置插件实现 plugin.SkillProvider,
由内核统一写盘/幂等/事件/停用清理(internal/plugin/builtin_skill.go)。
· 新增 internal/plugins/agentcli/skill.go
· BuiltinSkills() 交出正文,生命周期与插件同生共死
(禁用插件后 skill 还留着 = 模型看得到说明、点进去工具却不存在)
## 文档的核心:可操作判据,不是抽象词
一句判据:**跑完就结束 → cmd_run。要一直活着、要看屏幕、要按键 → terminal。**
关键的三条(缺任何一条模型都会退回旧行为):
· 点明 cmd 插件要 plugin_start(否则模型找不到 cmd_run 就用终端)
· 点明复合命令(cd && echo && ls)属于一次性,不是"所以要用终端"
· 解释 /dev/ptmx 那个真实报错的含义(命令跑完终端已退出),
否则模型会以为终端坏了
索引行(模型每轮唯一能看到的一行)也带边界:
"交互式终端(vim/ssh/top/长驻进程)。一次性的 shell 命令不要用终端,用 cmd_run。"
## 判据(均经变异验证)
TestSkillProviderContract 编译期断言 SkillProvider + 正文非空
TestSkillSeparatesTerminalFromOneShot ★ 六条必备内容缺一即红
TestSkillIndexLineStatesBoundary 索引行必须指出替代方案
TestSkillBodyFirstParagraphStandalone 正文首段(真正注入提示词的那段)能独立看懂
变异验证(实测):把整份文档换成"终端可以执行各种命令"的弱化版本
⇒ 3 条同时红。
★ 第一版变异是"只改一句话",判据没红 —— 因为 cmd_run 在文中另有 13 处。
那次变异无效,不算验证。改成整份替换后才是真验证。
## 端到端验证(独立实例 + 假 LLM 捕获真实请求)
[plugin] agentcli: installed skill "agentcli-terminal" -> .../skills/agentcli-terminal
[clawhubadapter] skill agentcli-terminal detected, handed off to skillmgr
实际注入到 system prompt 的 [Skills] 段:
agentcli-terminal v1.0.0 - 终端给**要持续输入**的程序用(vim、ssh、top、REPL、
要按键确认的安装器);一次性的 shell 命令用 **cmd_run**(cmd 插件,需…
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 判据全绿
独立实例实测 真机装盘 + 真机注入提示词(见上)
|
2026-11-04 13:22:07 +08:00 |
|
|
|
01c7c0145e
|
fix(kernel): 补上统一挂载点的生产接线 —— 上一版功能实际未生效
## 这个 commit 修的是我自己上一版的破绽
上一轮我报告"63 包全绿"。今天用户问「确定都可以正常工作」,
一查发现:**生产路径上根本没人调 kstate.Mount()**。
$ grep -rn 'kstate.Mount(' --include='*.go' internal/ cmd/ | grep -v _test
(空)
后果链:
kstate.Get() 永远返回 nil
⇒ agent/core 的 renderSystemPrompt 走 `if ks != nil` 跳过分支
⇒ 插件声明的系统提示词段**一条都不渲染**
⇒ 模型看不到任何 skill 索引,只能凭工具名猜用法
⇒ 正是本次重构要修的那个生产故障(拿 terminal_create 当 cmd_run)
而且**日志里一个字都没有** —— 静默跳过,不是报错。
## 为什么测试全绿
因为测试自己调了 `MountForTest`:
restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
t.Cleanup(restore)
判据把缺失的那根线补上了,所以永远绿。这正是本仓反复吃过的亏
(engineering-lessons §1.1)。
## 一共两个断点
① kernelboot.Run 没调 kstate.Mount(ks)
② sdk.SetSystemPromptAPI 生产上没人调
⇒ 插件的 s.SystemPrompt() 返回 nil
⇒ skillmgr.publishIndex() 对 nil 是静默 return(它不能 panic)
## 修复
① kernelboot.Run 装入挂载点(唯一入口)
② 补齐三段接线:
plugin.Registry 加 promptSections 字段 + SetPromptSections
kernelmod/pluginhost 从 ks.PromptSections 注入进去
internal/sdk 收到后调 SetSystemPromptAPI(promptSectionAdapter)
## 新增判据:**不自己 Mount**,只读 Run 之后的状态
TestKstateIsMountedByRun 源码判据(那行必须在)
TestPromptSectionsWiredToPlugins 三段接线逐个核对
TestSharedDataInjectionsHaveProducers 通用:注入点必须有生产者
TestRunActuallyMountsState ★ 端到端
最后一条是关键:它先 `MountForTest(nil)` **清空**挂载点,再跑真实
`kernelboot.Run` + 真实模块登记表,然后从底层包视角(kstate.Get())
检查能不能看到东西。它不能靠"测试自己挂"蒙混过关。
变异验证(实测):
· 删掉 kstate.Mount(ks) ⇒ 源码判据红 + 端到端判据红
「kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil」
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 端到端判据全绿
六环人工核对 ①Mount ②Run ③产出 ④注入 ⑤sdk ⑥skillmgr 各 1 处
## 教训
"全绿"只在**判据不自己补线**时才是证据。凡是"被测方需要某个前置
装配、而判据自己提供了它"的判据,都必须再配一条"从零开始"的
端到端版本。
|
2026-11-04 12:41:56 +08:00 |
|
|
|
8cc8bca380
|
test(kernel): 架构防腐判据 + 修 migrateEntry 的并发缺陷
## 判据(internal/kernelmod/all/layering_test.go)
六条,全部经变异验证:
TestCoreDoesNotImportKernelboot core 不依赖装配引擎
TestPluginDoesNotImportKernelboot plugin 不依赖装配引擎
TestSdkDoesNotImportKernelboot sdk 不依赖装配引擎
TestSecurityDoesNotImportKernelboot security 不依赖装配引擎
TestKstateDoesNotDependOnCore kstate 不依赖 core("core 能读挂载点"的另一面)
TestSecuritySingletonGuardStillExists 单例判据文件不得被删
TestAgentHasNoPromptSectionsField Agent 不得再持有提示词段字段
TestPluginSettersAreInjection 防过度收敛:plugin 不得读 kstate
TestPromptSectionsOnlyFromKstate 内核只从挂载点读插件段
用 `go list -deps` 而不是源码扫描:传递依赖能抓到间接环。
## 为什么需要"防过度收敛"这一条
kernelplan v1 曾主张把 plugin 的 12 个 SetXxx 改成"读 kstate"。实测做不到:
kstate.State.PluginRegistry 的类型是 *plugin.Registry
⇒ kstate 依赖 plugin
⇒ plugin import kstate 就是 import cycle(已用探针实证)
更重要的是——它们**本来就不是缺陷**:每个 Setter 只有一个调用点,
调用方持有 ks、显式传参;少了任何一个插件拿 nil 立刻报错,
不存在"静默降级"(那才是包级单例的病症)。
TestPluginSettersAreInjection 防的是后来者看到一堆 Setter 就想合并成
全局注册表,那会把"显式注入"退化成"隐式全局"。
## 顺带修一个真缺陷
migrateEntry 在**解锁之后**调 saveLocked()。而 saveLocked 遍历 s.data
序列化 —— 解锁后再调就是在读一个可能被并发改写的 map:
· data race(-race 可复现)
· 可能写出半更新的快照(部分条目是新密文、部分是旧密文)
函数名里的 Locked 就是要求"调用方已持锁"。改为持锁期内调用。
判据:go test ./internal/security/ -race 全绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
go test ./internal/security/ -race PASS
干净检出(git worktree) build + 判据全绿
## kernelplan.md 同步
T5 标记为"计划错误,已撤销",并记录实测依据 —— 避免下次又有人
想"顺手收敛掉"这十几个 Setter。
|
2026-11-04 12:28:43 +08:00 |
|
|
|
5bdd0b5107
|
refactor(security): Store 复用 Codec 加密能力 + 删 globalCodec 单例
## 用户裁定
> store也应该调用security的加密能力!
## 改动前的事实(实测)
两套**完全重复**的 AES-256-GCM:
Store.seal/open store.go:372,388 裸 base64
Codec.Seal/Open codec.go:113,139 enc:v1: 前缀 + 域隔离 + 幂等
算法、nonce 布局(前置 + io.ReadFull(rand))、密钥环轮换逻辑全都一样,
只有 Codec 多了前缀与域隔离。
危害不是"代码重复"这么轻:
· 两套实现各自演化,任何一侧的改动不会被另一侧的判据发现
· Store 缺域隔离 ⇒ 变量库与配置共用密钥(安全倒退)
· Store 缺前缀 ⇒ 无法区分"旧密文"与"明文"(惰性迁移的障碍)
## 本次做的
### Store 委托 Codec
codecOf() 按当前 keys 环惰性构建(轮换后自动跟上)
sealWithRing() 新格式(enc:v1: 前缀)
openWithStoreRing() 兼容新旧两种格式,返回 needsMigrate
删除 Store 自带的 seal/open/openWithRing —— 现在**只有一处** AES-GCM。
### 域隔离(安全判据)
storeCodecDomain = "security/variables"
configCodecDomain = "config-at-rest"
两者必须不同:同域时一处明文泄漏可推导另一处密文。
### 存量密文惰性迁移
旧格式是裸 base64 无前缀,而 Codec.Open 对无前缀输入**原样返回明文**
(那是给 config 的存量明文兼容)。直接交给 Open 会把密文当明文返回,
症状是"凭据回填出一个 base64 串"。
所以 Store 走 openWithStoreRing:
enc:v1:... → Codec.Open
裸 base64 → Codec.OpenLegacy(解开后立刻回写新格式)
不做启动时全量迁移(21G 数据目录的教训:迁移窗口越长越危险),
也不让两种格式永久共存(判据永不能要求"落盘都是新格式")。
### 删 globalCodec 单例 → 归 Runtime
security 模块 → rt.SetCodec(c) → ks.Security
config 模块 → cfgReg.SetCodec(ks.Security.Codec())
ConfigRegistry 加 codec 字段(注入而非读全局),6 处 ConfigCodec() 改为
r.codecOf();PluginSettings 经 p.registry 转调。
sdk.SecretsAPI 也加 codec 参数(插件加密能力来自它的内核实例)。
### 顺带
· InitConfigCodec* / ClearConfigCodec 删除,改 NewConfigCodec 返回实例
· homed-key-rotate 改为 构造 + reg.SetCodec(不再装全局)
· bootstrap_order_test 的锚点从 InitConfigCodecWithOldKeys 改为
NewConfigCodec(守的不变量不变,只是生效点写法变了)
## 判据(均经变异验证)
TestNoPackageLevelSingletons 禁表补入 globalCodec
TestStoreWritesPrefixedCiphertext 新密文必须带 enc:v1:(旧实现裸 base64 ⇒ 红)
TestStoreAndConfigUseDifferentDomains 域隔离;改成同域 ⇒ 红(实测确认)
TestLegacyCiphertextStillReadableAndMigrates 旧格式可读 + 落盘已升级
TestRotationKeepsOldCiphertextReadable 轮换后旧条目仍可回填
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + security/config 测试全绿
## 数据风险
加密实现切换有丢数据风险,因此:
· 先写往返判据,再改实现
· 惰性迁移(读到才升级),不做启动全量重写
· 旧格式与旧密钥环**都保留**读取路径
|
2026-11-04 12:22:08 +08:00 |
|
|
|
47afd67ca1
|
refactor(security): 删除三份包级单例,共享数据统一挂 kstate
## 用户裁定
> secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的
> 挂载点上暴露。
> store也应该调用security的加密能力!
## 缺陷
security 包原有**三份**独立包级单例,各自被 InitXxx/SetXxx 装入、
各自有访问器,三份都绕开了内核统一挂载点 kstate.State:
global *Store 变量库(凭据别名) Global()
runtime *Runtime 安全防火墙 RuntimeGlobal()
globalCodec *Codec 配置落盘加密 ConfigCodec()
## 为什么单例是错的(不是"多一个全局变量")
本 agent 的 a.security 与 security.Global() 是**两个独立变量**。
正常装配下它们恰好指向同一份数据,但"恰好"不是保证:
· 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的与跑的不是同一份
· 多 Runtime:两个内核实例/热重载时单例只有一份,另一方被覆盖
· 装配顺序:单例装晚了,先注册的 provider 就没被装饰
第三条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器,
而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。
改动过程中判据如实报红 640 次泄漏。
## 本次做的(T3.1 + T3.3)
### 删 global *Store → 注入
变量库的唯一来源是 security 模块产出的 Runtime,经注入面分发:
AgentConfig.Security → a.security.Store() → a.secretStore()
ProviderManager.SetSecretStore
plugin.Registry.SetSecretStore → sdk.NewSecretsAPI(store)
core 的 4 处 security.Global()(output/toolcall/tooldefs/secret_tools)
改为 a.secretStore();context 与 stageHost 改为注入字段;
provider 装饰器改为读注入的 Store。
### 删 runtime *Runtime → 注入
plugin.Registry.SetSecurityRuntime → sdk.NewSecurityAPI(name, rt)
→ sdk.newEventsAPI(name, bus, rt)
Prepare 的幂等缓存改为 kernelmod/security 私有变量 ——
装配器私有,不再是任何包都能取的公开访问器。
### 顺带删死代码
security.FillToolArgs / FillPayload 生产调用者为 0(StageHost 已自己
用注入的 Store 做同一件事),连同只测它们的判据一起删除。
### llm 模块补 FacSecurity 依赖
这是**真实存在**的依赖:注册 provider 时要套出向装饰器,而装饰器需要
变量库。此前靠"单例先装了"掩盖了顺序错误。
## 判据(均经变异验证)
TestNoPackageLevelSingletons 源码扫描;加回 var global ⇒ 红(实测)
TestMustStoreDoesNotRegisterGlobally 两次 MustStore 必须得到不同实例
TestStoreEncryptsWithOwnKey 落盘无明文 + 重载可解
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 3 包测试全绿
## 未做(见 kernelplan.md T3.2)
globalCodec 收敛 + Store 复用 Codec
还牵涉 config 的 6 处调用点与**存量密文惰性迁移**,单独一步做。
TestNoPackageLevelSingletons 的禁表暂不含 globalCodec ——
半成品状态长期红的判据会被人习惯性忽略,那时它就等于不存在。
|
2026-11-04 11:57:48 +08:00 |
|
|
|
cc0eb51286
|
refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
|
2026-11-04 11:23:05 +08:00 |
|
|
|
387ae945df
|
fix(agent): 子 agent 继承技能索引 —— 修 agent 把终端当命令执行器用
## 缺陷(生产实测)
`SetSkillIndexProvider` 只在 cmd/homed/bootstrap.go 里对**根 agent** 调了一次。
rtchild / 驻留子各自 `New(AgentConfig{...})` 构造载体,都没传这个字段 ⇒
子的 system prompt 里**完全没有 skill 索引**。
症状(日志实录):A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有
`cmd_run`(cmd 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着
「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create` 当成
命令执行器:每条 `cd … && echo … && grep …` 开一个新终端,终端随命令结束退出,
`terminal_read` 立刻撞 `read /dev/ptmx: input/output error`,然后为下一条命令
再开一个 —— 终端列表被填满,每一条都死在半路。
与本仓 Security 那次同型:父有的能力,子没继承。
## 修法
rtchild 与驻留子的 AgentConfig 各补一行 `SkillIndexProvider: a.skillIndex`,
与 `SystemPrompt` / `StageHost` / `Security` 同一组「必须继承父」的字段。
## 顺带两件事
### 1. 内置 skill 的更新永远到不了已部署机器
原落盘规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件:
用户改过的版本 ⇒ 应该保留
内核自己写过的旧版 ⇒ 应该随内核更新
两者都表现为「目录已存在」,于是 b850fea 把 `memory_recall` 改成
`stablemem_recall` 后,生产机的 kernel-memory 一直停在旧名,模型照着
旧名调工具必然失败,而日志里一个字都没有。
改为用 `.builtin-sha256` 摘要区分:
当前内容 == stamp ⇒ 没人动过 ⇒ 可安全覆盖成新版
当前内容 != stamp ⇒ 用户改过 ⇒ 保留
没有 stamp(老目录)⇒ 保守视为用户版本,不覆盖
### 2. 新增 kernel-terminal skill
工具描述只说「适用于交互式程序」,没说何时**不该**用;`command` 参数又鼓励
传整条命令。补一条内置 skill 讲清 cmd_run 与 terminal_create 的边界、
以及终端的 input/key/notify 用法。描述控制在 80 字符内(进索引不截断)。
## 判据(均经变异验证)
TestRTChild_InheritsSkillIndex 删掉接线 ⇒ 红(实测确认)
TestResident_InheritsSkillIndex 同上(生产实际中招的那一类)
TestRootAgent_SkillIndexStillInjected 确认没改坏根 agent 路径
TestChildWithoutSkillIndexIsNotBroken 父无索引时子不 panic
TestMaterializeBuiltinSkills_* 5 条:覆盖语义四条成组断言
(改回旧行为 ⇒ OverwritesUnmodified 红,实测确认)
判据测的是**生成的 prompt**,不是 `skillIndex != nil`:
后者接上了但注入那段被删照样绿。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
干净检出(git worktree)单独验证 PASS
|
2026-11-04 10:17:45 +08:00 |
|
|
|
ba8a33dad8
|
fix(agentcli): 终端默认不推送 + input/key 可同传 + 失效终端结构化答复
## 一、默认不主动推送(用户裁定)
「agentcli 没必要进行直接推送,因为本质它就类似人类开启的终端窗口」
终端窗口的语义是:人敲命令 → 自己去看输出。输出不该主动跳到眼前。
主动注入虽然走 inputCh(排队、不抢占),但**每次都会唤醒 agent 跑一轮
推理**;`tail -f` / `npm install` 这类长输出命令会让它被反复唤醒。
改动:
- terminal_create 默认 watch 从 `{onExit:true}` 改为 `{onExit:false, quiet:true}`
- 结束/退出/超时清理一律静默,输出只由 terminal_read 主动读
- 需要推送必须**显式**声明 notify=exit / interval=Ns / buffer=N
- terminal_watch 的 clear 语义改为「回到默认(不推送)」,
不再自动恢复 exit 提醒
- 删掉「没配 interval/buffer/quiet 就自动 onExit」的兜底(它会让默认
静默被悄悄推翻)
## 二、input 与 key 可以同时提交(真 bug)
旧实现是互斥结构:
if key != "" { ... } else if input != "" { ... }
于是 `{"input":"ls","key":"enter"}` 只发出 `\r`,**input 被静默丢弃**。
模型收到 status=ok / bytes=1 以为成功,去读输出却是空的,于是反复重试
——这就是「终端没反应」的直接成因。
现在按声明顺序发送:先 input,再 key。整对写入在同一把会话锁
(新增 TerminalSession.writeMu)内完成,避免同终端并发写插进中间把命令拆坏。
## 三、失效终端给结构化答复
「终端不存在或已关闭」从一句文本改为:
{"error":"terminal_gone","terminal":"...","next_action":"调用 terminal_create 重建..."}
原因与 browser 插件同形态:只给普通失败时,模型会拿同一个 id 反复重试。
覆盖 terminal_write/read/resize/close/watch 五处。
## 四、通道注册不再吞错
- _ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
+ if err := s.RegisterInputChannel(...); err != nil { return fmt.Errorf(...) }
注册失败时该通道根本不存在,之后所有 InjectText 都投进虚空 ——
通知静默消失且日志无痕。宁可启动失败也不静默降级。
## 判据(均已变异验证可红)
- TestWriteSendsInputThenKey 断言 input 与 \r 两段都真的写进终端
- TestWriteInputOnlyUnchanged 只传 input 时不额外发键
- TestTerminalCreateDefaultsToNoPush 默认 watch 必须不推送
- TestExplicitExitNotifyStillWorks 显式 notify=exit 仍然生效
- TestWatchClearRestoresNoPush clear 回到默认而非 exit
- TestTerminalGoneIsStructured 五个工具都返回 terminal_gone + next_action
变异验证:
- 默认改回 onExit:true → TestTerminalCreateDefaultsToNoPush 红
- input 分支不发送 → TestWriteSendsInputThenKey 红(实际 1 次: ["\r"])
- 错误码改成其它文本 → TestTerminalGoneIsStructured 红
## 验证
go build ./... → 0
go vet ./... → 0
gofmt -l internal/plugins/agentcli/ → 干净
go test ./... -count=1 -race → 62 ok / 0 FAIL / 0 DATA RACE
|
2026-11-04 09:51:22 +08:00 |
|
|
|
2b2707cb89
|
test(agent): 让 rt 跨进程 e2e 真正跑起来 + 策略撤销改为行为判据
## 一、e2e 一直 SKIP,等于从未验证
`TestExternalRTChild_*` 4 条判据长期 SKIP(缺插件产物)。本次用
`third_party/hologramagentsdk/example/rtvoice/build/plugin.bin`
实跑,立刻暴露两处**测试与插件不同步**:
rtvoice_inject 插件已改名为 <name>_say ⇒ 注入失败
rtvoice_dump 插件从来没有这个工具 ⇒ 观测失败
改名发生在插件侧,而判据长期不运行,所以没人发现。这正是
"SKIP 判据等于没有判据"的实证。
修法:
· 注入改用 `rtvoice-e2e_say`(插件当前唯一调试入口,走与麦克风
完全相同的 p.Push → InjectInterruptTextOpts 路径)
· 输出观测改为对 outputch 设备做一次**跨进程 RPC**(channelDevice
句柄 → 插件进程 → 拿回执),不依赖插件内部私有状态
## 二、策略撤销判据从源码扫描改为行为断言
`TestChildTeardownRevokesPolicy` 原先扫源码文本,找停止函数体里有没有
`revokeChildPolicy`。R2/R3 把撤销移进骨架 Cleanup 后文本匹配失效 ——
但行为是对的,判据自己变红。
这暴露出它抓不住的真实风险:`StopDeps.RevokePolicy` 是**注入**的,
漏填时 Cleanup 里的 nil 检查会静默跳过,而字符串仍在别处,源码扫描照样绿。
现在分两层:
· kernelmod/all 保留源码断言,但钉**两个环节成对**:
① Cleanup 真的调用 deps.RevokePolicy
② 各类型真的注入它
并增加"单数与复数必须共用骨架 Stop"的结构护栏
· agent/core 新增行为判据 childpolicy_test.go:
声明策略 → 走真实停止路径 → 主体表里必须消失
## 三、验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./... -count=1 62 包全过,0 FAIL
go test ./... -count=1 -race 0 FAIL
rt 跨进程 e2e(4 条,不再 SKIP):
TestExternalRTChild_FullChain PASS(rtchild 跑了 1 轮)
TestExternalRTChild_ReuseDoesNotLeakTranscript PASS
TestExternalRTChild_BulkStopUnregistersInbound PASS
TestExternalRTChild_RepeatedCyclesDoNotAccumulate PASS
变异验证:断开 routeInputByOwner 的 rt 分支 ⇒
FullChain 立即红,并准确报出"输入没有到达 rtSlot"
干净检出(git worktree)单独 build + vet + test + e2e PASS
|
2026-11-04 08:56:47 +08:00 |
|
|
|
637a3a3d3f
|
refactor(agent): R4 工作式子语义落地 + 清除 R1–R3 遗留的死代码
## 1. 工作式子的工具禁令收成单一事实来源
禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧
(模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口:
定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**
现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释"
绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject
都从它派生。
上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接,
属于新造的技术债,已重写为单表直查。
判据(均经变异验证):
TestWorkerToolDenyListIsSingleSource 长出第二份名单 ⇒ 红(实测确认)
TestWorkerToolAllowedMatchesReject 两侧判据漂移 ⇒ 红
TestWorkerToolRejectExplainsWhy 拒绝文案不可执行 ⇒ 红
## 2. 删除零使用的抽象(不留技术债)
R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec /
ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound /
ChannelDisposition,实现过程中发现它们是假抽象:
· 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent
· 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放
· 实测:26 个导出符号里 20 个零使用、零实现
已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型"
这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。
child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry /
StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。
## 3. 修掉误导性签名
Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** ——
残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就
已经完成。看签名会以为这里处理残余,是误导。已去掉该参数,
并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l 干净
go test ./internal/agent/core/ -count=1 ok 15.300s
go test ./internal/agent/core/ -count=1 -race ok 18.040s
干净检出(git worktree)单独 build + vet + test PASS
|
2026-11-04 08:35:23 +08:00 |
|
|
|
a02feaaff7
|
refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。
## 1. 抽出 child 包(类型抽象 + 单一登记表)
新增 internal/agent/core/child/:
child.go Kind / Child(嵌入用)/ State / 残余与通道处置
registry.go 单一 Registry + Entry 接口 + Cleanup 骨架
kind.go ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
kinds.go 四种子 agent 的清单与迁移状态
此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。
现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。
## 2. rt 的输出改为异步(用户的原始诉求)
rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。
新增 outputpump.go(输出通道的异步壳):
· Push 立即返回,交付由独立消费 goroutine 完成
· 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
· 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
· Close 排空后退出,不吞最后半句
· 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)
## 3. 工具钩子统一(本轮最大发现)
实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。
⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。
新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。
## 判据(均经变异验证)
TestRTChild_OutputIsAsync 改回同步 ⇒ 红(实测确认)
TestOutputPump_DropsOldestOnFull 改阻塞/无界 ⇒ 红
TestOutputPump_CloseDrains 丢弃队列 ⇒ 红
TestOutputPump_ResolvesDeviceEachTime 缓存指针 ⇒ 红
TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
TestRTChild_StageCanDenyTool 同上
TestWorkerToolsGoThroughPluginStages 同上(变异实测确认)
TestWorkerToolsStaySerial 改成并发 ⇒ 红
TestNoChildTypeTablesInCore 源码扫描,禁类型专属表回潮
TestRTChild_HasGraphMemory 等 5 条 rt 接入图记忆
## 顺带修复
· rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
· rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。
## 保留的语义(逐项确认仍在)
驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
三种通道处置:ChannelRelease / Suspend / Takeover
残余 keep/drop + harvestResidentMemory
child.Start() 的 7 个 goroutine
验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
|
2026-11-04 08:21:15 +08:00 |
|
|
|
24b03e56e9
|
fix(memory): L1 恢复为 provider 原生消息序列
## 设计口径(用户裁定,2026-11-03)
「我的设计是 l1 渲染出来就是传统式的上下文排列,
难怪指代消解能力这么差」
## 之前错在哪
buildMessages 把**全部历史事件拼成一条 system 消息**:
system: 系统提示词
system: [Conversation timeline]
[15:04:05] 用户: 帮我找月租<400的房子
[15:04:05] 助手: ...
[15:04:05] 工具(qq_get_message): ...
user: 当前输入
说话人身份靠**文本前缀**表达。模型对它拿到的是"背景资料"式的注意力
结构,而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上
上一句"帮我找月租<400…")。
★ 0f98f36 那次只改了**文本渲染格式**(把日志样式换成对话样式),
底层"一条 system 承载全部历史"的结构一动没动,所以问题还在。
## 现在的形态
system 系统提示词
user 帮我找一套月租<400 水电常价带独卫的单间
assistant 好的,我去找。
user 新乡
assistant 新乡这边有几套。
user 当前输入
工具轮按协议展开:
user 帮我看看天气
assistant (content + tool_calls=[c1,c2])
tool (tool_call_id=c1) 晴 26 度
tool (tool_call_id=c2) 他说新乡
user 继续
实现要点:
- 新增 renderL1Messages:一个 ContextEvent 展开为
user → assistant(calls) → tool... 消息;
- tool_call 与 tool 按 CallID 严格配对(CallID 优先,缺失退化到工具名);
- InactivePages 剔除**完整的工具页**:assistant 上对应的 tool_call 与
紧随的 tool 消息一起消失,绝不留下"有 call 无 result"(上游必 400);
- 超长工具结果仍渲染为句柄(toolresult_read 取回);
- 保留 timelineUserText 的中断轮处理:从聊天工具回执里取真实原话,
不让注入样板冒充用户发言;
- 保留 token 预算裁剪,事件级按消息粒度计入。
## 清理(无生产调用方的旧渲染路径)
删除:
- formatMergedTimeline(旧 system-timeline 唯一生产者)
- renderedToolResultsOf(旧截断风格渲染,已被原生序列取代)
- timelineToolLines(仅上面两者使用)
- timelineUserLabel / timelineAssistantLabel
- toolResultTimelineChars / toolResultTimelineCharsChat /
toolResultTimelineMax / isChatCorpusTool
删除对应已失效判据:timeline_contract_test.go、timeline_toolresult_test.go
保留 toolResultPageIndex:overflow.go 的 eventTokens 仍在用,
且统计侧与渲染侧必须共用同一份配对规则(否则"渲染少了、统计没少"
会让裁剪永远裁不动,2026-11-02 实测事故)。
## 判据(新增,均已变异验证)
l1_native_sequence_test.go:
- TestL1RendersNativeMessageSequence
断言 role 序列 system/user/assistant/user/assistant/user,
且 system 消息中不得再出现 "[Conversation timeline]" 或 "用户:"
- TestL1ExpandsToolCallsAsPairedMessages
断言 assistant.ToolCalls[i].ID 与紧随 tool.ToolCallID 一一配对
- TestL1InactivePageDropsWholePair
剔除页时 call 与 result 必须同时消失
变异验证:把实现退回旧 system-timeline ⇒ 三条全红,恢复后全绿。
## 验证
go build ./... → 0
go vet ./... → 0
gofmt -l internal/ → 干净
go test ./... -count=1 -race → 62 ok / 0 FAIL / 0 DATA RACE
|
2026-11-03 14:37:53 +08:00 |
|
|
|
81a0f5f598
|
feat(security): 插件按工具声明内容策略 + 目录名统一
## 一、插件声明层(用户裁定)
> 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
> 敏感词策略」
SecurityProfile 新增两个字段:
ToolSensitiveWords map[string][]string // key=工具名模式(尾 *),value=词表
DetectInjection bool // 开启内核内置注入迹象识别
语义是**追加不覆盖**:整轮 SensitiveWords 仍然生效。
取宽不取窄的理由:当前处置是 B 档(标记+警示,不阻断),
多标一条只是多一句警示,漏标则是安全缺口。
## 二、修掉我自己造的「莲藕式分层」
前一版把 *security.Round 从 runInputTask 一路搬到工具链
(prepareInputTask(evt, round) → TaskFrame.SecRound →
executeToolCallOutcomeWithRound),并在 rtchild 里另补一条。
那是把**分层策略实现成跨层传参**:每多一条工具执行路径就要求调用方
多传一次句柄。profileActivator 的设计注释早就写明了正路:
> 工具调用发生在 LLM 决定之后,此时插件已不在调用栈上。
> 唯一可靠的传递方式是**内核在输入进来时记住**,在该轮的工具判定里使用。
改为 security 自取:
Runtime.ScanContentForAgent(agentID, isChild, ins, text)
→ ForAgent(agentID, isChild) // per-agent 视图,天然不串档
→ EffectiveProfile() // session > longtime
→ 合并 全局词表 + 档位词表 + 工具词表 + DetectInjection
core 只递交**事实**(我是谁 / 哪个工具 / 什么文本),不认识 Round。
与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
TaskFrame.SecRound / executeToolCallOutcomeWithRound 全部撤除。
## 三、目录名统一
third_party/homeagent-sdk → third_party/hologramagentsdk
module 与远端仓早已是 hologramagentsdk,只剩物理目录是遗留名。
同步改了 go.mod replace、构建/打包/部署脚本、5 处测试里的分段路径。
注意:这里改的是**主仓 vendored 的 SDK 源码路径**,example/ 由 SDK
独立仓自己跟踪,主仓不涉及。
## 判据
TestToolSensitiveWordsAppendOnlyForThatTool 词表只对目标工具生效 + 追加语义
TestToolSensitiveWordsUseAgentScope security 按 agent scope 自取生效
(变异验证:删掉 ForAgent 查询 → 红)
## 验证
go build ./... → 0
go vet ./... → 0
go test ./... -count=1 -race → 62 ok / 0 FAIL / 0 DATA RACE
gofmt -l internal/ → 干净
|
2026-11-03 09:51:11 +08:00 |
|
|
|
9ab19f7493
|
test(security): cover per-tool sensitive word append semantics
|
2026-11-03 09:20:21 +08:00 |
|
|
|
ef9368e5b8
|
feat(security): add plugin-declared per-tool content policy
|
2026-11-03 09:20:04 +08:00 |
|
|
|
43eaa17b41
|
fix(agent): 补齐 registerToolCancel 的定义——修复 HEAD 不可编译
## 我造成的破坏
上一个提交 e6023e1 里,我用 `git add internal/agent/core/toolcall.go`
(整文件)时,把工作区中**别人尚未提交**的两行调用一起暂存了:
toolCancelID := a.registerToolCancel(cancel)
defer a.unregisterToolCancel(toolCancelID)
而这两个方法与它们依赖的字段(cancelTools/toolSeq/toolMu)留在了
agent.go / eventloop.go 里没被提交。
⇒ **e6023e1 单独 checkout 编译不过**:
internal/agent/core/toolcall.go:234:20:
a.registerToolCancel undefined (type *Agent has no field or method registerToolCancel)
已用 git worktree 在 e6023e1 上实测确认,不是推测。
## 为什么这违反了模块化设计
按本仓的模块化设计,`eventloop.go` 里的 registerToolCancel 与 `agent.go`
的字段是**一个内聚单元**,必须同批提交。把它们与 toolcall.go 的调用拆散,
就等于交付了一个引用不存在的符号 —— 构建的可 bisect 性被破坏。
★ 根因是我的提交操作,不是设计问题:我用整文件 add 代替了只暂存自己的
hunk。当时确实检查过 toolcall.go「只有我的改动」,但只 grep 了
`cancelRunningTools|cancelTool`,漏掉了 `registerToolCancel` ——
**检查用了错误的模式串,等于没检查**。
## 本次提交
把那个内聚单元的完整改动补齐(这是「停止键能取消正在跑的工具」特性):
- `agent.go`:cancelTools / toolSeq / toolMu 字段
- `eventloop.go`:registerToolCancel / unregisterToolCancel / cancelRunningTools
以及 stop 分支的调用点
- `stop_cancels_tool_test.go`:该特性的判据
特性说明见字段与方法上的注释(原始作者撰写)。
## 验证
go build ./... → 0
go test -run TestStopCancelsRunningTool → PASS
go test ./... -count=1 -race → 见下一条
|
2026-11-03 08:46:46 +08:00 |
|
|
|
b2079fea0a
|
docs(security): 内容审查接线已结案,同步两张状态表
- kernel-secret-firewall-design.md 接线状态表:ScanContent 一行
从 🔴「配置里的敏感词一条不生效」改为 ✅ 2026-11-02 接通,
并写明两处落点与 B 档语义(标记+警示,不阻断)。
- future-plan.md §一 汇总表第 4 行同步为 ✅。
- §1.2 改写为「已结案」,保留两条教训:
① 第一版让 agent 自己解析档位 —— 正是 §1.1 收敛掉的第 2 层毛病,
盲试两轮才回去读设计文档;设计里早就写清了落点。
② 最初 6 条判据只测辅助函数,删掉 task.go 的调用仍全绿 ——
与「实现完整、判据绿、生产零调用」的原始缺陷形态一模一样。
补了走真实入口 runInputTask 的那条才真的变红。
仍零生产调用方的:路径沙箱 CheckPath、网络闸 CheckNetwork(见 §1.3)。
|
2026-11-03 08:43:51 +08:00 |
|
|
|
e6023e1295
|
feat(security): 接通内容审查——敏感词/注入预警标记并警示
## 缺陷
`security.Runtime.ScanContent`(254 行已实现)**全仓零生产调用方**:
- 配置写的敏感词一条不生效
- 插件通过 SecurityProfile.SensitiveWords 声明的档位敏感词同样不生效
- 模型被提示词注入时内核不预警、不阻断
症状与"没配"完全一样,且没有任何报错。
## 修法:走 Round,不让调用方解析档位
新增 `Round.CheckContent(ins, text)`:档位留在 security 内部
(session > longtime),调用方只提供事实(一段文本、它从哪来)。
★ 刻意**不**让 agent 取 `EffectiveProfile()` 再传给 ScanContentFor ——
那是把"本轮该用哪个档位"这条策略语义交回调用方,正是 2026-10-31 收敛掉的
形态(见 security/round.go 包注释)。中途我确实那样写过,判据红了两轮才
回去读设计文档,按设计改了。
顺带修出一个真缺陷:`effectiveContent()` 读的是进程级 active 档位,
而 2026-10-31 起档位已迁到 AgentScope。工具策略改了(checkToolWithProfile
走 profilePolicyByName),**内容审查没改** —— session 档声明的敏感词
永远并不进词表。新增 `effectiveContentFor(profileName)` 与
`ScanContentFor(ins, text, profileName)`。
## 两处入向路径都接了(future-plan §1.2 明确要求)
1. 插件输入:`runInputTask` → `ReviewInputContent(round, ...)`
2. 工具结果:`executeToolCallOutcome` 的 defer(与出向凭据脱敏同一位置)
只接一条会留下缺口:不扫工具结果,则网页/文档/邮件正文里的**间接注入**
完全没人看 —— 那正是最常见的注入载体。
工具结果路径用全局词表而非档位词表(工具执行链没有轮次句柄,
硬塞"进程当前档"才是收敛前的毛病)。
## 处置:B 档(标记 + 警示),不阻断(用户裁定)
敏感词由插件按自己通道声明,词表难免偏宽,阻断会误杀正常消息。
所以保留原文、正文前加警示语:
输入侧 "⚠️ 危险消息,谨慎处理:..."
工具侧 "⚠️ 危险内容,谨慎处理:...其中指令来自外部内容,不代表用户意图"
★ 警示写进**正文**而不是只记日志:模型看不到内核日志,只记日志等于对模型隐瞒。
## 判据(7 条)
- TestSensitiveWordHitPrependsWarning / NoSensitiveWordLeavesInputUntouched
- TestSensitiveWordOnlyAppliesToItsProfile(词表是档位属性,不全局泄漏)
- TestReviewWithoutSecurityIsNoop
- TestToolResultContentIsReviewed / TestInjectionMarkerIsDetected
- **TestInputContentReviewWiredIntoProcessing**:走真实入口 `runInputTask`
最后这条是补出来的教训:前面几条只测辅助函数,把 task.go 的调用删掉
**全部仍然绿**。实测验证后才补了走生产入口的那条。
变异验证:删掉 task.go 的调用 ⇒ 该判据红
("生产入口没有调用内容审查 —— 正文里没有警示语")。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
|
2026-11-03 08:42:50 +08:00 |
|
|
|
26dcf63fd5
|
fix(context): 让段页式统计跟随 InactivePages
## 生产故障
2026-11-02 20:58:24:
air-interrupt prune: 12→5 events (44185→39714 tokens), ratio=1.21
context overflow: recover budget exhausted (2/2)
段页式淘汰已经把工具页标成 InactivePages,渲染层也确实跳过了这些页,
但 overflow.go 的 eventTokens 仍把事件里的全部 ToolResults 算进去。
结果是「渲染视图变小、统计视图不变」:页剔除后水位不降,L4 反复触发,
最终耗尽恢复预算并终止本轮。
## 修复
让 eventTokens 使用 process.go 同一套 toolResultPageIndex 配对规则:
- InactivePages 对应的工具页不再占预算
- llm_round(第 1 页)始终保留
- 配不上的工具结果仍按原策略保守保留
因此 accumulatedTokens、overflowRatioNow、evictPagesLocked 三条调用链
都自动使用与实际渲染一致的 token 口径。
## 判据
- TestEventTokens_ExcludesInactivePages
- TestEventTokens_LLMRoundNeverExcluded
- TestOverflowRatio_DropsAfterPageEviction
变异验证:去掉 InactivePages 判断 ⇒ 前两类核心判据变红。
go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
|
2026-11-02 21:54:35 +08:00 |
|
|
|
9fcde9862e
|
merge: 修 rtchild 偶发红
|
2026-10-31 19:16:59 +08:00 |
|
|
|
07e4958e44
|
test(core): 修偶发红的 rtchild 用例,登记另一处未修的
## 背景
CI 的 `-race` 门两次变红(提交 `merge: pr31` 与 `style(webui)`),
都是 `TestRTChild_InterruptTruncatesOutput`:
--- FAIL: TestRTChild_InterruptTruncatesOutput (5.00s)
rtchild_r3_test.go:271: 等待超时:开始输出
本地 `-race` 单测 6/6、整包连跑 4 轮全过,复现不出来。
## 查到的真相
CI 日志里 `[rtchild] t started` 之后再无任何 round 行 —— **一轮都没跑到
provider**。所以不是输出丢包,是轮次根本没启动起来(CI 的 2 核 runner +
race 插桩下启动更慢)。
原写法把两件事塞进同一个固定 5 秒窗口:
waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })
「输出已开始」是**前置条件**,「Stop 后输出冻结」才是被测行为。混在一起后,
超时只能报"没等到输出",看不出到底是「轮次没启动」还是「输出没外发」。
## 改法(同 2026-10-24 TestStress_Nesting... 的先例:等状态,不等时间)
waitForWithin(t, "轮次进入 ChatStream", 30s, p.callCount() >= 1)
waitForWithin(t, "开始输出(chunk 到设备)", 30s, len(dev.snapshot()) >= 2)
两段分开等、各自 30 秒:超时能直接指认卡在哪一步。被测断言一字未改。
顺带把原来丢弃的 `PushRTInput` 返回值改成检查(同文件第 297 行本来就这么写)。
## 另一处已定位但**没修**
`TestResidualDropNotifiesSyncCaller`:本地整包 `-race` 8 轮偶发 1 次
(`应处置 1 条,实际 0`),CI 从未出现。
根因是 `waitSchedulerStopped()` 只等 `running == nil`,**不等调度循环退出** ——
cancel 之后循环仍有窗口把测试刚入队的任务取走。要修得给生产代码加
「循环已退出」信号,不是改测试能解决的。已记入 `docs/zh/future-plan.md` §5.0。
## 验证
go vet ./internal/agent/core/ ✓
go test ./internal/agent/core/ -count=1 ✓
go test -race ./internal/agent/core/ -count=1 ✓(连跑 3 轮)
单测 -race -count=5 ✓
|
2026-10-31 19:16:58 +08:00 |
|
|
|
de1a8157f9
|
merge: 日志默认去掉源文件位置
|
2026-10-31 18:49:38 +08:00 |
|
|
|
0cb20fb7f5
|
fix(log): 生产日志默认不再带源文件位置
## 现象
生产日志每行都带 `file.go:NNN`:
2026/10/31 18:47:03 bootstrap.go:320: [homed] shutting down...
2026/10/31 18:47:03 builtin_skill.go:163: [plugin] seq: removed skill ...
2026/10/31 18:47:03 sidecar.go:419: [clawhubadapter] sidecar manager stopped
来源是 `cmd/homed/bootstrap.go` 里无条件 `log.SetFlags(... | log.Lshortfile)`。
## 判断:生产确实不需要
- 多数日志**本身带模块前缀**(`[homed]` / `[plugin]` / `[kbtree]` / `[skillmgr]`),
去掉行号后定位能力并不缺
- `Lshortfile` 在**每一次** log 调用里做一次 `runtime.Caller`,不为零
- 它还不能真的消歧:它只打基名,26 个插件里有多个 `plugin.go`,
`plugin.go:104` 反而指不出是哪一个 —— 真正的消歧靠的是消息里的 `[插件名]` 前缀
## 改法
`logFlags()` 抽出来,默认 `Ldate|Ltime`;`HOMED_LOG_SOURCE=1`(或 true/yes/on)
时追加 `Lshortfile`。
保留开关而不是删掉:排查插件/装配问题时行号仍有用,部署方临时开一下即可。
用法写进 `docs/zh/deploy-runbook.md` §3.0。
## 判据
- `TestLogFlagsNoSourceByDefault`:默认不得有 Lshortfile,且日期时间必须保留
- `TestLogFlagsSourceOptIn`:开关的六种正向写法生效、四种关闭写法不误开
## 验证
go build ./... ✓
go vet ./cmd/homed/ ✓
go test ./cmd/homed/ ✓(含新增两条)
|
2026-10-31 18:49:36 +08:00 |
|
|
|
3da59bc92f
|
merge: 知识库改词法为主
|
2026-10-31 16:57:31 +08:00 |
|
|
|
0365b08dc9
|
fix(knowledge): 改成词法路为主,RRF 方案实测无效
## 上一版(RRF)在生产上零收益
73bc3f9 用「稠密 1.0 / 词法 0.5 的 RRF」重启部署后,生产上 7 条查询
结果基本没变,目标条目一次都没进前三。原因是我在**本地退化路径**上验证,
而生产走稠密路径——两个分支的融合逻辑根本不同。
在真实 196 条库 + 真实 chineseclip 上测出两路的名次,问题一目了然:
查询 稠密名次 词法名次
PruneOldBackups 103 1
strip_path 164 1
minSceneEvidence 130 1
coldcontext_query 149 1
上下文裁剪的保护条数受什么约束 157 1
冷上下文取回为什么不能先删后灌 158 1
词法路全中,稠密路全在 100 名外。稠密权重 1.0 时它的 `1.0/61` 永远压过
词法的 `0.5/61`,所以词法路精确命中也没用。
## 为什么连 RRF 本身也不行
试过把权重翻成词法 1.0 / 稠密 0.5,仍然错。因为 RRF **丢掉分数差距**:
`strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 当它们等强,
稠密对 top2 的加分一叠就反超。
## 现在的做法:fuseLexPrimary
- 词法路给主序,**保留分数差距**(这是关键词)
- 稠密路用于:同分裁决、补位、以及词法无信号时接管
(`minLexSignal = 0.02`,低于此视为 jieba 泛滥词的微弱命中,让稠密接)
- 稠密结果仍然被使用,不是死代码
## 实测(真实库 + 真实模型)
top1 = 10/13 top3 = 11/13
改前生产表现:7/7 全错。剩下的 3 条未命中(仓颉关键字 rank2、鸿蒙、更新日志)
是既有的弱点,与本次改动无关。
## 判据
- `TestDensePathFeedsRanking`:钉「稠密结果必须喂进排序」(改了形状,意图不变)
- `TestSearchDocReflectsFusion`:钉文档写明 fuseLexPrimary + 词法为主
- 文档注释里记下两次翻转(2026-10-08 → 10-31)与 RRF 被否的实测原因,
避免下一个人再翻回去
|
2026-10-31 16:57:29 +08:00 |
|
|
|
73bc3f94b7
|
merge: 知识库标识符检索修复
|
2026-10-31 16:42:38 +08:00 |
|
|
|
0ca9e96290
|
fix(knowledge): 修复英文标识符检索不到(分词器 + RRF 融合)
## 故障
真实库 196 条里 154 条是 cangjie 文档(1.53MB / 88.6% 字节)。用户反馈
「语义检索命中偏弱,cangjie 霸榜」。实测三条带精确标识符的查询:
PruneOldBackups → top5 全是 cangjie,目标条目一次没出现
minSceneEvidence → 同上
strip_path → 同上
## 真因不是 cangjie,是分词器
cangjie 只是把问题放大的体量。真正的洞在 `memory.TokenizeWords`(jieba)
对英文标识符的处理 —— 实测:
PruneOldBackups → [P r u n e O l d B a c k p s] ← 切成单字符还去重
strip_path → [s t r i p _ a h]
于是任意两篇含英文字母的文档 token 重叠率都能到 50%,词法路彻底失效。
而稠密路(chineseclip)对「只在文中出现一次的专名」本就没有分辨力。
这解释了 2026-10-08 那次决策:当时把词法路从融合里去掉,理由是
「词法路对英文标识符失效」—— 观察对,**归因错**:失效的是分词器。
## 改法
1. `identtoken.go`(新):知识库词法路专用分词器
- 补标识符**整词**:`PruneOldBackups` → `pruneoldbackups`
- 补**分隔符部件**:`strip_path` → `strip` / `path`(仅当下划线等存在时)
- **不拆 camelCase**:拆了会产出 `old`/`backups`,而 `old` 在
`cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
出现 31 次 —— 实测评测中该文档得分 0.54 > 目标的 0.20,正好把无关文档顶上来
- 丢掉 jieba 切出来的单字母残渣
- 中文分词完全沿用 jieba,行为不变
- 只注入知识库的 veczer,**不动** memory 索引/关键词提取
2. `fusion.go`(新):RRF 倒数排名融合
- `score = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)`
- 不用当初那套「逐路按查询内最大值归一化」:那个做法把 0.02 的噪声抬到
与 1.0 等权,正是 2026-10-08 淘汰融合的真凶。RRF 只看名次,与量纲无关
- 稠密权重 1.0(语义主线)/ 词法 0.5(精确命名救助)
3. `Search` 文档注释重写:把两段历史都写清(2026-10-08 为何淘汰、2026-10-31
为何加回但换成 RRF),避免下一个人重复踩。
## 判据
- `TestIdentifierTokenFeedsLex`:钉分词结果(必须产出整词、不得拆出 old)
- `TestIdentifierQueryBeatsVolume`:60 篇英文词密集的噪声文档 + 一条只在
目标条目出现一次的标识符,top1 必须命中目标 —— 分词器一退化就红
- `TestSeparatorIdentifierNotBuried`:带 `_` 的标识符,整词要压过部件噪声
- `TestDensePathFeedsRanking`(原 `TestDensePathIsSoleRecall`):保留它真正的
意图 —— 防「调用了 denseHits 却把结果丢掉」这个反模式,形状改为喂给 fuseRRF
- `TestSearchDocReflectsRRF`(原 `..DenseOnly`):钉文档与实现不漂移
## 验证
go build ./... ✓
go vet ./internal/knowledge/ ✓
gofmt -l ✓
go test ./internal/knowledge/ ✓(含 -race)
go test ./... -count=1 全绿
⚠️ 生产实例需重启才能生效(lex 索引在启动时重建,无数据迁移)。
|
2026-10-31 16:42:36 +08:00 |
|
|
|
cc07adb20b
|
style(webui): dashboard.js 格式化(换行与引号风格)
编辑器/格式化器在保存时改的,只动换行与引号,无语义变化:
· 一行多条件的 if 拆成多行
· 含双引号的字符串改用双引号包裹(避免转义)
· 长字符串拼接拆分
13 insertions / 4 deletions,逐行核过无逻辑改动。
|
2026-10-31 16:20:47 +08:00 |
|
|
|
47cdb03918
|
merge: pr31 update 回滚安全
|
2026-10-31 15:56:11 +08:00 |
|
|
|
f9ee58a914
|
merge: pr31 — update 回滚安全(原子写 + 回读校验),解 19 处冲突
pr31 与 main 是**双向分叉**,不是单向落后。`ba281a8` 已把它的多数工作
在 main 上重新实现(dataDir 守卫、gc.go、dataDir 解析),但两边各自又
往前走了,所以 6 个文件 19 处冲突要逐条判。
## 合进来的(main 缺)
- **标记原子写**:main 是 `os.WriteFile` 直接覆写,掉电留半截标记;
改为 `写 .tmp → fsync → close → rename → fsync 目录`
- **H4**:`publishResult` 里 `if errMsg != "" { _ = errMsg }` 把失败原因
丢了,订阅方只看到 ok=false。现在 error 字段恒定存在
- **回读校验补最低可用性**:main 对无 digest 的旧 marker 只比"内容与备份
一致",而**零字节备份内容也一致** ⇒ 判成回滚成功、不清 marker 的时机
也错过了。补"非空 + 带可执行位",刻意**不**要求 `--version`
(老内核没这个 flag,那正是 PR #31 的回归)
- `kernel_apply_test.go`(836 行,含 B2c `TestMarkPendingUpgrade_PreservesOldMarkerOnFailure`)
## 保留 main 的(pr31 在这几处是旧版或更差)
- **回滚校验用 digest**(`verifyRestored`)而不是 `--version`:
pr31 要求回滚后能读版本,而 1.3.x 及以前没有 `--version`,
而它们恰恰是最常见的回滚目标 ⇒ 从旧版升级失败后永久回滚不了
- **`restoreDepGraph` 三态**:原来有+备份有→还原、原来有+备份无→不算成功、
原来无→删掉新装的。pr31 只处理第一种,会导致「旧 homed + 新 depgraph」
的崩溃循环
- **marker 在还原完整后才清**:pr31 在还原前就清,还原失败即失去第二次自愈
- **CLI 不重启服务**:pr31 仍带 `restartUnit`,会杀掉整个 cgroup 连带 guard
- **`resolveDataDir`**:pr31 的 `upgradeDataDir` 是它的子集,多扫几个候选前缀
## 冲突处理中的三处重复声明
两边各加了同名函数,自动合并把它们都留下了:
- `readBinVersionStrict` —— 删 pr31 的(main 在 77 行有)
- `kernelDataDirMu/kernelDataDir/SetKernelDataDir/KernelDataDir` —— 删 kernel.go
里那份(main 在 install.go)
- `newPkgRoot` —— pr31 的重命名 `newPkgRootFromBin`,两套测试各自可用
- rollback.go 里 `verifyRestored` 与 `readBinVersionStrict` 两段校验并存 —— 删后者
顺带修掉删除时误伤的 `ShortSum` 注释,并删掉 pr31 引入但无人调用的
`DataDirEnv`(main 明确不再拿 env 当 dataDir 来源)。
## 验证
go build ./... ✓
go vet ./... ✓
go test ./... -count=1 62 ok / 0 fail
go test -race ./internal/update/ ... ✓
gofmt -l 干净
|
2026-10-31 15:56:10 +08:00 |
|
|
|
58dea7e6c4
|
merge: 介绍站内容修正
|
2026-10-31 11:44:04 +08:00 |
|
|
|
aa9e8c7454
|
docs(site): 修正介绍站三处与代码不符的描述
## 1. 「一轮里的多个 tool_call 默认并发」写反了
同一张卡的标题是「同轮工具默认串行,声明后才并发」,正文却写「默认并发」。
按 `internal/agent/core/argvalidate.go:307` 的 `batchRunnable`:批内**每一个**
工具都 `ParallelSafe` 才并发,有一个没声明就整批串行,`Serial` 优先级更高。
改:标题保留,正文按实现重写。
## 2. 「最近 10 条恒受保护」已不成立
`effectiveProtectedCount` 现在返回 `min(配置值, ContextTokens/单条平均token)`。
固定 10 条在 50k 窗口、5800 token/事件时会占 58k,比 40000 预算还大 —— 这正是
召回率从 44% 掉到 22% 的那次实测。站点两处都还在写「恒受保护」,改成「受预算约束」。
## 3. 第二层命名与描述
- 「低分下沉成文档,冷文档再蒸馏」→ 冷上下文
- ② 层正文「JSON + TF-IDF 索引的中间层」→ 实际是稠密 + TF-IDF 双路,
且与 L1 结构同构、取回走两阶段
- FAQ「上下文事件裁剪后归档成文档」同步改
全站 `文档层|冷文档|归档成文档|Document 层` 归零。
HTML 结构校验:div / section 配对差 0,`<p>` 开闭 62/62。
|
2026-10-31 11:44:04 +08:00 |
|
|
|
8a6e480175
|
merge: 架构节改参考文档写法
|
2026-10-31 11:42:23 +08:00 |
|
|
|
bd9dd9e75f
|
docs: 架构节改成参考文档写法
上一版还是论证体:四个小标题里三个是否定式断言(「上下文**不是**截断+摘要」
「三层记忆是迁移,**不是**复制」「记忆按场面分,**不按**会话」),
每段都以一句评论收尾。段落字数 62–128,几乎等长 —— 这是最明显的机器痕迹。
改成参考文档:
## 小标题换成名词
上下文不是「截断 + 摘要」 → 上下文窗口
三层记忆是迁移,不是复制 → 三层记忆
记忆按场面分,不按会话 → 场面
标题说明这节讲什么,不下判断。判断该由正文的数据支撑,不该写在标题里。
## 删掉与假想敌的对比
「常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是丢什么由位置决定」
「普通 agent 通常一条会话一条历史,换个话题就失忆」
换成直接说我们怎么做的。读者不需要先被告知别人怎么错。
## 段落长度拉开
之前 62–128 字(极差 66)。现在每节先给一行文件路径,正文长短不一:
32 字 `internal/agent/core/context.go`
127 字 这条约束来自一次实测。50k 窗口……
51 字 N 默认 10,实际取值受预算约束:……
129 字 场面由五个特征构成……
极差 98 字。技术文档本来就是「一行路径 + 一段解释 + 一行数字」这种不均匀节奏。
## 去掉两处破折号
「没有『取出即删除』的路径 —— 删除排在确认之后」
「不吸收整轮指纹 —— 吸收了会……」
改成句号。全文破折号归零。
## 补上实现位置
原来只讲设计没给代码位置,改成每节标文件路径:
`internal/agent/core/context.go`、`internal/memory/coldcontext/`、
`internal/memory/scene_emerge.go`、`internal/agent/core/stages.go`。
## 核对
文中数字逐条对源码:`preemptPromotionCap = 2`、`minSceneEvidence = 2`、
`FindColdContexts(72*time.Hour, 2)` 均一致。两版 9 个标题对齐,无死链。
|
2026-10-31 11:42:21 +08:00 |
|
|
|
e542bc1643
|
merge: 介绍站命名同步
|
2026-10-31 11:41:18 +08:00 |
|
|
|
27f25bdc4f
|
docs(site): 介绍站第二层改名 Cold Context
与仓库内文档同步:第二层早已改名 cold context(工具名 coldcontext_query /
coldcontext_commit),站点仍在用「Document 层」,且第二层描述写的是
「JSON + TF-IDF 索引」—— 实际是稠密向量与 TF-IDF 双路检索。
改了 6 处:meta description、三层配色注释、① 层说明里的归档目标、
② 层标题与正文、三层链路串。② 的正文同时换成当前事实:
与 L1 结构同构、稠密+TF-IDF 双路、取回走「先确认再删」两阶段。
|
2026-10-31 11:41:18 +08:00 |
|
|
|
4df8c73555
|
merge: 第二层改名 Cold Context + 许可段精简
|
2026-10-31 11:39:15 +08:00 |
|
|
|
7c0eec1b2f
|
docs: 第二层改名 Cold Context,清理四组已不存在的符号名
## 问题
README 与两份 ARCHITECTURE / OVERVIEW 仍在用旧称「Document 层」,
而第二层早已改名 cold context(工具名 `coldcontext_query` / `coldcontext_commit`)。
不止是层名。文档里引用的四个符号在代码里**一个都不存在**:
| 文档里写的 | 代码里实际 |
|---|---|
| `docStore.ContextToDoc` | `coldcontextStore.ContextToColdContext` |
| `FindColdDocs` | `FindColdContexts` |
| `docToTriples` | `coldDocToTriples` |
| `docStore.SetVectorizer` | (已无此调用,embedder 构造期注入) |
四处都用 `grep -rn "\b名字\b" --include=*.go` 确认过:旧名 0 处,新名各自 4 处。
## 改了什么
- `assets/docs/zh/ARCHITECTURE.md` 13 处、`en/ARCHITECTURE.md` 15 处
- `assets/docs/zh|en/OVERVIEW.md` 各 3 处
- README 两版:「文档层」→「冷上下文层」
- `assets/branding/mascot-xiaozhai-guide.md` 的丝带配色表(三色分别对应三层)
## 顺带修正层号
ARCHITECTURE 用的是 **L1 Context / L2 Cold Context / L3 Stable**,
我上一版 README 写成了 L0/L1/L2 —— 少了一层,且与架构文档对不上。已改为 L1/L2/L3。
## 顺带修掉断掉的交叉引用
`internal/agent/core/toolcall.go` 与 `recallcoldcontext_test.go` 的注释写
「ARCHITECTURE「记忆流转」② Document 层的读取分支」,指的正是刚改名的节。
不改就成了悬空引用。三个测试函数名(`TestContextToDoc` 等)指向已删函数,一并改名。
## 验证
- 全仓 `grep` 旧名:`*.md` / `*.go` 均 0 处(排除 build/third_party/.pi)
- `go build ./...` ✓
- `go test ./internal/memory/coldcontext/ ./internal/agent/core/` ✓
|
2026-10-31 11:39:13 +08:00 |
|