|
|
f9ebdd5c74
|
Merge commit 'bf44654' into ci/boot-graph-tests
|
2026-10-25 07:57:21 +08:00 |
|
|
|
599252f9b0
|
fix(ci): ID:19 空表假绿堵口 —— 空表须显式声明 # none,删空与有意清空可区分
check-ci-gates.py: TABLE 捕获体 `(.*?)` 允许空串,表被删空时静默走「无排除项」分支。
现在 ci.yml 里加显式空表标记校验(while 跳过 # 注释行),门脚本补正反自测:
合法 `# none` 必过、完全空体/只空行/非显式注释必红。
ID:18: bootgraph_test.go 失败面引用改为按区分词定位,不再依赖包名/行号。
|
2026-10-25 07:56:45 +08:00 |
|
|
|
ed61a8d604
|
test(core): ID:12 嵌套压力用例改「等状态」,不再赌 300ms 固定窗口
根因(-race 实测定性,非竞争):a.cancelLLM 的装配在进入 Chat 之前,
因此「任务已被选中运行」与「cancelLLM 已装上」之间存在一个空档。中断若
恰好落在空档里,registerInterrupt 已经打印 preempt 并置位让位信号,但
cancelCurrentLLM() 读到的 cancel 仍是 nil ⇒ 取消不生效,受害者跑满延迟后以
outcomeDone 结束(runTaskSteps 在 outcomeDone 处直接返回,不再经过安全点)
⇒ 栈深永远达不到目标值,「第 N 级注入后栈深未达 N:0」。
改法:把判据从「等固定时间」换成「等状态」——
· gatedProvider:Chat 进入即发 entered token 并阻塞到 ctx 取消或 gate 关闭,
使「受害者正处于一次可取消的 LLM 调用中」成为可等待的状态;
· 注入前 waitEntered 等该状态,注入后 waitStackDepth 轮询等到栈深真的达成;
· 排空阶段 close(gate) 让仍在运行的 L4 自然收尾。
延迟参数 300ms 未动,未改调度器参数,未加 skip,未改被测行为。
证据(internal/agent/core 下连跑 -race 10 轮):10/10 PASS;
另在 12 路 CPU 满载(改点前同条件下实测 12/30 轮失败)下 3/3 PASS。
Co-authored-by: pi <pi@local>
|
2026-10-24 14:34:24 +08:00 |
|
|
|
1f41fe1a47
|
Merge commit 'refs/tmp/gmain' into ci/boot-graph-tests
|
2026-10-24 13:34:57 +08:00 |
|
|
|
2d49c23cc6
|
Merge remote-tracking branch 'refs/remotes/gh/main' into fix/webui-gzip-race
|
2026-10-24 13:25:18 +08:00 |
|
|
|
1905d32053
|
docs(kernelboot): ID:14 已完成,同步 registry_test.go 失败面第 4 条
## 为什么改
bootgraph_test.go(原 internal/kernelboot/modules/registry_test.go)文件头
「失败面」一节第 4 条是**历史实测记录**,原文结句写着:
要"红得干净"得让 kernelboot.Run 对 nil 依赖显式报错 —— 那是业务改动,
不在本线权限内,已记入待办。
ID:14(e70c2b5)做完之后这句成了**假信息**:留着比删掉更有害 ——
下次有人照着它判断「Run 不拦 nil 依赖」,会得出与事实相反的结论。
同时 kernelstate.go 的注释已改成「Run 会显式拦」,两边口径不一致更要命。
## 改了什么(纯注释,**零行为改动**)
- 原记录的「崩在业务代码里」形态**保留**,但明确标注为「ID:14 之前的形态,
作为为什么需要执行期校验的原始证据」
- 新增「已完成(ID:14,提交 e70c2b5)」小节,写清现在的行为与**实测**结果
- 指向专用回归判据(runtime_dep_test.go / runtimedep_test.go)
## 注释里那句「换成可读报错」是实测过的(非声称)
用真实登记表 kernelmod.Registered() 算 plan 后注入两种变异,均得
*DependencyNotReadyError,**无 panic / SIGSEGV**:
· 整层逆序 ⇒ 第 0 层拒(produced 为空)
模块 agent 缺设施 [docmem knowledge llm memory stagehost textmem tracker];
模块 plugin-host 缺设施 [config docmem kernel-io knowledge lua memory
stagehost supervisor textmem tracker]
· 跳过第 2 层 ⇒ 第 2 层拒
模块 stage-host 缺设施 [knowledge llm textmem]
(初稿写成「第 1 层 … 模块 llm 缺设施 [lua]」,与实测不符,已按实际输出改正。)
## 校验
git diff 中非注释行改动 = 0(grep 计数)
go build ./... = 0 error,go vet ./... = 0 warning
go test ./internal/kernelboot/ ./internal/kernelmod/all/ = ok
|
2026-10-24 12:58:40 +08:00 |
|
|
|
3864f36992
|
refactor(kernelboot,kernelmod): ID:13 删死包 kernelboot/modules,整机启动图判据迁到 kernelmod/all
## 决策
删除 internal/kernelboot/modules(registry.go + registry_test.go),
把整机启动图判据迁到 internal/kernelmod/all(真实聚合入口)。
文件头那条 TODO(死包清理) 随包一起消失。
## 为什么这个包该删
实测零生产调用:
· 生产代码里 import 它的包数 = 0
· Register() / BuildModules() / Names() 的外部调用点 = 0
· 它的 Register/BuildModules 是 internal/kernelmod 那套注册表的**第二份实现**
(留着只会让人下次接错口子)
· kernelmod/all/facilities_sync_test.go 的注释里早已写明
「kernelmod 才是真实注册表(模块编译期工厂加载)」
## 判据迁移(能力未削弱,逐条实测)
- 判据 1–4(整机装配/层拓扑/严格逆序关闭/无残留): 只换宿主,
用 kernelmod.Registered() 作为装配输入不变,实测 5 层 / 17 模块 /
关闭顺序 [4 3 2 1 0] / 3 轮 goroutine 差值 0 / 订阅者 2→2
- 判据 5(TestEveryRegisteredModuleIsCompiledIn): 真相源是扫 `..` 源码树的
「目录+类型名 复合键 + 四方法齐全」,与宿主包无关。scanModuleTypes 的
modRoot 从 "../../kernelmod" 改为 ".."(本文件现在在 kernelmod/all/ 下)
- 删掉 `_ ".../kernelmod/all"` 自引用(判据现在与聚合入口同包,
本包被编译即聚合 import 生效 —— 反而更贴近生产同一条 import 链)
## 「新模块登记了但漏 import」仍咬得住(3 个变异亲测)
A 从 all.go 删 multimodal 聚合 import(叶子模块,无人依赖)
→ TestEveryRegisteredModuleIsCompiledIn 红(指名 multimodal)
→ TestEveryModuleDirIsImported 红
B 删 multimodal 的 init()(Register 调用点消失,真相源不能是被测入口)
→ TestEveryRegisteredModuleIsCompiledIn 仍红(源码侧多数一个类型)
C supervisor 的 Name() 改成非字面量
→ 判据显式失败(不静默跳过 ⇒ 不会变空转)
## 悬空引用清零
全仓 grep kernelboot/modules:代码/文档/CI 只剩 ci.yml 里一条
「那个包零生产调用,已删除」的历史说明。同步修正:
· ci.yml「整机启动图判据」步骤 → go test ./internal/kernelmod/all/
· ci.yml -race 排除表 pkg 行目标包 → internal/kernelmod/all
(该包现在装整机 ⇒ 同样会撞 KERNEL-REGISTRY-SETLOCK;
实测不带 -race 6/6 绿,排除仍必要,单号不变)
· ci.yml 三条说明文本(含证据步骤的提示语)
· kernelboot/kernelstate.go 指向旧判据路径的注释
· docs/zh/kernel-modularization-design.md §2.7 的 declarations.go 路径
· check-ci-gates.py §4 断言目标包 → ./internal/kernelmod/all/
(否则它盯的是一个已不存在的包)
## 验证
go build ./... = 0 error,go vet ./... = 0 error
go test ./internal/kernelboot/... ./internal/kernelmod/... = ok
go test -race ./internal/kernelmod/all/ = ok(连跑 6 次无 DATA RACE)
check-ci-gates.py EXIT=0;ID:15 的 verify_v4 矩阵复跑仍 5 变异全抓 + 误报项 EXIT=0 + 基线 EXIT=0
|
2026-10-24 12:56:37 +08:00 |
|
|
|
e70c2b55b4
|
feat(kernelboot): ID:14 Run 增加执行期依赖校验,顺序被改坏时报可读错而非 SIGSEGV
Build() 里已有构建期校验 diagnoseUnresolvable(查声明:缺依赖/环/重名),
本单**不重复**它。缺的是执行期那一层:声明全对、图也自洽时,plan 的层顺序
仍可能被改坏(手改 Layers / 图与代码漂移 / 有人把某层提前),此时 Build 绿,
模块 Init 拿到 nil 依赖,崩在业务代码里。
实测复现(本单判据的复现形状):把 kernelmod 真实登记表算出的第 1 层
(lua/memory/onnx-parser/…)与第 2 层(llm/text-memory/knowledge-store)交换,
llm.buildProviderManager 收到 0x0 的 LuaVM:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV ... addr=0x10]
internal/lua.(*VM).ConfigureConcurrency(0x0, ...) vm.go:213
internal/kernelmod/llm.buildProviderManager(..., 0x0, ...) mod.go:70
改动:Run 逐层维护 produced(**只有真跑完的层**才贡献设施,刻意不从 plan 预填),
每层 Init **之前**核对本层各模块 Requires ⊆ produced;缺则返回
*DependencyNotReadyError 并**本层一个 Init 都不调**(宁可什么都不装,也不装一半再崩)。
错误文本指名「第 N 层 / 模块 X 缺设施 [...]」并列已实际产出的设施。
判据:
internal/kernelmod/all/runtime_dep_test.go —— 用真实登记表算 plan 后做变异
A 正序前缀不误报
B 交换两层 ⇒ 判据红(指名 llm 缺 lua / text-memory 缺 memory,onnx /
knowledge-store 缺 knowledge-migrated),且 ks.LuaVM 仍为 nil(没跑到 llm)
C 删掉提供者整层 ⇒ 第 0 层就拒绝,IO/EventBus 仍 nil(一个模块都没跑)
internal/kernelboot/runtimedep_test.go —— 纯逻辑四形状 + 防 M2 自检
变异证明(4 个,逐个人工注入后跑判据,全部咬住):
M0 撤掉守卫 → 复现原 SIGSEGV(ConfigureConcurrency(0x0))
M1 守卫恒放行 → 5 条判据红
M2 produced 从 plan 预填 → 退化成 Build 重复实现,顺序类判据红
M3 报错丢模块名 → 指名类判据红
修前红/修后绿:
修前(M0):panic SIGSEGV @ lua.(*VM).ConfigureConcurrency(0x0)
修后:DependencyNotReadyError,文本含「第 1 层」「llm」「lua」「缺设施」
go build ./... = 0 error,go vet ./... = 0 error
go test ./internal/kernelboot/... ./internal/kernelmod/... = ok(含 -count=1)
|
2026-10-24 12:50:57 +08:00 |
|
|
|
7457b052a7
|
fix(webui): streamOpenAI 改为 w 单写者模型,修 gzipResponseWriter 真数据竞争
根因(-race 实测,非推测):
streamOpenAI 的 handler goroutine 在收尾处直接 fmt.Fprintf(w, finish chunk)、
fmt.Fprintf(w, "data: [DONE]")、flusher.Flush(),与增量 flush goroutine
(16ms 批量合并)**并发写同一个 http.ResponseWriter**。经 gzip 中间件时,
gzipResponseWriter 的 mode / wroteHeader / buf 三个字段无锁被两方同时读写:
Read gzipResponseWriter.Write gzip.go:139(读 g.mode)
└─ streamOpenAI handler goroutine handler_openai.go:256
Write gzipResponseWriter.commit gzip.go:206(写 g.mode/wroteHeader)
└─ streamOpenAI 写 goroutine handler_openai.go:165
修法选择:改写并发模型(单写者),不给 gzipResponseWriter 加锁。
理由两条:
1. 加锁只哄住 race detector,是治标 —— 并发写同一个 ResponseWriter 这件事
本身就该被结构禁止,而不是靠锁逐字段兜。
2. 持锁要跨 Write / Flush 这类网络 IO,一旦客户端慢,整条 SSE 会串行卡在
锁上(锁内做 IO)。单写者模型从结构上消灭竞争,且零锁开销。
改动:
- 新增 finishCh(chan finishItem,buf=1)+ writerExit。终止帧(finish chunk
或超时错误 + [DONE] + Flush)也交给那个唯一的写 goroutine 发。
- 新增 sendFinish(usage, timedOut):**先投递再等 writerExit**。
顺序不能反 —— writer goroutine 只在收到 finishCh 或 done 关闭时退出,而
done 在 defer 里关(函数返回之后),先等后发即死锁。select 里的 <-done
是防御路径:done 已关时 writer 可能已不在,不该再等一个永不到来的接收者。
- 超时路径与正常收尾路径都改走 sendFinish,handler goroutine 不再碰 w。
- writerExit 用 safeclose.Safe 关闭,遵守本仓 close 纪律
(internal/safeclose/TestNoBareCloseOutsideOutsideSafeclose 禁裸 close(chan))。
- 顺带修掉一个既有逻辑缺陷:旧写法下 [DONE] 可能先于 deltaCh 里尚未发完的
尾部 delta 到达客户端;现在先 flushPending 再发终止帧。
验证(全部 -count=1,除注明者):
- 基线(f408448 原版)TestOpenAIStreamIsActuallyStreaming + -race:
单跑 5/5 次各 8 条 DATA RACE 且 FAIL;-count=5 时 10 / 12 / 10 条。
- 修复后同一用例 + -race:单跑 6/6 次 races=0 PASS。
- 修复后整包 go test -race ./internal/plugins/webui/:3/3 次 races=0 PASS
(63s / 69s / 64s)。
- go build ./... 通过;go vet ./... 通过;go test ./... 58 包 ok、0 FAIL、
0 WARNING: DATA RACE。
判据:cr-HomeAgent-6c0d1e07(stress),带变异 M1「sendFinish 改回直写 w
(并吃掉 <-writerExit,避免只靠死锁超时报红)」——实测被咬住(判据报红)。
|
2026-10-24 12:35:31 +08:00 |
|
|
|
19c78ca946
|
fix(plugin): Registry 的 Set* 全部取写锁,读侧改一致性快照(KERNEL-REGISTRY-SETLOCK)
根因(前一条 CI 线实测):internal/plugin/registry.go 的 Set* 长期是单行
无锁直写,而 r.mu 一直存在却从未被这批 setter 使用;同时
kernelmod/agent.wirePluginSDK 与 kernelmod/pluginhost.injectBaseDeps
同处启动图第 4 层、并行执行,各自把同一批字段写一遍 ⇒ 写-写竞争。
## 一、加锁(真正的修复)
· 24 个 Set* 全部改为 r.mu.Lock() / defer Unlock():
SetIOManager/SetEventBus/SetMemory/SetTextMemory/SetDocStore/SetKnowledge/
SetProviderManager/SetConfigRegistry/SetPluginDir/SetDataDir/SetLuaVM/
SetBaseAPIKey/SetKernelControl/SetToolRegistrar/SetStageRegistrar/
SetAPIRegistrar/SetToolCleaner/SetStatusProvider/SetSupervisor/SetTracker/
SetConfig/SetStageHost/SetDeviceAuthQuery/SetIndexer
· 读侧保住 RLock,并新增 sdkDeps + snapshotDeps():buildSDK 在**一次**
RLock 内取齐整组依赖后立刻放锁,随后用快照装配 SDK。
为什么不在整个 buildSDK 外套 RLock:其闭包(regOutput 等)在插件 Start
期间才被调用,持锁跨用户代码有死锁风险;改为快照 + 放锁。
为什么必须一次取齐:逐字段裸读既各自是一处竞争,也会让同一次装配
看到"lua 已换新、cfg 还是旧"的撕裂组合。
· kernelControlFor / stageRegistrarFor 拆出无锁内核
(kernelControlForCtl / stageRegistrarForSource),供快照后复用,
避免持锁再 RLock 的自锁。
· 其余读点集中走访问器:pluginDirOrEmpty / cfgRegistryOrNil /
toolCleanerOrNil / stageHostOrNil / eventBusOrNil / ioManagerOrNil
(dynamic_proc.go 的 evBus 两处同步改)。
· 未改任何语义、未重命名导出符号。
## 二、去重复注入(可行,已做)
按"必要性不对称"判定:保留 pluginhost.injectBaseDeps,砍掉
agent.wirePluginSDK 里的 7 项重复写,agent 侧只留 SetStatusProvider。
理由:pluginhost 紧接着就 LoadSkipping 拉插件,且它刻意 Requires() 不含
agent("插件不必等 agent 35 秒"的收益就在这半边)——砍它会让第 4 层加载
的插件拿到全 nil 依赖;而 agent 侧写的是同一批 ks.* 的同一份值,写第二次
无新增信息。
★ 去重**不能**替代加锁:去重后 agent 仍写 SetStatusProvider,pluginhost
同时可能 buildSDK 读 status,写-读竞争仍在(实测可复现 1 条 DATA RACE)。
所以锁是修复本体,去重只是消除无意义的重复写。
## 三、验证(全部 -count=1)
判据(internal/plugin/registry_setlock_test.go):
· TestRegistrySettersTakeWriteLock —— 源码级:24 个 Set* 函数体必须各含
r.mu.Lock()(race 是概率性的,"锁被删了"这类回退必须确定性咬住)
· TestSettersConcurrentWriteRace —— 两个写 goroutine 用不同值并发写同一批
字段 + 读侧并行
· TestBuildSDKConcurrentWithSetters —— 走真实读路径 buildSDK/snapshotDeps
与 setter 并发(补上一条测不到的**读侧**缺口)
变异实证(每条都实跑,均报红):
M1 删掉 SetLuaVM 的锁 ⇒ 源码判据报红 + -race 报 DATA RACE
M2 删掉 SetConfig 的锁 ⇒ 源码判据报红 + TestBuildSDKConcurrent 报红
M3 只删 snapshotDeps 的 RLock ⇒ -race 报红,栈指向读侧 :486/:494/:495
vs 写侧 :274/:367(证明读侧回退也有人管)
race 数字(CI 线整机判据 go test -race ./internal/kernelboot/modules/):
修复前 25 轮:2 轮报红,累计 14 条 DATA RACE
修复后 25 轮:0 轮报红,0 条 DATA RACE
范围包(./internal/plugin/... ./internal/kernelmod/...)修复后 -race 全绿。
go build ./... / go vet ./... 干净。
## 四、CI 侧收口(不在本分支落地)
ci/boot-graph-tests 的 race 排除表里那条
pkg|internal/kernelboot/modules|…|KERNEL-REGISTRY-SETLOCK
现在可以删了。按任务边界本分支不碰 ci.yml,改由
id10_ci_closeout.patch(对 ci/boot-graph-tests 可干净 git apply)+
ID10_CI_CLOSEOUT.md 交给 CI 线合入。
|
2026-10-24 12:17:46 +08:00 |
|
|
|
49141fe6f0
|
ci: 把 check-ci-gates.py 接进 go job,CI 自己检查自己的配置不许静默退化
背景:.github/scripts/check-ci-gates.py 上一轮已写好并入仓(ed8f28e),
但它**没被任何 workflow 调用** —— `grep check-ci-gates .github/workflows/ci.yml`
零命中。等于判据只活在评审口径里,CI 实际上不跑它。本次把它接上。
## 一、ci.yml:新增 2 步(go job,插在 go vet 之后、go test 之前)
- `actions/setup-python@v7`(3.12)
- `CI 配置门禁自检(动态枚举 / 排除表 / 启动图判据不许静默退化)`
位置为什么在 go test ./... **之前**而不是之后:
· 该步骤是纯静态检查 —— 只读 ci.yml,秒级,无网络、无 cgo、
不建 SQLite、不占端口;而 go test ./... 要跑 20 分钟。
· 「门禁本身坏掉」应该第一时间红。放后面会让一次坏配置白白消耗
20 分钟机器时间才发现,也让失败信号埋在 93 个包的输出后面。
runner 的 pyyaml **不假设自带**:先 `python3 -c 'import yaml'` 探测,
缺了才 `pip install pyyaml`(幂等,几秒)。本机实测 pyyaml 6.0.3 已有,
但本机的 Python 环境不等于 runner —— 所以做探测而不是赌。
## 二、顺手纳管 internal/kernelboot/modules/registry.go 的注释改动
该文件 +6/−3 的改动是**纯注释**,内容为 11:08 那轮已拍板要记进文件头的说明:
真相源为什么刻意不扫 `kernelmod.Register(...)` 调用点 ——
删掉某模块的 init() 后调用点消失,源码侧与登记表侧会同时少一个,
比对照样相等 ⇒ 假绿。手写模块名清单同理。故真相源取
「目录+类型名复合键 + 四方法齐全」的类型,而不是要检测对象的入口。
★ 这点在 commit message 里点明,是为了不让它变成夹带:它不是本次新写的代码,
是上一轮已定但未提交的说明文字,与本次「门禁入 CI」同一主题(判据不许假绿)。
## 三、验证(先证能红,再证能绿,都不靠嘴说)
从 ci.yml 里**抽出该 step 的 run 块原样执行**(不是手敲等价命令):
· 变色前(动态枚举改回写死 ./internal/agent/core/ ./cmd/waiter/)
→ EXIT=1,报 `race job 应恰有一步用 go list ./... 动态枚举包(实际 0 步)`
· 还原后 → EXIT=0,报「CI 门禁自检通过:动态枚举 / 排除表校验 / 启动图判据步骤 / 交叉枚举 / 全部 run 步骤语法」
另在临时副本里抽查 5 个变异,4 个如期变红(写死枚举 / 去掉 -count=1 /
-race 不吃枚举 / cross 写死 cmd)。第 5 个「删掉排除表的原因校验」**没红** ——
已定位为脚本自身缺口,见下条,不在本次授权范围故未改。
## 四、已知遗留(未修,留给后续)
check-ci-gates.py 第 100 行用的是 `any("没有写原因" in r for ...)` ——
它只检查**提示文案还在**,不检查那段 `if [ -z "${reason// /}" ]` 校验逻辑本身。
于是把条件改成 `if false;` 而保留文案,断言仍然绿(实测 EXIT=0)。
判据被注释/文案顶包,与它自己在 docstring 里批判的「注释骗过 grep」是同一类错误。
属于该脚本的缺陷,本次任务授权只到「接进 CI」,故只记录不修。
|
2026-10-24 11:55:28 +08:00 |
|
|
|
ed8f28ea6c
|
test(kernelboot,ci): 用真实启动图装配整机补判据,race 门由写死两包扩到全仓
背景:老大指出「CI 测试不够灵活、覆盖不到内核方方面面,应从启动图入手」。
先定位 CI 失败:六个门禁在基线上本地全绿,复现不出红 —— 实质是**覆盖面太窄**
而非配置写错(原 race 门只跑 ./internal/agent/core/ 与 ./cmd/waiter/ 两个包,
kernelboot 下 scheduler/syncbus/stress 的并发用例全在裸奔,绿是因为没测到)。
## 一、新增 internal/kernelboot/modules/registry_test.go(913 行,5 条判据)
kernelboot 自身判据很密(图编解码/分层/调度/迁移回滚/并发压测),但**全部用
假模块**在图上验算法。「真实 17 个模块按真实登记表装配成整机、逐层启动、
再严格逆序关闭」一条用例都没有。这中间藏着一整类失败:算法对、单模块对,
但装配起来不对(漏挂模块 / 某层关闭函数没登记 / 关闭顺序反了)。
5 条判据(零假模块,全部基于 kernelmod.Registered() 的 17 个真模块):
- TestRealRegistryAssemblesWholeMachine:每模块进图、设施真就绪(不是"登记过")
- TestLayerTopologyIsRespected:层间严格有序、层内两两无依赖、分层真带来并行
- TestShutdownIsStrictlyReverseLayerOrder:关闭严格逆序(实测哨兵序 [4 3 2 1 0])
- TestShutdownLeavesNoResidue:连续启停 3 轮,goroutine 回基线 + 临时目录删净
+ syncbus 订阅者不增长
- TestEveryRegisteredModuleIsCompiledIn:源码树扫描与登记表**双向**集合比对
### 判据 5 的真相源为何是"类型+四方法集"而不是 Register 调用点(初版栽过)
初版扫 `kernelmod.Register(...)` 调用点。实测:删掉叶子模块 multimodal 的
init() 后,**5 条判据全绿** —— 调用点消失 ⇒ 源码侧与登记表侧同时少一个 ⇒
集合比对照样相等。而"忘记初始化注册"恰是它声称要抓的场景。
根因是把「要检测的对象」当成了「检测的入口」,入口一被破坏就失去视野。
改成扫「实现了 Name/Requires/Provides/Init 四方法的类型」:模块哪怕不
Register、不被 import,四个方法仍写在源码里。
坑:本仓大量类型都叫 Module,只按类型名做键会撞成一堆(实测撞到只剩 2 个)
⇒ 用「目录+类型名」复合键;agent 是指针接收者 *Module,要处理 StarExpr。
修后恰扫出 17 个类型,与登记表 17 个名字一一对应,零误报。
### 失败面逐条实测(不是注释声称)
M1 删叶子模块 init() → 只有判据 5 红(1–4 全绿,这正是它存在的理由)
M2 CloseAll 逆序改正序 → 判据 3 红
M3 删聚合 import → 判据 5 红
其余:跳过某层会让内核 panic(llm 拿到 nil LuaVM,SIGSEGV);层顺序改逆序
红在装配失败(依赖数据为 nil)而非顺序断言 —— 这两种能发现但报错可读性差,
已写入文件注释,修它属业务改动(另派)。
## 二、.github/workflows/ci.yml
- race 门:写死两包 → `go list ./...` 全仓动态枚举(剔除 cmd/gui,
枚举 <50 包则响亮失败),实测 92 包
- 新增 -skip 排除表:每项必须写「原因 + 修复单号」,无原因该步骤自身失败;
每次 CI 打印完整清单,避免"排除项静默腐烂"
- 新增「已知红的可核对证据」步:用**静态断言**检查两处已知缺陷是否仍在
(webui gzipResponseWriter 无锁、kernel Set* 无锁直写)。
不用"DATA RACE 报告数>0"当断言 —— 实测这些竞争偶发(正向 0 条、
另一次 8 条),那样必然假绿。
- cross 门:cmd 清单由写死 → 逐包 `CGO_ENABLED=0 go build` 实测枚举
(实测 4 个纯 Go / 8 个依赖 cgo;homed-key-rotate 一直是纯 Go 却从没进过
清单,白少一个覆盖);矩阵补 windows/arm64 与 linux/386
- go job:单列「整机启动图判据」一步(带 -count=1,便于失败定位)
## 三、新增 .github/scripts/check-ci-gates.py(180 行)
官方复核时发现:第一版判据只跑 gofmt/build/vet/go test,**完全不校验 ci.yml**,
而 ci.yml 正是改动占比最大的部分 —— 用「删掉 cmd/gui 剔除」这个变异咬出了该缺口。
本脚本用 YAML 解析断言 6 类门禁机制仍在,并给每个 run 步骤做 bash -n。
关键细节:`run: |` 块里的 `# ...` 是**字符串不是 YAML 注释**,`"cmd/gui" in run`
会被一句说明性注释满足 —— 真把 grep -v 删掉断言仍绿。故剥 shell 注释后再断言,
并给剥注释函数**本身**加了自测。
## 四、两处纯注释纠偏
- modules/registry.go:文档写「防漏 import 判据见 registry_test.go」(main 上该
文件当时不存在)、第④步指向 `modules/imports.go`(真实位置是
internal/kernelmod/all/all.go)。已纠正并落地为两条判据的指引。
- kernelboot/kernelstate.go:把「判据见 modules」具体化为函数名。
## 门禁(全部 -count=1,无缓存)
go build ./... = 0
go vet ./... = 0
go test ./... = 0(59 ok / 0 FAIL)
go test -race 91 包 = 0(58 ok / 0 FAIL)
check-ci-gates.py = 0
证据步骤正向 = 0;反向(给 SetLuaVM 加锁)= 1(证明是活断言不是死代码)
## 已知红(均待另开线,本线不越界改业务)
1. 内核:agent.wirePluginSDK 与 pluginhost.injectBaseDeps 在第 4 层并行重复
写同一批 plugin.Registry 字段,而 Set* 全不取锁(r.mu 存在但没用)。
实测偶发约 4%(1/25)。当前以 pkg 级排除临时挡住,**定性为临时手段**;
唯一出口是给 Set* 加锁(修复单 KERNEL-REGISTRY-SETLOCK)。
2. webui:gzipResponseWriter 的 mode/wroteHeader/buf 无锁,handler goroutine
与增量写 goroutine 并发写同一 ResponseWriter(修复单 WEBUI-GZIP-RACE)。
3. internal/agent/core TestStress_Nesting... 时序偶发(-race 放大 300ms 假设,
无 DATA RACE 报告),未 skip,复现命令记在 ci.yml 注释里。
边界:未改任何内核业务逻辑(两个业务文件 diff 逐行只有注释);未 push、未开 PR。
|
2026-10-24 11:30:51 +08:00 |
|
|
|
3f834794c5
|
refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做
SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:
replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk
一旦 SDK 侧改成新 module 名,主仓立刻炸:
go: ./third_party/homeagent-sdk@ used for two different module paths
(github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)
Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。
## 一个被证伪的前提
我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。
这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。
## 连带修掉两处
| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |
第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。
## 验证
- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
|
2026-10-23 15:51:02 +08:00 |
|
|
|
6327578693
|
fix(media): 支持 webp 解码 + 向量迁移失败必须留下原因
## 生产现状(2026-10-23 排查)
启动日志只有一行,没有任何细节:
[media] 启动向量迁移: 4 个图像块 → fp=cd2a495cf990 dim=512 workers=4
[media] 向量迁移完成: 成功=0 失败=4 总计=4
反查 `memory/graph.db` 才找到根因:
| mime | 块数 | fingerprint |
|---|---|---|
| image/jpeg | 153 | 当前 fp |
| image/png | 394 | 当前 fp |
| **image/webp** | **4** | **空**(从未嵌入成功) |
而 `providers/chineseclip/image.go` 只注册了 gif/jpeg/png:
_ "image/gif"
_ "image/jpeg"
_ "image/png"
⇒ `image.Decode` 对 webp 返回 `image: unknown format` ⇒ 这 4 个块
**永远**拿不到向量 ⇒ 语义检索永远搜不到它们,且没有任何报错。
## 两个独立缺陷
### ① 功能缺口:webp 不可解码
加 `_ "golang.org/x/image/webp"`。这是纯 Go 实现、无 cgo,不改变二进制形态。
代价:`x/image v0.25.0` 硬性要求 `x/text >= v0.23.0`,因此 `x/text` 从
`v0.3.8` 升到 `v0.23.0`。已实测 `providers/chineseclip` 全绿(分词器用
`x/text/unicode/norm` 做 NFC 归一化,升级未改变行为)。
### ② 可观测性缺口:失败被 `continue` 吞掉
原实现:
if err := a.reembedOneBlock(b, fp); err != nil {
atomic.AddInt64(&failed, 1)
continue ← 原因就此消失
}
现在按 `mime | error` 聚合后打印(不逐条打,上千张图会刷屏):
[media] 向量迁移完成: 成功=0 失败=0 不支持=4 总计=4 fp=cd2a495cf990
[media] 失败原因分类(按 mime | error 聚合):
[media] 4 个: image/webp | chineseclip: 解码图像: image: unknown format
同时区分 `ErrModalityUnsupported`(**永久**无向量,重试无意义)与临时失败,
单独计为 `不支持=N` —— 混在"失败"里会让人以为重试就能恢复。
## 判据(4 条,全部变异自证)
| 判据 | 覆盖 | 变异 |
|---|---|---|
| `TestWebpIsDecodable` | webp 能被 `image.Decode` 识别 | 注释掉 webp 注册 ⇒ 红并指名「image.go 里缺少 ...」 |
| `TestWebpPreprocessProducesTensor` | webp 能走完整预处理(缩放+归一化),不只解码 | 同上 ⇒ 红 |
| `TestPngStillDecodable` | 注册表改动没弄坏 png | — |
| `TestReembedStaleMedia_LogsFailureReasons` | 失败原因(mime + 错误原文 + 计数)必须进日志 | 删掉打印 ⇒ 红 |
| `TestReembedStaleMedia_SeparatesUnsupportedFromFailed` | 不支持模态单独计数 | — |
webp 夹具用 ffmpeg 生成的真实 2×2 webp(44 字节)。手写字节序列构造不出
合法容器——第一版手写就失败了,这是实测教训。
## 验证
- `providers/chineseclip` / `internal/agent/core` / `internal/memory/vector` 全绿
- `go build ./...` / `go vet ./...` 干净
- 全量回归:见提交后 CI
|
2026-10-23 15:50:59 +08:00 |
|
|
|
d3590192e7
|
fix(outputch): 内核不再替插件判断载荷语义 —— 空 payload 是合法占位
## 实测踩到的真 bug(生产环境)
QQ 插件重装后首次实测 `sticker_index` 用法,直接失败:
output_send__qq result: 参数有误,未发送任何内容:payload 不能为空
而插件侧明明有这个放行分支:
if payload == "" && !(rawType == "sticker" && stickerIndex > 0) {
return nil, fmt.Errorf("payload 不能为空")
}
分支永远走不到 —— 内核先拒了。
## 根因:两处内核越权判断
| 位置 | 旧判断 | 问题 |
|---|---|---|
| `parseOutputItems` | `strings.TrimSpace(p) == ""` ⇒ 拒绝 | 内核不知道 payload 语义。`type=sticker` + `meta.sticker_index` 要求 payload 留空占位,真实 URL 由插件从收藏表情取 |
| `sendOneOutput` | `stageCtx.FinalText == ""` ⇒ "输出被插件清空" | 原本就空的占位被判成异常 |
**正确边界**:参数**是否存在**归内核(没传 `payload` 键 ⇒ 拒绝,结构性错误内核能判);参数**值是否合法**归插件(空串 ⇒ 交插件判断并返回 error)。
这与本轮 outputch 的另一条改动是同一条原则(`type` 的能力校验下放插件):
**内核只透传与对齐,不解释插件自定义字段的含义**。
## 连带修正:`stringOrArray("")` 不再返回 nil
原来 `payload: ""` 被当成"未提供",与"没传 payload 键"混为一谈。
现在空串**保留为一个元素**:前者是模型明确给的空占位(须走到插件),
后者才是真的没提供(由 `len(payloads) == 0` 拦下)。
## 判据调整(原有判据与正确设计冲突)
`TestOutputSend_RejectsEmptyEntries` 断言"含空条目必须整 call 拒绝" —— 那是
按旧的越权设计写的,**与正确边界冲突**。改写为三条分别钉住边界:
| 判据 | 断言 |
|---|---|
| `EmptyPayloadDeferredToPlugin` | 内核不拦空条目,交插件判断(2 条都要送到) |
| `EmptyStringIsNotMissing` | `payload: ""` 是占位而非缺失,必须走到插件且原样透传空串 |
| `MissingPayloadStillRejected` | 完全没传 payload 键仍拒绝(内核能判的结构性错误) |
变异验证(把 trim 判空加回 `parseOutputItems`)⇒ 两条同时变红:
- 「内核不该拦空条目,应交插件判断,实际只发 0 条」
- 「空字符串 payload 应交给插件(sticker_index 用法依赖它),实际 0 次调用」
## 验证
- `go test ./internal/agent/core/` 全绿(含新 3 条)
- 生产实测修复前后对照(journalctl 原文):
21:54:35 output_send__qq result: 参数有误,未发送任何内容:payload 不能为空 ← 修复前
21:56:34 output_send__qq result: ok ← 修复后
- webui / agent-core 全绿
|
2026-10-22 22:42:02 +08:00 |
|
|
|
b20e32d81d
|
feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求)
模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。
改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定
如何回复」,语气偏描述。改为开篇总述句:
**只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**
返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。
同时补上本次内核新增能力:payload 传数组可一次发多条。
## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent"
用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**:
ProxyAuthAgent = "agent" ← 新值
ProxyAuthAgentLegacy = "homeagent" ← 只用于兼容读取
ProxyAuthHomeAgent = ProxyAuthAgent (Deprecated 别名,存量源码可编译)
为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里
写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19`
就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒
反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。
### 归一化是安全关键
`EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。
判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较
—— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy
⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。
### 顺带修掉两处隐患
1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为
`sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成
未知值,而未知值在旧代码里等于「无鉴权」。
2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。
## 判据
| 判据 | 覆盖 | 变异自证 |
|---|---|---|
| `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 |
| `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 |
## 验证
- `go build ./...` / `go vet ./...` 干净
- webui / internal-sdk / agent-core 全绿
- SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败
|
2026-10-22 21:17:05 +08:00 |
|
|
|
f850d754c1
|
feat(outputch): 一 call 多发 + 多类型 —— 内核级通用能力(非某插件专属)
用户要求:内核 outputch 是通用基础,传入 meta/payload/type(三者均为**插件自定义
字段**)。要支持一次调用发多条、多种类型消息,通过 payload 数组 + type 数组实现。
## 内核原有形态的限制
output_send__<ch> 每次只能发一条单类型:
payload: string / type: string / meta: string
带附件或多段内容的通道只能额外注册独立 tool(如 QQ 的 qq_send_file),
而这不该是插件的职责 —— "发一条消息"本来就是输出门的语义。
## 改法
### ① 工具 schema:oneOf [string, array]
payload / type / meta 三者的 schema 改为同时接受字符串与数组。
payload 是字符串时走单条路径,**行为与改动前逐字相同**(向后兼容,用户拍板)。
### ② 解析规则(parseOutputItems)
- payload 与 type 必须等长;type 省略或单值 ⇒ 广播
- meta 允许**单值广播**(用户拍板)
- 长度不符 ⇒ **整 call 拒绝,一条都不发**
- 含空条目 ⇒ 同样整 call 拒绝(不能静默发空消息)
### ③ 能力校验下放插件(用户拍板)
删掉内核里的 `switch rawType { case text/voice/image/file: 查 caps }`:
· type 是**插件自定义枚举**,内核不认识它。写死四个值 = 内核在理解插件语义;
· 且插件的自定义类型(如 QQ 的 sticker)会被内核无端拒绝。
内核只保留两件它确实该管的校验:**授权闸**(IsOutputAllowed)与
**通道存在性**。type 与能力的匹配由插件在 handler 里判断并**返回 error**。
### ④ 部分失败:继续发剩余的,如实逐项汇报
用户要求"部分失败向 agent 返回失败项"。实现选择"继续发完"而不是"中止":
第 2 条图片失败不该把第 3 条文字也丢掉,否则"一次发多条"失去意义。
返回形态:
全部成功(多条) 已通过 [qq] 通道发送 3 条
部分失败 已通过 [qq] 通道发送 2/3 条:
[1] text ok
[2] image 失败:<插件返回的错误原文>
[3] text ok
失败的条目请单独重发。
单条成功仍返回 "ok"(与旧版完全一致,避免富回执驱动回声效应 ——
output.go 里有实测依据)。
### ⑤ 拆出 sendOneOutput
before_output / 安全回填 / after_output 这套顺序对**每一条**都必须完整走一遍。
混在批量循环里极易漏环(例如只给第一条跑 stage),所以把单条路径独立成函数,
批量只是循环调用它。
## 判据(8 条,全部有变异自证)
| 判据 | 覆盖 |
|---|---|
| SingleValueUnchanged | 单值 payload 行为与旧版逐字相同(返回 "ok") |
| ArraySendsInOrder | 数组按顺序发 N 条,type 一一对应 |
| MetaBroadcast | meta 单值广播给所有条 |
| LengthMismatchRejectsWholeCall | 长度不符 ⇒ 一条都不发 |
| PartialFailureReportsAndContinues | 第 2 条失败仍尝试第 3 条 + 如实列出失败项与插件错误原文 |
| KernelDoesNotInterpretType | 自定义 type "sticker" 原样透传,内核不拦 |
| RejectsEmptyEntries | 含空条目整 call 拒绝 |
| UnauthorizedStillBlocked | 授权闸不被批量绕过 |
变异全部被抓:
- 第 2 条失败即中止 → 「实际只调用 2 次」
- 恢复内核 type 能力校验 → 「内核不该对 type 做能力判断」
- meta 不广播 → 「meta 未广播到」
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 现有 output 相关测试(output_grant / output_rules / resident_output)全绿
## 未做(下一步)
- example 插件(qq)收回 qq_send_file / qq_send_sticker,改走 outputch
- outputch 说明迁入各插件 skill(保留 _help 机制)
- 插件侧补 type 能力自校验(校验已下放,需要它们真的做)
|
2026-10-22 21:05:17 +08:00 |
|
|
|
f256098c9f
|
refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent
仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:
- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
目录内的 module/app 描述同步;**证书路径原样保留**
(default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author
★ 必须保持原样的两处(改了会坏):
· legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
—— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
· 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
—— 那是 SDK 契约里的值,属独立仓库,本仓不改
## ② 修 21 处乱码(U+FFFD)
这些是历史编辑留下的编码破坏,全在注释里,例如:
「赌注重下���遍」→「赌注重下一遍」
「「不��滤」」 →「「不过滤」」
「**待��退场**」→「**待退场**」
★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。
## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)
实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:
【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
【三层记忆】 1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)
收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":
工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
关系用 memory_recall / 成文知识用 knowledge_search
判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。
## 判据
prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)
变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
|
2026-10-22 20:49:44 +08:00 |
|
|
|
6bea130639
|
fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层
用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。
## 我上一轮的错误(PR #22)
把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记:
doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory)
→ L1 裁掉它 → Prune 跳过归档(错误实现)
→ 三层都不在 ⇒ 内容永久丢失
Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。
这不是自激防护,这是把数据丢掉当成防护。
## 正确区分(两件事不能共用一个标记)
| 对象 | 进计算层 | 进归档 | 理由 |
|---|---|---|---|
| doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 |
| **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 |
## 修法
ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」,
与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。
自激的入口在"重新计算向量"那一侧(textForVector 返回空),
不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。
## 判据(改写了那条断言错误行为的判据)
TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事:
1. 回填事件带 NoMemory 标记且未被向量化(不参与计算)
2. 前提成立:L2 里确实已无该事件(Consume 删掉了)
3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失)
变异验证:
- Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」
- restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权)
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
|
2026-10-22 20:37:34 +08:00 |
|
|
|
71e2b83a76
|
fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
用户指出(2026-10-22):doc_query 的结果走 NoMemory 路径,否则会对记忆块
产生自激震荡。核实后确认**两个入口都开着**。
## 回路
doc_query
├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
│ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
│ → Prune 因相关而保留它们(挤掉真正的新事件)
│ → 下一轮 Prune 又把它们归档回 L2
└─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
## ① 工具输出:doc_query 未声明 NoMemory
基础设施早就有(textForVector 里按 ToolDef.NoMemory 跳过工具输出),
但 doc_query 是**内置工具**,它的声明无处安放:
StageHost.ToolDef 只覆盖插件工具,内置工具由 executeToolCallInner 的
switch 分派,不经过注册。
修法:
- internal/sdk/tool.go:BuiltinToolDef 加 NoMemory 字段(与 ToolDef.NoMemory
同义,两条路必须能声明同一件事)
- internal/agent/core/tooldefs.go:declareNoMemoryTool + noMemoryOf,
由 init() 里**显式名单**登记 doc_query
- internal/agent/core/context.go:textForVector 同时查插件 ToolDef 与
内置声明表(原来只查前者)
★ 为什么在 init 显式登记、不靠 toolDefWith 构造时登记:
工具表是按需构造的(受 `if a.docStore != nil` 之类条件控制),
而 textForVector 可能在构造之前被调用 ⇒ 声明会"时有时无"。
实测踩到:构造前查 noMemoryOf("doc_query") 得到 false。
★ declareParallelTool 顺带改为**按字段合并**(原来整体覆盖):
同一工具可能同时声明 parallel 与 noMemory,覆盖会让后登记的那项
静默丢失 —— 声明丢一个是最难察觉的一类问题。
## ② 回填事件:加 ContextEvent.NoMemory
ContextEvent 新增 NoMemory 字段(此前只有 ToolDef/ChannelDef 有)。
restoreDocEvents 回填时置位(含存量降级路径),于是:
- textForVector 直接返回 "" ⇒ 不向量化、不参与关键词/蒸馏
- PruneWithProtected 归档前过滤掉它们 ⇒ 不再产生新的 L2 副本
语义与 ToolDef.NoMemory 一致:**原文仍在上下文可读**,只是不进计算、
不再产生新记忆。这是「回填是为了让模型读到原文」的本意。
## 判据(3 条,钉住两个入口 + 一个后果)
selfexcite_test.go:
- DocQueryDeclaresNoMemory:声明可查 + 工具输出不进 textForVector
- RestoredEventsNotReArchived:回填事件带 NoMemory 标记 + Prune 后
L2 里不出现含回填内容的新文档(真跑 Prune 验证回路)
- RestoredEventsDontCrowdOutNewOnes:回填事件不被向量化(否则会
参与相关性打分、挤掉新事件)
变异全部被抓住:
- Prune 不过滤 NoMemory → 「回填事件被 Prune 又归档回了 L2」
- restoreDocEvents 不置 NoMemory → 「没有 NoMemory 标记」+ 回路成立
- init 不登记 doc_query → 「未声明 NoMemory」
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 公开 SDK(third_party/homeagent-sdk)未改动 —— 改的是 internal/sdk
|
2026-10-22 20:16:57 +08:00 |
|
|
|
e2cb6e70e0
|
feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。
## ① 向量输入:Summary + Content → 事件正文
denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。
改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。
★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
(Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。
## ② doc 层移除 jieba 语义生成
- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)
★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
→ 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
可用,jieba 就不参与。
## ③ doc_query 返回真实内容
旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。
新返回:
已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):
[1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
配色方案怎么定?
结论:紫蓝红三色带按六比三比一…
共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。
每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。
## ④ 提示词:agent 终于能理解三层记忆
- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
并在块尾明确引导「要完整原文用 doc_query」
## ⑤ 向量算法版本(否则修复对存量无效)
实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。
修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。
## 判据(每条都有变异自证)
document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)
core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导
变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。
## 验证
- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
· 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
· 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
区分度最高 0.18(旧实现 0.003)
|
2026-10-22 19:49:25 +08:00 |
|
|
|
b1f02a22b6
|
fix(memory): 向量输入改用事件正文 —— 摘要是 jieba 词袋,把全库拉成同一方向
## 实测缺陷(真实 chineseclip,4 篇主题完全不同的短正文)
[1][3] 0.9478 [2][3] 0.9427
[1][2] 0.9316 [0][3] 0.8614
两两余弦 0.85~0.95,几乎同向。查询分数 0.70~0.78 挤在一起,区分度 0.08。
根因:denseFor 用 `doc.Summary + " " + doc.Content`,而 Summary 由
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成,同一来源的摘要**逐字同质**:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
这与设计直接矛盾:ARCHITECTURE L131/L174 写的向量层级是
「统一多模态空间(主)→ StaticEmbedder → TF-IDF(回退)」,
而 Summary 是**主路径的输入**,却由**最末一级的词法分词**产出。
## 修法
denseFor 改用 documentTextForVector(doc):按事件顺序拼原文(来自块里的正文)。
存量无 Events 的文档退回 Content(渲染视图,但比 Summary 更接近内容)。
★ 绝不含 Summary —— 它是词袋,会把全库拉向同一方向。
## 媒体为什么不单独生成摘要
媒体块与文本块**在同一事件内**(Doc.Events[i].Blocks),事件正文进入向量
即覆盖其语义。为媒体单独生成描述正是 CAS 时代「描述式索引」的错误
(描述是二手信息),v1.2.0 已整体拆除,不重走。
「按图搜图」是视觉路(RecallBlocks 读块自己的向量)的职责,已有独立通路。
## 判据
- TestRetrieval_SummaryNoiseMustNotPolluteVector:主题不同的短正文文档,
两两余弦必须 <0.80。修复前 0.82~0.86。
- TestRetrieval_VectorInputIsBodyNotSummary:正文里的唯一哨兵必须能检索到。
变异:denseFor 改回 Summary+Content ⇒ 前者立刻红(实测 0.8225~0.8573)。
|
2026-10-22 19:33:50 +08:00 |
|
|
|
1dcd3ced03
|
test(memory): 先写检索质量判据 —— 当前红,量化「检索不可用」
上一轮(memory-blockflow-repair-plan)写了 16 条结构判据、6 个变异全绿,
然后用生产数据实测发现检索**完全不可用**:
查询 "doc 层召回是怎么设计的" → 0.7834 内容无关
查询 "hmap 插件安装验签" → 0.7922 内容无关
查询 "QQ 头像" → 0.7601 内容无关
三个互不相关的查询分数全挤在 0.75~0.79,区分度≈0。
★ 教训:结构判据不能替代质量判据。
「字段非空」「条数正确」「不 panic」在检索完全失效时依然全绿。
## 本文件
5 条判据,其中 2 条当前**红**(这就是要修的缺陷):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
→ 用 fakeDenseSpace(按主题词投影),验证排序与区分度骨架
- **VectorInputIsBodyNotSummary**(红)→ 正文哨兵必须进向量
- **SummaryNoiseMustNotPolluteVector**(红)→ 主题不同的文档不得接近同向
## 第 5 条的实测依据(真实 chineseclip,4 篇短正文)
[1][3] 0.9478 [2][3] 0.9427
[1][2] 0.9316 [0][3] 0.8614
四篇主题完全不同的文档,两两余弦 0.85~0.95 —— 几乎同向。
根因:denseFor 用 doc.Summary + doc.Content,而 Summary 由 jieba 词袋生成,
同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
判据用 bigramSpace(字符 bigram 归一化直方图)模拟该行为,
不依赖模型文件,CI 可跑;阈值 0.80,实测缺陷态 0.82~0.86。
★ 正文必须**短**:正文越长 Summary 噪声占比越小、缺陷越隐蔽 ——
这正是生产上只有短文档受害的原因。
|
2026-10-22 19:32:59 +08:00 |
|
|
|
47c95efb80
|
feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里
块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。
## 断点(生产实测)
**① L2 只存渲染视图,事件本体丢失**
ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
- response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
- 工具输出段恰好 203 字符:7593/13261
- ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。
**② L2 里没有文本块,与 L1 不同构**
L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。
**③ 召回造合成事件,时间戳用归档时刻**
doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。
**④ 块在 Consume 时两层同时消失**
Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。
## 修法(按设计,不是按我的推断)
设计前提(写进 ARCHITECTURE 与计划文档):
· 记忆块是唯一物理载体,层是持有关系
· L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
· 正文(含多媒体正文)内联在块里;事件只是块的容器
· 任何一层都不压缩(视图不是记忆)
· 内核不得硬编码任何插件行为
1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
插件名,改由插件经 NoMemory/Cleaner 声明
## 顺带修掉的真实缺陷
simpleHash 是 O(n²) 且哈希串长度正比于内容:
for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。
## 存量
按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。
## 判据(16 条,全部有变异自证)
document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力
变异全部被抓:
· 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215)
· Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1)
· 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
· 召回不回填 Blocks → MigratesBlocksBackToContext
· 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
· 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s)
## 验证
- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确
## 不改
- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
|
2026-10-22 11:09:13 +08:00 |
|
|
|
4e70b8d5d8
|
refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。
- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
(857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
homed/waiter 可执行名、.hmap 后缀
|
2026-10-21 12:17:19 +08:00 |
|
|
|
4e66e644da
|
test(security): 修 -race 下 TempDir 清理假红 —— 断言已过却被清理判失败
## CI 实证(run #86 / #87,Race detector 连续两次红)
失败信息自相矛盾:
--- FAIL: TestE2E_ResponseSecretNeverReachesContext (0.00s)
persist_leak_test.go:94: ✅ 回复侧明文未进 context;变量库 1 条
testing.go:1369: TempDir RemoveAll cleanup: unlinkat /tmp/TestE2E_.../001: directory not empty
断言全过、变量库也拦住了,唯一失败点是 `t.TempDir()` 的严格清理:RemoveAll
要 rmdir 那一刻,变量库还有一次落盘(saveLocked 的 tmp→rename)在途/刚落完,
于是判 "directory not empty"。本地不复现是因为机器快、时序不同;CI 在 -race
下慢一拍就撞上。
## 修法
`newScrubStore` 改为自建 MkdirTemp + 重试清理(50×20ms),最后尽力删除且
**不让清理失败污染判据**。安全判据本身没有放宽——只修测试基础设施。
## 验证
本地 `go test -race ./internal/agent/core/ ./cmd/waiter/ -count=1`:
两个包全绿(16.2s / 1.0s)。
|
2026-10-20 10:24:31 +08:00 |
|
|
|
8a2503796e
|
feat(persona): 说话方式增加「主动发表情包」——情绪到位别只干说话
配套 QQ 表情能力(qq_list_stickers / qq_send_sticker / qq_add_sticker):
人格里补一条表达习惯,让模型在有表情能力时**主动**用,而不是等用户点名要。
分寸也写进人格(一轮最多一两张、要真对得上情绪、没合适的不发),避免刷屏。
实例 personal.md 同步。
注:人格只写"表达习惯",不写工具名/调用方式——那些在 qq-tools 技能里。
|
2026-10-20 10:06:49 +08:00 |
|
|
|
679536692f
|
fix(pluginmgr): 升级后恢复运行状态 —— 重装不该把正在用的插件静默停掉
## 日志实证(2026-10-20 09:54)
重装 qq 插件后,agent 的工具清单里 qq_* 全消失,它只能:
terminal_list → get_plugin_tools ×2 → plugin_start(qq) → plugin_status
→ output_send__qq_help → get_plugin_tools → 才摸到 qq_list_stickers
根因:升级路径 `installFromData` 里 StopAndUnload 停掉旧实例后,**再也没有
把它拉回来**。安装的语义应当是"换个包",不是"把正在用的插件关掉"。
## 修法
- 升级前通过 ListPluginRuntimes 记录 `wasLoaded`
- 解压成功、skill 交接完成后,若升级前在跑则 StartPlugin 拉回
- 返回体暴露 `was_running` / `running_restored`,失败不阻断安装(插件已装好)
实测:`reinstalled qq, was_running=true, running_restored=true`,装完
loaded/alive 均为 true、PID 更新。
## 测试
- TestUpgradeRestoresRunningState:升级前在跑 ⇒ StartPlugin 恰好调用一次
- TestUpgradeDoesNotStartStoppedPlugin:升级前没跑 ⇒ 不得强行拉起
## 同批日志里的另外两条(非代码问题,仅记录)
1. llmsproxy 在 09:52:21 / 09:53:14 被重启,其间 homed 收到 connection refused,
经「stream 失败→非流式 + 2s 重试→换 provider」兜底,用户侧无失败但多耗一轮。
2. 上游返回 404「The model is not registered」与 429 限流(channels-codearts),
属 llmsproxy 侧渠道配置/配额问题,建议在 llmsproxy 里核对该模型注册。
|
2026-10-20 10:01:59 +08:00 |
|
|
|
276553727f
|
fix(persona): 默认人格补「一条消息内不空行分段」
与 SDK 仓的 QQ 通道收敛配套:提示词层先要求不空行(模型大概率照做),
通道层再确定性收敛(模型没照做时兜底)。实例 personal.md 已同步。
|
2026-10-20 09:44:29 +08:00 |
|
|
|
3119d48625
|
refactor(terminal): 终端逻辑回归 agentcli,内核不再编码终端语义
按用户指正调整架构:终端是 agentcli 的职责,内核不该有终端逻辑;插件与
agentcli 的联动应走事件总线,而不是内核内部组件。
## 删掉内核里的终端语义
`internal/agent/core/terminal_registry.go`(280 行)整体删除。它做了两件不该
内核做的事:定义「什么是终端会话」、从 EventToolCall 的 Go map 文本里解析
terminal id / command 并归并会话与命令历史。连带移除:
- Agent 的 terminalReg 字段、构造、subscribeTerminalRegistry 订阅
- StatusAPI 上的 ListTerminals / CmdHistory
- sdk.TerminalAPI + PluginSDK.Terminal() + Registry.SetTerminalAPI 及接线
- 对应测试(迁到插件侧视图包)
保留 `sdk.TerminalStatus` / `CmdExecStatus`:它们只是线上 JSON 格式,WebUI/CLI
返回给前端时口径一致。
## 插件侧订阅事件总线自攒视图
新增 `internal/pluginview`(TerminalView):把原先内核那份归并逻辑原样搬过来,
由插件自己持有。数据源仍是两条总线事件:
EventTerminalOutput(agentcli 发)→ 会话输出 + 生命周期
EventToolCall (内核发) → cmd_run 历史、terminal_create/close 归并
WebUI 在 NewHandler 订阅、Close 退订;CLI 在 Start 订阅、Stop 退订。
两边共用同一实现,避免历史上「两份推导各踩一次 Go map 文本」的坑。
视图不是权威:真值在 agentcli,插件晚于会话启动会缺早期会话(注释写明)。
## agentcli 保持内置
它现在仍需要 `Publish`(进程内事件总线写入)——外部插件没有上行事件通道
(`EventPublish` 在 withheldCapabilities 里,proc 只有 events.subscribe)。
按判定标准留内置;将来要外置,须先开一条受控的插件→内核上行事件通道,
本次刻意不开(用户决定:先保持内置)。
## 文档
中英 ARCHITECTURE 新增「终端归属」小节:说明职责边界、两条事件、视图非权威、
以及 agentcli 为何仍内置;内置判定表里 cli/webui 去掉 TerminalAPI 一项。
## 验收(本机实例实测)
- 内核编译通过,删除后 `go build ./... && go vet ./...` 干净,`go test ./internal/...` 全绿
- 实例重启后:让 agent 开终端跑 `echo term-view-test` →
WebUI `GET /api/v1/terminals` 返回 term_1(output 正确、running=false)
CLI `/terminals`(unix socket)返回同一份;
两处都是从事件总线自攒,内核侧无终端代码参与
- cmd 历史分支由迁过来的单测覆盖(Go map 文本解析、生命周期、上限裁剪)
|
2026-10-20 09:33:08 +08:00 |
|
|
|
b9097986c8
|
fix(prompt): 拼装时空行翻倍 + 人格支持版本占位符 + 配置项说明对齐分层原则
## 1. 提示词里的空行翻倍(用户实测发现)
抓包看实际 payload,system 里出现 3 处连续 3~4 个换行:
…别包装成"稍等我看一下"。\n\n\n【人格设定】
…/mascot.webp 直接能看。\n\n\n\n【场景记忆】
…能否同轮发出。\n\n\n【输出规则】
根因:拼装统一用 `prompt += "\n\n" + block`,而块自己常以换行结尾
(人格块是 "【人格设定】\n%s\n",自定义 system_prompt 也常带尾部换行)。
空行不致命但推高 token,也让「提示词到底长什么样」难以肉眼核对。
修法:新增 appendPromptBlock(trim 块两端 + 只拼一个 "\n\n"),所有拼装点改用它。
重新抓包:连续 3+ 换行 0 处。
## 2. 人格块支持版本占位符
buildSystemPrompt 只对 systemPrompt 调 expandPromptVars,人格块没展开 ——
人格里写死版本号仍会随发版说谎(实测线上人格卡写 v1.0.3)。现在人格同样展开
{{kernel_version}}/{{kernel_commit}}/{{sdk_version}}。
## 3. 人格/系统提示词两个配置项的说明重写
- core.agent.personal_prompt:DisplayName 改为「人格设定(称呼/语气/说话方式/形象)」,
说明只写人设;工具用法写工具描述、多步流程写技能;支持占位符;文件优先。
- core.agent.system_prompt:DisplayName 改为「系统提示词(身份与行为规则)」,
说明只写跨工具/跨通道成立的内容;运行时通道投递/并行/时序语义由内核注入无需重复;
留在提示词里的工具用法会随插件外置而失真。
## 4. 【记忆清理指令】→【记忆维护】
具体选哪个 memory_* 工具、各自副作用早已写在工具描述里(【记忆清理】前缀),
提示词里那份逐条列举属于重复。只保留「必须真做、不能只描述计划」这条
不看工具描述也会犯的错,并要求用一句人话说结果。
## 5. 新增 opt-in 提示词抓包(排查用)
HOMED_PROMPT_DUMP=<path> 时把每轮实际发给 LLM 的 messages 落 jsonl(0600)。
环境变量而非常规配置项:内容是用户隐私,只该在诊断时临时开。
本次排查即由它拿到证据;诊断后已移除临时 drop-in。
验证:go test(agent core / config / sdk / healthcheck)全绿;
实例重启后抓包复核空行归零;对话口吻仍为「短、口语、不列清单」。
|
2026-10-20 09:16:07 +08:00 |
|
|
|
d35b7b7275
|
feat(status): ONNX 状态补 dep_parser 字段;人格改成「说人话」的说话方式
## 1. ONNX 状态补依存句法分析器(onnx.dep_parser)
此前 onnx.enabled 只反映**多模态向量空间**,解析器状态完全不可见 —— 于是
只能靠翻启动日志猜,而日志又曾把「内嵌模型已启用」错报成「规则引擎」
(见上一个提交)。现在健康快照直接给:
"dep_parser": { "enabled": true,
"model_path": "/home/newqqagent/nlp/dep_parser.onnx",
"embedded": true }
- sdk.ONNXStatus 增加 DepParser / DepParserStatus(enabled/model_path/embedded)
- agent 侧由 nlp 包级默认解析器反查实际状态(判据是「真能 Parse 且报得出路径」,
不是「配置里写了 onnx_model_path」)
- healthcheck_kernel 的描述同步说明该字段
## 2. 人格:从「交报告」改成「聊天」
用户反馈实例说话一股人机味。实测原文:
第一件·文件读写:
- 写:files_write 往 /tmp/... 写入 hello-files,回执 11 字节
- 读:files_read 读回来,内容就是 hello-files,对上了
问题不在语气词,而在**结构**:清单体 + 小标题 + 加粗 + 把工具名/字节数/耗时
当成绩单报出来。旧人格只禁了「客服腔四件套」,没禁这些。
DefaultPersonaPrompt 的说话方式一节重写,明确:
- 短句为主;先给结果/情绪,再补解释
- 不用清单体(“-”/“1.”/“•”)、不用小标题/加粗/表格
- 不报工单式细节(不罗列工具名、参数、字节数、耗时)——说人话
- 不用客服腔,拿不准/失败直说
- 只有用户明确要清单/步骤/数据时才结构化
## 3. 本机实例人格同步优化(personal.md,优先级高于配置项)
按同一套规则重写 /home/newqqagent/personal/personal.md,并保留「照大肥鱼那股
活人味来」与形象特征。重启后实测(问「在吗?状态咋样」):
在呢在呢 (`・ω・´)
刚拉了个运行时快照看了眼,状态挺好——内核 1.4.0dev 跑着,29 个插件全部 loaded……
刚忙完就赶紧来找我聊天,老大你这节奏也太充实了呗~
无清单、无小标题、无工具名报账。
验证:go test(sdk / agent core / healthcheck / config)全绿;
本机实例重启后 onnx.dep_parser 三字段正确返回。
|
2026-10-20 09:08:20 +08:00 |
|
|
|
48ca95f964
|
fix(onnx): 启动日志错报「走规则引擎」—— 实际一直用内嵌 ONNX 模型
用户质疑「编译的内核没启用 onnx」。核查结论:**ONNX 是启用的**,是我上一版
日志改错了,误导了判断。
## 事实
- 编译:`make build` 默认 `HOMED_TAGS=onnxruntime`(Makefile 有明确注释,
产物里 `go version -m` 可验 `-tags=onnxruntime`、CGO_ENABLED=1)。
- 多模态向量空间:`provider=chineseclip dim=512` 已加载,健康快照
`onnx.enabled=true`。
- 依存句法分析器:`core.agent.onnx_model_path` 留空时走**二进制内嵌模型**
(`//go:embed models/*` → 解到 `<data>/nlp/dep_parser.onnx` 后加载),
同样是 ONNX 推理,不是规则引擎。
## 上一版错在哪
我只回显了**配置值**(空串),于是打出
`dep parser ready (内置规则引擎;未配置 ...)` —— 把「内嵌模型已启用」
说成了「降级到规则引擎」。原始日志 `initialized (model: )` 的空括号
本来就是同一个信息缺失,我把它改成了**更确定的错误结论**。
## 修法
- `nlp.ONNXParser` 记录实际加载的 `modelPath` 并暴露 `ModelPath()`
(stub 构建下恒为空,两套构建接口一致)。
- 内核启动日志改为报实际路径:
`dep parser ready (ONNX,模型: /home/newqqagent/nlp/dep_parser.onnx)`
部署后实录:该行正确输出实际模型路径;`onnx.enabled=true`;
启动告警仅剩两条 CLI 型技能降级说明(已在上一个提交解释)。
|
2026-10-20 09:04:11 +08:00 |
|
|
|
16c9d4f69e
|
fix(startup): 启动阶段两类噪声归零 —— 插件 stdout 日志与 CLI 型技能误判
用户反馈启动日志里有报错。实测定位到两处,都是真实问题:
## 1. huawei_smarthome 往 stdout 打日志(8 条「收到非法 JSON 帧」)
stdout 是 JSON-RPC **协议通道**,它在 plugin.go/handlers.go/service.go 等处
用了 fmt.Printf(31 处),内核每收到一行就把协议解析失败一次。
这不是内核的错,是插件写法错 —— 已在其源码(/home/newqqagent/plugindev/
huawei_smarthome)把 fmt.Printf/Println 全部改成 log.Printf(stderr),
重新 hmapdev 打包并安装。重启后非法帧 0 条、10 个工具正常注册。
## 2. clawhubadapter 把 CLI 型 main.py 当 JSON-RPC sidecar
判定规则原为「有 main.py 就按 pysidecar 拉起」,但 ClawHub 上大量技能的
main.py 是 **CLI 入口**(`python3 main.py <tool> '<json>'`),对它发 ping
必然超时(bilibili、gaode 各等 5 秒)。旧代码只打一行错误就完事 ——
启动日志一片红,而技能既没变成工具、也没交给 skillmgr,等于半死不活。
改为:sidecar 就绪失败即降级为「CLI 型技能」,移交 skillmgr 并给出说明,
日志措辞从「错误」改为「未就绪 → 按 CLI 型处理」。两个技能现已可经
skill_info 读到文档、再用命令执行。
## 3. 顺带修一条看着像配置泄漏的日志
`dep parser initialized (model: )` —— 空括号是「未配置 ONNX 模型、走内置规则
引擎」的正常回退。改为显式说明,避免误读成缺配置。
## 验收(本机实例重启后实录)
- 「非法 JSON 帧」0 条(此前 8 条)
- dep parser 日志:`ready (内置规则引擎;未配置 core.agent.onnx_model_path)`
- 剩余 3 条均为说明性:mail-bridge 出站通道兜底、两个 CLI 型技能降级移交
- 13 个内置 + 13 个自启外部插件全部 loaded,agent main started
|
2026-10-20 09:00:58 +08:00 |
|
|
|
b5f024b054
|
refactor(prompt): 提示词去工具化 + WebUI 插件状态按运行期真相展示
## 1. 系统提示词/人格瘦身(工具用法搬去 tooldef 与 skill)
用户指出:默认系统提示词与 personal 里有大量本该属于 skill / 工具描述的内容。
核对属实,且已造成**真实故障**:提示词教模型用 cmd_run,而 cmd 外置按需后
工具根本不在清单里 ⇒ 模型第一轮调用不存在的工具,拿到「工具 cmd_run 不存在」。
改动:
- kernelmod/agent.defaultSystemPrompt:删掉 output_* 用法、工具分类清单、
cmd_run/terminal/files 策略、并行策略、媒体工具清单、emoji 风格要求。
只留:身份 + 「工具描述与技能索引会注入,先读再用」+「只依据工具返回值作答」。
- config.DefaultPersonaPrompt:只留身份/称呼/语气 + 版本以运行时快照为准
(后者由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)。
- 命令与文件策略下沉到工具描述:cmd_run 补「完整 bash,支持管道/heredoc/重定向」,
files_write 补「原子替换 + 覆盖受保护路径前留档」。
- 对话时序块自带语义说明(原先靠提示词解释,提示词一精简就丢语义)。
- 文档新增「提示词分层」章节(中英),写明归属判据与这次的反面教材。
## 2. WebUI:插件「启动状态」按运行期真相展示
旧实现只看内核 status 的 loaded 一个布尔,三种形态被混成同一个「未加载」:
auto_start=false 的按需待命(正常)、该起没起(异常)、plugin_stop 后的运行态停止。
用户看到一屏「未加载」以为插件全坏了。
- sdk.PluginRuntimeInfo 新增 AutoStart / RuntimeStopped;registry 从清单填充。
- pluginmgr /plugins 列表暴露 auto_start / runtime_stopped / builtin / tool_count。
- webui 新增 POST /plugins/<name>/start|stop(运行态,不动持久禁用清单)。
- dashboard 状态列改为五态:已禁用 / 进程丢失 / 已运行 / 按需待命 / 未运行,
并额外标注「自启 / 按需」;按钮改为 启动/停止(运行态)与 禁用/启用(持久态)分列。
- 新增两条回归判据(引用 auto_start/runtime_stopped/alive 字段;startPlugin 走 /start)。
## 验收(本机实例实测)
- files 保持 auto_start=false(按需);问 agent 写文件 → 它先 plugin_start cmd+files,
再 files_write/files_read,内容校验一致;未用 shell 代替文件操作。
- WebUI 插件页截图确认:a2a/acp/agentcli 显示「已运行 自启」,
ai_image 显示「按需待命 按需」+「启动」按钮。
- go test(config/agent core/plugin/pluginmgr/webui/sdk)全绿。
|
2026-10-20 08:57:36 +08:00 |
|
|
|
2aab90c508
|
fix(plugins): 内置清单终审 15 → 13 —— kbtree/multimodal 翻案加回
## 翻案依据(逐方法对照公开 SDK,非方法名 grep)
初判把 kbtree/multimodal 归入"只用公开 SDK",错了。两边的
s.Knowledge() / s.LLM() **同名但接口面不同**:
- kbtree 需要 KnowledgeAPI.Subtree/CategoryCounts/SearchIn/Categories
与 KnowledgeTreeView/KnowledgeCategoryCount 类型 —— 公开 KnowledgeAPI
只有 Search/Add/List,树视图类型公开面根本没有
- multimodal 需要 LLMAPI.Chat + LLMMessage/LLMContentBlock ——
公开 LLMAPI 只有 ListSources/SetSource/CurrentSource,没有 Chat
外置它们 = OCR/看图/转写、知识树浏览静默失效。
## 最终内置 13 个
cli webui agentcli healthcheck pluginmgr seq remotedevice
clawhubadapter kbtree multimodal cfgmgr skillmgr seemedia
外置 5 个(SDK 仓 example/ 已提供工程并已部署验证):
cmd localuse mcp(本轮)+ ai_image files(此前)+ timer(更早)
## 文档同步(中英 6 份)
数量 11 → 13;判定标准小节补「终审翻案」与教训:
**判外置与否必须逐方法对照公开接口,同名方法不等于同面。**
验证:go build/vet/test 全绿;生产实例已部署新内核 + 新插件包,
四个按需插件经 plugin_start 拉起、工具全部注册(cmd_run /
local_screensee / mcp_* / files_edit_line 实核)。
|
2026-10-19 22:09:09 +08:00 |
|
|
|
835688c94a
|
refactor(plugins): 按「内置插件判定标准」收敛内置清单 16 → 11
## 判定标准(写进 all.go 与 ARCHITECTURE)
**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在
编译期内置清单**;其余一律外置为 .hmap 插件,独立编译、独立发版。
依据是 internal/plugin/proc/capability.go 的 withheldCapabilities:
内部 SDK 的 TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI/
SelftestAPI/ToolAPI/Publish 等能力面,外部插件在类型层就不存在。
需要它们的插件外置后功能会**静默消失**(不报错),必须内置;
只要公开能力的插件,留在内核里是白付重编成本。
## 内置 11 个(all.go 单一权威清单)
cli webui TerminalAPI/Tracker/Status/Supervisor/Adapter
agentcli EventPublish(终端输出走 Publish,公开 SDK 无此方法)
healthcheck SelftestAPI
pluginmgr failback 恢复链必需
seq ToolAPI
remotedevice RegisterChannel
clawhubadapter InjectInputSync
cfgmgr skillmgr seemedia(保留)
## 外置 8 个(源码保留,.hmap 由 SDK 仓 example/ 提供)
timer(此前)ai_image files cmd kbtree localuse mcp multimodal
## 同步修正
- kbtree 归位:它一直是编译期内置插件,却长期被 main.go 单独导入,
游离在 all.go 清单之外(文档盘点因此数错)。现移入 all.go,
main.go 不再单独导入任何插件,并加注释禁止该做法。
- integration_test:外置插件的 7 个用例(cmd_run/terminal_*)加
requireExternalTool 跳过保护——用例保留(行为判据仍有价值),
在纯内置装配下 Skip,不造假失败。
- 全部文档同步 11 个的清单与判定标准(中英四份 + 两份 README)。
## 已核实不做的事
- 保留的 11 个内置插件无一在代码里硬依赖被外置的插件:
cli 对 terminal_* 是 ExecuteTool 按名动态调用(运行时依赖);
healthcheck/seq 只在白名单与示例文本里出现;webui 只订阅事件。
- kernelmod/(内核模块)与 plugins/(插件)严格区分:
kernelmod/multimodal 是内核装配模块,与外置的 multimodal 插件无关。
验证:go build ./... 全绿;go test ./internal/plugins/... 全绿;
webui 新增 5 条命令历史渲染回归判据(TestCmdHistory*)全绿。
|
2026-10-19 21:13:23 +08:00 |
|
|
|
99914a9e9b
|
test(plugins): 加负向断言守护 timer 不再被注册进内核
timer 已移出内置插件(717054a),但原有 TestIntegrationAllPluginsRegister
只是正向清单 —— 它断言「该有的工具都在」,对「多出来」是瞎的:
把 internal/plugins/timer 与 all.go 的 blank import 加回去,那条测试依然全绿。
于是「已移除」这件事只能靠人记住。
补一条负向断言,在三个互相独立的注册面上各断一次(任一处漏掉都能单独复活 timer):
- 工具面:timer_set / timer_list / timer_cancel 不应存在
- 输入面:inputch "timer" 不应存在(定时器注入中断的通道)
- 注册表:插件元信息与编译期工厂都不应存在(IsBuiltinPlugin 为假)
已做变异验证:把 timer 插件与 blank import 恢复后,四条断言全部触发、测试 FAIL;
移除后 PASS。故该判据非空转。
(cherry picked from commit d5882ee44a4810fe9e5ee02fb329ad423b065d6e)
|
2026-10-19 16:09:03 +08:00 |
|
|
|
6b64d69b10
|
refactor(timer): 从内核内置插件迁为外部插件,移除编译期注册
老大要求「把 timer 移到外部插件,移除内核的内置 timer」。本次只做内核侧
的**摘除**,插件本体在外部工程(plugins/timer → .hmap 安装)。
timer 原先由 internal/plugins/timer 的 init() 注册进编译期工厂表。
后果是改一行定时器逻辑要重编整个 homed(87MB)并重启内核——而它恰是
最该独立演进的部件:有自己的持久化状态、触发语义、并发模型,却被绑死
在主进程的发版节奏上。
搬出来后:换实现只需重装插件 + 重启单个插件进程;插件崩了自己被拉起,
不影响 agent 主循环。
1. 删除 internal/plugins/timer(plugin.go + plugin_test.go)
2. internal/plugins/all.go 去掉该 import,并写明**不要加回去**的原因
3. internal/plugins/integration_test.go 的 expectedTools 去掉 timer_set
TestIntegrationAllPluginsRegister 的语义是「编译期内置插件是否都注册了工具」,
它走的是本地装配(setupIntegration 只加载 internal/plugins 下的内置包)。
timer 搬走后不再参与这个进程的装配,留在列表里只会造出永远绿不了的假失败。
真正不该省的是验证:外部插件的工具是否真注册成功,靠装完 .hmap 后实调
timer_set / timer_list / timer_cancel 来确认(见交付报告)。
- 配置表:外部插件同用 config_<name> ⇒ config_timer,老值 max_duration /
pending_timers 直接读回,老大已设的定时器无需迁移
- 工具名:timer_set / timer_list / timer_cancel 不变,模型侧零感知
- 注入源与通道:source/channel 仍是 "timer"
- 注入级别:内置插件可声明 L4,外部插件被夹到 L3;本插件本就要 L3,
故行为等价
StageHost.RegisterTool 对同名工具返回 `already registered`,而插件注册是
批量失败(新实例的工具一个都注不上)。所以必须**先装新内核**(不含内置
timer)再装外部 timer 插件,反之会撞名。
(cherry picked from commit f584776f23c3bfd8fb2892155e6018a1ec57eac0)
|
2026-10-19 16:09:03 +08:00 |
|
|
|
ecffb8a7de
|
test(plugin): InjectOptions 含 map 后不可比较,改逐字段验零值
Payload 字段(map[string]interface{})加进 InjectOptions 后,
结构体不再可比 ⇒ `z != sdk.InjectOptions{}` 直接编译失败
(invalid operation: struct containing map cannot be compared)。
|
2026-10-18 18:30:04 +08:00 |
|
|
|
aad8ddbf1e
|
fix(core): output_send 的 stage 补 Extra 与 after_output
## 缺陷(2026-10-18 实测定位)
`before_output` 被两个语义共用:
emitResponse(eventloop.go) = 一轮的回复,turn 边界
output.go = 单次 output_send__* 的工具输出
**真正把消息发出去的是后者**,而它构造 stageCtx 时连 Extra 都没初始化:
stageCtx := &sdk.StageContext{
FinalText: payload,
Phase: sdk.StageBeforeOutput,
}
诊断日志实证:`[qq-dbg] before_output: input_source="" qq_uid=<nil> extra_keys=0`
后果:通道插件在"消息即将发出"这个最需要上下文的时点,什么都读不到。
QQ 的「对方正在输入中」因此一次都没能触发。
## 修法
Extra 补三个键:
output_channel 目标通道(插件据此判断"是不是发给我的")
output_meta 本次发送的 meta 原文(user_id / group_id 在里面)
output_type text / image / file / voice
## 同时补上 after_output
这是"消息真的送达"的时刻,但此前只有 before_output。没有配对收尾,
插件只能等自己的超时(QQ 那边是 30 秒)—— 用户看到指示器挂很久。
阶段顺序:before_output(可改写 payload/拦截)→ Execute → after_output。
## 为什么 after_output 放在 Execute 成功之后
失败时不发 after_output:消息没送达,"发出后收尾"的语义不成立,
提前触发会让插件误以为已经发出去(进而在状态跟踪上记错)。
|
2026-10-18 18:01:15 +08:00 |
|
|
|
70f7bea4b9
|
feat(io): InjectOptions.Payload 业务字段扩展,透传到本轮 stage
## 为什么需要
QQ 的「对方正在输入中」必须只在**自己引起的** before_output 里发,且要
知道发给谁。但两条路都断了:
on_input 的 stageCtx 是局部变量(stageCtxFromInput),runStage 完就丢
before_output 的 stageCtx 是内核**新建**的(emitResponse)
⇒ 两者**不共享 Extra**
## 第一版是错的(用户指出)
我先加了 `InjectOptions.Meta` —— 那是把同一件事做了两遍。用户直接问:
「为什么又额外定义了 injectMeta,而不是包含在 inputch 的请求体中?」
查证后的真实边界:`InputEvent.Payload` **本就是通道送入内核的请求体**,
身份信息本该在那里。但 `IOInjector` 接口全是
`(source, channel, text, opts)`,插件无法给 payload 加字段 —— 所以
payload 需要一个**扩展口**,而不是一个平行的新概念。
改名为 `Payload` 还有一个实际好处:所有注入路径(HTTP/CLI/ACP)共用
同一套机制,不是注入专属。
## 内核怎么处理
注入点 opts.Payload
→ 与真 payload 合并(**内核保留键不可覆盖**)
→ emitResponse 把非保留键原样透传到 stageCtx.Extra
保留键清单 `reservedPayloadKeys` 覆盖注入路径自身写入的键
(no_memory/context_policy/recall_policy/scene_policy/cleaner_name/
priority/type/content)与内核读取的语义键。允许覆盖意味着插件能伪造
"这条不记记忆"或"这条要立即打断"——那是权限提升,不是便利。
拦截方式是**跳过**而非拒绝整次注入:插件可能只想带一个业务字段,
因顺手多写一个保留键就让整条注入失败,代价太大。
## 判据(4 条,变异 2/2 咬)
· 插件字段原样进 payload
· 保留键不可覆盖(逐个验证 11 个键)
· 空 Payload 不改变旧行为
· 保留字清单必须覆盖注入路径自己写入的键(否则新键=新提权面)
## 附带修掉的编译破坏
`internal/plugin/lua_surface_test.go` 用 `z != sdk.InjectOptions{}` 比较
零值 —— 结构体含 map 就**不可比较**,加了 Payload 后直接编译失败。
改为逐字段验证。
|
2026-10-18 17:40:07 +08:00 |
|
|
|
40de84facc
|
feat(systemd,webui,security): 数据目录一致性接线 + 内嵌主密钥注入
## 1. 数据目录一致性(原为死代码)
`internal/systemd/sync.go` 能编译但**从未接线** —— 没有任何地方调用它。
内核有**两处**数据目录来源且不等价:
-data <dir> ← systemd 单元 → memory/graph.db、插件数据根
core.daemon.data_dir ← 数据库 → memory/text、documents、knowledge
不一致时数据**分裂**:图记忆在 A、文本/知识在 B,检索跨目录、备份会漏。
生产当前两者一致所以没暴露,但 WebUI 允许改 DB 里的值而**无任何提示**。
接线三处:
· cmd/homed/main.go 启动时检查,不一致则打告警 + 内联修复命令
· GET /api/v1/system/data-dir 返回诊断(只在真不一致时给 sync_command)
· 判据 6 条(InSync/Clean/SyncCommand 内联 sed/备份/daemon-reload/drop-in)
★ 只告警**不改**:静默改 systemd 单元比不提示更危险 —— 改错了服务
起不来,而用户不知道是谁改的。命令里已内联 sed 替换,人复制执行即可。
## 2. 内嵌主密钥(方案 C)
build.sh 支持 `HA_EMBEDDED_KEY_HEX`(64 位 hex)经 -ldflags 注入。
从**环境变量**读而不是写进文件:本仓有两个公开远端,64 位 hex 一旦
写进被跟踪的文件就永久留在 git 历史里。
未设时不注入、不报错:开发机与 CI 本来就不该带生产密钥。
设了但格式不对则**中断构建**(`return 1`)—— 静默产出一个没有内嵌钥的
二进制,会让"防丢失"这个目的悄悄失效。
实测(真机):磁盘上**没有** security.key 时,仅靠 embedded 密钥即可
Submit + 二次加载解密成功;日志确认
`密钥来源 embedded (构建期注入,指纹 0db8e9c5)`。
## 判据第一版的两个错误(记下来)
· 没给 UnitPath 就断言"必须含 sed -i" —— 实际 sed 行只在 UnitPath
非空时生成,判据误报。
· 断言 `/` 必须转义 —— 但 sed 分隔符是 `|` 不是 `/`。`/` 无需转义;
真正必须转义的是 `&`(sed 里表示整个匹配)与 `|`(分隔符)。
|
2026-10-18 13:51:00 +08:00 |
|
|
|
6a15b80ee9
|
revert: 撤掉多余的 auto_start 门(调度器早已处理)
## 为什么要撤
c426921 我以为"外部插件的 auto_start 完全不生效"。**判断错了。**
实机验证:13 个声明 auto_start=false 的插件在重启后确实没加载
(建链 22 → 11)。靠的是**既有机制**,不是我加的门:
internal/kernelboot/scheduler.go:277 canLaunch: if !p.AutoStart { return false }
internal/kernelmod/pluginhost/mod.go buildSkipSet: catalog 里的插件全交给调度器
internal/kernelmod/pluginhost/catalog_test.go:198 判据已覆盖
我当时只看到 `Load` 遍历目录时不查 auto_start,就断定该字段不生效 ——
那只是**没看到另一条路径**(LoadSkipping + 调度器分层)。
## 为什么必须撤而不是留作"兜底"
· 两处逻辑重叠:调度器按 catalog 决定跳过,我的门在 loadOne 里再判一次。
两处各说各话,将来改一处漏另一处就是新的静默不一致。
· 我那条判据测的是**我自己加的门**,不是真实行为 —— 它变绿不证明任何
产品行为正确(撤掉这道门,判据红,但产品行为不变)。
· **没验证过的防御就是噪音。**
## 撤回后行为不变
按需加载链路实测(weather):
plugin_start → loaded=true / alive=true / pid=838963
plugin_stop → loaded=false / alive=false,pid 消失
两者都 persistent="未改动"(不动持久禁用清单)
这些都发生在**没有**我这个门的旧二进制上。
|
2026-10-18 13:36:16 +08:00 |
|
|
|
59f7001932
|
fix(security): 工具输出与参数会原样写进日志 —— 落盘前脱敏
## 缺陷
两处日志把工具内容原样打印,没有经过 security.Replace:
task.go:1038 log.Printf("[agent] tool %s result: %s", ..., truncateStr(result, 100))
task.go:967 log.Printf("[agent] tool %s raw_arguments: %s", ..., truncateStr(tc.RawArguments, 300))
实测场景:enc_shell 跑 curl 拿到 200,响应体里带着上游 key 的完整值
(`{"key":"sk-..."}`),这一行直接写进 /home/newqqagent/log/homed_*.log。
## 为什么日志泄漏比上下文泄漏更严重
模型上下文 短期——一轮结束就不再发送
日志 长期——每天一个文件、从不自动清理,systemd journal 另存一份,
排查问题时还会被贴进 issue
一次落盘就是永久泄漏。凭据轮换前,任何拿到日志的人都能直接用。
## 改动
· 新增 scrubForLog(text, where):唯一入口,空安全(Global()==nil 时原样返回)
· 两处日志调用点都改为先 scrubForLog 再落盘
· 拦截本身记一笔(只记模式与长度,不记原文),便于事后发现"哪个工具常吐凭据"
## 判据(4 条)
· 明文被换成别名(真 Store,不用 mock)
· 普通文本不被改动(不误伤)
· Global() 为 nil 时不 panic(安全模块未启用是常态)
· **接线**:两处调用点确实走了 scrubForLog(只测函数不测接线会漏掉原缺陷)
变异:scrubForLog 直接返回原文 → 咬;工具结果日志去掉 scrubForLog → 咬。
|
2026-10-18 13:20:54 +08:00 |
|
|
|
d9c2e51f61
|
fix(agent): cancel 停不下调度循环 —— 队列非空时永远不看 ctx.Done
## 缺陷(真缺陷,不是测试问题)
`schedulerLoop` 只在"队列空"的分支里 select `ctx.Done()`:
if kind == nextNone {
select { ... case <-a.ctx.Done(): return }
}
于是**只要队列里有任务,循环就永远不看 ctx.Done** —— 一直取、一直执行,
直到队列被取空才可能退出。
生产后果(`reclaim` / `destroy`):销毁流程先回收残余任务、再销毁子。
而回收到销毁之间子仍在运行,继续把队列里的任务取走执行 —— 那些任务的
结果**永远无人认领**(子随即被销毁),且日志无痕迹:既不在"残余清单"里,
也没跑到出结果。带 ResponseCh 的同步调用方(cli/a2a)更糟:Stop 的
drainPendingInterrupts 给它补一个 "skipped",用户看到"已跳过",
而任务其实执行过。
## 修法
1. `schedulerLoop` 循环顶部(**取任务之前**)检查 ctx.Done。
2. 新增 `waitSchedulerStopped`:cancel 之后等 running 归零(上限 5s,
超时如实记日志,不让控制面永久挂住)。
3. 新增 `FinishResident`:停止调度 → 回收残余 → 处置 → 收割记忆 → 销毁,
**一步完成**,消除"取完到销毁"之间的窗口。`reclaim` / `destroy` 改走它。
4. 记忆收割抽成 `harvestResidentMemory`,供 FinishResident 的 onDestroy 复用
(必须在 Destroy 之前 —— Destroy 会关掉 temp)。
## 判据(2 条,都做了"撤销修复必须变红"的验证)
· TestSchedulerStopsTakingTasksAfterCancelWithNonEmptyQueue
用阻塞 provider 把循环钉在 executeNewTask 里,此时塞 5 条进队列、
cancel、放行。修复在位 ⇒ 5 条一条都不执行;撤销修复 ⇒ 取走 5 条。
**实测撤销后报「仍取走了 5 条(剩 0/5)」,恢复后 10/10 绿。**
· TestFinishResidentIsAtomic
断言返回后子已从登记表移除(销毁确实在同一事务内完成)。
原 flaky 的 TestResidualKeepReturnsTasksToParent 与
TestResidualDropNotifiesSyncCaller 也修了:先 cancel + 等静止再入队,
得到确定的初始状态(它们验的是转交逻辑,不是子的生命周期)。30/30 绿。
## 我踩过的两条弯路(都写进判据注释了)
1. 加 `scheduler.freeze` 绕过。错在:调度器本来就有停止语义(ctx),
再引入一个状态位会让"谁负责停"含糊,且忘了解除就永久僵住。已删。
2. 判据第一版写成「先 Start、再 cancel、然后入队」—— 那时队列是**空的**,
循环正好走 nextNone 看到 ctx.Done 退出,于是**撤掉产品修复判据依然
30/30 全绿**。是用户追问"凭什么说修好了"才发现这是假绿。
必须造出"队列非空时 cancel"这个确切场景才测得到。
|
2026-10-18 13:07:40 +08:00 |
|
|
|
c426921847
|
fix(plugin): 外部插件的 auto_start 从未生效 —— 补三态启动门
## 缺陷
`plugin.json` 的 `auto_start` 一路写得出来(hmapdev 打包会写),
但 **Load 的目录扫描从不读它**:`loadOne` 里只有持久禁用门与运行态门。
也就是说这个字段在**外部插件上完全不生效** —— 声明 `auto_start: false`
的插件照样开机自启。
(内置模块那条走 `kernelboot/scheduler.go` 的 `p.AutoStart`,与外部插件
是两条路,所以缺陷只暴露在外部插件上。)
## 三态语义(现在三条都成立)
自动启动 auto_start 缺省/true + 启动扫描 ⇒ 拉起
启用但不自启 auto_start=false + 启动扫描 ⇒ 跳过
同上 + plugin_start ⇒ 拉起
禁用 持久禁用清单 + 任何路径 ⇒ 拒绝(plugin_start 也拒)
★ `explicitStart` 是这次的关键设计点:`auto_start: false` 的含义是
"**别在开机时拉起我**",不是"永远不许启动"。少了这个区分,
按需加载的插件就再也起不来了 —— 比不声明更糟。
## 判据(4 条)
· auto_start=false 在启动扫描时被拦(且不调用 Start)
· 同一插件经显式启动**真的加载并 Start**(plugin_start 可用)
· 未声明 auto_start ⇒ 自启(不误伤存量插件)
· plugin.json 读不到 ⇒ 默认自启(清单损坏不该让插件静默消失)
## ★ 判据第一版是假绿(变异不咬)
第一版直接断言 `loadOneOpt` 返回 true/false。但 **false 有两个来源**:
① 被 auto_start 门拦住(正确)② 没有 plugin.bin 加载失败(噪音)。
两者无法区分 ⇒ 删掉 auto_start 门后判据照样通过。
改成用 `RegisterNative` 注册一个**真能 Start 的**假插件:
返回 true 必然意味着门没拦它,返回 false 必然意味着门拦了它,语义唯一。
修完两个变异都准确咬住(删门 / 忽略 explicitStart)。
|
2026-10-18 11:24:14 +08:00 |
|
|
|
53950e0388
|
test(pluginmgr): 钉住「插件必须走 plugin_install,不能手工替换二进制」
## 我犯的错(2026-10-18)
改完 commondev 后我是这么"部署"的:
python3 -c "zipfile...extractall('/tmp/x')"
install -m 0755 /tmp/x/plugin.bin <插件目录>/plugin.bin
install -m 0644 /tmp/x/rawfile/runtime/pi/sidecar.mjs <插件目录>/...
systemctl restart homeagent
用户一句「为什么不打包为 hmap 安装,而是手动替换」点破了问题。
## 手动替换丢了什么
换成标准 plugin_install 后,返回:
action: "reinstalled", checksum: "13a46f87161d37c8…",
config_kept: true, skills_registered: 1, reload_required: true
这个 checksum 正是 .hmap 的 sha256(实测一致)。而手工 cp 拿不到的东西:
· **整包校验和** —— 包与落盘结果之间没有任何可核对的凭据
· **备份 + 失败回滚** —— 手工 install 半途失败会留下半新半旧的目录,
比彻底失败更难查(插件能加载、某个功能诡异失效)
· **全量路径校验** —— validatePackage 遍历**所有**条目拒 `..`/绝对路径
(该循环曾因 break 漏检 rawfile,已修)
· **rawfile 全量解包** —— 我只覆盖了 2 个 sidecar,其余(node/pi 运行时)
仍是旧的。证据:替换后 `rawfile/` 目录 mtime 停在 10-16,
标准安装后整个目录 mtime 才变成 10-49
· **配置保留与 skill 注册** —— config_kept / skills_registered 在返回值里
## 判据(2 条)
代码无法禁止人手工 cp,但可以把**唯一正确入口**钉住:
· TestInstallKeepsBackupAndRollback:installFromData 必须仍有
「备份旧目录 → extractPackage → 失败回滚」三件事
· TestValidatePackageChecksAllEntries:路径校验必须遍历全部条目,
且所在循环里不能有 break(历史缺陷:manifest 之后的条目漏检)
## 变异
去掉备份 → 咬("安装不再备份旧目录 —— 失败就没法回滚")
去掉回滚 → 咬
|
2026-10-18 10:51:46 +08:00 |
|
|
|
9b2c74bc49
|
refactor(config): 默认值单一来源 —— ConfigDef.Default,不再各调用点重复硬编码
## 起因(用户质疑)
我把 offload 的触发阈值从 5 分钟改成 1 分钟时,同一个默认值散落在四处:
defaultOffloadBusyAfter = 5 * time.Minute (agent/core 常量)
set("core.agent.offload_busy_after", "5m") (seedDBValues 播种)
reg(ConfigDef{... Default: "5m" ...}) (seedCoreDefs 定义)
GetDuration("...", 5*time.Minute) (kernelmod 读取点)
用户问:「为什么不是单一从配置读取,而是硬编码在源码中?」
答:这不是合理的分层,是重复。同一份默认值四处字面量,改一处漏两处
就是**静默的行为漂移** —— 不报错,只是某些路径用 5m、另一些用 1m。
## 改动
**① 默认值的唯一来源 = ConfigDef.Default**
GetInt/GetDuration/GetBool/GetString 新增 defDefault():
DB 里查不到行时,从已注册的 ConfigDef.Default 取,而不是用调用方传的字面量。
调用点现在写 GetDuration(key, 0) —— 那个 0 只是「连 ConfigDef 都没注册」
时的兜底,正常路径永远用不到。
**② 删掉 seedDBValues 里 54 行重复的 set(),改由 reg() 落库**
reg() 原本只 `r.defs[k]=&d`(只登记不落库),默认值另由 set() 写一遍。
现在 reg() = registerDefLocked(登记 + 落库)。
为此抽出 registerDefLocked:SeedDefaults 已持 r.mu,直接调 RegisterDef
会自死锁(sync.Mutex 不可重入)。
**③ 8 个键必须「只登记、不播种」,新增 regMeta()**
core.agent.system_prompt 播种会把版本号冻死在 DB 里
core.defaults.llm_endpoints 空默认值
core.memory.multimodal_space.options.* provider 私有选项
第一版没有区分,一改就红了两条既有判据:
TestSeedDefaultsDoesNotSeedSystemPrompt
TestSeedDefaultsDoesNotInjectIntoLegacyInstall
后者尤其重要:老安装升级被注入新默认值 = 静默加载 1.8GB 向量模型。
**④ seeded 集合:回退只对「本次真播种过」的键生效**
这是修 ③ 过程中暴露的更根本的问题。GetXxx 无条件回退到
ConfigDef.Default 会让**老安装**读到新版本的默认值 —— 即使 DB 里
根本没有那一行。加了 seeded map 区分:
全新安装 → 键已播种 → 回退 ConfigDef.Default(正常)
老安装 → 新键没播种 → 返回零值(不注入)
## 顺带修的
offload_busy_after 的 Type 从 "string" 改成 "duration" —— 它本来就是时长,
用 string 时 WebUI 只能给文本框,拿不到时长控件。
## 判据
沿用既有判据,不新增。新写的 offload_threshold_test.go 已删除 ——
它断言「四处默认值必须一致」,方向是**鼓励重复**,与本次目标相反。
|
2026-10-18 10:16:53 +08:00 |
|
|
|
ef08ebb7d3
|
chore(mascot): 立绘 v2 —— 真 alpha 素材 + 头部裁切头像 + 素材判据
老大给的 4 张新立绘里有 3 张是「棋盘格假透明」JPEG(像素是灰白格子,
不是 alpha)。直接进仓库会在深色面板上露出格子,所以先抠成真 alpha 再落盘。
## 为什么主图与头像要分两个文件
现役 mascot.webp 唯一的消费点是**聊天消息的 30px 圆形头像**
(dashboard.js 4 处 aiAvatar + GUI app.js 2 处),概览页并没有放立绘。
主图是坐姿全身,按现有 CSS(object-fit:cover + scale(1.5))裁成圆后
只剩裙子、看不到脸 —— 只换图不做头像版,等于把头像换成一张看不懂的图。
- assets/branding/mascot-xiaozhai.webp 1024² 主立绘(官方展示)
- assets/branding/mascot-xiaozhai-avatar.webp 512² 头部方裁(运行期源)
- internal/plugins/webui/mascot.webp 256² 头像版(原 133KB→24KB)
- cmd/gui/renderer/mascot.webp 128² 头像版(原 133KB→9KB)
参考稿另存三张(不参与运行):sheet(设定总图)/ portrait(半身)/
turnaround(三视图)。
## 抠图怎么做的(可复现,不是手搓)
背景是 20.45px 周期的棋盘格(237/254 两色)。判据取「中性灰 且 灰度≥228」
的像素做候选,再从图像边界做连通域传播 —— 这样白裙子/白围裙因为不中性
(带蓝调)不会被误判,避免了当年 site/README 记录的 flood-fill 渗色问题。
边界再内缩 1px 抹白边,0.7px 高斯羽化去锯齿。抠完透明占比 55.8%,
深/浅两种背景上目检均无残留格子与白边。
## CSS:scale(1.5) → 1.25
原来的 1.5 是为「全身图裁圆」补的放大。换成头部方裁后,1.5 会切掉两侧
发绳与呆毛 —— 按真实 30px 渲染放大对照看过 1.0/1.15/1.25/1.4 四档。
## 新增判据(含反向验证)
internal/plugins/webui/mascot_asset_test.go 直接读 WebP 容器头,
断言 VP8X 的 Alpha 标志位与 ALPH chunk **同时存在**,外加正方形/≥128px/
≤128KB。理由:这类退化在肉眼 review 里抓不住(缩到 30px 就是一团浅色),
而用户每天都在看;且 site/README.md 记着当年正因为「白底图没法抠」
干脆不放立绘,这个坑值得一个自动化判据。
反向验证(已实跑,非声称):把素材换成 HEAD 旧图,判据报红并准确指出
「Alpha 标志位未置位 / 缺 ALPH chunk / 体积超限」;换回新图转绿。
另有一条独立用例构造「带 VP8X 但不置 Alpha 位」的最小容器,
确认解析器不会对退化素材误判为绿。
## 顺带修的两处
1. assets/docs/{zh,en}/PLUGIN_DEV.md 的 mascot 引用路径是坏的:
`../../assets/branding/...` 从 assets/docs/zh/ 出发解析成
`assets/assets/branding/...`,文件不存在(旧版一直是坏的)。
已改为 `../../branding/...` 并逐条验证路径可解析。
同时指向新的头像版(那处是 20px 圆形内联小图标)。
2. gzip.go / gzip_test.go 注释里的「mascot.webp(133KB)」改为不带死数字
的说法 —— 体积会随素材变,注释写死数字只会变成误导。
## 验证
go build ./... → 0
go vet ./internal/plugins/webui/ ./cmd/gui/ → 干净
gofmt -l → 空
go test ./internal/plugins/webui/ -count=1 → ok(15.1s,含新增 2 个用例)
## 没做的事
- 没碰 main、没开 PR、没部署、没重启服务。
- **概览页仍未放立绘**。site/README 里「素材不可用」这条已不成立(素材有
alpha 了),但版面是否该放图属于设计取舍,不在本次范围 —— 文档已相应改写,
不再用「素材不行」当理由。
- 抠图对发丝边缘是「够用」不是「完美」:0.7px 羽化在 1024 下看不出来,
但若将来要印物料级大图,需要重出带原生 alpha 的源图。
|
2026-10-18 07:38:10 +08:00 |
|