|
|
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 |
|
|
|
5a3a8f484b
|
fix(agent): 工具超时不再一刀切 60s,支持工具自声明与按参数推导
## 缺陷(2026-10-17 生产日志实测)
`toolcall.go` 里写死 `time.After(60 * time.Second)`,与工具自己的超时
**互相矛盾**且让内层形同虚设:
23:13:38 执行 cmd_run(agent 传了 timeout="130" 级参数)
23:14:38 ★ 内核 60s 硬超时掐断 → 「执行超时(60秒)」
同一份日志里 healthcheck 声明默认 120s ⇒ 被砍掉一半,天生跑不完。
## 改动
1. `ToolDef.Timeout` / `BuiltinToolDef.Timeout`:工具自己声明单次上限。
留空 = 沿用 60s(存量插件**一行不改,行为逐字不变**)。
2. 内核取 `max(声明值, 调用参数 timeout)`,再夹到硬上限 10m:
· 声明修好固定时长的工具(healthcheck 5m)
· 参数推导修好时长可变的工具(cmd_run 的 timeout 参数)
· 硬上限防插件声明成 0/无限把队列堵死
3. 非法声明("130" 少单位、0s、负数)**回退默认并记日志**,不静默接受。
写错单位是最容易犯的错,默默变成 60s 会让人以为"声明没生效"却查不出。
4. healthcheck 声明 5m(它的 llm_timeout 默认就是 120s)。
★ 没有把 timeout 放进 `ToSchema()`:那份 map 是**发给模型**的 function
schema。把内核的执行参数混进去,模型会以为需要自己传 timeout 参数。
## 判据(6 条)
未声明→60s / 声明生效 / 参数参与取 max / 参数比声明小用声明 /
非法回退 / 超上限夹住。变异全部咬住。
## 附:这条路只治「工具真的需要更久」
向开发线投递(output_send__*)的两次 60s 超时**不是这个原因** ——
那是 commondev 把"投递"实现成了"同步等任务完成",已在 SDK 仓修成异步。
这里没有给 output_send 放大超时:投递应该瞬时返回,慢是 bug 不是需求。
|
2026-10-18 06:38:51 +08:00 |
|
|
|
683f0126af
|
test(webui): 内核按钮必须真的出现在**渲染后**的页面里
## 为什么需要这组判据
dashboard.html 与 dashboard.js 都是 `//go:embed` 进二进制的,再由 init
组装成 dashboardHTML(替换 {{DASHBOARD_JS}} 等占位符)。这里有两个
**各自都可能坏掉**的环节:
1. 按钮写进了 dashboard.html,但占位符没被替换 / 替换错位
⇒ 页面里看不到按钮
2. JS 函数写进了 dashboard.js,但被包在 IIFE 里(不在全局)
⇒ onclick="kernelRestart()" 点了报 "kernelRestart is not defined"
只查源码里有没有那个字符串,这两种情况都照不出来。
## 判据(3 条,都查组装后的 dashboardHTML / embed 源)
· TestKernelButtonsRenderedInPage:渲染后的页面含两个按钮的 id 与 onclick
· TestKernelFunctionsAreGlobal:4 个函数必须是**顶层**定义
(浏览器 onclick 走全局查找,jsdom 的行为测试做不到这点)
· TestKernelButtonsInNavFooter:两个按钮必须落在 nav-footer 内
—— 它们作用于整个服务,切到任何标签页都该能点到
实测:渲染页面 408057 字节,六项断言全在。
顺带把 dashboard.js 的格式化差异一并提交(-w 对比为空的纯格式调整)。
|
2026-10-17 21:26:12 +08:00 |
|
|
|
198811253a
|
feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议
## 需求
WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。
## 设计:复用心跳,不新开通道(用户指出的方向)
guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。
WebUI 点按钮
→ POST /api/v1/kernel/{restart,shutdown}
→ sdk.KernelControlAPI(只投递,立即返回)
→ 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
→ 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
→ systemctl stop → 不再拉起
★ 关机为什么必须由 guard 做:
unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
(用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。
★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
"指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。
## 顺带修的真缺陷:心跳文件无限增长
生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。
## 安全:能力分级 + 白名单(两道)
1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
★ 判据 `TestCapability_AllMethodsClassified` 当场报出
"未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。
## 判据(7 条)
心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛
变异 6/6 咬住。全量 53 包 ok。
## UI
导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
|
2026-10-17 21:18:07 +08:00 |
|
|
|
134cd860b6
|
fix(webui): 聊天受理超时 15s 太短,误报「已取消(用户停止)」
## 内测用户报告
发一句话,界面回「已取消(用户停止)」,而且连着出现多次。
## 实测真相(用户提供的日志)
20:58:36 POST /api/v1/chat 开始,agent 开始干活
20:58:48 agent 仍在跑(terminal_create / terminal_read)
20:58:52 ★ 前端 15s ackTimer 到点 → ctrl.abort()
→ 服务端记 status=504,耗时 15.89s
20:59:29 ★ agent 正常跑完,52.5 秒、22 个工具、返回 response
也就是说:**agent 从来没有被取消过**。它一直在正常干活并跑完了,
只是前端自己掐断了连接,回复没法送回,UI 就把"我没等到"显示成了
"用户停止"——一个用户从没做过的操作。
## 根因
`sendChat()` 里把"受理确认"的等待写死 15 秒,而 agent 的正常任务
动辄 45-55 秒(工具越多越久;同一份日志里另一次是 45262ms)。
这个 POST 本来就是**触发式**的:真正渲染靠 SSE 流式(`armTurnWatchdog`
兜 120 秒)。在受理环节抢时间没有任何意义,只会误伤正常任务。
## 改动
1. **超时 15s → 600s**:不再用固定短超时。保留上限只为兜住
"连 TCP 都建不起来"这类真故障,避免请求永远挂着。
2. **文案改掉**:原来 abort 后提示「请求超时(可能已发送)」,
红字 error 样式。用户看到"超时"会以为消息丢了而**重发** ——
实测会造成同一句话产生两条任务。改为:
「已发出,AI 正在处理(复杂任务可能要一两分钟)。请等待回复,不要重复发送。」
并且不再用 error 样式(这不是错误)。
## 判据(2 条,源码扫描)
· `TestChatAckTimeoutIsGenerous`:超时 ≥ 5 分钟
(实测正常任务 45-55 秒,留足余量)
· `TestChatAckTimeoutMessageDoesNotBlameFailure`:提示不得出现
"失败/超时/Error/failed",必须说清"正在处理"且提醒"不要重发"
变异 2/2 咬住(改回 15s / 改回"请求超时"文案都当场变红)。
★ 为什么用源码扫描而不是行为测试:这是前端 abort 时序,没有可注入的
时钟。扫描至少能防住"有人把 15s 改回来"。
|
2026-10-17 21:16:49 +08:00 |
|
|
|
662d5472ee
|
fix(agent): 安全装饰器吃掉 provider 的模态能力声明(see_image 失效根因)
## 现象
调用 see_image 工具是通的、图片也确实注入了,但内核回:
当前模型不支持图片,且没有可用的视觉回退源
而配置明明是 `core.llm.sources.llmsproxy.vision = true`,后端也确实支持看图。
用户以为是配置没热生效、以为要重启,实际都不是。
## 根因:一条完整的静默失效链
vision=true(配置是对的)
→ LuaAdaptedProvider.Vision = true
→ ProviderManager.Register 把它包成 *secureProviderDecorator
(security.Global() != nil 时无条件包装)
→ ProviderSupportsVision(p) 做 p.(ModalProvider) 断言
→ ★ 装饰器只实现了 Name/MaxContextTokens/Chat/ChatStream,
**漏了 ModalProvider 的 SupportsVision/SupportsAudio**
→ 断言失败 ⇒ 返回 false
→ prepareToolBlocks 判定"主模型不支持图片"
→ see_image 的 image_url 被当成需要回退的块
→ resolveModalFallback 找不到可用视觉源
→ 「没有可用的视觉回退源」
装饰器只该**改写请求内容**(明文→别名防泄漏),不该改变能力声明。
## 修法
透传给内层:内层实现了 ModalProvider 就照实转,否则 false(不 panic)。
## 判据(4 条)
`internal/agent/api/provider_security_modal_test.go`:
vision 透传 / audio 透传 / vision=false 不得被污染成 true / 无 ModalProvider 不 panic
★ 我第一版判据是**假绿**的,值得记下来:
`WrapProviderForSecurity(p, nil)` 在 store==nil 时**直接返回原对象**
(见其实现),所以根本没包装,测的是未包装的 stub ⇒ 删掉透传也不红。
已改为构造真实 security.Store,并加**包装前置断言**
(`_, ok := wrapped.(*secureProviderDecorator)`),防止再次假绿。
变异 2/2 咬住:
· 删掉 SupportsVision 透传 → TestRegisterPreservesVisionCapability 红
· 一律返回 true → TestRegisterPreservesNonVisionCapability 红
## 实测(完整生产装配路径)
源 llmsproxy 类型=*api.secureProviderDecorator vision=true audio=false
默认源 "llmsproxy": ProviderSupportsVision=true
重启本机实例后:22 插件建链 / 0 panic。
|
2026-10-17 20:20:49 +08:00 |
|
|
|
a68d6a428c
|
feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序
把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。
## 为什么
原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
· nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
只靠 ticker 10min 才没炸(隐式依赖,无声明)
· 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
· 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)
## 怎么改
- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)
## 判据
kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
· 删掉 AddCloser 的锁 → -race 报数据竞争
· Scheduler 同批跑去重 → 203 vs 200
· 图 hash 不匹配 → 拒绝启动
## 顺带修的缺陷
热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。
实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。
* feat(security): 密钥防火墙 + 配置加密 + close 纪律
## 密钥防火墙
模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
· 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
· 入向:模型传别名,入口处填回真值(插件完全无感知)
· 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件
模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。
## 配置加密
- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计
## close 纪律(用户明确要求:只要调 close 就要有前置检查)
internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
· `select { case <-ch: default: close(ch) }` **不是**并发安全的
(两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
· `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
· map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
改成 *pendingRPC 包装,每通道一个 once
## 判据
security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。
* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复
## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)
- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
"未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
(不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
那是进程退出用的,误用于"暂停"就是关了就死
## 数据目录迁移(伪关机 → 迁移 → 唤醒)
- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
→ 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本
★ 迁移脚本的三个实测教训(都写成了判据):
1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)
## 发行包修复(全部经实测确认)
1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
("生产构建(内嵌 hash 非空)但读不到依赖图")
2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
⇒ --version 显示 unknown
4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH
## ★ build.sh 的版本号必须校验(真实事故)
原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。
修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。
## deb 的 systemd unit 改为 guard 模式
ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。
## 其他
- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)
* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)
- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
"出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
mgrIsRuntimeStopped)+ settings 对齐
判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。
* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)
GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:
internal/security/prefilter_test.go:39
internal/security/store_test.go:177
internal/security/survey_test.go:20
它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。
★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。
改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
glpat-abcdefghijklmnopqrst → glpat-FAKE-TEST-TOKEN-NOT-REAL
sk-abcdefghij...012345 → sk-FAKE-TEST-KEY-NOT-REAL-0000000
AKIAIOSFODNN7EXAMPLE → AKIA0000FAKE0000TEST (保持 20 字符)
ASIAIOSFODNN7EXAMPLE → ASIA0000FAKE0000TEST
xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL
★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。
顺带核实:全仓 grep 无任何真实密钥泄漏。
* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一
上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:
VERSION="${VERSION:-$(git describe --tags --dirty)}"
无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。
危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。
修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。
判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。
实测:不传 VERSION 跑 package-linux.sh,输出
[PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
Version: 1.4.0
* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)
CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:
--- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
bridge_sync_test.go:170: 读模板: open
../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
no such file or directory
## 根因:判据依赖了一个**被 gitignore 的路径**
`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
· 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
· CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**
我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。
## 修法
文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。
★ 这不是"放弃检查":
· 跨仓一致性由 **SDK 仓自己的判据**守着
(tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
· 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)
## 验证
· 本机(有文件) → PASS(判据真跑)
· 模拟 CI(移走 tools/)→ SKIP(不再 fail)
· 全量 53 包 ok / 0 fail
## 顺带
同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
|
2026-10-17 19:18:24 +08:00 |
|
|
|
cf3d3769b1
|
perf(starmap): WebUI/GUI 星图卡顿修复 + 抽出共享「布局数学」单一源
## 症状与实测
WebUI 星图建图阻塞主线程 9339ms、draw calls 7514;GUI 建图 2466ms、
draw calls 3477。两者在空闲时仍在空转渲染。
## WebUI(internal/plugins/webui/dashboard.js)
- 力导向布局 O(n²) 两两互斥改**网格近邻**,并移入 **Web Worker**
⇒ 建图阻塞 **9339ms → 57ms**
- 每节点光晕 3753 个 draw call → 1 个 InstancedMesh
- 边 966 → 4 个 LineSegments
- 空闲帧跳过 + 星空时间驱动
- 结果:draw calls **7514 → 3760**,空闲阻塞 **99% → 3%**
## GUI(cmd/gui/renderer/app.js)
- draw calls **3477 → 1809**,建图 **2466ms → 943ms**
- 首屏可见性守卫(GUI 默认 chat 视图,星图不该在后台初始化)
- 空闲 12fps → 0.2fps
修 GUI 两个自引 bug:
1. 抄 WebUI 的 `starmapMorphSet`(GUI 实际用 `state.starmapGrown`)⇒
每帧 ReferenceError、星图 0 帧;
2. `camKey` 读 `starmapCam.position` 无守卫(`switchChatPanel` 同步调
`starmapAnimate` 时 cam 为 nil)。**同一缺陷 WebUI 也存在,两处均已补守卫。**
## 共享源(方案 A:只共享「布局数学」这一层)
新增 `shared/starmap/layout.js`(3 个纯函数,零 DOM/全局引用)+
生成器 `tools/starmap-sync.mjs` + 门禁 `make check-starmap-shared`(已进
make test)。三处(共享源 / dashboard.js / app.js)的标记块必须逐字节相同。
**为何只共享这一层**(实测依据):web 星图 54 个函数、gui 24 个,两者同名
函数逐字节相同数为 **0**。GUI 有设备桥/computer use/托盘/自有面板,WebUI 有
侧栏页签体系 —— 共享受众完全不同的 UI 代码是错的,能安全共享的只有布局数学。
**为何用生成器而非运行时加载**:GUI 用 file:// (main.js 的 loadFile),可能
离线;让其拉服务端 dashboard.js 会把「星图能否初始化」绑到网络与登录态上。
**为何 GUI 不用 Web Worker**:file:// 下外链与 Blob Worker 实测都能跑,但不把
「星图能否初始化」押在打包环境的 Worker 行为上。
## 判据
- `cmd/gui/starmap-perf.test.mjs`(21 条,接入 npm test)
- 扩充 `internal/plugins/webui/starmap_perf_test.go`(同源判据 +
checkStarmapSharedBlock)
- 变异 M1/M2/M3 全部报红
## 附带
cmd/gui/renderer/app.js 同时含上一提交的 GUI `system` 事件消费(协议修复),
因同文件交织未再拆分,见提交 fix(clients)。
|
2026-10-13 16:21:04 +08:00 |
|
|
|
50ef27891f
|
fix(clients): 三方客户端与内核的 SSE 协议一致性 —— 幽灵类型、发了没人收、客户端漏接
一次三方(CLI/GUI/鸿蒙)与内核的协议审计,发现四处**全部静默**的不一致
(无任何测试或日志会报),本次一并修复并把它们变成可执行判据。
## 四处不一致
1. `agent_error` 是**幽灵类型**:SSE 白名单订阅了它、鸿蒙还写了处理分支
(显示「本轮处理失败」),但全仓**无任何发布者** —— events 包与 SDK 都
没有这个常量。于是那段分支永远不可达,而真正的失败帧叫 `error`
(webui/cli 都发 `{type:"error"}`),鸿蒙从未接住。
2. `system` **发了没人收**:internal/plugin/dynamic_proc.go 一直在发插件崩溃
(注释还写着「webui/诊断插件可订阅」),却不在 SSE 白名单里 ⇒ 事件发出来
就掉地上,插件崩溃对用户完全不可见。
3. CLI 缺 `stage`/`system`:CLI 是唯一看不到阶段进展的客户端,agent 跑长任务
时终端长时间静默,用户分不清「在干活」还是「卡住了」。
4. 鸿蒙设备桥**静默丢弃**:只处理 bind_ack 与 cmd,服务端下行的
push / cmd_speech_start / cmd_speech_end / hello_ack 直接 return ——
「收到了但不支持」与「根本没收到」在日志上完全一样,无从排查。
## 修法(注意:三方客户端的通信模型不同,清单不可一刀切)
CLI 走 InjectTextSync **同步拿最终回复**(response 帧)+ 事件订阅做过程输出,
**不做 SSE 重连** ⇒ 它需要 stage/tool_call/reasoning/delta,
但**不需要** agent_output(正文来自同步回执)与 sync_required(那是 SSE
断线重连专用的补拉信号)。GUI/鸿蒙是真 SSE 客户端,两者都需要全量对话事件。
第一版判据按同一份清单要求三者,误报了 CLI —— 那是判据的错,不是代码的错。
- handler_chat.go:subTypes 删 `agent_error`、补 `system`
- dashboard.js / ChatSse.ets:补 `system` 消费(WebUI 用 toast,鸿蒙用 setStage)
- ChatSse.ets:幽灵分支 `agent_error` 改为真实类型 `error`
- cli/plugin.go:补 `stage`(过滤 `_consolidation_`)与 `system` 订阅
- cmd/waiter:补 Phase/Event/Plugin 字段与 stage/system 渲染
- DeviceBridge.ets:静默丢弃改为带 hilog 日志(区分「不支持」与「没收到」)
## 判据(internal/plugins/webui/handler_sse_protocol_test.go,7 条)
TestSSESubTypesAreRealEventTypes(幽灵类型)/
TestEveryPublishedEventHasAnOutwardPath(发了没人收,例外须显式声明理由)/
TestClientsRecognizeServerEventTypes(按客户端分档)/
TestNoPhantomFrameTypes / TestStageFrameFieldsMatchServer /
TestSseWhitelistParsesToExpectedSet(**解析器反向自检** —— 解析器失效会让整组
判据静默变成空集并全绿,是最危险的假绿)/
TestDeviceBridgeOpCoverage。已接入 `make check-client-protocol`(进 make test)。
变异 M1–M8 全部验证。其中 M5 首轮未报红:判据只查前缀 `json:"phase`,
把 tag 改成 `phaseX` 仍匹配 ⇒ 改为匹配**完整 tag** 后才咬住。
## 端到端实测(不只看测试绿)
- 真实 SSE 流收到 stage×15 / content_delta×111 / memory_access×3 /
agent_llm_chain / tool_call×2,**agent_error 0 次**(幽灵已清)
- kill 一个 plugin.bin → SSE 收到 `event: system`
`{"event":"plugin_crashed","plugin":"a2a"}`,日志确认「自动重启 a2a」
- WebUI 真弹出 toast「插件 acp 异常退出,内核正在重启它」(MutationObserver 捕获)
|
2026-10-13 16:20:28 +08:00 |
|
|
|
ec035951cc
|
fix(agent): 批内附加消息延迟到整批落完,修复上游 tool_calls 配对 400
生产实测(llmsproxy tier 1,每 5 秒持续复现,非偶发):
api error 400: 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)
根因在 stepToolAfter:它在**批内循环**里对每个工具各调一次,却当场追加
两类附加消息(多模态注入 mediaMsg=user、记忆召回=system)。于是 N 个工具
的批变成
assistant tool_calls=[c1,c2]
tool c1
user ← 插在中间
tool c2 ← 被挤开,与 assistant 不再相邻
上游按「紧跟 assistant 的连续 tool 消息」计数,只数到 1 条却声明了 2 条。
修复:TaskFrame 增加 batchAttachments / batchRecall 积压附加消息,
新增 flushBatchAttachments() 只在**整批 tool 消息全部落完之后**调用。
两个调用点:并行路径 stepToolBatch 收尾循环之后;串行路径 stepToolBegin
发现批已耗尽时。
这也落实了「多模态注入点应在当前轮工具调用之后」这一设计约束。
判据(internal/agent/core/toolcall_adjacency_test.go):
- assertToolCallsAdjacent —— assistant(tool_calls=[c1..cn]) 之后必须**紧接**
n 条 role=tool 且 id 一一对应。既有 TestBatchToolCallIDsAllPaired 只断言
**数量**配对(其注释明确写着「不断言消息的物理排列」),而数量对、排列错
上游照样 400。
- assertMediaNotSilentlyDropped —— 注入的媒体不得静默丢失。
- 三条用例覆盖串行路径 / 并行路径 / 最小回归。
变异验证(M1 当场追加 media / M3 删并行 flush / M4 删串行 flush)全部报红。
其中两轮暴露「判据太松」并已补强:
1. 测试工具未声明 ParallelSafe ⇒ 整批降级走串行,并行判据形同虚设。
补并行专用判据 + 前置断言(不满足即 t.Fatalf,不静默跳过)。
2. 仅断言相邻性时,删掉 flush 反而"更相邻"(附加消息压根没进来),
判据全绿而媒体被**静默丢弃** ⇒ 补 assertMediaNotSilentlyDropped。
教训:只断言一个性质会漏掉另一个,相邻性与不丢失必须同时断言。
验证:core 包三次全绿 + -race 干净;全仓 48 包 0 FAIL;
端到端真实多工具并行对话正常完成,日志 insufficient tool messages = 0 次。
|
2026-10-13 16:18:13 +08:00 |
|
|
|
c7da8fde82
|
perf(webui): 星图力导向布局改网格近邻 + Web Worker,消除主线程 10.9s 阻塞
生产库 3754 块时,buildChatStarmapGraph 在内联脚本里同步跑 50 轮全对全
(O(n²))斥力:每轮 704 万次配对,50 轮共 3.52 亿次。CDP Profiler 实测
单次阻塞主线程 10.9 秒,页面启动首帧 11.8 秒;PerformanceObserver 在
40 秒窗口统计到累计 40,005ms 长任务。
两条独立问题叠加,本提交分别解决:
① O(n²) → 网格近邻(O(n·k))
斥力体里本来就有 `if (d < 25)` —— 只对近邻施力,全对全是白算。
改为 3×3×3 网格分桶,只遍历邻近格。实测 3754 节点 7016ms → ~120ms。
② 同线程 → Web Worker
即使算量合理也不该占主线程。计算整体移入
static/starmap-layout-worker.js,主线程零阻塞。
配套:迭代数按 n 自适应(小图跑满 50 轮,大图退到 14 轮)+ 迭代间早停
(阈值取平均间距的 0.2%,随规模自适应)。
★ 串行回退(file://、老浏览器、Worker 构造失败)必须用**同一算法**。
若回退走旧全对全,两条路径会给出不同布局 —— 「有 Worker 和没 Worker
看到的图不一样」极难排查。故两边各写一份逐字对应的实现。
★ 另修:renderChatStarmap 必须在容器不可见时提前返回(offsetParent)。
starmapActiveContainer 会 fallback 到聊天面板,导致未打开星图也被迫
建图,把聊天页签一起拖死。
判据 TestStarmapNoPerFrameFullScan 同时覆盖两处实现,经三向变异验证:
① 回退改回 Object.keys 全对全 → FAIL
② Worker 去掉 CUTOFF 网格法 → FAIL
③ 删除 Worker 文件 → FAIL
|
2026-10-13 10:12:57 +08:00 |
|
|
|
ceb8a7b165
|
refactor(proc)!: 版本身份只由版本号本身承载,删除全部自定版本号
内核侧配合 SDK 仓 b2872f3:兼容性不再依赖任何自造版本号。
删除三处自造号:
- ProtocolVersion(协议号)→ 改为比对主版本段(MajorVersion)
- shmVersion(共享段 header)→ 改为校验描述符个数(field_count)
- unifiedVersion(SuperBlock)→ 结构自描述,不再校验
握手契约随之调整:
HandshakeParams: protocol(int) → core_version(string)
HandshakeResult: protocol(int) → sdk_version 承担判据
内核比对 MajorVersion(meta.Version) == MajorVersion(res.SDKVersion),
跨主版本即拒并提示重编(主版本跃迁 = 破坏性变更的定义)。
为什么废除:额外的号需要人去记得升,而它没有任何结构性约束兜底,
必然腐烂。实证:ProtocolVersion 常量在多次布局变更里只升过一次,
对应的守卫测试长期失败无人察觉(期望 18/1,实际 19/2)。
新增判据:
- TestProcess_MajorVersionMismatchRejected:跨主版本被拒(testdata/badproto)
- TestProcess_SameMajorDifferentMinorAccepted:同主版本放行(新增 testdata/oldminor)
后者是 §七「1.x 内纯追加、不调新面不用重编」规约的落地 —— 若误用全等
比较,未重编的存量插件会被无谓地全部拒绝。
两条均经变异验证:改成全等比较 → 前者过/后者挂;删掉校验 → 前者挂。
|
2026-10-13 09:39:34 +08:00 |
|
|
|
31de3921ed
|
Merge branch 'fix/proc-extra-frame-passthrough' into refactor/block-union-payload
|
2026-10-13 08:51:11 +08:00 |
|