Commit Graph

1075 Commits

Author SHA1 Message Date
c9be04fdac fix(agent): 被拒绝/被跳过的工具没落 ToolResults —— call 与 result 失配
## 根因(4ab888c 的自检抓出来的)

4ab888c 在发送前加了配对自检,生产日志立刻指出真正来源:

    ★ dropped 1 unpaired tool message(s) before send (names: [qq_call_state])

顺着工具名查 context.json,脏帧是决定性的:

    [42] tool_calls   = [qq_call_state, output_send__qq]
         tool_results = [output_send__qq]        ← call_state 从未落盘

该帧自己的记录写了原因:qq_call_state 被 QQ 插件的来源权限策略拦下
(判定为"非所有者私聊")。

## 缺陷位置

stepToolBegin 的顺序是「先记 call,再判能不能执行」:

    f.appendToolCallRecord(tc)                // ← 先落 ToolCalls
    if a.runStage(sdk.StageBeforeToolcall, …) {
        f.Msgs 补一条 role=tool                 // ← 模型能看到
        f.ToolIdx++
        return outcomeContinue                  // ← 但没写 ToolResults
    }

同一函数里还有一处同构分支(插件崩溃态跳过执行),同样只补 f.Msgs。

task.go 顶部的注释写着「ToolCalls 与 ToolResults 通过 CallID 配对;
两者都由 appendToolCallRecord 同步追加」—— 这个不变量在**提前返回路径**
上被违反了。

## 为什么这会变成会话级故障

    ContextEvent{ToolCalls: 2, ToolResults: 1}
    ⇒ 渲染出 assistant(tool_calls=[c1,c2]) 但只有 c1 的结果
    ⇒ 上游硬拒绝(HTTP 400):
       An assistant message with 'tool_calls' must be followed by tool messages
       responding to each 'tool_call_id'. (insufficient tool messages…)

关键在于脏数据**持久化进 context.json**,之后每一轮都带着它,直到自检剔掉。

## 修法

新增 appendToolResultRecord(与 appendToolCallRecord 成对的公开入口),
在两处提前返回各补一行。

判定"被拒绝/被跳过"≠"没有结果":拒绝原因本身就是结果,而且模型确实
从 f.Msgs 看到了它。落 context 的那份必须与发给模型的一致。

并发路径(stepToolBatch)没有这个问题 —— runOneTool 只返回结果,
join 后的顺序收尾段无条件写 ToolResults(含被拒绝项)。

## 判据(2 条,2 次变异全部实测变红)

  TestDeniedToolStillRecordsResult
      注册一个真的会拒绝的 before_toolcall 钩子,跑完整一轮,
      断言落进 context 的每个 call 都有 result,且拒绝原因文本一致。
      变异:删掉拒绝分支那行 ⇒ "★ call call_deny_1 (denied_tool) 没有对应的
      ToolResults"(实测红)

  TestUnhealthyPluginSkipStillRecordsResult
      钉住第二条同构分支。
      变异:删掉跳过分支那行 ⇒ "★ 被跳过的工具 call_unhealthy_1 没落
      ToolResults —— 同型失配,上游 400"(实测红)

两条都走真实链路(真的调 runStage / 真的置崩溃态),不是只测
appendToolResultRecord 这个函数本身。

## 顺带发现一个独立缺陷(未修,不在本次范围)

写第二条判据时发现:插件崩溃熔断**实际从不生效**。

    recordCrash 标记 Unhealthy=true,但从不设置 LastReload
    ⇒ isHealthy 的冷却判据 time.Since(r.LastReload) > reloadCooldown
      在 LastReload 为零值时恒为真(Since 是巨大的正数)
    ⇒ 判定"冷却期早已过去" ⇒ 立刻自愈

实测:连续 3 次 recordCrash 后 needReload=true、Unhealthy=true,
紧接着 isHealthy 返回 true。

后果:task.go:1065/1135 的「插件崩溃态跳过执行」分支实际走不到。
这条应单独修并单独钉,本提交的判据用直达状态绕过它(注释已写明原因)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestDenied/TestUnhealthy 全绿
2026-11-04 16:24:49 +08:00
4ab888ce06 fix(agent): 发送前自检 tool_call 配对 —— 止损并定位上游 400
## 为什么还要修(前面两个修复都不够)

今天已经修了两处:

  263b651  并发批次 stepToolBatch 漏记 ToolCalls
  0ed3c89  L1 预算截断切开 tool 配对

并用生产 context.json 验证历史已干净:

    53 个事件,0 个"结果多于调用"的帧

但 A2A **新会话**(a2a_1793778753015670668)仍然报同一个 400:

    An assistant message with 'tool_calls' must be followed by tool messages
    responding to each 'tool_call_id'. (insufficient tool messages following...)

同时日志显示 `ratio=0.28`(远低于 1.25 阈值)、`skip oversized frame` 计数 0
⇒ 既不是裁剪,也不是预算截断。

## 定位:问题在 f.Msgs,不在 L1 历史

`f.Msgs` 是**多条路径**拼出来的:

    prepare 重建      buildMessages
    中断恢复          rebaseFramePrefix(重建 prefix + 接回 tail)
    批末附加          flushBatchAttachments
    占位补全          dropContinuationPlaceholders / continuation

任何一条漏掉配对,最终都表现为同一个 400,而协议**不告诉你是哪条 call**。
所以把自检放在唯一一次组装 provider 请求的位置(stepLLM,task.go:750),
而不是散在各条路径上。

## 为什么剔 call 而不是补占位

补一条 "tool result: (missing)" 会让模型看到它**从未调用过**的结果,
可能据此继续推理 —— 那是编造事实。剔掉 call 只是少了一次尝试,
模型可以重来,语义安全。

被剔的 call 记录工具名与数量,日志里能看出是哪一轮出的问题。

## 判据(5 条,3 次变异全部实测变红)

  DropsCallWithoutResult       主方向:没有结果的 call 必须剔
      变异:直接 return msgs ⇒ "★ 未配对的 call c2 仍在消息里 —— 上游必然 400"

  DropsOrphanToolResult        反方向:找不到 call 的孤立 tool 必须删
      变异:删掉第二遍循环 ⇒ 红

  DropsEmptyAssistantShell     call 全剔且正文为空 ⇒ 整条删
      变异:去掉该分支 ⇒ 红

  KeepsContentWhenAllCallsDropped
      call 全剔但**有正文**时保留正文(那是模型说的话,不能丢)

  LeavesValidSequenceUntouched
      合法序列必须逐条不变 —— 修正逻辑不能引入新问题

只测一个方向会放过另一半失配,所以两个方向都钉。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + TestDropUnpaired 全绿

## 下一步

这条是**止损 + 定位**,不是根因修复。部署后如果日志里出现
"dropped N unpaired tool message(s)",那 N 与工具名就指向真正漏配对的
那条路径,据此再修上游。
2026-11-04 15:58:22 +08:00
af17af5082 chore: 规划书不进仓库(与 plan.md / kernelplan.md 同惯例)
## 为什么

规划/设计在交接前会反复改。入库后有两个实际问题:

  · 每改一次就是一条"改文档"提交,把真实代码提交埋掉
  · 规划与实现必然漂移,读了仓库里的人会拿到过期结论

## 既有惯例

本仓早就是这样处理的,不是新立的规矩:

  /plan.md         曾在 7901651「移除过程文档」被清出
  /kernelplan.md   从未入库
  两者都在 .gitignore 里,都只存在于工作区

## 做法

  · docs/zh/dshplgadapter-plan.md 从索引移除(文件本身留在工作区)
  · 挪到仓库根 dshplgadapter-plan.md,与 plan.md / kernelplan.md 同位置
  · .gitignore 加 /dshplgadapter-plan.md,并写明理由

实现落地后,结论会进 docs/zh/ 下的正式设计文档 —— 那里才是该留档的地方。

## 影响

内核仓历史里 e8d07cd、82d08eb 两个提交只含这份规划书,现已从工作树移除。
如需追查当时的结论,git show e8d07cd 仍可读到(历史不重写)。
2026-11-04 15:54:56 +08:00
1b6cd57dca fix(skillmgr): 内核旧版 skill 永不更新 —— 无 stamp 不等于"用户改过"
## 缺陷(实测,非推测)

给 kernel-plugins 补上 `skill_info(name, section=...)` 的用法说明后,
改了嵌入内容,但**生产上永远不会更新**:

    /home/hologramagent/skills/kernel-plugins/   mtime 2026-10-26,无 .builtin-sha256
    嵌入版本 1.1.0   vs   磁盘版本 1.0.0

`materializeBuiltinSkills` 的判断链是:

    builtinSkillUnmodified(dir)  → 读 .builtin-sha256
                                  没有 stamp ⇒ false
    ⇒ continue("用户改过或无法确认,保留人的版本")

而 stamp 机制是 387ae94(今天早些时候)引入的。生产机上这份 skill
是**更早的内核**写的 —— 它没有 stamp,于是被永久判为"来历不明"。

后果不只是这一份文档:任何在 stamp 机制之前部署的机器,其内核自带
skill 都停在旧版。而 kernel-* 系列正是教模型怎么用工具/记忆/终端的
文档 —— 内核改进了文档,模型永远看不到。

## 修法:用 frontmatter version 区分两种"没有 stamp"

原判据把两件事混成一件:

    用户改过措辞        ⇒ 必须保留
    内核在 stamp 之前写的 ⇒ 应该升级

新增 builtinSkillOutdated(dir, embedded):只在**没有 stamp** 时被调用,
判据是磁盘 frontmatter 的 `version` 严格低于内嵌版本。

安全性分析(写在代码注释里):
    人改措辞         通常不动 version ⇒ 不低于 ⇒ 保留 ✓
    人把 version 改高                        ⇒ 保留 ✓
    内核旧版(无 stamp)                      ⇒ 升级 ✓(这正是要修的)
    解析不出版本号                            ⇒ 返回 false ⇒ 保留(保守)
误升级的唯一条件是"人把 version 改低",而那本身就意味着放弃内核版本。

原有的四条保守判据(含 NoStampIsConservative)全部保持绿。

## 判据抓到了我自己写的 bug

TestCompareVersion 立刻红了:

    compareVersion("1.0","1.0.0")=-1,期望 0

原因:缺失段留成空串 ⇒ Atoi("") 失败 ⇒ 落到字符串比较 ⇒ `"" < "0"`
⇒ 版本号少写一段的 skill 会被无谓地重复升级。改为缺失段视为 "0"。

## 变异验证

  builtinSkillOutdated 恒 return false(= 修复前行为)
      ⇒ TestMaterializeBuiltinSkills_UpgradesStaleKernelVersion 红:
        "★ 没有 stamp 的内核旧版(version 0.0.1)没被升级"

  compareVersion 缺失段改回空串
      ⇒ TestCompareVersion 红:"compareVersion(\"1.0\",\"1.0.0\")=-1,期望 0"

## 顺带:kernel-plugins v1.1.0

补一节「Reading a skill without burning context」,说明:
  - 长文档返回目录 + 开头 30 行,目录每行带 (N 行)
  - `section` 可按编号或标题子串取,大小写不敏感,容忍 `##` 前缀
  - 顶层章节返回整棵子树
  - 未命中会列出全部可用章节
  - 章节边界尊重围栏代码块

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + skillmgr 全绿

(包数 63 → 62 是因 clawhubadapter 已在 a0077a2 迁出为外部插件,与本改动无关)
2026-11-04 15:50:39 +08:00
82d08ebc45 docs(plan): 修正 dshplgadapter 规划书的仓库引用 + 补交接须知
## 原稿的四类引用错误(交给别人会直接卡住)

  ① 说 clawhubadapter 在 internal/plugins/ —— 已迁出,那里没有了(a0077a2)
  ② 指向 /home/program/clawhubadapter-plugin/ —— 本机路径,别人打不开
  ③ 指向 /opt/agentmail/plugins/dsh-mail-bridge/ —— 本机生产拷贝
  ④ ★ 完全没提 deepseek-ai/deepseek-harness —— 漏掉唯一的 dsh 官方文档源

第 ④ 条最严重:dsh / dsh-tools / cordis 三个包的 package.json 的
repository 字段全指向那个 monorepo(公开,master,2026-11-03 仍在更新),
里面有 docs/cordis-tutorial/01..07 的**七步中文教程**、tool-execution-pipeline
(tools/execute 异步链的权威说明)、cordis-primer、api/、cookbook/ ——
正是阶段 1-3 的直接依据,原稿一条都没有。

## 现在的引用(逐个实测 HTTP 200)

  github.com/JianFeeeee/HologramAgent          内核仓
  github.com/JianFeeeee/hologramagentsdk       SDK 仓
  github.com/JianFeeeee/clawhubadapter         参考插件(原型)
  github.com/deepseek-ai/deepseek-harness      dsh 官方
  github.com/QIKO0721/dsh-mail-bridge          mail-bridge 公开镜像

只剩两处本机路径,且都写了适用条件、标明不是阻塞项
(dsh 的 ~/.dsh/profiles 固定布局;mail-bridge 的本机生产拷贝)。

## 新增 §9 交接须知

给没有本机环境、没有上下文的人:

  · 9.1 需要的 6 样东西 + 哪些阻塞(只有 mail-bridge 一项依赖特定机器)
  · 9.2 开工前的环境自检命令(含期望输出)
  · 9.3 三条别再踩的坑(go build 不成立 / NewPluginFactory / stdout 只能 JSON)
  · 9.5 一个前置决策:dsh 插件能跑任意 Node 代码 = 给本机执行权限,
        必须在阶段 1 前决定,不能"先跑通再说"
  · 9.6 已验证 vs 推测的诚实标注

9.6 是本轮最重要的补充。四条"推测"(裸 Context 承载插件、fd3 通道、
defineTool schema 无损转换、异步链不死锁)就是阶段 0 存在的理由 ——
不标出来,接手人会把推测当事实,直接跳到阶段 1。

## 结构校验

表格 0 处列数不一致;代码围栏 38 个(偶数,配对完整);766 行。
2026-11-04 15:49:40 +08:00
e8d07cd8a0 docs(plan): dshplgadapter 规划书 —— 按外部插件形态重写
## 方向

把 dsh(DeepSeek Harness)的 Cordis 插件生态接入 HologramAgent,
参考原型是刚迁出的 clawhubadapter(两者同构:兼容层 + sidecar + 转译注册)。

## 本文件在被推翻过一次,纠正记在正文里

初版结论是「必须做成内置插件」,依据是「外部插件发不了事件,所以
skill 移交只能靠概率」。该依据实测被推翻:

  · 外部插件自带 skill 由 pluginmgr 在安装/启动时移交,与插件发不发事件无关
  · clawhubadapter 已实际迁成外部插件并端到端跑通

所以 v2 改成外部插件,并把原因写进 §0 结论 2(保留纠错痕迹,
下次有人拿旧结论当依据时会看到为什么它不成立)。

## 内容的依据

全部来自实测,不凭记忆:

  dsh 0.2.1-rc.2 / cordis 4.0.5 / dsh-tools 的 defineTool 签名
  /opt/agentmail/plugins/dsh-mail-bridge/src/index.ts(2586 行生产代码,
  是 dsh 插件侧 API 最可靠的文档)
  third_party/hologramagentsdk 的 capability-boundary.md 与 proc_main.go.tmpl
  (外部插件实际拿到什么能力,全在那一个文件里)

## 最该先做的一件事

不是写代码,是探针 0.1:验证 fd 3 做协议通道。

理由:dsh 生态插件大量用 console.log,而子进程 stdout 是 JSON-RPC 通道。
本仓为这个坑付过代价(生产累计 1038 条「非法 JSON 帧」报错,定位真因
花了一整轮)。clawhubadapter 靠「模拟器把 logger 接到 stderr」躲过去,
但那只管模拟器内的调用,插件直接 process.stdout.write 仍会漏。
这条不解决就不该进阶段 1。
2026-11-04 15:24:00 +08:00
a0077a2bad refactor(plugin): clawhubadapter 迁出为外部插件
## 为什么迁

它是唯一还留在内核仓的「兼容层」插件,而它的全部工作都是对接**外部生态**
(ClawHub/OpenClaw 的包格式),跟内核版本无关。留在仓里有两个实际问题:

  · 外部生态的新包格式跟进要走内核发版
  · 它必须跟着内核一起重新编译,而它并不依赖内核内部实现

## 迁移改动(逻辑一行没动,只换接口形态)

  package clawhubadapter        → package main
  init()+RegisterFactory        → NewPluginFactory(proc 运行时按名调用)
  internal/sdk                  → hologramagentsdk/sdk
  internal/safeclose.Safe       → 自写 safeClose[T](泛型覆盖 3 种 channel)
  InjectInputSync(4 参)          → InjectInputSync(3 参, 返回 string)
  s.Events().Publish            → 删除(外部插件拿不到,也不需要)

## 为什么删掉 Events().Publish 而不是找替代

原实现靠它把纯 SKILL 条目移交 skillmgr。查清后发现**不需要**:
pluginmgr 在安装与每次启动时扫 `<插件目录>/skill/` 自己发 skill_detected
(internal/plugins/pluginmgr/skill_handout.go)。外部插件带 skill 走
工程根 skill/ 目录,与插件能不能发事件是两件事。

链路上没有一行来自插件自身,实测日志:

  [pluginmgr] clawhubadapter: handed off skill clawhub-tools to skillmgr
  [skillmgr]  registered via skill_detected event: clawhub-tools
  [plugin]    loaded SKILL: clawhub-tools v1.0.0

## 验证

  迁移工程  /home/program/clawhubadapter-plugin/clawhubadapter
  go vet / gofmt                    干净
  go test -race                     ok(20 条原有 + 4 条外部形态判据)
  hmapdev build                     packaged clawhubadapter_bundle.hmap
  变异验证                           4 条判据全部真红

  隔离实例端到端(真实安装路径,非模拟):
    registering tool: clawhubadapter_* × 10
    [plugin] loaded: clawhubadapter
    [plugin] loaded SKILL: clawhub-tools v1.0.0

  内核仓:go build / vet / gofmt / go test -race 全绿,62 包

## 生产切换的注意点

本 commit 只删源码,**不改生产**。生产 homed 里仍编着内置版;
要真切换必须先装 .hmap 再重编 homed,否则等于把这个插件弄没了。
2026-11-04 15:23:53 +08:00
67be114647 feat(skillmgr): skill_info 支持按章节读取 —— 长 SKILL 不再一次吞一万 token
## 问题

skill_info 此前**整份返回** SKILL.md。生产最坏一例:

    commondev-discipline v2.3.0 → 983 行 / 53810 字节 / ≈10600 token

模型为查「怎么开 PR」(§14,21 行)必须把 hmap 插件开发纪律(320 行)
和红牌一起读进上下文。一次误读一万 token,还挤掉了真正要用的历史。

拆 skill(一个插件声明多个)能解决"跨文档绑定",但解决不了
"单文档内部按需读" —— 本提交补的就是后者。

## 改法

skill_info(name, section=...) 只返回匹配的章节。不传 section 时:

  短文档(<=200 行)  直接返回全文(加目录只是噪音)
  长文档(>200 行)   返回「目录 + 开头 30 行」,模型据此决定读哪节

目录每行都标 `(N 行)` —— 行数不是装饰,它是模型在"一次吞一万 token"
和"先读目录"之间做选择的唯一依据。

## 三个实现要点

① **切分必须感知围栏代码块**

   SKILL.md 里有大量 shell 注释:

       ```bash
       # 期望 200;401/403 = 凭据无效
       curl -s ...
       ```

   硬按 `#` 切会把一条 curl 示例拆成两半 —— 模型拿到的是无法执行的
   碎片,比不给还糟。parseSections 跟踪 ``` / ~~~ 围栏,块内不切。

② **返回行号区间,不复制字符串**

   章节内容的唯一事实来源仍是文件本身,目录与正文用同一套边界计算,
   不会漂移成两份。

③ **点名顶层章节给整棵子树**

   问 `## Setup` 时要的是 §11–§16 全部,不是只有标题那一行。
   边界 = 下一个**同级或更浅**的标题。

## 判据(均经变异验证)

  TestSectionsRebuildEqualsOriginal    ★ 主判据
      所有区间拼接必须逐字节等于原文
      一次同时抓漏行 / 重行 / 错位三类缺陷
      变异:end 改 heads[i+1].line-1 ⇒ "原文 16 行,拼回 13 行"(实测红)

  TestSectionsShellCommentIsNotHeading
      变异:删掉 fence 判断 ⇒ "代码块内的 shell 注释被当成标题:
      \"期望 200;401/403 = 凭据无效\"(行 4)"(实测红)

  TestResolveSectionReturnsWholeSubtree
      变异:`<=` 改 `<` ⇒ "Setup 越界吃进了 Usage 的内容"(实测红)

  TestResolveSectionToleratesHashPrefix
      模型常把 `## Setup: git 操作` 整行传进来,必须容错

  TestRenderTOCListsLevels
      目录只列到 maxLevel,且必须带行数

## 判据本身最容易写废的地方(已避开)

① 用人造短文档测切分 —— 那种文档没有 shell 注释、没有嵌套层级、
   没有围栏代码块,测过了不代表能切真实文件。
   主判据**用生产那份 983 行的真实文件**。
② 只断言"切出 N 个章节" —— 数量对不代表边界对。

## 真实数据验证

    文档 983 行 → 切出 46 个章节(## 顶层 7 个)
    区间拼接 == 原文(逐字节)
    section="审批"    →  21 行(§16)
    section="Setup"   → 123 行(含 §11–§16 全部子节)
    section="hmap"    → 320 行

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    63 包全过,0 FAIL
    干净检出(worktree)       build + skillmgr 全绿
2026-11-04 15:06:04 +08:00
0ed3c894f5 fix(agent): L1 预算截断切开 tool 配对 —— 这才是上游 400 的真因
## 上一个提交修的是另一半,这是主因

263b651 修了"并发批次漏记 ToolCalls"(历史脏数据)。但**新会话仍然报同样的错**,
说明还有一条独立的路径在制造非法序列。

## 定位过程

① 先读内核自己写的日志(homed_<时间>.log):

    overflow check #1: ratio=1.21 events=41 (threshold=1.25)

上下文**没触发裁剪**(1.21 < 1.25)——所以不是裁剪切断配对。

② 于是直接拿生产 context.json 的真实数据跑渲染,按 call_id 统计配对:

    budget=480000 → 469 calls / 386 answered / **83 dangling**
    budget=100000 → 469 calls /  40 answered / 429 dangling

**默认预算下就已经在切断。**

## 根因

renderL1Messages 的预算截断是**逐条消息**累加的:

    for _, m := range msgs {
        if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 {
            return out          // ← 可能切在 assistant(tool_calls) 之后、tool 之前
        }
        out = append(out, m)
    }

`msgs` 是一组 [user?, assistant(tool_calls), tool, tool, ...]。`return` 落在
组中间 ⇒ 序列里留下一个永远等不到结果的 tool_calls ⇒ 上游 400。

这与 InactivePages 的约束是同一件事(那边注释已写着"剔掉 call 却留下 result
就是造一个必崩的帧")—— **整组要么全留,要么全丢**。

## 修法:以事件(消息组)为截断单位 + 跳过失控帧

    ① 组内完整:groupCost 一次算完,整组 append 或整组不 append
    ② 单组超过预算 1/4 视为**失控帧**(实测见过 468 个 tool_call 的单帧,
       51 万 token),此时跳过它继续往后渲染 —— 否则它会把之后的全部
       历史一起吞掉(实测 49 个事件只剩 8 个,上下文几乎清空)

② 的判据只看"这一组自己是否失控",**不看 out 是否为空**:
第一版写成 `len(out)==0 || groupCost*4 <= maxTokens`,于是失控帧恰好
排在第一位时仍然 return —— 判据实测抓到"渲染出 0 条消息"。

## 判据(均经变异验证)

  TestRenderBudgetNeverSplitsToolPairing
      扫 8 个预算档位,任何档位都不得有悬空调用
      变异:改回逐条累加 ⇒ budget=50 时 "call ca0 无配对结果"(实测红)

  TestRenderOversizedFrameDoesNotDropNewerHistory
      失控帧之后的新对话必须仍然可见,且跳过时不产生悬空
      变异:去掉"跳过"分支 ⇒ "渲染出 0 条消息"(实测红)

## 用生产真实数据验证修复效果

    修复前:budget=358553 → 14 条消息(5 assistant / 1 tool / 8 user)
    修复后:budget=358553 → 236 条消息(41 assistant / 147 tool / 48 user)
             dangling=0,四个预算档位均为 0

即:不再有非法序列,且上下文不再被单帧吞空。

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + TestRender* 全绿
2026-11-04 14:15:33 +08:00
263b651bf0 fix(agent): 并发批次漏记 ToolCalls —— 修上游 400 与 A2A 会话不可用
## 故障现象

生产 A2A 只回一句错误:

    An assistant message with 'tool_calls' must be followed by tool messages
    responding to each 'tool_call_id'. (insufficient tool messages following
    tool_calls message)

所有 provider 全部失败,那条会话彻底不可用。

## 排查方法:读内核自己写的日志

内核每次启动写一份 homed_<时间>.log。在其中看到:

    overflow check #1: ratio=1.21 events=41 (threshold=1.25)

上下文**没有**触发裁剪(1.21 < 1.25)——所以不是裁剪切断配对。

于是查 context.json 的实际数据,按 call_id 配对统计,发现 4 个事件
出现"结果多于调用":

    [17] calls=18 results=20
    [22] calls= 1 results= 3
    [28] calls=14 results=20
    [35] calls= 5 results= 7

## 根因

帧里有两条执行路径,各自记录:

    stepToolBegin → appendToolCallRecord(tc)   ← 记 ToolCalls + ToolsUsed
    stepToolExec  → append ToolResults          (串行路径,两条都记)
    stepToolBatch → append ToolResults          (并发路径,只记结果!)

并发路径(stepToolBatch)**从不调 appendToolCallRecord**。

于是落进 ContextEvent 的数据里 ToolResults 多于 ToolCalls。渲染时
每条 result 都生成一条 role="tool" 消息,而配对的 assistant(tool_calls)
里没有这个 call_id ⇒ 上游 400 ⇒ 所有 provider 失败。

同时还漏了 ToolsUsed(它与 ToolCalls 在 appendToolCallRecord 里同处维护):
并发批次的工具从未被记进"用过的工具",记忆/统计/页化都在少算。

## 为什么不是本次内核升级引入

对比日志:ba8a33d(升级前)今天也有 3 次同样错误。它是既有缺陷,
只是并发批次越多越容易触发。本次排查顺带定位并修掉。

## 修法

在 join 之后的**顺序收尾段**补一行 appendToolCallRecord:

    f.appendToolCallRecord(f.CurTool)
    f.ToolResults = append(...)

为什么放这里而不是 fan-out 里:函数注释已明确 f.Msgs / f.ToolResults
在 goroutine 期间一律不碰。这里是按索引串行的收尾段,与单步路径语义一致。

## 判据 TestBatchContextEventPairsCallsWithResults

三条:调用记录齐全、每条结果有配对、每个调用有结果(双向)。

★ 已有 TestBatchToolCallIDsAllPaired 没抓到,因为它只看 f.Msgs ——
而 Msgs 里的 assistant 消息由 ensureBatchAssistant 单独写入(含全部
PendingTools),不经过 f.ToolCalls。所以 Msgs 侧配平、落盘侧不配平。
判据必须落在**要落盘的那份数据**上。

## 变异验证(两次才对)

第一次:删掉修复行 ⇒ 判据**没红**。原因:测试里的工具没声明
ParallelSafe,batchRunnable 判 false ⇒ 走串行路径(stepToolExec),
根本没测到目标代码。

第二次:给工具加 ParallelSafe + 断言 ConcurrencySafeOf 为真
(确保真的走并发),再删修复行 ⇒

    ★ f.ToolCalls 只有 0 个(map[]),期望 2 个

这才是有效验证。教训:**判据必须断言自己走到了目标路径**,
否则"删掉实现仍绿"会被误读成"实现不必要"。

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + TestBatch* 全绿
2026-11-04 14:02:58 +08:00
a8ce5ece30 feat(agentcli): 补 skill 文档 —— 终端 vs 一次性命令的边界
## 为什么补这个

真实生产事故:agent 把 terminal_create 当成一次性命令执行器用。

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && echo "=== 已安装的 dsh 包 ===" && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

三层成因:

  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run,需先 plugin_start cmd
  ② terminal_create 的 command 参数描述写着"要执行的命令",语义上像"跑一条命令"
  ③ 没有文档说清"一次性命令该用 cmd_run、终端是给要持续输入的程序用的"

①③ 已分别修掉(cmd-tools skill + 本文件)。② 是设计上的引力:
参数名叫 command、描述说"要执行的命令",模型自然会塞复合命令进去。
文档要正面抵消。

## 实现方式

按 seq 的既有模式:内置插件实现 plugin.SkillProvider,
由内核统一写盘/幂等/事件/停用清理(internal/plugin/builtin_skill.go)。

  · 新增 internal/plugins/agentcli/skill.go
  · BuiltinSkills() 交出正文,生命周期与插件同生共死
    (禁用插件后 skill 还留着 = 模型看得到说明、点进去工具却不存在)

## 文档的核心:可操作判据,不是抽象词

一句判据:**跑完就结束 → cmd_run。要一直活着、要看屏幕、要按键 → terminal。**

关键的三条(缺任何一条模型都会退回旧行为):

  · 点明 cmd 插件要 plugin_start(否则模型找不到 cmd_run 就用终端)
  · 点明复合命令(cd && echo && ls)属于一次性,不是"所以要用终端"
  · 解释 /dev/ptmx 那个真实报错的含义(命令跑完终端已退出),
    否则模型会以为终端坏了

索引行(模型每轮唯一能看到的一行)也带边界:
"交互式终端(vim/ssh/top/长驻进程)。一次性的 shell 命令不要用终端,用 cmd_run。"

## 判据(均经变异验证)

  TestSkillProviderContract               编译期断言 SkillProvider + 正文非空
  TestSkillSeparatesTerminalFromOneShot   ★ 六条必备内容缺一即红
  TestSkillIndexLineStatesBoundary        索引行必须指出替代方案
  TestSkillBodyFirstParagraphStandalone   正文首段(真正注入提示词的那段)能独立看懂

变异验证(实测):把整份文档换成"终端可以执行各种命令"的弱化版本
⇒ 3 条同时红。

★ 第一版变异是"只改一句话",判据没红 —— 因为 cmd_run 在文中另有 13 处。
  那次变异无效,不算验证。改成整份替换后才是真验证。

## 端到端验证(独立实例 + 假 LLM 捕获真实请求)

  [plugin] agentcli: installed skill "agentcli-terminal" -> .../skills/agentcli-terminal
  [clawhubadapter] skill agentcli-terminal detected, handed off to skillmgr

实际注入到 system prompt 的 [Skills] 段:

    agentcli-terminal v1.0.0 - 终端给**要持续输入**的程序用(vim、ssh、top、REPL、
    要按键确认的安装器);一次性的 shell 命令用 **cmd_run**(cmd 插件,需…

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + 判据全绿
  独立实例实测                 真机装盘 + 真机注入提示词(见上)
2026-11-04 13:22:07 +08:00
01c7c0145e fix(kernel): 补上统一挂载点的生产接线 —— 上一版功能实际未生效
## 这个 commit 修的是我自己上一版的破绽

上一轮我报告"63 包全绿"。今天用户问「确定都可以正常工作」,
一查发现:**生产路径上根本没人调 kstate.Mount()**。

    $ grep -rn 'kstate.Mount(' --include='*.go' internal/ cmd/ | grep -v _test
    (空)

后果链:

    kstate.Get() 永远返回 nil
      ⇒ agent/core 的 renderSystemPrompt 走 `if ks != nil` 跳过分支
      ⇒ 插件声明的系统提示词段**一条都不渲染**
      ⇒ 模型看不到任何 skill 索引,只能凭工具名猜用法
      ⇒ 正是本次重构要修的那个生产故障(拿 terminal_create 当 cmd_run)

而且**日志里一个字都没有** —— 静默跳过,不是报错。

## 为什么测试全绿

因为测试自己调了 `MountForTest`:

    restore := kstate.MountForTest(&kstate.State{PromptSections: reg})
    t.Cleanup(restore)

判据把缺失的那根线补上了,所以永远绿。这正是本仓反复吃过的亏
(engineering-lessons §1.1)。

## 一共两个断点

  ① kernelboot.Run 没调 kstate.Mount(ks)
  ② sdk.SetSystemPromptAPI 生产上没人调
     ⇒ 插件的 s.SystemPrompt() 返回 nil
     ⇒ skillmgr.publishIndex() 对 nil 是静默 return(它不能 panic)

## 修复

  ① kernelboot.Run 装入挂载点(唯一入口)
  ② 补齐三段接线:
       plugin.Registry 加 promptSections 字段 + SetPromptSections
       kernelmod/pluginhost 从 ks.PromptSections 注入进去
       internal/sdk 收到后调 SetSystemPromptAPI(promptSectionAdapter)

## 新增判据:**不自己 Mount**,只读 Run 之后的状态

  TestKstateIsMountedByRun             源码判据(那行必须在)
  TestPromptSectionsWiredToPlugins     三段接线逐个核对
  TestSharedDataInjectionsHaveProducers 通用:注入点必须有生产者
  TestRunActuallyMountsState           ★ 端到端

最后一条是关键:它先 `MountForTest(nil)` **清空**挂载点,再跑真实
`kernelboot.Run` + 真实模块登记表,然后从底层包视角(kstate.Get())
检查能不能看到东西。它不能靠"测试自己挂"蒙混过关。

变异验证(实测):
  · 删掉 kstate.Mount(ks) ⇒ 源码判据红 + 端到端判据红
    「kernelboot.Run 跑完了,但 kstate.Get() 仍是 nil」

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + 端到端判据全绿
  六环人工核对                 ①Mount ②Run ③产出 ④注入 ⑤sdk ⑥skillmgr 各 1 处

## 教训

"全绿"只在**判据不自己补线**时才是证据。凡是"被测方需要某个前置
装配、而判据自己提供了它"的判据,都必须再配一条"从零开始"的
端到端版本。
2026-11-04 12:41:56 +08:00
8cc8bca380 test(kernel): 架构防腐判据 + 修 migrateEntry 的并发缺陷
## 判据(internal/kernelmod/all/layering_test.go)

六条,全部经变异验证:

  TestCoreDoesNotImportKernelboot        core 不依赖装配引擎
  TestPluginDoesNotImportKernelboot      plugin 不依赖装配引擎
  TestSdkDoesNotImportKernelboot         sdk 不依赖装配引擎
  TestSecurityDoesNotImportKernelboot    security 不依赖装配引擎
  TestKstateDoesNotDependOnCore          kstate 不依赖 core("core 能读挂载点"的另一面)
  TestSecuritySingletonGuardStillExists  单例判据文件不得被删
  TestAgentHasNoPromptSectionsField      Agent 不得再持有提示词段字段
  TestPluginSettersAreInjection          防过度收敛:plugin 不得读 kstate
  TestPromptSectionsOnlyFromKstate       内核只从挂载点读插件段

用 `go list -deps` 而不是源码扫描:传递依赖能抓到间接环。

## 为什么需要"防过度收敛"这一条

kernelplan v1 曾主张把 plugin 的 12 个 SetXxx 改成"读 kstate"。实测做不到:

    kstate.State.PluginRegistry 的类型是 *plugin.Registry
    ⇒ kstate 依赖 plugin
    ⇒ plugin import kstate 就是 import cycle(已用探针实证)

更重要的是——它们**本来就不是缺陷**:每个 Setter 只有一个调用点,
调用方持有 ks、显式传参;少了任何一个插件拿 nil 立刻报错,
不存在"静默降级"(那才是包级单例的病症)。

TestPluginSettersAreInjection 防的是后来者看到一堆 Setter 就想合并成
全局注册表,那会把"显式注入"退化成"隐式全局"。

## 顺带修一个真缺陷

migrateEntry 在**解锁之后**调 saveLocked()。而 saveLocked 遍历 s.data
序列化 —— 解锁后再调就是在读一个可能被并发改写的 map:

  · data race(-race 可复现)
  · 可能写出半更新的快照(部分条目是新密文、部分是旧密文)

函数名里的 Locked 就是要求"调用方已持锁"。改为持锁期内调用。
判据:go test ./internal/security/ -race 全绿。

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  go test ./internal/security/ -race   PASS
  干净检出(git worktree)     build + 判据全绿

## kernelplan.md 同步

T5 标记为"计划错误,已撤销",并记录实测依据 —— 避免下次又有人
想"顺手收敛掉"这十几个 Setter。
2026-11-04 12:28:43 +08:00
5bdd0b5107 refactor(security): Store 复用 Codec 加密能力 + 删 globalCodec 单例
## 用户裁定

> store也应该调用security的加密能力!

## 改动前的事实(实测)

两套**完全重复**的 AES-256-GCM:

    Store.seal/open     store.go:372,388    裸 base64
    Codec.Seal/Open     codec.go:113,139    enc:v1: 前缀 + 域隔离 + 幂等

算法、nonce 布局(前置 + io.ReadFull(rand))、密钥环轮换逻辑全都一样,
只有 Codec 多了前缀与域隔离。

危害不是"代码重复"这么轻:

  · 两套实现各自演化,任何一侧的改动不会被另一侧的判据发现
  · Store 缺域隔离 ⇒ 变量库与配置共用密钥(安全倒退)
  · Store 缺前缀 ⇒ 无法区分"旧密文"与"明文"(惰性迁移的障碍)

## 本次做的

### Store 委托 Codec

    codecOf()            按当前 keys 环惰性构建(轮换后自动跟上)
    sealWithRing()       新格式(enc:v1: 前缀)
    openWithStoreRing()  兼容新旧两种格式,返回 needsMigrate

删除 Store 自带的 seal/open/openWithRing —— 现在**只有一处** AES-GCM。

### 域隔离(安全判据)

    storeCodecDomain  = "security/variables"
    configCodecDomain = "config-at-rest"

两者必须不同:同域时一处明文泄漏可推导另一处密文。

### 存量密文惰性迁移

旧格式是裸 base64 无前缀,而 Codec.Open 对无前缀输入**原样返回明文**
(那是给 config 的存量明文兼容)。直接交给 Open 会把密文当明文返回,
症状是"凭据回填出一个 base64 串"。

所以 Store 走 openWithStoreRing:

    enc:v1:... → Codec.Open
    裸 base64  → Codec.OpenLegacy(解开后立刻回写新格式)

不做启动时全量迁移(21G 数据目录的教训:迁移窗口越长越危险),
也不让两种格式永久共存(判据永不能要求"落盘都是新格式")。

### 删 globalCodec 单例 → 归 Runtime

    security 模块 → rt.SetCodec(c) → ks.Security
    config 模块   → cfgReg.SetCodec(ks.Security.Codec())

ConfigRegistry 加 codec 字段(注入而非读全局),6 处 ConfigCodec() 改为
r.codecOf();PluginSettings 经 p.registry 转调。
sdk.SecretsAPI 也加 codec 参数(插件加密能力来自它的内核实例)。

### 顺带

  · InitConfigCodec* / ClearConfigCodec 删除,改 NewConfigCodec 返回实例
  · homed-key-rotate 改为 构造 + reg.SetCodec(不再装全局)
  · bootstrap_order_test 的锚点从 InitConfigCodecWithOldKeys 改为
    NewConfigCodec(守的不变量不变,只是生效点写法变了)

## 判据(均经变异验证)

  TestNoPackageLevelSingletons          禁表补入 globalCodec
  TestStoreWritesPrefixedCiphertext     新密文必须带 enc:v1:(旧实现裸 base64 ⇒ 红)
  TestStoreAndConfigUseDifferentDomains 域隔离;改成同域 ⇒ 红(实测确认)
  TestLegacyCiphertextStillReadableAndMigrates  旧格式可读 + 落盘已升级
  TestRotationKeepsOldCiphertextReadable        轮换后旧条目仍可回填

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + security/config 测试全绿

## 数据风险

加密实现切换有丢数据风险,因此:
  · 先写往返判据,再改实现
  · 惰性迁移(读到才升级),不做启动全量重写
  · 旧格式与旧密钥环**都保留**读取路径
2026-11-04 12:22:08 +08:00
47afd67ca1 refactor(security): 删除三份包级单例,共享数据统一挂 kstate
## 用户裁定

> secutiry 的实现也是错误的,所有共享数据统一挂载一个 kernel 提供的
> 挂载点上暴露。

> store也应该调用security的加密能力!

## 缺陷

security 包原有**三份**独立包级单例,各自被 InitXxx/SetXxx 装入、
各自有访问器,三份都绕开了内核统一挂载点 kstate.State:

    global      *Store    变量库(凭据别名)    Global()
    runtime     *Runtime  安全防火墙            RuntimeGlobal()
    globalCodec *Codec    配置落盘加密          ConfigCodec()

## 为什么单例是错的(不是"多一个全局变量")

本 agent 的 a.security 与 security.Global() 是**两个独立变量**。
正常装配下它们恰好指向同一份数据,但"恰好"不是保证:

  · 测试替身:测试装单例、被测代码用 agent 字段 ⇒ 测的与跑的不是同一份
  · 多 Runtime:两个内核实例/热重载时单例只有一份,另一方被覆盖
  · 装配顺序:单例装晚了,先注册的 provider 就没被装饰

第三条**曾真实存在**:ProviderManager.Register 在注册时读单例套装饰器,
而 llm 模块原本不声明 FacSecurity 依赖 —— 顺序正确纯属巧合。
改动过程中判据如实报红 640 次泄漏。

## 本次做的(T3.1 + T3.3)

### 删 global *Store → 注入

变量库的唯一来源是 security 模块产出的 Runtime,经注入面分发:

    AgentConfig.Security        → a.security.Store() → a.secretStore()
    ProviderManager.SetSecretStore
    plugin.Registry.SetSecretStore  → sdk.NewSecretsAPI(store)

core 的 4 处 security.Global()(output/toolcall/tooldefs/secret_tools)
改为 a.secretStore();context 与 stageHost 改为注入字段;
provider 装饰器改为读注入的 Store。

### 删 runtime *Runtime → 注入

    plugin.Registry.SetSecurityRuntime → sdk.NewSecurityAPI(name, rt)
                                       → sdk.newEventsAPI(name, bus, rt)

Prepare 的幂等缓存改为 kernelmod/security 私有变量 ——
装配器私有,不再是任何包都能取的公开访问器。

### 顺带删死代码

security.FillToolArgs / FillPayload 生产调用者为 0(StageHost 已自己
用注入的 Store 做同一件事),连同只测它们的判据一起删除。

### llm 模块补 FacSecurity 依赖

这是**真实存在**的依赖:注册 provider 时要套出向装饰器,而装饰器需要
变量库。此前靠"单例先装了"掩盖了顺序错误。

## 判据(均经变异验证)

  TestNoPackageLevelSingletons          源码扫描;加回 var global ⇒ 红(实测)
  TestMustStoreDoesNotRegisterGlobally  两次 MustStore 必须得到不同实例
  TestStoreEncryptsWithOwnKey           落盘无明文 + 重载可解

## 验证

  go build ./...              PASS
  go vet ./...                PASS
  gofmt -l                    干净
  go test ./... -count=1      63 包全过,0 FAIL
  干净检出(git worktree)     build + 3 包测试全绿

## 未做(见 kernelplan.md T3.2)

  globalCodec 收敛 + Store 复用 Codec
  还牵涉 config 的 6 处调用点与**存量密文惰性迁移**,单独一步做。
  TestNoPackageLevelSingletons 的禁表暂不含 globalCodec ——
  半成品状态长期红的判据会被人习惯性忽略,那时它就等于不存在。
2026-11-04 11:57:48 +08:00
cc0eb51286 refactor(kernel): 统一挂载点 kstate + 插件声明式系统提示词段
## 起因(生产故障)

A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:

    terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
    → read /dev/ptmx: input/output error
    → 再开 term_7、term_8、term_9 …

链路拆开四层:
  ① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
  ② 需要 cmd-tools skill 说明"先 plugin_start cmd"
  ③ 但 skill 索引没进那个 agent 的提示词
  ④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线

④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。

## 环的精确形状(实测,非推测)

kernelboot 因为持有 KernelState 而依赖 19 个业务包:

    kernelboot ──▶ agent/core ──▶ …
               ──▶ plugin
               ──▶ security
               ──▶ memory / knowledge / text / coldcontext / …

而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。

后果是四个底层包各自造通道:

    core      → Agent 字段(每种 agent 构造点都要记得传)
    plugin    → 12 个 SetXxx
    sdk       → SDKConfig 传参
    security  → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()

四条通道是同一个根因的四个表现。

## 本次做的事

### 1. 抽出 internal/kstate(断环)

kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:

    internal/kstate     纯数据 + 唯一挂载点(叶子,谁都能 import)
    internal/kernelboot 装配引擎,零业务包

挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。

验证:core 现在能 import kstate(此前 import cycle not allowed)。

### 2. StageHost / Agent 字段存 any

它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:

    > 是其他包在kstate挂共享内容啊

存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。

★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。

### 3. 插件声明式系统提示词段

SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:

    kstate.State.PromptSections *promptsec.Registry   ← 唯一事实来源
    Agent.renderSystemPrompt() 每轮重建时读它

skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":

    DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}

并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。

### 4. 内核自身的提示词段也走同一管理器

按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。

顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。

## 判据(均经变异验证)

  TestCoreCanImportKstate            真编译探针;环重现则红(实测)
  TestKstateDoesNotDependOnCore      静态版,指向具体字段
  TestKstateIsLeaf                   kstate 不得反依赖上层
  TestRTChildSeesPromptSections      子 agent 能看到插件段
  TestResidentSeesPromptSections     生产实际中招的那一类;变异后红(实测)
  TestRootAgentSeesPromptSections    确认没改坏根路径
  TestNoPromptSectionsMount          未装配时不 panic、不凭空出现段
  TestDeclareTwiceRendersOnce        ★ 回应"会不会多份":声明 5 次渲染 1 份
  TestSectionRemovalTakesEffect      Stop 后段消失
  TestRuntimeUpdateVisibleToExistingAgent  同一实例下一轮看到新内容
  TestSectionOrderIsRespected        Order 生效且稳定

## 验证

  go build ./...                   PASS
  go vet ./...                     PASS
  gofmt -l internal/ cmd/ pkg/     干净
  go test ./... -count=1           63 包全过,0 FAIL
  干净检出(git worktree)           build + 4 包测试全绿

## 未做(见 kernelplan.md)

  T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
  T5 plugin Setter 收敛
  T6 防腐判据(部分已随本次落地)
2026-11-04 11:23:05 +08:00
387ae945df fix(agent): 子 agent 继承技能索引 —— 修 agent 把终端当命令执行器用
## 缺陷(生产实测)

`SetSkillIndexProvider` 只在 cmd/homed/bootstrap.go 里对**根 agent** 调了一次。
rtchild / 驻留子各自 `New(AgentConfig{...})` 构造载体,都没传这个字段 ⇒
子的 system prompt 里**完全没有 skill 索引**。

症状(日志实录):A2A 会话由「临时分诊助手」(驻留子)接手,它手上没有
`cmd_run`(cmd 插件 `auto_start=false`),而 `cmd-tools` skill 正文里写着
「先 plugin_start cmd」。它看不到这条 skill,于是把 `terminal_create` 当成
命令执行器:每条 `cd … && echo … && grep …` 开一个新终端,终端随命令结束退出,
`terminal_read` 立刻撞 `read /dev/ptmx: input/output error`,然后为下一条命令
再开一个 —— 终端列表被填满,每一条都死在半路。

与本仓 Security 那次同型:父有的能力,子没继承。

## 修法

rtchild 与驻留子的 AgentConfig 各补一行 `SkillIndexProvider: a.skillIndex`,
与 `SystemPrompt` / `StageHost` / `Security` 同一组「必须继承父」的字段。

## 顺带两件事

### 1. 内置 skill 的更新永远到不了已部署机器

原落盘规则是「目标目录已存在就跳过」,本意是保住用户改动。但它把两件事混成一件:

    用户改过的版本     ⇒ 应该保留
    内核自己写过的旧版 ⇒ 应该随内核更新

两者都表现为「目录已存在」,于是 b850fea 把 `memory_recall` 改成
`stablemem_recall` 后,生产机的 kernel-memory 一直停在旧名,模型照着
旧名调工具必然失败,而日志里一个字都没有。

改为用 `.builtin-sha256` 摘要区分:
  当前内容 == stamp  ⇒ 没人动过 ⇒ 可安全覆盖成新版
  当前内容 != stamp  ⇒ 用户改过 ⇒ 保留
  没有 stamp(老目录)⇒ 保守视为用户版本,不覆盖

### 2. 新增 kernel-terminal skill

工具描述只说「适用于交互式程序」,没说何时**不该**用;`command` 参数又鼓励
传整条命令。补一条内置 skill 讲清 cmd_run 与 terminal_create 的边界、
以及终端的 input/key/notify 用法。描述控制在 80 字符内(进索引不截断)。

## 判据(均经变异验证)

  TestRTChild_InheritsSkillIndex        删掉接线 ⇒ 红(实测确认)
  TestResident_InheritsSkillIndex       同上(生产实际中招的那一类)
  TestRootAgent_SkillIndexStillInjected 确认没改坏根 agent 路径
  TestChildWithoutSkillIndexIsNotBroken 父无索引时子不 panic
  TestMaterializeBuiltinSkills_*        5 条:覆盖语义四条成组断言
                                        (改回旧行为 ⇒ OverwritesUnmodified 红,实测确认)

判据测的是**生成的 prompt**,不是 `skillIndex != nil`:
后者接上了但注入那段被删照样绿。

## 验证

  go build ./...                       PASS
  go vet ./...                         PASS
  gofmt -l                             干净
  go test ./... -count=1               62 包全过,0 FAIL
  干净检出(git worktree)单独验证      PASS
2026-11-04 10:17:45 +08:00
ba8a33dad8 fix(agentcli): 终端默认不推送 + input/key 可同传 + 失效终端结构化答复
## 一、默认不主动推送(用户裁定)

	「agentcli 没必要进行直接推送,因为本质它就类似人类开启的终端窗口」

终端窗口的语义是:人敲命令 → 自己去看输出。输出不该主动跳到眼前。
主动注入虽然走 inputCh(排队、不抢占),但**每次都会唤醒 agent 跑一轮
推理**;`tail -f` / `npm install` 这类长输出命令会让它被反复唤醒。

改动:
- terminal_create 默认 watch 从 `{onExit:true}` 改为 `{onExit:false, quiet:true}`
- 结束/退出/超时清理一律静默,输出只由 terminal_read 主动读
- 需要推送必须**显式**声明 notify=exit / interval=Ns / buffer=N
- terminal_watch 的 clear 语义改为「回到默认(不推送)」,
  不再自动恢复 exit 提醒
- 删掉「没配 interval/buffer/quiet 就自动 onExit」的兜底(它会让默认
  静默被悄悄推翻)

## 二、input 与 key 可以同时提交(真 bug)

旧实现是互斥结构:

	if key != "" { ... } else if input != "" { ... }

于是 `{"input":"ls","key":"enter"}` 只发出 `\r`,**input 被静默丢弃**。
模型收到 status=ok / bytes=1 以为成功,去读输出却是空的,于是反复重试
——这就是「终端没反应」的直接成因。

现在按声明顺序发送:先 input,再 key。整对写入在同一把会话锁
(新增 TerminalSession.writeMu)内完成,避免同终端并发写插进中间把命令拆坏。

## 三、失效终端给结构化答复

「终端不存在或已关闭」从一句文本改为:

	{"error":"terminal_gone","terminal":"...","next_action":"调用 terminal_create 重建..."}

原因与 browser 插件同形态:只给普通失败时,模型会拿同一个 id 反复重试。
覆盖 terminal_write/read/resize/close/watch 五处。

## 四、通道注册不再吞错

	- _ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
	+ if err := s.RegisterInputChannel(...); err != nil { return fmt.Errorf(...) }

注册失败时该通道根本不存在,之后所有 InjectText 都投进虚空 ——
通知静默消失且日志无痕。宁可启动失败也不静默降级。

## 判据(均已变异验证可红)

- TestWriteSendsInputThenKey        断言 input 与 \r 两段都真的写进终端
- TestWriteInputOnlyUnchanged       只传 input 时不额外发键
- TestTerminalCreateDefaultsToNoPush 默认 watch 必须不推送
- TestExplicitExitNotifyStillWorks  显式 notify=exit 仍然生效
- TestWatchClearRestoresNoPush      clear 回到默认而非 exit
- TestTerminalGoneIsStructured      五个工具都返回 terminal_gone + next_action

变异验证:
- 默认改回 onExit:true        → TestTerminalCreateDefaultsToNoPush 红
- input 分支不发送            → TestWriteSendsInputThenKey 红(实际 1 次: ["\r"])
- 错误码改成其它文本          → TestTerminalGoneIsStructured 红

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	gofmt -l internal/plugins/agentcli/ → 干净
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
2026-11-04 09:51:22 +08:00
2b2707cb89 test(agent): 让 rt 跨进程 e2e 真正跑起来 + 策略撤销改为行为判据
## 一、e2e 一直 SKIP,等于从未验证

`TestExternalRTChild_*` 4 条判据长期 SKIP(缺插件产物)。本次用
`third_party/hologramagentsdk/example/rtvoice/build/plugin.bin`
实跑,立刻暴露两处**测试与插件不同步**:

  rtvoice_inject  插件已改名为 <name>_say     ⇒ 注入失败
  rtvoice_dump    插件从来没有这个工具        ⇒ 观测失败

改名发生在插件侧,而判据长期不运行,所以没人发现。这正是
"SKIP 判据等于没有判据"的实证。

修法:
  · 注入改用 `rtvoice-e2e_say`(插件当前唯一调试入口,走与麦克风
    完全相同的 p.Push → InjectInterruptTextOpts 路径)
  · 输出观测改为对 outputch 设备做一次**跨进程 RPC**(channelDevice
    句柄 → 插件进程 → 拿回执),不依赖插件内部私有状态

## 二、策略撤销判据从源码扫描改为行为断言

`TestChildTeardownRevokesPolicy` 原先扫源码文本,找停止函数体里有没有
`revokeChildPolicy`。R2/R3 把撤销移进骨架 Cleanup 后文本匹配失效 ——
但行为是对的,判据自己变红。

这暴露出它抓不住的真实风险:`StopDeps.RevokePolicy` 是**注入**的,
漏填时 Cleanup 里的 nil 检查会静默跳过,而字符串仍在别处,源码扫描照样绿。

现在分两层:
  · kernelmod/all  保留源码断言,但钉**两个环节成对**:
                   ① Cleanup 真的调用 deps.RevokePolicy
                   ② 各类型真的注入它
                   并增加"单数与复数必须共用骨架 Stop"的结构护栏
  · agent/core     新增行为判据 childpolicy_test.go:
                   声明策略 → 走真实停止路径 → 主体表里必须消失

## 三、验证

    go build ./...                                  PASS
    go vet ./...                                    PASS
    gofmt -l                                        干净
    go test ./... -count=1        62 包全过,0 FAIL
    go test ./... -count=1 -race  0 FAIL

    rt 跨进程 e2e(4 条,不再 SKIP):
      TestExternalRTChild_FullChain                    PASS(rtchild 跑了 1 轮)
      TestExternalRTChild_ReuseDoesNotLeakTranscript   PASS
      TestExternalRTChild_BulkStopUnregistersInbound   PASS
      TestExternalRTChild_RepeatedCyclesDoNotAccumulate PASS

    变异验证:断开 routeInputByOwner 的 rt 分支 ⇒
      FullChain 立即红,并准确报出"输入没有到达 rtSlot"

    干净检出(git worktree)单独 build + vet + test + e2e  PASS
2026-11-04 08:56:47 +08:00
637a3a3d3f refactor(agent): R4 工作式子语义落地 + 清除 R1–R3 遗留的死代码
## 1. 工作式子的工具禁令收成单一事实来源

禁令此前写了两遍:定义侧(把工具从 childTools 滤掉)与执行侧
(模型硬调时拒绝)。两处名单必须逐字一致,任何一边落后就是真实缺口:

  定义侧漏滤 ⇒ 模型看得见但不该能用(白耗 token,还会反复试)
  执行侧漏拒 ⇒ 模型绕过定义直接调用**就能生效**

现在只有 workerForbidden 一张表,每条禁令把"匹配方式 + 类别 + 解释"
绑在一个结构体里;定义侧 workerToolAllowed、执行侧 workerToolReject
都从它派生。

上一版我用 reason 字符串 + 下标猜分类 + hint 查表做了三处间接,
属于新造的技术债,已重写为单表直查。

判据(均经变异验证):
  TestWorkerToolDenyListIsSingleSource  长出第二份名单 ⇒ 红(实测确认)
  TestWorkerToolAllowedMatchesReject    两侧判据漂移 ⇒ 红
  TestWorkerToolRejectExplainsWhy       拒绝文案不可执行 ⇒ 红

## 2. 删除零使用的抽象(不留技术债)

R1–R3 引入了 ChildKind / Host / Deliverer / DeviceResolver / ToolSpec /
ToolResult / DeclaredKinds / KindWorker / State / SetState / BumpRound /
ChannelDisposition,实现过程中发现它们是假抽象:

  · 各类型要用载体 Agent 的大半能力,要满足接口就得在实现里类型断言回 *Agent
  · 接口比被抽象的东西还大 ⇒ 只是把复杂度换个地方放
  · 实测:26 个导出符号里 20 个零使用、零实现

已删除 host.go 与 kind.go,并把"四种类型清单"和"转投驻留子为何不是类型"
这两个有价值的结论移进 kinds.go 的文档注释(知识保留,死代码删除)。

child 包现在只剩实际被用的 9 个符号:Kind / Child / Entry / Registry /
StopDeps / ErrChildExists / ErrChildNotFound(+ 2 个 Kind 常量)。

## 3. 修掉误导性签名

Entry.Dispose(policy ResidualPolicy) 里 policy **两个实现都不读** ——
残余处置实际走 ApplyResidual / FinishResidentWith,在 Dispose 之前就
已经完成。看签名会以为这里处理残余,是误导。已去掉该参数,
并删掉 child 包里重复定义的 ResidualPolicy(core 包已有一份,且多一个 stash)。

## 验证

  go build ./...                                  PASS
  go vet ./...                                    PASS
  gofmt -l                                        干净
  go test ./internal/agent/core/ -count=1         ok 15.300s
  go test ./internal/agent/core/ -count=1 -race   ok 18.040s
  干净检出(git worktree)单独 build + vet + test  PASS
2026-11-04 08:35:23 +08:00
a02feaaff7 refactor(agent): 子 agent 类型抽象 + rt 输出异步 + 工具钩子统一
三件事,都是同一个根因的症状:子 agent 的类型逻辑复制成多份,
于是标准路径上的能力没跟着传过来。

## 1. 抽出 child 包(类型抽象 + 单一登记表)

新增 internal/agent/core/child/:
  child.go      Kind / Child(嵌入用)/ State / 残余与通道处置
  registry.go   单一 Registry + Entry 接口 + Cleanup 骨架
  kind.go       ChildKind —— 按两条主轴(上下文 / 工具与输出异步)
  kinds.go      四种子 agent 的清单与迁移状态

此前 Agent 上有两张互不知情的表(residents / rtChildren),各带一把锁。
实测新增一种子 agent 要改 18 个生产文件,并已因复制而漂移过一次
(StopRTChildren 漏注销入站登记,见 engineering-lessons.md §2.3)。

现在单数与复数停止**共用** Cleanup,那个形态在结构上不可能复现。

## 2. rt 的输出改为异步(用户的原始诉求)

rtEmit 曾直接 dev.Execute(...),插件侧延迟(TTS/网络编码/字幕渲染)
直接节流模型的 token 消费 —— 「不攒整段」的收益全被吃掉。

新增 outputpump.go(输出通道的异步壳):
  · Push 立即返回,交付由独立消费 goroutine 完成
  · 有界队列(64),满则**丢最旧**(符合 rt 的"最新者胜")
  · 丢弃与设备缺失都留痕,否则"用户听不到"没有任何线索
  · Close 排空后退出,不吞最后半句
  · 设备每片重新解析(插件崩溃会 Unregister,缓存指针会指向死设备)

## 3. 工具钩子统一(本轮最大发现)

实测:StageBeforeToolcall / StageAfterToolcall 全仓只在
task.go:1022/1079/1194,三处都在 runOneTool / stepToolAfter 里;
而 executeToolCallOutcome 内部**没有任何 runStage**。

⇒ rtchild 与工作式子两条路径**完全跳过插件钩子**:不能审计、
不能拒绝、不能改参、不能脱敏。README 承诺的 before_toolcall
在这两条路径上不成立。

新增 toolbatch.go(唯一批执行实现:批并发 + 双钩子 + 顺序落结果),
两种类型切过去。工作式子的"禁输出工具"与"保持串行"用 batchOpts 注入,
不各写一遍执行逻辑。

## 判据(均经变异验证)

  TestRTChild_OutputIsAsync              改回同步 ⇒ 红(实测确认)
  TestOutputPump_DropsOldestOnFull       改阻塞/无界 ⇒ 红
  TestOutputPump_CloseDrains             丢弃队列 ⇒ 红
  TestOutputPump_ResolvesDeviceEachTime  缓存指针 ⇒ 红
  TestRTChild_ToolsGoThroughPluginStages 绕开钩子 ⇒ 红
  TestRTChild_StageCanDenyTool           同上
  TestWorkerToolsGoThroughPluginStages   同上(变异实测确认)
  TestWorkerToolsStaySerial              改成并发 ⇒ 红
  TestNoChildTypeTablesInCore            源码扫描,禁类型专属表回潮
  TestRTChild_HasGraphMemory 等 5 条     rt 接入图记忆

## 顺带修复

  · rt 接入图记忆(参照驻留子的轻量内核形态):此前 graphMem() == nil,
    memory_recall / memory_commit 在 rt 里全部不可达,每通电话从零开始。
    默认只读(通话高频写入会灌满图谱噪声),4 个失败点合并成一个 rollback
    闭包(逐点挂必然漏一处 ⇒ 泄 SQLite 句柄)。
  · rt 的 PreferredProvider 走候选链(此前裸调 a.provider,优先源无效且零回退)。

## 保留的语义(逐项确认仍在)

  驻留子三层压缩:checkContextFull / handleChildContextFull / CompressResident
  三种通道处置:ChannelRelease / Suspend / Takeover
  残余 keep/drop + harvestResidentMemory
  child.Start() 的 7 个 goroutine

验证:build / vet / gofmt / go test -race 全绿;
且在干净检出(git worktree)上单独 build + test 通过。
2026-11-04 08:21:15 +08:00
24b03e56e9 fix(memory): L1 恢复为 provider 原生消息序列
## 设计口径(用户裁定,2026-11-03)

	「我的设计是 l1 渲染出来就是传统式的上下文排列,
	  难怪指代消解能力这么差」

## 之前错在哪

buildMessages 把**全部历史事件拼成一条 system 消息**:

	system: 系统提示词
	system: [Conversation timeline]
	        [15:04:05] 用户: 帮我找月租<400的房子
	        [15:04:05] 助手: ...
	        [15:04:05] 工具(qq_get_message): ...
	user:   当前输入

说话人身份靠**文本前缀**表达。模型对它拿到的是"背景资料"式的注意力
结构,而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上
上一句"帮我找月租<400…")。

★ 0f98f36 那次只改了**文本渲染格式**(把日志样式换成对话样式),
  底层"一条 system 承载全部历史"的结构一动没动,所以问题还在。

## 现在的形态

	system     系统提示词
	user       帮我找一套月租<400 水电常价带独卫的单间
	assistant  好的,我去找。
	user       新乡
	assistant  新乡这边有几套。
	user       当前输入

工具轮按协议展开:

	user       帮我看看天气
	assistant  (content + tool_calls=[c1,c2])
	tool       (tool_call_id=c1) 晴 26 度
	tool       (tool_call_id=c2) 他说新乡
	user       继续

实现要点:
- 新增 renderL1Messages:一个 ContextEvent 展开为
  user → assistant(calls) → tool... 消息;
- tool_call 与 tool 按 CallID 严格配对(CallID 优先,缺失退化到工具名);
- InactivePages 剔除**完整的工具页**:assistant 上对应的 tool_call 与
  紧随的 tool 消息一起消失,绝不留下"有 call 无 result"(上游必 400);
- 超长工具结果仍渲染为句柄(toolresult_read 取回);
- 保留 timelineUserText 的中断轮处理:从聊天工具回执里取真实原话,
  不让注入样板冒充用户发言;
- 保留 token 预算裁剪,事件级按消息粒度计入。

## 清理(无生产调用方的旧渲染路径)

删除:
- formatMergedTimeline(旧 system-timeline 唯一生产者)
- renderedToolResultsOf(旧截断风格渲染,已被原生序列取代)
- timelineToolLines(仅上面两者使用)
- timelineUserLabel / timelineAssistantLabel
- toolResultTimelineChars / toolResultTimelineCharsChat /
  toolResultTimelineMax / isChatCorpusTool
删除对应已失效判据:timeline_contract_test.go、timeline_toolresult_test.go

保留 toolResultPageIndex:overflow.go 的 eventTokens 仍在用,
且统计侧与渲染侧必须共用同一份配对规则(否则"渲染少了、统计没少"
会让裁剪永远裁不动,2026-11-02 实测事故)。

## 判据(新增,均已变异验证)

l1_native_sequence_test.go:
- TestL1RendersNativeMessageSequence
    断言 role 序列 system/user/assistant/user/assistant/user,
    且 system 消息中不得再出现 "[Conversation timeline]" 或 "用户:"
- TestL1ExpandsToolCallsAsPairedMessages
    断言 assistant.ToolCalls[i].ID 与紧随 tool.ToolCallID 一一配对
- TestL1InactivePageDropsWholePair
    剔除页时 call 与 result 必须同时消失

变异验证:把实现退回旧 system-timeline ⇒ 三条全红,恢复后全绿。

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	gofmt -l internal/             → 干净
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
2026-11-03 14:37:53 +08:00
81a0f5f598 feat(security): 插件按工具声明内容策略 + 目录名统一
## 一、插件声明层(用户裁定)

> 「敏感词策略也应该是插件声明啊,每个插件声明自己轮次以及自己工具的
>   敏感词策略」

SecurityProfile 新增两个字段:

  ToolSensitiveWords map[string][]string  // key=工具名模式(尾 *),value=词表
  DetectInjection    bool                 // 开启内核内置注入迹象识别

语义是**追加不覆盖**:整轮 SensitiveWords 仍然生效。
取宽不取窄的理由:当前处置是 B 档(标记+警示,不阻断),
多标一条只是多一句警示,漏标则是安全缺口。

## 二、修掉我自己造的「莲藕式分层」

前一版把 *security.Round 从 runInputTask 一路搬到工具链
(prepareInputTask(evt, round) → TaskFrame.SecRound →
executeToolCallOutcomeWithRound),并在 rtchild 里另补一条。

那是把**分层策略实现成跨层传参**:每多一条工具执行路径就要求调用方
多传一次句柄。profileActivator 的设计注释早就写明了正路:

> 工具调用发生在 LLM 决定之后,此时插件已不在调用栈上。
> 唯一可靠的传递方式是**内核在输入进来时记住**,在该轮的工具判定里使用。

改为 security 自取:

  Runtime.ScanContentForAgent(agentID, isChild, ins, text)
    → ForAgent(agentID, isChild)   // per-agent 视图,天然不串档
    → EffectiveProfile()           // session > longtime
    → 合并 全局词表 + 档位词表 + 工具词表 + DetectInjection

core 只递交**事实**(我是谁 / 哪个工具 / 什么文本),不认识 Round。
与 CheckToolForAgent 同一套身份推断,不引入第二个入口语义。
TaskFrame.SecRound / executeToolCallOutcomeWithRound 全部撤除。

## 三、目录名统一

third_party/homeagent-sdk → third_party/hologramagentsdk

module 与远端仓早已是 hologramagentsdk,只剩物理目录是遗留名。
同步改了 go.mod replace、构建/打包/部署脚本、5 处测试里的分段路径。
注意:这里改的是**主仓 vendored 的 SDK 源码路径**,example/ 由 SDK
独立仓自己跟踪,主仓不涉及。

## 判据

TestToolSensitiveWordsAppendOnlyForThatTool   词表只对目标工具生效 + 追加语义
TestToolSensitiveWordsUseAgentScope           security 按 agent scope 自取生效
(变异验证:删掉 ForAgent 查询 → 红)

## 验证

	go build ./...                 → 0
	go vet ./...                   → 0
	go test ./... -count=1 -race   → 62 ok / 0 FAIL / 0 DATA RACE
	gofmt -l internal/             → 干净
2026-11-03 09:51:11 +08:00
9ab19f7493 test(security): cover per-tool sensitive word append semantics 2026-11-03 09:20:21 +08:00
ef9368e5b8 feat(security): add plugin-declared per-tool content policy 2026-11-03 09:20:04 +08:00
43eaa17b41 fix(agent): 补齐 registerToolCancel 的定义——修复 HEAD 不可编译
## 我造成的破坏

上一个提交 e6023e1 里,我用 `git add internal/agent/core/toolcall.go`
(整文件)时,把工作区中**别人尚未提交**的两行调用一起暂存了:

	toolCancelID := a.registerToolCancel(cancel)
	defer a.unregisterToolCancel(toolCancelID)

而这两个方法与它们依赖的字段(cancelTools/toolSeq/toolMu)留在了
agent.go / eventloop.go 里没被提交。

⇒ **e6023e1 单独 checkout 编译不过**:

	internal/agent/core/toolcall.go:234:20:
	  a.registerToolCancel undefined (type *Agent has no field or method registerToolCancel)

已用 git worktree 在 e6023e1 上实测确认,不是推测。

## 为什么这违反了模块化设计

按本仓的模块化设计,`eventloop.go` 里的 registerToolCancel 与 `agent.go`
的字段是**一个内聚单元**,必须同批提交。把它们与 toolcall.go 的调用拆散,
就等于交付了一个引用不存在的符号 —— 构建的可 bisect 性被破坏。

★ 根因是我的提交操作,不是设计问题:我用整文件 add 代替了只暂存自己的
hunk。当时确实检查过 toolcall.go「只有我的改动」,但只 grep 了
`cancelRunningTools|cancelTool`,漏掉了 `registerToolCancel` ——
**检查用了错误的模式串,等于没检查**。

## 本次提交

把那个内聚单元的完整改动补齐(这是「停止键能取消正在跑的工具」特性):

- `agent.go`:cancelTools / toolSeq / toolMu 字段
- `eventloop.go`:registerToolCancel / unregisterToolCancel / cancelRunningTools
  以及 stop 分支的调用点
- `stop_cancels_tool_test.go`:该特性的判据

特性说明见字段与方法上的注释(原始作者撰写)。

## 验证

	go build ./...                 → 0
	go test -run TestStopCancelsRunningTool → PASS
	go test ./... -count=1 -race   → 见下一条
2026-11-03 08:46:46 +08:00
b2079fea0a docs(security): 内容审查接线已结案,同步两张状态表
- kernel-secret-firewall-design.md 接线状态表:ScanContent 一行
  从 🔴「配置里的敏感词一条不生效」改为 ✅ 2026-11-02 接通,
  并写明两处落点与 B 档语义(标记+警示,不阻断)。
- future-plan.md §一 汇总表第 4 行同步为 ✅。
- §1.2 改写为「已结案」,保留两条教训:
  ① 第一版让 agent 自己解析档位 —— 正是 §1.1 收敛掉的第 2 层毛病,
     盲试两轮才回去读设计文档;设计里早就写清了落点。
  ② 最初 6 条判据只测辅助函数,删掉 task.go 的调用仍全绿 ——
     与「实现完整、判据绿、生产零调用」的原始缺陷形态一模一样。
     补了走真实入口 runInputTask 的那条才真的变红。

仍零生产调用方的:路径沙箱 CheckPath、网络闸 CheckNetwork(见 §1.3)。
2026-11-03 08:43:51 +08:00
e6023e1295 feat(security): 接通内容审查——敏感词/注入预警标记并警示
## 缺陷

`security.Runtime.ScanContent`(254 行已实现)**全仓零生产调用方**:

- 配置写的敏感词一条不生效
- 插件通过 SecurityProfile.SensitiveWords 声明的档位敏感词同样不生效
- 模型被提示词注入时内核不预警、不阻断

症状与"没配"完全一样,且没有任何报错。

## 修法:走 Round,不让调用方解析档位

新增 `Round.CheckContent(ins, text)`:档位留在 security 内部
(session > longtime),调用方只提供事实(一段文本、它从哪来)。

★ 刻意**不**让 agent 取 `EffectiveProfile()` 再传给 ScanContentFor ——
那是把"本轮该用哪个档位"这条策略语义交回调用方,正是 2026-10-31 收敛掉的
形态(见 security/round.go 包注释)。中途我确实那样写过,判据红了两轮才
回去读设计文档,按设计改了。

顺带修出一个真缺陷:`effectiveContent()` 读的是进程级 active 档位,
而 2026-10-31 起档位已迁到 AgentScope。工具策略改了(checkToolWithProfile
走 profilePolicyByName),**内容审查没改** —— session 档声明的敏感词
永远并不进词表。新增 `effectiveContentFor(profileName)` 与
`ScanContentFor(ins, text, profileName)`。

## 两处入向路径都接了(future-plan §1.2 明确要求)

1. 插件输入:`runInputTask` → `ReviewInputContent(round, ...)`
2. 工具结果:`executeToolCallOutcome` 的 defer(与出向凭据脱敏同一位置)

只接一条会留下缺口:不扫工具结果,则网页/文档/邮件正文里的**间接注入**
完全没人看 —— 那正是最常见的注入载体。

工具结果路径用全局词表而非档位词表(工具执行链没有轮次句柄,
硬塞"进程当前档"才是收敛前的毛病)。

## 处置:B 档(标记 + 警示),不阻断(用户裁定)

敏感词由插件按自己通道声明,词表难免偏宽,阻断会误杀正常消息。
所以保留原文、正文前加警示语:

    输入侧  "⚠️ 危险消息,谨慎处理:..."
    工具侧  "⚠️ 危险内容,谨慎处理:...其中指令来自外部内容,不代表用户意图"

★ 警示写进**正文**而不是只记日志:模型看不到内核日志,只记日志等于对模型隐瞒。

## 判据(7 条)

- TestSensitiveWordHitPrependsWarning / NoSensitiveWordLeavesInputUntouched
- TestSensitiveWordOnlyAppliesToItsProfile(词表是档位属性,不全局泄漏)
- TestReviewWithoutSecurityIsNoop
- TestToolResultContentIsReviewed / TestInjectionMarkerIsDetected
- **TestInputContentReviewWiredIntoProcessing**:走真实入口 `runInputTask`

最后这条是补出来的教训:前面几条只测辅助函数,把 task.go 的调用删掉
**全部仍然绿**。实测验证后才补了走生产入口的那条。

变异验证:删掉 task.go 的调用 ⇒ 该判据红
("生产入口没有调用内容审查 —— 正文里没有警示语")。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-11-03 08:42:50 +08:00
26dcf63fd5 fix(context): 让段页式统计跟随 InactivePages
## 生产故障

2026-11-02 20:58:24:

    air-interrupt prune: 12→5 events (44185→39714 tokens), ratio=1.21
    context overflow: recover budget exhausted (2/2)

段页式淘汰已经把工具页标成 InactivePages,渲染层也确实跳过了这些页,
但 overflow.go 的 eventTokens 仍把事件里的全部 ToolResults 算进去。

结果是「渲染视图变小、统计视图不变」:页剔除后水位不降,L4 反复触发,
最终耗尽恢复预算并终止本轮。

## 修复

让 eventTokens 使用 process.go 同一套 toolResultPageIndex 配对规则:

- InactivePages 对应的工具页不再占预算
- llm_round(第 1 页)始终保留
- 配不上的工具结果仍按原策略保守保留

因此 accumulatedTokens、overflowRatioNow、evictPagesLocked 三条调用链
都自动使用与实际渲染一致的 token 口径。

## 判据

- TestEventTokens_ExcludesInactivePages
- TestEventTokens_LLMRoundNeverExcluded
- TestOverflowRatio_DropsAfterPageEviction

变异验证:去掉 InactivePages 判断 ⇒ 前两类核心判据变红。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-11-02 21:54:35 +08:00
9fcde9862e merge: 修 rtchild 偶发红 2026-10-31 19:16:59 +08:00
07e4958e44 test(core): 修偶发红的 rtchild 用例,登记另一处未修的
## 背景

CI 的 `-race` 门两次变红(提交 `merge: pr31` 与 `style(webui)`),
都是 `TestRTChild_InterruptTruncatesOutput`:

    --- FAIL: TestRTChild_InterruptTruncatesOutput (5.00s)
        rtchild_r3_test.go:271: 等待超时:开始输出

本地 `-race` 单测 6/6、整包连跑 4 轮全过,复现不出来。

## 查到的真相

CI 日志里 `[rtchild] t started` 之后再无任何 round 行 —— **一轮都没跑到
provider**。所以不是输出丢包,是轮次根本没启动起来(CI 的 2 核 runner +
race 插桩下启动更慢)。

原写法把两件事塞进同一个固定 5 秒窗口:

    waitFor(t, "开始输出", func() bool { return len(dev.snapshot()) >= 2 })

「输出已开始」是**前置条件**,「Stop 后输出冻结」才是被测行为。混在一起后,
超时只能报"没等到输出",看不出到底是「轮次没启动」还是「输出没外发」。

## 改法(同 2026-10-24 TestStress_Nesting... 的先例:等状态,不等时间)

    waitForWithin(t, "轮次进入 ChatStream", 30s, p.callCount() >= 1)
    waitForWithin(t, "开始输出(chunk 到设备)", 30s, len(dev.snapshot()) >= 2)

两段分开等、各自 30 秒:超时能直接指认卡在哪一步。被测断言一字未改。
顺带把原来丢弃的 `PushRTInput` 返回值改成检查(同文件第 297 行本来就这么写)。

## 另一处已定位但**没修**

`TestResidualDropNotifiesSyncCaller`:本地整包 `-race` 8 轮偶发 1 次
(`应处置 1 条,实际 0`),CI 从未出现。

根因是 `waitSchedulerStopped()` 只等 `running == nil`,**不等调度循环退出** ——
cancel 之后循环仍有窗口把测试刚入队的任务取走。要修得给生产代码加
「循环已退出」信号,不是改测试能解决的。已记入 `docs/zh/future-plan.md` §5.0。

## 验证

    go vet ./internal/agent/core/                  ✓
    go test ./internal/agent/core/ -count=1        ✓
    go test -race ./internal/agent/core/ -count=1  ✓(连跑 3 轮)
    单测 -race -count=5                            ✓
2026-10-31 19:16:58 +08:00
de1a8157f9 merge: 日志默认去掉源文件位置 2026-10-31 18:49:38 +08:00
0cb20fb7f5 fix(log): 生产日志默认不再带源文件位置
## 现象

生产日志每行都带 `file.go:NNN`:

    2026/10/31 18:47:03 bootstrap.go:320: [homed] shutting down...
    2026/10/31 18:47:03 builtin_skill.go:163: [plugin] seq: removed skill ...
    2026/10/31 18:47:03 sidecar.go:419: [clawhubadapter] sidecar manager stopped

来源是 `cmd/homed/bootstrap.go` 里无条件 `log.SetFlags(... | log.Lshortfile)`。

## 判断:生产确实不需要

- 多数日志**本身带模块前缀**(`[homed]` / `[plugin]` / `[kbtree]` / `[skillmgr]`),
  去掉行号后定位能力并不缺
- `Lshortfile` 在**每一次** log 调用里做一次 `runtime.Caller`,不为零
- 它还不能真的消歧:它只打基名,26 个插件里有多个 `plugin.go`,
  `plugin.go:104` 反而指不出是哪一个 —— 真正的消歧靠的是消息里的 `[插件名]` 前缀

## 改法

`logFlags()` 抽出来,默认 `Ldate|Ltime`;`HOMED_LOG_SOURCE=1`(或 true/yes/on)
时追加 `Lshortfile`。

保留开关而不是删掉:排查插件/装配问题时行号仍有用,部署方临时开一下即可。
用法写进 `docs/zh/deploy-runbook.md` §3.0。

## 判据

- `TestLogFlagsNoSourceByDefault`:默认不得有 Lshortfile,且日期时间必须保留
- `TestLogFlagsSourceOptIn`:开关的六种正向写法生效、四种关闭写法不误开

## 验证

    go build ./...             ✓
    go vet ./cmd/homed/        ✓
    go test ./cmd/homed/       ✓(含新增两条)
2026-10-31 18:49:36 +08:00
3da59bc92f merge: 知识库改词法为主 2026-10-31 16:57:31 +08:00
0365b08dc9 fix(knowledge): 改成词法路为主,RRF 方案实测无效
## 上一版(RRF)在生产上零收益

73bc3f9 用「稠密 1.0 / 词法 0.5 的 RRF」重启部署后,生产上 7 条查询
结果基本没变,目标条目一次都没进前三。原因是我在**本地退化路径**上验证,
而生产走稠密路径——两个分支的融合逻辑根本不同。

在真实 196 条库 + 真实 chineseclip 上测出两路的名次,问题一目了然:

    查询                        稠密名次   词法名次
    PruneOldBackups                103         1
    strip_path                     164         1
    minSceneEvidence               130         1
    coldcontext_query              149         1
    上下文裁剪的保护条数受什么约束    157         1
    冷上下文取回为什么不能先删后灌    158         1

词法路全中,稠密路全在 100 名外。稠密权重 1.0 时它的 `1.0/61` 永远压过
词法的 `0.5/61`,所以词法路精确命中也没用。

## 为什么连 RRF 本身也不行

试过把权重翻成词法 1.0 / 稠密 0.5,仍然错。因为 RRF **丢掉分数差距**:
`strip_path` 的 top1 是 0.056、top2 是 0.055 —— 名次差 1,RRF 当它们等强,
稠密对 top2 的加分一叠就反超。

## 现在的做法:fuseLexPrimary

- 词法路给主序,**保留分数差距**(这是关键词)
- 稠密路用于:同分裁决、补位、以及词法无信号时接管
  (`minLexSignal = 0.02`,低于此视为 jieba 泛滥词的微弱命中,让稠密接)
- 稠密结果仍然被使用,不是死代码

## 实测(真实库 + 真实模型)

    top1 = 10/13   top3 = 11/13

改前生产表现:7/7 全错。剩下的 3 条未命中(仓颉关键字 rank2、鸿蒙、更新日志)
是既有的弱点,与本次改动无关。

## 判据

- `TestDensePathFeedsRanking`:钉「稠密结果必须喂进排序」(改了形状,意图不变)
- `TestSearchDocReflectsFusion`:钉文档写明 fuseLexPrimary + 词法为主
- 文档注释里记下两次翻转(2026-10-08 → 10-31)与 RRF 被否的实测原因,
  避免下一个人再翻回去
2026-10-31 16:57:29 +08:00
73bc3f94b7 merge: 知识库标识符检索修复 2026-10-31 16:42:38 +08:00
0ca9e96290 fix(knowledge): 修复英文标识符检索不到(分词器 + RRF 融合)
## 故障

真实库 196 条里 154 条是 cangjie 文档(1.53MB / 88.6% 字节)。用户反馈
「语义检索命中偏弱,cangjie 霸榜」。实测三条带精确标识符的查询:

    PruneOldBackups   → top5 全是 cangjie,目标条目一次没出现
    minSceneEvidence  → 同上
    strip_path        → 同上

## 真因不是 cangjie,是分词器

cangjie 只是把问题放大的体量。真正的洞在 `memory.TokenizeWords`(jieba)
对英文标识符的处理 —— 实测:

    PruneOldBackups → [P r u n e O l d B a c k p s]   ← 切成单字符还去重
    strip_path      → [s t r i p _ a h]

于是任意两篇含英文字母的文档 token 重叠率都能到 50%,词法路彻底失效。
而稠密路(chineseclip)对「只在文中出现一次的专名」本就没有分辨力。

这解释了 2026-10-08 那次决策:当时把词法路从融合里去掉,理由是
「词法路对英文标识符失效」—— 观察对,**归因错**:失效的是分词器。

## 改法

1. `identtoken.go`(新):知识库词法路专用分词器
   - 补标识符**整词**:`PruneOldBackups` → `pruneoldbackups`
   - 补**分隔符部件**:`strip_path` → `strip` / `path`(仅当下划线等存在时)
   - **不拆 camelCase**:拆了会产出 `old`/`backups`,而 `old` 在
     `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`,
     出现 31 次 —— 实测评测中该文档得分 0.54 > 目标的 0.20,正好把无关文档顶上来
   - 丢掉 jieba 切出来的单字母残渣
   - 中文分词完全沿用 jieba,行为不变
   - 只注入知识库的 veczer,**不动** memory 索引/关键词提取

2. `fusion.go`(新):RRF 倒数排名融合
   - `score = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)`
   - 不用当初那套「逐路按查询内最大值归一化」:那个做法把 0.02 的噪声抬到
     与 1.0 等权,正是 2026-10-08 淘汰融合的真凶。RRF 只看名次,与量纲无关
   - 稠密权重 1.0(语义主线)/ 词法 0.5(精确命名救助)

3. `Search` 文档注释重写:把两段历史都写清(2026-10-08 为何淘汰、2026-10-31
   为何加回但换成 RRF),避免下一个人重复踩。

## 判据

- `TestIdentifierTokenFeedsLex`:钉分词结果(必须产出整词、不得拆出 old)
- `TestIdentifierQueryBeatsVolume`:60 篇英文词密集的噪声文档 + 一条只在
  目标条目出现一次的标识符,top1 必须命中目标 —— 分词器一退化就红
- `TestSeparatorIdentifierNotBuried`:带 `_` 的标识符,整词要压过部件噪声
- `TestDensePathFeedsRanking`(原 `TestDensePathIsSoleRecall`):保留它真正的
  意图 —— 防「调用了 denseHits 却把结果丢掉」这个反模式,形状改为喂给 fuseRRF
- `TestSearchDocReflectsRRF`(原 `..DenseOnly`):钉文档与实现不漂移

## 验证

    go build ./...                        ✓
    go vet ./internal/knowledge/          ✓
    gofmt -l                              ✓
    go test ./internal/knowledge/         ✓(含 -race)
    go test ./... -count=1                全绿

⚠️ 生产实例需重启才能生效(lex 索引在启动时重建,无数据迁移)。
2026-10-31 16:42:36 +08:00
cc07adb20b style(webui): dashboard.js 格式化(换行与引号风格)
编辑器/格式化器在保存时改的,只动换行与引号,无语义变化:

  · 一行多条件的 if 拆成多行
  · 含双引号的字符串改用双引号包裹(避免转义)
  · 长字符串拼接拆分

13 insertions / 4 deletions,逐行核过无逻辑改动。
2026-10-31 16:20:47 +08:00
47cdb03918 merge: pr31 update 回滚安全 2026-10-31 15:56:11 +08:00
f9ee58a914 merge: pr31 — update 回滚安全(原子写 + 回读校验),解 19 处冲突
pr31 与 main 是**双向分叉**,不是单向落后。`ba281a8` 已把它的多数工作
在 main 上重新实现(dataDir 守卫、gc.go、dataDir 解析),但两边各自又
往前走了,所以 6 个文件 19 处冲突要逐条判。

## 合进来的(main 缺)

- **标记原子写**:main 是 `os.WriteFile` 直接覆写,掉电留半截标记;
  改为 `写 .tmp → fsync → close → rename → fsync 目录`
- **H4**:`publishResult` 里 `if errMsg != "" { _ = errMsg }` 把失败原因
  丢了,订阅方只看到 ok=false。现在 error 字段恒定存在
- **回读校验补最低可用性**:main 对无 digest 的旧 marker 只比"内容与备份
  一致",而**零字节备份内容也一致** ⇒ 判成回滚成功、不清 marker 的时机
  也错过了。补"非空 + 带可执行位",刻意**不**要求 `--version`
  (老内核没这个 flag,那正是 PR #31 的回归)
- `kernel_apply_test.go`(836 行,含 B2c `TestMarkPendingUpgrade_PreservesOldMarkerOnFailure`)

## 保留 main 的(pr31 在这几处是旧版或更差)

- **回滚校验用 digest**(`verifyRestored`)而不是 `--version`:
  pr31 要求回滚后能读版本,而 1.3.x 及以前没有 `--version`,
  而它们恰恰是最常见的回滚目标 ⇒ 从旧版升级失败后永久回滚不了
- **`restoreDepGraph` 三态**:原来有+备份有→还原、原来有+备份无→不算成功、
  原来无→删掉新装的。pr31 只处理第一种,会导致「旧 homed + 新 depgraph」
  的崩溃循环
- **marker 在还原完整后才清**:pr31 在还原前就清,还原失败即失去第二次自愈
- **CLI 不重启服务**:pr31 仍带 `restartUnit`,会杀掉整个 cgroup 连带 guard
- **`resolveDataDir`**:pr31 的 `upgradeDataDir` 是它的子集,多扫几个候选前缀

## 冲突处理中的三处重复声明

两边各加了同名函数,自动合并把它们都留下了:

- `readBinVersionStrict` —— 删 pr31 的(main 在 77 行有)
- `kernelDataDirMu/kernelDataDir/SetKernelDataDir/KernelDataDir` —— 删 kernel.go
  里那份(main 在 install.go)
- `newPkgRoot` —— pr31 的重命名 `newPkgRootFromBin`,两套测试各自可用
- rollback.go 里 `verifyRestored` 与 `readBinVersionStrict` 两段校验并存 —— 删后者

顺带修掉删除时误伤的 `ShortSum` 注释,并删掉 pr31 引入但无人调用的
`DataDirEnv`(main 明确不再拿 env 当 dataDir 来源)。

## 验证

    go build ./...                        ✓
    go vet ./...                          ✓
    go test ./... -count=1                62 ok / 0 fail
    go test -race ./internal/update/ ...  ✓
    gofmt -l                              干净
2026-10-31 15:56:10 +08:00
58dea7e6c4 merge: 介绍站内容修正 2026-10-31 11:44:04 +08:00
aa9e8c7454 docs(site): 修正介绍站三处与代码不符的描述
## 1. 「一轮里的多个 tool_call 默认并发」写反了

同一张卡的标题是「同轮工具默认串行,声明后才并发」,正文却写「默认并发」。
按 `internal/agent/core/argvalidate.go:307` 的 `batchRunnable`:批内**每一个**
工具都 `ParallelSafe` 才并发,有一个没声明就整批串行,`Serial` 优先级更高。

改:标题保留,正文按实现重写。

## 2. 「最近 10 条恒受保护」已不成立

`effectiveProtectedCount` 现在返回 `min(配置值, ContextTokens/单条平均token)`。
固定 10 条在 50k 窗口、5800 token/事件时会占 58k,比 40000 预算还大 —— 这正是
召回率从 44% 掉到 22% 的那次实测。站点两处都还在写「恒受保护」,改成「受预算约束」。

## 3. 第二层命名与描述

- 「低分下沉成文档,冷文档再蒸馏」→ 冷上下文
- ② 层正文「JSON + TF-IDF 索引的中间层」→ 实际是稠密 + TF-IDF 双路,
  且与 L1 结构同构、取回走两阶段
- FAQ「上下文事件裁剪后归档成文档」同步改

全站 `文档层|冷文档|归档成文档|Document 层` 归零。
HTML 结构校验:div / section 配对差 0,`<p>` 开闭 62/62。
2026-10-31 11:44:04 +08:00
8a6e480175 merge: 架构节改参考文档写法 2026-10-31 11:42:23 +08:00
bd9dd9e75f docs: 架构节改成参考文档写法
上一版还是论证体:四个小标题里三个是否定式断言(「上下文**不是**截断+摘要」
「三层记忆是迁移,**不是**复制」「记忆按场面分,**不按**会话」),
每段都以一句评论收尾。段落字数 62–128,几乎等长 —— 这是最明显的机器痕迹。

改成参考文档:

## 小标题换成名词

   上下文不是「截断 + 摘要」  →  上下文窗口
   三层记忆是迁移,不是复制    →  三层记忆
   记忆按场面分,不按会话      →  场面

标题说明这节讲什么,不下判断。判断该由正文的数据支撑,不该写在标题里。

## 删掉与假想敌的对比

「常见做法是把超窗的对话裁掉或让模型自己摘要,两者的共同问题是丢什么由位置决定」
「普通 agent 通常一条会话一条历史,换个话题就失忆」

换成直接说我们怎么做的。读者不需要先被告知别人怎么错。

## 段落长度拉开

之前 62–128 字(极差 66)。现在每节先给一行文件路径,正文长短不一:

    32 字  `internal/agent/core/context.go`
   127 字  这条约束来自一次实测。50k 窗口……
    51 字  N 默认 10,实际取值受预算约束:……
   129 字  场面由五个特征构成……

极差 98 字。技术文档本来就是「一行路径 + 一段解释 + 一行数字」这种不均匀节奏。

## 去掉两处破折号

「没有『取出即删除』的路径 —— 删除排在确认之后」
「不吸收整轮指纹 —— 吸收了会……」

改成句号。全文破折号归零。

## 补上实现位置

原来只讲设计没给代码位置,改成每节标文件路径:
`internal/agent/core/context.go`、`internal/memory/coldcontext/`、
`internal/memory/scene_emerge.go`、`internal/agent/core/stages.go`。

## 核对

文中数字逐条对源码:`preemptPromotionCap = 2`、`minSceneEvidence = 2`、
`FindColdContexts(72*time.Hour, 2)` 均一致。两版 9 个标题对齐,无死链。
2026-10-31 11:42:21 +08:00
e542bc1643 merge: 介绍站命名同步 2026-10-31 11:41:18 +08:00
27f25bdc4f docs(site): 介绍站第二层改名 Cold Context
与仓库内文档同步:第二层早已改名 cold context(工具名 coldcontext_query /
coldcontext_commit),站点仍在用「Document 层」,且第二层描述写的是
「JSON + TF-IDF 索引」—— 实际是稠密向量与 TF-IDF 双路检索。

改了 6 处:meta description、三层配色注释、① 层说明里的归档目标、
② 层标题与正文、三层链路串。② 的正文同时换成当前事实:
与 L1 结构同构、稠密+TF-IDF 双路、取回走「先确认再删」两阶段。
2026-10-31 11:41:18 +08:00
4df8c73555 merge: 第二层改名 Cold Context + 许可段精简 2026-10-31 11:39:15 +08:00
7c0eec1b2f docs: 第二层改名 Cold Context,清理四组已不存在的符号名
## 问题

README 与两份 ARCHITECTURE / OVERVIEW 仍在用旧称「Document 层」,
而第二层早已改名 cold context(工具名 `coldcontext_query` / `coldcontext_commit`)。

不止是层名。文档里引用的四个符号在代码里**一个都不存在**:

| 文档里写的 | 代码里实际 |
|---|---|
| `docStore.ContextToDoc` | `coldcontextStore.ContextToColdContext` |
| `FindColdDocs` | `FindColdContexts` |
| `docToTriples` | `coldDocToTriples` |
| `docStore.SetVectorizer` | (已无此调用,embedder 构造期注入) |

四处都用 `grep -rn "\b名字\b" --include=*.go` 确认过:旧名 0 处,新名各自 4 处。

## 改了什么

- `assets/docs/zh/ARCHITECTURE.md` 13 处、`en/ARCHITECTURE.md` 15 处
- `assets/docs/zh|en/OVERVIEW.md` 各 3 处
- README 两版:「文档层」→「冷上下文层」
- `assets/branding/mascot-xiaozhai-guide.md` 的丝带配色表(三色分别对应三层)

## 顺带修正层号

ARCHITECTURE 用的是 **L1 Context / L2 Cold Context / L3 Stable**,
我上一版 README 写成了 L0/L1/L2 —— 少了一层,且与架构文档对不上。已改为 L1/L2/L3。

## 顺带修掉断掉的交叉引用

`internal/agent/core/toolcall.go` 与 `recallcoldcontext_test.go` 的注释写
「ARCHITECTURE「记忆流转」② Document 层的读取分支」,指的正是刚改名的节。
不改就成了悬空引用。三个测试函数名(`TestContextToDoc` 等)指向已删函数,一并改名。

## 验证

- 全仓 `grep` 旧名:`*.md` / `*.go` 均 0 处(排除 build/third_party/.pi)
- `go build ./...` ✓
- `go test ./internal/memory/coldcontext/ ./internal/agent/core/` ✓
2026-10-31 11:39:13 +08:00
6a2c907e04 merge: 架构节补上下文管理与记忆 2026-10-31 11:30:22 +08:00
152dc680ad docs: 架构节写清上下文管理与记忆的实际做法
## 补上真正的差异点

之前架构节只有一张图和七阶段表,把最核心的上下文管理省略了。
用 codegraph 查了 `RelevanceContext` / `PruneWithProtected` /
`coldcontext.Store` / `block_recall`,确认实际做法,逐条对照常见 agent:

### 上下文:按相关度取舍,不是截断或摘要

常见做法是超窗裁掉旧对话,或让模型自己摘要 —— 共同问题是**丢什么由位置决定**。
这里是给每条历史事件按当前输入打分,低分下沉到文档层。

打分主路径是稠密余弦(与媒体、文档同一向量空间),模型未就绪退到 TF-IDF。

关键在 `effectiveProtectedCount`:保护条数受预算约束。

```go
budgetCap = ContextTokens / avgEventTokens
pCount = min(配置值, budgetCap)
```

源码里记着这条约束的来历:50k 窗口下单条事件约 5800 token(工具回灌型),
预算 40000,「钉住 10 条」本身就装不下 —— 每轮裁剪都撞保护下限,
两轮后预算耗尽,任务直接终止,召回率从 44% 掉到 22%。

### 三层是迁移,不是复制

`ColdDoc` 的注释写得很清楚:「块的真实归属在 Events[i].Blocks 里」,
块在任一层只有一个持有者,不靠引用计数保活。

回灌用 `Peek` + 确认 + `Remove` 两阶段,源码明确标注「**禁止先删后灌**
—— 删除必须排在确认之后,回灌失败即永久丢数据」。

### 场面:两条路并行,且互不吞并

声明场景**不吸收整轮指纹**(`EnsureScene` 的注释):否则它会以接近 1.0 的相似度
吃掉后续所有同类轮次,被动聚类再也长不出更细的场面。

这条边界是踩出来的,不是设计时想到的。

## 开头相应收缩

原来开头四段「**粗体断言** + 三行解释」与架构节重复。改成一句介绍 + 安装 +
三段话点出差异,细节留给架构节。

两版 163 / 191 行,9 个标题对齐,代码块 10/10 配对,无死链。
2026-10-31 11:30:18 +08:00