|
|
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 |
|
|
|
1d843daf2a
|
fix(guard): 心跳提前启动 + 首次同步写入 —— 启动竞态的 worker 侧根修
562ba6bd 修了 guard 侧(给旧 mtime 加启动宽限,不误杀),那只是防御。
**根因**在 worker 侧:心跳文件要等全部重型初始化完(原 main 尾部)才
首写,且首次写是异步(在 goroutine 里,与多模态空间/知识库重建竞争
调度)。停机 >30s 后重启时 mtime 停在上一代 worker,guard 的 5s ticker
先到就 SIGKILL。
两层独立生效:
worker 侧(本提交,治本):dataDir 一确定就启动心跳,首次写同步
guard 侧(562ba6bd,防御):保留启动宽限期容忍旧 mtime
## 改动
- main.go:startHeartbeat 从尾部(190)移到 dataDir 解析后(54),
用 context.Background()(心跳生命周期=进程,与 agent ctx 解耦)。
- bootstrap.go:startHeartbeat 首次 writeHB() 提到 goroutine 之外
同步执行——返回前 mtime 必已变新,main 往下做慢事之前 guard 看到的
是新心跳。
## 判据(TestHeartbeatFirstWriteSynchronous,变异测试通过)
(a) 返回后立即查 mtime:必须是新的(同步首写),不许等到第一个 tick
首写移回 goroutine(异步)→ (a) 当场 FAIL,已实测
(b) 5s 后 mtime 再变(ticker 在跑)
(c) stop() 后不再更新(hbStop 生效,不泄漏 goroutine)
判据覆盖完整生命周期:启动即新 → 周期更新 → 停止即停。
cmd/homed 包全量测试通过;全仓生产编译 GREEN。
|
2026-10-12 19:32:04 +08:00 |
|
|
|
562ba6bd98
|
fix(guard): 启动宽限期 —— 停机重启后不再误杀加载中的 worker
## 故障(2026-10-12 部署实测,6 连杀 + 服务降级)
停机 34s 后重启:heartbeat 文件 mtime 停在**上一代 worker**(已远超
30s 阈值)。新 worker 要先做完全部重型初始化(chineseclip 512 +
两个静态词向量 + 知识重建,冷启动 5–8s)才写第一次心跳
(startHeartbeat 在 main.go:190,位于所有初始化之后)。
而 guard 的 5s ticker 先到、拿旧 mtime 判 stale → 把还在加载的
worker SIGKILL → 循环重启,guard 进入 failback Safe-Mode:
19:00:28 worker up pid=931403 boot=normal
19:00:33 heartbeat stale for pid=931403 ... SIGKILL ← 加载中
19:00:33 worker up pid=931557 boot=normal
19:00:38 heartbeat stale ... SIGKILL
...(6 连杀后)
19:00:43 3 failures >= max 3, entering failback (Safe-Mode)
worker 自身日志完全健康(graph/multimodal/knowledge 依次就绪),
纯死于"还没来得及写第一次心跳"。
## 根因
heartbeatStale 对「文件不存在」宽限,但对「文件存在、mtime 是
上一代的」不宽限——停机超过 heartbeatTimeout 的每一次重启都会踩。
旧内核未暴露只因它从未停机这么久(上一次是系统重启,mtime 新鲜)。
既有缺陷,block-union 重构未触碰该逻辑(main 分支同病)。
## 修法
spawn 后给一个 heartbeatTimeout 的**启动宽限期**:期间即使心跳
文件 mtime 极旧也不判杀。宽限期满后恢复原判定(卡死 worker 靠
宽限躲不过去)。决策抽成纯函数 shouldKillStaleHeartbeat。
## 判据(TestStartupGraceAllowsInit,变异测试通过)
(a) 宽限期内 + mtime 极旧(上一代遗留)→ 不许杀
(末沿 -50ms 也钉住:ticker 恰在 deadline 前触发是常态)
(b) 宽限期满 + mtime 极旧(真卡死)→ 必须杀
(c) 宽限期满 + mtime 新鲜(健康)→ 不许杀
变异(移除宽限判定、直接 heartbeatStale)→ (a) 两条断言当场 FAIL;
恢复后全绿。TestHeartbeatStale(三分支既有判据)不受影响。
cmd/homed 包全量测试通过;全仓生产编译 GREEN。
|
2026-10-12 19:16:57 +08:00 |
|
|
|
baf2ecd7c9
|
test(memory): 块 union 的并发压测(字节与块同表之后的并发热点)
块 union 化把「元数据」与「负载」合并进同一行(memory_blocks.data BLOB),
原本分属两个存储(media.db 元数据 + blobs 文件)的读写现在全压到同一张表、
同一把 g.mu。这条改动是否引入锁争用/丢更新/字节错位,只有并发实测能回答。
7 条压测(全部 -race 通过,约 26 万次并发读、0 撕裂 0 丢失):
1. ConcurrentPutSameContent —— 16 写者同内容:只留 1 行且字节完整
2. LeanRewriteMustNotBlankData —— ★ 瘦块(blockColumns 不含 data)重写
已回填块时,ON CONFLICT 不得把 data 覆盖成空。这正是生产踩过的坑,
变异(把 `CASE WHEN ... THEN memory_blocks.data` 改成 `= excluded.data`)
当场被这条判据抓住。
3. ConcurrentReadWriteNoTear —— 8 读者 + 4 写者(改向量):16456 次读
0 撕裂 0 丢失
4. BackfillConcurrentWithRecall —— 回填(单事务)与读取并发:101033 次读
0 错字节;终态候选清零
5. DeleteConcurrentWithRead —— 用真实删除路径 DeleteEntity:143k 次读
只能看到「完整内容」或「不存在」;终态块与负载一起消失(union 两半同生共灭)
6. ConcurrentSetVector —— 12 写者并发写向量:仍是合法 JSON、维度正确、字节未动
7. ManyBlocksLargePayloads —— 120 块 × 256KB = 30MB 反复读写逐字节校验
★ 为何删除压测不用媒体块:生产**没有**按块 ID 删媒体块的路径。删除发生在
实体级(DeleteEntity 按 text_content 匹配)与孤块清理(PurgeOrphans 只扫
text_content != ''),所以用 DeleteEntity 这个真实路径。
|
2026-10-12 17:37:08 +08:00 |
|
|
|
fdfbcea479
|
fix(memory)!: 视觉路被文本块饿死 —— 模态过滤必须在截断之前
## 回归(块 union 化引入,真实数据实测)
CAS 时代 `crossmodal.go` 的视觉路查 `QueryMediaScored`,SQL 是
`FROM media`——**只含媒体**,结构上不可能被文本块挤掉。
86572d13 把它换成通用 `RecallBlocks`(全局 top-K,含数千文本块),
于是视觉路每查必空。
## 定量证据(生产模型 chineseclip + 真实生产库副本)
同一向量空间内,文本查询与两类块的原始余弦:
| 查询 | 图像块 max / p95 | 文本块 max / p95 |
|---|---|---|
| 图片 | 0.3773 / 0.3554 | 1.0000 / 0.8644 |
| 一张图片 | 0.4096 / 0.3707 | 0.9222 / 0.8693 |
| 照片 | 0.3850 / 0.3651 | 0.9349 / 0.8672 |
图片的绝对相似度天然低于「字面相同的文本」。复刻生产调用
(per = max(8, topK*2),再 `continue` 掉文本块):
per= 8 → 5 次查询 40 条,图像块 0 条
per=200 → 5 次查询 1000 条,图像块 0 条 ← 视觉路被彻底饿死
根因:**「取全局 top-K 再过滤掉文本块」——过滤发生在截断之后**,
名额早已被文本块占满。下面的 `continue` 只是防御,不是筛选手段。
## 修法
`BlockRecallQuery` 新增 `Modality` / `ExcludeText`,并在 **SQL 层**
限定(不是取回来再丢):
- 语义正确:让 top-K 在目标模态内部竞争
- 顺带省算力:不必反序列化 3639 个文本块的向量再丢弃
`crossmodal.go` 视觉路传 `ExcludeText: true`。修复后同一诊断:
`per=200 → 560 条全部是图像块,文本块 0 条`。
## 判据(含变异)
- `TestRecallBlocks_模态过滤在截断前生效`(单测,500 满分文本块 + 2 低分媒体块):
① `ExcludeText` TopK=2 仍取到 2 个媒体块;② `Modality=image` 只返回图像块。
**变异**(同时移除 SQL 过滤与循环内 continue)→ 两条断言当场 FAIL。
注:只移除 SQL 过滤而保留循环内 continue **不算变异**——语义等价,
差别仅在性能(这点也实测确认过)。
- `TestE2E_MultimodalBlockRecall`(-tags 'e2e onnxruntime',生产模型 + 真库):
① 12 个真图块可嵌入(512 维);② 12/12 块能被自己的向量召回(纯向量);
③ 跨进程重开后向量/指纹/字节都在;④ `ExcludeText` 视觉路召到 20 个媒体块。
判据②原写成"必须 top1",实测发现同一张图被存成 4 个块(digest 相同)
→ 向量逐位相同 → 并列满分,top1 只取决于排序稳定性。
改为"必须出现在满分并列集,且排在它前面的都必须是同内容"。
回归:memory / agent-core / sdk 全绿;全仓生产编译 GREEN。
|
2026-10-12 17:18:04 +08:00 |
|
|
|
937164e515
|
docs(memory): 修正三处仍把已删 CAS 当活的注释
9f7d91c2 删掉了 media.Store,但有三处注释还在说"向量存在 media.Store 里"、
"直接用于 media.Store 的稠密余弦检索"——留着会让读者以为那个包还在。
- internal/memory/vector/store.go:多模态稠密空间现用于**媒体块自带向量**的
余弦召回,不再经 media.Store。
- internal/knowledge/knowledge.go:MediaRef 注释澄清字节作为条目自带数据
落在条目目录的 File 上(Digest 只是内容派生身份,同内存无第二份)。
- internal/memory/migrate_blockdata.go:resolver 由调用方提供(迁移命令直读
旧 CAS 冻结布局),sha256 校验也在那一侧做,不在这里重复。
纯注释,无行为变更。全仓生产编译 GREEN;memory/knowledge/vector 包测试通过。
|
2026-10-12 16:55:40 +08:00 |
|
|
|
c620694084
|
docs(memory)!: 同步 CAS 淘汰后的媒体记忆描述 + 清死配置键
承接 9f7d91c2(删 CAS 包):文档与配置注册表仍在描述已经不存在的
内容寻址存储,留着会误导后来者按旧模型接线。
- internal/config/registry.go:删 `core.memory.media.enabled` 与
`core.memory.media.dir` 两个配置键。唯一读取方 initMediaStore 已随
9f7d91c2 删除,两键再无读取处(已 grep 确认 0 处)。
- assets/docs/{zh,en}/ARCHITECTURE.md:媒体记忆一节从"CAS + digest 寻址"
改写为"块 union:媒体即块的一种取值"。要点:
- 负载归属 memory_blocks.data(随块同生共灭,无块外字节层)
- 块身份内容派生 blk_media_<sha256(mime+data)[:12]>(同内容同 ID)
- 检索走块自身向量;负载按需 BlockData / 插件经 Memory().MediaBytes
- 知识库媒体字节落条目目录(Remove 的 RemoveAll 天然清理)
- 跨空间迁移改用 fingerprint 字段名(vec_model 已是旧概念)
并写明为何删(两套存储必然要打补丁、digest 与块 ID 双身份易错位),
避免"旧设计看起来也行"的错觉。
全仓生产编译 GREEN;config 包测试通过。
|
2026-10-12 16:54:46 +08:00 |
|
|
|
9f7d91c219
|
refactor(memory)!: 删除 CAS 包与第二套召回(块 union 化第四阶段:收尾)
块 union 化前三阶段已让块自带负载、回填存量字节。本提交拆掉 CAS 本身:
`internal/memory/media/`(2100 行)+ `QueryMediaScored`(第二套召回)
+ `media_refs`/`ref_count` 引用账本 + `internal/sdk/media.go`(MediaAPI)
+ `SDKConfig.Media` / `SetMediaStore` 接线。删完 CAS 才真正不存在。
## 为何可删(不是"先删了再说")
- **MediaAPI 在生产早已是死的**:`SDKConfig.Media` 自 86572d13 起从未被设置,
`sdk.Media()` 恒为 nil,插件 digest 路径一直走"内容寻址库不可用"分支。
删的是死代码,不是活路径。
- **第二套召回确无调用方**:`QueryMediaScored` 仅被 media 包自身的测试引用,
跨包引用为 0(块召回走 `RecallBlocks`)。
- **heldMediaDigests 补丁**(crossmodal 靠它防召回"无处可归的媒体")
已在 86572d13 删除 —— 两套存储才需要的那种补丁,随存储一起没了。
## 替代路径:块引用取代 digest
插件取媒体从 `sdk.Media().Get(digest)` 改为 `sdk.Memory().MediaBytes(ref)`:
- **新增 `MemoryAPI.MediaBytes(ref) (data, mime, ok, err)`**(内置接口,非 pubsdk):
按块引用(完整块 ID 或唯一前缀)取字节;`ok=false` 而非 error ——
"这个引用没有媒体"是调用方能处理的常态。
- **seemedia / multimodal**:`digest` 参数语义改为**媒体块引用**,工具描述同步。
path/url 两条来源不变(生产实际只用这两条)。
- **webui 知识上传**:媒体字节不再 Put 进 CAS 再拿 digest,而是作为
`KnowledgeMediaRef.Data` 随引用携带,由 knowledge.Write 落进条目目录
(与 content.md 同生共灭,Remove 的 RemoveAll 天然清掉)。
- **回填命令去依赖**:cmd/homed-media-backfill 不再 import 待删的 media 包,
改为直接读**冻结的历史布局** blobs/<d[:2]>/<d[2:]> 并自校验 sha256。
依赖待删代码的迁移工具会在删包那刻失效 —— 迁移必须比被迁移的东西活得久。
## 判据(全部做过变异测试)
- `TestMemoryBytes_ByBlockRef`:完整 ID / 唯一前缀可取回且逐字节一致;
不存在→ok=false;过短前缀(歧义)→ok=false;无负载块→ok=false;空引用不 panic。
变异(不校验 ok,把不存在当有)当场被 3 条断言抓住。
- `TestE2EMediaBytesAgainstBackfilledDB`(-tags e2e,读真回填库):
10/10 块经 MediaBytes 取回的字节与块 data 的 sha256 完全一致。
- `TestKnowledgeUploadedMediaRetrievable` 重写为**条目目录自带字节**判据
(不再是"向存储按 digest 读回");`TestKnowledgeUploadMediaNeedsNoStore`
改为"上传无外部存储依赖也必须成功"(旧的 503 语义已随 CAS 消失)。
## 静态确认
`QueryMediaScored` / `internal/memory/media` import / `MediaAPI` /
`sdk.Media()` / `SetMediaStore` / `heldMediaDigests` 全仓均为 0 处
(仅剩 medialoop.go 一句历史注释)。
回归:memory / sdk / agent-core / knowledge / plugin / webui / seemedia /
multimodal 八包全绿;全仓生产编译 GREEN。
|
2026-10-12 16:52:43 +08:00 |
|
|
|
2585bc67ef
|
feat(memory)!: 存量媒体字节回填(块 union 化第三阶段:数据迁移)
块 union 化后媒体字节归块所有(memory_blocks.data),但存量库里
data 为空、payload_digest 仍指向旧 CAS。不回填就删 CAS ⇒ BlockData
取不到字节,图永久丢失。本提交补上这一步。
生产数据实测(/home/newqqagent/memory):
- graph.db 112 个媒体块,引 106 个唯一 digest
- media.db 156 条 + blobs 87M,112/112 digest 命中
内核(internal/memory/migrate_blockdata.go)
- BackfillBlockData(resolver):单事务、全回滚,按 payload_digest
把字节写进块自身 data 列。保留原块 ID —— 边/场景/向量都挂在它上面。
- CountBlockDataBackfillCandidates:报告模式,不写库。
- ProbeBackfillAvailability:dry-run 看 CAS 命中率。
- ensurePayloadDigestColumn:★ 关键。block union 化后新库的 CREATE
已删该列,旧库还留着。迁移必须两者都能跑:旧库忽略 duplicate
column 错误,新库补列后得到 0 候选(安全空操作),而不是崩在
"no such column"。
- CAS 取不到字节的块计 Missing 并跳过(既有数据损失,不阻断其余)。
命令(cmd/homed-media-backfill)
- 默认只报告,-apply 才写;写前自动 sqlite3 .backup 快照。
- 回填后立刻 BlockData 抽样自检。
判据(4 条单元 + 1 条真实数据 e2e,全部做过变异测试)
- RoundTrip:BlockData 取回字节 == 来源逐字节一致
- MissingDigestSkipped:缺失内容计 Missing 且不阻断
- IdempotentThreeRuns:连跑三次只真回填一次
- FreshDBSafeNoOp:新库(无该列、无历史数据)上是安全空操作
- E2E(-tags e2e,读真 graph.db):112 块全部可取回、sha256 与
payload_digest 全等、抽样 20 块用自己的向量自召回 score=1.0
变异测试(判据不是装饰):
- 回填写空字节 → RoundTrip 挂
- Missing 分支改为也写入 → MissingDigestSkipped 挂
- 候选查询不排除已回填 → IdempotentThreeRuns 挂
- 移除 ensure 调用 → FreshDBSafeNoOp 挂(no such column)
生产数据 e2e 已在 /tmp/e2e-backfill 副本上完成(sqlite3 .backup 快照 +
CAS 拷贝):报告 112/112 命中 → apply 回填 112 块 55.03MB → 独立
交叉验证(python hashlib 全量 112/112 sha256 吻合 + sha256sum 对照
磁盘 blob 一致)→ 幂等复跑 0 候选 → 读路径召回 20/20 命中。
|
2026-10-12 16:23:23 +08:00 |
|
|
|
33e1a0bfaf
|
test(memory)!: 块 union 化判据重写(CAS 淘汰第二阶段:测试)
生产已在上一提交(86572d13)全绿;本提交把 CAS 专属测试改到块语义,
并修掉几处判据自身的缺陷(判据错 ≠ 生产错):
- sdk/memory_impl_test: media.Store→块;NewGraphMemoryWithPlugin
- core/{graphmedia,mediaref,medialive,medialoop,inputunify}_test:
mediaStore→memory;PayloadDigest→Data/BlockData;shortDigest→shortID
- graphmedia: 无引用句子用独立块 ID(shortID 20 字符会与 blk_src_ 撞长度)
- memory/sentence_block: BlockByIDPrefix 加 len<12 下限(短引用守卫)
- core/medialoop: reembedOneBlock 按 BlockData 兜底取字节(lean 列无 data)
- knowledge: broken.png 改目录造读失败(chmod 0000 对 root 无效);
dense/attach 测试补 Data 并先落盘后 AttachMedia
- webui: graph_pulse fixture payload_digest→has_data
注:internal/plugin/proc 的 shmVersion 不匹配(内核1/插件2)是
SDK 仓(777c3a0,v2 模板)与本分支内核(v1)的跨仓版本错位,
与块 union 无关(stash 全部改动后仍复现),属 proc-extra 特性线。
|
2026-10-12 14:26:13 +08:00 |
|
|
|
86572d134d
|
refactor(memory)!: 块 union 化,彻底淘汰 CAS 第一阶段(生产代码)
块自带负载(MemoryBlock.Data),删 PayloadDigest;检索纯向量,媒体随块
同生灭。生产代码全绿;CAS 专属测试将在下一提交随判据重写。
- memory: Data BLOB + migration9;blockColumns 不含 data(召回只比向量);
MediaBlockID 内容派生 ID;BlockByIDPrefix/BlockData 按需加载
- sdk: memory_impl 去 media.Store;MemoryBlockView PayloadDigest→HasData
- core: mediaref/graphmedia/crossmodal/medialoop 全部改块语义;
captureBlockMedia 直接产块;resolveMediaRefs 按块 ID 解析
- knowledge: 媒体字节随条目目录自带(File+Data),删 mediaGet/mediaPut
- webui: graphBlockView/pulse payload_digest→has_data
- bootstrap/registry: 去 MediaStore 接线(SetMediaStore 空实现,待⑥删包)
|
2026-10-12 10:13:36 +08:00 |
|
|
|
6abf03bd78
|
fix(proc)!: shm 桥 v2 —— fExtraBag 透传插件自建 Extra 键,修复绑桁权限门失效
根因(2026-10-12 生产实证):shm 桥此前只透传 4 个具名 Extra 键
(media_blocks/media_type/input_source/output_channel),插件自建键
(qq 的 qq_auth 绑帧身份)在 WriteDirty/captureLocal 处被静默丢弃,
下一 stage ReadInto 重建的 Extra 永远没有它 → authOnFrame 恒 miss →
before_toolcall 回落到可变全局身份。owner 轮被并发的非 owner 中断
合并降权后,自己的 memory_commit 被硬私名单误拒(日志 22:20:58
「改写了 1 个字段」);反向时序则可能把非 owner 误放为 owner。
- shm.go:stageField 加 fExtraBag(19 字段),shmVersion 1→2
(AttachSegment 与插件握手对旧版失败即拒,不静默错读新布局)
- shmcodec.go:captureLocal/WriteAll/ReadInto/newSnapshotFromLocal
全链接入 bag;写侧过滤具名键(bag 不偷具名键槽位);读侧 bag 先
打平、具名键后覆盖(具名键永远赢);不可序列化值显式报错——
宁失败不静默丢键(丢键对 qq 是权限失效)
- extra_bag_test.go:判据 8 条(往返无损/具名键语义不变/不可序列化
报错/WriteDirty 零写与脏写/跨 stage 帧存活/袋污染具名键必输/
raw-JSON 写侧过滤/快照脏检),变异 M1-M3 全杀
配套:SDK 仓 777c3a0(模板 v2 同步 + qq 身份可序列化 + 插件侧判据)。
部署注意:shmVersion 2 后全部 23 个 proc 插件须用新 hmapdev 重编。
|
2026-10-12 08:30:14 +08:00 |
|
|
|
b15994d964
|
fix(seemedia): hear_audio 不再承诺「你会听到」,如实说明可能不支持 (#7)
## 问题(e2e 抓出,非猜测)
端到端跑 hear_audio 时模型答「老实说,这次我没法真的听到」,转而去数 WAV
字节。查日志:
tool hear_audio result: {"note":"音频已投给你——请在下一步直接聆听..."}
modal fallback: 0 block(s) transcribed, 0 skipped
(provider=llmsproxy vision=true audio=false)
真因是**配置缺口 + 描述说谎**叠加:
- 当前部署所有 LLM 源都是 audio=false(visionllm/llmsproxy 均如此)
- core.input_processing.audio.fallback_provider 为空 ⇒ 无回退源
- 于是内核既不能直视、也无法转写,只发一条「拿不到音频」的 notice
而工具描述写死「你会直接听到它」、note 写「请在下一步直接聆听」——
模型同时看到「你会听到」与「拿不到」,两句自相矛盾(这就是它转去数
WAV 字节的原因)。
## 为何不改代码改行为
插件在调用时**无法**得知当前模型是否支持音频,SDK 未暴露能力查询接口
(只有 LLMAPI 的 Chat/ReloadFromConfig)。所以插件没有能力「先判断再决定
要不要注块」。正确做法是:内核继续做它该做的诚实降级(已在 modalfallback
里实现),插件这边**不许替内核承诺**。
## 改了什么
- hear_audio 描述:从无条件承诺改为条件式(「若当前模型支持音频或配了回退源,
你会在下一步听到」),并明说:若都不支持,内核会明确告知拿不到(不会假装
听到),那时改用 transcribe_audio 或配置回退源
- handler 的 note:从「请直接聆听」改为「请确认是否真的收到;没收到就改用
transcribe_audio」
## 判据(可杀变异,3/3 实测)
- TestHearAudioDoesNotPromiseAudibility:描述不得含无条件承诺;必须指明
transcribe_audio 出路;必须如实说「不支持」
- TestHearAudioNoteIsHonest:note 不得说「直接聆听」;必须给替代方案
★ 判据自身修了一个 bug(变异测试抓出):原先用「下一个 RegisterTool」做段
终点,但 hear_audio 是**最后一个**注册 ⇒ 一路截到文件尾,把 handler 的 note
也吞进来,于是 note 里残留的 transcribe_audio 让「删掉描述出路」的变异逃逸。
改用 "p.handleHearAudio)" 作终点后该变异被杀。
变异实测:
F 描述改回无条件承诺 → FAIL ✓
G 描述删掉 transcribe_audio 出路 → FAIL ✓(修判据前曾逃逸)
H note 改回「直接聆听」 → FAIL ✓
I 描述删掉「不支持」说明 → FAIL ✓
18/18 通过;全量 homed 构建通过;二进制内已无旧假承诺、含新的诚实措辞。
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
|
2026-10-11 13:22:15 +08:00 |
|
|
|
16309c3307
|
feat(seemedia): 多模态直视插件——让模型一手看图/看视频/听音频 (#6)
* feat(seemedia): 新增多模态直视插件,让模型一手看图/看视频/听音频
## 动机
现有 multimodal(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。
缺的是**一手**路:让模型主动把原始像素/帧/音频投给**自己**去判读。本插件补上。
## 工具(三模态,用户决策 image+video+audio)
- `see_image`:path/digest/url → image_url 块,经 SetToolBlocks 直投主模型
- `see_video`:ffmpeg 抽帧(默认 6 帧,上限 10,对齐内核 modalfallback 约束)
→ 多张 image_url 块一起投;探时长成功走均匀定位抽帧,失败退单遍 thumbnail
- `hear_audio`:path/digest/url → audio_url 块直投
三者都 `ParallelSafe:false`:SetToolBlocks 写的是「当前 call」的块,并发会串台。
## 与 multimodal 并存(用户决策 coexist)
职责分工,不互相取代:
- 要省 token / 要把结论写进记忆 / 主模型不支持该模态 → describe_image(二手文字)
- 要模型亲自判读细节(看清截图、逐帧看操作) → see_*(一手原始媒体)
## 媒体来源(用户决策 path+digest+url 全支持)
- path:设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
- digest:用户上传已入 CAS 的媒体,sdk.Media().Get 取回,不在内核↔插件间传 base64
- url:http(s) 直链;图/音频原样透传(不下载、不入 CAS),视频交 ffmpeg 原生读 http
## 直接进记忆(记忆载体已块化,天然打通)
seemedia 经 SetToolBlocks 注入的块,内核 task.go captureBlockMedia 处**无条件**
落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退,媒体本体都该
进记忆」)。所以一手看到的图/帧**同时**进了记忆,之后能按多模态向量被
doc_query / memory_recall 召回。本插件不写任何入库代码,复用既有块化链路。
★ 边界(刻意取舍):captureBlockMedia 只对 data URL 入库,http(s) URL 被跳过
(不下载、不入记忆、不算向量,规避 SSRF/超时)。即 path/digest 来源可召回,
url 来源只是模型当场看到但不进记忆。要让 http 媒体长期可召回,先落盘再传 path。
## 形态:内置插件(用户决策 builtin)
与 multimodal 并排 internal/plugins/seemedia,经 all.go init() 注册。代价是延续
「内核进程内读任意本地文件」这个 a02d8b00 本想消除的张力——但 multimodal 已是
内置且同样读 path,保持一致比单独走子进程沙箱更不易让人困惑;日后收紧应两者同搬。
## 判据(可杀变异,实测)
plugin_test.go:
- url 透传且拒 file://(去掉 isHTTPURL 校验 → FAIL)
- path 优先于 digest、url 优先于 path,且 path 命中时不碰 CAS
- 无来源报错且信息可操作(指明 path/digest/url)
- 文件错误带路径、空文件明说「空」
- clampFrames 把帧数夹到 [1,10]、非正回默认(超限不夹 → FAIL)
- 三工具不得声明 ParallelSafe:true(防 SetToolBlocks 串台)
9/9 单测通过;本机 ffmpeg/ffprobe 已就位;全插件 + homed(-tags onnxruntime)
构建通过;internal/plugins 装配冒烟通过。
* test(seemedia): 补集成/压力/变异测试(16 判据,5 变异全杀)
上一个提交只带 9 个纯逻辑单测(来源解析、clamp、MIME)。抽帧是本插件唯一
真正驱动外部进程、且会并发的部分,必须真跑 ffmpeg 压一压:
- 真视频 → 请求 N 帧就拿到 N 帧,每帧校验 PNG 魔数(1/3/6/10 四档)
- ffprobe 置空 → 走单遍兜底仍能拿到帧
- 损坏/非视频文件 → 如实报错,不 panic、不返半成品
- 已取消的 ctx → ffmpeg 必须立刻失败(取消传播真生效)
- 并发 16 路抽帧 → 不串台、不泄漏临时目录(前后计数比对)
- digest 视频 → 真 CAS 取字节落临时文件,cleanup 后必须删掉
- 静态判据:所有子进程必须 exec.CommandContext,不得裸 exec.Command
为什么超时判据从「计时」改成「已取消 ctx」:合成视频抽帧本来就快(~0.1s),
「1ns 超时」和「忽略超时」在计时上分不出——实测这种变异杀不掉,是判据设计
缺陷。改用已取消 ctx 的行为判据(CommandContext 在 ctx 已取消时 Start 即失败)
后,忽略 ctx 的变异被杀。
变异实测(5/5 全 FAIL,还原后全绿):
A clampFrames 超限不夹 → TestClampFrames FAIL
B url 分支去掉 isHTTPURL → TestResolveMediaURL_URLPassthrough FAIL
C cleanup 不删临时文件 → TestResolveVideoInput_DigestCleanup FAIL
D grabFrameAt 忽略 ctx → TestFrameGrabHonorsCanceledContext FAIL
E 抽帧数写死 1 → TestExtractFrames_RealVideoExactCount FAIL
16/16 通过;`-tags onnxruntime` 全量 homed 构建通过,三工具在册。
---------
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
|
2026-10-11 13:06:23 +08:00 |
|
|
|
a7f89a1b45
|
fix(guard)!: L2 自杀防护 + 配置零值兜底 + 补齐零覆盖的测试
部署 guard 前的审查发现两个**致命**缺陷。它们此前都零测试覆盖 ——
正是「没测过才敢发现」。
## ① L2 自杀循环(启用 guard 就会触发)
doLastResort(restart_app) 执行 `systemctl restart <unit>`,而启用 guard 的
标准做法正是让 systemd 管理 `homed --role=guard` —— <unit> 就是它自己:
guard: doLastResort → systemctl restart homeagent
→ 杀掉自己 → systemd 按 Restart=always 拉起新 guard
→ 新 guard 再 spawn 失败 → 再自杀 …… 无限循环
每轮白跑一遍完整启动。与 2026-09-05 那次 systemd 审计记录的事故同类
(坏配置导致 836 次重启、79 分钟)。
### 修法:自指时直接退出,而不是换个单元名
「换个别的服务」语义完全不对 —— L2 的本意就是重启自己。而
`Restart=always` 已经提供了这件事:**guard 退出 == systemd 重新拉起 ==
重启整个单元**。所以自指时 os.Exit(41) 与 restart_app 完全等价,且无自杀窗口。
- `doLastResortGuarded(cfg, unit)`:判自指 → 退出 41;否则原逻辑
- `detectOwnSystemdUnit()`:读 /proc/self/cgroup(cgroup v2),兜底
$INVOCATION_ID → /run/systemd/invocation/<id>。**拿不到就返回空字符串**
让调用方跳过检测 —— 不猜单元名(猜错 = 拦错/杀错服务)
- `exitGuardRestartRequested = 41`:刻意避开 worker 的 42-44 区间
- 两处 doLastResort 调用点全部改走防护版
## ② 配置损坏 → agent 装上就崩
defaultGuardConfig **不解析时长字符串**(只给 "30s" 这类字面量),
parseDur 在 loadGuardConfig 里、且在 yaml.Unmarshal **失败时提前 return
之后**——那段根本不执行。
于是 guard.yaml 一旦写坏(缩进错、半截内容,手改文本极常见):
| 字段 | 实际值 | 后果 |
|---|---|---|
| heartbeatTimeout | **0** | `time.Since(mtime) > 0` 恒真 → **每轮杀 worker** → 无限重启 |
| heartbeatInterval | 0 | `time.NewTicker(0)` panic |
| attemptTimeout | 0 | 恢复 probe 立即超时 → L1 梯子必败 |
| Recovery.Plugins | 空 | 受限启动什么都不加载 |
这不是「配置没生效」,是「装上就崩」。修法:把 parseDur 与插件集缺省
移进 defaultGuardConfig,任何路径都有正值兜底。
## ③ 测试:0 → 25 条(全可杀变异)
| 文件 | 覆盖 |
|---|---|
| `guard_config_test.go` | 配置默认值/YAML/损坏/越界 + 心跳三态 |
| `guard_lifecycle_test.go` | worker 生命周期:6 种退出码分类、信号转发、坏二进制、卡死压力测试、L2 防护行为、单元探测 |
| `guard_safety_test.go` | 破坏性路径:回滚不可逆、任务协议往返、failback 插件集 |
| `rollback_irreversible_test.go` | 不可逆性证据 + 二次回滚 no-op + 新建→删除 |
**7 个变异逐个实测被杀**:parseDur 移回原处 / 心跳漏判 / 心跳误判 /
插件集留空 / exitRecovered→crash / exitRecoveryFailed→recovered /
rollback 不删 changeset / L2 防护失效 / 单元探测返回假名。
### 判据自身被变异测试逼着改三次
1. `isSelfRestartCommand` 的测试全绿,但把 `doLastResortGuarded` 改成
直接调 `doLastResort`(防护完全不生效)时**仍然全绿** ⇒ 补了
**行为测试**:真子进程 + 假 systemctl(PATH 前置),验「自指时
restart_command 绝不被执行」
2. 单元探测只断言「非空+格式合法」,被 `return "wrong.service"` 骗过
⇒ 补了与**独立第二实现**(测试里重写一遍 cgroup 解析)的交叉核对
3. 信号转发测试用 `sleep 300` 会泄漏孤儿进程(sh fork 子进程,信号只到 sh)
⇒ 改 `exec sleep 300`,并加断言检测泄漏(60s → 0.52s)
### 踩到的两个坑(都记在判据注释里)
- **POSIX 环境变量不能含 NUL**:`\x00` 分隔符让 exec 直接报
"environment variable contains NUL",改用 `\x1f`
- **测试函数里 os.Exit 会终止整个测试二进制** ⇒ 子进程入口必须用 TestMain
全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL。
|
2026-10-11 10:09:15 +08:00 |
|
|
|
33a042e4a3
|
chore(sdk-mirror): 同步 vendored SDK meta —— 补 SDKModule/LegacySDKModule
把 SDK 仓 e0ee0c4 的 `meta/meta.go` 同步到主仓的镜像副本。
## 为何主仓要跟踪这一份
主仓有 13 个 SDK 文件是**镜像副本**(`chore(sdk-mirror)` 系列提交维护),
`meta/meta.go` 在其中。主仓 Go 代码通过 module 路径 import 它
(`internal/agent/core/tooldefs.go` 用 `sdkmeta`),所以它的内容必须与
SDK 仓一致 —— 否则主仓编译到的是旧常量。
## 本次同步的内容
- 新增 `SDKModule` / `LegacySDKModule` 常量
- (原注释引用了 `SDKModule` 但常量并不存在,一并补上)
两个名字都要保留的原因见该文件注释:存量插件的 go.mod 里写的仍是旧路径
(本机实测 5 个插件工作区全是 `gitcode.com/...`),识别逻辑必须两种都认。
主仓 `go build ./...` 与 `internal/agent/core` 全量测试通过。
|
2026-10-10 23:27:46 +08:00 |
|
|
|
5187a62f47
|
feat(knowledge)!: 检索改为纯稠密向量路,去掉三路融合
按「完全改用向量方案」的要求,SearchIn 现在只走多模态稠密路,
不再与稀疏语义路 / 词法路融合。
## 为什么必须去掉融合(实测,不是判断)
生产知识库(192 条)上,10 条带标注查询的命中率:
改前(三路融合) top1= 1/10 top3= 2/10
改后(纯稠密路) top1= 6/10 top3= 7/10
改前连条目**自己的全名**都搜不到:
`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`、`homeagent_identity` 搜不到
`homeagent_identity`,用户看到的是「查什么都返回同样的几个无关条目」。
而稠密路单独打分时**全库排名 1/192**:
the_jet_engine → the_jet_engine_rolls-royce 0.769
plugin_dev_sdk → plugin_dev_sdk 0.852
homeagent_identity → homeagent_identity 0.823
### 根因:逐路归一化把噪声抬到与信号同权
`addPath` 把每路都按**查询内最大值**归一化到 1.0 再加权:
scores[h.id] += weight * h.score / max
于是词法路原始分只有 **0.027** 的弱信号归一化后也变成 1.0,与稠密路的
1.0 等权相加。叠加两个因素:
- jieba 把英文标识符切碎:`the_jet_engine_rolls-royce` →
`[t h e _ j n g i r o l s - y c]`,两条**无关**文档 token 重叠 50%
- `SetMinScore(0)` 导致 186/192 条都进候选,几乎全库打分
余弦分数**本身就可比**(同空间同维度),根本不需要逐路归一化 ——
那一步当初是为「量级不同的三路」服务的,现在只剩一路,它反而成了
破坏排序的东西。
## 历史结论为何在此翻转
旧注释引的实测(仅稠密路 top-1 15% / MRR 0.271;融合后 21% / 0.376)
建立在这两个**已不成立的前提**上:
- 33 条的小规模 KB(现 192 条,含大量英文标识符)
- 稀疏 fastText 词向量(现 chineseclip 512 维多模态空间)
那段结论我没删,而是在 Search 的注释里写明「前提已变、结论翻转」,
免得后来者看到旧数字又把它改回去。
## 退化路径有意保留
未注入多模态空间时(provider 留空)仍走稀疏两路。不保留的话,
那些部署的知识库会直接变成**完全不可用** —— 比排序差更严重。
该路径行为与接入稠密路之前逐字一致(TestNoDenseSpaceKeepsLegacyBehavior)。
## 判据(都可杀变异,逐个实测)
新增 `dense_recall_quality_test.go`:
- `TestDenseRecallQuality`——带标注 10 条查询,门槛 top1≥4 且 top3≥7。
把 denseEnabled 改成恒假 → FAIL「top1 仅 1/10」(正是退化态)
- `TestDensePathIsSoleRecall`——钉形状:稠密路必须在稀疏路之前且
**结果真的参与组装**。第一版只查「有没有调 denseHits」,把结果赋给 `_`
仍能骗过它;补上 `s.items[h.id]` 断言后,变异(结果不参与组装)才被杀
- `TestSearchDocReflectsDenseOnly`——防文档漂移。第一版简单搜「三路」
会误伤(新文档解释「为何不再融合」时正当地提到它),改为检查只在
*声称当前如此* 时才会出现的句子
## 顺带修的
- `TestDenseCrossModalRecall` 的桩把**所有未知文本**映射到同一向量,
导致 `other` 与 `cat-photo` 正文同向、「猫」查询下 `other` 以余弦 1.0
压过 `cat-photo` 的 0.707。那是**桩的假象不是产品缺陷**。给 other
一个真正正交的方向(且 key 必须是 denseFor 实际传入的
`Name + " " + Content`,写成纯 Content 会静默回落到默认向量)。
- 清掉随融合退场的死代码 `denseSpaceWeight` / `hasAnyDense`,
并把 `vec`/`lex` 字段注释改准(它们现在只服务退化路径)。
- `rankdiag_test.go` 现在测的是**退化路径**(它不注入多模态空间),
注释与门槛说明据此改准:实测 top-1 24% / MRR 0.358,
别拿它当知识库的检索水平。
全仓 `go test ./...` → 48 包 ok / 0 FAIL。
|
2026-10-08 21:34:30 +08:00 |
|
|
|
df865518e6
|
perf(memory): Introspect 507ms→11ms、ListPersons 130ms→3ms
继续清「非常卡」。上次修完 pulse 端点后,`/kernel` 与 `/runtime` 仍各约
600ms(每 15s 一次)。这次**逐项量**而不是猜(临时探针量各子收集器):
memDB.Introspect 507ms ← 主凶
social.ListPersons 129ms
textMem.FileCount 0.1ms
docStore.Stats 0.0ms
knowledge.List/Stats 0.0ms
## ① Introspect 507ms → 11ms(43×)
hotspots 的度数写成**相关标量子查询**:
COALESCE((SELECT COUNT(*) FROM memory_block_edges e
WHERE (e.source_id = b.id OR e.target_id = b.id) ...), 0)
EXPLAIN 是 `SCAN b`(3364 块)→ `CORRELATED SCALAR SUBQUERY` → `SCAN e`
(2829 边),即 **3364 × 2829 ≈ 950 万次行扫描**,且 ORDER BY 前必须算完
全部块的度数。
改成边表按端点 UNION ALL + GROUP BY 聚合一次,再 LEFT JOIN 回块表。
边表只扫两遍而不是 3364 遍。**新旧查询输出逐字节一致**(已 diff 验证)。
## ② ListPersons 130ms → 3ms(38×)
它走 `Recall(nil, nil, 1, "")`,而那条 full-recall 路径为了填
`result.Entities` 会加载**全部块(blockColumns 含稠密向量)**:
Recall 全程 130ms:实体 3266 个、关系 30 条
即为了 30 条关系反序列化了 3266 个块的向量,而 ListPersons
**只看 result.Relations**。
新增 `GraphDB.RecentRelations(limit)`:与 full-recall 分支里的关系子查询
**逐字对齐**(同一 WHERE/ORDER/LIMIT,含 trait 边——过滤掉它会让特质
主体漏出人物表)。实测该查询单独 4ms。
等价性验证:新旧路径的人物列表 + 关系集**逐字节 diff 完全一致**
(56 人、同样的边)。旧路径输出是先 git stash 采样的,不是凭记忆比对。
## 验证
- `TestIntrospect_HotspotsQueryIsNotCorrelated`(源码形状)+
`TestIntrospect_LargeGraphIsFast`(2000 块/1500 边的耗时门)。
**两者各自独立可杀变异**(实测):
- 源码断言:退回相关子查询 → FAIL「必须用一次聚合」
- 耗时门:同变异 → FAIL「149ms 超过 60ms 门槛」(修复后 11ms)
为何源码断言要锚在返回值行而非 `Index`:注释里也提到 memory_hotspots,
用 Index 会截到注释处、取到的片段根本没有查询——第一版就这么写错了,
自己先红了一次才发现。
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
|
2026-10-08 16:41:03 +08:00 |
|
|
|
aaf833a687
|
fix(proc): 关闭时等 stage 收尾,修每次 shutdown 必现的 SIGSEGV
连续两次部署都被验证门拦下(「近 2 分钟出现 5 条 panic/fatal」)。
查出这不是新版本崩了,而是**旧进程关闭时**必崩 —— 每次 shutdown 都触发:
fatal error: fault
[signal SIGSEGV: segmentation violation ...]
proc.(*Segment).descOffset
proc.(*Segment).setDesc
proc.(*Segment).writeLocal.func1 shmcodec.go:104
proc.(*Segment).writeLocal
proc.(*Segment).WriteAll
proc.(*stageCoordinator).enter host.go:310
proc.(*Host).beginStage
proc.(*coreHandler).runStage
agent/core.(*StageHost).RunStage
## 根因:Close 只处理了「事件环」一路写者,漏了「stage 数据面」
Host.Close 里那段长注释把事件环写得非常清楚(订阅 handler 会
ring.WritePush 写共享内存,unmap 后必炸,而 recover 捕不到 runtime
fatal),也**确实**在 freeShm 前退订了。
但共享段有**两条独立写者**。beginStage 的首进者会持 stageMu 调
Segment.WriteAll 写同一块段 —— 它**不在事件环订阅里**,退订阅根本管不到它。
于是 Close 只等问题 A 就 freeShm,问题 B 继续写已 unmap 的内存。
## 修法:用 stageMu 作为「没有 stage 在写段」的判据
stageMu 的既有语义已经就是我们要等的东西(首进者取、最后离开者放,
见 beginStage/endStage 注释),不必再造一个会与它失去同步的并行状态。
`Close` 现在 unmap 前独占获取它。
另加 `closing atomic.Bool`:光「等正在进行的 stage」还不够 —— Close
释放 stageMu 之后仍可能有新 stage 进场(agent 收尾路径),那时它照写已
释放的段。beginStage 现在看到一个明确的错误,而不是撞 SIGSEGV。
## 为何这件事必须修,而不只是「已知缺陷」
它让部署验证门失效:门报的「5 条 panic/fatal」分不清是新版本坏了,
还是旧版本关闭时的既有噪声。我这两次都手工核对了 PID 才敢继续 ——
下一个部署的人不该被迫做这种考古。修掉之后,这个门才真的能拦回归。
## 验证(判据可杀变异,逐个实测)
- `TestHost_CloseWaitsForInflightStage`:stage 持锁时 Close 必须阻塞。
删掉 `closing.Store` + `stageMu.Lock` → FAIL「未等 stage 收尾就返回」
- `TestHost_BeginStageAfterCloseRejected`:Close 后 beginStage 必须报错。
把检查改成 `if false` → **测试进程真的 SIGSEGV 被杀**(与生产同栈)
- `go test -race ./internal/plugin/proc/` 通过
- 全仓 `go test ./... -count=1` → 48 包 ok / 0 FAIL
|
2026-10-08 12:46:02 +08:00 |
|
|
|
8336509bfa
|
perf(webui): 修星图形变活跃集的泄漏 + 加回归判据
上一提交(3e201dd4)把 starmapTickMorph 从「每帧全量遍历」改成
starmapMorphSet 活跃集,但我漏了一处:buildChatStarmapGraph 重建图时
只重置了 starmapNodeMeshes / starmapEdgeLines,**没清空 starmapMorphSet**。
后果:旧 mesh 已从场景摘掉,却留在活跃集里,于是
- 集合随每次重建单调增长、永不归零 ⇒「空集时 O(1) 返回」的快速路径
从此失效,正是这次优化想省掉的那笔开销又回来了
- 每帧遍历一批已经不在场景里的废弃 mesh
同时补 starmap_perf_test.go。为何用源码断言而非运行时测量:
dashboard.js 是嵌入的浏览器脚本(依赖 three.js / DOM),Node 跑不了;
而这几条都是**结构性**缺陷(循环扫全表、循环不停、每帧重建),
源码里能可靠判定,回归时立刻红。
判据可杀变异(逐个实测过,不是装饰):
- 节流常量改名 → FAIL「缺少节流」
- morph 早退条件删掉 → FAIL「必须提前返回」
- clear() 改成 if (false) 或整行删除 → FAIL「必须无条件清空」
第三处特意断言在**重建函数体内**且是 `(?m)^\s*if \(starmapMorphSet\) ...`
(先写成全仓搜 `starmapMorphSet.clear()`,变异测试显示 `if (false)` 能骗过
它 —— 弱判据当场修强)。
|
2026-10-08 12:40:43 +08:00 |
|
|
|
3e201dd4af
|
perf(webui): pulse 端点不再全量扫描图库;修星图每帧全量开销
用户反馈「非常卡」。实测定位到两处(都在轮询/渲染的每帧路径上):
## 1. 后端:/memory/graph/pulse 名为轻量、实为全量
该端点自称轻量(注释写着「只回窗口内变动的实体,与完整图谱差两个数量级」),
但实现是**先调 GraphData() 取全量快照 → json.Marshal 约 3.3MB →
反序列化 → 才按时间过滤**。生产实测与全量端点**同价**:
/memory/graph 135-147ms
/memory/graph/pulse 144-157ms ← 每 10s 被轮询一次
即每 10 秒白付一次全量序列化。生产库 3190 块 / 2692 边。
改法:新增 `GraphDB.BlocksChangedSince(since, limit)` —— 库侧 SQL 过滤
(`WHERE created_at >= ? OR updated_at >= ?` + LIMIT),响应体只含窗口内
的块。limit 上限保护:窗口内可能有很多块(批量迁移),而星图只需知道
「有新东西」。
接口走内置 `sdk.MemoryAPI`(非 pubsdk),与既有 GraphData 同理,
**不影响外部插件**。
## 2. 前端:星图渲染循环每帧全量遍历 + 永不停止
三处,都是我上一批星图改动引入的:
- **RAF 循环从不停**:切到聊天/终端页签、甚至页面不可见时,仍以 60fps
跑着每帧 3190 节点的遍历。改用显式开关 + `starmapWake()` 重新点火
(判据问「星图容器是否可见」,不用 document.hidden —— 星图可能在
未激活的页签里,那时页面可见但星图看不到)。
- **starmapTickMorph 每帧扫全部节点**:3190 × 60fps = 每秒 19 万次
userData 读取与分支,而绝大多数帧里「正在形变」的节点是 0 个。
改用 `starmapMorphSet` 活跃集合,空集时 O(1) 返回。
- **标签层每帧重建 plan + measureText**:加 100ms 节流(文字层 10fps
肉眼无差别,3D 图形仍 60fps)与 measureText 缓存(同步文字布局很贵,
同文字同字体宽度不变)+ 缓存上限保护。
## 验证
- `blocks_changed_test.go`(新增):窗口语义、空窗口恒 0(否则星图会每次
轮询重放生长动画)、limit 真的封顶
- 既有 pulse 判据全过(`OnlyRecent` / `SinceWidens` / `NoVector` /
`IgnoresFrozenLegacyNodes`)—— 改写没破坏语义。
测试替身 `fakeGraphMemory` 同步实现新方法,且**忠实反映过滤语义**
(偷懒返回全部会把「窗口过滤」判据架空)
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
|
2026-10-08 12:35:28 +08:00 |
|
|
|
9d097febc9
|
chore(module): 打包脚本的 SDK clone/replace 路径随 module 迁移更新
package-linux.sh 里三处 sed 替换模式与 SDK clone 地址仍用 gitcode 形态。
module 已迁 github(仓名 homeagentsdk),这些模式串若不跟着改,
打包时会匹配不到 ⇒ **静默跳过 SDK 路径修正** ⇒ 在缺 in-repo SDK 的
环境下构建失败(或用了错误的 SDK 路径且不报错)。
属上一条 module 迁移提交的遗漏文件——它含 gitcode 形态的模式串,
但被「纯 module 改名」的自动分类漏了(模式串里同时出现 replace 与路径)。
|
2026-10-08 10:41:33 +08:00 |
|
|
|
69c9a9613f
|
feat(webui): 星图切到块体系数据源 + 全部标名/灰调/读写反馈/三种变化动画
## 根因:记忆系统已迁移,前端没跟上
`GraphData()` 同时返回两套数据,而前端**只读旧的**:
nodes/edges ← 旧表 entities/relations(2026-10-04 停写)
memory_blocks/block_edges ← 块/边体系(真实活跃的记忆)
生产实测:`entities` 停更在 2026-10-03(1294 条),而 `memory_blocks`
同期从 1294 涨到 3187。写入函数 `upsertEntity` 已成**全仓零调用的孤儿**。
⇒ 星图结构性看不到任何新记忆。
(另修的同类问题:`starmapDirty` 只写不读且未声明,赋值成隐式全局 ⇒
即使后端发事件,新块也永远不会出现在图上。见下「判据」。)
## 数据源切换
新增 `smAdaptGraph()` 把块/边归一化为星图节点/边(字段与旧格式一致,
下游建图/布局/图例/脉冲无需各自判源):
- `mention_count` 回填为**度数**(块表无此列,而它驱动节点大小/标签优先级)
- 只画 `status` 为 active/空 的边(deleted/merged 的边留着会让失效关系看着还在)
- 端点缺失的边丢弃(画不出,且会让度数统计偏大)
## 视觉(按「全部标名 + 灰调 + 悬浮提亮 + 查询提亮 + 三种变化动画」)
**全部节点名**:新增标签覆盖层 —— **一个** 2D canvas 画全部名字,而非
每节点一张 CanvasTexture(原实现 1151 张 ≈ 72MB 显存,也是它当初被删的原因)。
三级可读性:活动/悬浮=实色+底块,其余=低透明当底纹;**同屏文本去重**
(3178 块里大量同文本块,不去重会叠成十几层同样的字)。
仅在相机或脉冲集合变化时重绘。
**灰色调**:`smGreyFor()` 全图灰阶,**亮度按度数**(亮处即记忆稠密处)。
原按类型上色对真实数据只用得上一种颜色(99% 同一类型)。
**悬浮提亮**:发光 +1.4 + 放大 1.25×(只放大不亮在淡底上不明显,只亮不放大
又看不出是哪颗)。
**查询提亮**:`memory_access` → `starmapPulseByIDs()`,按**真实块 ID** 精确命中。
**三种变化动画**(三种是**排他状态机**,不与可叠加的活动 pulse 混用):
| 动画 | 曲线 | 时长 |
|---|---|---|
| 插入生长 | easeOutBack 过冲(从 0.05 起,从 0 起看不出「长」) | 1800ms |
| 删除消散 | 收缩 + 变暗 + 球面均匀粒子飞散(减速淡出) | 1200ms |
| 合入 | 源消散 → **停顿 120ms** → 目标重生 | 1200+120+900ms |
合入刻意分两段且中间停顿:同时放会读成「一个变小一个变大」,
先看不到源、再看到目标冒出,「合入」的因果才成立。
粒子用**单个** Points 承载(上限 600),而非每粒子一个 mesh ——
批量删除(Purge 上百块)时后者会瞬间建上千对象。
消散完成必须真 `scene.remove()`,否则 scale=0 的不可见 mesh 仍可被鼠标拾中。
**顺带**:
- 补选中详情面板(`starmapSelected` 此前**只赋值、无人读取**,点击节点
没有任何信息反馈 —— 「可读性差」的核心成因之一)
- 布局半径按 √n 自适应 + 相机初始距离自适应(3178 节点挤在旧的固定
15–80 环带里会糊成一片)
- 图例改为说明**视觉编码**(大小/亮度/活动/三种动画),不再列不存在的类型色
- `starmapDirty` 消费点加 30s 节流(`/memory/graph` 实测 3.3MB,
密集写入时每 10s 重拉是纯浪费)
## 判据
- `graph_source_test.go`:钉住后端**必须下发 memory_blocks**
(前端星图的真实数据源),防未来又切回停写的 nodes
- `TestDashboardJS_NoWriteOnlyGlobals`(新增静态判据):**只写不读的全局布尔标志**
⇒ 这类「置了标志无人消费」是静默失效,真跑 node 也抓不到(隐式全局赋值合法)。
该判据写出来就当场抓到 `starmapDirty`,并经变异验证有效
(删掉消费点 → 判据点名报错)
- `node --check` + webui 全部判据绿
|
2026-10-08 10:41:22 +08:00 |
|
|
|
9036db9321
|
feat(memory): 图数据库自报真实读写事件,取代「按工具名猜节点」
## 问题(生产实证)
星图的「跟随 agent 活动」此前靠**工具名词元猜节点**:把 `memory_recall`
拆成 [memory, recall] 去匹配实体名。而图谱里的实体名是**中文概念**
(小宅/对话/待命),工具名是英文 —— 两套命名空间永不交集。
实测(生产库)每个工具名命中 **0** 个节点:
hint=memory_recall → memory/recall → 0
hint=files_read → files/read → 0
hint=knowledge_search → knowledge/search → 0
匹配失败后走「补齐」分支,按 mention_count 取前 8 个 ⇒ **无论 agent 在干什么,
点亮的都是同一批无关节点**(小宅/对话/待命/投递/图片/文档/插件/老大)。
这就是「星图没有读写反馈」的真身。
## 改法:让图数据库在真正读写处上报
新增 `events.EventMemoryAccess` + `GraphDB.AccessHook`,在读写入口埋点上报
**真实块 ID**(不再猜):
| 操作 | 埋点 | 上报 |
|---|---|---|
| 读 | RecallSorted | blocks |
| 写 | commit | blocks + **created**(真正新建的) |
| 删 | DeleteEntity | removed |
| 合 | MergeBlocks | merged(from→to) + removed |
三处关键设计:
1. **钩子用独立锁** `hookMu`。用 `g.mu` 会**自死锁** —— 埋点方法已持该锁,
而 Go 的 RWMutex 不可重入。
2. **上报在锁外**。拆 `commit`→`commitLocked` 两层解决 `defer Unlock` 的时序:
钩子内部可能要回调内存系统(如 SSE 订阅者补数据),持锁调用必死锁。
3. **区分「新建」与「更新」**(`putBlockTx` 返回 isNew)。用 SELECT 判存在性
而非 `RETURNING (xmax = 0)` —— 后者在**事务 + ON CONFLICT** 下报
`no such column: xmax`(实测:autocommit 可用、包进 tx 就不可用),
而本函数只在事务里被调用。重复提交同一事实不会重复播「生长」动画。
接线:`bootstrap.newMainAgent` 里 `memDB.SetAccessHook(...)` → `events.Bus`。
只在 `evBus != nil` 时接,把「内核进程」与「独立 CLI 工具」
(homed-graph-migrate / memgc 也用 NewGraphDB)区分开 —— 后者不该为一次
批量迁移向不存在的订阅者发几万条事件。
## 验证
- `access_hook_test.go`:钉住 commit/recall/delete 三类埋点真的触发、
且重复提交不误报新建
- `access_hook_race_test.go`:4 写 + 4 读 + 钩子热替换并发,`-race` 下无竞态、
无死锁;**断言事件数 > 0**(否则「钩子从未触发」也会假通过)
- 该测试起初是 flaky 的(读者可能在写者落库前查询 ⇒ 空结果不报事件 ⇒
断言随机失败)。修法是先播种确定数据再并发读,已连跑 3 次稳定
- 生产实例端到端:CLI 注入一句话 → SSE 收到 6 条 memory_access,
带真实块 ID 与 op/created
|
2026-10-08 10:41:07 +08:00 |
|
|
|
e6c1bfa6bf
|
fix(spawn): 子 Agent 报告如实 + 跑满预算不再丢弃成果 + 提示词补执行纪律
三处修复,均来自生产日志实证(本机 systemd 实例,2026-10-08)。
## 1. 报告轮数在说谎(max_turns != 5 时必现)
生产日志:`spawn_child` 声明「最多 10 轮」,子 Agent 实际跑满 10 轮后被判定
「子 Agent 执行超时(超过 5 轮)」——**那个 5 是硬编码的**,与真实轮数无关。
排查时会被这个假数字带偏(我本人就先据此去查「为什么是 5」)。
改法:新增 `completed` / `reachedLimit` 两个标志如实分类收尾原因,
文案用 `maxTurns` 插值。顺带修两个边界:
- 模型返回**空文本**也算「回答完了」,此前会被误报成故障;
- 「跑满预算」不是「超时」,不再如此描述。
## 2. 跑满预算时丢弃全部中间成果
原实现直接回一句「超时」,子 Agent 前几轮已取到的工具结果**全部丢弃**。
父 Agent 拿到不可操作的信号只能重 spawn —— 生产日志里 `child_5→6→7` 正是
同一任务连试三次,每次重烧一整个上下文(也是实例内存偏高的成因之一)。
改法:新增 `salvageChildSummary()`,在**失败路径**上多做一次不带工具的调用
(`ToolChoice: "none"`),把已有成果落成文本并标注「可能不完整」。
成本权衡:多一次 LLM 调用 ≪ 父重 spawn 烧掉整个上下文。
正常完成的子任务**不会**触发它(有判据钉住,避免白花调用)。
## 3. 提示词缺「执行纪律」与「工具执行顺序」
子 Agent 的提示词原先只有 3 行,而父有 7 个章节。缺失的直接后果(生产日志):
child_1 done: I'll start by searching for each school's admissions catalog information.
child_3 done: 我将逐校用官方渠道核实。先并行检索四校的招生专业目录信息。
**它把「宣告计划」当成任务完成了**——因为退出判据只看模型有没有返回 tool_calls,
而「I'll start by…」之后就不再调工具。父的提示词里早有同类先例
(【记忆清理指令】:「必须实际调用…不能只回复文本」「不要只描述计划而不执行」),
子这里一句都没有。
另缺【工具执行顺序】:同轮多个 tool_call 默认**并行**,而子不知道
「依赖前一步结果的调用必须分两轮」,多步任务因此被塞进一轮。
★ 循环判据**未改动**(它是对的:模型确实不再要求工具了)——
靠提示词让模型不要过早停止调用,而不是放宽判据。
## 验证
- 新增 `spawn_replenish_test.go`:4 组判据覆盖 max_turns ∈ {1,5,10,14}、
成果保留、正常完成不触发收尾、日志含轮次/预算/工具名
- **变异测试**:把轮数改回硬编码 5 / 去掉收尾提炼 / 删掉子日志 → 三个变异全部被杀
- `go test ./internal/agent/core/ -count=1` 绿;`-race` 绿
注:本提交含一行随 module 迁移的 import 路径更新(spawn.go 的 agentAPI 导入)。
|
2026-10-08 10:40:53 +08:00 |
|
|
|
6d38a164eb
|
chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景
仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。
## 关键区分(这是本次改动安全的前提)
两个 module 是**不同**的,影响面差一个数量级:
| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |
★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。
SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。
## 改动
- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)
## 明确**保留** gitcode 的地方(镜像机制,不能动)
- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)
## 验证
- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好
## 顺带修正
`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
|
2026-10-08 10:40:17 +08:00 |
|
|
|
476e1b1296
|
fix(proc): stage 锁改可重入 + 协调器结束解绑与自愈,解 qq「重复申请 stage 锁」
## 生产现象
本机 homed(10-07 11:28~21:58)93+1 条:
[stage] before_toolcall handler error: proc: qq.stage.invoke:
申请 stage 锁: proc: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)
qq 的 before_toolcall 是它的**权限门**,每次工具调用都跑。
被拒 ⇒ 该 handler 永不执行 ⇒ **权限门失效**。
## 走过的三条错路(记录以免重犯)
❌ 错路一:「内核没释放锁」。模板 handleStageInvoke 的加解锁完整配对
(unlocked 标志保证只解一次),不会漏解。
❌ 错路二:「同一插件注册了两个 before_toolcall handler」。
实测线上 qq 是单进程(pid 恒定),且
`strings plugin.bin | grep beforeOwnToolcall` = 0 ⇒ 只有一份 handler。
❌ 错路三(我自己实现的):把「重入即报错」改成「重入即等待」。
★ 同进程重入就是自己等自己 ⇒ 30s 超时死锁,比原报错更糟。
跑判据时立刻挂 30s 才发现。**重入的正确解是可重入,不是等待。**
## 真正的根因与修法
`stageLock` 是每个协调器新建一个,而 `lockRegistry` 只 bind 从不解绑;
且 Acquire 对同插件重入直接报错。两个缺口叠加:
1. **Acquire 改为可重入**(新增 depth 计数)
同一插件可在同一 stage 注册多个 handler(内核 RegisterStageFor 用
append),它们并发扇出各发一次 stage.lock;同一进程内第二个进来时
已在临界区,本就不该再取互斥量。depth 减到 0 才真正释放
——内层释放时若直接放锁,外层还在临界区就会被别人抢进去。
不同插件的互斥语义完全不变。
2. **endStage 在「最后离开者」分支解绑路由**
协调器结束时它就不该再被路由到;否则下一轮的 stage.lock 会打到
上一轮那只可能仍 held 的锁上。
3. **endStage 补泄漏自愈**(releaseIfLeaked)
插件异常路径(handler panic 被 recover 吞掉、RPC 写失败)会让 defer
unlock 之前就 return,锁就永久挂着。锁的所有权在内核进程
(这正是 ForceRelease 存在的理由),那内核就必须能收尾。
## 判据
stage_lock_reentrant_test.go(8 条)+ 改写 lock_test.go 那条旧判据。
★ 旧判据 TestStageLock_ReentrantAcquireRejected 原本要求「重入必须被拒绝」
——它在 2026-09 实验 9 里是对的(那时 Acquire 做成等待)。
生产证明「拒绝」本身是错的,已改名为 ...ReentrantAcquirePassesThrough,
并在注释里写明为什么改。
★ **变异测试抓到判据两个缺口**(判据不红 = 判据没覆盖,不是实现没问题):
· 去掉 releaseIfLeaked → 判据不红 ⇒ 补 TestEndStage_ReleasesLeakedLock
· 内层 Release 直接放锁 → 判据不红 ⇒ 补 TestStageLock_InnerReleaseKeepsOuterLock
(原来只断言最终状态,bug 在中间状态)
四个变异重做后全红:还原报错 / 去掉解绑 / 去掉自愈 / 破坏 depth。
## 部署验证
- 内核 install -m 0755 原子替换;qq 用 `hmapdev build` 重编 hmap,
经内核接口 `plugin_install(path=…, overwrite=true)` 装(reinstalled,
config_kept=true),再 `plgreload` 生效(pid 2643323 新进程)
- 重载后连调 5 次 qq_get_friends:**重复申请 stage 锁 0 条**
(部署前那份日志 94 条),stage handler error 0 条
- 权限门确认在执行:5 次调用全部返回真实好友数据,非拒绝
- panic/fatal 0、39 插件、graph.db integrity ok
- 快照 /home/newqqagent/memory/pre-qq-rebuild-20261007-233032/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-08 07:56:51 +08:00 |
|
|
|
6089b86935
|
ci: main 过全部检查后自动快进 gitcode 镜像
## 背景
gitcode 的定位是**只读镜像**(ci-cd-runbook §0),但既有 sync job 只在
release.yml 里推 **tag** —— main 分支从不同步。镜像的 main 停在 09-29、
与主仓分叉 768/746,本次已手工 force push 对齐一次(审计确认 github 是
严格超集,gitcode 无独有内容后才做的)。
光对齐一次,下个月还会旧 —— 镜像的单向性应该由 CI 保证,不靠人记。
## 改动
ci.yml 新增 sync-gitcode-main job:
- **needs 全部 6 个必需检查**(go/race/cross/gui/csrc/docs):
红的代码不进镜像 —— 镜像永远等于「过了 CI 的 main」
- **只在 main 的 push 上跑**(feature/release 不镜像,与 tag 语义一致)
- 未配置 GITCODE_TOKEN 时**显式跳过**(与 release.yml 的 sync-gitcode
同口径,不阻断 GitHub 侧 CI)
- **快进 push(非 force)**:正常流水线下镜像只可能落后不可能领先;
出现非快进说明有人绕过 CI 动过镜像 ⇒ 响亮失败而非悄悄覆盖,
那是「单向性被破坏」的信号,值得一次人工确认
- checkout 用 fetch-depth: 0:镜像可能在任意旧提交上,快进需要共同祖先
## 验证
- yaml 可解析;job 名与 needs 引用逐一核对
- 10 项分支保护必需 context 不受影响(本 job 是新增,不在保护清单)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-08 07:56:51 +08:00 |
|
|
|
a9e0489e57
|
docs: 修正「项目概览/架构」把已删除机制写成现行机制(中英四份)
## 问题
assets/docs/{zh,en}/OVERVIEW.md 的媒体记忆段仍在描述 v1.2.0 **已整体拆除**的机制,
且两份文档的第一读者入口(README「文档」区、site/index.html 的「项目概览」链接)
都指向它。docs/git-branching.md §七.7 的反例表里恰好列着这一条,说明这是
「文档当事实后反向误导」的复发项。
同时修掉同一批里其余过期的结构性断言(均为逐条回源码核实后改)。
## 改动(四份文件,7 处)
OVERVIEW.md(zh/en 对称):
- 媒体记忆段:「引用计数 GC + 描述文本才是持久语义记忆 + [<mime> <digest>] 标记」
→ 「一等记忆块:块自带向量与指纹直接检索;无独立 GC / 无引用计数 / 无 keep-set;
删除块即删内容」,并显式注明旧机制已在 v1.2.0 拆除
- 依据:internal/memory/block.go("不携带 Layer、Owner 或 RefCount")、
ARCHITECTURE.md 自身的「不再有描述式索引」段、
graphmedia_test.go:24「描述文本、marker 反解…全部已废弃」、
inputunify_test.go:226「不往文本里贴 marker」
- LLM Provider 段:「三种实现 OpenAIProvider/OllamaProvider/LuaAdaptedProvider」
→ 「唯一实现 LuaAdaptedProvider」
- 依据:internal/agent/api/provider.go 只有 LuaAdaptedProvider 一个实现类型
- 适配器:「内置 8 个」→「内置 10 个」并补全名单(+kimicode/server)
- 依据:ls internal/lua/adapters/*.lua = 10;README.md 同一处早已写「10 个」,
两份文档此前自相矛盾
- 「支持 8 个 LLM 源自动降级」→「多源自动降级」
- 依据:源数量由部署配置,默认 seed 仅 deepseek 一个
(registry.go defaultSources 唯一条目)
ARCHITECTURE.md(zh/en 对称):
- 代码结构树 internal/skill/(**该目录不存在**)→ 移入 plugins/skillmgr/
- 依据:internal/plugins/skillmgr/ 存在,internal/skill/ 不存在(README 亦写 plugins/skillmgr)
- dynamic.go 描述「.so 动态加载器」→「加载通道分派(proc/lua/skill);.so 仅剩给明确报错」
- 依据:dynamic.go 的 tryDynamic 实际只分派 proc 与 lua,.so 命中只为给出「请用新版
hmapdev 重编」的明确错误(legacyCABIEntries 注释)
- 配置系统「8 个 LLM 源的 seeds」→「含 1 个 LLM 源 seed,其余源由部署时添加」
- 适配器数 8 → 10
- Provider 分层图(仅 en 侧滞后):删掉不存在的 OpenAIProvider/OllamaProvider
- 依据:zh 侧同一图早已写作「LuaAdaptedProvider (唯一实现)」,en 侧未跟上
## 验证
- 全量残留扫描:OpenAIProvider / OllamaProvider / 引用计数 GC /
Reference-counted GC / 8 个 LLM / 8 LLM / internal/skill / .so 动态加载
→ 在 assets/docs/ 与两份 README 中均为 **0 命中**(改前分别为 3/2/2/1/2/1/2/2)
- 每条新断言逐条回源码核实(adapters=10、provider struct=1、skillmgr 存在、
internal/skill 不存在、defaultSources=1、无 marker 写入代码)
- 纯文档改动:不涉及 .go,不改变构建产物(assets/docs 未被 //go:embed 打包,
仅被 site/index.html 以 GitHub 链接形式引用)
- 未新增版本号字面量,符合 §七.7 的「模型或用户会当作事实的文本不得写死版本号」
## 分支归属
按 docs/git-branching.md §七.7:面向使用者的开发者文档先落 release 线、
再 cherry-pick 合入 main(不 merge)。本次提交在 release/v1.3.x。
|
2026-10-08 07:55:28 +08:00 |
|
|
|
91b490d823
|
Merge pull request #4 from JianFeeeee/feature/multimodal-and-device-tools
多模态工具插件化 + 设备能力结构化工具 + get_plugin_tools 收编
|
2026-10-06 22:53:50 +08:00 |
|
|
|
a25ba4b794
|
test(gui): 修 silent-start 判据的字节窗口假红(被注释挤爆)
## 问题
`make test` / CI 的 `npm test` 都红:
✗ 非静默时经 ready-to-show 显示 — 没有 ready-to-show → show()…
## 归因(逐提交二分)
origin/main (7e4f1058) 通过
2bbbb6b8 / bca791a1 通过
3988c198 / 85d76ad4 通过
878fd770 通过
7bd569ec ★ 失败 ← 引入点
04c5883f / 5f12ea3d ★ 失败
不是本次内核改动引入;是 7bd569ec(10-05「修长时间挂后台后打开白屏」)
把 `once` 改成 `on` 时**同时补了一段解释性注释**,把
`ready-to-show` 与 `show()` 的间距从 331 字符推到 558 字符,
而判据的窗口是硬编码 `{0,120}`。
**实现是对的,判据的字节窗口太小。**
## 修法
判据该断的是**结构关系**(ready-to-show 处理器的回调里有 show()),
不是两者相隔多少字符 —— 后者会随注释/排版无意义地红。
- 先去行注释再量(注释不该参与结构判定)
- 窗口放到 400(容纳该回调体,仍远小于「跑到别的函数里」的距离)
## 验证
- 修后 `node silent-start.test.mjs` 全部通过
- ★ 变异自证仍能红:删掉 ready-to-show 处理器 → 判据报红
(证明不是把判据改松到什么都不抓)
- `make test` 全绿(go test + test-gui + csrc-test + check-csrc + check-codec-cgo-only)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-06 22:44:27 +08:00 |
|
|
|
b27e6142e2
|
fix(remotedevice): 补 screensue/camerasue/speakeruse 专用工具,修「agent 被迫手拼命令字符串」
## 问题(生产日志实证,10-06 12:46~13:04)
三个能力只能走 device_ctl_cmdrun 的 **command 字符串**,agent 于是手拼:
homeagent-screensue 0 <!DOCTYPE html><html><head><meta charset="utf-8">…
结果是工具调用**连执行都没执行**:
13:00:38 stream tool_call device_ctl_cmdrun argument fragments invalid JSON:
"{\"device_id\": \"gui-JianF\", \"command\": \"homeagent-screensue 0
<!DOCTYPE html>…<meta charset=\\\"utf-8\\\">…
13:00:38 args unparseable (982 bytes) — surfacing to model instead of calling
三个原因叠加:
① 引号要穿过两层 JSON 转义(HTML 必然含引号)
② 时长与内容共用一个字符串靠空格切分(内容里的空格有歧义)
③ 长度无上限(一屏看板几十 KB)
agent 的应对是反复试参数 —— 它在猜格式,不是在做事:
screensue shown on display 0 for 15s / 25s / 30s / 40s / 60s / 180s
## 改动
新增三个结构化工具(与 screensee 同一层):
| 工具 | 参数 |
|---|---|
| `screensue` | device_id, content 或 content_path, duration_seconds |
| `camerasue` | device_id, duration_seconds(省略/0=拍照,N=录像 N 秒) |
| `speakeruse` | device_id, text 或 text_path |
- 命令字符串由**服务端**拼装,形状与 GUI `executeHomeagentCmd` 的解析一致
- 长内容走 `*_path`(服务端读文件,上限 2MB,与 clipboardsue 对齐)
- `duration_seconds=0` **显式下发 0**(常驻),与「省略=默认 5 秒」语义区分开
- device_ctl_cmdrun 描述改向:告明它只管 shell,屏幕/摄像头/语音请用专用工具;
homeagent-* 旧前缀仍兼容(不破坏既有调用)
## 顺带修掉一个「只提交判据没提交实现」的缺口
clipboardsue 的实现读 `args["content"]`,但**工具 schema 里只有 text** ——
参数存在却不暴露,等于没有。已补进 schema。
## 判据
capability_tools_test.go,11 条。三个变异自证会红:
- 变异 A:把 content 与时长拼回一个字符串 → 红
(`got="screensue 三点开会"` / 含引号内容被下划线替换)
- 变异 B:duration=0 时省略 → 红(与「默认5秒」混淆)
- 变异 C:超时错误退化成一句「失败」→ 红(丢掉 req_id 与复查提示)
★ 过程中两次遇到「无输出」,都不是被杀死而是**编译失败**
(未使用的 prefix / 类型不符)。本项目已记过这个坑:
编译失败 ≠ 被杀。加 `_ = prefix` 后确认是真的被杀。
另:一条判据自己假红了一次 —— `res["size"]` 经 JSON 回来是 float64
而非 int,断言写错。判据的红也要先分清「实现错了」与「断言错了」。
## 验证
- go build / go vet / go test ./internal/... 全绿
- go test -race remotedevice 无数据竞争
- 部署后 39 插件加载、0 panic/fatal、DB integrity ok
- 快照 /home/newqqagent/memory/pre-capability-tools-20261006-211446/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-06 21:17:03 +08:00 |
|
|
|
1ff87d5122
|
merge: 整合 origin/feature/gui-starmap-pulse-vendor-align(GUI 星图/白屏/设备桥 keepalive 8 提交)
来自远端 GUI 线(JianFeeeee,10-05~10-06):
- 设备桥 gateway 自动发现回写主进程
- 设备桥 WebSocket keepalive(修「设备可见但命令下发不到」)
- 设备桥心跳与断线重连(兑现 liveness 门禁)
- device/push 校验 payload,杜绝「假成功」
- 修「长时间挂后台后打开白屏」:renderer/GPU 崩溃自愈
- 星图几何体共享 + 重建时 dispose(修聊天页长时间挂起后卡死)
- 补星图面板缺失的 </div>(修对话页 5 个页签空白)
与本分支三条本地提交(多模态插件化 / screensee 落盘 / scheduler 判据)
在 internal/plugins/remotedevice/plugin.go 上同文件不同函数段,无冲突。
|
2026-10-06 14:37:54 +08:00 |
|
|
|
5f12ea3df9
|
fix(gui): 补星图面板缺失的 </div>,修对话页 5 个页签空白
## 实测故障
对话页里「终端 / 运行中命令 / 记忆 / 上下文 / 知识」五个页签点进去全是
空白,只有「对话」和「星图」正常。
## 根因:少一个 </div>
构造星图面板的那段 HTML 里有 **4 个 `<div>` 开、只有 3 个 `</div>` 闭**,
它没闭合 —— 后面拼接的所有 chat-panel 都被浏览器塞进 chat-panel-starmap
**内部**。
CDP 查真实 DOM 的铁证:
.chat-layout 的直接子节点只有 3 个:
DIV.chat-tabs / DIV#chat-panel-chat / DIV#chat-panel-starmap
terminal/cmd/memory/context/knowledge 的父节点
= chat-panel-starmap ← 不是同级的 chat-layout
后果:这些面板 display 是flex(switchChatPanel 把 active 加对了),
但父级 display:none ⇒ getBoundingClientRect() 恒为 0x0 ⇒ 显示为空白。
实测尺寸对照(修复前 → 修复后):
terminal 0x0 → 1156x621
cmd 0x0 → 1156x621
memory 0x0 → 1156x621
context 0x0 → 1156x621
knowledge 0x0 → 1156x621
## 为什么一直没被发现
标签不配对在源码里看不出来(跨行字符串拼接的 HTML 片段),浏览器也
**不报错** —— 它只是「尽力修复」成畸形结构。现象是「点了没反应/一片空白」,
很容易被归因为数据没到或后端挂了(我最初就查了半天气网络与接口 401)。
而且它只在部分面板上发作:chat 与 starmap 恰好是前两个,位置靠前不受影响,
于是「大部分能用」掩盖了「后面全废」。
## 判据(cmd/gui/chat-panel-dom.test.mjs)
3 条:整段 div 标签配平、各面板独立配平、各面板顶层不被嵌套进前一面板。
逐面板断言能定位到**具体是哪个**面板不配平(变异测试下报
`starmap(开4/闭3)`),而不只是「总数差了 1」。
变异测试:去掉一个 `</div>` ⇒ 前两条转红并指出 starmap。
判据自身也错过一次:还原 HTML 时把 `"` 全替换成 `'`,而 marker 用的是双引号,
7 个面板全报「未找到」。已修正并记在注释里。
|
2026-10-06 13:09:12 +08:00 |
|
|
|
04c5883f4b
|
fix(gui): 星图几何体共享 + 重建时 dispose,修聊天页长时间挂起后卡死
## 实测故障
聊天页长时间挂着之后 GUI 卡死。CDP 抓生产实例(2588 节点):
Mesh/SphereGeometry 2588 个 geometry 数据合计 22.67MB
Sprite/BufferGeometry 1294 个 label 纹理,256×64 RGBA ≈ 64KB/个
进程常驻内存反复冲上 GB 级、被 GC 拉回后又再度暴涨(实测 986MB ↔ 3.8GB
震荡),CPU 稳定 30%(多线程耗在 GC 与对象构建),句柄数稳定 490 ⇒
不是句柄泄漏,而是持续的 GPU 资源分配。
## 两个叠加缺陷
1. **几何体从不共享**。每个节点 `new THREE.SphereGeometry(rad, 16, 12)`、
每个光晕球 `new THREE.SphereGeometry(gr, 16, 12)` —— 半径各异看起来
「只能各自 new」。但 three.js 里大小应由 mesh.scale 表达,几何体应共享。
2. **重建时不 dispose**。buildChatStarmapGraph 只 `starmapScene.remove(m)`,
从不 dispose()。remove 只把对象从场景树摘下来,geometry/material/texture
仍占着 WebGL 资源。全代码搜不到 dispose() 就是铁证。星图每次全量重建
泄漏一整轮,节点数还随记忆增长 ⇒ 泄漏只增不减。
## 修法
- `starmapSphereGeometry()`:建一次半径 0.5 的球,全场景共用。
**半径必须仍是 0.5** —— 节点缩放基准 `baseScale = rad / 0.5` 的分母
就是它,改成 1 会让所有节点缩放翻倍,且与脉冲结束时的
`setScalar(baseScale)` 复原逻辑不自洽。
- `disposeStarmapObject()`:递归 traverse 子树(节点下挂着 glow sphere
与 label sprite,只 dispose 顶层会漏),释放 geometry/material/texture;
用 seen 集合防重复释放,并**排除共享几何体**(释放它会让仍在渲染的
mesh 变黑/报错)。
- 重建时节点与边都走 dispose。
几何体数据从 2588 份降到 1 份(22.67MB → 9KB),label 纹理由
「只增不减」变成「每轮释放」。
## 判据(cmd/gui/starmap-gpu-lifecycle.test.mjs)
13 条。变异测试验证非假红:
- 还原 `new SphereGeometry(rad,…)` ⇒ 「节点循环用共享几何体」+
「代码里只有一处构造」转红
- `disposeStarmapObject(m)` 换回 `starmapScene.remove(m)` ⇒
「重建时释放旧节点资源」转红
排障中判据自身报过 3 次假红,根因都写进注释:
- 注释里也写着 `starmapNodeMeshes.forEach` 与 `disposeStarmapObject`,
裸正则从注释开始匹配 ⇒ 改为先剥注释
- 匹配窗口 200 太大:节点那处换成 remove 后,**边**的
`disposeStarmapObject(l)`(相距 84 字符)仍落在窗口内 ⇒ 收到 40
- 摘要里 `new SphereGeometry` 计数包含注释里的引用,显示 2 次而真实
只有 1 次 ⇒ 计数前先剥注释
## 验证
6 个门禁全绿:starmap-gpu-lifecycle / window-liveness /
device-bridge-liveness / endpoint-align / device-align /
device-gateway-discovery。
|
2026-10-06 10:29:58 +08:00 |
|
|
|
f33ebee01b
|
test(agent/core): 消除测试对 scheduler.queue 的无锁直读,并加判据防回归
## 起因
offload_test.go 等 4 个文件里有 18 处 `len(s.queue)` / `range s.queue`,
全在锁外。当时**安全**——那些测试用 newRootWithoutSchedulerLoop,
刻意不启 schedulerLoop,所以没有并发写者。
但这个安全性 ① 不由测试自身保证,只由「别改构造函数」这个口头约定保证;
② 一旦有人换成 newRootWith 就变成真竞争,症状是「今天全绿、改一次就炸」。
先确认生产代码没问题:逐处核对 scheduler.go 里 6 个读 s.queue 的函数,
全部在 mu.Lock/RLock 内(takeQueuedInputs/hasRoom/nextRef/armStop/
pendingEvents/takeAllPendingEvents)。所以这是**测试的隐患**,不是核心缺陷。
## 改动
- scheduler.go 新增两个带锁只读访问器:
· queueLen() —— 替代 len(s.queue)
· queueSnapshot() —— 返回浅拷贝,替代 range/索引(拷贝保证 len 与
range 不会在遍历中途变化)
- 4 个测试文件 18 处直读全部改走访问器
- 新增 queue_access_test.go:AST 静态判据,扫所有 *_test.go,
同一函数内出现过 Lock/RLock 即视为持锁(Go 的 defer Unlock 静态难还原,
按函数近似足够保守);命中无锁直读即红
## 判据自检
静态判据最大的失败模式是「根本没抓到东西」,所以带双向自检:
- 正向:注入一处无锁直读 → 必须被抓到
- 反向:注入一处持锁直读 → 必须**不**被报(否则噪声大没人看)
- 真变异:把一处访问器改回 `len(s.queue)` → 红
## 验证
- go build ./... / go vet / go test ./internal/... 全绿
- go test -race ./internal/agent/core/ 无数据竞争
- 部署后:39 插件加载、3 个 multimodal 工具注册、0 panic/fatal
- graph.db integrity ok,blocks 2787 未动
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-06 09:22:12 +08:00 |
|
|
|
a7d048c922
|
feat(remotedevice): screensee 落盘化,回路径而非自动送模型
## 为什么
原行为把截图的 base64 直接送去视觉模型自动描述。三个问题:
1. **上下文成本**:96KB 截图 ≈ 13 万 token 的内联 base64
2. **源不可控**:生产实证 AUTO 把大图路由到不支持视觉的源,模型回
「没有收到截图」——而我们无法区分「图没到」与「模型看不了」
3. **agent 无法决策**:不知道图存不存在,也就不知道该 describe 还是该 ocr
## 现在
截图落盘 → 回 {file,mime,size,sha256,width,height} →
agent 显式调 describe_image(path=…)/ocr_image(path=…)。
需要哪个能力由它自己判断,provider 参数也可控。
工具描述同步改写,明说「本工具不会自动描述任何内容,两步缺一不可」——
否则模型会以为拿到 file 就等于看到了画面。
## 实现
- `Registry.SaveInlineMedia(name, payload, mimeHint)`:
解 data URL / 裸 base64 → 落盘 → 回可核对的元信息
- 文件名用**内容摘要**后缀(`<name>_<sha16>.<ext>`):
截屏/录像会被反复抓取,用 reqID 命名会把同一张图存成多份,
用固定名则不同内容互相覆盖 —— 摘要两个问题都避免
- `decodeInlineMedia` / `imageDimensions`:JPEG SOFn 与 PNG IHDR
解尺寸,不解码全图;认不出的格式**省略字段而非填 0**
- 落盘失败当**工具失败**上抛,不静默退回内联:
图已在手上却拿不到路径,agent 既没有描述也没有可查证产物
- 旧路径保留在 `auto_describe=true`(调试用)
顺带把 cmd_data_end 的媒体落盘逻辑也换成同一个方法(原来内联了
一份 MkdirAll+WriteFile 的副本)。
## 判据
新增 inline_media_test.go,六个判据经四个变异自证会红:
- 变异 A:写入截半的字节但元信息照填 → 红(只查「文件存在」会放过)
- 变异 B:尺寸写死 0 占位 → 红(元信息「看起来有」但不可信)
- 变异 C:sha256 换成假的 → 红
- 变异 D:未配置 mediaDir 时静默降级 → 红
★ 变异 C 第一次跑出「无输出」,查下来是**编译失败不是被杀**
(`sum`/`hex` 变成未使用)。这正是本项目记过的坑:
编译失败 ≠ 被杀。改写成能编译的注入后确认被杀。
## 验证
- go build ./... / go vet / go test ./internal/... 全绿
- go test -race remotedevice + multimodal 无数据竞争
- 旧判据按新契约更新时保留原意:
TestScreenseE2E_SuccessReturnsDescription 现在同时验两条路径
(默认不出 description 且不调 handler;auto_describe 才出)
- 部署后:39 插件加载、0 panic/fatal、plugin=multimodal
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-screensee-persist-20261006-091536/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-06 09:16:15 +08:00 |
|
|
|
a02d8b00a5
|
feat(multimodal): 多模态工具移出内核为内置插件 + get_plugin_tools 收编为内核服务
## 背景
README 第一原则:「内核职责限定在 LLM 编排、记忆管理与知识检索;
所有 IO 能力由插件实现」,而「文件读取」正在 IO 列表里。
describe_image / ocr_image / transcribe_audio 读本地文件却住在内核进程内
——无沙箱、无能力面、无审计,正是 capability.go 那套权限梯度要防的事。
## 本次改动
### 1. 多模态工具移出内核(internal/plugins/multimodal)
- 新增内置插件,注册 describe_image / transcribe_audio / ocr_image
- 媒体来源支持 path(设备/工具回传,如 screensee 的 file)与 digest(CAS)
- 失败一律以 error 上抛,不渲染成普通文本(延续 screensee 的失败传播纪律)
- ocr_image 的描述明说「不是专用 OCR 引擎」:它只是用视觉模型读字,
真 OCR 在 internal/nlp 的 ONNX 未接入。名字承诺大于实现会让模型误判精度。
内核侧:删除 media.go 整文件(9 函数,全部零引用)、3 个 dispatch case、
3 个工具定义。
**留在内核**:input 通路(用户直传媒体直接拼进本轮 LLM 请求)——
那是「输入即上下文」,不是工具调用。
### 2. get_plugin_tools 收编为内核完整服务
原先它只收 StageHost + IOManager 两处,而三个媒体工具、memory_*、
knowledge_*、spawn_child 等全是内核 buildToolDefs 里直接 append 的
——一条都数不到。agent 能调这些工具,却调 get_plugin_tools 看不见它们。
- 以 buildToolDefs()(发给模型的那份工具表)为单一真相源
- 新增 builtinToolOwner 显式归属表:内置工具恒为 core
- resolveToolPlugin 的下划线前缀猜测限定在「该前缀是已注册插件」时才采纳,
避免 ocr_image→"ocr"、memory_commit→"memory" 这类幽灵归属(7 处调用点依赖它)
## 判据
新增 plugin_tools_service_test.go、multimodal_departed_test.go、
multimodal/plugin_test.go。三个结构判据经变异自证会红:
1. 提示词改回「请使用 describe_image 工具」→ 仍出现该名 ⇒ 红
★ 这条抓到了真问题:eventloop.go 的媒体提示词仍在叫模型调旧工具名,
而媒体 block 其实已内联给模型,且新工具需要 path/digest(用户直传都没有)
⇒ 模型照做必然失败。已修:改为「已随本消息附上,请直接查看」。
2. 内核为多模态加一个 os.ReadFile → 红(比「名字没出现」更强)
3. core 反向 import 插件 → 红(迁移而非删除,且守住分层)
## 验证
- go build ./... / go vet / go test ./internal/... 全绿
- go test -race ./internal/agent/core/ ./internal/plugins/multimodal/ 无数据竞争
- 部署后:39 插件加载、0 panic/fatal、0 非空 error
- 三个工具注册归属为 plugin=multimodal(不是 inferToolPlugin 猜的 describe)
- graph.db integrity ok,blocks 2787 / relations 980 未动
- 快照 /home/newqqagent/memory/pre-multimodal-plugin-20261006-090541/
- 二进制备份 /tmp/homed.bak-mmplugin
一并包含此前已验证未提交的三处静默失效修复:
- MCP MCPCallResult.IsError 丢失(空结果伪装成功)
- DeleteEntity 谎报删除
- screensee 谎报成功 + 截图诊断(尺寸/magic/sha)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
2026-10-06 09:07:18 +08:00 |
|
|
|
7bd569eccd
|
fix(gui): 修「长时间挂后台后打开白屏」—— 补 renderer/GPU 崩溃自愈
## 实测故障
GUI 在后台挂很久(数小时)后,从托盘打开是**白屏**,且不会自愈 ——
重启进程才恢复。
本机现场证据:GPU 进程启动于 20:27:56,而主进程是 16:03:29 ——
GPU 进程在主进程运行 4 小时后重启过一次,正是长时间后台后 GPU context
被回收的实证。
## 原因链(三个洞叠加)
1. **没有任何崩溃监听**。全代码搜不到 render-process-gone /
unresponsive / child-process-gone。renderer 被回收后全程无感知。
2. **窗口「可见」不等于「有内容」**。renderer 挂掉后窗口句柄仍在、
isVisible() 返回 true,而 showMainWindow() 只判
`if (!isVisible()) show()` ⇒ show() 被跳过 ⇒ 托盘点一下开出白窗口,
永远不会好。
3. **ready-to-show 用了 once**。once 只等第一次首帧;窗口被系统回收后
重建、或 GPU 重启后重新 load,那些路径都需要再 show 一次,
once 让它们静默失败。
仓库历史上有过「白屏修复(惰性Tray)」提交,但那只修了**首次**显示时机,
没管**长期后台后**。
## 修复
- render-process-gone → reload 自愈;reason==='oom' 时**不** reload
(内存不足时反复重载只会立刻再崩一次,把机器拖死)
- child-process-gone 且 type==='GPU' → reload 渲染进程
(无边框窗口在GPU 重启后常停在上一帧/全白,与 renderer 崩溃表现重合
但成因不同;不重建窗口,避免丢窗口位置与尺寸)
- showMainWindow() 加 webContents.isCrashed() 判定,为真则先 reload
- ready-to-show: once → on
- 通知点击路径改走 showMainWindow(),不再自己调 show()
(原来的直接 show() 绕过健康检查,同样能开出白窗口)
## 判据(cmd/gui/window-liveness.test.mjs)
9 条,文本扫描(复现需「挂后台数小时 + renderer 被回收」,依赖驱动行为,
CI 不可靠)。覆盖:render-process-gone 监听、崩溃后有 reload、
OOM 不重载、GPU 崩溃监听、isCrashed 判定、判定后重载、
ready-to-show 用 on、无绕过健康检查的直接 show、通知点击走 showMainWindow。
变异测试验证非假红:删掉 isCrashed 分支 ⇒ 对应两条转红。
排障中判据自身报过 2 次假红,根因都写进注释:
- sliceFunction 从 match 结果换成字符串后,残留的 showFn[0] 取到首字符
- 注释里也写着 isCrashed(),纯字符串匹配命中注释而非代码
|
2026-10-05 21:56:14 +08:00 |
|
|
|
878fd770bc
|
fix(remotedevice): device/push 校验 payload,杜绝「假成功」
## 实测坑(2026-10-05)
排查「设备能力全部调用失败」时发现:POST /api/v1/device/push 返回
`{"status":"ok"}`,设备侧却什么都没发生,日志里连一条 recv 都没有。
根因是**调用方把参数写错了**,而服务端把这个错误藏了起来:
{"device_id":"gui-JianF","command":"homeagent-clipboardsee"}
→ command 字段被静默丢弃(端点只认 payload)
→ req.Payload 为 nil
→ PushJSON 序列化后写进 socket,**成功**
→ 设备收到 {"payload":null},op 为空,直接忽略
→ 服务端返回 200 {"status":"ok"}
一次「什么也没发生」的操作,在报文上是彻底的成功。排查只能靠设备侧
日志发现「命令没到」,极易误判成链路/鉴权/心跳问题—— 本次就先后误
判为「frp 转发丢帧」「命令送达有问题」,绕了很久。
补上 payload 校验,并把正确格式写进报错文案(使用者最可能犯的就是
把 command 写在顶层)。
## 不动 device_id 的校验顺序
device_id 仍先于 payload 校验:更基础的参数先报错。三条非法输入现在
分别报 device_id 缺失 / payload 缺失 / payload 为 null。
## 判据
新增 push_validation_test.go,三个 case:
- command 写在顶层(最常见的误解)
- payload 为 null
- 既缺 device_id 又缺 payload(断言先报 device_id)
关键断言是「非法请求绝不返回 ok」—— 正是上一版的漏洞。
## 验证
- go test -run TestHandleDevicePushRequiresPayload ⇒ 3/3 PASS
- gofmt 干净
⚠ 本包的 go test ./... 跑不通:clipboardsue_content_test.go 引用了只在
GUI 侧定义的 clipboardsueMaxBytes(来自远端 7901651「根目录清理」),
编译失败。已确认与本次改动无关,未动该文件;上面那条是用 -run 精确跑通的。
|
2026-10-05 21:18:49 +08:00 |
|
|
|
85d76ad495
|
fix(gui): 设备桥补心跳与断线重连,兑现 liveness 门禁
## 背景
`device-bridge-liveness.test.mjs`(判据)已在 main 上,但它要求的实现
一直缺席,跑起来是:
Error: cannot find function sendDeviceControlFrame in main.js
判据注释描述了两个洞,合起来的效果是「一次失联,终身失联」:
1. 不发 ping —— waiter 侧有 pingLoop,GUI 侧一次都没发过;
2. 不重连 —— startDeviceBridge 失败就 console.error 结束。
我先前只补了 ping 且命名为 sendDevicePing,判据仍红;本次按判据契约补全。
## 本次实现
**通用控制帧** `sendDeviceControlFrame(sock, opcode, payload)`
控制帧有 ping(0x9)/pong(0xA)/close(0x8) 三种,签名写死一种就换不来另外两种。
**心跳** 每 30s 一个 0x9 帧(与 waiter 的 pingLoop 对齐)。
起在 doConnectDeviceBridge 而非 connectDeviceWS —— 跟着「一条实际连上的
桥」而不是「某次调用」,后者在重连路径上会漏。
**重连** `scheduleDeviceReconnect`:线性退避 base×n、封顶 60s,
成功即归零(否则「昨天断十次、今天断一次」要等 10×base 才有第一次重试)。
退避先递增后算:head 用 max(1,attempt) 会让 attempt 0 与 1 都算出 base,
出现「退避头两次一样」的假象。
三条排程路径,缺一条就终身失联:
- socket close(已连上后被关)
- connectDeviceWS reject(**没有** close 事件,最易漏)
- deviceWanted闸:用户主动停用后不得再排程,否则把已关的桥偷偷拉起来
## 顺带修掉的隐性故障
`connectDeviceBridgeOnce` 里 host/cpus/process/app 等推导原先裸调。
它们在 ws.send 的实参里,抛错会被上层 catch 吞成「连接失败」—— 而实际
连接是好的,表现为「反复重连但永远连不上」。现已逐项 try 包裹。
## 判据分工(避免两个门禁互相打架)
删掉 device-gateway-discovery.test.mjs 里 6 条 keepalive 文本判据
(grep 0x89、找 setInterval 间隔…)。它们只能证明「代码里出现过这些
字符」,证明不了「心跳真发出去了」。liveness 那份用 node:vm 跑真实函数,
是强判据;弱的那份会在实现重构时先红、掩盖真信号。
## 验证
device-bridge-liveness 14 条全绿(改前是 0 条可跑)。
另外 7 个门禁全绿:device-gateway-discovery / device-align /
endpoint-align / connections-io / retry-guard / sse-backoff /
package-config。
对齐过程中判据自身报过 3 次假红(process 未注入、doConnectDeviceBridge
无 return、ws 为 undefined),根因都已写进代码注释。
|
2026-10-05 14:37:43 +08:00 |
|
|
|
3988c19880
|
fix(gui): 设备桥补 WebSocket keepalive,修「设备可见但命令下发不到」
## 实测故障(2026-10-05)
设备能力全部调用失败,但设备**在面板里能看到**。服务端实测:
POST /api/v1/device/push {"device_id":"gui-JianF","command":"..."}
→ {"error":"device gui-JianF not online"}
同一时刻 NAS 上的 waiter 完全正常(last_seen 持续刷新、push 返回 ok)。
## 根因
GUI **从不发WebSocket ping**。全文件搜不到,是唯一没有 keepalive 的设备端。
链路中间是 frp + TLS 反代,空闲超时(常见 30~60s)会静默关掉连接
—— 没有 close 事件、没有错误日志,只是数据不再流动。
服务端 handleWS 的 defer 于是执行 markOffline(curID) → 从 conns 删除。
于是形成这个割裂状态:
· devices 表里有gui-JianF ⇒ webui 面板显示在线
· conns 表里没有 ⇒ PushCmd 报 not online
「可见但不可用」正是这个割裂的表象。registry.go:735 那段 ping/pong
注释修的是「**回** pong 不能因未 bind 而失败」,与「客户端有没有发」
是两个方向的坑 —— 那段代码一直在,却从未被 GUI 用上。
## 修复
connectDeviceWS 在 opened 后启动 25s 周期的 ping(0x89 控制帧,客户端
必须加掩码)。25s 压在常见 30s 空闲阈值之下。
两条清理路径都 clearInterval:close() 与 sock.on("close")。缺任一条都会
泄漏定时器并向已销毁 socket 写入(进程不退出)。
## 验证
修复前:gui-JianF 不在 devices 列表,push → not online
修复后:90 秒后仍在列表且 online:true,push → {"status":"ok"}
(同一时刻不再掉线,keepalive 生效)
门禁新增 6 条 keepalive 判据(共 15 条):
存在 sendDevicePing / 0x89 控制帧 / opened 后启动定时器 / 间隔 ≤30s /
close 清理 / close 事件清理。
变异测试:删掉 setInterval(复现故障形态)⇒ 定时器与间隔两条转红。
|
2026-10-05 13:51:20 +08:00 |
|
|
|
bca791a14b
|
fix(gui): 设备桥 gateway 自动发现回写主进程
## 要判的是什么
2026-10-05 实测故障:GUI 面板里能看到设备通道地址,但设备桥连不上。
日志只有一行 `ws cookie len=0` + 401,没有任何「地址来自发现」的痕迹。
根因不是配置错,是**发现结果与实际连接配置从不联通**:
- 渲染进程 app.js 的 loadDiscoveredGateway() 调 /device/gateway,
拿到的地址只写进 **state.discoveredGateway**(UI 展示用);
- 主进程 startDeviceBridge() 读的是 **gui-prefs.deviceBridge.gateway**;
- 两边从未交换过 —— state 是渲染进程内存,prefs 是主进程文件。
于是服务端明明已经通过 /device/gateway 告诉了客户端正确地址
(proxy.go:1078 生成的 url_portal),GUI 却仍在用用户几个月前手填的旧地址,
且永远 401/ECONNREFUSED、不重试。
## 修复
发现成功后回写 deviceBridge.set({gateway}),让主进程真正用上。
两个刻意的约束:
1. **仅当本地 gateway 为空才写**(!cur.gateway)—— 手填值优先。
用户可能故意指向内网直连或灰度实例,发现只做缺省补全。
2. **只传 gateway,不传 token** —— /device/gateway 不返回设备令牌
(ws_token),写了反而会写坏已有的正确 token。
deviceBridge.set 会 stop+start 桥,改完立即生效,无需重启进程。
## 顺带记下一个实测坑(已写进门禁)
设备桥 WS 的认证**不走 cookie**。registry.go ServeWS 只认三种来源:
?token= / X-API-Key / Sec-WebSocket-Protocol: homeagent.<token>,
cookie 完全不参与 acceptBind。
排查时曾误以为「发 cookie 就能过」—— 实测服务端仍 401。
cookie 那条路是错的,已还原 connections.json。
## 门禁(cmd/gui/device-gateway-discovery.test.mjs)
9 条判据,文本扫描(自动回写由 refreshAll 的 15s 定时器驱动,
构造那个时序需真 Electron + 真后端 + 会话 cookie,CI 里没有)。
覆盖:发现调用存在、回写接通、本地非空则不覆盖、只传 gateway、
preload 暴露 set、主进程注册 handler、get 返回明文 token、
set 会重启桥、connectDeviceWS 把 token 放进 query。
变异测试验证非假红:
- 删掉回写块(复现本次故障形态)⇒ 第 2、3、4 条转红;
- 去掉「本地为空才写」改成无条件覆盖 ⇒ 第 3 条转红。
排障过程中判据自身报过 3 次假红(正则窗口不足、字符串切片后仍按
match 数组用 setHandler[0]),均已修正并记在注释里。
|
2026-10-05 13:51:20 +08:00 |
|
|
|
2bbbb6b830
|
gitignore: exclude third_party/homeagent-sdk/example/ from core repo
|
2026-10-05 13:51:19 +08:00 |
|
|
|
7e4f1058ea
|
chore: 移出本地工具配置 .pi-lens.json
它是 pi-lens 的**仓库级 ignore 配置**,随每个人的工具版本与偏好变化,
对 CI 和其他贡献者毫无意义,还会成为 merge 噪音。
⇒ 移出仓库并加进 .gitignore,各人在本机放自己的一份。
★ 但它注释里的**根因分析要留在仓库**(配置外置、结论内留)——
runbook §6.1/6.2 已写得更全(runner 不按目录语言选+
failed-first 让误报永不自愈),故补 §6.3 说明「为什么仓库里没这个文件」,
原§6.3/6.4 顺延为 §6.4/6.5。
这个上游缺陷不知道哪年修:修之前每个新 clone 的人都会再踩一遍,
所以结论必须内留而配置必须外置。
|
2026-10-05 13:28:01 +08:00 |
|
|
|
5250ca35ea
|
fix(mailmap): 归并目标改为 GitHub noreply,消除幽灵贡献者
★ 根因是我把归并目标写成了 `JianFeeeee@users.noreply.gitcode.com`
—— 那是**GitCode 的 noreply 域,GitHub 不认**。
而 459 个提交(早期历史的大头)用的正是它,GitHub 无法把它们关联到
任何本地账号,只能用启发式挂上一个**不相干的账号**当贡献者
(实测出现过 linu223xprob1e)。
⇒ mailmap 把提交「归并到」某邮箱,等于告诉 GitHub「这些提交属于这个身份」;
归并到一个它不认识的域,反而**制造**幽灵贡献者。
★ 教训:目标必须是**目标平台认的**标识,不是「看起来像匿名邮箱就行」。
归并目标改为标准格式 109188060+JianFeeeee@users.noreply.github.com
(id + 登录名,从 GitHub API 实测得来,非猜测)。
本地实测(origin/main 全历史 765 提交):
归并前:459 gitcode noreply / 135 root@qq / 51 JianFeeeee@root@qq / 33 jianf…
归并后:745 JianFeeeee <109188060+JianFeeeee@users.noreply.github.com>
19 HomeAgent Agent(刻意不归并,区分自动化提交)
1 pi(自动化 agent)
1 jianf <â^@2198972886@qq.com> ← 坏字节,mailmap 治不了
最后 1 条需改写历史(邮箱前缀带 3 个坏字节,文本文件无法表达),
另开一次操作处理。
|
2026-10-05 11:51:13 +08:00 |
|
|
|
7901651946
|
chore: 根目录清理(第二轮)—— 移除过程文档、本机脚本、临时产物 (#3)
★ 上一轮清理被合并覆盖:GUI 分支含 DESKTOP-PET-NOTES.md /
SERVER-HANDOFF.md / GUI-PLAN.md,合并时取了我方版本,
但我方 main 已是 squash 产物 ⇒ 三个文件复活。
而我当时只核对了 benchmark.md 一个就宣布成功 —— 判断依据不足。
本轮逐个文件验证(不再抽样)。
删除(零代码引用)
DESKTOP-PET-NOTES.md 外部产品(Coopanion)拆解笔记
SERVER-HANDOFF.md 给服务端的待办清单,已过时
napcat-design-dna.json 设计 DNA 数据
tmp_jinabench.py 临时脚本
tmp_benchmark/ 临时跑分程序
归位(不是删)
plan.md → docs/zh/legacy-issues.md (遗留问题清单,有价值)
GUI-PLAN.md → cmd/gui/GUI-PLAN.md (含坐标换算/koffi 踩坑)
根目录那份与 cmd/gui/ 已有副本字节相同(285b84dd…),删前者
deploy-plan.sh → deploy/scripts/
deploy-waiter.sh → deploy/scripts/
deploy-sdk-site.sh → deploy/scripts/
脱敏(deploy-plan.sh 与 deploy-sdk-site.sh 含本机路径)
/home/newqqagent → ${HA_DATA}
/home/program/TrueAgent → ${REPO}
/home/program → ${HOME_DIR}
引用同步:27 处(docs/zh/deploy-runbook.md、toolcall-parallel-execution-plan.md、
scripts/capability-bench/PLAN.md、三个脚本自身的用法注释、ci.yml 注释)
根目录现状:仅 README.md / README_EN.md / LICENSE / Makefile /
go.mod / go.sum / .gitignore / .golangci.yml / .mailmap / .pi-lens.json
验证:go build ✅ 三个 sh 的 bash -n ✅
go test -short ./internal/... ./pkg/... ./cmd/... 全绿
根目录文件清单逐个核对(10 项,全为门面/配置)
Co-authored-by: pi <pi@local>
|
2026-10-05 11:44:04 +08:00 |
|