65 Commits

Author SHA1 Message Date
11144c62b5 chore(sdk): 同步 SDK 仓 SetAutoRestart 文档修正
SDK 仓 5af2a86 把 `SetAutoRestart` 的「崩溃后自动重载」改准为
「崩溃后自动重启」,并补上真实约束(线性退避 1s→2s→3s、
5 分钟窗口内第 4 次崩溃即停止、与「重载」是两回事)。

主仓以 vendored 方式跟踪该文件(third_party/homeagent-sdk/sdk/plugin.go,
主仓只跟踪其 40 个文件、不含 README),故此处同步同一改动,
保持两处一致。
2026-09-21 10:35:12 +08:00
d05161ac8d fix(plugin): 退避注释里无实测支撑的「感知不到工具缺席」
`scheduleProcRestart` 的注释写:

    线性退避:1 次→1s,2 次→2s,3 次→3s。崩溃循环时不至于打满 CPU,
    又足够快到用户感知不到工具缺席。

前半句是事实(退避确实只为防崩溃循环打满 CPU),后半句是主观断言:
首次重启就要等 1s,这 1s 内该插件的工具是缺席的、调用会直接报错。
「用户感知不到」既无实测支撑,也会让读代码的人误以为是无感恢复。

这正是另一处文档(README「崩溃到恢复 <1s」)同源的问题 ——
实测退避为 1s/2s/3s,故 <1s 从未成立(`procRestartBackoff = time.Second`
由 02cc74c 引入,且该提交是 v1.0.0 的祖先)。

改为写明真实代价与插件侧的正确做法(在 OnStart 里自建重连与状态重建),
与 SDK 仓 README 刚补的说明保持一致。
2026-09-21 10:34:41 +08:00
7587bd82d8 site: 正名「驻留子 agent」+ 删净页面小字注解
用户两点意见:
1. 那个东西不叫「分诊助手」,叫**驻留子 agent**
2. 页面上加的小字注解全是废话,全删

## ① 正名:分诊助手 → 驻留子 agent

「分诊」只是 offload.go 里对**职责**的描述(triage),
`resident.go` 顶注给的正式名是「驻留子」,`docs/zh/resident-subagent-design.md`
也把它列为独立概念(根 agent / 驻留子 / 工作式轻量子三类)。

7 处全部改正,含图内 SVG 文字、`aria-label`、架构流程图、FAQ 正文:

| 位置 | 原 | 现 |
|---|---|---|
| 图 5 框标题 | 分诊助手 | 驻留子 agent |
| 图 5 框内第二行 | 临时驻留子 | (删,与标题重复) |
| 图 5 的 aria-label | 转投给分诊助手 | 转投给驻留子 agent |
| 正文 | 转给一个临时驻留的**分诊助手** | 转给一个**驻留子 agent** |
| 架构图转投分支 | → 转投临时分诊助手 | → 转投驻留子 agent |
| FAQ | 这正是「分诊助手」的用途 | 这正是驻留子 agent 的用途 |
| CSS 注释 | 分诊助手接到消息 | 驻留子 agent 接到消息 |

## ② 删掉 11 处注解小字

原则:**复述图上已画出来的、或标题已说过的** → 删;
**承载数据**(插件版本 / 源码路径 / SVG 图内必要标签)→ 留。

- `.duty-note` ×6:架构图里 6 个阶段下面那行灰字
  (「构建消息与工具表 · 插件可在此短路」等)—— 阶段名本身已经说清,
  而这 6 行会把循环框右半边撑空
- `.flow-note`:重复解释 `on_input` / `before_output` 跑在什么时候
- `.dv-caps` ×3:图 4 的「任何中断都能插它前面」「被打断的现场压入中断栈」、
  图 5 的「不必干等」—— 图上插队动线和回执 chip 已经把它们画出来了
- `.dv-note` ×2:图 2「实测:statically linked,无动态依赖」、
  图 3「实测整段写入+读回 3.5µs」—— 数字重复
- `.dv5-tnote`:「默认关闭,需部署方打开」—— FAQ 里有完整说明(含理由)
- `.duty-ev` ×4 + `.sm` ×4:证据行/注释行,数字并回正文
  (「实测整段写入 + 读回 3.5µs。」等)

## ③ 删小字的连带修正

- **11 处死 CSS 全部清掉**(`.duty-ev` / `.flow-note` / `.dv-caps` /
  `.dv-note` / `.dv5-tnote` / `.sm`),单文件不留无人引用的规则
- 循环框删掉 6 行灰字后**右半边空了一大块** → 改为 `fit-content` 收窄居中

## 自己踩到并改掉的坑

- 循环框收得太窄(16rem),回边徽标「有工具调用则回到 ①」**压住**
  `after_toolcall` → 加 `min-width` 并加大底部 padding
- 想让循环框占满宽度,试过**阶段项排两列** → 截图发现编号被 grid
  按行填充排成 `1/3/5 · 2/4/6`,线性流程顺序全乱。已回退单列,
  并把这条教训写进 CSS 注释(免得后人再试一次)
- 图 5 触发条件框删掉小字后留白偏大 → 高度 56→42

## 验证

- 三档宽度(1440/768/390):无横向溢出、回边徽标与阶段项**零重叠**、
  文字未被截断
- 深浅双主题 / JS 禁用 / reduce 回归全绿;控制台零错误
- 死 CSS 计数全部归零;「分诊」全站残留 0
- CSS 括号平衡,文件 141,847 字节(原 143,960,净减 2.1KB)
2026-09-21 10:23:06 +08:00
7b7d405463 site: 重做第 3/5 张示意图 + 删冗长文案 + 修中文排版
用户反馈:3/5 页面图片不够精致、动画单调;hero 与架构节两句文案啰嗦;
图 1 那句注释要删。

## ① 删掉两处冗余文案

- 图 1 的 `<p class="dv-note">箭头只进出插件 —— 内核一列都没有</p>`:
  图上已经画出来了(光球只打插件、内核周围无连线),注解是重复
- 架构节副标题的「插件可改写或短路」:与组件表里 `Stage` 那行完全重复
  (那行还更具体,带 1/4/2 的阶段分布)

## ② hero 副标题重写

原文「内核只管编排、记忆与调度,消息 / 文件 / 网络 / 设备一律交给插件。
插件崩了不牵连内核 —— 换掉一个二进制就热重载。」三重堆叠、句子太长。

改短后又发现**两处与源码不符**,一并改准:
- 「热重载」不是崩溃后的行为。崩溃走的是退避重启
  (`scheduleProcRestart`:1s/2s/3s,`AutoRestartEnabled` 默认 true),
  `ReloadOne` 是换二进制那条路径。混成一句等于说错。
- 改为「崩了自己重启,波及不到内核」。

## ③ 图 3(共享内存):从稀疏线框重画

原图 19 个元素、层次扁平。重画后 53 个元素、三层递进:

- 两个进程做成带玻璃高光的卡片,各标自己的**虚拟地址**(0x7f2a… / 0x55c1…)
- 中间一句桥接:「不同虚拟地址 · 同一物理页」——这才是零拷贝的关键
- 物理页外框呼吸描边 + 极淡填充
- 段内标出 `header`(魔数·版本)与 `arena`,并加**字节刻度**,
  把「一片区域」讲成「有结构的区域」

## ④ 图 5(长任务转投):填掉大片空白

原图下三分之一完全空着,且「两种出路」只写在文案里、图上没有任何体现。

- 主 agent 加**忙碌进度条**(一直爬不到头,呼应「占住很久」)
- 积压消息由 2 条改 3 条、宽度递减成「一摞」,并逐条被取走
- 右上空白填入**触发条件**,给的是源码实测默认值
  (`defaultOffloadBusyAfter = 5min`、`defaultOffloadMinPending = 3`),
  并注明「默认关闭,需部署方打开」
- 新增回执区:`① 简单 → 直接办完,发回原通道` /
  `② 需主 agent → 回「忙碌中,请稍候」`,并用光点示意回执送达用户

## ⑤ 排版:中文之间夹空格

改文案时实测发现正文里有「实时 渲染」这类中文间空格 —— 源码换行被浏览器
渲染成一个空格。找出真实渲染有空格的位置并修掉(改用 innerText 判定,
textContent 会把 `<em>` 等块边界误判为空格,实测误报了 2 处)。

## 自己踩到并修掉的几个坑

- 「各自的虚拟地址」两句标签被竖向虚线穿过(压字)→ 改为一句居中桥接
- 图 3 标题靠左时被 `x=72` 的写入虚线穿过 → 改居中
- 玻璃高光整块铺满像蒙了层白纱 → 收到上半部、透明度 .3
- 巡行光点框看起来像页内多了一层框 → 去掉,改为外框呼吸
- 内层标题与外层桥接都在说「同一物理页」→ 内层改为描述段布局

## 验证

- 深浅双主题逐图截图核对(图 3、图 5 各两套)
- 动画:图 3 从 5 → 11 个动画元素、图 5 从 8 → 10;元素总数 19→53、17→45
- reduce 下新增的 CSS 动画(physBreathe、dv5-pile、忙碌条)全部停;
  SMIL 仍靠 pauseAnimations,8/8 SVG 已暂停
- 回归:深浅主题 / JS 禁用 / reduce / 390·768·1440 三档,零横向溢出、无控制台错误
- 结构:CSS 括号平衡、HTML 无未闭合标签
2026-09-20 23:37:16 +08:00
5d395116d1 site: 修正 FAQ 三处与源码不符的描述
用户指出「常见问题部分描述不太符合事实」。逐条对着源码核了六条,
查出三处不准(第 4 条是实质性误导),另修掉一个由此暴露的滚动缺陷。

## ① 第 3 条:「L4 是内核保留的」漏了一半

源码 `interruptLevel(evt, privileged)` 有**两条**放行 L4 的路径:
`privileged=true`(内核)与 `isKernelLevelSource`(编译期内置插件)。
`scheduler.go:74` 的注释也写明「只给内核与编译期内置插件」。

原文只说「内核保留」,会把内置插件的能力说没。已补上。
(「外部插件声明 L4 会被夹到 L3」这句本身没错,`clampPluginLevel` 确实如此。)

## ② 第 4 条:自动转投默认是关闭的 —— 原文读起来像默认行为

`DefaultOffloadOptions()` 返回 `Enabled: false`,源码注明理由:
「默认关闭、由部署方显式打开,与『显式才是特权』同一条理由」。

原文说「内核拉起临时驻留子接手」,通篇没提这个前提,读者会以为开箱即用。
已补上「默认关闭」并给出默认阈值(实测 `defaultOffloadBusyAfter = 5min`、
`defaultOffloadMinPending = 3`,原文只说「忙超阈值」「积压够多」,没给数)。

## ③ 第 5 条:「媒体跟着记忆块走」不完整

`payloadHeld()` 的存在说明有**共享**一说:同一份字节可能被多个块持有,
此时**不删**。两个删除点(`medialoop.forgetPayloads` 与 `sdk/memory_impl.go`)
都各自做了 `stillHeld` / `payloadHeld` 检查,注释写明
「同一张图可能被多个块引用」。「跟着块走」会让人以为按块计数即可。
已改为「如果同一份字节仍被别的块共享,则不删」。

## 核对无误的四条(未改)

- 第 1 条:`现场保存/恢复`、`中断栈` 都是 `scheduler.go` 的原词;四级中断、
  上下文预算、蒸馏与召回均在
- 第 2 条:三通道准确(`proc/shm.go` 顶注:`stdio JSON-RPC` 控制面 /
  `shm + 偏移` 数据面 / `eventfd` 通知面「事件环 post-and-forget」);
  「摘除工具、阶段与通道」对应 `UnregisterPluginTools` /
  `UnregisterPluginStages` / `releasePluginChannels`;`onProcCrash` 注释即
  「摘注册面、喂健康计数、排一次重启」
- 第 6 条:`go:embed adapters/*.lua` 真嵌入;9 个名字与 `internal/lua/adapters/`
  下文件逐一对应(`server.lua` 是网关脚本,不计入)

## ④ 顺带修掉:FAQ 全部展开后滚不到底

改动后我照例量了末尾能否到底,发现**全部展开时卡住、页脚不可见**:
展开后 faq 712 + footer 367 = 1079 > 一屏 900,`mandatory` 又把滚动锁住。

这里走了两次弯路,都记进注释了:
- 先想只让 faq 退出吸附 → **死锁**:它下方没有吸附点,而 mandatory 只允许
  停在吸附点,实测卡在 plugins 的吸附位(y=9455)不再前进
- 阈值先误用「视口高」→ 展开后 faq 高 712 < 892,判不出超限,类根本加不上

最终改为「展开超过『视口高 − 页脚高』时整页切到自由滚动」,
因为 faq 是最后一个吸附节,它一旦装不下,末尾就必须整体自由。

## 验证

- 折叠态:到底=true,页脚可见,faq 标题不被导航遮挡(h2 顶 210 > 67)
- 展开 1 条 / 展开全部 6 条:两种状态都到底=true、页脚可见
- 一滑一页仍生效(8/8 次停靠不同节,72px 偏移为导航高度)
- 深浅双主题 / JS 禁用 / reduce / 390·768·1440 三档:全绿,零横向溢出,无控制台错误
2026-09-20 22:49:56 +08:00
fc8f153e34 site: 修首屏滚不到底 + 交错行出入方向 + 让示意图真动起来
用户反馈三点:① 拉不到最下面 ② 文字与图片没有进出动画,单调
③ 图片仍是「框框住文本」,且希望有「外界光球打到插件上」。

## ① 滚不到底:真 bug,且是两个独立根因叠加

实测(滑到底后读 scrollY):停在 10283,上限 10669,**差 386px**,
页脚完全不可见。两个原因:

- **末尾两节合计超过一屏**。faq 高 635 + footer 367 = 1002px > 一屏 828px。
  `scroll-snap-type: y mandatory` 只允许停在吸附点,于是浏览器被迫二选一:
  要么 faq 标题对齐(则页脚滚不到),要么页脚可见(则标题被导航压住)。
  实测两种坏法都出现过:h2 顶 = -57 / -38(导航底 67)。
  修法:FAQ 六条改双列(省下约 190px),收尾段取消整屏高度,
  合计压到 762px,两个要求即可同时满足。
- **只在 footer 单独设吸附点会形成死锁**。试过给 architecture 设
  `scroll-snap-align: none`(它高 1180px),结果前一个吸附点把它自己吸回来、
  后面又无吸附点接住,实测卡死在 y=5791,怎么滑都不动。
  教训写进注释:高节退出吸附不是解法,「装得进一屏」才是。

## ② 交错行:方向与布局相反(结构性错误)

原文给每行写死 `slide-l`(文)与 `slide-r`(图)。但翻转行里图在**左边**,
却仍从右侧飞入 —— 图穿过文字进场,方向与布局相反。5 行里 3 行错。

修法不是逐行改正,而是**从布局推导方向**:`.alt:not(.flip)` 文左图右、
`.alt.flip` 图左文右,方向跟着 `.flip` 走。这样不可能再写错。
退出时不加 `.in`,transform 回到同侧 ——「从左进就从左出」是自动的。

## ③ 示意图:从静态线框变成有语义的动画

现状实测很糟:5 张图共 116 个元素,**只有 4 个在动**(各 1 条虚线),
图 2 完全静止 —— 用户说「框框住文本」是准确的。

按每张图的语义给它自己的动作(13 种 keyframes,47 个动画元素):

- **图 1 隔离**:外部光球从画面外飞入,四种颜色命中四个插件,
  激起涟漪 + 插件闪一下;内核一列都没有。这正是「外面的一切都落在插件上」。
- **图 2 零依赖**:四项依赖被**逐条划掉**。用 `pathLength=1` + `stroke-dashoffset`
  让线自己「划」过去,不是淡入。
- **图 3 共享内存**:写入/读回两个包沿链路跑,arena 上有光带自左向右扫过。
- **图 4 优先级**:金色包沿四条曲线**插队**到 L1–L4 之前;
  四级强度条依次点亮;队列条逐条被取走(先到先处理)。
- **图 5 长任务转投**:积压消息被接走飞向分诊助手,收到时闪一下。

配套修掉三处我自己引入的缺陷:
- `.dv-pkdot` 原用 `pkPulse` 动 `r`,与行进包叠加会闪烁 → 改只动透明度
- `.dv-ripple:nth-of-type(2n)` 按 `<circle>` 计数,误配到数据包圆点,
  四个涟漪全被染成金色 → 改显式逐色
- 涟漪原画在插件框**内部**,像框里画了个圆 → 移到框下层,从背后漾开

## ④ 顺带发现并修掉的真问题

- **CSS 的 `prefers-reduced-motion` 管不到 SMIL**:实测 reduce 下 6 个
  数据包照跑。加 JS 调 `svg.pauseAnimations()`,实测 8/8 SVG 已暂停。
- **窄屏横向溢出 26px**:源于我这次加的入场位移(`translateX(42px)`)
  在未入场时探出视口。修法是 `overflow-x: clip`(不用 hidden,避免新建滚动容器)
  并窄屏收到 ±20px。实测 390/768/1440 三档溢出均为 0。

## 验证

- 滚动:到底=true,页脚可见,faq 标题不被遮挡(h2 顶=210 > 导航底 67)
- 方向:5 行全部「文在左侧就从左进」,翻转行正确反向
- 入场:逐节停留 13 节全部入场,0 未揭示;
  滑到底后 23 个 opacity=0 的元素**全在视口上方**(退场生效),无一是「场内却不可见」
- 回归:深浅双主题、JS 禁用、reduce、390/768/1440 三档 全绿,无控制台错误
- 结构:HTML 解析无未闭合,CSS 括号平衡
2026-09-20 22:37:06 +08:00
3374e7dbd9 docs: 更正三处无实测支撑的性能断言
用户指出现有文档里的性能数字可疑。逐个实测后发现三类问题,都不加改原文地
标注更正(历史条目保留原文,仓内已有此惯例)。

## ① 「崩溃到恢复 <1s」——从未成立

写于 v1.0.0 发版说明。但**当时的退避代码就已是 1s**(查 v1.0.0 tag 的
`procRestartBackoff = time.Second`),首次重启就要等 1s。

实测(新增临时测试测量 scheduleProcRestart 延迟):

    第 1 次崩溃 → 1s      第 2 次 → 2.001s      第 3 次 → 3.002s

顺带纠正我自己刚在站点写错的阈值:并非「崩 3 次停下」。实测第 **4** 次
才停(`procMaxRestarts=3`,判定为 `n > 3`),前 3 次都会重启。

## ② 「RPC 往返 p50 24.1µs」——量级对、数字不符

实测 `BenchmarkToolInvoke`:inline/small **30.4µs**、frame/small 51.5µs、
inline/large 767µs、frame/large 398µs。原文与实测同为几十微秒量级,
但具体值对不上,且未注明测的是哪种 payload。

## ③ 「CLIP 实测常驻 1.15GB」——采样点不对(6 处)

实测加载 chineseclip 两塔,RSS 会**自己降下来**:

    加载前      0.00 GB
    两塔加载后  1.59 GB   ← 峰值
    GC + 静置     0.89 GB   ← 稳态(内核回收未用页)

1.15GB 落在两者之间,既不代表峰值也不代表稳态。线上稳态实测 0.39~0.58GB
(更长时间静置后更低)。同源问题:qwen3vl 的「常驻 9.4GB」实为**峰值**,
其视觉塔本就是按需加载(源码注释:每张图约 1.6GB,故按需)。

6 处全部改为「稳态 X(峰值 Y)」双值,消除口径歧义:README 中英、
docs/zh/multimodal-space.md、config/registry.go(2 处 + 1 处注释)、
providers/chineseclip/tokenizer.go。

## 验证

- `go build`(含 `-tags onnxruntime` 与不带)与 `go vet` 均通过
- 全仓 `grep 1.15GB` 已清零
- 测量用的临时测试文件已删除,无残留
2026-09-20 19:56:49 +08:00
ca6f4c510c feat(site): priority 图补上「排队输入」这一类(用户指出的漏项)
## 漏项

文案写「输入走两条路」,但图上只有 L1–L4 —— **排队的完全没出现**。
源码 `scheduler.go` 开篇就写明是**两类别**:

- TaskInterrupt(InjectInterrupt*):带 L1..L4,可抢占
- TaskQueued(InjectText*/InjectInputSync*):**无级别**,可被任何中断打断

「级别只属于中断」是这套调度模型的关键一句,图里不表达就等于漏了一半。

## 改法

图改成左右两列,标题直接写清差异:

    中断 · 带级别            排队 · 无级别
    ├ L4 内核独占            ├ 队列条(先到先处理)
    ├ L3 需及时处理          └ 任何中断都能插它前面
    ├ L2 消息类                      │
    └ L1 完全可等                     │
        └───────────┬──────────────┘
                正在跑的任务

- 排队列用**虚线框 + 素色条**(不发光),与左侧的实线发光级别条刻意区分 ——
  「无级别」这件事本身就该在视觉上体现
- 两路汇入「正在跑的任务」,并标注「同一时刻只一个」

## 顺带修的两处

- **浅色下排队条看不见**:原先复用 `gCard`,而浅色下 gCard 是白的,
  白底白条等于没画。改用独立的 `--dv-queue-item` 令牌
- **横向虚线穿过队列条**:原想表达「处理方向」,结果画在了条内部。
  改为右侧竖线 + 向下箭头

## 验证

- 双主题:控制台错误 0、横向溢出 0、图元零越界
- 一滑一页仍生效;JS 禁用 24/24 可见;reduce 下吸附停用
- 390/768/1440 溢出均 0
2026-09-20 10:39:53 +08:00
88923ed663 feat(site): 示意图升级为玻璃面板 + 修 priority 图两处错误
## 根源(用户指「图太平,没有高级 UI 效果」)

问题不在画得不够花,而在**手法本身就平**:纯 SVG rect + 平面文字浮在平坦
背景上,本质上就是工程草图。换基础:

- **玻璃面板**:每张图裹一层带内上高光、内下厚度、外分层投影的玻璃底 +
  `backdrop-filter: blur(8px)`,图形才像「浮在界面上」而非浮在空背景上
- **顶部柔光 + 细颗粒**:径向渐变给受光面,`feTurbulence` 噪点抿掉矢量图的塑料感
- **渐变节点 + 投影**:所有节点从纯色填充改为竖向渐变面 + `feDropShadow`
- **悬停提亮**:`fLift` 滤镜(更远的阴影 + 青色泛光)
- **流动虚线**:连接线 `dashFlow` 动画,看起来「有东西在跑」
- 全部走主题令牌,深浅各自调参(浅色受光方向相反)

## 修 priority 图两处真错误

**① 文案与源码语义错位**(用户报的)
原文写「可以等的 / 不能等的,不能等的再排 L1–L4」——
但源码 `LevelBackground = L1` 的注释是「**完全可等**」。我把 L1 归进了「不能等的」。
实际是:**排队无级别(谁都能插它),中断才带 L1–L4**。已改。

**② L4 文字溢出框外 57px**
`dv-ts` 是 `text-anchor: middle`,但我按左对齐给了 x=46,长的那行
(「L4 内核独占 · 立即打断」)以 46 为中心向两边展开 → 左溢 57px。
新增 `.dv-tsl`(`text-anchor: start`)专供左对齐行。实测四行现均整齐落在框内。

## 顺带修

- 「抢占」标签贴边 99.8%,玻璃面板内边距会裁掉它 → 整条线内移
- 抢占方向原为「从 L4 顶部绕出去悬在半空」,既没连上目标也读反了 →
  改为「从级别条右侧指向正在跑的任务」
- 图 4 内容仅占 viewBox 70%×71%(其余图 88–93%),显空 →
  重画为「强度条背景 + 级别徽标 + 场景标注 + 抢占回边」,现 81%×90%

## 验证

- 双主题:5 面板、控制台错误 0、横向溢出 0
- **全部图元与文字零越界**(含 viewBox 内边界检测)
- 一滑一页仍生效;JS 禁用 24/24 可见;reduce 下 snap 与流动动画均停
- 390/768/1440 溢出均 0
2026-09-20 10:33:20 +08:00
631963fdc7 feat(site): 总起页改为「真正的 AgentOS 长这样」+ 修页脚状态栏不可点
## 总起页:从对比改为展示(用户要求)

前几版是对照表(「别人说 X → 我们要做到 Y」)。用户指出重点不是对比,
而是**把自己作为「真正的 AgentOS 样例」展示出来**。改为四张并列卡片,
每条给「机制 + 可测数字」,不做比较。

标题「真正的 AgentOS,长这样」;四题:隔离 / 调度 / 通信 / 资源 ——
操作系统躲不开的四道题,逐题给答案。

## 文案改了第三轮(用户指「读着太难受」)

按「短句、有节奏、不堆从句」重写四段正文。举一例:

  旧:插件不是进程内的一个库,是内核 spawn 的独立进程。崩了就把它的
      工具、钩子、通道一并摘掉,其余照跑。
  新:插件不在内核里,是另一个进程。崩了就把它注册的东西一并摘掉,其余照跑。

## 修页脚「状态」栏不可点(用户报的 bug)

三行原为 `<span class="muted">` 死文本,点不动。改为链接:

- 最新发布:v1.3.x 线   → /releases
- main 在研:1.4.0      → blob/main/internal/meta/meta.go(版本号的实际来源)
- 许可:AGPL-3.0-only   → blob/main/LICENSE

## 一处自查纠正

我一度在卡片里写「插件崩溃 → 恢复 <1s」,那是照抄 README 的旧说法。
查源码 `dynamic_proc.go` 发现退避实为 **1s / 2s / 3s**(`procRestartBackoff=1s`,
5 分钟内崩 3 次 `procMaxRestarts=3` 即停手等人),**<1s 不成立**。
改为如实写明退避序列与停手机上阈值。README 那句待另开一轮核实。

## 验证

- 双主题:4 卡片、控制台错误 0、横向溢出 0
- 一滑一页仍生效(6 次滑动偏差恒为 72px = scroll-padding-top)
- JS 禁用 24/24 可见;reduce 下 snap 自动关闭
- 390/768/1440 溢出均 0
- 页脚 9 个 gitcode 目标逐个对照 origin/main 的树:**全部存在**
  (不只看 HTTP 200 —— gitcode 对错误路径也返回 200,此前踩过)
2026-09-20 10:11:02 +08:00
4078f3ac0a feat(site): 重写首屏文案 + 交错图文布局 + 一滑一页
## 口号与文笔(用户指「不够响亮、部分文笔不好」)

Hero 改为「是…更是…」句式:

  是记得住的管家 / 更是从不让你干等的搭档

## 五个设计决定:从卡片改为交错图文

原来 4 张卡片平铺。改为 5 行交错(文/图左右互换),每行配一张内联 SVG
示意图,纯 CSS + 主题令牌,深浅自适应,零外部依赖。

**换掉一条、新增一条**(用户指出「插件跑在独立进程」不算特色 —— MCP、LSP
都这么做,不是差异点):

| | 内容 | 依据 |
|---|---|---|
| ② | 部署,从未如此便捷 | 实测插件 `statically linked`、`not a dynamic executable` |
| ③ | 数据如水,随流,随改,随走 | 共享内存 + 相对偏移零拷贝;实测整段写入读回 3.5µs |

标题按用户给的句式写(②③ 原文照用),正文不给形容词、给可核验的做法与数字。

## 一滑一页

`scroll-snap-type: y mandatory` + 每节 `min-height: 100svh`。
为此把 features 的 5 条决定各拆成独立 section(原 2.74 屏塞 5 行,
mandatory 下会锁死底部),architecture 的流程图也单独成节。
现 13 节,实测连续 8 次滑动精确停在第 1..8 节,间距 828px = 一屏。

## 两处实测纠正(都是我先判断错、再被数据推翻)

1. **`proximity` 做不到「一滑一页」**:实测滑 500px 落点就是 500,离最近
   节边界 395px,不触发吸附 —— 只是「有时粘一下」。改用 mandatory。
2. **我误报 architecture「底部锁死」**:按 `h > innerHeight` 判定,忽略了
   溢出行仍可滚动。用真实 wheel 实测 13 节末元素全部可达(含该节 828 < 900)。
   所以没有锁死,压缩 vertical rhythm 是顺带的,不是修复。

## 验证

- 深浅双主题:13 节 / 5 交错行 / 5 示意图、控制台错误 0、横向溢出 0
- 一滑一页:8 次滑动停在 8 个不同节,落点间距精确 828px
- 72px 落点偏移经查是 `scroll-padding-top`(导航高 67px),确保标题不被遮挡 —— 有意为之
- JS 禁用 24/24 可见;reduce 下 snap 自动关闭(`prefers-reduced-motion`)
- 390/768 无 snap(窄屏强制一屏反而难受);1024/1440 启用
- 真人式滚动(wheel 与 400px 步进两种)未揭示元素均为 0

注:本轮前期用了几个 Python 补丁脚本改 HTML,用户指出「不好」。后续改为
直接编辑以产出可审阅的 diff,脚本已删除。
2026-09-20 09:46:18 +08:00
0c9a3900b8 fix(release): .hmap 纳入发布产物白名单 + 路径解析
## 白名单(真问题)

`upload_assets.py` 的 ARTIFACT_SUFFIXES 只有 .tar.gz/.zip/.deb/.rpm/.pkg/_win64.exe,
**没有 .hmap** —— 即使插件包已经构建好放在 dist/ 下,上传时也会被静默跳过。
这正是「release 里一个插件包都没有」的直接原因之一。

补 `.hmap` 与 `SHA256SUMS.plugins`(插件包的汇总校验和,与内核包的 SHA256SUMS 分开,
避免混用)。实测 is_artifact() 现能正确识别两者、仍跳过 README.md。

## 测试路径解析

`HMAP_BUNDLE_DIR=dist/plugins` 这种相对仓根的写法原先会失败:测试的 cwd 是包目录
(internal/plugins/pluginmgr),相对路径解析到包内,报 "no such file or directory",
看起来像产物不存在。改为相对路径按仓根解析(向上找含 go.mod 的目录)。

实测三种调用都正确:相对路径、绝对路径、不设时 skip。
2026-09-20 09:07:33 +08:00
a35f2126a1 test(pluginmgr): 校验发布用插件包能被内核真实安装
配套 SDK 仓新增的 scripts/build_plugin_bundles.sh:**能构建出来 ≠ 内核装得上**,
这个测试用内核自己的 extractPackage 把产物真解一遍,验证三种包形态都落成规范入口。

覆盖的三种形态(都由真实产物验证过):
- 多平台 bundle:`plugin.bin.<os>.<arch>` → 按当前平台挑出并**重命名为 plugin.bin**
- 单平台包(qq 的 plg.json 是 bundle:false):只有 `plugin.bin`
- Lua 包(luademo):入口是 `main.lua`,不编译 Go

不设 HMAP_BUNDLE_DIR 时 skip(不作为常规 CI 的必跑项,避免依赖 hmapdev 工具链):

    HMAP_BUNDLE_DIR=/path/to/plugins go test ./internal/plugins/pluginmgr/ \
      -run TestBuildPluginBundlesInstallable -v

实测 21 个真实产物全部通过(含 Lua 与单平台两种非 bundle 形态)。
2026-09-20 09:02:42 +08:00
9b26db45bc fix(site): 逐条对照源码修正描述(含两处真错误)
上一版有几处表述与源码不符。这轮把页面上每条可核验的说法都对着代码重新查一遍,
改掉 15 处,其中两处是**事实错误**而非措辞问题。

## 事实错误

**① L4 的归属说反了(FAQ)**
原文让读者「用更高级别的中断(如 L4:内核与内核级插件)」插队,暗示插件能用 L4。
源码 `scheduler.go` 的 `clampPluginLevel` 把 **>L3 一律夹到 L3**,注释也写明
「L4 由内核独占(panic、内核事件 selfip)」。照原文写插件会静默拿到 L3。
改为:插件可声明 L1–L3,L4 是内核保留的「立即打断」。

**② 驻留子的父侧动作列错**
组件表写「父可查看/收发/压缩/回收」。"收"不存在 —— 源码的动作集是
`list | create | send | inspect | compress | reclaim | destroy`,
子持有状态面由**父 pull**(resident.go 开篇注释:父持登记表,子持 inputch 处理表,
父 pull 不打断子)。改成「查看/发送/压缩/回收/销毁,子是父拉取而非推送」。

## 措辞不准确(12 处)

- **Context 层**「最近若干条受保护」→ 源码 `pCount := 10` **写死十条**;
  「预训练词向量 → 余弦相似度,TF-IDF 回退」→ 实为优先稠密向量余弦、
  未配置时退到稀疏词向量(TF-IDF / fastText);「自动下沉」→ 归档进 Document 层
- **PluginSDK「四通道」**→ 不是四个"通道",是三面接口(工具/钩子/事件)+ 输出通道声明
- **管道「7 个阶段钩子」**→ 会被读成都在管道内。实际分布是进管道前 1(on_input)、
  轮次中 4、收尾 2(before/after_output),两处都标明
- **sanitizer** 只写了"清工具调用残留",漏了它更常做的是洗坏 UTF-8/U+FFFD/ANSI
  (而这类字节会被模型复读),且不注册工具只挂钩子
- **rss**「推送通知」→ 实际是按间隔轮询 + 中断注入;补上"订阅时记历史条目,
  所以订一个源不会把旧文章全推一遍"
- **memo** 补上可核验的机制:每 5 分钟检查未完成待办
- **ocr / bili** 补外部依赖(tesseract + chi_sim / yt-dlp)—— 不写清楚装完才发现缺
- **mc**「两阶段激活」原样照抄没解释;实为「想连着(意图)」与「确实连着(连接)」
  两个状态分开,所以 bridge 被 kill -9 后能自动重登恢复会话
- **qq** 一句话太单薄,补 20 工具 + 权限模型要点(身份绑帧、取交集、前缀拒绝)
- **a2a / music / weather** 分别补:两个方向与端点、只读无副作用、NoMemory 取舍

## 顺带修掉两个我上一轮引入的 HTML 缺陷

用行替换时失手:Context 卡丢了一个 `</p>`、mc 卡多了一个 `</span>`。
这次写了栈式配对检查才发现(简单的计数对比看不出来)。

## 验证

- 栈式标签配对:p/span/div/button/code/section/h2/h3/details/ul/ol/a/li **全部平衡**
  (修复前 p 差 1、span 差 -1)
- 事实终检 10/10:内置插件 16(all.go 导入数)、LLM 适配器 9 且**逐个名字对上**、
  Go 行 109241→109k、go.mod 1.25.0、L4 归属、resident 动作、Stage 分布、备案号
- 浏览器回归:深浅错误 0、JS 禁用 47/47 可见、reduce 动效停、390/768/1440 溢出 0、
  滚到底未揭示元素 0
- mc 工具数:本写「12 个动作工具」,实测 `tp+"act"` 去重后 activate/deactivate/status
  之外是 **11** 个,已改
2026-09-20 08:48:15 +08:00
47052cb115 fix(site): 更正媒体机制描述 + 页脚补备案号
## 描述性错误(用户指出)
1. **「引用计数 GC」已不存在**。「有引用绝不删」「媒体靠引用计数 GC」
   两处都在讲一个已废弃的账本 —— 实测源码里已无 media_refs/ref_count,
   `internal/memory/media/media.go` 明确写「这不是 GC,也不看引用计数」。
   现行规则是「删除持有它的记忆块即删内容」,与文本块同一套
   (medialoop.go 的 payloadHeld 只在确认无块共享时才删字节)。
2. **「描述才是持久语义」整张卡已过时**。旧实现靠视觉模型生成的描述当索引;
   现已弃用 —— `mediaref.go` 写明标签「不再包含任何生成的描述文本」,
   图片改按统一空间向量检索,`graphmedia.go` 还带一个把旧描述式实体
   迁移成原生记忆块的迁移函数。卡片改为「图片靠自己的向量被检索」。

## 备案号
页脚补 豫ICP备2024074105号-1 与 豫公网安备41070202001579号,
链接到 beian.miit.gov.cn / beian.mps.gov.cn。取值来源是现网
门户配置(/root/portal/dashy/conf.yml),未凭记忆编造。

## 验证
深浅双主题下渲染正确、两条链接 href 实测无误、无 JS 错误。
2026-09-20 00:00:23 +08:00
09298886a2 feat(site): 「一条消息进来之后」改为真正的流程图
原来是 ASCII <pre> 图。它有三个问题:

1. **画不出循环**。真实执行序是 7 步状态机,其中工具循环要回到开头
   再来一轮 —— ASCII 只能表达上下关系,这一点只能靠文字暗示。
2. **7 个钩子排成一行是错的**。on_input 在进管道前跑,
   before/after_output 在**全部轮次结束后**才跑一次;把它们与管道内的
   钩子并列,读起来像一条直线。
3. 漏掉了 post_action 之后才发生的工具调用,以及"上下文裁剪 + 相关记忆召回"
   这一步(在 after_toolcall 里)。

## 现在的结构
五层节点 + 分支 + 循环体:
外部输入 → 输入调度器(三条分支:入队列 / 抢占 / 转投)
→ 处理管道 →〔① pre_action ② LLM ③ post_action ④ before_toolcall
⑤ 执行工具 ⑥ after_toolcall〕↻ 循环 → 三层记忆 → 输出通道

事实全部对照源码核过(不是照抄旧图):
- 7 个 Stage 常量取自 SDK `third_party/homeagent-sdk/sdk/plugin.go`
- 顺序取自内核 `internal/agent/core/task.go` 的 Step 状态机
  (StepPrepare→StepLLM→StepToolBegin→StepToolExec→StepToolAfter→StepTurnEnd)
- 脚本末尾补一句说明 on_input / before_output / after_output 的时机

## 视觉
节点用色与三层记忆的三色一致(蓝=Context/青=管道/金=Graph,紫=转投);
连接线上的光点错峰下行,序号依次点亮。全部是内联 SVG-free 的纯 CSS,
无外部依赖。

## 关键取舍
- **删掉了贯穿全图的中轴线**:节点背景是半透明令牌,轴线会直接透出来,
  实测在「处理管道」里穿过整个编号列表,看着像画错了。连接线本身就是主轴。
- **循环回边改为内嵌徽标**:先做成从框底绕出的弧线,但它会压到下一条
  连接线 —— 同样像画错。
- **给连接线补了静态箭头**:动画关掉时(reduce)方向也要看得出来。

## 验证(独立 headless 实跑)
深浅双主题控制台错误 0、页面溢出 0;图内溢出 0(390/620/900);
**JS 禁用下 14 个节点全部可见、7 个钩子名齐全**(流程图是内容不是装饰);
reduce 下光点/图标动画确为 none 而静态箭头仍在(宽 7px/2px);
**7 个钩子名与 SDK 常量逐一比对通过**,防止文案漂移;
滚动到底未揭示元素 0。
2026-09-19 22:38:06 +08:00
db8534e315 feat(site): 文案精简 + 插件可点击 + 版面精致化
## 文案(净减约 25%,信息量不变)
删的是解释性赘语与重复限定,不是信息:
- 「内核不直接读写任何外部世界…于是「内核有多可信」与…」→「内核不碰任何外部世界…可以分开评估」
- 「大多数框架先写功能再补边界。HomeAgent 反过来:先把边界和调度定死,再往上加能力。」
  →「先定边界与调度,再加能力。」
- FAQ 六条逐条收紧;副标题从句子改回短语
- AI 声明与许可段去重复(两段都在讲同一件事)

## 插件徽章从装饰变为可交互(这是用户报的「无法点击」)
每个徽章现在是真按钮:点开显示该插件的**版本 + 用途**(取自各 plugin.json,
共 20 个),可多开、可收起,末尾「展开全部 20 个」一次全开。
键盘可达(Enter/Space),选中态用 aria-pressed 表达。
初版是纯 <span>,带 hover 效果却不可点 —— 看起来能点但点了没反应。

## 修正一处事实错误
统计卡原写「**36 外部插件**」。实测 36 是**加载总数**(16 内置 + 20 外部);
外部插件实为 20 个。同时:
- 「110k Go 代码行」→ 109k(实测 109,241)
- 「10 LLM 协议适配器」→ 9(server.lua 是 zen 网关脚本,不是厂商适配器)
每张卡补一行小字说明口径,避免再被误读。

## 版面
section 统一 4.5rem 节奏、卡片内边距与标题间距收敛、组件表代码列定宽对齐、
统计卡加口径小字、三层记忆卡收紧。插件选中态从实心青底(20 个齐亮像一堵墙)
改为淡青底 + 主色描边,并给 color-mix 加了 rgba 回退。

## 验证(独立 headless 实跑,全部通过)
深浅双主题 console 错误 0;JS 禁用 47/47 可见且插件卡默认全隐(0 张);
reduce 下粒子停、全可见;主题切换刷新保持、首绘无闪白;
390/768/1440 横向溢出均 0;移动端点插件正常展开;
插件交互逐项验过:单击展开 → 再点收起 → 多开 3 张 → 全展开 20 张 → 收起。
★ 一度报「19 个元素未揭示」,查证是我测试脚本没滚动所致 ——
逐步滚到底后实测 0 个未揭示,非真回归。

另把取数命令写进 site/README.md,并注明「36 = 加载总数」这个易错点。
2026-09-19 22:24:03 +08:00
fab27a1194 feat(site): 深浅双主题 + 动效层,并按用户要求移除立绘
## 深浅双主题
跟随系统偏好,导航栏按钮可手动切换(存 localStorage)。首绘前在 <head> 里
定好 data-theme,无闪白(实测 reload 首绘即正确背景色)。
语义色全部令牌化,:root[data-theme="light"] 只覆盖取值;品牌三色两主题共用
(对应三层记忆,换主题不该换语义)。

## 动效层(1 → 11 个 keyframes)
极光漂移 + 细网格背景、粒子网络(近邻连线,密度按面积自适应、上限 72)、
三色滚动进度条、标题渐变流动、分块上错落入场、卡片聚光 + 3D 微倾、
三层记忆色条自上而下灌注、架构图流光带 + 节点脉冲、数字滚动到位、
分节标题下划线展开。

三条硬约束(都吃过亏):
1. **内容默认可读** —— 初始隐藏只在 .js-fx 下生效,而 .js-fx 仅当 JS 真跑起来才加。
   实测 JS 禁用时 30/30 元素可见(旧版把 opacity:0 写默认样式里 → 26/30 永久不可见)。
2. **尊重 prefers-reduced-motion** —— 不启粒子、不画进度条、元素直接可见。
3. **装饰不得产生滚动条** —— canvas 改用 documentElement.clientWidth
   (window.innerWidth 含滚动条,实测多出 15px 撑出横向滚动),body 加 overflow-x: clip 兜底。

## 移除立绘(用户要求)
删掉 HTML/CSS/JS/资源/令牌全部痕迹,Hero 改单栏。README 记下为什么最终不放图:
原图是不透明 WebP(mode=RGB 实测),白底与角色白裙子同色,flood-fill 会渗进轮廓
让约 42% 身体透明 —— 这类素材要么出透明图,要么就别放。

## 顺带修掉 3 个真 bug(都是主题化后暴露/复核出来的)
1. **代码块换行全丢** —— .code 缺 white-space: pre,实测整段命令挤成一行。
   (这个 bug 在我这次改动之前就存在)
2. **浅色下导航看不清** —— header 背景硬编码 rgba(11,16,32,.78),改用 --nav-bg。
3. **浅色下立绘处有灰块** —— .mascot::after 硬编码深色,改用 --mascot-fade
   (该规则已随立绘一并删除)。
另把 .badge / .btn-ghost:hover / 按钮光泽里 3 处 rgba(255,255,255,…) 令牌化为
--hover / --sheen,否则浅色下是白压白。

## 验证(共享 Chromium 实跑)
深/浅首屏 + 记忆段 + 架构段截图逐张看过;console 错误 0;坏图 0;
JS 禁用 30/30 可见;reduce 全可见且粒子/进度条已停;主题切换 → 刷新后保持;
390/768/1440 三档横向溢出均为 0;CSS 花括号平衡、8 个 keyframes 无孤儿。
2026-09-19 21:49:59 +08:00
923d5d595f feat(site): 新增产品官网落地页(单文件 · 零构建 · 零外部依赖)
用户要求写一个官网介绍页面。做成纯静态单文件,与仓库 WebUI 的既有做法一致
(原生 HTML/CSS/JS,无打包步骤)。

## 内容(每一条都对着源码/运行实例核实过)
- Hero:一句话定位(常驻型个人 Agent 框架)+ 看板娘立绘
- 四个设计决定:内核零 IO / 插件独立进程 / 输入有级别 / 忙时有人顶班
- 三层记忆:Context → Document → Graph,含媒体一等节点、描述即语义记忆、统一多模态空间
- 架构:一条消息进来之后的完整路径图 + 核心组件表
- 数字(**实测值,非估算**):36 外部插件 / 340 工具 / 110k Go 行 / 10 LLM 适配器
- 上手命令、插件徽章墙、6 条 FAQ、页脚(文档/深入/项目/状态)

## 两个设计决定,都有理由
1. **配色取自品牌指南**:蓝/青/金正好对应三层记忆,故三层记忆那节直接用三色做色条。
2. **立绘按「有意的圆角卡面」呈现,不抠图**:原图是白底 + 蓝紫渐变外框,
   而白底与角色的白裙子同色 —— 连通域分析显示 flood-fill 会让 42% 的身体变透明
   (围裙、发丝高光被吃掉)。改为圆角 + 发光边框 + 底部渐隐,方形图与深色页自然衔接。

## 修掉一个真实的可访问性缺陷
初版把 `opacity:0` 写在**默认样式**里、由 IntersectionObserver 加 `.in` 揭示。
实测:30 个 .reveal 元素里 26 个停在不可见 —— **JS 被禁用或报错时整页永久空白**。
改为渐进增强:内容默认可见,仅当 JS 真跑起来才加 `.js-reveal` 接管动画。
复测两种场景均 30/30 可见(正常滚动 + 禁用 JS)。

## 验证(用共享 Chromium 实跑,不只是看代码)
- 控制台错误 0;两张图均加载(logo 400x400、立绘 1024x1024)
- 移动端 390px:无横向溢出,导航折叠,立绘置顶
- FAQ 手风琴展开正常(open=true)
- a11y:图片 alt 齐全、单一 h1、lang=zh-CN、无空文本链接
- 标签配对全 OK;34.6 KB;**无任何外部依赖**(无 CDN/字体/JS 库)
2026-09-19 21:15:00 +08:00
71faf8d9ad docs(readme): 按源码修正 README 的过时事实(中英同步)
上一轮只补了 v1.3.x 变更日志,没系统核对全文。本次逐条对照源码,修掉 5 处硬错误:

1. **消息时序图漏掉输入调度器**(最严重):还画着 `IO->>EV: inputCh` 直连
   eventLoop,而当前输入必须先进调度器。补 participant 与调度阶段
   (两类别+四级中断、同级不排队/更高级抢占、转投分诊助手)。
2. **图里的 `drainInterrupts` 已不存在**:实测该函数在源码中查无此项,
   改为「安全点:中断求值/让位」(真实机制见 scheduler.go)。
3. **内置插件数 11 → 18**:漏列 ai_image / data / localuse / multimodal /
   remotedevice / skillmgr(实测 `ls internal/plugins/` = 18)。
4. **Lua 适配器 8 → 10**:漏列 ollama / server(实测 = 10)。
5. **`agent/api/` 描述错误**:它只有 provider.go,不含 Lua 适配器
   (适配器在 internal/lua/adapters/);改为如实的「provider.go 调 vm」。

另修一处**自相矛盾**:构建章节写「依赖 Linux/Windows」,而下载章节说
homed 已放弃 Windows 原生(`package-windows.sh` 明确「不往 Windows 装 homed」,
只建 waiter.exe + 引导 WSL2)。改为「依赖 Linux」并说明 Windows/macOS 的真实边界。

并给「设计要点」补上两个当前核心机制(此前只有域分离与三层记忆):
输入调度(两类别+四级中断)与驻留子/分诊助手。

验证:全仓文档断链 0;6 个 mermaid 图块配对全 OK;上述数字逐条实测复核。
2026-09-19 20:59:42 +08:00
c0274b71d5 docs: 删除迁移期临时文档,现行内容搬进正式文档
用户指出迁移评估那批是**过程性临时文档**,迁移已完成就该退场。

## 删除(38 个文件)
- docs/zh/架构迁移评估.md(1621 行)—— 评估稿。开头的「现网正在发生的问题」
  (output_send 永远成功 / cgo 超时泄漏 26 次 / stage 污染)**全部已修复**,
  留着是误导性告警。其 §三「目标架构」已被 ARCHITECTURE.md 完整覆盖
  (且后者更细,含子进程生命周期管理)。
- docs/zh/plugin-interface-matrix.md(428 行)—— 迁移基线矩阵。
- docs/zh/experiments/(36 文件)—— 18 项可行性实验,验证的是"该不该迁移",
  迁移早已完成;实测无任何构建/测试依赖它。

## 现行内容先搬走(不能随临时文档一起丢)
- plugin-interface-matrix §九「接口扩展规则」→ 搬进 docs/git-branching.md 新增 §八
  (只增不减/签名不改、新增必须"插件调用内核实现"方向、hmapdev 模板必须同步接线
  否则全体插件编译失败、"接口纯追加"≠"无需重编"、合回 main 的同步清单)。
- git-branching §六 原写「接口冻结是合回门禁」—— 冻结是**迁移期**约束,v1.1.x 起
  已到期,改为标注失效并指向 §八。

## 引用清理
8 处引用全部改指现行文档:plan.md ×3、两篇设计文档各 ×1、
4 处源码注释(proc/shm.go、proc/process.go、dynamic_proc.go、entry_dispatch_test.go、
proc/bench_test.go)。仅 third_party(SDK 独立仓)保留 1 处,不动。

## 验证
- `go build ./...` 通过;`go test ./internal/plugin/...` 两个包全绿
- 本项目文档**断链 0**(另 2 处断链在 oh_modules 第三方依赖内)
2026-09-19 19:22:48 +08:00
7213edd181 docs: 全面按当前源码更新文档 + 删除已过时文档
## 删除(内容已落地/已被替换,保留只会误导)
- demo.md ................... failback 与 recoverydiag 均已实现,0 引用
- docs/defect-qq-output-send-loop.md .. 已修复(本身也标了「已修复」),0 引用
- docs/embedding-comparison.md ....... 一次性选型报告,仅被 agent 产物引用
- docs/zh/plan.md ............ 描述的旧 nav 布局已重写、死配置已清,全部完成
- docs/zh/plugin-migration-plan.md ... 迁移已上生产,纯过程稿(Part 0~6 全完成)

## 更新(按当前源码核对)
- assets/docs/{zh,en}/ARCHITECTURE.md(README 指向的用户文档,最重要):
  把只讲 cancel/intercept 的旧「中断机制」章节重写为「输入调度器与中断机制」——
  补上两类别 + 四级中断(L1~L4,默认 L1、外部插件 L4 夹到 L3)+ 抢占/挂起/中断栈
  + 饥饿防护(PreemptCount 提升,封顶 L4)+ 抢占冷却(2s)+ 停止语义(cancelBudget)
  + 驻留子/分诊助手/残余任务;新增「上下文预算」章节(窗口 ≠ 工作面,600K 封顶,
  预算是上限非填充目标)。中英章节数现已对齐(各 13 节)。
- assets/docs/{zh,en}/PLUGIN_DEV.md:插件示例表补 6 个缺失项
  (acp/deepsearch/plugindev/recoverydiag/vanblog/vikunja);qq 工具数 17 → 20(实测)。
- README.md / README_EN.md:补 v1.3.x 线(此前只到 v1.2.0,而 1.3.x 已发布 12 个 patch)——
  驻留式子 agent、输出通道寻址、输入调度器、轻量内核 profile、积压及时反馈。
- plan.md:开头两个「⚠️ 紧急/正在持续污染」是过期告警(实测  残留 = 0),
  改为「已解决」并加文档定位说明;§13 仍是活跃路线图故保留。
- docs/zh/plugin-interface-matrix.md + 两处源码注释:清理指向已删文档的断链。

全仓 md 断链检查:仅剩 1 处,位于 third_party 的 oh_modules(第三方依赖,非本项目)。
2026-09-19 19:14:55 +08:00
8acd3ce1a8 fix(offload): 内核说明不能被再转投(自我循环)+ offload_owned 未接线
★ 线上实测两个缺陷:

1. **自我循环**:转投会在队列留一条 [系统] 说明(source=kernel),
   而转投条件把这条说明也算进「积压够了」⇒ 每次转投都产生下一轮要转投的东西。
   实测 5 秒内连续触发两次,分诊助手不断收到「N 条积压已转投」这类噪音。
   修法:takeQueuedInputs 排除 isKernelNotice(source=kernel)。

2. **offload_owned 永远为 false**:我加了 ResidentInfo 字段、加了状态面映射,
   却漏了在 rc.info() 里赋值 ⇒ 线上转投子明明存在,读出来是 null。
   这类「加了字段但没接线」不会报错,只会让父的判断悄悄失效
   (父据此决定回收策略,读到 false 就会把临时助手当成正式子)。

测试 +3:说明不转投 / 循环必须终止 / offload_owned 会被上报。
前两条已实测「禁用守卫会失败、恢复后通过」,是真回归测试。
2026-09-19 17:47:44 +08:00
943eef01cf feat(resident): 分诊助手定位 + 残余任务由父显式决定
用户澄清(重要定性):这不是「内核替父决定」,而是**及时反馈** ——
主 agent 忙时不该让用户干等十几分钟。子 agent 是**分诊助手**:
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」、不勉强作答。

三处补齐:

1. 分诊助手的职责提示词(之前完全没给 ⇒ 子不知道自己为什么存在):
   两条路(直接办 / 报忙碌)、拿不准时报忙碌、必须 output_send 到原通道。
2. 驻留子继承父的 SystemPrompt(之前没传 ⇒ 子只用一句兜底文案,
   拿不到「异步通道必须显式 output_send,否则回复被静默丢弃」这条铁律。
   webui 这类同步通道能回是因为走 ResponseCh,掩盖了这个缺陷)。
3. 残余任务由父显式决定(用户要求):reclaim/destroy 时子手头未处理的消息
   不再由内核悄悄处置 —— 内核只负责列清楚,父用 residual=keep/drop 决定。
   之前 pendingEvents 只收带 ResponseCh 的,异步(qq)残余任务完全不在内,
   被销毁时静默消失、用户零反馈且日志无痕。

配套:
- scheduler.takeAllPendingEvents:取走全部未执行事件(不筛通道)
- ApplyResidual(keep|drop):keep 转回父队列(保留 ResponseCh),
  drop 逐条记日志 + 给同步调用方补终态(否则 cli/a2a 永久挂起)
- 状态面暴露 offload_owned,让父分清「我建的子」与「内核临时拉的助手」
- 工具 schema 加 residual 参数并说明 drop 的代价

这也是用户观察到的「机制很自然」的落点:分诊助手就在同一张登记表里,
父能 inspect/send/compress/reclaim/destroy,控制面 6 动作按 id 生效不区分来源。

测试 +7(残余 keep 转回且保留 ResponseCh / drop 通知同步调用方 /
空残余如实报告 / 分诊提示词 / 继承 SystemPrompt),
其中 drop 那条已实测「对着静默丢弃的旧实现会失败」。全套绿。
2026-09-19 17:43:16 +08:00
01909bb914 fix(offload): 转投必须保留 ResponseCh,否则同步调用方永久挂起
线上实测第二个 bug:转投生效、子也正常处理(日志各 ~3s),但 webui 的 HTTP
请求一直挂着不返回,最终 504。

根因:第一版用 InjectInputTo 转发,它会**重建** InputEvent ⇒ ResponseCh 被丢掉。
而 cli / a2a / webui 这类**同步**调用方正阻塞等这个 channel。
仓库反复警告过同一件事(Agent.Stop 的注释:「带 ResponseCh 的同步注入方
(cli / clawhubadapter 均无超时)会永久挂起」)。

修法:改走既有的跨 agent 投递原语 DeliverRouted —— 它推**原事件**,保留
ResponseCh/RequestID,只往 payload 里补转投标注。

回归测试 TestForwardKeepsResponseCh 断言**最强的那条性质**:真的等同步回执回来。
(不用「读子的 InputChan」来断言:SpawnResident 会启动子自己的调度循环,
它会与测试抢同一个 channel,那样写出来的测试是 flaky 的 —— 我第一版就是这样,
实测挂死过一次。)
已实测该测试对着错误实现会失败(10s 超时)、修后通过。
2026-09-19 17:16:24 +08:00
fe1d2672d8 fix(offload): 积压可能全堵在 io 输入 channel,不在就绪队列
线上实测发现上一版**永不触发**:主 agent 跑着 6×45s 的长任务、我连发 4 条消息,
scheduler 始终显示 queue=0、residents=0,转投一次都没发生。

根因:schedulerLoop 是**同步执行**任务的,所以「正忙」期间它根本回不到循环顶部
去调 pumpInbox —— 后到的输入全堆在 io.inputCh(容量 256)里,压根没进 sched.queue。
而 takeQueuedInputs 只看 s.queue ⇒ 恒取不到东西。

★ 仓库里早记过同一个坑:armStop 的注释写着「pending 是还没被 pumpInbox 搬进队列
的那一段……只数 s.queue 会得到 0(实测),配额随之失效」。我重犯了它。

修法:转投前先 drainInboxToQueue() 把 channel 里的输入搬进队列。
与 pumpInbox 的区别是**不要求 hasRoom** —— pumpInbox 满时会停下保留背压,
而转投场景恰恰是「队列空、输入堵在 channel」(调度器回不到 pumpInbox)。
队列上限仍由 enqueue 把关,放不下的给同步调用方 skipped 终态(不丢、不阻塞)。

回归测试 TestOffloadSeesInputsStuckInChannel 精确复现该现场状态:
已实测它对着修复前的逻辑**会失败**(期望 3 实际 0),修后通过 —— 是真回归测试。
2026-09-19 17:05:38 +08:00
69446a2649 feat(scheduler): 主 agent 忙时把积压任务自动转投给驻留子
问题(2026-09-19 线上实测):主 agent 被长任务占住时(现场:12 分 8 秒、69 次
工具调用),后来到达的消息全部以 level insufficient 排进中断队列干等 —— 同级
中断不能抢占同级运行任务(canPreempt),只能等前一个跑完。而内核本有驻留子
(独立 agent + 独立调度器)可并行干活。

行为(用户 2026-09-19 明确要求):
- 触发:运行任务持续 > offload_busy_after(5m) 且积压 >= offload_min_pending(3)
- 拉起/复用「转投专用」驻留子,把积压的纯排队输入转投过去
- 在原队列位置留下说明「[系统] N 条积压任务已转投给驻留子 agent X 处理…」

通道配置(按用户口径,与人工创建的子刻意不同):
- 不配 inputch(内核的干活 agent,不接收插件用户输入)
- 持有全部输出通道(结果要能发回 qq/webui 等正确通道)

三个设计要点(都是实测撞出来的,写进代码注释与设计文档 §7.1):
1. 检查必须在**独立 goroutine**:schedulerLoop 同步执行任务,放它里面在
   「正忙」期间根本回不到循环顶部 ⇒ 永不触发(我第一版就写错了,测试才发现)。
2. 只转投 TaskQueued 纯排队输入:中断任务带级别语义、self 任务与父的记忆面绑定。
3. 转投失败/关闭时必须把任务**放回队列前端**:吞一条输入比多处理一条更糟。

这是设计 §7「决策在父的模型手里」的**刻意例外**(父正忙、物理上无法决策,
而积压任务本来就是空的),已在文档中显式记录,且默认关闭、由部署方显式打开。

测试 11 条:只取排队输入 / 不足量不取 / 放回不丢任务 / 说明自解释 / 默认关闭 /
空闲不触发 / 端到端转投 / 上限不增殖 / 独立 goroutine 确实会触发。
2026-09-19 16:59:47 +08:00
e273924511 fix(llm): 参数无法解析时给出真因,不再静默丢弃整条调用
★ 上次修复误判了成因。真实根因(本次运行日志 34/34 同形):
    {"command": "…完好的长命令…", "timeout": 20s}
  command 一字节没错,只是 timeout 值少了引号 —— cmd_run 的 schema 把 timeout
  声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
  finish_reason=length 出现 0 次 ⇒ 上次那条"截断"分支从不生效。

旧行为把**整个参数**丢掉,模型只看到 "command is required",看不出坏在 timeout,
只能原样重试。实测本次运行 cmd_run 失败率 35%(34 败 / 71 成),
12 分钟的任务里更是 48% 时间耗在这上面 —— 每次失败都付一次完整 LLM 往返。

三处改动:
1. repairToolArgsJSON:解析失败时先试窄修复 —— 只给"值位置上未加引号的带单位
   数字"补引号,且修完必须真能解析成功才接受。不碰合法 JSON、不动正文里的 20s、
   不会把真截断"修好"。
2. 修复仍失败时不再静默降级成空 map,改为带 __arg_error 交给模型,并按成因
   分流文案:截断→拆小参数;JSON 写坏→提醒带单位的值要加引号。
3. 统一键名 __arg_error(原 __truncated_error 只覆盖截断,语义过窄)。

同一缺陷面不止 cmd:agentcli/healthcheck/timer 都有 string 类型却以
"5m, 1h" 作示例的参数,此修复一并覆盖。

回归测试:真实日志样本修复、保守性(不碰合法/正文/截断)、
端到端(修复后 timeout 仍能被 time.ParseDuration 接受)。
2026-09-19 16:49:16 +08:00
53e7106985 fix(llm): 参数解析失败不再丢弃完好字段(改错值格式,不是截断)
★ 上次修复误判了成因。真实根因(日志 11/11 同形):
    {"command": "…完好的长命令…", "timeout": 20s}
  command 一字节没错,只是 timeout 值少了引号 —— 而 cmd_run 的 schema 把
  timeout 声明成 string、示例写着 "10s, 1m, 30s",模型照抄格式却忘了引号。
  实测 finish_reason=length 出现 0 次,所以上次那条"截断"分支从不生效。

旧行为把**整个参数**丢掉:模型只看到 "command is required",看不出是 timeout
写坏了,只能原样重试 —— 12 分钟的任务里 30 次失败 / 32 次成功(48% 浪费),
每次失败都付一次完整 LLM 往返。

改法:parseToolArgsJSON 失败时先试 repairToolArgsJSON,只做一件很窄的事 ——
给"值位置上未加引号的带单位数字"补引号,且修完必须真能解析成功才接受。
因此不会改坏合法 JSON、不会动字符串正文里的 20s、不会把真截断"修好"。

真实日志样本 + 保守性 + 反伪造三组回归测试已钉死。
2026-09-19 16:47:13 +08:00
6ddef5e49f feat(llm): 声明真实上下文窗口 + 工作区间与窗口分离
问题:core.llm.model=AUTO,而 ModelContextWindow("auto") 匹配不到任何分支、
掉进 default 32768 —— 该源真实窗口是 1M(实测 990,034 token 的 prompt 通过),
内核却按小 30 倍的窗口算全部预算。

三处改动:
1. ModelContextWindow 补 deepseek-v4/v3 → 1M;推断不出时打日志(静默降级是
   这次问题的成因,不能再默默退回一个小值)。
2. sourceFieldDefs 补 context_window 声明:它早已被 readInt 读进 LLMSource
   并透传到 provider,但没进这张表 ⇒ WebUI 里看不见也改不了。
3. ComputeTokenBudget 新增 maxTargetTokens=600000:窗口 1M 不等于按 838K
   (80%)干活。标称窗口≠有效窗口,600K 是该源最优工作区间,所以把
   「窗口上限」(会不会被上游拒)与「工作区间」(预算分配)分开。

配套 core.llm.max_tokens 4096→32768(实测长输出样本达 18,272 token,
16384 仍会截断;上限是 cap 不是目标,短问答零成本)。

测试:tokenbudget_test.go 钉死封顶生效且小窗口不受影响;
context_window_test.go 钉死推断值与显式声明优先级。
2026-09-19 14:24:31 +08:00
95292aff8e test(llm): 钉死截断必须短路工具分派
补一条端到端断言:截断的 tool call 绝不能拿着空 map 走到 files_write
(那会回 'path is required',模型据此原样重试)。断言 executeToolCallInner
的短路 + 指引可执行。
2026-09-19 13:53:56 +08:00
2722d76095 fix(llm): max_tokens 截断不再静默降级成空参数
长参数工具调用(整段脚本/大 JSON)被 core.llm.max_tokens 从中间切断时,
上游回 finish_reason=length,而旧实现把这个信号整个丢掉:残缺 JSON 解析失败
后静默降级成空 map,工具只看到参数为空并报 'path is required'。模型因此完全
看不出真因,原样重试四遍、次次撞同一堵墙(2026-09-19 实测 4 次 files_write 失败)。

注:files_read 并未失败——是写挂之后模型反复重写把读卷进同一轮,看起来像两者都报错。

改法:
- finish_reason=length 时不再静默降级,改为塞入 __truncated_error 指引,
  告诉模型「参数被截断 + 请拆成多次调用/追加写 + 勿原样重试」;
- executeToolCallInner 见到该标记即短路,不拿空参数去调工具;
- 非截断的残缺 JSON 保持旧行为(避免把「厂商不回 finish_reason」误判成截断)。

回归测试 2 条钉死这两面。
2026-09-19 13:49:21 +08:00
01113664b4 fix(obs): 抢占日志改在判决点打(修自伤)+ scheduler 事件接进 SSE
## 修我上一版的自伤

上一版把 preempt 日志打在 `executeNewTask`,但那时 `nextRef` 已经把
`s.running` 换成了抢占者自己,于是输出成了

    preempt start: task#2 ... -> victim task#2 (cli)

victim 打印的是入侵者本人。判据必须落在 `registerInterrupt`——那一刻
running 还是真正的受害者。改为在抢占判决点打:

    [agent] preempt: task#2 class=interrupt level=3 from cli (L3) preempts task#1 class=queued level=0 (qq)

## 补上「入队而非抢占」的日志

中断到了却没生效,此前完全不可解释。现在两种成因分开写:

    [agent] interrupt queued: ... vs ... (qq) — running in critical section; queue=N
    [agent] interrupt queued: ... vs ... (qq) — preempt cooldown; queue=N
    [agent] interrupt queued: ... vs ... (qq) — level insufficient; queue=N

没有这条,`interrupt from X` 打过之后任务为什么没让位就只能猜。

## scheduler 事件接进 SSE

`EventScheduler` 此前既不在 `handler_chat.go` 的 subTypes、也没有任何订阅者
(全仓 grep 零命中)——内核里 suspend/resume 只 publishEvent,于是事件发出来
就掉地上,对内对外都不可见。加进 subTypes 后前端/客户端能看到抢占链。

## 验证

- preempt_logging_test.go 增一条:victim 与入侵者必须是不同来源(qq vs cli),
  且排队输入对排队任务 `canPreempt` 必为假。
- 实测输出含 `cli (L3) preempts task#1 class=queued level=0 (qq)`。
- 全量 `go test ./internal/... ./cmd/...` 与 `go vet ./internal/...` 全绿。
2026-09-19 11:57:50 +08:00
b1ec278136 feat(obs): 抢占日志说出「受害者是谁」——suspend/resume 此前完全不落日志
排查「我的任务怎么被莫名打断了」时撞上的观测缺口。

## 缺口

`executeNewTask` 里挂起、`resumeTask` 里恢复,两处都**只发事件、不写日志**:

    a.sched.suspend(t, f)
    a.publishEvent(events.EventScheduler, map[string]any{"action": "suspend", ...})

于是生产日志里只有两行:`interrupt from X` 与 `LLM request cancelled by
preemption` —— **看不到受害者是谁、被谁挤下去、后来有没有恢复**。后果是实测过的:
按时间先后猜凶手,把时间上相邻的输入误认成抢占者。

## 改动

- `sourceOf(task, frame)`:取可辨识来源(`evt.Source` 优先,回退 OutputChannel,
  自循环任务给 `self:<channel>`)。取 Source 而**不是** OutputChannel:
  前者回答「谁送来的」(qq / homeagent-mail-bridge / timer / child/xxx),
  后者只回答投递到哪个通道;多数场景同名,但因果链上要的是前者。
- `describeTask(task)`:`task#N class=queued|interrupt level=L`。
- `suspendDepth()`:日志专用,走锁而不是让日志点直接摸 `suspendStack`。
- 三个日志点:抢占开始(含 victim)、挂起(含来源与栈深)、恢复。

输出形状:

    [agent] preempt start: task#2 class=interrupt level=4 from cli -> victim task#1 class=queued level=0 (qq)
    [agent] suspend: task#1 class=queued level=0 (qq) yields to an interrupt; suspendStack=0
    [agent] resume: task#1 class=queued level=0 (qq) resumes after the interrupt finished

## 验证

- 新增 preempt_logging_test.go:抢占后栈深 0→1、sourceOf 取到 qq、self/nil 不 panic。
- 实测日志(TestPreempt_HigherPreemptsAndResumes)三条齐全,能一眼看出
  是 `cli` 的 L4 挤掉了 `qq` 的排队任务、随后 qq 恢复。
- `go test ./internal/... ./cmd/...` 全绿。
2026-09-19 11:31:04 +08:00
d202f2ceec feat(ohos): screensue 改用 Web 组件渲染 HTML(RichText 撑不住)
用户实测截图:推送内容把整段 HTML 源码当字符串显示(含 <style>、@keyframes、
内联 <svg>、radial-gradient)。RichText 只认极小标签子集,这些一律不渲染。
用户明确要求「引入 webview」。

## 修法

- 新增 `common/ScreensueHtml.ets`(从 BridgeCaps 抽出:后者加进 HTML 逻辑后
  超 520 行,越了工程「单文件 ≤400 行」的约定;且「screensue 怎么解析/渲染」与
  「设备能力怎么实现」本是两件事)。
- `ScreensuePage.ets`:HTML 走 **Web**,纯文本仍走 Text。
- 加载用 `loadData(base64)`:encoding 非 base64 时按 URL 规则转义,几 KB 的
  完整文档会撞长度/转义问题。自写 `base64Utf8`(UTF-8 手编字节,含代理对合成)
  —— 直接把 UTF-16 码元交给 Base64Helper 会让中文变乱码。
- 非完整文档补一层 shell(meta viewport + 主题前景色),完整文档原样加载。

## 顺带修掉一个真实缺陷(实测发现)

`looksLikeHtml` 旧判据要求「首个非空字符就是 '<'」。而 agent 传参常把整段文档
连引号一起给(`'<html>…'`)——截图里那个孤立的 `'` 就是这么来的,判据因此
**判否并退回纯文本**,所以看到的是源码。改为扫第一个「像标签开头」的 '<'
(跳过引号/前导文字),且只在其后紧跟字母或 '/' 时才算,避免误判 "a < b"。
Node 复刻同一算法验证了 7 个样例(含截图实况、<3 表情、比较符)。

## 安全(我因为引入 WebView 而必须自己把关)

内容来自 agent(第三方)。显式关闭:
`javaScriptAccess(false)` / `fileAccess(false)` / `domStorageAccess(false)` /
`onlineImageAccess(false)` / `zoomAccess(false)`。
★ **javaScriptAccess 的默认值是 true** —— 不显式关掉等于让远端内容在客户端执行脚本。
已实测取证:推入带 `<script>` 与 `<img onerror>` 的页面,屏上稳定显示 `JS-OFF`,
两条执行路径都没跑起来。

## 另一个实测发现的缺陷

`onControllerAttached` 只在挂载时触发一次,而 ScreensuePage 在 `if (visible)`
里常驻 —— 连续两条 screensue 只改 @Prop、组件不重建,Web 一直显示**上一条**
内容(实测:倒计时变成 33s 但画面还是旧 HTML)。改 `@Prop @Watch('onDataChanged')`
显式重载,并记录 attached 状态避免过早 loadData(会抛 17100001)。

## 验证(模拟器真机链路)

把工程内连接指向本机服务、开启「允许 agent 控制本机」授权,经
`device_ctl_cmdrun` → 设备桥 → screensue 推入用户截图里那段原样 HTML:
- 渲染成功:radial-gradient 背景、内联 SVG 兔子、CSS 发光文字、两行文案
  (修复前同一段内容显示为满屏标签源码)
- 连推第二条 → 画面正确刷新为 SECOND PUSH
- JS 探测 → JS-OFF(脚本被拦)

模拟器只能装 unsigned 包(signed 报 READ_PASTEBOARD 授权失败,与既有记录一致)。
2026-09-18 12:01:33 +08:00
895948b24e fix(stop): 配额须计入停在输入 channel 的待处理消息
实测:停止后排队消息仍逐条跑完。原因是 armStop 只数 sched.queue,
而用户按下停止时调度器正忙于当前任务,其余消息大多还没被 pumpInbox
搬进队列、仍停在 inputCh ⇒ queued=0、配额归零。

- IOManager.PendingInputs():暴露 channel 中待处理条数。
- armStop(pending int):queued = len(s.queue) + pending。
- 补 TestStop_ArmCountsPendingChannelInputs 锁死该口径。
2026-09-18 11:39:04 +08:00
698ff7ddd5 fix(stop): 修自伤——interceptLoop 里 takeStop() 被 || 短路提前消费
上一版把 stop 标记在 interceptLoop 里消费掉了一部分:
`if n := armStop(); n > 0 || takeStop() { ... }`,queued=0 时短路到
takeStop(),标记先被吃掉,stepLLM 永远看不到 → 取消后照样重跑一轮。
实测:日志正确打出 stop requested ... queued=0,但生成仍跑到自然结束(5000+ 字全文落库)。

改为只 arm 不 take(takeStop 只由 stepLLM 消费),并补一条**经真实
interceptLoop** 的用例:它必须 a.Start()(第一版测试只调 New(),
interceptLoop 根本没跑,假绿)。已验证该用例在注入此 bug 时失败、修复后通过。
2026-09-18 11:32:20 +08:00
ccc2ac2d4d fix(stop): 停止按钮真正生效——停止 ≠ 空中断;鸿蒙 screensue 支持 HTML
两处鸿蒙端缺陷 + 一个跨端(WebUI/GUI/鸿蒙)的停止语义缺陷。

## 症状(实测取证)

1. **鸿蒙终止按钮按下没反应**。POST /chat/interrupt 带空 body,接口回 200
   `{"status":"interrupted"}`,但 journalctl 零中断日志、生成继续跑到自然结束。
2. **鸿蒙 screensue 不解析 HTML**,把标签当普通字符串显示。

## 根因

停止按钮走的是「空内容中断」,而 interceptLoop 有一行
`if text == "" { continue }` —— 空内容被判为「无事发生」直接丢弃。
所以停止指令从未到达调度器;接口那个 200 是不诚实的。

另查明两条会放大症状的既有问题(停止后仍在跑):
- `chatStreamWithFallback`:流式连接失败时无条件回退非流式 `Chat`。
  上下文已取消时这等于**再发一次完整请求**(停止后模型继续生成)。
- `stepLLM`:`context.Canceled` 一律 `outcomeContinue` 重跑本步。
  这是给「被更高中断抢占」用的(现场要交出去、稍后继续),
  但用户按停止是「不要了」,重跑就是停止没生效。

## 修法(按用户明确的设计)

停止 = ①立即结束当前 LLM 推理(不重试、不恢复);
②对**停止那一刻已排队**的 x 条消息,后续在 pre-action 阶段依次短路。

- scheduler:新增 `armStop`(登记快照配额并返回当时排队深度)/`takeStop`/
  `consumeCancel`。配额取快照值(停止后新到的输入不受影响),
  重复按停止取 max 不累加(两个客户端同时按不该翻倍)。
- `interceptLoop`:读 `stop` 标记。停止时 armStop + cancelCurrentLLM;
  **纯停止不再进中断队列**(旧实现把它当空中断入队,所以停完还会活)。
  带注释的停止(`/stop 换个话题`)仍走中断路径。
- `stepLLM`:取消 + `takeStop()` → 直接 `outcomeDone`(不再重跑)。
- `stepPrepare`:`consumeCancel()` 命中即在 pre-action 短路收尾。
- `chatStreamWithFallback`:以 **ctx.Err()** 为判据拒绝回退(不是「错误是不是
  Canceled」——很多 provider 用 Canceled 表示「不支持流式」,那种必须继续回退,
  否则会把探测误判成取消;这条区分是跑全量测试时才暴露的)。
- WebUI handler / CLI `/stop`:空消息时带 `stop:true`。

## 鸿蒙端

- `BridgeCaps.ets`:新增 `looksLikeHtml`(首字符 '<' + 字母开头标签名,
  避免误判 "<3" 这类文本)、`screensueHtml`、`escapeHtmlText`。
- `ScreensuePage.ets`:HTML 走 **RichText**(只解析 HTML 子集、无脚本无网络),
  纯文本仍走 Text。不用 Web 组件:agent 下发的是第三方内容,
  Web 默认带 javaScriptAccess/fileAccess,等于让远端内容在客户端执行脚本。
  注入主题前景色,避免 RichText 用系统默认色导致深色主题下黑字不可见。
- `ChatSession.ets`:`interruptChat` 改发 `{stop:true}`(含类型声明,
  ArkTS 禁止无类型对象字面量),并在本地即时复位忙态 + 提示「已停止」。

## 验证

- 新增 `stop_semantics_test.go`:停止终结任务不重试(provider 调用次数恒为 1)、
  配额是快照(x 条短路、随后新到的不受影响)、重复 arm 取 max。
- `go test ./internal/... ./cmd/...` 全绿。
- 鸿蒙 HAP 构建通过;unsigned 包已装进模拟器(signed 包受
  READ_PASTEBOARD 授权限制装不上,与既有记录一致)。
2026-09-18 11:26:12 +08:00
831bd2290b fix(ohos): 聊天改用 seq/after 增量轮询,修「不滚动/己方消息不显示/新消息不加载」
jianf 报的三个现象其实同一个根因:WebUI 前端在 commit 9711177 已改为
「暴露数据查询 API + 前端轮询 patch 视图」,聊天记录走 /chat/history?after=<seq>
增量游标 + seq 对账;鸿蒙端一直只做**首屏全量加载**,没跟上这套口径。

1) 页面不加载新的聊天信息(根因)
   鸿蒙只在 aboutToAppear 拉一次 /chat/history?limit=40,之后除了 SSE 就没有
   任何拉取。SSE 只在「本端发起的那一轮」推事件,其他端/其他渠道(QQ、
   WebUI 浏览器)发来的消息永远不会出现在鸿蒙页面上。线上实测:鸿蒙
   IP(61.54.104.206, auth=api-key)最后一次请求停在 9/17 22:34,此后
   只有浏览器 session 在轮询。

2) App 发出的消息不显示
   原来靠 mergeHistoryWithLocal 按「正文内容」去重:本地乐观 user 消息
   与服务端回显内容一致就被判重复丢弃;同一句话发两次同样误删。
   改为按 seq 对账(新增 reconcileServerMsgs,对齐 WebUI applyServerMessages):
   服务端带 seq → 有则原地更新、无则认领本地无 seq 的同类乐观消息;
   旧后端无 seq 才退化为内容比对。

3) 加载完不滚动、停在最新消息处
   @Watch 只在值**变化**时触发,不触发初始值。ChatPage.aboutToAppear 里
   loadHistory() 是异步的,若它在 ChatStream 构造之前就完成,
   requestScroll 递增的 chatScrollRev 就成了「挂载前已发生的变化」——
   onScrollReq 永不被调,于是停在顶部/中间。
   修:ChatStream.aboutToAppear 见已有消息就自己滚一次;scrollToBottom
   的重试从 50/260ms 扩到 50..800ms(长历史布局慢,两次不够),
   并用世代号作废旧一轮定时器,避免与新滚动打架。

配套:
- Model.ChatMessage 加 seq;ChatHistory 解析 seq 与 last_seq(游标缺失时
  回退本页最大 seq,保证不倒退)。
- 新增 pollIncremental():after 增量 + limit=1 尾部探测(工具卡/最终文本是
  原地改写已有 seq,不产生新 seq,只靠 after 拿不到),3s 节奏与 WebUI 一致。
- startPolling 挂在 connect() 而非 loadHistory 末尾:首屏失败也能自愈。
- disconnect() 停轮询。

验证:hvigor assembleHap BUILD SUCCESSFUL;make check-client-versions 一致;
go build/vet/test 全量零失败。
2026-09-18 10:49:30 +08:00
1f5af1dccf feat(cli,ohos): 补齐两处能力缺口——CLI /memory context|tools、鸿蒙 camerasue 录像回传
核对「CLI 与 WebUI 插件能力对齐」时发现两个此前遗漏的缺口,一并补齐。

1) CLI /memory 缺 context 与 tools(internal/plugins/cli/plugin.go)
   WebUI 有 GET /api/v1/memory/context 与 /api/v1/memory/tools,CLI 只有
   query/graph/text。IndexerAPI 本就对内部插件开放(BuildContext/
   FormatContext/GetToolDefinitions/BuildToolPrompt),不是 SDK 缺口。
   补上后与 WebUI 同源:context 打印「实际会注入什么上下文」,
   tools 打印工具定义 + 工具提示词。
   waiter 同步接上两条远端路由与 help 文案。

2) 鸿蒙 camerasue 录像(BridgeCaps/BridgeRouter/DeviceBridge.ets)
   此前 `camerasue <N秒>` 直接返回「暂不支持录像回传」。查 SDK 后发现
   cameraPicker 本身就有 PickerMediaType.VIDEO 与 PickerProfile.videoDuration
   —— 录像完全可行,只是回传通道没接。
   现改为:VIDEO 模式取回 mp4,经 DeviceBridge.sendDataChunked 按
   cmd_data_start/分块/cmd_data_end 回传(与 GUI/CLI 录像路径一致),
   网关聚合后落盘成文件、agent 拿路径;照片仍走小体积 base64 内联。
   上限 64MB、时长 1~300s,超限明确报错而不是把 WS/上下文撑爆。

   依赖方向处理:BridgeCaps 需要「往本请求回传字节」,但 DeviceBridge 为取
   CapResult 已 import BridgeCaps,反向 import 会成环。改为 BridgeRouter
   注入 DataChunkSender 回调(它同时持有 deviceBridge 与 reqId),
   BridgeCaps 不碰 socket。新增 CapResult.chunked 标记「结果已由能力分块
   发完」,DeviceBridge 据此不再回 cmd_result,避免网关把已完成请求与后续
   分块错配。

验证:hvigor assembleHap BUILD SUCCESSFUL(ArkTS 编译通过,改动文件零告警);
make check-client-versions 一致;go vet 干净;全量 go test ./internal/... ./cmd/... 零失败。
2026-09-18 10:04:53 +08:00
9de3b365a6 fix(agentcli): 修 4 个真实缺陷——停机死锁、超时泄漏、僵尸堆积、孙进程逃逸
jianf 提示 agentcli 可能有问题,系统性审了一遍(含 -race 与线上实证),
确认并修复 4 个互相叠加的真实缺陷,每个都配了「去掉修复即失败」的回归测试。

1) 停机/热重载死锁(plugin_stop_test.go)
   Stop() 先 p.wg.Wait() 再 Close 终端,而 readLoop 自己也记在 p.wg 上、
   只监 t.stopCh 不监 p.stopCh。只要有一个终端开着,wg.Wait() 就永不返回。
   后果:插件卸载/热重载(StopAndUnload/ReloadOne)与停机全挂死,且
   registry 持锁时是整个内核一起挂。
   修:先关活跃终端(move 出 map 后在锁外 Close),再 wg.Wait();
   readLoop 顶部加 p.stopCh 探测;Stop() 用 sync.Once 保证幂等。

2) 终端超时后资源全泄漏(plugin_lifecycle_test.go)
   readLoop 的 IsExpired 分支只 delete(sessions) 后 return,既不 Kill 也不
   Close。终端已被移出 sessions,cleanupLoop 也再看不到它,进程/PTY fd/
   reader 协程无人回收。实测:timeout=1s 的 sleep 300 超时后进程仍在跑。
   修:readLoop 加 defer releaseResources(),保证「只要退出就释放」。

3) 子进程从不回收 → <defunct> 僵尸堆积(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 只 Start 从不 Wait。线上实测 homed 名下已有一个
   [sh] <defunct> 僵尸子进程。
   修:linuxPty 加 Wait()(sync.Once 保证只 Wait 一次),
   releaseResources 通过可选接口 Wait() error 调用(Windows ConPTY 不实现则跳过)。

4) Kill 只杀直接子进程,孙进程逃逸(pty_linux.go + plugin_lifecycle_test.go)
   newCommandPty 用 Setsid,sh 是新进程组领头,真正的命令(sleep/vim)是
   其孙进程且同组。只 Kill(sh) 会留下孤儿继续跑。实测:`sleep 300; echo done`
   只杀 leader 后 sleep 仍在(被 init 收养)。
   修:改为 syscall.Kill(-pid, SIGKILL) 杀整个进程组,失败再回落单进程 Kill。

测试设计要点:回归用例必须让「sh 保留为父进程 + 孙进程显式 trap "" HUP」,
否则单个 sleep 会被 sh exec 掉、关 PTY 的 SIGHUP 又会顺手带走孙进程,
两个缺陷都测不出来(这两种情况都实际踩过并修正了用例)。

全量 go test ./internal/... ./cmd/... 通过,agentcli 单包 -race 通过。
2026-09-17 20:24:03 +08:00
ec13eb391a fix(agentcli): 终端退出前补推残留输出,短命令输出不再丢失
线上验证「内核开、两个插件接」时发现的真实缺陷:`echo`、`ls` 这类在首个
200ms ticker 之前就结束的短命令,readLoop 走到 `!terminalRunning(t)` 分支
直接 return,残留在 stream 里的输出从未 flush。

症状:输出只留在 session.buf 里——agent 用 terminal_read 能看到,但
terminal_output 事件永远发不出去,于是内核权威视图(以及 WebUI/CLI 的
/terminals)的 output 恒为空。实测 term_2(echo HELLO_KERNEL_REGISTRY)
在 /terminals 里 output="" 而 agent 同期 terminal_read 拿到了正文。

修法:
- 抽出 flushTermStream(s, t),ticker 与所有退出路径共用同一条推送路径
  (避免以后再出现「某条退出路径忘了 flush」)。
- readLoop 顶部加 defer:defer flushTermStream 后于 defer emitTermState
  声明 → LIFO 下先 flush 再报停止,保证「最后一段输出」先于 running=false
  到达订阅者。

测试:plugin_flush_test.go 新增 TestReadLoopFlushesOutputOnExit——走
EventBus 捕获事件,推入输出后立即让进程退出(远早于 ticker),断言输出
已补推且末态 running=false。已验证去掉修复即 FAIL、加回即 PASS。

注:handleRead(clear=true) 会主动 Reset stream(避免与读取结果重复),
属既有设计;本修复针对的是「未被读取就退出」的路径。
2026-09-17 20:00:47 +08:00
e70d2171ee refactor(terminal): 内核开终端/命令历史权威视图,WebUI 与 CLI 都改接内核
按「内核开,两个插件接」重构终端与命令历史的数据归属。

背景:此前 WebUI 与 CLI 各订 EventToolCall/EventTerminalOutput 攅一份状态,
同一件事两份推导,还各自踩过同一个坑——工具 result 是 Go 的 map 文本
(map[cols:80 ... id:term_2 ...]),断言成 map[string]interface{} 永远失败,
terminal_create 的 id 回填不生效,/terminals 因此恒空(WebUI 也一样)。
实测确认:WebUI 自己的 /api/v1/terminals 与 /api/v1/cmd/history 同样是空的。

内核开(权威唯一真相):
- internal/agent/core/terminal_registry.go:TerminalRegistry 归并两类事件——
  EventToolCall(terminal_create/close、cmd_run,id/command 从 args 或 Go map
  文本回填)与 EventTerminalOutput(agentcli 生命周期 + 输出,含 64KB 缓冲上限、
  100 条命令历史、50 个终端上限)。
- internal/sdk/terminal.go:新增 TerminalAPI(ListTerminals/CmdHistory)与
  TerminalStatus/CmdExecStatus DTO。**不塞进 KernelStatus**:那是全量快照,
  前端每 3 秒轮询 /kernel,背上每终端最多 64KB 输出会让轮询成本爆炸;
  终端输出是按需拉取的明细,另开接口。
- Agent 订阅自己的事件总线(subscribeTerminalRegistry),且**只根 agent 建**
  (驻留子共用同一总线,每个子都建会 N+1 份重复记账)。
- SDKConfig/Registry/bootstrap 接线:pluginReg.SetTerminalAPI(agent)。

生产者补全(agentcli):终端无输出时 ticker 不发事件,内核就无从知道终端
存在。新增 emitTermState,在 handleCreate/handleClose/readLoop 退出(超时/
进程结束/读取错误/stopCh)显式上报 running 状态,并给输出事件补 command 字段。
handleClose 改为接收 *sdk.PluginSDK 以便上报。

两个插件接(消费方):
- WebUI:删掉本地 termStates/cmdHistory/subscribeTerminalStream/handleToolEvent
  及不再使用的 getStr;/terminals 与 /cmd/history 直接读 s.Terminal()。
- CLI:删掉上一轮刚加的 subscribeToolEvents 与 cliTermState/cliCmdExec;
  /terminals 与 /cmd/history 直接读 s.Terminal()。两条路(local/remote)都通。

测试:新增 terminal_registry_test.go,锁死 Go map 文本解析(旧缺陷根因)、
生命周期、CLI 直调路径(无 EventToolCall 仅凭 output 事件建条目)、历史与
终端数量上限。全量 go test ./internal/... ./cmd/... 通过。
2026-09-17 18:56:15 +08:00
3cce605722 feat(cli): /terminals、/cmd/history、/terminal 对齐 WebUI(事件面 + ToolAPI,无需新接口)
去看了一遍源码,纠正上轮判断:终端与命令历史也不是 WebUI 插件私有。
- 终端会话:agentcli 插件持有,发 EventTerminalOutput;WebUI 只是订阅该事件
  自己攒视图。命令历史:WebUI 订阅 EventToolCall 的 cmd_run 攒的。
- 于是 CLI 插件订阅同样两个事件即可同口径:/terminals、/cmd/history。
- 开/写/读/关终端:SDK 的 ToolAPI.ExecuteTool 已允许跨插件调用工具,
  CLI 直接调 agentcli 的 terminal_create/write/read/close,新增 /terminal 子命令。

waiter 侧同步:/terminals、/cmd/history 两条路(local/remote)都接;/terminal
仅在 local 可用(远端 WebUI 无对应 REST 端点,明确提示而不是当聊天发出去)。
2026-09-15 15:31:32 +08:00
0227fc2d4d feat(cli): /persona 与 /agents 对齐 WebUI(复用已开放的 SDK 面)
- /persona:读写 core.agent.personal_prompt / core.internal.persona_initialized;
  插件 SDK 的 Settings() 满足 internal/config.PersonaKV,与 WebUI 同一实现。
  GET 等价返回 initialized/current_prompt/file_override;/persona set <mode> [内容] 写。
- /agents:改用 supervisor.ListAgents()(WebUI /agents 同源),此前只回一个
  agent_id、驻留子信息全丢。
- waiter 侧 /persona 在 local/remote 两条路都接上,/help 补齐。
2026-09-15 15:25:20 +08:00
91c25fa536 feat(cli): /runtime 接上(runtime 本就经 KernelStatus 对内部插件开放)
纠正上一轮的判断:runtime 不是“SDK 未暴露”。s.Status().GetKernelStatus()
里的 Scheduler / Residents / Channels / InputChannels 就是 WebUI /runtime
的数据源,内部插件同样拿得到——CLI 只是漏接了这条命令。

- CLI 插件新增 /runtime,输出与 GET /api/v1/runtime 同口径。
- waiter 侧 /runtime 在 local(发 CLI 插件)与 remote(走 REST)两条路都接上,/help 补齐。
2026-09-15 14:29:29 +08:00
bcaa8f3f31 feat(cli): /plugin install 真正可用(直连 pluginmgr 回环端点,与 WebUI 同实现)
原来 CLI 的 /plugin install 只打印“请去 WebUI”。插件安装逻辑在 pluginmgr
插件里(回环 HTTP,默认 127.0.0.1:9876,无鉴权),WebUI 也是转发到它;
CLI 插件改为直连同一端点,能力对齐。
2026-09-15 12:17:34 +08:00
5333a33e20 feat(cli): CLI 插件能力对齐 WebUI(memory/knowledge/config/tracker/adapters/network)
原来 CLI 插件只覆盖 WebUI 的一小部分:/memory 只有 query、/knowledge 只有
list、没有 config/tracker/adapters/network,结构化输出还各拼一套文本格式。

按 WebUI 的 REST 面对齐:
- /memory query|graph|text [n]     (对应 /memory、/memory/graph、/memory/text)
- /knowledge | delete <name> | stats(对应 GET/DELETE /knowledge)
- /config                          (对应 GET /config)
- /tracker | rollback              (对应 GET /tracker、POST /tracker/rollback)
- /adapters | remove <name>        (对应 GET/DELETE /adapters)
- /network                         (对应 GET /network)
- 统一 writeJSONContent:结构化数据一律缩进 JSON,与 WebUI 同口径。

waiter 侧同步:把上述命令在 local(发 CLI 插件)与 remote(走 REST)两条路
都接上,/help 补齐;两条路语义一致。
2026-09-15 12:14:25 +08:00
95b1950bb3 fix(ohos): 历史刷新不再整表替换,避免刚发出的消息凭空消失
sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争:若历史快照
里还没有这条 user 消息,整表替换会让它消失(“客户端侧发出的消息不显示”)。
改为合并:历史为权威,但保留本地两类消息追加在末尾——
  - user 且 source 为空(乐观消息)且内容未出现在历史里;
  - assistant 且 !isFinal(仍在流式输出)。
2026-09-15 12:09:58 +08:00
307a6faee7 fix(ohos): 设置页插件/工具数不显示 + 聊天自动滚底时机
设置页计数(实测:后端返回 plugins=35/tools=260,卡片却一直显示 '-'):
- 根因是 ArkUI 的 @Builder 按值传参是“快照”语义——父组件因
  @StorageProp 变化重渲染时不会用新值重跑 builder,数值永远停在
  首次渲染的 0。把 KPI 小卡从 @Builder 方法改为独立 @Component
  (@Prop 单向下发),父组件重渲染时子组件拿到新值并重绘。
- 已上模拟器验证:设置页显示 插件 35 / 工具 260。

聊天自动滚动:
- scrollToBottom 原来只在 50ms 后滚一次;长历史/长思考卡的布局在
  消息数组更新后的若干帧才稳定,一次滚动会落在“当时”的底部,
  最后一条被输入区挡住。改为 50ms 与 260ms 各滚一次,480ms 后
  恢复正常滚动态。
2026-09-15 11:56:18 +08:00
baddaf387e merge: 回流场景式关联召回 + 记忆整备 + 客户端修复(feature/recall-policy)
- 场景式关联召回:声明/涌现双通道、场面指纹聚类、场景前缀/相似度召回
- 记忆整备:doc→graph 闸门、噪音/孤立清理、关系去重、原句回显、memoryPass 收敛
- 本轮修复:衰减真半衰期、索引同步基线、Ensence 死参/死分支、冗余索引
- 客户端:鸿蒙未连接连接入口 + camerasue;waiter 斜杠命令本地语义修复
- 版本:GUI/鸿蒙/waiter 与内核统一 1.4.0(make check-client-versions)
- 客户端版本同步文档 §四
2026-09-15 11:13:04 +08:00
15497ee0a2 feat(ohos+waiter): 补 camerasue 能力;修 waiter 本地斜杠命令被当聊天文本
ohos camerasue:
- LOCAL_DEVICE_CAPS 增 camerasue;BridgeRouter 增 camerasue 路由。
- 实现走系统相机选择器 cameraPicker(三方应用无法无界面直驱摄像头),
  结果落应用沙箱(saveUri=filesDir),不写系统媒体库、不需 READ_IMAGEVIDEO。
- 录像(camerasue <N秒>)明确返回“暂不支持录像回传”,而不是回一个超长
  base64 撑爆上下文;二进制分块回传待接线。

waiter 本地/远端命令语义统一:
- 修 bug:/settings、/settings set、/plugin install/remove/info、
  /memory query、/knowledge delete 本地分支发的是 cmd[1:](丢掉前导 /),
  于是 CLI 插件不认、被当成聊天文本丢给 LLM。改为原样发(保留 /)。
- 补 /stop、/interrupt:/help 一直写着但 handleBuiltin 没实现,会落到
  “当普通消息发给 Agent”。远端走 POST /chat/interrupt,本地交给 CLI 插件。
- 补 /plugin disable|enable:远端插件管理 REST 动作,本地 CLI 插件。
- /help 文案改为“local 与 remote 行为一致”并列出新命令。
2026-09-15 11:12:27 +08:00
c151d391ee docs(release): §四 补客户端版本必须与内核同步 + make 门禁 2026-09-15 11:04:31 +08:00
065732f42e chore(version): 客户端版本与内核对齐(唯一事实源 internal/meta.Version)
此前三份版本号互不相干:内核 1.4.0、GUI 1.0.0、鸿蒙 1.1.1。手工各改各的
必然漂移,所以把「对齐」做成机械动作而不是约定:

- deploy/scripts/sync-client-versions.sh:从 internal/meta.Version 读版本,
  同步 cmd/gui/package.json 与鸿蒙 AppScope/app.json5(versionName +
  versionCode=X*1e6+Y*1e3+Z);--check 给 CI/Makefile 做漂移门禁。
- Makefile 新增 sync-client-versions / check-client-versions;build-cli 也注入
  LDFLAGS,waiter 与 homed 同版本。
- 鸿蒙:新增 common/AppVersion.ets,从 bundleManager 读安装包 versionName,
  BridgeProtocol/BridgeCaps 里两处硬编码 '1.1.1' 改为读它——版本只剩
  app.json5 一份,杜绝第二真相。
- waiter:`-version` 打印版本,启动横幅与设备桥 hello 的 version 字段
  直接引用 internal/meta,与内核天然同源。

对齐后:GUI 1.4.0 / 鸿蒙 1.4.0(1004000) / waiter 1.4.0 / 内核 1.4.0。
2026-09-15 11:03:26 +08:00
a9ad97240b fix(ohos): 未连接后端时给出不可错过的连接入口
问题:全新安装(未配置后端)时,聊天页只有空列表+输入框,用户找不到
任何连后端的入口;设置页的连接入口在列表里也容易被略过。

改动:
- 聊天空态按连接状态分流:未连接显示「尚未连接后端服务」+「去设置连接」
  按钮;已连接显示「开始新的对话」。
- 跨页信号(AppStorage:K_HAS_CONN / K_REQUESTED_TAB / K_SETTINGS_SUB):
  按钮 → Index 切到设置 Tab → SettingsPage 直接打开「后端连接」二级页。
- 设置页在未连接时主动把连接表单推到面前:窄屏 onNavigationModeChange(Stack)
  直接 push,宽屏右栏默认页从「运行状态」改为「后端连接」。
- 连接增删改切后广播 K_HAS_CONN,聊天空态即时切换文案与入口。

已在手机(窄屏)与折叠展开(宽屏)模拟器验证:空态按钮可达、点击后
落到带「+ 添加」的连接表单;冷启动点设置 Tab 亦自动打开连接页。
2026-09-15 10:52:48 +08:00
acc94723fd fix(memory): 打通场景/索引/召回残余矛盾点,清理死代码与冗余索引
- DecaySceneRefs 真半衰期:新增 scene_refs.decayed_at 作计时起点,
  每个引用至多每 halfLife 衰减一次。此前只按 created_at 判龄 + 每次
  心跳对半砍,30 天阈值配 60 分钟心跳会在几小时内清空老关联(不是半
  衰期是骤死);时间基准改走 SQLite datetime('now'),不再与 Go 本地
  时间混用。
- Indexer.syncIfStale 基线口径与 Sync 对齐(min(实体数, 全量召回上限)):
  实体数超过上限时原实现永远不相等,每 retrainInterval 全量重训一次。
- EnsureScene 去掉从不使用的 Situation 参数;修正 EnterSceneWithHint /
  resolveTurnScenes / 测试里「声明场景会学整轮指纹」的过时注释(实际
  刻意不学,否则会吃死被动路)。
- SituationFeature.Weight 补 peer_group → wFeatPeer:此前落到 default
  话题级 0.4,群聊身份被降级成软信号。
- RecallBySituation 注释改为与实现一致(相似度只决定命中哪些场景,
  不参与每条关系排序)。
- 移除只被测试使用的 EmergentScenes(SceneStats 已含 origin/strength/
  features,完全覆盖)。
- 清理与 UNIQUE 隐含索引重复的 idx_entity_name / idx_sentences_text。
- 新增 TestSyncIfStaleBaseline / TestPeerGroupWeight,衰减测试补「同一
  半衰期内不重复衰减」用例。

go build/vet 干净,internal/... 全绿。
2026-09-15 10:20:40 +08:00
49695c38f3 feat(memory): 场景双通道——主动声明与被动涌现并存,且互不吞噬
按「声明式的也要支持,相当于主动被动两条路」落实。此前两者只是恰好并存,
没有边界,实测会互相吃掉(下面的坑就是)。

- Triple.Scenes []string(多值):一轮写下的记忆**两条路都挂**。
  只挂一条会丢东西——只挂声明则细粒度唤起丢失,只挂涌现则首次交互
  (场景还没长出来)没有兜底。单值 Scene 保留兼容。
- TurnScene:Primary 用于写(优先涌现场景,首次退到声明场景兜底),
  Keys 是两条路的并集,用于召回(声明+涌动的场景一起进 RecallByScene)。
- EnterSceneWithHint:主动路 EnsureScene(声明即建场景,不等第二次),
  被动路 EnterScene(指纹聚类)。写侧由 executeToolCall 把本轮场景集合
  传给 memory_commit,模型不需要知道"场景"这回事。

踩到并修掉的坑(两条路互相吞噬):
  最初让声明场景也吸收**整轮指纹**,于是 chan:qq 的相似度永远是 1.0,
  把后续所有同类轮次全部吃掉 → 被动路再也长不出更细的场面,
  实测 turn2.Emergent=true 但 Primary 仍是 chan:qq、没有 auto: 场景。
  修法:给场景加 origin(declared/emergent):
  - 被动聚类只认 origin='emergent' 的场景(声明场景不进相似度空间);
  - 声明场景的特征**只从键自身解析**(chan:qq/peer:group_1 → {chan:qq, peer:group_1}),
    白名单 kind(chan/peer/peer_group/tool/topic/part),不猜——
    「老大2026-09-04_12:27_qq私聊图片」里的 12:27 也是 kind:value 形态,
    放进特征空间就是往相似度里灌垃圾(有测试钉住)。
  - 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),机制各归各。
- memgc -scene-stats 增加 [declared|emergent] 与 strength/features 两栏,
  可直接观察两条路各自在长什么。

新增/改写用例:
- TestDeclaredAndEmergentBothLearn:首次交互兜底到声明场景 → 第 2 轮长出
  细粒度涌现场景且**优先用于写入** → 声明场景不进相似度空间(防止压死被动路)
  但仍走声明键取回 → 两条路都进召回集合 → 声明键特征解析与白名单。
- TestEffectiveScenes:多值+单值合并去重保序。

go build/vet 干净,go test -count=1 ./... 全绿。
2026-09-15 09:37:29 +08:00
bb7e7979ae fix(memory): SceneStats 的 strength/features 不再说谎
- 旧行经 ALTER 加列后 strength 为 NULL,直接 SELECT 会显示成 0(实际是 1 次);
  改为 COALESCE(strength,1),并补上 features 计数(场景长出了几个特征)。
- 两栏一起看才能判断「场景是不是真在涌现」,而不是被一次性写出来的。
2026-09-15 09:29:46 +08:00
d4f9a12db0 feat(memory): 场景从「声明」改为「涌现」——场面指纹自己长成场景
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。
那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事,
后续类似场面自动唤起对应记忆」的要求重做。

机制(全部取自运行时可观察量,无需模型配合、无需人工标注):

- **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx /
  tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0),
  工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。
- **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。
  加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让
  一次偶然的话题重合把两个不同场面并成一个。
- **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景
  (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」,
  给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。
- **强化**:场景每次重现 strength+1、并入新特征。
- **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低
  ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。
- **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删;
  已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。

三个必须讲清的边界:
1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度,
   「工具调得多」会被误读成「这个场面更常出现」。
2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚),
   涌现是「这轮看起来像哪个场面」。两者都进召回。
3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景,
   模型不需要知道场景这回事。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例(核心证据):
- TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 →
  同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→
  强度随重现增长、特征多条。全程没有任何人声明过场景键。
- TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被
  自动唤起(含原句),无关场面不唤起。
- TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉,
  仍在重现的场景不受影响。
- TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
2026-09-15 09:27:39 +08:00
b7e47a1b1f feat(rel): 文档层接入场景(document 节点挂场景)
场景贯穿流水线的 doc 层补完:
- 新增 TagSceneDocument,linkBlocksToDocument 里建文档节点时一并挂场景;
- RecallByScene 返回该场景下的文档 id(可枚举「这个场面有哪些文档」);
- 文档场景由**来源派生**(chan:<source>),不存冗余 Doc.Scene 字段——
  存一份会随来源改名而说谎,是同一事实的第二份真相。

go build/vet 干净,go test -count=1 ./internal/memory/ ./internal/agent/core/ 全绿。
2026-09-15 09:16:22 +08:00
9d45cd0277 fix(rel): 场景贯穿流水线到块层 + 编辑不再丢置信度/场景 + 构建默认带 onnxruntime
三件事,前两件是上一轮热部署暴露/遗留的真缺陷。

1) 热部署差点静默降级(已修)
   `make build` 之前**不带任何 tags**,而发行构建(deploy/packaging/build.sh)
   默认 HOMED_TAGS=onnxruntime,package-linux.sh 还会直接拒收非 onnxruntime 二进制。
   实测差异:33MB vs 84MB;启动日志里
   「multimodal space active: provider=chineseclip dim=512」整行消失、
   少加载一个插件(chinese-clip/qwen3vl provider 降级)、
   静态词向量退回 fallback。即「随手 make build」与「发行构建」不是同一个东西,
   而部署时无从察觉。
   修:Makefile 的 build 默认 HOMED_TAGS ?= onnxruntime(与打包脚本一致),
   构建后自动校验二进制里有没有 onnxruntime,缺了就打 WARN。
   生产已按此重新构建部署(v1.4.0+hotfix.d98bf51,已核实 provider 行回归)。

2) memory_edit 每跑一次就静默降级一次(新)
   memory_edit 是「按包含匹配 Purge + 写新三元组」,中间那一步把旧关系的
   置信度、原句、**场景引用**全丢了:置信度被重置成默认 1.0,场景钉死的记忆
   被打散成无场景。而关系复审心跳(reviewLoop)走的正是这条路——每轮复审都
   在无声地削记忆质量。
   修:编辑前用 FindRelations 精确取回旧关系,把置信度/原句/场景带到新三元组;
   新增 ScenesOfRelation。Purge(hard/soft)与 PurgeNoise/PurgeOrphans 之后
   统一清理悬空 scene_refs,SceneStats 不再说谎。

3) 场景贯穿流水线到块层(按「rel 应贯穿整条流水线」的设计)
   此前场景只到 relation/entity:块(L0/L3 一等记忆块)没有场景,于是
   「那场 QQ 对话里发过来的那张图」在场面重现时永远取不回来。
   - MemoryBlock.Scene + memory_blocks.scene 列(幂等 ALTER 迁移)。
   - scene_refs 增加 ref_text 承载字符串主键(块/文档 id 不是数值)。
     **不能只 ALTER ADD COLUMN**:唯一约束要从 (scene_id,kind,ref_id) 变成
     含 ref_text 的四元组,而 ALTER 改不了约束——旧约束会让「同场景第 2 个块」
     直接冲突(只在多块场景暴露)。改为按列探测后整表重建并搬运旧数据。
   - PutMemoryBlocks 同事务挂 scene_refs(kind='block');无场景重写不覆盖已有场景
     (否则一次无场景重写就静默抹掉挂载)。
   - RecallByScene 返回块;FormatContext 增「场景素材」段(模态 + 文本/短 digest),
     上限 3 条。
   - 生产者接线:attachBlocksToSentence 让块继承承载它的三元组的场景;
     linkBlocksToDocument 让文档的块继承文档来源场景(QQ 归档的图挂 chan:qq)。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景块(取回/同场景多块/无场景重写不抹场景/悬空引用清理)、
**旧表结构迁移**(降级成旧 scene_refs 后重开,旧数据保留且多块可写)、
场景素材注入、FindRelations+ScenesOfRelation 编辑搬运闭环。

生产:已重建(-tags onnxruntime)并原子替换 /usr/local/bin/homed + 重启,
35 插件全加载、panic/fatal=0、chineseclip 空间 active。
2026-09-15 09:03:36 +08:00
d98bf512e1 feat(memory): 场景式关联召回——给记忆节点赋场景引用,场面重现即取回
背景(实测):带条件的记忆召不回来。生产库里明明有
「QQ回复禁用Markdown格式 --规定--> 纯文本不用Markdown」「老大 --偏好--> 同左」,
但输入「QQ回复格式」时命中 148 个实体、规则排第 32,注入只取前 5——规则根本没进去;
输入「在吗」这种零内容词的短消息,向量路反而灌进 17 个毫不相关的实体。

根因:词法/向量召回都建立在「字面或语义相似」上,而条件式记忆(在什么场合该怎么做)
约束的是**场面**不是话题。用户措辞不重合时它天然召不回;措辞太宽("QQ")时又被同形
命中淹没。另一处:自动注入只给实体名索引,而规则本体长在关系上(relation_type + object),
即使命中名字也拿不到「纯文本不用Markdown」这句正文。

改动:把「触发条件」升成一等索引维度。

- schema:新增 scenes(key) + scene_refs(scene_id, kind, ref_id, weight),
  kind ∈ relation|entity。刻意不建外键:节点可能先于引用被清理,
  悬空引用由读取侧 JOIN 过滤,级联删除会把清理变成跨表事务。
- 场景键是分层字符串(`/` 分隔,由宽到窄):chan:qq、chan:qq/peer:group_123、
  tool:qq_get_message。NormalizeSceneKey 归一(小写、空白/标点→_、按 `/` 分层),
  空白不算层级——否则「老大2026-09-04 12:27 QQ私聊图片」这种来源名会被拆成伪层级。
- 写入即挂场景:Triple 新增 Scene 字段,commit() 在同一事务里把「关系 + 两端实体」
  挂到场景上(同事务是必须的:关系进库但引用丢了 = 这条记忆永远无声地召不回来)。
- 召回:RecallByScene 前缀匹配(chan:qq 取回 chan:qq 及所有更窄场景;用 `/` 兜底
  防止 chan:qq 吞掉 chan:qq2),按 weight(=写入置信度)降序,返回**关系全文 + 原句**。
- 注入:BuildContextInScene 在词法/向量之外叠加场景路,FormatContext 把场景块排在
  最前(规则对行为的约束强于话题相关的实体名),上限 8 条 + 原句截断 60 字;
  场景实体不在【记忆索引】里重复占位。BuildContext(input) 保持原语义(无场景)。
- 当前场景推导:payload.scene 显式声明 > 通道(chan:qq)> 工具(tool:qq_get_message),
  并列命中不取交集。qq 通道本身 RecallPolicy=none(到达的是中断元文本),
  真正召回在 qq_get_message 工具上——现在那一步同时带上 chan:qq 与 tool:qq_get_message。
- 写入侧:memory_commit 新增 scene 参数(逐条 triples[].scene 优先,顶层 scene 作批次默认);
  docToTriples 按文档来源自动带 chan:<source>(QQ 归档的知识天然属于 QQ 场面)。
  不做自动猜测:猜错的场景会把无关记忆钉死,之后每次进入该场面都被注入。
- 存量引导:memgc -tag-scene <键> -entity-glob <GLOB>。用 GLOB 而非 LIKE——
  LIKE 对 ASCII 不区分大小写,`%QQ%` 会把对象带 /home/newqqagent 的路径类记忆
  (生产数据目录、email-mcp、dify-ops 路径…实测 7 条)一起卷进 QQ 场景。
- 清理对齐:PurgeNoise/PurgeOrphans 之后顺带删悬空场景引用,并提供
  PurgeStaleSceneRefs;memgc -scene-stats 看场景规模。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:场景键归一(含超长/分层/空白)、写入即挂场景(两端实体进、未标的实体不进)、
前缀语义(含 chan:qq2 反例)、weight 排序与 limit、GLOB 存量引导(dry-run 不写库)、
清理后无悬空引用、场景注入面(关系全文+原句+不在索引重复占位)、
agent 侧 sceneKeysFor 优先级(显式声明 > 通道 > 工具、数组形式、nil 安全)。

生产库实测(先 sqlite3 .backup 到 graph.db.bak-20260915-081043 再写):
把 22 条 QQ 相关关系标进 chan:qq(GLOB *QQ* 19 条 + *qq_* 3 条)。同一批输入前后对比:
- 「在吗」:改前注入 17 个无关实体;改后场景块直接给出「QQ回复禁用Markdown格式
  --规定--> 纯文本不用Markdown」等规则正文(零字面重合也能召回)。
- 「QQ回复格式」:改前规则排第 32 被截掉;改后排在场景块首位。
- 「帮我发个语音」:场景规则置顶,词法路的 qq通道语音输入 等仍在其后。
2026-09-15 08:13:52 +08:00
b37141f3f5 fix(memory): doc→graph 补回常用词闸门 + 存量噪音/孤立节点清理
问题:图记忆里堆着「结果(192) / 什么(59) / 哪个(99) / 待命(176) / 报告(174) /
context_archived(43)」这类节点,mention_count 冲到几百、度数只有 1~2——
占着热实体位、挤满召回预算,却不带任何结构。

根因:这层过滤原本存在,后来被换掉没补回。1cb3e87(NLP 三元组提取系统)
把 doc→graph 从「CutExact 滑窗词链」换成依存句法提取器时,CutExact
(去停用词 324 条 + validEntityName + 去重,注释至今还写着「用于 doc→graph
蒸馏」)失去了唯一生产调用点,只剩 cut_test.go 在调它。此后落库闸门只剩
validEntityName——它挡的是「不像名字的字符串」(2–50 字符、含字母),
完全不挡「像名字的常用词」。

改动:
- 新增 internal/memory/noise.go:IsNoiseEntity 收敛判定(停用词 /
  context_archived / 模板摘要回声),FilterNoiseTriples 给自动填充路用,
  PurgeNoise + PurgeOrphans + cmd/memgc 供存量清理(默认 dry-run)。
  判定刻意保守:只拦三类客观噪音,开放类词(报告/对话/处理)不拦——
  它们挡不挡是领域决策,见函数注释。
- docToTriples 接上闸门(用户点名的「文档常用词」路)。
  对话蒸馏路 extractKeyTriples **不接**:CutExact 当年也只挂 doc→graph,
  且 pipeline_test 明确断言「我 --读书--> 杭州」必须抽出(代词主语是该路
  既定行为),是否拦属行为决策,已在代码注释里写明并留给使用者定夺。
- cut.go 补全封闭类常用词:咱俩/咱们(我们/你们/他们 早有)、任何/此/本/
  其中/以及/那么/这样/那样/一样/还有/还要/只是/老是/全部/所有/有些/一些/
  别的/其他/其余/各自/本身/方位词/部分/方面。
  「不能/不会」试过又撤回:它们是 embedder tokenize 的实词路径,
  加进停用词会让 static_embedder 的领域聚类用例翻转(今天天气句与股票句
  的相似度大小关系反了),属真回归,不留。
- graph.go:CleanupOrphanedSentences 拆出 Locked 版供 PurgeNoise 复用。

验证:go build/vet 干净,go test -count=1 ./... 全绿。
新增用例:IsNoiseEntity 判定表、FilterNoiseTriples 顺序与边界、
PurgeNoise(统计/幂等/dry-run 不写库/不误删仍被媒体块边引用的句子)、
PurgeOrphans(识别/不误判有边实体/幂等)、docToTriples 噪音闸门与
模板锚点不被误杀。

存量清理(生产库 /home/newqqagent/memory/graph.db,先用 sqlite3 .backup 备份
到 graph.db.bak-20260915-073210):
- 噪音实体 29 个 + 其关系 59 条
- 零关系孤立实体 25 个
- 结果:实体 778→724,关系 639→580;sentences 3 条与 block_edges 3 条原样保留
  (媒体块引用不被误删),PRAGMA integrity_check=ok、无悬空关系/块边。
- 运行中的 homed 无需重启:下一个 archive 心跳会 Indexer.Sync 重建实体名向量索引。
2026-09-15 07:47:16 +08:00
94995eaa64 fix(memory): 修图记忆召回的两处能力缺失(关系重复 + 原句不回显)
针对 memory_recall / 自动注入这条图记忆召回链路的实测复核:

- Recall(depth>1) 跨层不去重:每层都用已累积的 entityIDs 查邻接,
  上一层刚产出的关系会在下一层被反复查回并再次 append。实测
  小明→小红 在 depth=2 出现两次,memory_recall 的 10 条关系预算被
  同一句话刷屏、真正的新关系(小红→小刚)被截断。改为按 relation ID
  跨层去重(实体本就已去重)。
- memory_recall 从不回显 sentence_text:工具 schema 明写「填了才能日后
  从图谱回到原文」、Recall 也已 JOIN 出句子,但输出只给实体名与关系类型,
  该字段形同虚设。抽出 formatRecallRelations,对非空原句截断 60 字附在
  关系行后;超过 10 条仍截断并提示。

测试:TestRecallWithDepth 增补去重与精确条数断言;
新增 TestFormatRecallRelations_SurfacesSentence / _Truncates。
go build/vet 干净,go test -race ./internal/memory/ ./internal/agent/core/... 全绿。
2026-09-15 06:48:36 +08:00
6afe361804 fix(memory): 记忆层启动接线/并发/落盘一致性整备
按设计方案整顿记忆系统,收敛一批"单测照不出、只在长跑生产里暴露"的缺陷:

- 启动接线:initMemoryStack 残留 `defer distiller.Stop()`,规则蒸馏
  10min 心跳启动即死。改为由调用点 cleanup 停机,并补 Stopped() 探针 +
  TestInitMemoryStackKeepsDistillerRunning / TestStartKeepsLoopRunningUntilStop。
- L0 相关性上下文:SetDenseSpace 注入稠密空间时回填已有事件的稠密向量,
  否则旧事件走稀疏余弦、新事件走稠密余弦,同一次 Prune 里两种尺度混排。
- 文档检索:QueryScored 访问计数从读锁内写移出(-race 竞争),更新后置脏,
  优雅关停可落盘、FindColdDocs 冷度判据跨重启不再失真。
- 蒸馏管线:只 flush 未落盘记录(persisted 标记)、蒸馏成功后从 raw 文件
  删除对应行、原子写文件,修重启重复蒸馏导致 mention_count 膨胀。
- 图库:全量 Recall 加实体上限(内部整备路径,防大图整表进内存);
  ClearSentenceID 补写锁;Commit/upsertEntity 计数语义注释澄清。
- 索引器:recalled 去重集加 FIFO 上限,防长跑进程自动注入越来越沉默。
- 文档/媒体注释修正;README 记忆层流程对齐跨模态召回。

验证:go build ./...、go vet、go test -race
./internal/memory/... ./internal/agent/core/... ./cmd/homed/... 全绿。
2026-09-15 06:32:36 +08:00
165 changed files with 13799 additions and 6328 deletions

View File

@ -1,4 +1,17 @@
.PHONY: all build build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt
.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions
# HOMED_TAGS 默认带 onnxruntime发行版**默认启用**本地向量空间(与
# deploy/packaging/build.sh 保持一致)。
#
# 曾经这里是空 tags实测的后果2026-09-15 热部署):`make build` 产出的
# homed 只有 33MB而 onnxruntime 版是 84MB启动日志里
# 「multimodal space active: provider=chineseclip」整行消失少加载一个插件
# 静态词向量也退化成 fallback——而打包脚本会直接**拒收**这种二进制
# package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」
# 与「发行构建」不是同一个东西,部署时无从察觉。
# 需要极简构建时显式 HOMED_TAGS= 关掉。
HOMED_TAGS ?= onnxruntime
TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),)
BINARY=homed
CLI_BINARY=waiter
@ -17,13 +30,15 @@ all: build build-cli
build:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=1 $(GO) build -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION))"
CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/
@echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')"
@go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \
|| echo "WARN: 本次构建不含 onnxruntime本地向量空间不可用HOMED_TAGS= 显式关掉时才符合预期)"
build-cli:
@mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY)"
CGO_ENABLED=0 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(CLI_BINARY) ./cmd/waiter/
@echo "Built: $(BUILD_DIR)/$(CLI_BINARY) ($(VERSION))"
build-gui:
@cd cmd/gui && npm install --production && npx electron-packager . $(GUI_BINARY) --out=../../$(BUILD_DIR) --overwrite --no-sandbox
@ -62,6 +77,14 @@ fmt:
lint:
$(GO) vet ./...
# 客户端版本与内核版本同步(唯一事实源 internal/meta.Version
# GUI/鸿蒙各有自版本字段手工改必漂——用脚本拉齐check 版给门禁用。
sync-client-versions:
@bash deploy/scripts/sync-client-versions.sh
check-client-versions:
@bash deploy/scripts/sync-client-versions.sh --check
# lint-full在 vet 之外跑 golangci-lint阈值见 .golangci.yml起步 warn-only
# 未安装时给出可执行的安装提示与跳过原因,而不是静默成功。
.PHONY: lint-full

View File

@ -26,6 +26,16 @@ homed内核零 IO ← PluginSDK → 插件(所有 IO 能力)
- **Document 层**:临时记忆,冷数据自动下沉,也支持用户主动提交
- **Graph 层**SQLite 图数据库,持久化实体关系和语义记忆,支持蒸馏管道从原始对话中提取三元组
**输入调度:两类别 + 四级中断** — 输入不直接进 LLM先进调度器。
排队(待办工作)与中断(按"有多不能等"分 L1~L4两类高级可抢占低级并保存现场
中断栈同级不抢占。L4 只归内核与内核级插件(如 WebUI 终止按钮)。
见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md) 的「输入调度器与中断机制」。
**驻留式子 agent** — 内核可派驻轻量内核的子 agent自己的调度器与 temp 图记忆,
共享通道登记表),把长任务/积压交出去并行做。主 agent 忙久了,内核还会把排队输入
交给临时**分诊助手**:简单的直接处理,需要主 agent 的立刻回「忙碌中,请稍候」,
用户不再干等。见 [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md)。
## 架构图
### 一、消息处理时序
@ -34,6 +44,7 @@ homed内核零 IO ← PluginSDK → 插件(所有 IO 能力)
sequenceDiagram
participant U as 用户/插件
participant IO as IOManager
participant SCH as 输入调度器
participant EV as eventLoop
participant CTX as RelevanceContext
participant LLM as LLM+工具循环
@ -41,7 +52,14 @@ sequenceDiagram
participant MEM as 三层记忆
U->>IO: InjectInput(type, payload)
IO->>EV: inputCh
IO->>SCH: inputCh
rect lavender
Note over SCH: 两类别 + 四级中断L1~L4
SCH->>SCH: 同级不抢占 → 入就绪队列/中断队列
SCH->>SCH: 更高级 → 抢占(现场压中断栈,稍后可恢复)
SCH->>SCH: 转投(主 agent 忙久了 → 交给临时分诊助手)
end
SCH->>EV: 选中一个任务开始跑
rect lavender
Note over EV: processTextInput
EV->>ST: StageOnInput 插件可改写/短路
@ -55,7 +73,7 @@ sequenceDiagram
EV->>MEM: buildSystemPrompt DocQuery摘要+Graph记忆索引+人格+技能
EV->>ST: StagePreAction 插件可预拦截
loop 工具循环
LLM->>LLM: drainInterrupts
LLM->>LLM: 安全点:中断求值/让位
LLM->>LLM: LLM Chat
LLM->>ST: StagePostAction 插件可修改/短路
alt 无tool call
@ -110,7 +128,7 @@ flowchart TB
end
subgraph D[② Document 文件记忆]
DS[DocStore JSON+TF-IDF]
Q1[Query 摘要自动注入] -->|【相关记忆文档】| SP
Q1[QueryScored+crossModalMarkdown] -->|【跨模态相关记忆】| SP
Q2[doc_query LLM主动召回] -->|Consume+删除源| DS
Q2 -->|原始时间戳写入上下文| RC
CD[FindColdDocs 72h] -->|docToTriples| G
@ -180,27 +198,44 @@ API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQ
cmd/homed/ 守护进程入口,组装所有子系统
cmd/waiter/ CLI 客户端Unix socket
internal/
├── agent/core/ Agent 核心事件循环、LLM 工具循环、7 阶段管道
├── agent/api/ LLM Provider + 8 个 Lua 适配器
├── agent/core/ Agent 核心:输入调度器(两类别+四级中断)、事件循环、LLM 工具循环、7 阶段管道、驻留子
├── agent/api/ LLM ProviderLua 适配层provider.go 调 vm
├── memory/ 三层记忆Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(预训练词嵌入/TF-IDF回退) + CleanTemplateText(去模版)
├── knowledge/ 知识库(文件系统 + TF-IDF
├── plugin/ 插件注册表 + 子进程加载器stdio RPC + 共享内存段 + 事件环)
├── plugins/ 内置 11 个插件webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr
├── plugins/ 内置 18 个插件webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data 等
├── sdk/ PluginSDKTool/Stage/Event 三通道)
├── config/ SQLite 配置中心
├── events/ 事件总线
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
└── internal/lua/adapters/ 10 个 LLM 协议适配器脚本
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
```
## 项目状态
**v1.3.x 线**v1.3.1v1.3.12,最新已发布)—— **驻留式子 agent** + **输入调度器重做**
- **驻留式子 agent**:内核可派驻轻量内核的子 agent自己的调度器、自己的 temp 图记忆、
共享通道登记表)。父经 `resident_agents`list/create/send/inspect/compress/reclaim/destroy
派活与收活inputch 可划给子,输入**在进内核之前**就已路由到子。
- **输出通道可寻址到具体 agent**`AllowedOutputs` 授权集合(三处过滤点一致),
父/子之间可互相投递;设备能力也 outputch 化(每设备一个 `device/<id>` 通道)。
- **输入调度器**:排队/中断两类别 + 四级中断L1~L4+ 抢占/挂起/恢复/中断栈;
同级不抢占、有饥饿防护与抢占冷却L4 只归内核与内核级插件WebUI 终止按钮)。
- **轻量内核 profile**:子的记忆面收窄为「传统上下文 + 图记忆」(窄接口,
主库以 query_only 受限句柄打开,写走自己的 temp 实例)。
- **积压及时反馈**(后续线):主 agent 长时间忙时,内核把排队输入交给临时**分诊助手** ——
简单的直接处理并回复,需要主 agent 的立刻回「忙碌中,请稍候」,用户不再干等十几分钟。
- 修掉一批真实缺陷:销毁驻留子时入站 inputch`child/<id>`)注册残留、
子的轮次永远显示 0`info()` 根本没填)、子侧 childIO 空壳(未继承父的输出通道)、
设备心跳 pong 忘了 Flush每 60 秒掉线、Lua 插件桥与 SDK 1.3.0 对齐。
**v1.2.0** — 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存。
- **模型中立的统一向量空间**:内核不再适配任何具体模型,只提供公共 provider SPI
`pkg/embedding``Modality` / `Input{Data,MIME}` / `Info{Dimension,Fingerprint,Modalities}`
+ 名字注册表),实现在 `providers/*`。默认 **Chinese-CLIP ViT-B/16** —— text 与 image
落在**同一空间**512 维、指纹 `cd2a495cf990`、Apache-2.0、独立实测常驻约 1.15GB
落在**同一空间**512 维、指纹 `cd2a495cf990`、Apache-2.0;实测加载峰值 1.59GB、静置回收后稳态约 0.89GB
`qwen3vl` 保留2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
文本检索仍由既有词向量 / TF-IDF 兜底CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**
这是已知并写进文档的代价。
@ -221,6 +256,14 @@ internal/
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
>
> 另有**两项性能断言的量纲需要更正**2026-09-20 实测):
> 「崩溃到恢复 <1s」不成立 —— 崩溃后是**线性退避重启**,即 1s / 2s / 3s`procRestartBackoff=1s × 第 n 次`
> 首次重启就要等 1s。且 5 分钟窗口内第 **4** 次崩溃即停止自动重启待人工介入(`procMaxRestarts=3`,判定为 `n > 3`)。
> 该断言写下时v1.0.0)退避值已是 1s故从未成立。
> 「RPC 往返 p50 24.1µs」与当前实测同量级但不吻合本机 `BenchmarkToolInvoke` 实测
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
> 保留原文不修改,以免伪造历史。
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用媒体永久算「被引用」GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string、**`memory_commit``sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
@ -265,7 +308,12 @@ make test # go test ./...
make install # 安装到系统
```
依赖Go 1.25+, CGo (go-sqlite3), Linux/Windows
依赖Go 1.25+, CGo (go-sqlite3), Linux
> `homed` 需 Linux依赖 fd 继承与共享内存段的段内偏移解引用,见
> `cmd/homed/platform_windows.go`Windows 上只构建 `waiter.exe`
> `homed` 跑在 WSL2 里见「下载」。macOS 可构建 `waiter`/`initconfig`
> `homed` 需在原生 macOS 构建。
## 许可

View File

@ -32,6 +32,19 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
- **Document Layer**: Temporary memory with automatic cold data sinking, also supports user-initiated submissions
- **Graph Layer**: SQLite graph database, persists entity relationships and semantic memory, supports distillation pipelines to extract triples from conversations
**Input Scheduling: 2 classes + 4 interrupt levels** — Input does not go straight to the LLM;
it first enters the scheduler. Two classes (queued = pending work, interrupt = ranked L1L4 by
"how urgent") with preemption and frame saving (interrupt stack); same level never preempts
same level. L4 belongs only to the kernel and kernel-level plugins (e.g. the WebUI stop button).
See "Input Scheduler & Interrupt Mechanism" in [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md).
**Resident sub-agents** — The kernel can station lightweight-kernel child agents (their own
scheduler and temp graph memory, sharing the channel registry) to run long or backlogged work
in parallel. When the main agent stays busy, the kernel hands queued input to a temporary
**triage assistant**: simple items are handled directly, items needing the main agent get an
immediate "busy, please wait" — users no longer wait in silence.
See [`docs/zh/resident-subagent-design.md`](docs/zh/resident-subagent-design.md).
## Architecture Diagrams
### 1. Message Processing Sequence
@ -40,6 +53,7 @@ plane). A plugin crash cannot take down the kernel and it restarts automatically
sequenceDiagram
participant U as User/Plugin
participant IO as IOManager
participant SCH as Input Scheduler
participant EV as eventLoop
participant CTX as RelevanceContext
participant LLM as LLM+Tool Loop
@ -47,7 +61,14 @@ sequenceDiagram
participant MEM as Three-Layer Memory
U->>IO: InjectInput(type, payload)
IO->>EV: inputCh
IO->>SCH: inputCh
rect lavender
Note over SCH: 2 task classes + 4 interrupt levels (L1-L4)
SCH->>SCH: same level never preempts -> ready/interrupt queue
SCH->>SCH: higher level -> preempt (frame pushed to interrupt stack)
SCH->>SCH: offload (main agent busy too long -> temporary triage assistant)
end
SCH->>EV: pick one task and run it
rect lavender
Note over EV: processTextInput
EV->>ST: StageOnInput Plugin can rewrite/short-circuit
@ -61,7 +82,7 @@ sequenceDiagram
EV->>MEM: buildSystemPrompt DocQuery summary+Graph memory index+Persona+Skills
EV->>ST: StagePreAction Plugin can pre-intercept
loop Tool loop
LLM->>LLM: drainInterrupts
LLM->>LLM: safe point: interrupt eval / yield
LLM->>LLM: LLM Chat
LLM->>ST: StagePostAction Plugin can modify/short-circuit
alt No tool call
@ -169,28 +190,53 @@ API keys are configured via WebUI `http://localhost:8080` settings page, persist
cmd/homed/ Daemon entry, assembles all subsystems
cmd/waiter/ CLI client (Unix socket)
internal/
├── agent/core/ Agent core: event loop, LLM tool loop, 7-stage pipeline
├── agent/api/ LLM Provider + 8 Lua adapters
├── agent/core/ Agent core: input scheduler (2 classes + 4 levels), event loop, LLM tool loop, 7-stage pipeline, residents
├── agent/api/ LLM Provider (Lua adapter layer: provider.go drives the vm)
├── memory/ Three-layer memory: Graph(SQLite) / Document(JSON+TF-IDF) / Text(JSONL) + StaticEmbedder(pretrained word embedding/TF-IDF fallback) + CleanTemplateText(de-template)
├── knowledge/ Knowledge base (filesystem + TF-IDF)
├── plugin/ Plugin registry + subprocess loader (stdio RPC + shared memory segment + event ring)
├── plugins/ 11 built-in plugins (webui/cli/timer/cmd/mcp/clawhubadapter/agentcli/healthcheck/pluginmgr/files/cfgmgr)
├── plugins/ 18 built-in plugins (webui/cli/timer/cmd/mcp/files/cfgmgr/agentcli/healthcheck/pluginmgr/clawhubadapter/multimodal/remotedevice/ai_image/localuse/skillmgr/data, ...)
├── sdk/ PluginSDK (Tool/Stage/Event three channels)
├── config/ SQLite config center
├── events/ Event bus
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
└── internal/lua/adapters/ 10 LLM protocol adapter scripts
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `hmapdev` toolchain, refer to Go and Lua examples in `example/`
```
## Project Status
**v1.3.x line** (v1.3.1v1.3.12, latest released) — **resident sub-agents** + **input scheduler rework**.
- **Resident sub-agents**: the kernel can station lightweight-kernel child agents (their own
scheduler, their own temp graph memory, sharing the channel registry). The parent dispatches
and collects work via `resident_agents` (list/create/send/inspect/compress/reclaim/destroy).
An inputch can be assigned to a child, so input is routed to it **before entering the kernel**.
- **Output channels addressable to a specific agent**: the `AllowedOutputs` grant set
(consistent across all three filter points) lets parent/child deliver to each other;
device capabilities became output channels too (one `device/<id>` per device).
- **Input scheduler**: two task classes (queued/interrupt) + four interrupt levels (L1L4)
+ preempt/suspend/resume/interrupt-stack; same level never preempts same level, with a
starvation guard and preemption cooldown. L4 belongs only to the kernel and kernel-level
plugins (e.g. the WebUI stop button).
- **Lightweight kernel profile**: a child's memory surface narrows to "conventional context
+ graph memory" (narrow interface; the main graph opens as a query_only handle, writes go
to its own temp instance).
- **Backlog timely feedback** (later in the line): when the main agent is busy for a long time,
the kernel hands queued input to a temporary **triage assistant** — simple items are handled
directly, items needing the main agent get an immediate "busy, please wait", so users no
longer wait 10+ minutes in silence.
- Fixed a batch of real defects: inbound inputch (`child/<id>`) registration leak on resident
destruction, a child's round count always showing 0 (`info()` never filled it), an empty
child-side childIO (output channels not inherited), device heartbeat pong missing Flush
(dropping every 60s), and Lua plugin bridge alignment with SDK 1.3.0.
**v1.2.0** — unified multimodal vector space, media promoted to first-class graph memory, and the whole data plane moved into shared memory.
- **Model-neutral unified embedding space**: the kernel no longer adapts to any specific model.
It exposes only a public provider SPI (`pkg/embedding`: `Modality` / `Input{Data,MIME}` /
`Info{Dimension,Fingerprint,Modalities}` + a name registry), with implementations under
`providers/*`. Default: **Chinese-CLIP ViT-B/16** — text and image land in the **same space**
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0, ~1.15GB RSS measured standalone);
(512-dim, fingerprint `cd2a495cf990`, Apache-2.0; measured ~1.59GB peak on load, settling to ~0.89GB steady-state);
`qwen3vl` is kept (2048-dim, ~9.4GB) for machines with headroom or future video. Text search
still falls back to the existing word-vector / TF-IDF path — a CLIP dual tower's pure-text
semantics are **weaker than an MLLM-style embedder**, a cost documented rather than hidden.
@ -217,6 +263,17 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
> The historical entries below are kept verbatim to show the evolution; two mechanisms in them
> were **removed in v1.2.0**: text-description-based media indexing, and reference-counted media GC.
>
> **Two performance claims also need correcting** (measured 2026-09-20):
> "crash-to-recovery under 1s" does not hold — restarts are **linearly backed off**, i.e.
> 1s / 2s / 3s (`procRestartBackoff=1s × nth crash`). Even the *first* restart waits 1s.
> And the **4th** crash within a 5-minute window stops automatic restarts pending human
> intervention (`procMaxRestarts=3`, tested as `n > 3`).
> The backoff was already 1s when this claim was written (v1.0.0), so it never held.
> "RPC round-trip p50 24.1µs" is the right order of magnitude but does not match current
> measurements: `BenchmarkToolInvoke` on this machine gives inline/small **30.4µs**,
> frame/small 51.5µs, inline/large 767µs, frame/large 398µs.
> The original text is left unedited rather than rewritten, so the history isn't falsified.
**v1.1.1** — Multimodal reaches the **plugin boundary**. v1.1.0 gave the memory system binary
multimedia nodes, but that path was open only to the kernel itself; this release opens it to
@ -279,7 +336,12 @@ make test # go test ./...
make install # Install to system
```
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux.
> `homed` requires Linux (it relies on fd inheritance and intra-segment offset
dereferencing of the shared memory region; see `cmd/homed/platform_windows.go`).
On Windows only `waiter.exe` is built and `homed` runs under WSL2 (see Downloads).
macOS can build `waiter`/`initconfig`; `homed` must be built on native macOS.
## License

View File

@ -455,7 +455,74 @@ Extended fields:
- Relation extension: Confidence
## Interrupt Mechanism
## Input Scheduler & Interrupt Mechanism
Inputs do not go straight to the LLM — they first enter the **input scheduler**
(`internal/agent/core/scheduler.go`). Full design:
[`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md).
### Two task classes
| Class | Level | Meaning |
|-------|-------|---------|
| `TaskQueued` | none (always 0) | Pending work. Any interrupt (≥ L1) preempts it |
| `TaskInterrupt` | L1L4 | "How urgent is this", declared by the source via `InjectOptions.Priority` |
### Four interrupt levels
| Level | Meaning | Typical source |
|-------|---------|----------------|
| L1 Background | Fully deferrable | QQ/WeChat messages, bulk notifications |
| L2 Message | General notice | Plugin hints that should be seen soon but aren't urgent |
| L3 Interactive | Needs timely handling | Timer expiry, terminal output, resident-agent reports |
| L4 Critical | **Kernel-exclusive** | panic, kernel events, kernel-level plugin stop button |
When no level is declared it defaults to **L1** — "explicit is a privilege", so a new
plugin never gets preemption rights by accident. L4 declared by an external plugin is
**clamped to L3** (`clampPluginLevel`).
### Preemption and suspension
- **Same level never preempts same level** (`canPreempt` requires strictly greater) —
this is why messages normally wait for the running task to finish.
- A preempted task is pushed onto the **interrupt stack** (LIFO) with its frame saved,
and resumed later; the stack is never re-sorted by priority.
- **Starvation guard**: preemption count raises the effective level
(`effectiveLevel = Level + min(PreemptCount, 2)`, capped at L4).
- **Preemption cooldown**: a just-preempted task cannot be preempted again for
`preemptCooldown` (2s), so a high-priority stream cannot interrupt the same task forever.
- The interrupt stack depth is structurally bounded (chain = queued ← L1 ← L2 ← L3 ← L4).
### Stop (user presses stop / `/stop`)
Stop is not an empty interrupt. It does two things: ① cancel the current LLM inference;
② short-circuit the x messages **already queued at the moment of stop** during their
pre-action phase (`cancelBudget` snapshot), instead of running them as new input.
Inputs arriving **after** the stop are unaffected.
`PendingInputs()` must include the segment still sitting in `io.inputCh` (not yet moved
into the queue by `pumpInbox`) — during a stop the scheduler is usually busy running a
task, and counting only `sched.queue` yields 0.
### Resident sub-agents and timely feedback
Design: [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md).
- A **resident** is an independent lightweight-kernel agent: its own scheduler, its own
temp graph memory, sharing the channel registry.
- Parent→child control plane: `resident_agents`
(list / create / send / inspect / compress / reclaim / destroy).
- **Backlog feedback**: when the main agent is busy for a long time (default > 5m,
configurable), the kernel hands queued inputs to a temporary **triage assistant**
(`offload_*` config): simple ones are handled directly, ones needing the main agent
get an immediate "busy, please wait". Users no longer wait 10+ minutes in silence.
- The triage assistant gets **no inputch** (it receives no plugin user input) and
**all output channels** (results must reach the original channel).
- On reclaim/destroy, its **residual tasks are decided explicitly by the parent**:
`residual=keep` (returned to the parent queue, default) or `drop` (explicitly
discarded with a per-item log entry).
### Legacy three-path view (still present, now a layer beneath the scheduler)
```
interceptLoop (goroutine)
@ -465,15 +532,28 @@ interceptLoop (goroutine)
└── (c) InjectInput() → Trigger new processing when idle
```
Three delivery paths:
Code: `internal/agent/core/scheduler.go` (scheduler), `eventloop.go` (intercept loop).
| Path | Effect | Timing |
|------|--------|--------|
| cancelLLM | Cancel current HTTP request | On context.Canceled |
| interceptCh | Insert `[interrupt message]` in process() | Before each LLM call |
| InjectInput | Trigger new processing when eventLoop is idle | No ongoing request |
## Context Budget
Code: `internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts`
`internal/agent/core/tokenbudget.go``ComputeTokenBudget`:
```
maxCtx = provider.MaxContextTokens() // declared window (per-source context_window wins)
targetUsage = min(maxCtx × 0.8, 600000) // working band, capped at 600K
├── memory recall budget = (targetUsage - fixed) / 3
└── context events budget = remaining 2/3
```
**Window ≠ working band**: a source's real window may reach 1M, but near-full windows
lose attention and cost/latency rise linearly, so `maxTargetTokens=600000` caps the
working band separately. If the model name (e.g. `AUTO`) yields no window,
`ModelContextWindow` **logs a warning** and falls back conservatively; operators should
declare `core.llm.sources.<name>.context_window` explicitly.
**Budgets are ceilings, not fill targets**: memory is recall-ranked (it stops when nothing
is relevant) and the timeline is taken newest-first within budget. Measured: with a 400K
budget, actual injection was still a few hundred characters.
## Configuration System

View File

@ -804,7 +804,7 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
|---------|------|----------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | Weather queries (wttr.in); demonstrates NoMemory/Cleaner/stage hooks/channels/text memory |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Full-featured Lua example covering the whole v0.8.0 Lua SDK surface |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 17 tools, full input/output channel wiring |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot integration, 20 tools, full input/output channel wiring |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | Memo management, PreAction injection + timed interrupt dual reminder |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | File system operations, 4 write modes, sandbox isolation |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | Web search + HTTP fetch (SSRF) + Chromium render (merged from web/webfetch) |
@ -817,6 +817,12 @@ Internal: records are stored in SQLite `disabled_plugins` table (`name`, `disabl
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS subscriptions |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI image generation |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | Music playback |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja task management (projects/tasks/labels CRUD) |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog publishing and management |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | Multi-round deep search (progressive focus + cited summary) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol (external editors/IDEs drive this agent) |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | Five-part fault diagnosis (triage / sqlite check / log signatures / diff / ranked conclusions); core plugin of failback mode |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | Plugin scaffolding: generate, build, install — lets the agent develop plugins itself |
### Built-in Plugins

View File

@ -444,7 +444,64 @@ type Plugin interface {
- Relation 扩展Confidence
## 中断机制
## 输入调度器与中断机制
输入不直接进 LLM —— 它们先进**输入调度器**`internal/agent/core/scheduler.go`)。
设计全文见 [`docs/zh/input-scheduler-design.md`](../../../docs/zh/input-scheduler-design.md)。
### 两类任务
| 类别 | 级别 | 语义 |
|------|------|------|
| `TaskQueued` 排队输入 | 无级别(恒 0 | 待办工作。任何中断(≥ L1都能抢它 |
| `TaskInterrupt` 中断 | L1~L4 | "这件事有多不能等",由来源在 `InjectOptions.Priority` 声明 |
### 四级中断
| 级别 | 含义 | 典型来源 |
|------|------|----------|
| L1 背景 | 完全可等 | QQ/微信消息、批量通知 |
| L2 消息 | 一般提醒 | 插件希望尽快看到但不紧急的提示 |
| L3 交互 | 需及时处理 | 定时器到达、终端输出、子 agent 汇报 |
| L4 关键 | **内核独占** | panic、内核事件、内核级插件的终止按钮 |
未声明级别时取 **L1**`DefaultLevel`)——「显式才是特权」,新插件不会默认拿到抢占权。
外部插件声明 L4 会被**夹到 L3**`clampPluginLevel`)。
### 抢占与挂起
- **同级不能抢占同级**`canPreempt` 要求严格大于)——这是日常"消息排队等前面跑完"的成因。
- 被抢占的任务压入**中断栈**LIFO`suspendStack` 保存现场,稍后恢复;
栈内不做优先级重排("后被打断的先恢复"才是栈语义)。
- **饥饿防护**:被抢占次数会提升有效级别(`effectiveLevel = Level + min(PreemptCount, 2)`
封顶 L4确保低级别流不会被困。
- **抢占冷却**:刚被抢占过的任务在 `preemptCooldown`2s内不再被抢
避免高优先级流把同一个任务反复打断到永不完结。
- 中断栈帧数有**结构上界**(链条 = 排队 ← L1 ← L2 ← L3 ← L4最多挂起 4 帧)。
### 停止(用户按停止按钮 / `/stop`
停止 ≠ 空中断。它做两件事:① 立即结束当前 LLM 推理;② 对**停止那一刻已排队**
的 x 条消息依次在 pre-action 阶段短路(`cancelBudget` 快照配额),而不是把它们
当新输入再跑一遍。停止之后**新到**的输入不受影响。
`PendingInputs()` 必须把"还停在 `io.inputCh`、没被 `pumpInbox` 搬进队列"的那一段
算进来 —— 停止时调度器多半正忙于当前任务,只数 `sched.queue` 会得到 0。
### 驻留式子 agent 与及时反馈
设计见 [`docs/zh/resident-subagent-design.md`](../../../docs/zh/resident-subagent-design.md)。
- **驻留子**是轻量内核的独立 agent自己的调度器、自己的 temp 图记忆、共享的通道登记表。
- 父对子的控制面:`resident_agents`list / create / send / inspect / compress / reclaim / destroy
- **积压及时反馈**:主 agent 长时间忙时(默认 > 5m可配内核把排队输入交给
一个临时**分诊助手**`offload_*` 配置):简单的直接处理并回复,需要主 agent 的
立刻回「忙碌中,请稍候」。这样用户不会干等十几分钟。
- 分诊助手**不配 inputch**(不接收插件用户输入)、**持有全部输出通道**(结果要能发回原通道)。
- 回收/销毁时它手头的**残余任务由父显式决定**`residual=keep`(转回父队列,默认)
`drop`(明确丢弃,逐条记日志)。
### 旧版三路径(仍存在,但已是调度器之下的一层)
```
interceptLoop (goroutine)
@ -454,15 +511,26 @@ interceptLoop (goroutine)
└── (c) InjectInput() → 空闲时触发新处理
```
三种投递路径:
代码:`internal/agent/core/scheduler.go`(调度器)、`eventloop.go`(拦截循环)。
| 路径 | 效果 | 时机 |
|------|------|------|
| cancelLLM | 取消当前 HTTP 请求 | 收到 context.Canceled |
| interceptCh | process() 中插入 `[打断消息]` | 每个 LLM call 前 |
| InjectInput | eventLoop 空闲时触发新处理 | 无进行中请求 |
## 上下文预算
代码:`internal/agent/core/eventloop.go``interceptLoop` / `drainInterrupts`
`internal/agent/core/tokenbudget.go``ComputeTokenBudget`
```
maxCtx = provider.MaxContextTokens() // 声明窗口per-source context_window 优先)
targetUsage = min(maxCtx × 0.8, 600000) // 工作面:封顶 600K
├── 记忆召回预算 = (targetUsage - 固定开销) / 3
└── 上下文事件预算 = 其余 2/3
```
**窗口 ≠ 工作面**:源的真实窗口可能到 1M但接近满窗口时注意力涣散、
成本与延迟线性上升,因此 `maxTargetTokens=600000` 把工作面单独封顶。
若模型名(如 `AUTO`)推断不出窗口,`ModelContextWindow` 会**打日志提醒**并回退保守值,
部署方应用 `core.llm.sources.<name>.context_window` 显式声明。
**预算都是上限而非填充目标**:记忆按相关度召回(没相关就停),时间线按预算从新到旧取。
实测:预算 400K 时实际注入仍只有几百字符。
## 配置系统

View File

@ -797,7 +797,7 @@ pmgr.ReloadPlugins() // 重载所有插件
|------|------|------|
| [weather](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/weather) | Go | 天气查询wttr.in演示 NoMemory/Cleaner/阶段钩子/通道/文本记忆 |
| [luademo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/luademo) | Lua | Lua 全功能示例,覆盖 v0.8.0 Lua SDK 全部 API 面 |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,17 个工具,输入/输出通道完整对接 |
| [qq](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/qq) | Go | NapCat OneBot 对接,20 个工具,输入/输出通道完整对接 |
| [memo](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/memo) | Go | 备忘管理PreAction 注入 + 定时打断双提醒 |
| [files](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/files) | Go | 文件系统操作4 种写入模式,沙箱隔离 |
| [browser](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/browser) | Go | 网络搜索、网页抓取SSRF、浏览器渲染合并自 web/webfetch |
@ -810,6 +810,12 @@ pmgr.ReloadPlugins() // 重载所有插件
| [rss](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/rss) | Go | RSS 订阅 |
| [ai_image](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/ai_image) | Go | AI 图片生成 |
| [music](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/music) | Go | 音乐播放 |
| [vikunja](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vikunja) | Go | Vikunja 任务管理对接(项目/任务/标签 CRUD |
| [vanblog](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/vanblog) | Go | VanBlog 博客发布与管理 |
| [deepsearch](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/deepsearch) | Go | 多轮深度检索(逐层聚焦 + 引用汇总) |
| [acp](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/acp) | Go | Agent Client Protocol 对接(外部编辑器/IDE 驱动本 agent |
| [recoverydiag](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/recoverydiag) | Go | 故障诊断五件套(分诊/sqlite 校验/日志签名/diff/结论排序failback 模式的核心插件 |
| [plugindev](https://gitcode.com/JianFeeeee/homeagent-sdk/tree/main/example/plugindev) | Go | 插件脚手架:生成工程、构建、安装,供 agent 自助开发插件 |
### 内置插件

View File

@ -1,6 +1,6 @@
{
"name": "homeagent-gui",
"version": "1.0.0",
"version": "1.4.0",
"author": "JianFeeeee <jianfeeeee@homeagent.local>",
"homepage": "https://gitcode.com/JianFeeeee/HomeAgent",
"description": "HomeAgent Desktop GUI - Multi-connection management dashboard",

View File

@ -147,9 +147,6 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) {
} else {
log.Printf("[homed] graph memory initialized")
}
if memDB != nil {
}
memIdx := memory.NewIndexer(memDB)
memIdx.Sync() // 启动时立即同步避免前30分钟空窗
socialStore := social.New(memDB)
@ -160,8 +157,11 @@ func initMemoryStack(dataDir string) (*memoryStack, func()) {
BatchSize: 50,
})
if memDB != nil {
// 这里**故意不写 defer distiller.Stop()**:本函数在 return 时即触发
// defer而 Stop() → cancel() 会让刚启动的 distillLoop 立刻退出,
// 规则蒸馏管线启动即死、10min 心跳从不运行(旧 main() 拆分时的残留)。
// 停机由调用点注册的 cleanup 负责(见下方返回值)。
distiller.Start()
defer distiller.Stop()
}
return &memoryStack{db: memDB, indexer: memIdx, social: socialStore, distiller: distiller},
@ -487,6 +487,12 @@ func newMainAgent(cfg *types.Config, cfgReg *internalConfig.ConfigRegistry, prov
ReviewInterval: cfgReg.GetDuration("core.agent.review_interval", 120*time.Minute),
MergeInterval: cfgReg.GetDuration("core.agent.merge_interval", 120*time.Minute),
MaxToolTurns: cfgReg.GetInt("core.agent.max_tool_turns", 10),
Offload: agentCore.OffloadOptions{
Enabled: cfgReg.GetBool("core.agent.offload_enabled", false),
BusyAfter: cfgReg.GetDuration("core.agent.offload_busy_after", 5*time.Minute),
MinPending: cfgReg.GetInt("core.agent.offload_min_pending", 3),
MaxResidents: cfgReg.GetInt("core.agent.offload_max_residents", 2),
},
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
@ -800,6 +806,7 @@ func wirePluginSDK(pluginReg *plugin.Registry, luaVM *luapkg.VM, baseAPIKey stri
pluginReg.SetStageHost(stageHost)
pluginReg.SetIndexer(memIdx)
pluginReg.SetStatusProvider(agent)
pluginReg.SetTerminalAPI(agent)
}
// resolveWebUIOverride 解析 webui 监听地址的覆盖值,空串表示不覆盖。

View File

@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// TestInitMemoryStackKeepsDistillerRunning 锁死启动接线回归:
// initMemoryStack 必须返回一个**仍在运行**的蒸馏器。
//
// 历史 bugmain() 拆分时函数体内残留一句 `defer distiller.Stop()`
// 函数一 return 就 cancel 掉刚启动的循环,规则蒸馏 10min 心跳从不运行。
// 该缺陷不会让任何单测变红——pipeline 的 TestDistillOnce* 直接调
// distillOnce绕过了 Start/Stop 接线;只有在这里按「启动阶段函数」的
// 真实调用方式断言,才照得出来。
func TestInitMemoryStackKeepsDistillerRunning(t *testing.T) {
dir := t.TempDir()
// NewGraphDB 需要父目录已存在(生产由 dataDir 初始化保证)。
if err := os.MkdirAll(filepath.Join(dir, "memory"), 0755); err != nil {
t.Fatal(err)
}
st, cleanup := initMemoryStack(dir)
if st == nil || st.distiller == nil {
cleanup()
t.Fatal("initMemoryStack 未返回蒸馏器")
}
if st.db == nil {
cleanup()
t.Skip("图库未初始化,无法验证蒸馏接线")
}
if st.distiller.Stopped() {
cleanup()
t.Fatal("initMemoryStack 返回后蒸馏循环已被停掉defer Stop 残留?)")
}
// cleanup 是唯一的停机点:先停蒸馏器、再关图库。
cleanup()
if !st.distiller.Stopped() {
t.Fatal("cleanup 之后蒸馏器应已停止")
}
}

123
cmd/memgc/main.go Normal file
View File

@ -0,0 +1,123 @@
// memgc 清理图记忆里已存在的「噪音实体」「孤立实体」及其关系。
//
// 为什么需要这个命令噪音闸门internal/memory.IsNoiseEntity只能拦住
// **新写入**的噪音。旧库里那批(常用词 / 归档内部标记 / 模板摘要回声)是
// 闸门上线前攒下的存量,没人清就一直在——热实体被它们占着,召回预算被
// 同构垃圾边挤满。清理是一次性动作,但需要可重复执行、可先看不做。
//
// 两件事分开开关:-orphans 处理的是「零关系的空节点」(清理噪音后另一端
// 留下的壳),它们的名字本身可能没问题,但已经不在图里了。
//
// 用法(默认 dry-run只列不删
//
// memgc -db /home/newqqagent/memory/graph.db
// memgc -db /home/newqqagent/memory/graph.db -orphans -apply
//
// 清理生产库前请先备份sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"
// 不要用 cp —— WAL 模式下会复制出主库与 -wal 不一致的快照。
package main
import (
"flag"
"fmt"
"log"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
func main() {
path := flag.String("db", "", "graph.db 路径(必填)")
apply := flag.Bool("apply", false, "真正删除;不加则只 dry-run 打印")
orphans := flag.Bool("orphans", false, "同时处理「零关系孤立实体」(先被清理的噪音在另一端留下的空节点)")
tagScene := flag.String("tag-scene", "", "存量引导:把实体名匹配 -entity-glob 的活跃关系标进该场景键(如 chan:qq")
entityGlob := flag.String("entity-glob", "", "配合 -tag-scene 的 GLOB 模式(如 *QQ*。GLOB 区分大小写,避免把 /home/newqqagent 这类路径卷进场景")
sceneStats := flag.Bool("scene-stats", false, "只打印场景规模摘要")
flag.Parse()
if *path == "" {
flag.Usage()
log.Fatal("memgc: 必须指定 -db")
}
g, err := memory.NewGraphDB(*path)
if err != nil {
log.Fatalf("memgc: open %s: %v", *path, err)
}
defer g.Close()
if *sceneStats {
stats, err := g.SceneStats()
if err != nil {
log.Fatalf("memgc: scene stats: %v", err)
}
fmt.Printf("场景 %d 个:\n", len(stats))
for _, st := range stats {
fmt.Printf(" [%-9s] %-40s refs=%-5d rel=%-5d ent=%-4d strength=%-4d features=%-3d updated=%s\n",
st.Origin, st.Key, st.Refs, st.Relations, st.Entities, st.Strength, st.Features,
st.UpdatedAt.Format("2006-01-02 15:04"))
}
return
}
// 存量引导:场景是后引入的维度,老库里的规则(那批 QQ 规则就是典型)
// 没有任何场景引用,不补挂就永远吃不到场景召回。
if *tagScene != "" {
if *entityGlob == "" {
log.Fatal("memgc: -tag-scene 需要配套 -entity-glob如 '*QQ*');不做自动猜测")
}
n, err := g.TagSceneByEntityGlob(*tagScene, *entityGlob, !*apply)
if err != nil {
log.Fatalf("memgc: tag scene: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 已把 %d 条关系标进场景 %q\n", n, *tagScene)
} else {
fmt.Printf("[DRY-RUN] 将把 %d 条关系标进场景 %q未写库\n", n, *tagScene)
}
return
}
junk, err := g.NoiseEntities()
if err != nil {
log.Fatalf("memgc: scan: %v", err)
}
fmt.Printf("噪音实体 %d 个:\n", len(junk))
for _, e := range junk {
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
}
de, dr, err := g.PurgeNoise(!*apply)
if err != nil {
log.Fatalf("memgc: purge: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 噪音:已删除 实体=%d 关系=%d\n", de, dr)
} else {
fmt.Printf("[DRY-RUN] 噪音:将删除 实体=%d 关系=%d未写库加 -apply 才落地)\n", de, dr)
}
if *orphans {
list, err := g.OrphanEntities()
if err != nil {
log.Fatalf("memgc: orphans: %v", err)
}
fmt.Printf("孤立实体(零关系)%d 个:\n", len(list))
for _, e := range list {
fmt.Printf(" %-64s type=%-8s mentions=%d\n", e.Name, e.Type, e.MentionCount)
}
n, err := g.PurgeOrphans(!*apply)
if err != nil {
log.Fatalf("memgc: purge orphans: %v", err)
}
if *apply {
fmt.Printf("[APPLIED] 孤立实体:已删除 %d 个\n", n)
} else {
fmt.Printf("[DRY-RUN] 孤立实体:将删除 %d 个\n", n)
}
}
if *apply {
fmt.Println("提示:运行中的进程会在下一个 archive 心跳Indexer.Sync重建实体名向量索引无需重启。")
}
}

View File

@ -2,8 +2,8 @@
"app": {
"bundleName": "com.example.homeagent",
"vendor": "HomeAgent",
"versionCode": 1001001,
"versionName": "1.1.1",
"versionCode": 1004000,
"versionName": "1.4.0",
// 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
"icon": "$media:layered_image",

View File

@ -0,0 +1,19 @@
import { bundleManager } from '@kit.AbilityKit';
/**
* 应用版本号:从 bundle 元数据读取,而不是在 .ets 里再抄一份。
*
* AppScope/app.json5 是版本的唯一来源(由 deploy/scripts/sync-client-versions.sh
* 与内核 internal/meta.Version 对齐)。在代码里再写一个字面量就是第二份真相,
* 实测已经漂过app.json5 写 1.1.1、设备桥上又是一份 1.1.1,而内核早已 1.4.0。
* 设备桥上报 / deviceinfo 回显的真实安装包版本,应当来自同一个来源。
*/
export function appVersion(): string {
try {
const info: bundleManager.BundleInfo =
bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
return info.versionName;
} catch (e) {
return '';
}
}

View File

@ -5,6 +5,9 @@ import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { camera, cameraPicker } from '@kit.CameraKit';
import { fileIo, fileUri } from '@kit.CoreFileKit';
import { appVersion } from './AppVersion';
// ===== 能力结果 =====
@ -17,12 +20,17 @@ export const LOCAL_DEVICE_CAPS: string[] = [
'clipboardsee',
'clipboardsue',
'speakeruse',
'camerasue',
];
export interface CapResult {
status: string; // 'ok' | 'error'
output: string;
error: string;
// chunked 为 true 时表示结果**已由能力内部经二进制分块回传**(如录像),
// DeviceBridge 不要再发 cmd_result否则网关会把后续分块挂在一条已完成的
// 请求上,或先用 cmd_result 结束、再来的 cmd_data_start 找不到归属。
chunked?: boolean;
}
interface DeviceStatusPayload {
@ -71,6 +79,19 @@ function errResult(errMsg: string): CapResult {
return r;
}
/**
* 二进制分块发送回调。由 BridgeRouter 注入(它持有 deviceBridge + reqId
* BridgeCaps 因此不必 import DeviceBridge —— 否则 DeviceBridge 为取 CapResult
* 而 import BridgeCaps两边成环。分层也更干净能力实现不碰 socket。
*/
export type DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => void;
// chunkedResult结果已由能力自己分块发出不再回 cmd_result。
function chunkedResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '', chunked: true };
return r;
}
// ===== screensee截取本应用当前画面前台时为整屏可见内容=====
const SNAPSHOT_COMPONENT_ID: string = 'homeagent-root';
@ -129,6 +150,142 @@ export async function capScreensee(): Promise<CapResult> {
}
}
// ===== camerasue系统相机抓拍 =====
//
// 与桌面/CLI 端的实现路径不同:鸿蒙三方应用不能无界面地直接驱动摄像头
// CameraKit 需要预览 surface + CAMERA 权限,且后台采集受限),能拿到
// “用户正在拍的这一张”的合规路径是系统相机选择器 cameraPicker —— 由系统
// 相机完成采集,本应用只取回结果文件。语义与桌面端一致:现在给 agent 拍一张。
//
// 结果落在应用沙箱saveUri 指向 filesDir不写系统媒体库也就不需要
// READ_IMAGEVIDEO 这类受限权限。
const CAMERASUE_MAX_B64: number = 950000;
/** 录像回传上限:与网关 mediaDir 落盘模式配合,避免把设备内存/WS 打爆。 */
const CAMERASUE_MAX_VIDEO: number = 64 * 1024 * 1024;
/**
* camerasue 实现。
*
* 参数语义与 homeagent-cmdrun 的说明一致:无参数 = 抓拍单张;
* `<N秒>` = 录 N 秒视频。
*
* 视频为什么要走二进制分块:一段 10s 录像动辄数 MBbase64 后还要再膨胀
* 1/3既撑爆模型上下文也撑爆 WS 单帧。cameraPicker 本身支持 VIDEO
* 模式(系统相机会直接进录像界面),取回文件后用 sendDataChunked 按
* cmd_data_start/分块/cmd_data_end 回传——网关侧聚合后落盘成文件agent 拿路径。
* 这与 GUI/CLI 客户端的 camerasue 录像路径一致。
*/
export async function capCamerasue(context: common.UIAbilityContext,
rawArgs: string,
sendChunked: DataChunkSender | null): Promise<CapResult> {
const raw: string = rawArgs.trim();
let videoSeconds: number = 0;
if (raw.length > 0) {
const digits: RegExp = new RegExp('^\\d+$');
if (!digits.test(raw)) {
return errResult('camerasue 参数只接受纯数字秒数,如 camerasue 5');
}
videoSeconds = parseInt(raw, 10);
if (videoSeconds <= 0 || videoSeconds > 300) {
return errResult('录像时长需在 1~300 秒之间');
}
}
const isVideo: boolean = videoSeconds > 0;
const ext: string = isVideo ? '.mp4' : '.jpg';
const filePath: string = context.filesDir + '/camerasue_' + Date.now().toString() + ext;
try {
// cameraPicker 要求 saveUri 指向的文件存在且可写,先建空文件占位
const f: fileIo.File = fileIo.openSync(filePath,
fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE);
fileIo.closeSync(f);
} catch (e) {
return errResult('无法创建相机输出文件');
}
try {
const profile: cameraPicker.PickerProfile = {
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK,
saveUri: fileUri.getUriFromPath(filePath),
};
if (isVideo) {
profile.videoDuration = videoSeconds;
}
const mediaType: cameraPicker.PickerMediaType = isVideo
? cameraPicker.PickerMediaType.VIDEO
: cameraPicker.PickerMediaType.PHOTO;
const res: cameraPicker.PickerResult =
await cameraPicker.pick(context, [mediaType], profile);
if (res.resultCode !== 0 || res.resultUri.length === 0) {
return errResult(isVideo ? '未获取到录像(可能被取消)' : '未获取到照片(可能被取消)');
}
} catch (e) {
return errResult('相机不可用或未授权,请确认应用在前台并允许使用相机');
}
if (isVideo) {
return readAndSendVideo(filePath, sendChunked);
}
return readPhotoAsBase64(filePath);
}
/** 读回录像并以二进制分块回传网关聚合后落盘agent 拿文件路径。 */
function readAndSendVideo(filePath: string, sendChunked: DataChunkSender | null): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
if (stat.size <= 0) {
return errResult('录像文件为空,请重试');
}
if (stat.size > CAMERASUE_MAX_VIDEO) {
return errResult('录像文件过大(超过 64MB请缩短时长');
}
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
if (sendChunked === null) {
return errResult('录像回传通道未就绪,请重试');
}
sendChunked('camera_video', 'video/mp4', new Uint8Array(buf));
// 分块已代表本次请求的完整结果DeviceBridge 不再回 cmd_result。
return chunkedResult('录像已回传(' + stat.size.toString() + ' 字节)');
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('录像读取失败,请重试');
}
}
/** 照片仍走小体积 base64 内联(图片不大,不必分块)。 */
function readPhotoAsBase64(filePath: string): CapResult {
let fd: number = -1;
try {
const stat: fileIo.Stat = fileIo.statSync(filePath);
const buf: ArrayBuffer = new ArrayBuffer(stat.size);
const rf: fileIo.File = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
fd = rf.fd;
fileIo.readSync(fd, buf);
fileIo.closeSync(rf);
fd = -1;
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(buf));
if (b64.length > CAMERASUE_MAX_B64) {
return errResult('照片数据过大,请降低分辨率后重试');
}
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
if (fd >= 0) {
try { fileIo.closeSync(fd); } catch (ignore) {}
}
return errResult('照片读取失败,请重试');
}
}
// ===== clipboardsee / clipboardsue =====
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
@ -249,7 +406,7 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
os_release: deviceInfo.osFullName,
version: '1.1.1',
version: appVersion(),
cpus: 0,
brand: deviceInfo.brand,
manufacturer: deviceInfo.manufacture,
@ -269,29 +426,3 @@ export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
};
return okResult(JSON.stringify(payload));
}
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}

View File

@ -10,6 +10,7 @@
*/
import { CapResult } from './BridgeCaps';
import { appVersion } from './AppVersion';
// ===== 协议消息(与 remotedevice 插件对齐)=====
@ -83,7 +84,7 @@ export function bridgeHelloFrame(deviceId: string, name: string, kind: string,
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.1',
version: appVersion(),
cpus: 0,
};
const device: HelloDevice = {

View File

@ -1,15 +1,16 @@
import { deviceBridge } from './DeviceBridge';
import {
CapResult,
DataChunkSender,
capScreensee,
capCamerasue,
capClipboardSee,
capClipboardsue,
capSpeakerUse,
capDeviceInfo,
capStatus,
parseScreensue,
ScreensuePayload,
} from './BridgeCaps';
import { parseScreensue, ScreensuePayload } from './ScreensueHtml';
import { connStore } from './ConnStore';
import { common } from '@kit.AbilityKit';
@ -84,6 +85,17 @@ async function executeCommand(reqId: string, command: string): Promise<CapResult
}
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
}
if (name === 'camerasue') {
if (appContext === null) {
return errRes('相机能力尚未就绪,请保持应用在前台后重试');
}
// 录像走二进制分块:把「往本请求回传字节」的能力注入能力实现,
// 避免 BridgeCaps 反向 import DeviceBridge 形成循环依赖。
const sender: DataChunkSender = (kind: string, mime: string, bytes: Uint8Array) => {
deviceBridge.sendDataChunked(reqId, kind, mime, bytes);
};
return capCamerasue(appContext, args, sender);
}
if (name === 'clipboardsee') {
if (hasArgs(args)) {
return errRes('clipboardsee 不接受额外参数');

View File

@ -16,6 +16,15 @@ export interface ParsedHistory {
offset: number;
/** 服务端是否还有更早的历史 */
hasMore: boolean;
/**
* 服务端下发的增量游标(响应里的 last_seq
*
* 为什么必须带回来:/chat/history?after=<seq> 只回 seq 更大的消息,
* 客户端存下游标下次带上,才能只拿增量而不重新拉整页
* jianf 说的“暴露数据查询 api前端轮询后 patch 视图”那条路)。
* 缺了它就只能每次全量拉,也就无法发现“别人发来的新消息”。
*/
lastSeq: number;
}
/** 解析后端 /chat/history 的响应体(含分页元数据),供首屏与翻页复用。 */
@ -23,7 +32,7 @@ export function parseHistoryPayload(
obj: Record<string, Object>, alloc: () => number): ParsedHistory {
const rawList: Object | undefined = obj['messages'] as Object | undefined;
if (rawList === undefined || rawList === null) {
return { msgs: [], offset: 0, hasMore: false };
return { msgs: [], offset: 0, hasMore: false, lastSeq: 0 };
}
const arr: Object[] = rawList as Object[];
const msgs: ChatMessage[] = [];
@ -42,6 +51,12 @@ export function parseHistoryPayload(
content: content,
isFinal: true,
};
// seq服务端单调递增序号增量游标与 keyed 对账的定位符。
// 缺失(旧后端/本地乐观消息)时保持 undefined不编造。
const seqVal: Object | undefined = item['seq'];
if (typeof seqVal === 'number' && (seqVal as number) > 0) {
msg.seq = seqVal as number;
}
if (att !== undefined) {
msg.attachment = att;
}
@ -64,7 +79,15 @@ export function parseHistoryPayload(
}
const offset: number = typeof obj['offset'] === 'number' ? obj['offset'] as number : 0;
const hasMore: boolean = obj['has_more'] === true;
return { msgs: msgs, offset: offset, hasMore: hasMore };
// last_seq增量游标。缺失时回退到本页最大 seq保证游标不会倒退。
let lastSeq: number = typeof obj['last_seq'] === 'number' ? obj['last_seq'] as number : 0;
for (let i = 0; i < msgs.length; i++) {
const sq: number | undefined = msgs[i].seq;
if (sq !== undefined && sq > lastSeq) {
lastSeq = sq;
}
}
return { msgs: msgs, offset: offset, hasMore: hasMore, lastSeq: lastSeq };
}
/** 后端 tool_calls 条目带 tool 和 name 两份args/result 可能是对象也可能是字符串。 */

View File

@ -21,6 +21,14 @@ interface SendChatBody {
device_name?: string;
}
/** POST /chat/interrupt 的请求体。 */
interface InterruptBody {
/** true = 停止(立即结束当前推理 + 短路已排队消息false/省略 = 普通中断。 */
stop: boolean;
/** 可选:中断时附带给模型的一句话;停止时为 undefined。 */
message?: string;
}
/**
* 纯文本发送POST /chat。
* 带附件的情况走 sendChatFile后端收下附件后自己写会话并触发 agent
@ -167,10 +175,32 @@ export async function sendChatFile(text: string, path: string, name: string,
chatStore.requestScroll();
}
/**
* 停止当前生成(停止按钮)。
*
* 发送 **`stop: true`**,与「带一句话的中断」区分开:
* - stop:true无 message= ①立即结束当前 LLM 推理(不重试);
* ②对停止那一刻已排队的消息,后端在 pre-action 逐个短路。
* - message 非空 = 普通中断,模型看到被打断的上下文 + 新输入。
*
* 为什么必须带 stop此前这里 POST 的是 null空 body后端把空内容当成
* “无事发生”直接丢掉了——接口回 200 但生成继续跑到自然结束,也就是“按了没反应”。
* 带中文字段比空 body 多不了几个字节,就把语义说清楚了。
*/
export async function interruptChat(): Promise<void> {
try {
await apiClient.post('/chat/interrupt', null);
} catch (e) {
// ignore
if (connStore.getCurrentConnection() === null) {
return;
}
try {
const body: InterruptBody = { stop: true };
await apiClient.post('/chat/interrupt', body);
} catch (e) {
// 停止是“减少工作”的指令,失败不需打断用户;但状态必须复位,
// 否则按钮会一直停在“停止”态,用户以为没生效。
}
// 即时反馈:不等 SSE 的终态事件,先把本地忙态清掉。
// 若后端稍后真的推来终态SSE 处理器会再刷一次(幂等)。
chatStore.setLoading(false);
chatStore.setStage('已停止');
chatStore.forceRefresh();
}

View File

@ -34,6 +34,115 @@ export const K_CHAT_STAGE: string = 'chatStageText';
export const K_CHAT_CONNECTED: string = 'chatSseUp';
const SSE_RECONNECT_MS: number = 5000;
/** 增量轮询间隔:与 WebUI 的 chatTicker 一致3s。 */
const CHAT_POLL_MS: number = 3000;
/**
* 把服务端来的消息并进本地列表,**按 seq 对账**(与 WebUI 的
* applyServerMessages 同口径)。
*
* 为什么不再按“正文内容”去重:那是本次调研确认的缺陷根因。同一个人把
* 同一句话发两次,或本地乐观消息与服务端回显内容相同时,内容比对会把
* 其中一条误判成重复而丢弃“App 发出的消息不显示”就是这个表现)。
* seq 是服务端分配的唯一序号,才是可靠的定位符。
*
* 规则:
* - 服务端消息带 seq本地已有同 seq → 原地更新(工具卡/最终文本是
* 原地改的,不产生新 seq只靠 after 拿不到,必须靠尾部探测更新);
* 本地没有 → 追加。
* - 服务端消息无 seq旧后端退化为「本地末尾同角色同内容则认领」。
* - 本地无 seq 的乐观 user 消息:服务端回显同一句时被认领(补上 seq
* 而不是重复出现——认领先匹配最后一条无 seq 的同类消息。
*
* tailOnly只允许在末尾追加/更新,用于“尾部探测”(拉最新一条做原地更新),
* 避免把历史中间的消息插进来造成顺序错乱。
*/
function reconcileServerMsgs(local: ChatMessage[], incoming: ChatMessage[],
tailOnly: boolean, alloc: () => number): ChatMessage[] {
const out: ChatMessage[] = local.slice();
for (let i = 0; i < incoming.length; i++) {
const sm: ChatMessage = incoming[i];
const sq: number | undefined = sm.seq;
let found: number = -1;
if (sq !== undefined) {
// 从尾部往前找:新消息总在尾部,省掉全表扫描
for (let j = out.length - 1; j >= 0 && j >= out.length - 12; j--) {
if (out[j].seq === sq) {
found = j;
break;
}
}
}
if (found >= 0) {
// 原地更新:保留本地 id组件按 id 复用,不重建气泡),
// 只覆盖服务端权威字段。
const prev: ChatMessage = out[found];
if (prev.content !== sm.content) {
prev.content = sm.content;
}
if (sm.reasoningContent !== undefined && prev.reasoningContent !== sm.reasoningContent) {
prev.reasoningContent = sm.reasoningContent;
}
if (sm.toolCalls !== undefined) {
prev.toolCalls = sm.toolCalls;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
if (sm.source !== undefined) {
prev.source = sm.source;
}
prev.isFinal = true;
prev.isStreaming = false;
continue;
}
if (tailOnly) {
// 尾部探测:只有比本地最后一条 seq 更大才有意义,否则忽略(它已在中间)
let maxLocalSeq: number = 0;
for (let j = 0; j < out.length; j++) {
const ls: number | undefined = out[j].seq;
if (ls !== undefined && ls > maxLocalSeq) {
maxLocalSeq = ls;
}
}
if (sq !== undefined && sq > maxLocalSeq) {
sm.id = alloc();
out.push(sm);
}
continue;
}
// 认领本地乐观消息:本地末尾尚未拿到 seq 的同类消息,视为它的回显。
if (sq !== undefined) {
let claimed: number = -1;
for (let j = out.length - 1; j >= 0; j--) {
const lm: ChatMessage = out[j];
if (lm.seq !== undefined) {
break;
}
if (lm.role === sm.role) {
claimed = j;
break;
}
}
if (claimed >= 0) {
const prev: ChatMessage = out[claimed];
prev.seq = sq;
prev.isFinal = true;
prev.isStreaming = false;
if (sm.source !== undefined) {
prev.source = sm.source;
}
if (sm.attachment !== undefined) {
prev.attachment = sm.attachment;
}
continue;
}
}
sm.id = alloc();
out.push(sm);
}
return out;
}
class ChatStore implements ChatStreamSink {
private msgs: ChatMessage[] = [];
@ -49,6 +158,11 @@ class ChatStore implements ChatStreamSink {
private newIds: number[] = [];
// SSE 正在为当前轮次推送内容时置 true阻止 POST 响应重复创建消息
private sseActiveForTurn: boolean = false;
/** 增量游标:本地已知的最大服务端 seq对应 WebUI 的 state.chatLastSeq */
private lastSeq: number = 0;
/** 增量轮询中进行中,避免重入 */
private polling: boolean = false;
private pollTimer: number = -1;
private sse: SseClient = new SseClient();
init(): void {
@ -284,19 +398,112 @@ class ChatStore implements ChatStreamSink {
const resp = await apiClient.getWithTimeout('/chat/history?limit=' + CHAT_PAGE_SIZE, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
if (parsed.msgs.length === 0) {
return;
// 首屏允许列表本来就是空的(全部加载失败/新会话):这里不做早退,
// 否则游标 lastSeq 永远建不起来,增量轮询也就起不来。
if (this.msgs.length === 0) {
this.msgs = parsed.msgs;
} else {
// 与本地未回显的消息按 seq 对账,而不是整表替换。
//
// 为什么sync_required 触发的 reloadHistory 会与刚发出的 POST 竞争;
// 若历史快照里还没有这条 user 消息,整表替换会让它凭空消失
// (“客户端侧发出的消息不显示”)。
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
}
this.msgs = parsed.msgs;
this.offset = parsed.offset;
this.hasEarlier = parsed.hasMore;
// 增量游标:首屏全量后据 last_seq 初始化,后续只拿增量。
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
this.forceRefresh();
this.requestScroll();
this.startPolling();
} catch (e) {
// ignore history load failure
}
}
/**
* 增量轮询:只拉 seq 更大的消息,再补一次尾部探测。
*
* 这是 jianf 说的「接口调用方式改变」——后端 /chat/history 早已提供
* after=<seq> 游标commit 9711177WebUI 前端据此 3s 轮询增量并 patch
* 视图。鸿蒙端一直只做首屏全量加载,于是**其他端/其他渠道发来的消息
* 永远进不来**(页面不会加载新的聊天信息)。
*
* 尾部探测不可省:工具调用与最终文本是**原地改写**已有 seq 的记录,
* 不会产生新 seq单靠 after 拿不到这些更新。
*/
async pollIncremental(): Promise<void> {
if (this.polling) {
return;
}
this.polling = true;
try {
if (this.lastSeq <= 0) {
// 游标还没建立(首屏没跑或失败):退回全量,交给 loadHistory 建游标。
this.polling = false;
await this.loadHistory();
return;
}
const resp = await apiClient.getWithTimeout(
'/chat/history?after=' + this.lastSeq, 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
const parsed: ParsedHistory = this.parseHistory(obj);
let changed: boolean = false;
if (parsed.msgs.length > 0) {
this.msgs = reconcileServerMsgs(this.msgs, parsed.msgs, false,
() => this.allocId());
changed = true;
}
if (parsed.lastSeq > this.lastSeq) {
this.lastSeq = parsed.lastSeq;
}
// 尾部探测:拿最新一条做原地更新(工具卡/最终文本)。
try {
const tailResp = await apiClient.getWithTimeout('/chat/history?limit=1', 8000);
const tailObj: Record<string, Object> = JSON.parse(tailResp.body) as Record<string, Object>;
const tail: ParsedHistory = this.parseHistory(tailObj);
if (tail.msgs.length > 0) {
const before: number = this.msgs.length;
this.msgs = reconcileServerMsgs(this.msgs, tail.msgs, true,
() => this.allocId());
if (this.msgs.length !== before) {
changed = true;
}
}
} catch (e) {
// 尾部探测失败不影响增量结果
}
if (changed) {
this.forceRefresh();
}
} catch (e) {
// 轮询失败静默下一拍会重试SSE 仍在负责流式渲染)
} finally {
this.polling = false;
}
}
/** 起 3s 增量轮询(与 WebUI 的 chatTicker 同节奏)。重复调用无副作用。 */
startPolling(): void {
if (this.pollTimer >= 0) {
return;
}
this.pollTimer = setInterval(() => {
this.pollIncremental();
}, CHAT_POLL_MS);
}
stopPolling(): void {
if (this.pollTimer >= 0) {
clearInterval(this.pollTimer);
this.pollTimer = -1;
}
}
/**
* 向上翻页:拉 offset 之前的更早一页,前置到 messages 头部并保持滚动位置。
* 触顶yOffset 接近 0且有更早历史时由 onDidScroll 触发。
@ -344,6 +551,11 @@ class ChatStore implements ChatStreamSink {
if (cur === null) {
return;
}
// 增量轮询与 SSE 同时拉起SSE 负责 token 级流式观感,
// 轮询负责「界面最终状态」——两者是两条腿,缺一不可
// (轮询没接是“其他端/其他渠道的新消息永远不出现”的直接原因)。
// 放在这里而不是只放在 loadHistory 末尾:首屏加载失败时也要能自愈。
this.startPolling();
this.sse.close();
this.sse.connect(cur, '/chat/events',
(ev: SseEvent) => {
@ -381,6 +593,7 @@ class ChatStore implements ChatStreamSink {
/** 页面消失:断线、停表,避免后台空转 */
disconnect(): void {
this.cancelReconnect();
this.stopPolling();
this.sse.close();
this.cancelRefresh();
}

View File

@ -17,6 +17,18 @@ export const DEFAULT_WS_PORT: number = 9890;
/** 聊天历史首屏条数:只拉最新 N 条,向上滚动触顶再加载更早的 */
export const CHAT_PAGE_SIZE: number = 40;
// ===== AppStorage 跨页面信号键 =====
//
// 未连接后端时的“入口可达性”靠这三个键串起来:聊天空态按钮 → 切主 Tab →
// 设置页打开连接二级页。不用组件回调是因为按钮与目标分属不同的 Swiper 子页,
// 中间还隔着 Index逐层传回调会把两个无关页面耦在一起。
/** 当前是否已配置并激活后端连接(空态/入口的响应式判断) */
export const K_HAS_CONN: string = 'hasConn';
/** 外部请求切换主 Tab-1 = 无请求),由 Index 监听 */
export const K_REQUESTED_TAB: string = 'requestedTab';
/** 请求设置页打开某个二级页(空串 = 无请求),由 SettingsPage 监听 */
export const K_SETTINGS_SUB: string = 'settingsSubRequest';
// ===== sakura / frost palette (style.css :root) =====
export const COLOR_SAKURA_100: string = 'rgba(10, 89, 247, 0.1)';
export const COLOR_SAKURA_200: string = 'rgba(10, 89, 247, 0.16)';

View File

@ -247,6 +247,11 @@ export class DeviceBridgeClient {
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
// res.chunked 时结果已由能力自己用二进制分块发完(如录像):
// 此时再发 cmd_result 会让网关把一条已完成请求与后续分块错配。
if (res.chunked === true) {
return;
}
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');

View File

@ -0,0 +1,182 @@
/**
* screensue 载荷解析与 HTML 渲染(无 UI 依赖)。
*
* 从 BridgeCaps.ets 抽出:加进 HTML 检测/编码后那个文件超过 520 行,
* 超出工程「单文件 ≤400 行」的约定而「screensue 内容怎么解析、怎么渲染」
* 与「设备能力怎么实现」本就是两件事。
*/
import { util } from '@kit.ArkTS';
// ===== screensue 内容解析 =====
// 服务端协议: screensue [秒] <内容>0=常驻。
export interface ScreensuePayload {
duration: number; // 秒0 表示常驻直到用户关闭
content: string;
}
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
}
p.content = rest;
return p;
}
/**
* 判断 agent 下发的 screensue 内容是不是 HTML。
*
* 服务端两侧协议都允许 HTMLlocaluse 的 local_screensue 在 Linux 用 browsh/w3m
* 渲染 HTMLremotedevice 的工具说明写的就是「显示内容/HTML」
*
* ★ 判据必须容忍前导杂质:实测 agent 常把整段文档连引号一起传进来
* `'<html>…</html>'`),而"首个非空字符必须是 '<'"的旧判据直接判否、
* 退回纯文本渲染,用户看到的就是满屏标签源码(截图取证)。
*
* 所以这里扫到第一个「像标签开头」的 '<',不要求它在开头;但只有后面紧根
* 字母或 '/' 时才认,避免把 "a < b" 这类文本里的比较符当标签。
*/
export function looksLikeHtml(content: string): boolean {
return findHtmlStart(content) >= 0;
}
/** 找到第一个「像标签开头」的 '<';没有则 -1。 */
function findHtmlStart(content: string): number {
for (let i = 0; i < content.length; i++) {
if (content.charAt(i) !== '<') {
continue;
}
const next: string = i + 1 < content.length ? content.charAt(i + 1) : '';
if (next === '/') {
const after: string = i + 2 < content.length ? content.charAt(i + 2) : '';
if (isAsciiLetter(after)) {
return i;
}
continue;
}
if (isAsciiLetter(next)) {
return i;
}
}
return -1;
}
function isAsciiLetter(ch: string): boolean {
if (ch.length === 0) {
return false;
}
const c: number = ch.charCodeAt(0);
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
/**
* 取出真正的 HTML 片段:剥掉 agent 误带的包裹引号,再从头截到第一个标签。
*
* 剥引号是必须的:不剥的话那个孤立的 `'` 会被 Web 当正文渲染出来
* (截图上第一行就是它),而且它还会把后续判据带偏。返回 '' 表示不是 HTML。
*/
export function screensueHtmlDocument(content: string): string {
let body: string = content.trim();
// 反复剥成对的包裹引号agent 把整段 HTML 当命令参数传时的常见形态)。
while (body.length >= 2) {
const first: string = body.charAt(0);
const last: string = body.charAt(body.length - 1);
if ((first === '\'' && last === '\'') || (first === '"' && last === '"')) {
body = body.substring(1, body.length - 1).trim();
continue;
}
break;
}
const idx: number = findHtmlStart(body);
if (idx < 0) {
return '';
}
if (idx > 0) {
body = body.substring(idx);
}
return body;
}
/**
* 把 screensue 内容编成可直接交给 Web 组件 `loadData` 的 base64。
*
* 为什么必须上 Web不再用 RichTextRichText 只认极小标签子集,
* 对 <style>、CSS 动画、内联 SVG 一律不渲染 —— 实测 agent 推的是完整
* HTML 文档(含 @keyframes 与 <svg>RichText 下只能看到源码。用户明确要求引入 webview。
*
* 为什么用 base64 而不是明文 loadDataencoding 非 base64 时按 URL 规则转义,
* 一个几 KB 的完整文档会撞上长度/转义问题base64 是整篇加载的推荐方式,
* 中文与引号、'#' 也不会被二次转义(自己手写 UTF-8 编码,见 base64Utf8
*
* 片段(非完整文档)补一层 shell加 <meta viewport> 让窄屏排版正确,
* 并注入主题前景色,避免深色主题下黑字不可见。返回 '' 表示不是 HTML走纯文本渲染
*/
export function screensueWebData(content: string, dark: boolean): string {
const fragment: string = screensueHtmlDocument(content);
if (fragment.length === 0) {
return '';
}
if (hasHtmlShell(fragment)) {
// 已是完整文档:不再包壳,也不注入颜色(由页面自带样式决定)。
return base64Utf8(fragment);
}
const fg: string = dark ? '#E8ECF4' : '#1B2430';
const wrapped: string = '<!DOCTYPE html><html><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<style>html,body{margin:0;padding:0}'
+ 'body{padding:10px;color:' + fg + ';font-family:sans-serif;font-size:16px;'
+ 'line-height:1.6;word-break:break-word;-webkit-text-size-adjust:100%}'
+ 'img,svg,video{max-width:100%;height:auto}</style></head><body>'
+ fragment + '</body></html>';
return base64Utf8(wrapped);
}
/** 内容是否已是完整 HTML 文档(有 <html> 或 <!DOCTYPE>),不必再包壳。 */
function hasHtmlShell(s: string): boolean {
const head: string = s.substring(0, 400).toLowerCase();
return head.indexOf('<html') >= 0 || head.indexOf('<!doctype') >= 0;
}
/**
* UTF-8 字符串 → base64。
*
* 不能把 UTF-16 码元直接交给 Base64Helper那样中文会变成乱码。
* 这里手写 UTF-8 字节序列(按码点,含代理对合成)后再编码。
*/
export function base64Utf8(s: string): string {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let code: number = s.charCodeAt(i);
// 代理对emoji 等)合成成一个码点。
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < s.length) {
const next: number = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
code = ((code - 0xD800) << 10) + (next - 0xDC00) + 0x10000;
i++;
}
}
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
} else if (code < 0x10000) {
bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
} else {
bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F),
0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
}
}
const helper: util.Base64Helper = new util.Base64Helper();
return helper.encodeToStringSync(new Uint8Array(bytes));
}

View File

@ -8,7 +8,8 @@
*/
import { ChatMessage } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_FAST, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { SUB_CONNECTIONS } from '../common/SettingsModel';
import { navBar } from '../common/NavBarController';
import { ChatAttachment } from '../model/Model';
import { chatStore, K_CHAT_REV, K_CHAT_SCROLL_REV, K_CHAT_LOADING, K_CHAT_STAGE } from '../common/ChatStore';
@ -20,6 +21,8 @@ import { PageTopBar } from './PageTopBar';
@Component
export struct ChatStream {
@StorageProp('themeIsDark') private isDark: boolean = true;
/** 是否已配置后端连接(决定空态是引导连接还是引导开聊) */
@StorageProp(K_HAS_CONN) private hasConn: boolean = false;
@StorageProp(K_CHAT_LOADING) private loading: boolean = false;
@StorageProp(K_CHAT_STAGE) private stage: string = '';
/** 数组快照的订阅信号 */
@ -33,10 +36,22 @@ export struct ChatStream {
private scroller: Scroller = new Scroller();
private autoScrolling: boolean = false;
/** 滚动世代号scrollRev 每次变化自增,旧一轮的延迟滚动据此作废 */
private scrollGen: number = 0;
private navHidden: boolean = false;
aboutToAppear(): void {
this.messages = chatStore.messages();
// 首帧如果已经有消息(历史加载先于本组件挂载完成),必须自己滚到底。
//
// 为何必须补这一下:@Watch 只在值**变化**时触发,不触发初始值。
// ChatPage.aboutToAppear 里 loadHistory() 是异步的,若它在 ChatStream
// 构造之前就完成了requestScroll 递增的 chatScrollRev 就成了“挂载前
// 已经发生的变化”——本组件的 onScrollReq 永远不会被调到,表现就是
// “消息加载好了却停在顶部/中间,不滚到最新”。
if (this.messages.length > 0) {
this.scrollToBottom();
}
}
private onChatRev(): void {
@ -66,14 +81,30 @@ export struct ChatStream {
private scrollToBottom(): void {
this.autoScrolling = true;
// 多次重试:内容高度是消息数组更新后**若干帧内**才逐步确定的,
// 长历史 / Markdown / 思考卡 / 工具卡布局都慢。旧实现只重试到 260ms
// 长历史下那一次仍落在“当时”的底部(用户看到的是加载完停在中间)。
// 用递增间隔重试到 ~1s让后几帧的布局增长也跟得上。
//
// scrollRev 变化时旧一轮的定时器不能继续干预新滚动,用世代号作废。
const gen: number = ++this.scrollGen;
const delays: number[] = [50, 120, 220, 360, 550, 800];
for (let i = 0; i < delays.length; i++) {
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.scroller.scrollEdge(Edge.Bottom);
}, delays[i]);
}
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
setTimeout(() => {
if (gen !== this.scrollGen) {
return;
}
this.autoScrolling = false;
this.navHidden = false;
navBar.setVisible(true);
}, 450);
}, 900);
}
/**
@ -186,6 +217,50 @@ export struct ChatStream {
.width('100%')
.height('100%')
// 层1.05:空态 —— 未连接后端时给出明确的“去设置连接”入口。
//
// 为什么必须有:全新安装时聊天页只有一条空列表 + 输入框,用户看不到
// 任何连后端的入口(入口在设置页的二级页里,很容易找不到)。
if (this.messages.length === 0 && !this.loading) {
Column({ space: 10 }) {
Image($r('app.media.ic_link'))
.width(34)
.height(34)
.fillColor(this.palette().textMuted)
.draggable(false)
Text(this.hasConn ? '开始新的对话' : '尚未连接后端服务')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textPrimary)
Text(this.hasConn
? '在下方输入框发送第一条消息'
: '请先在“后端连接”里填写服务地址与 API Key')
.fontSize(12)
.fontColor(this.palette().textMuted)
.textAlign(TextAlign.Center)
if (!this.hasConn) {
Button('去设置连接')
.height(34)
.fontSize(13)
.backgroundColor(this.palette().accent)
.fontColor(Color.White)
.margin({ top: 4 })
.onClick(() => {
// 跨页信号:切到设置 Tab并让设置页直接打开连接二级页
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, SUB_CONNECTIONS);
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, 3);
})
}
}
.width('100%')
.height('100%')
.padding({ left: 44, right: 44 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
// 自身不吃触摸(空白处仍可滑列表),但子节点(按钮)正常响应
.hitTestBehavior(HitTestMode.Transparent)
}
// 层1.5:顶栏遮罩(自身撑满并顶部对齐,全链路 hitTest None触摸完全穿透
PageTopBar({ title: '聊天' })

View File

@ -13,7 +13,7 @@ import { apiClient } from '../common/ApiClient';
import { connStore } from '../common/ConnStore';
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_MD, RADIUS_SM, K_HAS_CONN } from '../common/Constants';
import { SubPageLayer, PlainCard } from './SubPage';
import { common } from '@kit.AbilityKit';
@ -49,6 +49,11 @@ export struct ConnectionsPane {
}
}
/** 连接变更后广播状态:聊天空态据此隐藏“去设置连接”入口。 */
private syncConnFlag(): void {
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
}
private currentConnName(): string {
for (let i = 0; i < this.connections.length; i++) {
if (this.connections[i].id === this.currentId) {
@ -64,6 +69,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('已切换连接', false);
@ -88,6 +94,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已添加', false);
@ -107,6 +114,7 @@ export struct ConnectionsPane {
if (cur !== null) {
apiClient.setConnection(cur);
}
this.syncConnFlag();
this.connections = connStore.getConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已更新', false);
@ -151,6 +159,7 @@ export struct ConnectionsPane {
} else {
apiClient.clearConnection();
}
this.syncConnFlag();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.toast('连接已删除', false);
});

View File

@ -2,12 +2,24 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../commo
import { GradientBackground } from './GradientBackground';
import { PageTopBar } from './PageTopBar';
import { MotionBase } from './MotionBase';
import { looksLikeHtml, screensueWebData } from '../common/ScreensueHtml';
import { webview } from '@kit.ArkWeb';
/**
* agent 主动推送的前台内容页。
*
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
* 从而让左侧一级页面和主导航保持可见、可操作。
*
* 内容可能是纯文本,也可能是 HTML服务端两侧协议都允许见 ScreensueHtml
*
* HTML 走 **Web 组件**用户明确要求agent 推的常是完整文档 —— 带 <style>
* CSS 动画、内联 <svg>、radial-gradient 背景。RichText 只认极小标签子集,
* 对这些一律不渲染,实测只能看到满屏源码。
*
* 安全:内容来自 agent第三方所以显式关掉 JS 与本地文件访问 ——
* 注意 **javaScriptAccess 默认是 true**,不显式关掉等于让远端内容在客户端执行脚本。
* 纯文本仍走 Text无需开销也不该把文本塞进 Web
*/
@Component
export struct ScreensuePage {
@ -17,6 +29,16 @@ export struct ScreensuePage {
onClose: () => void = () => {
};
/** 内容是不是 HTML决定走 Web 还是 Text。 */
private htmlMode(): boolean {
return looksLikeHtml(this.pushedText);
}
/** HTML 的 base64 载荷(空串表示不是 HTML。 */
private webData(): string {
return screensueWebData(this.pushedText, this.isDark);
}
build() {
Stack({ alignContent: Alignment.Bottom }) {
GradientBackground()
@ -44,13 +66,22 @@ export struct ScreensuePage {
.width('100%')
Column() {
Text(this.pushedText)
.fontSize(16)
.lineHeight(25)
.fontColor(this.palette().textPrimary)
.width('100%')
.textAlign(TextAlign.Start)
.copyOption(CopyOptions.LocalDevice)
if (this.htmlMode()) {
// HTML整篇交给 Web 渲染base64 loadData见 screensueWebData
// 高度固定 420vpWeb 不参与父级自适应测量,给 height('100%')
// 会在 Scroll 里塌成 0。内容区本身可滚。
ScreenWebView({ data: this.webData(), isDark: this.isDark })
.width('100%')
.height(420)
} else {
Text(this.pushedText)
.fontSize(16)
.lineHeight(25)
.fontColor(this.palette().textPrimary)
.width('100%')
.textAlign(TextAlign.Start)
.copyOption(CopyOptions.LocalDevice)
}
}
.width('100%')
.padding(18)
@ -101,3 +132,66 @@ export struct ScreensuePage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 承载 screensue HTML 的 Web 视图(独立组件,便于按内容变化重建控制器)。
*
* 为什么单开一个组件而不是直接在 ScreensuePage 里放 Web
* `WebviewController` 与 Web 组件是一对一绑定的必须等组件挂载onControllerAttached
* 才能真正 loadData把它隔离在这里ScreensuePage 只管布局与倒计时。
*/
@Component
struct ScreenWebView {
// @Watch 挂在这里是必须的ScreensuePage 在 `if (screensueVisible)` 里常驻,
// 第二次 screensue 只会改这个 @Prop 而不会重建组件,而 onControllerAttached
// 只在挂载时触发一次 —— 不 watch 就会一直显示上一条推送的内容。
// 实测:连推两条不同 HTML倒计时变了、Web 里还是旧画面。
@Prop @Watch('onDataChanged') data: string = '';
@Prop isDark: boolean = true;
private controller: webview.WebviewController = new webview.WebviewController();
/** 控制器是否已与 Web 组件关联(过早 loadData 会抛 17100001。 */
private attached: boolean = false;
/** data 变化时重新加载(组件不重建,必须显式刷新)。 */
onDataChanged(): void {
if (this.attached) {
this.load();
}
}
build() {
Web({ src: '', controller: this.controller })
// ★ 内容来自 agent第三方显式关闭脚本与本地文件访问。
// javaScriptAccess 的默认值是 true不写这一行等于放任远端内容执行脚本。
.javaScriptAccess(false)
.fileAccess(false)
.domStorageAccess(false)
.onlineImageAccess(false)
.imageAccess(true) // 保留内联/数据 URI 图片(不联网)
.zoomAccess(false) // 禁手势缩放,避免与外层滚动打架
.horizontalScrollBarAccess(false)
.verticalScrollBarAccess(false)
.darkMode(WebDarkMode.Off)
.backgroundColor(Color.Transparent)
// 控制器挂载完才 loadData过早调用会抛 17100001控制器未与组件关联
.onControllerAttached(() => {
// 挂载完成才允许 loadData此前的变更由 onDataChanged 记着,这里补一次。
this.attached = true;
this.load();
})
.width('100%')
.height('100%')
}
/** 以 base64 整篇加载(空串直接跳过,避免 Web 显示错误页)。 */
private load(): void {
if (this.data.length === 0) {
return;
}
try {
this.controller.loadData(this.data, 'text/html', 'base64');
} catch (e) {
// 加载失败不该把整页带崩:保持空白,用户仍能看到顶栏与关闭按钮。
}
}
}

View File

@ -120,10 +120,15 @@ export struct StatusSummaryCard {
.alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
//
// 必须是**子组件**@Prop 单向下发)而不是本组件里的 @Builder
// ArkUI 的 @Builder 按值传参是“快照”语义,父组件重渲染时
// 不会用新值重跑 builder —— 实测K_PLUGINS 已经是 35
// 但 @Builder 里画的还是首次的 0永远显示 '-'。
Row({ space: 10 }) {
this.kpiTile($r('app.media.ic_plug'), '插件', this.plugins, COLOR_ACCENT)
this.kpiTile($r('app.media.ic_tool'), '工具', this.tools, COLOR_CYAN)
KpiTile({ icon: $r('app.media.ic_plug'), label: '插件', value: this.plugins, tint: COLOR_ACCENT })
KpiTile({ icon: $r('app.media.ic_tool'), label: '工具', value: this.tools, tint: COLOR_CYAN })
}
.width('100%')
.margin({ top: 14 })
@ -214,6 +219,51 @@ export struct StatusSummaryCard {
}
}
/**
* 能力计数小卡(插件 / 工具)。
*
* 单独成组件而非 @Builder@Builder 的按值参数不会随父组件重渲染而刷新,
* 数值会永远停在首次渲染的 0。@Prop 是单向下发,父组件因 @StorageProp
* 变化重渲染时,子组件拿到新值并重绘。
*/
@Component
struct KpiTile {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource = $r('app.media.ic_plug');
@Prop label: string = '';
@Prop value: number = 0;
@Prop tint: string = '';
build() {
Row({ space: 8 }) {
Image(this.icon)
.width(16)
.height(16)
.fillColor(this.tint)
.draggable(false)
Column({ space: 1 }) {
Text(this.value > 0 ? this.value.toString() : '-')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
Text(this.label)
.fontSize(11)
.fontColor(this.palette().textMuted)
}
.alignItems(HorizontalAlign.Start)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover)
.alignItems(VerticalAlign.Center)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 运行状态明细:设置页「运行状态」二级页面的内容。
*

View File

@ -26,6 +26,14 @@ export interface ChatMessage {
toolCalls?: ToolCallInfo[];
/** 消息来源通道:'webui' | 'channel' | 'webui/<device_id>' 等;用于区分设备/渠道消息 */
source?: string;
/**
* 服务端单调递增序号(后端 ChatMsg.seq
*
* 它是与后端增量查询(/chat/history?after=<seq>)对账的唯一定位符:
* 本地乐观消息没有 seq服务端回显后靠 seq 认领并去重。
* 没有它就只能拿“正文内容”去重,一旦同一句话发两次就会误删。
*/
seq?: number;
/** 图片/文件附件(后端 ChatMsg.attachment */
attachment?: ChatAttachment;
}

View File

@ -7,14 +7,15 @@ import { apiClient } from '../common/ApiClient';
import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH, K_HAS_CONN, K_REQUESTED_TAB, K_SETTINGS_SUB } from '../common/Constants';
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground';
import { ScreensuePage } from '../components/ScreensuePage';
import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { snapshotComponentId } from '../common/BridgeCaps';
import { ScreensuePayload } from '../common/ScreensueHtml';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
@ -83,6 +84,8 @@ struct Index {
/** 底部手势条高度vp */
@State bottomGesture: number = 16;
@StorageProp('themeIsDark') @Watch('onThemeChanged') private isDark: boolean = true;
/** 外部请求切换主 Tab未连接时聊天空态的“去设置连接”用 */
@StorageProp(K_REQUESTED_TAB) @Watch('onRequestedTab') private requestedTab: number = -1;
private swiper: SwiperController = new SwiperController();
private screensueTimer: number = -1;
private snapshotBuilder: CustomBuilder = (): void => { }; // 由 @Builder 传入的实际锚点
@ -101,6 +104,12 @@ struct Index {
if (cur !== null) {
apiClient.setConnection(cur);
}
// 后端连接状态广播:聊天空态根据它决定是否显示“去设置连接”。
// Index.aboutToAppear 在 EntryAbility 等 connStore.init 之后才跑,
// 所以此处读到的连接状态就是真实的启动态。
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
AppStorage.setOrCreate<number>(K_REQUESTED_TAB, -1);
AppStorage.setOrCreate<string>(K_SETTINGS_SUB, '');
// 种子化自定义背景图状态到 AppStorageGradientBackground 响应读取
const st = connStore.getSettings();
AppStorage.setOrCreate<string>('bgImage', st.bgImage ?? '');
@ -191,6 +200,23 @@ struct Index {
AppStorage.set<number>('currentTab', this.currentTab);
}
/**
* 响应外部切 Tab 请求(聊天空态的“去设置连接”)。
*
* 为什么不能直接改 AppStorage 的 currentTabIndex 的 currentTab 是
* @StateSwiper.index() 只认它;外部写 AppStorage 不会驱动 Swiper。
* 所以用独立请求键 + @Watch 把请求转成自己的状态变更。
*/
private onRequestedTab(): void {
const t: number = this.requestedTab;
AppStorage.set<number>(K_REQUESTED_TAB, -1);
if (t < 0 || t >= this.tabs.length) {
return;
}
this.currentTab = t;
navBar.setVisible(true);
}
private syncSystemBar(): void {
const dark: boolean = this.isDark;
const bg: string = dark ? '#000000' : '#F1F3F5';

View File

@ -3,7 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError';
import { connStore } from '../common/ConnStore';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { ConnectionConfig, AppSettings } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, K_HAS_CONN, K_SETTINGS_SUB } from '../common/Constants';
import { RADIUS_MD, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
import { StatusDetailContent } from '../components/StatusCards';
@ -42,6 +42,10 @@ export struct SettingsPage {
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
/** 未连接时是否已自动弹过连接页——避免用户关掉后又被 onNavigationModeChange 弹回来 */
private autoOpenedConn: boolean = false;
/** 外部请求打开某个二级页(聊天空态的“去设置连接”用) */
@StorageProp(K_SETTINGS_SUB) @Watch('onSubRequest') private subRequest: string = '';
// ===== backend key/value editor state =====
@State sections: SettingsSection[] = [];
@ -86,6 +90,39 @@ export struct SettingsPage {
this.connections = connStore.getConnections();
const cur = connStore.getCurrentConnection();
this.currentId = cur !== null ? cur.id : '';
// 广播连接状态:聊天空态据此显示“去设置连接”
AppStorage.setOrCreate<boolean>(K_HAS_CONN, apiClient.hasConnection());
// 可能从聊天空态带着“打开连接页”的请求进来(本页尚未挂载时请求已写入)
const pending: string = AppStorage.get<string>(K_SETTINGS_SUB) ?? '';
if (pending.length > 0) {
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
setTimeout(() => {
this.openSub(pending);
}, 0);
}
}
/**
* 宽屏右栏默认该展示哪一页:没连上就把“后端连接”给出来。
*
* 为什么不能只靠一级入口行:入口行在列表里,用户很容易略过;
* 而“未连接”恰恰是最需要直接看到连接表单的时刻。窄屏同理,
* 在 onNavigationModeChange(Stack) 里会把连接页直接推到面前。
*/
private initialSub(): string {
return apiClient.hasConnection() ? SUB_STATUS : SUB_CONNECTIONS;
}
/** 外部请求打开二级页(“去设置连接”) */
private onSubRequest(): void {
const id: string = this.subRequest;
if (id.length === 0) {
return;
}
AppStorage.set<string>(K_SETTINGS_SUB, '');
this.autoOpenedConn = true;
this.openSub(id);
}
private palette(): ThemePalette {
@ -287,12 +324,17 @@ export struct SettingsPage {
if (mode === NavigationMode.Split) {
markSubPageOpen(true);
if (this.navStack.size() === 0) {
this.openSub(SUB_STATUS);
this.openSub(this.initialSub());
}
} else {
this.navStack.clear(false);
this.activeSub = SUB_NONE;
markSubPageOpen(false);
// 窄屏:未配置后端时直接推连接页,保证“设置里一定能找到连后端的入口”。
if (!this.autoOpenedConn && !apiClient.hasConnection()) {
this.autoOpenedConn = true;
this.openSub(SUB_CONNECTIONS);
}
}
})
}

View File

@ -24,7 +24,7 @@ func handleBuiltin(cmd string, cfg *Config, state *State, reconnect func(), out
/conn use <name> switch to saved connection
/conn del <name> delete saved connection
Server commands (sent to agent):
Server commands (local 与 remote 行为一致):
/status system status
/kernel kernel status
/settings [prefix] list settings
@ -32,10 +32,27 @@ Server commands (sent to agent):
/plugin list list installed plugins
/plugin install <url> install plugin
/plugin remove <name> remove plugin
/plugin disable <name> disable plugin
/plugin enable <name> enable plugin
/plugin info <name> plugin details
/memory query <text> query graph memory
/knowledge list knowledge base
/memory graph dump full graph memory snapshot
/memory text [n] recent text-memory events + stats
/memory context [q] assembled memory context (what gets injected)
/memory tools memory tool definitions + tool prompt
/knowledge list knowledge base (+stats)
/knowledge delete <name> delete knowledge item
/config dump kernel config (JSON)
/tracker change-tracking stats
/tracker rollback roll back this session's file changes
/adapters list loaded Lua adapters
/adapters remove <name> remove a Lua adapter
/network network status + LLM endpoints
/runtime scheduler / residents / channel topology
/terminals list terminal sessions
/cmd/history command execution history
/terminal create|write|read|close … (local mode; calls agentcli tools)
/persona show persona (/persona set default|custom|later [text])
/agents list agents
/chat <text> send to agent
@ -54,6 +71,26 @@ Any other text is sent to the agent directly.`)
reconnect()
return true
// /stop 与 /interrupt取消当前生成可附带一句新指令
// 之前 /help 里写着这条命令,但 handleBuiltin 根本没有对应 case
// 于是它像普通文本一样被发给了 Agent。
// 本地交给 CLI 插件(内核优先级 L3远端走 WebUI 的 chat/interrupt
// (内核优先级 L4。两条路都是“真中断”不是发一句话。
case cmd == "/stop" || cmd == "/interrupt" ||
strings.HasPrefix(cmd, "/stop ") || strings.HasPrefix(cmd, "/interrupt "):
msg := stopMessage(cmd)
if rc := state.RemoteConn(); rc != nil {
body := fmt.Sprintf(`{"message":%q}`, msg)
if _, err := rc.DoAPI("POST", "/api/v1/chat/interrupt", body); err != nil {
fmt.Fprintf(out, "interrupt failed: %v\n", err)
} else {
fmt.Fprintln(out, "interrupt sent")
}
} else {
state.Send(cmd)
}
return true
case strings.HasPrefix(cmd, "/connect "):
cfg.Socket = strings.TrimSpace(cmd[9:])
cfg.Remote = ""
@ -143,7 +180,7 @@ Any other text is sent to the agent directly.`)
rc.DoAPI("PUT", "/api/v1/settings", body)
fmt.Fprintln(out, "ok")
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
@ -152,7 +189,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("GET", "/api/v1/settings", "")
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
@ -172,7 +209,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("POST", "/api/v1/plugins", body)
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
@ -182,7 +219,7 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("DELETE", "/api/v1/plugins/"+name, "")
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
@ -192,17 +229,56 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("GET", "/api/v1/plugins/"+name, "")
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
case strings.HasPrefix(cmd, "/memory query "):
q := strings.TrimSpace(cmd[14:])
// disable/enable本地由 CLI 插件处理,远端走插件管理 REST 动作接口。
case strings.HasPrefix(cmd, "/plugin disable ") || strings.HasPrefix(cmd, "/plugin enable "):
verb := "disable"
name := strings.TrimSpace(cmd[16:])
if strings.HasPrefix(cmd, "/plugin enable ") {
verb = "enable"
name = strings.TrimSpace(cmd[15:])
}
if name == "" {
fmt.Fprintln(out, "usage: /plugin disable|enable <name>")
return true
}
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
d, _ := rc.DoAPI("POST", "/api/v1/plugins/"+name+"/"+verb, "")
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
case strings.HasPrefix(cmd, "/memory "):
sub := strings.TrimSpace(cmd[8:])
if rc := state.RemoteConn(); rc != nil {
switch {
case strings.HasPrefix(sub, "query "):
q := strings.TrimSpace(strings.TrimPrefix(sub, "query "))
d, _ := rc.DoAPI("GET", "/api/v1/memory?query="+q, "")
printJSON(out, d)
case sub == "graph":
d, _ := rc.DoAPI("GET", "/api/v1/memory/graph", "")
printJSON(out, d)
case sub == "text" || strings.HasPrefix(sub, "text "):
d, _ := rc.DoAPI("GET", "/api/v1/memory/text", "")
printJSON(out, d)
case sub == "context" || strings.HasPrefix(sub, "context "):
q := strings.TrimSpace(strings.TrimPrefix(sub, "context"))
d, _ := rc.DoAPI("GET", "/api/v1/memory/context?q="+q, "")
printJSON(out, d)
case sub == "tools":
d, _ := rc.DoAPI("GET", "/api/v1/memory/tools", "")
printJSON(out, d)
default:
fmt.Fprintln(out, "usage: /memory query <text> | /memory graph | /memory text [n] | /memory context [q] | /memory tools")
}
} else {
state.Send(cmd)
}
return true
@ -212,16 +288,126 @@ Any other text is sent to the agent directly.`)
d, _ := rc.DoAPI("DELETE", "/api/v1/knowledge/"+name, "")
printJSON(out, d)
} else {
state.Send(cmd[1:])
state.Send(cmd)
}
return true
case cmd == "/knowledge":
case cmd == "/knowledge" || cmd == "/knowledge list" || cmd == "/knowledge stats":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/knowledge", "")
printJSON(out, d)
} else {
state.Send("/knowledge")
state.Send(cmd)
}
return true
case cmd == "/config":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/config", "")
printJSON(out, d)
} else {
state.Send("/config")
}
return true
case cmd == "/tracker":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/tracker", "")
printJSON(out, d)
} else {
state.Send("/tracker")
}
return true
case cmd == "/tracker rollback":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("POST", "/api/v1/tracker/rollback", "")
printJSON(out, d)
} else {
state.Send("/tracker rollback")
}
return true
case cmd == "/adapters" || strings.HasPrefix(cmd, "/adapters remove "):
if rc := state.RemoteConn(); rc != nil {
if strings.HasPrefix(cmd, "/adapters remove ") {
name := strings.TrimSpace(cmd[17:])
d, _ := rc.DoAPI("DELETE", "/api/v1/adapters/"+name, "")
printJSON(out, d)
} else {
d, _ := rc.DoAPI("GET", "/api/v1/adapters", "")
printJSON(out, d)
}
} else {
state.Send(cmd)
}
return true
case cmd == "/persona" || strings.HasPrefix(cmd, "/persona set "):
if rc := state.RemoteConn(); rc != nil {
if strings.HasPrefix(cmd, "/persona set ") {
rest := strings.TrimSpace(cmd[13:])
mode := rest
content := ""
if idx := strings.IndexByte(rest, ' '); idx > 0 {
mode = rest[:idx]
content = strings.TrimSpace(rest[idx+1:])
}
body := fmt.Sprintf(`{"mode":%q,"content":%q}`, mode, content)
d, _ := rc.DoAPI("POST", "/api/v1/persona", body)
printJSON(out, d)
} else {
d, _ := rc.DoAPI("GET", "/api/v1/persona", "")
printJSON(out, d)
}
} else {
state.Send(cmd)
}
return true
case cmd == "/terminals":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/terminals", "")
printJSON(out, d)
} else {
state.Send("/terminals")
}
return true
case cmd == "/cmd/history":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/cmd/history", "")
printJSON(out, d)
} else {
state.Send("/cmd/history")
}
return true
case cmd == "/terminal" || strings.HasPrefix(cmd, "/terminal "):
if rc := state.RemoteConn(); rc != nil {
// 远端 WebUI 没有“开终端”的 REST 端点(终端由 agentcli 工具创建),
// 不静默当聊天发出去,直接说明。
fmt.Fprintln(out, "remote 模式暂不支持终端操作;请在 local 模式或让 agent 调 terminal_* 工具")
} else {
state.Send(cmd)
}
return true
case cmd == "/runtime":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/runtime", "")
printJSON(out, d)
} else {
state.Send("/runtime")
}
return true
case cmd == "/network":
if rc := state.RemoteConn(); rc != nil {
d, _ := rc.DoAPI("GET", "/api/v1/network", "")
printJSON(out, d)
} else {
state.Send("/network")
}
return true
@ -239,6 +425,16 @@ Any other text is sent to the agent directly.`)
}
}
// stopMessage 从 /stop 或 /interrupt 行里取出可选的中断附带消息(空串=纯取消)。
func stopMessage(cmd string) string {
for _, prefix := range []string{"/interrupt", "/stop"} {
if strings.HasPrefix(cmd, prefix) {
return strings.TrimSpace(strings.TrimPrefix(cmd, prefix))
}
}
return ""
}
func printJSON(out io.Writer, d map[string]interface{}) {
if d == nil {
fmt.Fprintln(out, "(no data)")

View File

@ -15,6 +15,7 @@ import (
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
)
// ===== 设备桥管理 =====
@ -46,6 +47,9 @@ func startDeviceBridge(addr, token string) error {
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"cpus": runtime.NumCPU(),
// 客户端版本与内核同源internal/metadeviceinfo 回显的软件版本
// 因此与 homed 一致,不再是一个空缺字段。
"version": meta.Version,
}
// 确保 gateway URL 格式正确

View File

@ -11,6 +11,8 @@ import (
"sync"
"syscall"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
)
const (
@ -129,8 +131,15 @@ func main() {
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
testCap := flag.String("test-cap", "", "测试本地能力screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue如 --test-cap screensue")
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
showVersion := flag.Bool("version", false, "打印版本并退出")
flag.Parse()
// 版本号直接来自 internal/meta与 homed 同一事实源,不可能各写一个)。
if *showVersion {
fmt.Printf("waiter %s (commit %s, built %s)\n", meta.Version, meta.Commit, meta.BuildTime)
return
}
// 本地能力测试模式(无需连接服务器)
if *testCap != "" {
runCapTest(*testCap, *testCapArgs)
@ -270,9 +279,9 @@ func runLineMode(state *State, cfg *Config, history *History) {
addrLabel = cfg.Remote
}
if colors {
fmt.Printf("%sHomeAgent CLI%s %s(%s://%s)%s\n", colorBold, colorReset, colorDim, modeLabel, addrLabel, colorReset)
fmt.Printf("%sHomeAgent CLI%s %s%s (%s://%s)%s\n", colorBold, colorReset, colorDim, "v"+meta.Version, modeLabel, addrLabel, colorReset)
} else {
fmt.Printf("HomeAgent CLI (%s://%s)\n", modeLabel, addrLabel)
fmt.Printf("HomeAgent CLI v%s (%s://%s)\n", meta.Version, modeLabel, addrLabel)
}
fmt.Println("Type /help for commands.")

364
demo.md
View File

@ -1,364 +0,0 @@
# HomeAgent 自愈 / Failback 架构设计与讨论全程记录 (demo.md)
> 本文档按讨论演进顺序记录"守护 / 保活 / 文件追踪 / 崩溃自愈 / failback"整个设计过程,
> 含代码勘查结论、现实日志记录模式分析,以及最终定稿的架构与尚未落地的接口清单。
---
## 0. 背景与目标
框架目标:**内核零 IO、插件承载所有 IO**`homed 内核 ← PluginSDK → 插件`),三层记忆 + 常用块。
**Failback 的定位所有错误的一层兜底Safe-Mode 式),而非针对单一场景。**
- 主 agent全量 LLM agent是一切骚操作的执行者可能把自己搞到无法自愈的任意状态
LLM 源改坏 / 系统网络proxy、DNS、host破坏 / 配置文件损坏 / OOM / panic / 崩溃循环……
这些错误无法在**同一个被污染环境内**用自身操作自救。
- 故需要一层**脱离主 agent 坏环境的、最小厚度且独立可控的恢复层**——
类似 Windows **安全模式 / 启动修复**:只带最小驱动集合 + 干净 LLM 源(锚定 IP
单一职责:**让主 agent 回到可用状态;若不可行,则做最后的系统级兜底(回滚快照 / 重启)。**
- 它不替代主 agent 的功能,只在主 agent 无法自愈时作为最后一道防线出现。能省则省、能判定就不推理、有底即主张。
---
## 1. 现状盘点(代码勘查结论)
### 1.1 守护进程(`internal/supervisor/daemon.go`
- 主 agent in-process 常驻,`agent.Start()``cmd/homed/main.go:468` 直接启动,**无独立进程边界**。
- `healthLoop``checkAgent` 判 LLM 是否可达,判定**仅依赖 `network.Monitor``AggregateResult().LLMAPIReachable`**`daemon.go:132`)。
- `failCount >= MaxRetries`(默认 3`handleFailure`
- 有 tracker → `trk.Rollback()`;失败才降级 `restartAgent`
- `restartAgent``daemon.go:166`**只改内存状态再重新 Register不真重启任何进程**,几乎空转。
关键问题:
- 探测是系统级HTTP/DNS/TCP回滚只作用于 `<data>/agentfs` overlay 的 upper**二者对象错位**。
- `AgregateResult` 在无 LLM endpoint 时恒 healthy机制形同虚设。
- `lastHB` 每轮都置 `time.Now()``Uptime` 无意义。
- `RollbackPolicy``HealthThreshold/CooldownPeriod/AutoRollback` 都是死字段,只用 `MaxRetries`
### 1.2 文件追踪(`internal/tracker`
- overlayfs 三层:`lower/upper/work → merged``tracker.go:156`)。
- `captureFSState(upperDir)` 递归遍历 upper 并 sha256`changeset.go:56``PreAction/PostAction` 前后 diff`toolcall.go:86-94`)。
- **lower 恒空**`Init``MkdirAll`,从不填充)→ 无 canonical 基线可回滚。
- `Rollback()` = `RemoveAll(upper)` 清空全部 changesets`FileChange.Content`(本应存回滚原文)**从未回填**。
结论overlay/tracker 对"LLM 可达性"这主场景**错位**,只能作数据兜底。
### 1.3 通信插件真相(`third_party/homeagent-sdk/example/qq/plugin.go`
- agent 对外通信全部由**插件设置**驱动,存于 **ConfigRegistry / SQLite config.db**
`qq.napcat_url``qq.listen``qq.files_dir``qq.remote_dir``dm/group_policy``plugin.go:120-130`)。
- 插件在 `Start()``getSetting(...)` 读设置(`plugin.go:134-143`)→ **改动配置需重载插件才生效**
- `cfgmgr` 提供 `config_set / config_batch_set` 可运行时改任意 core/插件配置(`cfgmgr/plugin.go:54,103`)。
### 1.4 LLM 源与"恢复即生效"
`internal/sdk/llm_impl.go:103 ReloadFromConfig()` **已存在**
- `cfg := cfgReg.ToConfig()` 从 config.db 重建(含 `core.llm.sources.*`,见 `registry.go:590`
- `mgr.Reset()` → 逐源 `NewLuaAdaptedProvider` → 重设默认。
即:**LLM 源的"恢复即生效"钩子已经具备**,缺的是"快照 + 探测 + 触发"三件事。
---
## 2. 现实环境:日志记录模式内参
> 看真实 systemd 托管的 HomeAgent`/home/newqqagent`)日志,**目的是弄清现有的日志模型**
> (写哪、什么格式、工具调用打在哪),为 failback / recoveryDiag 的 `diag_log_scan` 提供准确的解析依据。
### 2.1 systemd 托管现状(样例)
```
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
```
- 实测数据区:`/home/newqqagent/` 下有 `log/``config.db``agentfs/`overlay merged`snapshots/``changesets/``knowledge/``memory/``plugins/`
`cli.sock``adapters/``homed.log``memos.json` 等——**日志以独立子目录 `log/` 存放,与配置/快照/knowledge 分置**。
- 启动段确认:`[files] started, sandbox: /`**files 沙箱=全主机 `/` 实锤**`main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8`LLM 走本地 mock
### 2.2 日志目录与格式(核心)
- **目录配置**`core.log.path`,默认 `<dataDir>/log``internal/config/registry.go:415,508`)。
- **单次运行文件**:每次启动新建 `homed_<YYYY-MM-DD_HH-MM-SS>.log``cmd/homed/main.go:75`
写入 `logDir` 下;`log.SetOutput(io.MultiWriter(os.Stderr, logFile))``main.go:80`)——
**同时进 stderrsystemd 捕获到 journald/`journalctl -u`)与文件**
- **格式**:标准 Go `log.Printf`,即 `YYYY/MM/DD HH:MM:SS file.go:line: [module] message`
用户可看文件,也可用 `journalctl -u homeagent.service` 看同一来源(同一行)。
- **层级压缩 + 保留**`internal/log/manager.go:26-28` + `compressor.go`
- 周度压缩 → `week_<year>-W<ww>.tar.gz`;月度 → `month_<yyyy-mm>.tar.gz`;年度 `year_*.tar.gz`;
raw 文件正则 `^homed_(\d{4}-\d{2}-\d{2})_\d{2}-\d{2}-\d{2}\.log$``compressor.go:16`)。
- 保留策略:`core.log.retention`default forever`core.log.retention_months`default 3
`applyRetention` 只留当前周 + 近 N 月(`retention.go`)。
实测:`log/` 下即为 `homed_2026-08-03_08-03-38.log` + `month_2026-*.tar.gz` + `week_2026-W31.tar.gz`,与代码一致。
### 2.3 工具调用日志打在哪儿(进程主循环 `internal/agent/core/process.go`
| 位置 | 日志行内容 | 备注 |
|---|---|---|
| `process.go:36` | `[agent] tool call loop start, max_ctx=… target=… fixed=… mem=… ctx=… N tools, M events, personality=X, docs=K` | 每轮循环开头上下文统计 |
| `process.go:196` | `[agent] executing tool: <name> (plugin=<p>, id=<id>)` | **只记工具名/插件/id不记 args** |
| `process.go:226` | `[agent] tool <name> result: <截断100字符>` | 结果截断到 100 字符(`truncateStr`|
| `process.go:218` | `[agent] skip tool <name>: plugin <name> unhealthy` | 插件崩溃态跳过 |
| `process.go:89/109/121/125` | LLM fallback`trying provider %q (#%d)` / `switched active provider` / `provider %q marked unavailable (HTTP %d)` / `provider %q failed` | 主循环内 LLM 商可观测 |
| `toolcall.go:20` | `[agent] tool %s panic: %v` + `debug.Stack()` | 工具 panic + 完整栈 |
| `toolcall.go:41` | `[agent] tool %s timed out after 60s` | 60s 超时 |
| `toolcall.go:92` | `[agent] tool %s changed %d files (changeset: %s)` | overlay changeset 摘要 |
| 插件侧 | Lua 插件 `sdk.log``print("[lua-plugin] <level>: <msg>")` | 模板见 `cmd_debug.go:74`/`templates.go` |
- 完整的工具**入参/结果**在 EventBus 事件 `EventToolCall``{tool, plugin, args, result, status}``process.go:184/205`)而非文件日志——**文件日志只是执行/结果的摘要指针**(结果被截断)。
- 重要观察:日志里未见 shell/cmd 之类的操作系统执行类调用摘要落盘(`[cmd]` 只在工具结果里),
需要的话由 `diag_log_scan``executing tool: cmd_*` 前缀做签名匹配即可。
### 2.4 崩溃 / 重启观察
- `NRestarts=0`MainPID 自 08-03 起稳定 3330844。曾出现**真实重复 panic**pid 3310036
`[stage] handler panic: runtime error: invalid memory address or nil pointer dereference`03:23 / 05:23 / 07:23约每 2h
`stages.go:139``RunStage` recover 吞掉 → **进程未真崩**systemd 未见重启。
- 08:03:38 有过一次干净 `[homed] stopped` → systemd `Started` → pid 3310036 → 3330844。
- 对 failback 的意义:现有崩溃防护全赖 **in-process recover**,真实进程级崩溃从未被监督;
且当前 `Restart=always` 由 systemd **直绑 worker 且无 StartLimit**——一旦真崩并陷入循环,
systemd 每 10s 反复拉起,没有独立 failback/取证层。→ guard 取代点在此。
---
## 3. 设计演进(讨论全过程)
### 3.0 起点:`internal/supervisor` + `internal/tracker` 我是"保活 + 文件追踪"
- 保活 = 网络健康感知 + 内存态重置;追踪 = overlayfs 变更集 + `Rollback` 清空。
- 意图LLM 不可达 → 回滚 agent 文件改动 → 自愈。
### 3.1 第一次纠正:回滚对象错位
- 回滚只作用于 overlayfs upper而真正能改坏网络的路径files 默认 `/`,可写 `/etc/resolv.conf``/etc/hosts`、代理配置)与 LLM 配置config.db、adapters 目录)**都不在 overlay 内**。
- → 检测命中但回滚删错对象,闭环在"回滚"这一环断掉。
### 3.2 第二次纠正:授权不能靠外部插件主动配合
- 阶段管道是被动通知(`RunStage` 收集 error不主动拒绝拒绝依赖各插件 handler。
- 插件是外部不可控对象 → "安全 = 插件主动授权放行"不成立。
- → 安全应做**默认拒绝**,由内核在 `executeToolCall` 分发点按 ToolDef 的 capability 裁决;配置快照 + replug 只兜底"可文件化"改动。
- 中途又修正:真正的祸首不是 IO 组件,而是 LLM 源/系统网络配置IO 组件崩溃是正交偶发轴,不并进来。
### 3.3 第三次纠正LLM 源才是核心config.db + ReloadFromConfig 已具钩子)
- `llm_set_source` 只切内存默认;真正改坏 LLM 源靠 `cfgmgr.config_set` 写 config.db `core.llm.sources.*`
- `ReloadFromConfig()` 已能"恢复即生效" → 只需补:**LLM 配置快照 + 真实 liveness 探测QuickChat+ 在 handleFailure 里触发 RFO 复检**。
- 但用户进一步点明:**重点是 agent 改了系统网络配置文件proxy/DNS** —— 这类连救援 LLM 都连不上。
### 3.4 定稿架构一lastFailback独立进程第一道防线
- 独立进程 + agent 碰不到的 root:0600 配置 + 最小插件集(文件读写 + cmd+ 单一任务。
- 用**锚定 IP/干净 DNS** 的 LLM 源绕开坏掉的 DNS/proxy。
- 若 failback 也失败 → agentfs 回滚最近快照 + 重启计算机(应用快照前系统配置)。
- 兼顾 systemd 托管冲突(主 agent 崩溃循环被 systemd 反复拉起 → failback 无限增殖)。
### 3.5 定稿架构二:去掉强 systemd 依赖 → 独立 guard 守护进程
- systemd 只绑定**静态、极小、永驻的 guard**guard 作为父进程拉起 worker`homed` 全量 agent
- guard 通过 IPCunix socket / 匿名 pipe收 worker 心跳;
父进程 `waitpid` 天然 reap 崩溃/OOM/信号退出。
- worker 可崩可换systemd 完全看不见 → **failback 唯一性 + 无风暴天然成立**
- guard 抗 OOM`oom_score_adj` 拉低,优先杀 worker 而非 guard。
### 3.6 定稿架构三:更纯粹 —— 同一二进制的两个 role非独立二进制
- **不新建二进制**。`homed` 拆两个入口:
- `homed --role=guard`:父守护进程,先起,负责拉起/监测/探活/裁决/恢复。
- `homed --role=agent`:主 agent工作进程guard 的子进程)。
- `homed --role=agent --boot=failback`:恢复用 agent受限 bootstrap
- guard 复用现有 homeagent 基础设置;检测到崩溃时拉起 failback agent只加载
**webfetch + 文件读写 + cmd** 三个插件,外加 **恢复知识库插件****常见错误检测插件**
复用 agent 核心以 `trigger_prompt` 初始化,要求其"尝试恢复主 agent"。
- guard 配置**独立 YAML**,不复用 config.db逃生舱知识必须脱离 agent 可达区)。
### 3.7 recoveryDiag崩溃取证 / 根因定位插件(省 token 关键)
- 铁律:**工具返回结论,不返回原文**(签名式/统计式/确定性排序)。
- 工具集:
| 工具 | 作用 |
|---|---|
| `diag_triage` | exit code/信号+uptime+头部嫌疑,快速粗分"进程死亡 vs 配置类不可达" |
| `diag_db` | config.db integrity_check + LLM 源解析校验,逐项 ok/fail |
| `diag_log_scan` | 时间窗内命中已知错误签名panic/provider failed/unreachable/sql/OOM|
| `diag_delta` | 崩溃前 config/agentfs 与 last-good 快照 diff"改了什么"|
| `diag_loc` | 综合正交,输出按因果强度排序的定位结论 + 推荐动作 |
- 崩溃类别 → 恢复分支决策表:
| 结论类 | 走分支 |
|---|---|
| 配置损坏类 | 还原 config 快照 + ReloadFromConfig + 拉活主 agent无需 agent 推理)|
| 系统网络类 | 还原 DNS/proxy → 重载主 agent第一步小修命中即停|
| 进程失稳类OOM/panic| 不还原配置,检查内存/泄漏 → 重建 worker |
| 未知/混合 | 放开 webfetch/知识库,用 rescue 源 + diag_loc 摘要最小推理 |
- 只有"未知/混合"消耗 token前几类近乎 0 token。
- 结论落盘 `recovery_kb/diag_<ts>.json`,回流知识库,同类崩溃下次直接命中,越用越省。
### 3.8 三层防御总览(最终)
```
L0 平时:核心只读探活 + 写前快照config_set 写 core.llm.* 前、files 写 /etc 前自动留档)
L1 failbackguard 拉起Safe-Mode 式兜底):按诊断分支逐类恢复——
还原 DNS/proxy → 还原 config 快照 + ReloadFromConfig → QuickChat 复检 → 拉起主 agent
L2 最后手段agentfs 回滚最近快照 + 重启(应用快照前系统配置)
```
- failback 是**所有错误LLM 不可达 / 网络 / 配置损坏 / OOM / panic / 崩溃循环)的统一兜底层**
并非只针对某一条;`diag_*` 决定它走哪条恢复路径。
---
## 4. 最终架构(定稿)
### 4.1 进程拓扑(同一二进制,两个 role
```
systemd ──▶ homed --role=guard # 父守护进程,永驻、静态、极小
├─ exec ──▶ homed --role=agent # 主 agent可崩
└─ exec ──▶ homed --role=agent --boot=failback # 恢复用 agent
```
- guard先起持有恢复知识锚定源 / DNS/proxy 还原 / 配置快照 / failback 逻辑)。
- workerguard 子进程,心跳经 IPC崩溃由 guard reap + 判型。
- failback agent = 受限启动webfetch+files+cmd + 恢复知识库 + recoveryDiag单一任务"恢复主 agent"N 轮有界。
### 4.2 guard 独立 YAML 示例
> 现状实现§5 已完成):`guard.yaml` 已落地为 `max_restarts / heartbeat_timeout / heartbeat_interval / llm_snapshot / failback_enabled / last_resort / restart_command / reboot_grace` 子集(`cmd/homed/guard.go`),恢复梯子=重试→LLM 基线恢复→failback 受限启动→last_resort。下表的 rescue 源 / trigger_prompt / N 轮 failback 推理是目标态,未实现。
```yaml
role: guard
llm:
sources:
- name: rescue
base_url: http://1.2.3.4:8080 # 锚定 IP 直连,绕开被破坏的 DNS/代理
api_key: ${GUARD_RESCUE_KEY}
adapter: ... # 锚定/SNI 型适配器
recovery:
max_attempts: 4 # 可配置尝试轮次
attempt_timeout: 120s
knowledge_base: /opt/homeagent/recovery/
trigger_prompt: "你是恢复 agent唯一任务让主 agent 恢复运行。优先还原 DNS/代理,再重载 LLM 源…"
plugins: [webfetch, files, cmd]
last_resort:
action: reboot # restart_app | reboot
snapshot_before: true
```
### 4.3 guard 恢复状态机N 轮有界)
```
guard 检测( exit≠0 | OOM | 心跳超时 | guard 锚定源探活失败 )
1. 固化追溯exit/信号、panic、journal、OOM 上下文 → 永久区
2. 拉起 failback agent受限插件 + rescue 源 + trigger_prompt + 知识库 + recoveryDiag
for attempt in 1..N:
(可选先 diag_triage/diag_loc 判型)
failback 尝试恢复
guard 每轮复检主 agent 是否可达/存活
├─ 成功 → 结束,交回主 agent
└─ 超时/失败 → kill 重建,进入下一轮
3. N 轮未成 → 取消 failback agent
→ agentfs 回滚崩溃前最近快照
→ 依 yaml 执行最后手段restart_app 或 reboot
```
### 4.4 systemd 绑定(极简,杜绝风暴)
```
[Unit] # guard
OnFailure=... # 备用通常不触发guard 稳定)
[Service] # guard
Restart=always # guard 静态稳定 → 几乎不重启
ExecStart=/usr/local/bin/homed --role=guard ...
# No StartLimit needed for loop 情况guard 不崩
```
- 主 agent 崩 → 只触发 guard 内部 failbacksystemd 仅看 guard看不到 worker 崩溃循环。
- failback 唯一性 + 无启动风暴:由"guard 永驻、唯一裁决"天然保证。
- guard 抗 OOM`oom_score_adj` 拉低。
---
## 5. 尚未落地的接口 / 下一步
**已完成**
`recoverydiag` 快速检查插件(`third_party/homeagent-sdk/example/recoverydiag/`,外部插件)。
- 五件套全实现:`diag_triage`(退出码/信号/存活粗分)、`diag_db`config.db integrity_check + LLM 源字段校验sqlite3 CLI 优先、缺失回退内核 Settings`diag_log_scan`(日志签名按类计数)、`diag_delta`baseline vs 现状 diff`diag_loc`(四项结论正交排序 + 推荐恢复动作)。
- 全部确定性、返回结论非原文、`NoMemory`;工具实际名带插件前缀 `recoverydiag_diag_*`
- 已通过 go vet + 6 个单测(对真实 config.db/日志跑通3 个 LLM 源全 ok、日志命中 228 行主导 provider/fatal并用**仓库内重建的 plugindev** 打出 `dist/recovery_diagnostics_linux_amd64.hmap`,装进运行实例(`/home/newqqagent/plugins/recoverydiag/`)加载成功、注册 5 工具。
- **结论落盘 + 知识库回流**`diag_loc``persist`(缺省 true→ 写 `<data_dir>/recovery_kb/diag_<ts>.json`(可配 `recovery_kb_dir`),并经 `sdk.Knowledge().Add``diag:<cause>:<ts>` 回流知识库(同类崩溃下次直接命中,越用越省);失败不阻塞工具。新增 `TestDiagLocPersist`
- 顺带修复:仓库内 `plugindev` 需重编译(`/usr/local/bin/plugindev` 是旧版、桥模板缺 `InjectInputSync`);重编译见 `third_party/homeagent-sdk/tools/plugindev``go build -o ... .`
- 注意:本环境 `snapshots/``changesets/` 均为空direct 模式无基线)→ `diag_delta` 需显式传入 baseline_dir未来接 guard 时由快照解包目录提供。
`ConfigRegistry` 快照钩子(`internal/config/registry.go`)。
- `SnapshotCoreLLM()`:抓全部 `core.llm.*` 键值快照;`RestoreCoreLLM(snap)`:精确还原(快照内键回写、快照外当前键删除)。
- `SetLLMSnapshotFile(path)`:写前自动留档——此后任意写 `core.llm.*` 键先把当前 LLM 配置整体快照到该文件guard 恢复的外部基线homed 启动即挂 `<data>/llm_snapshot.json`
- 文件持久化对:`SaveLLMSnapshot/LoadLLMSnapshot`。新增 `TestSnapshotRestoreCoreLLM``TestLLMSnapshotFile``TestSetLLMSnapshotFile`
`homed --role{guard,agent}` 入口拆分 + `--boot=failback` 受限插件集(`cmd/homed/`)。
- `--role=guard` 父守护(永驻):读独立 `<data>/guard.yaml`(避开被改坏的 config.db拉起 worker`--role=agent`)、心跳探活 + waitpid 收割、信号转发停机。
- `--role=agent` 工作进程:默认启动全插件;`--boot=failback` 走插件白名单(`core.agent.failback_plugins`,缺省 `webui,pluginmgr,recoverydiag`),内核 webfetch/files/cmd 仍内置可用。
- guard 恢复梯子(已端到端实测):连续 `max_restarts` 次 normal 崩溃 → `restoreLLMBaseline`(从 llm_snapshot.json 恢复 core.llm.*)→ failback 受限启动 → failback 也崩 → `last_resort`restart_app / reboot
- 心跳worker 每 5s 触碰 `<data>/heartbeat`agent 角色 goroutineguard 以 mtime 判定卡死(超 `heartbeat_timeout` 即 SIGKILL 计入崩溃)。
- 插件注册表加 `SetLoadAllowlist(names)`白名单外插件含已注册工厂一律跳过failback 40 工具 → 4 工具实测通过。
现状 bug 修复supervisor/network/tracker
- **monitor 无 endpoint 恒 healthy**`internal/network/monitor.go` + `pkg/types``NetworkCheckResult``EndpointsConfigured`;无探活端点时不再谎报 `LLMAPIReachable=true`(置 false + Error`NewMonitor` 初始化空切片消除启动竞态daemon 仅在配置了端点时才据此判定降级。探活端点新增 `core.defaults.llm_endpoints`(逗号分隔,留空自动取 LLM 源 base_url生产从此健康检查有真实目标。
- **lastHB 恒置 now**`internal/supervisor/daemon.go``checkAgent` 接入真实存活源 `SetHeartbeatSource`homed 注册为 agent core `GetKernelStatus`),只在确认 agent 存活时更新 `lastHB`;无源置 `HealthUnknown`,存活源丢失置 `HealthDown` 且不再刷新 lastHB。
- **restartAgent 只改内存空转**:增 `SetRestartHandler`homed 注册为"清理后以 `exitRestartRequested=42` 退出"不再假装成功guard 把 42 识别为"请求重建"`workerRestartRequested`,不计失败轮次直接重建),无 guard 时 systemd `Restart=always` 兜底。无 handler 时仅内存复位并打日志。
- **tracker 三缺陷**`internal/tracker/``captureFSStateWithContent` 为 before 基线捕获原文(上限 8MB`diffStates` 对 modified/deleted 回填 `FileChange.Content`(回滚用原文);新增 `RollbackLatest()` 定向撤销最近一条 changeset`Rollback()` 改为按时间逆序逐条逆应用(还原被改/被删文件原文、删除新增),无 changeset 时才退回整目录重置。新增 6 个测试覆盖。
剩余:
- guard ↔ agent 心跳 IPC 升级为带自诊断上报的 `PING/ACK`(当前为文件心跳 + 退出码)。
- supervisor 适配成 guard 的探测/裁决逻辑;`ReloadFromConfig()` 复用为"恢复即生效"。
- 生产实例迁移:编译新 homed、改 systemd 只托管 guard`--role=guard`),确认 failback 插件recoverydiag就位。
---
## 附录真实日志节选systemd 托管示例,`/home/newqqagent`
```
# systemd unit
homeagent.service: Type=simple, ExecStart=/usr/local/bin/homed -data /home/newqqagent, Restart=always, RestartSec=10
llm-mock.service: ExecStart=/usr/bin/python3 /opt/llm-mock/mock_server.py, Restart=always, RestartSec=3
# 日志文件与格式log.Printf 标准格式)
2026/08/03 08:03:38 main.go:80: [homed] logging to /home/newqqagent/log/homed_2026-08-03_08-03-38.log
2026/08/03 08:03:38 daemon.go:61: [homed] daemon started successfully
2026/08/03 08:03:39 tracker.go:65: [tracker] initialized (work=/home/newqqagent/agentfs)
# 工具调用摘要process.go
2026/08/03 10:03:52 process.go:36: [agent] tool call loop start, max_ctx=32768 target=26214 fixed=1306 mem=8302 ctx=16606 176 tools, 31 events, personality=true, docs=5589
... process.go:196: [agent] executing tool: <name> (plugin=<p>, id=<id>)
... process.go:226: [agent] tool <name> result: <截断100字符>
# 启动 & 沙箱
homed[3330844]: [files] started, sandbox: /
homed[3330844]: main agent started, model=mock-model base=http://127.0.0.1:18080/v1 sources=3 adapters=8
# 重复 in-process panic被 RunStage recover 吞掉,未进程级崩溃)
homed[3310036]: [stage] handler panic: runtime error: invalid memory address or nil pointer dereference # 03:23 / 05:23 / 07:23
# 一次性干净重启systemd 手动/触发 Startedpid 3310036 → 3330844
homed[3310036]: [homed] stopped
systemd[1]: Stopped homeagent.service - HomeAgent - 24/7 AI Butler.
systemd[1]: Started homeagent.service - HomeAgent - 24/7 AI Butler.
# 运行状态
systemctl show homeagent.service -p NRestarts → 0
systemctl show homeagent.service -p MainPID → 3330844自 08-03 起稳定)
# 归档
/home/newqqagent/log/: homed_2026-08-03_08-03-38.log + week_2026-W31.tar.gz + month_2026-*.tar.gz
```

View File

@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# 客户端版本与内核版本同步。
#
# 为什么要有这个脚本:内核的 internal/meta/meta.go Version 是唯一事实源,
# 而各客户端各有各的版本字段——GUI 在 package.json、鸿蒙在 AppScope/app.json5、
# waiter 走编译期注入。手工各改各的必然漂移(写这个脚本时的现状:内核 1.4.0、
# GUI 1.0.0、鸿蒙 1.1.1,三个号互不相干)。
#
# 用法:
# bash deploy/scripts/sync-client-versions.sh # 同步到内核当前版本
# bash deploy/scripts/sync-client-versions.sh 1.4.0 # 同步到指定版本
# bash deploy/scripts/sync-client-versions.sh --check # 只校验,漂移则退出 1
#
# 同步目标:
# cmd/gui/package.json version
# cmd/ohos/HomeAgent/AppScope/app.json5 versionName + versionCode
#
# waiter 不在此列:它直接引用 internal/meta.Version同一进程内编译
# 没有第二份版本字段可漂。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
CHECK=0
VERSION=""
for arg in "$@"; do
case "$arg" in
--check) CHECK=1 ;;
*) VERSION="$arg" ;;
esac
done
# 未显式给版本时,从内核唯一事实源读。
if [ -z "$VERSION" ]; then
VERSION="$(grep -oE 'Version = "[^"]+"' "$ROOT/internal/meta/meta.go" | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
fi
[ -n "$VERSION" ] || { echo "sync-client-versions: 无法确定版本号internal/meta/meta.go 里没找到 Version" >&2; exit 1; }
# versionCode 规则X*1e6 + Y*1e3 + Z。鸿蒙要求 versionCode 单调递增的整数,
# 直接搬 semver 会丢信息,所以用主/次/补丁三段编码1.4.0 → 1004000
CODE="$(python3 - "$VERSION" <<'PY'
import re, sys
m = re.match(r'^(\d+)\.(\d+)\.(\d+)', sys.argv[1])
if not m:
sys.exit("sync-client-versions: 版本号必须是 X.Y.Z 形态,得到 %r" % sys.argv[1])
print(int(m.group(1)) * 1000000 + int(m.group(2)) * 1000 + int(m.group(3)))
PY
)"
GUI_PKG="$ROOT/cmd/gui/package.json"
OHOS_APP="$ROOT/cmd/ohos/HomeAgent/AppScope/app.json5"
DRIFT=0
note() { printf ' %-52s %s\n' "$1" "$2"; }
# ── GUI ──
gui_cur="$(python3 - "$GUI_PKG" <<'PY'
import json, sys
print(json.load(open(sys.argv[1]))["version"])
PY
)"
if [ "$gui_cur" != "$VERSION" ]; then
DRIFT=1
if [ "$CHECK" -eq 1 ]; then
note "cmd/gui/package.json" "$gui_cur → 应为 $VERSION"
else
python3 - "$GUI_PKG" "$VERSION" <<'PY'
import json, sys
p, v = sys.argv[1], sys.argv[2]
d = json.load(open(p))
d["version"] = v
# indent=2 保留原格式;末尾补换行,避免 diff 噪声
with open(p, "w") as f:
json.dump(d, f, indent=2, ensure_ascii=False)
f.write("\n")
PY
note "cmd/gui/package.json" "$gui_cur$VERSION"
fi
fi
# ── 鸿蒙 ──
ohos_name="$(grep -oE '"versionName"[[:space:]]*:[[:space:]]*"[^"]+"' "$OHOS_APP" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')"
ohos_code="$(grep -oE '"versionCode"[[:space:]]*:[[:space:]]*[0-9]+' "$OHOS_APP" | head -1 | grep -oE '[0-9]+$')"
if [ "$ohos_name" != "$VERSION" ] || [ "$ohos_code" != "$CODE" ]; then
DRIFT=1
if [ "$CHECK" -eq 1 ]; then
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code → 应为 $VERSION/$CODE"
else
# app.json5 带注释,不是严格 JSON用 sed 定点替换两个字段。
sed -i -E "s/(\"versionCode\"[[:space:]]*:[[:space:]]*)[0-9]+/\1$CODE/" "$OHOS_APP"
sed -i -E "s/(\"versionName\"[[:space:]]*:[[:space:]]*\")[^\"]+/\1$VERSION/" "$OHOS_APP"
note "cmd/ohos AppScope/app.json5" "$ohos_name/$ohos_code$VERSION/$CODE"
fi
fi
echo "内核版本: $VERSION (versionCode $CODE)"
if [ "$CHECK" -eq 1 ]; then
if [ "$DRIFT" -eq 1 ]; then
echo "sync-client-versions: 客户端版本与内核不一致(见上);跑 bash deploy/scripts/sync-client-versions.sh 同步" >&2
exit 1
fi
echo "sync-client-versions: OK客户端与内核版本一致"
fi

View File

@ -32,13 +32,17 @@ ARTIFACT_SUFFIXES = (
".rpm",
".pkg",
"_win64.exe",
# 插件包。之前不在白名单里,会被静默跳过——而 release 本该带上它们,
# 否则用户要自己装 Go + hmapdev 逐插件构建(见 SDK 仓 scripts/build_plugin_bundles.sh
".hmap",
# 插件包汇总校验和(与 SHA256SUMS 同性质,独立文件免得混淆内核包与插件)
"SHA256SUMS.plugins",
)
def is_artifact(name: str) -> bool:
return name == "SHA256SUMS" or name.endswith(ARTIFACT_SUFFIXES)
def get_upload_url(tag: str, token: str, filename: str) -> tuple[str, dict]:
q = urllib.parse.urlencode({"file_name": filename})
url = f"{API}/{REPO}/releases/{tag}/upload_url?{q}"

View File

@ -1,248 +0,0 @@
# QQ `output_send` 回声/无限循环(核心侧缺陷) QQ `output_send` 回声/无限循环(核心侧缺陷,非插件)
> 状态:**已修复**(核心已具备轮次上限:`core.agent.max_tool_turns`,默认 10
> 在 `internal/agent/core/task.go` 到达上限即强制收尾;回归测试
> `TestMaxToolTurns_CapsRunawayLoop`。本文保留为缺陷定位过程记录。)
>
> 原始状态(修复前):**待修复**
> 影响Agent 单轮内每 ~10 秒调用一次 `output_send__qq`,持续数十分钟不结束(实测单轮 `1780624ms`80+ 次工具调用)
> 定位结论:**问题在核心(富回执 + 每轮重复追加同一条“继续”占位 + 无轮次上限QQ 插件侧已是最小回执,改插件无效**
---
## 1. 现象
生产日志(`/home/newqqagent`homed 运行期):
```
18:31:39 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:47 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:31:58 process.go:299: [agent] tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
18:32:06 ...
18:32:14 ...
(每 8~12 秒一条payload 长度各异387/237/246/203/252/270/254/231/258/227/188/227/200/209/155/284/212/173/198/242/218/191…
18:31:19 eventloop.go:426: [agent] text from qq → response (1780624ms, tools=[... 80+ 项 ...])
```
- 每条内容**都不同**,所以“相同参数才拦”的插件保险不会触发。
- 不是 webhook 回声15 分钟内只有 1 条真实入站中断)。
- 是模型每轮都收到“发送成功的富回执”,把它当成“继续下一步”的信号。
---
## 2. 根因链(核心侧,三层)
### 2.1 QQ 插件已经返回最小回执 —— 但被核心丢弃
`third_party/homeagent-sdk/example/qq/plugin.go``handleChannelOutput` 尾部):
```go
if sendErr != nil {
return nil, sendErr
}
// 成功:返回极简标记。不再回传 NapCat 原始响应(含 message_id 等)给模型,
// 避免模型把"发送成功"当成"上一步完成,继续下一步"的信号驱动循环。
return "ok", nil
```
插件返回的是字符串 `"ok"`
### 2.2 核心 proc 桥丢弃它并伪造 `status:sent`
`internal/plugin/proc/plugin.go:336``(*Plugin).invokeOutput`
```go
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{Channel: channel, Args: args})
if err != nil { return nil, err }
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil // ← "ok" 不是 JSON object落到这里
}
if _, ok := res["status"]; !ok {
res["status"] = "sent" // ← 再兜底
}
return res, nil
```
插件返回 `"ok"``json.Unmarshal``map[string]interface{}` 失败 → 核心合成 `{status: sent}`
**插件的返回值在这里被完全覆盖,所以只改插件永远修不掉回声。**
### 2.3 核心把这个富回执喂给模型
`internal/agent/core/output.go:81`HEAD / 部署中的 homed 行为):
```go
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
// → "已通过 [qq] 通道发送: map[status:sent]"
```
模型看到“发送成功 + 详情”后继续调用 `output_send__qq`,形成闭环。
### 2.4 核心没有工具轮次硬上限(放大器)
`core.agent.max_tool_turns` 只在配置层定义,**agent 循环里没有任何读取点**
```
internal/config/registry.go:551 set("core.agent.max_tool_turns", "10")
internal/config/registry.go:669 reg(ConfigDef{Key: "core.agent.max_tool_turns", ...})
$ grep -rn 'max_tool_turns\|MaxToolTurns' internal/agent/ → 无结果
```
`internal/agent/core/process.go:242` 的唯一终止条件是:
```go
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, toolResults, nil
}
```
即:**模型不主动停,循环就永不结束**。`core.agent.max_tool_turns`(本机 DB 现为 `1000`)形同虚设。
### 2.5 每轮重复追加同一条 user 占位(“反复喂相同消息”的直接来源)
`internal/agent/core/process.go:77-82`,位置在 `for turn := 0; ; turn++` 循环的**顶部**
```go
for turn := 0; ; turn++ {
for _, interrupt := range a.drainInterrupts() { ... }
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{ // ← process.go:79
Role: "user",
Content: "请根据以上工具结果继续。",
})
}
...
}
```
`msgs``process.go:32` 在循环**外**创建,循环内只增不减:
- 每轮工具调用结束后,`msgs` 尾部必然是 `tool` 消息;
- 下一轮顶部判断成立,于是**再追加一条完全相同的** `请根据以上工具结果继续。`
- 不做替换、不做去重、不做裁剪(`ContextPolicy: prune` 只裁剪 `a.context`,不裁剪 `msgs`)。
跑 N 轮,模型收到的 prompt 里就叠了 N 条一模一样的“继续”指令。这才是“核心把前面相同消息反复喂给模型”的直接机制,也是把模型持续推向 `output_send` 的持续推力。
**预期行为**占位消息应当a仅在没有尾部 user 消息时补一条b补之前先移除上一条同类占位保持至多一条绝不能线性累积。
---
## 3. 现有未完成/未部署的修复
| 文件 | 状态 | 内容 |
| --- | --- | --- |
| `internal/agent/core/output.go:81` | **已改,未提交** (`M`) | `return fmt.Sprintf("已通过 [%s] 通道发送: %v", ...)``return "ok"`(含解释回声的注释) |
| `internal/plugin/proc/plugin.go:336` | **已改,未提交** (`M`) | 仍是伪造 `status:sent` 的版本,未处理非 map 返回值 |
| `third_party/homeagent-sdk/example/qq/plugin.go` | **已改,未提交** (`M`) | `handleChannelOutput` 返回 `"ok"` |
运行中的 `homed`**Sep 6 11:39** 构建的二进制,不含 `output.go` 的极简回执改动 → 仍回显富回执。
另外该二进制用旧 SDK 协议(`shmMagic` 直连,无 `unifiedMagic`),而 `third_party/homeagent-sdk` 仓库 HEAD 已升级到统一区域协议(`fc23612` 起)。**重建并部署 homed 时二者必须对齐**(见 §5
---
## 4. 建议修复
### 4.1 (必须)让模型只看到最小回执
**方案 A最小改动已在工作区**`internal/agent/core/output.go`
```go
// internal/agent/core/output.go:81
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send回声效应是 output loop 的根源之一。
return "ok"
```
**方案 B同时修掉 proc 桥的伪造)**`internal/plugin/proc/plugin.go:336`
不要对非 map 结果伪造 `status:sent`,保留插件真实返回;例如:
```go
if len(raw) == 0 {
return map[string]interface{}{"status": "ok"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
// 插件返回的是标量(如 "ok")——原样透传,不要伪造 status
var scalar interface{}
if err2 := json.Unmarshal(raw, &scalar); err2 == nil {
return scalar, nil
}
return map[string]interface{}{"status": "ok"}, nil
}
```
注意:`output.go` 仍需要 `status == "unconfirmed"/"queued"` 的判定,改成标量透传时该判定自然跳过(非 map语义正确。
### 4.2 (必须)工具循环硬上限
`internal/agent/core/process.go` 的工具循环里读取并强制 `core.agent.max_tool_turns`
- 位置:`for turn := 0; ; turn++ {` 循环内,执行工具前/每轮结束后检查。
- 语义:达到上限时追加一条系统消息(如 `[系统] 已达到最大工具轮次 N请立即总结并停止调用工具`),并终止循环返回当前内容,而不是继续下一轮。
- 至少要在 `turn > maxTurns` 时强制 `break`,避免模型不停调用。
### 4.3 建议QQ 插件侧保持最小回执
`third_party/homeagent-sdk/example/qq/plugin.go``return "ok", nil` 是正确的,保留即可。
**不要**再依赖插件侧修这个回声——见 §2.2。
### 4.4 (必须)修掉每轮重复追加的 user 占位
`internal/agent/core/process.go:77-82`。改为“至多保留一条”,例如:
```go
// 只在尾部是工具轮产物时补位;先移除上一条同类占位,避免线性累积。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
// 若尾部之上已经存在一条我们自己的占位,就不要重复追加。
if !isContinuationPlaceholder(msgs[len(msgs)-1]) {
msgs = append(msgs, agentAPI.Message{
Role: "user",
Content: continuationPlaceholder,
})
}
}
```
更稳妥的写法:在追加前从 `msgs` 尾部回扫,删除所有此前由本机制插入的占位,再追加一条。判定不要只靠字符串相等,建议给占位加一个可识别标记(例如 `internal:continuation`)或单独的 `NoMemory/Role` 约定,避免误删真实用户消息。
同时建议给 `msgs` 加长度/ token 上限(或定期裁剪历史),防止长任务把上下文堆爆(这正是 §2.4 无轮次上限的伴生问题)。
---
## 5. 部署前提与步骤
> ⚠️ 部署 homed 前必须先对齐 SDK 协议,否则所有子进程插件握手失败(`统一区域魔数不匹配`)。
1. **确认工具链协议与要部署的 homed 一致**
- 现状:`/usr/local/bin/homed` = 旧协议;`/usr/local/bin/plugindev` 已替换为旧协议版本(备份 `/usr/local/bin/plugindev.bak-20260910-174547`)。
- 若决定升级到统一区域协议,则需同时:升级 homed 二进制 + 用新 SDK`third_party/homeagent-sdk` HEAD重建全部插件。
- 若维持旧协议:用 `/usr/local/bin/plugindev`(旧)重建插件即可,不要用仓库 HEAD 的 `tools/plugindev` 直接 `go run`
2. **构建 homed**`go build ./...` 已验证通过;产出替换 `/usr/local/bin/homed`(按项目部署纪律:备份 → 原子替换)。
3. **重启**`systemctl restart homeagent.service`
4. **重建受影响的子进程插件**(协议一致时):至少 `qq`
---
## 6. 验收标准
修复后,发一条会触发回复的 QQ 消息,应满足:
1. 日志中 `output_send__qq` 的 tool result **不再包含** `已通过 [qq] 通道发送: map[status:sent]`
2. 单轮只发送 1 条(或模型明确决定的多条**不同**消息),**不出现每 ~10 秒一次的持续调用**
3. 当模型异常地持续调用工具时,日志出现达到 `core.agent.max_tool_turns` 的终止记录,且该轮在有限步内结束;
4. `eventloop.go:426``text from qq → response` 耗时应回落到正常量级(秒级~分钟级),不再是 30 分钟;
5. 抓取发往上游的请求(或用调试钩子 dump `req.Messages`),确认 `请根据以上工具结果继续。` 在整轮 prompt 中**至多出现一次**;修复前应为 N 条N=轮数),这正是 §2.5 的判据。
---
## 7. 相关背景(避免误修)
- QQ 插件的 `beforeToolcall` 循环保险只拦“参数完全相同的重复调用”(`max_duplicate_qq_send`**拦不住内容各异的循环**;本次循环每条内容都不同,所以保险未触发。这是设计使然,不是 bug。
- 15 分钟内仅 1 条真实 QQ 入站中断,说明**不是** webhook 把出站消息当入站回灌,**不是**插件回声。
- `internal/plugin/proc/plugin.go:122``invokeCleaner` 签名不匹配(此前导致 `go build` 失败)**已被修复**,当前 `go build ./...` 通过。

View File

@ -1,124 +0,0 @@
# 检索方案对比报告2026-09-09
## 测试数据
- 文档库492 篇生产文档(过滤 108 条健康检查测试文档)
- 媒体库3 张生产图片(验证码、新闻截图、深色模式备忘录)
- 文本查询10 组(精确匹配、语义、跨语言、模糊表达)
- 媒体查询6 组(中文/英文查图片3 张图片各 2 条)
---
## 一、文本检索对比(文档库)
| 方案 | Hit@1 | Hit@5 | MRR | 平均延迟 |
|------|-------|-------|-----|----------|
| TF-IDF | 3/10 | 7/10 | 0.457 | 0.3ms |
| fastText200k 中文+378k 英文) | 5/10 | 5/10 | 0.530 | 8.3ms |
| TF-IDF + fastText RRF | 4/10 | 7/10 | 0.552 | 12.3ms |
| **Jina v5-omni-nano** | **8/10** | **10/10** | **0.900** | **39.9ms** |
### 关键发现
1. **Jina 的优势来自"短语语义"能力**
- "邮件代理是否已经成功接入" → TF-IDF rank 5Jina rank 1
- "升级安装 QQ 插件包" → fastText rank 169Jina rank 1margin +0.30
- "我所在城市的天气预报" → fastText rank 44Jina rank 1
- "聊天输入区域文字多了会不会自动增高" → TF-IDF rank 1Jina rank 1margin +0.33
2. **TF-IDF 在精确匹配上不可替代**
- "长期文档记忆功能是否健康" → TF-IDF rank 3Jina rank 1
- "重新加载全部扩展组件" → TF-IDF rank 0完全未命中Jina rank 2
- TF-IDF 的 Hit@5 70% 证明精确关键词召回仍有价值
3. **RRF 融合反而变差**
- TF-IDF+fastText RRF MRR=0.552,低于 Jina 单路 0.900
- 原因两种稀疏向量的排序在语义查询上高度重叠RRF 无法弥补各自短板
---
## 二、图片检索对比(同 3 张图片6 条查询)
| 方案 | Hit@1 | MRR | 平均 margin |
|------|-------|-----|-------------|
| CLIP ViT-B/32 | 4/6 | 0.806 | -0.008(负值!) |
| Jina v5-omni-nano | 4/6 | 0.833 | +0.024 |
### 逐条对比
| 查询 | CLIP rank | CLIP margin | Jina rank | Jina margin |
|------|-----------|-------------|-----------|-------------|
| 验证码图片(中) | 1 | +0.027 | 1 | +0.036 |
| 验证码图片(英) | 1 | +0.063 | 1 | +0.077 |
| 新闻截图(中) | 6 | -0.091 | 2 | -0.064 |
| 新闻截图(英) | 1 | +0.008 | 2 | -0.028 |
| 备忘录截图(中) | 3 | -0.045 | 1 | +0.045 |
| 备忘录截图(英) | 1 | +0.051 | 1 | +0.079 |
### 关键发现
1. **中文文本→图片**Jina 明显优于 CLIPMRR 0.833 vs 0.611
- CLIP 中文查询余弦可低至 -0.076(完全反直觉)
- Jina 最差也是 +0.045,正样本始终高于负样本
2. **新闻截图是共同弱点**
- CLIP 和 Jina 都被"深色模式备忘录"抢走新闻截图的排序
- 原因:新闻截图的文字描述含"深色"、"备忘录"等词,与备忘录图片的视觉特征重叠
- 这是描述质量 vs 视觉特征的竞争,不是模型问题
3. **margin 的实际意义**
- CLIP 的平均 margin = -0.008(负值意味着正样本平均不如负样本)
- Jina 的平均 margin = +0.024(正样本始终略高于负样本)
- 但两者的 margin 都很小(< 0.1生产环境仍需阈值校准
---
## 三、延迟与资源
| 方案 | 单次查询延迟 | 索引构建 | 内存 |
|------|-------------|----------|------|
| TF-IDF | 0.3ms | <1s | ~50MB |
| fastText | 8.3ms | <1s | ~200MB |
| CLIP ONNX | 26ms | N/A | ~600MB |
| Jina v5-omni CPU | 39.9ms | 78s492篇 | ~4GB |
---
## 四、结论与建议
### 核心判断
| 维度 | TF-IDF/fastText | CLIP | Jina v5-omni |
|------|-----------------|------|--------------|
| 文本精确匹配 | ★★★★★ | N/A | ★★★★ |
| 文本语义检索 | ★★ | N/A | ★★★★★ |
| 中文文本图片 | 无能力 | | ★★★★ |
| 英文文本图片 | 无能力 | ★★★ | ★★★★ |
| 图片图片 | 无能力 | ★★★ | ★★★★ |
| 多语言统一空间 | 无能力 | 有限 | ★★★★★ |
| 延迟 | ★★★★★ | ★★★ | ★★ |
### 架构建议
1. **保留 TF-IDF 作为精确召回的一级通道**
- 0.3ms 延迟不可替代
- Hit@5 70% 证明在关键词匹配场景仍有价值
- 特别是"插件安装"、"设备查询"这类精确操作指令
2. **用 Jina 替换 fastText + CLIP 的稠密通道**
- Jina 单路 MRR=0.90,超过 fastText+CLIP 融合
- 统一空间消除三条通道的维护成本
- 中文文本图片从"无法检索"提升到"可检索"
3. **两路融合TF-IDF + Jina RRF**而非 TF-IDF + fastText RRF
- TF-IDF 精确匹配 + Jina 语义覆盖
- RRF 避免跨空间分数归一化问题
- 预期 MRR > 0.90(精确匹配补 Jina 的语义盲区)
4. **图片检索仍需阈值校准**
- Jina 的 margin 平均 +0.024,生产环境需设置合理阈值
- 建议:用真实正负样本对重新标定,而非沿用 CLIP 的 0.20 阈值
### 下一步
- 实现 TF-IDF + Jina RRF 融合,验证 MRR 是否能突破 0.90
- 用更多生产图片标定 Jina 的图片检索阈值
- 测试 fastText 词嵌入是否可以完全被 Jina 文本编码替代L0 相关性计算)

View File

@ -261,6 +261,11 @@ git switch main && git cherry-pick <sha> # 遵守 §三:只 pick不 merge
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓中版本对齐是第一优先级**,见 §七)。
- **客户端版本必须与内核同步**GUI / 鸿蒙 / waiter 同一个号,当前皆为 `internal/meta.Version`
内核版本是唯一事实源,客户端不得各写一个。拉齐用 `make sync-client-versions`
发版前跑 `make check-client-versions` 做漂移门禁。waiter 直接引用 `internal/meta`(无第二份字段);
鸿蒙的 `versionName/versionCode` 由脚本写 `AppScope/app.json5`,运行时代码从 `bundleManager` 读,
不再硬编码。
---
@ -317,10 +322,15 @@ git branch -d release/v1.0.x # tag 已保存历史,
## 六、本规范与「接口冻结」约束的关系
- feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
- `internal/sdk` **不受冻结约束**,可自由扩展;冻结只针对公开 SDK 接口(`third_party/homeagent-sdk/sdk/`
- 若整改确需突破公开接口,走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七),
并同步 `SDKCompatibleVersion` 与 SDK 仓的 release tag
> **接口冻结已到期v1.1.x 起)**。冻结是**迁移期**的约束——它要保的是
> 「换运行模型不动业务代码」,靠 `git diff third_party/homeagent-sdk/sdk/` 为空来守
> 迁移完成v1.0.0 上生产)后该约束按时失效,取而代之的是 §八的三条演进规则。
> 本节保留历史条款,但**不再作为合回门禁**
- ~~feature 分支合回 main 的门禁(`git diff third_party/homeagent-sdk/sdk/` 为空)~~
—— **已失效**。现改为:公开接口的改动必须满足 §八(只增不减、签名不改、模板接线)。
- `internal/sdk` **不受冻结约束**,可自由扩展(此条仍成立);
公开 SDK 接口指 `third_party/homeagent-sdk/sdk/`。
- **公开接口的改动本身是 feature不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
——发布分支冻结功能§2.3),接口是最典型的功能面。
@ -451,3 +461,52 @@ GITCODE_REPO=JianFeeeee/homeagent-sdk ASSET_DIR=<sdk>/dist/release \
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)
---
## 八、公开 SDK 接口的演进规则
> 本节原在《外部插件接口不变矩阵》(迁移期临时文档,已随迁移完成删除)§九。
> 那份文档记的是**迁移期**的约束("换运行模型不动业务代码",靠
> `git diff third_party/homeagent-sdk/sdk/` 为空来守)。迁移完成后该约束**到期**——
> 继续冻结等于让 SDK 永远停在迁移那天的能力面,多模态这类功能永远到不了插件手上。
> 取代它的是下面三条更弱、但仍然硬的规则。
### 1. 只增不减,签名不改
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
实例v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
而那些插件根本不关心媒体。
### 2. 新增方法必须是「插件调用、内核实现」方向
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增方法后,插件只是
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
每个存量插件都会因未实现而编译失败。
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的实现若不满足新接口,
每个外部插件都**编不过**——是硬失败,不是软降级。
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
测试替身(`fakeCoreSDK`、`injectCapture`、`capability_test.go` 的手工方法清单)。
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,漂移**不会被编译器抓到**。
### 4. 「接口纯追加」不等于「无需重编」
插件运行协议版本(`ProtocolVersion`)与 SDK 接口版本是**两件事**。
协议升级(如 1.2.0 的 fd3 布局变更,不支持滚动升级)时,`ProtocolVersion` 不匹配
会在握手时被明确拒绝并提示用配套 `hmapdev` 重编。
必须把两者分开说,否则会被误读成"既然纯追加就还能用旧产物"。
### 5. 合回 main 前要同步的东西
1. 改动公开 SDK 接口面后,同步 SDK 仓的版本(§七)与 `SDKCompatibleVersion`
2. 生成模板已接线(跑 `cd tools/hmapdev && go test ./...`,含
`TestProcTemplate_CoversAllCoreMethods`
3. 存量插件源码零改动(逐个 `cd example/<n> && go vet ./...`
4. 并发安全(`go test -race -count=5 ./sdk/`)。

View File

@ -1,72 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef const char* (*verfn)(void);
static const char* call_ver(void* f){ return ((verfn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"unsafe"
)
func main() {
// Go 用 dlopen 加载纯 C shimshim 本身常驻,无所谓)
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
if shim == nil {
fmt.Println("shim 加载失败:", C.GoString(C.dlerror()))
os.Exit(1)
}
openName := C.CString("shim_open")
closeName := C.CString("shim_close")
symName := C.CString("shim_sym")
shimOpen := C.dlsym(shim, openName)
shimClose := C.dlsym(shim, closeName)
shimSym := C.dlsym(shim, symName)
C.free(unsafe.Pointer(openName))
C.free(unsafe.Pointer(closeName))
C.free(unsafe.Pointer(symName))
fmt.Printf("shim 就绪: open=%p close=%p sym=%p\n\n", shimOpen, shimClose, shimSym)
// 直接用 dlopen/dlsym 调 shim 的三个函数(避免再写一层 C 包装)
load := func(path string) unsafe.Pointer {
cp := C.CString(path)
defer C.free(unsafe.Pointer(cp))
return C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
}
ver := func(h unsafe.Pointer) string {
n := C.CString("probe_version")
defer C.free(unsafe.Pointer(n))
f := C.dlsym(h, n)
if f == nil { return "<no sym>" }
return C.GoString(C.call_ver(f))
}
fmt.Println("--- 场景: Go(带 NODELETE runtime) 加载/卸载纯 C 的第三层 so ---")
h1 := load("./probe.so")
fmt.Printf("1) dlopen probe.so handle=%p version=%s\n", h1, ver(h1))
rc := C.dlclose(h1)
fmt.Printf("2) dlclose rc=%d\n", int(rc))
// 换内容V1 -> V2同路径
in, _ := os.ReadFile("probe_v2.so")
os.WriteFile("probe.so", in, 0755)
fmt.Println("3) 磁盘 probe.so 内容替换为 V2同路径")
h2 := load("./probe.so")
fmt.Printf("4) 再 dlopen 同路径 handle=%p version=%s\n", h2, ver(h2))
if h1 == h2 {
fmt.Println(" => 句柄相同:未卸载,仍是旧代码")
} else {
fmt.Println(" => 句柄不同:真正卸载并重新装载了新代码 ✅")
}
}

View File

@ -1,49 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void* (*openfn)(const char*);
typedef int (*closefn)(void*);
static void* c_open(void* f, const char* p){ return ((openfn)f)(p); }
static int c_close(void* f, void* h){ return ((closefn)f)(h); }
*/
import "C"
import (
"fmt"
"os"
"strings"
"unsafe"
)
func cnt(s string) int {
b, _ := os.ReadFile("/proc/self/maps")
n := 0
for _, l := range strings.Split(string(b), "\n") { if strings.Contains(l, s) { n++ } }
return n
}
func main() {
sp := C.CString("./shim.so")
shim := C.dlopen(sp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(sp))
no := C.CString("shim_open"); nc := C.CString("shim_close")
fo := C.dlsym(shim, no); fc := C.dlsym(shim, nc)
C.free(unsafe.Pointer(no)); C.free(unsafe.Pointer(nc))
// 经【纯 C shim】去 dlopen/dlclose Go c-shared 插件
qp := C.CString("/home/newqqagent/plugins/qq/plugin.so")
h := C.c_open(fo, qp)
C.free(unsafe.Pointer(qp))
fmt.Printf("经 C shim dlopen Go 插件 handle=%p 映射段=%d\n", h, cnt("qq/plugin.so"))
rc := C.c_close(fc, h)
fmt.Printf("经 C shim dlclose rc=%d 映射段=%d\n", int(rc), cnt("qq/plugin.so"))
if cnt("qq/plugin.so") > 0 {
fmt.Println("\n❌ 仍未卸载 —— NODELETE 属于目标 .so 本身,与谁调 dlopen 无关")
} else {
fmt.Println("\n✅ 卸载成功")
}
}

View File

@ -1,58 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef char* (*verfn)(void);
static char* call_ver(void* f){ return ((verfn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"strings"
"unsafe"
)
func threads() int {
e, _ := os.ReadDir("/proc/self/task")
return len(e)
}
func rss() int {
b, _ := os.ReadFile("/proc/self/status")
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "VmRSS:") {
var k int
fmt.Sscanf(l, "VmRSS: %d kB", &k)
return k
}
}
return 0
}
func main() {
base, baseT := rss(), threads()
fmt.Printf("基线: RSS=%dKB threads=%d\n\n", base, baseT)
src, _ := os.ReadFile("glv1.so")
os.MkdirAll("stress", 0755)
var hs []unsafe.Pointer
for i := 1; i <= 30; i++ {
p := fmt.Sprintf("stress/%010d-qq.so", 1700000000+i)
os.WriteFile(p, src, 0755)
cp := C.CString("./" + p)
h := C.dlopen(cp, C.RTLD_NOW|C.RTLD_LOCAL)
C.free(unsafe.Pointer(cp))
if h == nil { fmt.Printf("第 %d 次失败\n", i); break }
hs = append(hs, h)
C.dlclose(h) // 模拟每次都尝试卸载no-op
if i%10 == 0 {
fmt.Printf("第 %2d 次重载: RSS=%dKB (+%dKB) threads=%d (+%d)\n",
i, rss(), rss()-base, threads(), threads()-baseT)
}
}
fmt.Printf("\n30 次重载后: RSS 增长 %dKB, 线程增长 %d\n", rss()-base, threads()-baseT)
fmt.Printf("每次重载均摊: RSS +%.1fKB, 线程 +%.2f\n",
float64(rss()-base)/30, float64(threads()-baseT)/30)
}

View File

@ -1,2 +0,0 @@
#include <stdio.h>
const char* probe_version(void){ return "V1"; }

View File

@ -1,2 +0,0 @@
#include <stdio.h>
const char* probe_version(void){ return "V2"; }

View File

@ -1,9 +0,0 @@
#include <dlfcn.h>
#include <stdio.h>
void* shim_open(const char* p){
void* h = dlopen(p, RTLD_NOW|RTLD_LOCAL);
if(!h) printf(" [shim] open FAIL: %s\n", dlerror());
return h;
}
int shim_close(void* h){ return dlclose(h); }
void* shim_sym(void* h, const char* n){ return dlsym(h, n); }

View File

@ -1,49 +0,0 @@
//go:build ignore
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 10多媒体 payload —— 共享内存零拷贝 vs JSON base64 ===")
sizes := []int{100 * 1024, 1024 * 1024, 5 * 1024 * 1024}
for _, sz := range sizes {
img := make([]byte, sz)
for i := range img { img[i] = byte(i % 251) }
// A. JSON + base64当前 ContentBlock 的做法)
t0 := time.Now()
b64 := base64.StdEncoding.EncodeToString(img)
blob, _ := json.Marshal(map[string]string{"type": "image_url", "url": "data:image/png;base64," + b64})
var back map[string]string
json.Unmarshal(blob, &back)
dec, _ := base64.StdEncoding.DecodeString(back["url"][22:])
jsonDur := time.Since(t0)
// B. 共享内存 arena写入 + 偏移解引用,零拷贝读)
mfd, _ := unix.MemfdCreate("arena", 0)
unix.Ftruncate(mfd, int64(sz+4096))
data, _ := unix.Mmap(mfd, 0, sz+4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
t0 = time.Now()
copy(data[4096:], img) // 写 arena
view := data[4096 : 4096+sz] // 偏移解引用 = 零拷贝切片
_ = view[sz-1]
shmDur := time.Since(t0)
unix.Munmap(data)
unix.Close(mfd)
fmt.Printf("\n%s payload:\n", map[int]string{100*1024:"100KB", 1024*1024:"1MB", 5*1024*1024:"5MB"}[sz])
fmt.Printf(" A JSON+base64: %8v 传输体积 %d B (+%.0f%%) 解出 %d B %s\n",
jsonDur, len(blob), float64(len(blob)-sz)/float64(sz)*100, len(dec),
map[bool]string{true:"✓",false:"✗"}[len(dec)==sz])
fmt.Printf(" B 共享内存: %8v 传输体积 8 B (描述符) 零拷贝视图 %d B\n", shmDur, len(view))
fmt.Printf(" → 加速 %.0fx, 体积节省 %.0f%%\n",
float64(jsonDur)/float64(shmDur), float64(len(blob)-8)/float64(len(blob))*100)
}
}

View File

@ -1,42 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os/exec"
"sort"
"time"
)
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
func main() {
fmt.Println("=== 实验 11工具调用 RPC 端到端延迟(实测 payload 中位 93B===")
cmd := exec.Command("./plug11")
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
cmd.Start()
enc := json.NewEncoder(bufio.NewWriter(sin))
w := bufio.NewWriter(sin); enc = json.NewEncoder(w)
dec := json.NewDecoder(bufio.NewReader(sout))
args := json.RawMessage(`{"city":"hangzhou","days":3,"unit":"celsius","detail":true}`)
const N = 10000
lat := make([]time.Duration, 0, N)
for i := 0; i < N; i++ {
t0 := time.Now()
enc.Encode(Req{ID: i, Method: "weather_query", Args: args}); w.Flush()
var r Res
if err := dec.Decode(&r); err != nil { break }
lat = append(lat, time.Since(t0))
}
sin.Close(); cmd.Wait()
sort.Slice(lat, func(a,b int) bool { return lat[a] < lat[b] })
p := func(q float64) time.Duration { return lat[int(float64(len(lat))*q)] }
fmt.Printf("样本 %d 次\n", len(lat))
fmt.Printf(" p50 = %v\n p90 = %v\n p99 = %v\n max = %v\n", p(0.5), p(0.9), p(0.99), lat[len(lat)-1])
fmt.Printf("\n对照 LLM 单轮往返 2-8 秒 → RPC 占比 ≈ %.5f%%\n",
float64(p(0.5))/float64(3*time.Second)*100)
}

View File

@ -1,12 +0,0 @@
//go:build ignore
package main
import ("bufio";"encoding/json";"os")
type Req struct{ ID int `json:"id"`; Method string `json:"method"`; Args json.RawMessage `json:"args"` }
type Res struct{ ID int `json:"id"`; Result string `json:"result"` }
func main(){
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
for { var q Req
if err:=dec.Decode(&q); err!=nil {return}
enc.Encode(Res{ID:q.ID, Result:`{"ok":true,"data":"` + string(q.Args) + `"}`}); w.Flush() }
}

View File

@ -1,58 +0,0 @@
//go:build ignore
package main
import (
"fmt"
"os"
"runtime"
"sync"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
func threads() int { e, _ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
fmt.Println("=== 实验 1eventfd 是否走 Go netpoller只 park goroutine 不占 OS 线程)===")
base := threads()
fmt.Printf("基线线程数: %d (GOMAXPROCS=%d)\n\n", base, runtime.GOMAXPROCS(0))
const N = 200 // 模拟 200 个订阅者等待
var wg sync.WaitGroup
var woke int64
files := make([]*os.File, N)
for i := 0; i < N; i++ {
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK|unix.EFD_CLOEXEC)
if err != nil { fmt.Println("eventfd 失败:", err); return }
f := os.NewFile(uintptr(efd), fmt.Sprintf("evt%d", i))
files[i] = f
wg.Add(1)
go func(f *os.File) {
defer wg.Done()
buf := make([]byte, 8)
// 阻塞读:若走 netpoller 只 park goroutine
if _, err := f.Read(buf); err == nil {
atomic.AddInt64(&woke, 1)
}
}(f)
}
time.Sleep(500 * time.Millisecond) // 让所有 goroutine 进入等待
waiting := threads()
fmt.Printf("%d 个 goroutine 阻塞在 eventfd.Read 后:\n", N)
fmt.Printf(" 线程数 = %d (增长 %d)\n", waiting, waiting-base)
if waiting-base < 20 {
fmt.Println(" ✅ 走 netpoller线程未随等待者数量增长")
} else {
fmt.Printf(" ❌ 退化为阻塞 syscall每个等待者占一个 OS 线程\n")
}
// 全部唤醒
one := []byte{1,0,0,0,0,0,0,0}
for _, f := range files { f.Write(one) }
wg.Wait()
fmt.Printf("\n唤醒数 = %d/%d 唤醒后线程数 = %d\n", woke, N, threads())
}

View File

@ -1,41 +0,0 @@
//go:build ignore
package main
import (
"encoding/binary"
"fmt"
"os"
"unsafe"
"golang.org/x/sys/unix"
)
// 子进程fd 3 = eventfd(通知), fd 4 = shm 文件
func main() {
efd := os.NewFile(3, "evt")
shmf := os.NewFile(4, "shm")
data, err := unix.Mmap(int(shmf.Fd()), 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { fmt.Println("CHILD mmap 失败:", err); os.Exit(1) }
fmt.Printf("CHILD: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
buf := make([]byte, 8)
if _, err := efd.Read(buf); err != nil {
fmt.Println("CHILD read err:", err); os.Exit(1)
}
n := binary.LittleEndian.Uint64(buf)
fmt.Printf("CHILD: 被 eventfd 唤醒, 计数=%d\n", n)
// 按偏移读:头部 16 字节 = {off uint32, len uint32, seq uint64}
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
seq := binary.LittleEndian.Uint64(data[8:16])
payload := string(data[off : off+ln])
fmt.Printf("CHILD: 偏移解引用 off=%d len=%d seq=%d → %q\n", off, ln, seq, payload)
// 子进程回写(验证双向可见)
copy(data[2048:], []byte("CHILD-ACK"))
binary.LittleEndian.PutUint32(data[16:20], 2048)
binary.LittleEndian.PutUint32(data[20:24], uint32(len("CHILD-ACK")))
fmt.Println("CHILD: 已回写 ACK")
}

View File

@ -1,60 +0,0 @@
//go:build ignore
package main
import (
"encoding/binary"
"fmt"
"os"
"os/exec"
"time"
"unsafe"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 2跨进程 eventfd 通知 + 共享内存偏移解引用 ===")
// eventfd 不带 CLOEXEC需要被子进程继承
efd, err := unix.Eventfd(0, unix.EFD_NONBLOCK)
if err != nil { panic(err) }
evtFile := os.NewFile(uintptr(efd), "evt")
// shm: 用 memfd匿名无需 /dev/shm 清理)
mfd, err := unix.MemfdCreate("stagectx", 0)
if err != nil { panic(err) }
if err := unix.Ftruncate(mfd, 4096); err != nil { panic(err) }
shmFile := os.NewFile(uintptr(mfd), "shm")
data, err := unix.Mmap(mfd, 0, 4096, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { panic(err) }
fmt.Printf("PARENT: mmap 基址 = %p\n", unsafe.Pointer(&data[0]))
// 写 payload 到 arena(偏移 1024),头部记描述符
msg := "hello-from-parent-via-offset"
copy(data[1024:], []byte(msg))
binary.LittleEndian.PutUint32(data[0:4], 1024)
binary.LittleEndian.PutUint32(data[4:8], uint32(len(msg)))
binary.LittleEndian.PutUint64(data[8:16], 42)
fmt.Printf("PARENT: 数据已落地 arena@1024, 描述符 {off:1024, len:%d, seq:42}\n", len(msg))
cmd := exec.Command("go", "run", "exp2_child.go")
cmd.ExtraFiles = []*os.File{evtFile, shmFile} // → 子进程 fd 3, 4
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Start(); err != nil { panic(err) }
time.Sleep(3 * time.Second) // 等 go run 编译+启动
fmt.Println("PARENT: 数据到位后 post eventfd不等待消费者")
t0 := time.Now()
evtFile.Write([]byte{1,0,0,0,0,0,0,0})
fmt.Printf("PARENT: post 耗时 %v ← post-and-forget\n", time.Since(t0))
cmd.Wait()
// 读子进程回写
off := binary.LittleEndian.Uint32(data[16:20])
ln := binary.LittleEndian.Uint32(data[20:24])
if ln > 0 {
fmt.Printf("PARENT: 读到子进程回写 → %q ✅ 双向可见\n", string(data[off:off+ln]))
}
}

View File

@ -1,31 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"time"
)
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
func main() {
in := bufio.NewReader(os.Stdin)
out := bufio.NewWriter(os.Stdout)
enc, dec := json.NewEncoder(out), json.NewDecoder(in)
const N = 20000
t0 := time.Now()
for i := 0; i < N; i++ {
enc.Encode(req{ID: i, Method: "stage.lock"})
out.Flush()
var r resp
if err := dec.Decode(&r); err != nil { fmt.Fprintln(os.Stderr, "dec:", err); return }
}
d := time.Since(t0)
fmt.Fprintf(os.Stderr, "CHILD: %d 次 lock RPC 往返 用时 %v, 均摊 %.2f µs/次\n",
N, d, float64(d.Microseconds())/float64(N))
}

View File

@ -1,37 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"sync"
)
type req struct{ ID int `json:"id"`; Method string `json:"method"` }
type resp struct{ ID int `json:"id"`; OK bool `json:"ok"` }
func main() {
fmt.Println("=== 实验 3锁仲裁 RPC 往返成本stdio JSON-RPC===")
cmd := exec.Command("go", "run", "exp3_child.go")
stdin, _ := cmd.StdinPipe()
stdout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
cmd.Start()
var mu sync.Mutex // 内核侧真实的锁仲裁
dec := json.NewDecoder(bufio.NewReader(stdout))
w := bufio.NewWriter(stdin)
enc := json.NewEncoder(w)
for {
var q req
if err := dec.Decode(&q); err != nil { break }
mu.Lock() // 真实加锁
mu.Unlock() // 立即释放(模拟仲裁开销)
enc.Encode(resp{ID: q.ID, OK: true})
w.Flush()
}
cmd.Wait()
}

View File

@ -1,71 +0,0 @@
//go:build ignore
package main
import (
"fmt"
"os"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
type ring struct {
writeSeq atomic.Uint64
cap uint64
slots []uint64
}
func main() {
fmt.Println("=== 实验 4事件环 post-and-forget vs 同步 Publish慢消费者场景===")
const tokens = 5000
// --- A. 现状:同步 Publish消费者慢 ---
slowHandler := func() { time.Sleep(20 * time.Microsecond) }
t0 := time.Now()
for i := 0; i < tokens; i++ { slowHandler() }
syncDur := time.Since(t0)
fmt.Printf("A 同步 Publish (慢消费者 20µs): %d token 耗时 %v → 均摊 %.1f µs/token\n",
tokens, syncDur, float64(syncDur.Microseconds())/tokens)
// --- B. 新方案:写环 + eventfd post不等消费者 ---
r := &ring{cap: 1024, slots: make([]uint64, 1024)}
efd, _ := unix.Eventfd(0, unix.EFD_NONBLOCK)
f := os.NewFile(uintptr(efd), "e")
var dropped atomic.Uint64
// 慢消费者 goroutine
done := make(chan struct{})
go func() {
buf := make([]byte, 8)
var readSeq uint64
for {
if _, err := f.Read(buf); err != nil { return }
w := r.writeSeq.Load()
if w-readSeq > r.cap {
dropped.Add(w - readSeq - r.cap)
readSeq = w - r.cap
}
for readSeq < w { readSeq++ }
time.Sleep(20 * time.Microsecond) // 慢
select { case <-done: return; default: }
}
}()
t0 = time.Now()
one := []byte{1,0,0,0,0,0,0,0}
for i := 0; i < tokens; i++ {
s := r.writeSeq.Add(1)
r.slots[s%r.cap] = s // 写数据
f.Write(one) // post不等
}
asyncDur := time.Since(t0)
close(done)
fmt.Printf("B 环+eventfd post: %d token 耗时 %v → 均摊 %.2f µs/token\n",
tokens, asyncDur, float64(asyncDur.Microseconds())/tokens)
fmt.Printf("\n加速比 %.1fx 丢弃事件 %d消费者跟不上已计数\n",
float64(syncDur)/float64(asyncDur), dropped.Load())
if asyncDur < syncDur/5 {
fmt.Println("✅ post-and-forget 使流式发布与消费者速度解耦")
}
}

View File

@ -1,55 +0,0 @@
//go:build ignore
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func pssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
if err != nil { return 0 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Pss:") {
f := strings.Fields(l)
n, _ := strconv.Atoi(f[1]); return n
}
}
return 0
}
func threads(pid int) int {
e, _ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e)
}
func main() {
fmt.Println("=== 实验 517 个 Go 子进程插件的真实常驻开销PSS 计入共享页去重)===")
var cmds []*exec.Cmd
for i := 0; i < 17; i++ {
c := exec.Command("./plugbin")
c.Stdin, _ = os.Open(os.DevNull)
if err := c.Start(); err != nil { fmt.Println("start:", err); return }
cmds = append(cmds, c)
}
time.Sleep(1500 * time.Millisecond)
totalPss, totalThreads := 0, 0
for _, c := range cmds {
totalPss += pssKB(c.Process.Pid)
totalThreads += threads(c.Process.Pid)
}
fmt.Printf("17 进程合计: PSS = %.1f MB, 线程 = %d\n", float64(totalPss)/1024, totalThreads)
fmt.Printf("单进程均摊: PSS = %.2f MB, 线程 = %.1f\n",
float64(totalPss)/1024/17, float64(totalThreads)/17)
fmt.Printf("\n对照 homed 当前(单进程装 17 个 .so:\n")
// 找 homed
out, _ := exec.Command("pgrep", "-x", "homed").Output()
if p := strings.TrimSpace(string(out)); p != "" {
pid, _ := strconv.Atoi(strings.Fields(p)[0])
fmt.Printf(" homed PSS = %.1f MB, 线程 = %d\n", float64(pssKB(pid))/1024, threads(pid))
}
for _, c := range cmds { c.Process.Kill(); c.Wait() }
}

View File

@ -1,23 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"os"
)
// 模拟一个最小插件stdio JSON-RPC loop + 一个 goroutine
func main() {
go func() { select {} }()
in := bufio.NewReader(os.Stdin)
dec := json.NewDecoder(in)
out := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(out)
for {
var m map[string]interface{}
if err := dec.Decode(&m); err != nil { return }
enc.Encode(map[string]interface{}{"ok": true})
out.Flush()
}
}

View File

@ -1,68 +0,0 @@
//go:build ignore
package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
func pssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/smaps_rollup", pid))
if err != nil { return -1 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "Pss:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
}
return -1
}
func rssKB(pid int) int {
b, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
if err != nil { return -1 }
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "VmRSS:") { f := strings.Fields(l); n,_ := strconv.Atoi(f[1]); return n }
}
return -1
}
func threads(pid int) int { e,_ := os.ReadDir(fmt.Sprintf("/proc/%d/task", pid)); return len(e) }
func main() {
fmt.Println("=== 实验 5b17 个 Go 子进程常驻开销(保持 stdin 管道存活)===")
var cmds []*exec.Cmd
var pipes []interface{ Close() error }
for i := 0; i < 17; i++ {
c := exec.Command("./plugbin")
w, _ := c.StdinPipe() // 保持打开 → 不 EOF
pipes = append(pipes, w)
c.Stdout = nil
if err := c.Start(); err != nil { fmt.Println(err); return }
cmds = append(cmds, c)
}
time.Sleep(2 * time.Second)
tp, tr, tt, alive := 0, 0, 0, 0
for _, c := range cmds {
pid := c.Process.Pid
if _, err := os.Stat(fmt.Sprintf("/proc/%d", pid)); err != nil { continue }
alive++
if v := pssKB(pid); v > 0 { tp += v }
if v := rssKB(pid); v > 0 { tr += v }
tt += threads(pid)
}
fmt.Printf("存活进程 %d/17\n", alive)
fmt.Printf("合计: PSS=%.1f MB RSS=%.1f MB 线程=%d\n",
float64(tp)/1024, float64(tr)/1024, tt)
if alive > 0 {
fmt.Printf("均摊: PSS=%.2f MB RSS=%.2f MB 线程=%.1f\n",
float64(tp)/1024/float64(alive), float64(tr)/1024/float64(alive), float64(tt)/float64(alive))
}
out, _ := exec.Command("pgrep", "-x", "homed").Output()
if p := strings.TrimSpace(string(out)); p != "" {
pid, _ := strconv.Atoi(strings.Fields(p)[0])
fmt.Printf("\n对照 homed单进程 + 17 个 .so: RSS=%.1f MB 线程=%d\n",
float64(rssKB(pid))/1024, threads(pid))
}
for _, c := range cmds { c.Process.Kill(); c.Wait() }
}

View File

@ -1,49 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"time"
)
func main() {
fmt.Println("=== 实验 6子进程崩溃隔离 + 退出码/EOF 作为 recordCrash 信号 ===")
cmd := exec.Command("./crashbin")
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Stderr = nil // 丢弃 panic 栈
cmd.Start()
fmt.Printf("插件进程 pid=%d 已启动\n", cmd.Process.Pid)
enc := json.NewEncoder(sin)
dec := json.NewDecoder(bufio.NewReader(sout))
// 正常调用
enc.Encode(map[string]string{"method": "ping"})
var r map[string]interface{}
if err := dec.Decode(&r); err == nil { fmt.Println("正常调用 → ", r) }
// 触发崩溃
fmt.Println("\n发送 boom插件内 panic...")
t0 := time.Now()
enc.Encode(map[string]string{"method": "boom"})
err := dec.Decode(&r)
detected := "未检测到"
if errors.Is(err, io.EOF) || err == io.ErrUnexpectedEOF { detected = "EOF" } else if err != nil { detected = fmt.Sprintf("%v", err) }
fmt.Printf("调用侧感知: %s (耗时 %v)\n", detected, time.Since(t0))
werr := cmd.Wait()
var ec int = -1
if ee, ok := werr.(*exec.ExitError); ok { ec = ee.ExitCode() }
fmt.Printf("进程退出码 = %d panic → 2可直接喂 recordCrash\n", ec)
fmt.Printf("\n宿主进程仍存活: pid=%d ✅ 崩溃已隔离\n", os.Getpid())
fmt.Println("→ 对照:当前 .so 模型下bridge 兜不住的 panic 会带崩整个 homed")
}

View File

@ -1,23 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"os"
)
func main() {
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
out := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(out)
for {
var m map[string]interface{}
if err := dec.Decode(&m); err != nil { return }
if m["method"] == "boom" {
panic("插件故意崩溃") // 真 panic
}
enc.Encode(map[string]interface{}{"ok": true})
out.Flush()
}
}

View File

@ -1,63 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"os/exec"
"time"
)
func spawnAndAsk(bin string) string {
cmd := exec.Command(bin)
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Start()
enc := json.NewEncoder(sin)
dec := json.NewDecoder(bufio.NewReader(sout))
enc.Encode(map[string]string{"method": "version"})
var r map[string]interface{}
dec.Decode(&r)
sin.Close()
cmd.Process.Kill()
cmd.Wait()
if v, ok := r["version"].(string); ok { return v }
return "?"
}
func build(ver, out string) {
src := fmt.Sprintf(`package main
import ("bufio";"encoding/json";"os")
func main(){
dec:=json.NewDecoder(bufio.NewReader(os.Stdin))
w:=bufio.NewWriter(os.Stdout); enc:=json.NewEncoder(w)
for { var m map[string]interface{}
if err:=dec.Decode(&m); err!=nil {return}
enc.Encode(map[string]string{"version":%q}); w.Flush() }
}`, ver)
os.MkdirAll("v", 0755)
os.WriteFile("v/main.go", []byte(src), 0644)
os.WriteFile("v/go.mod", []byte("module v\ngo 1.21\n"), 0644)
c := exec.Command("go", "build", "-o", "../"+out, ".")
c.Dir = "v"
if b, err := c.CombinedOutput(); err != nil { fmt.Println("build err:", string(b)) }
}
func main() {
fmt.Println("=== 实验 7子进程模型下的热重载迁移的原始目标===")
build("v1.0.0", "hotbin")
fmt.Printf("1) 首次启动插件 → version = %s\n", spawnAndAsk("./hotbin"))
fmt.Println("2) 替换二进制为 v2.0.0(同路径,无需版本化 hash 目录)")
build("v2.0.0", "hotbin")
time.Sleep(200 * time.Millisecond)
v := spawnAndAsk("./hotbin")
fmt.Printf("3) 重启插件进程 → version = %s\n", v)
if v == "v2.0.0" {
fmt.Println("\n✅ 同路径替换即生效:无 NODELETE、无版本化路径、无线程泄漏")
fmt.Println(" 对照 .so 模型:同路径 dlopen 复用旧映像,永远拿不到 v2")
}
}

View File

@ -1,84 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
)
func main() {
fmt.Println("=== 实验 8跨进程并发扇出改写同一 StageContext最高风险点 3.4===")
mfd, _ := unix.MemfdCreate("stagectx", 0)
unix.Ftruncate(mfd, 65536)
shmFile := os.NewFile(uintptr(mfd), "shm")
data, _ := unix.Mmap(mfd, 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
// 初始 final_text = "" @1024, arena 游标 = 1024
binary.LittleEndian.PutUint32(data[0:4], 1024)
binary.LittleEndian.PutUint32(data[4:8], 0)
binary.LittleEndian.PutUint32(data[8:12], 1024)
tags := []string{"A", "B", "C", "D", "E"} // 5 个并发插件
var mu sync.Mutex // 内核侧锁仲裁
var wg sync.WaitGroup
var rpcCount int64
var cntMu sync.Mutex
t0 := time.Now()
for _, tag := range tags {
cmd := exec.Command("go", "run", "exp8_worker.go", tag)
cmd.ExtraFiles = []*os.File{shmFile}
sin, _ := cmd.StdinPipe()
sout, _ := cmd.StdoutPipe()
cmd.Stderr = os.Stderr
cmd.Start()
wg.Add(1)
go func() {
defer wg.Done()
dec := json.NewDecoder(bufio.NewReader(sout))
w := bufio.NewWriter(sin)
enc := json.NewEncoder(w)
held := false
for {
var q map[string]string
if err := dec.Decode(&q); err != nil { break }
switch q["method"] {
case "stage.lock": mu.Lock(); held = true
case "stage.unlock": if held { mu.Unlock(); held = false }
}
cntMu.Lock(); rpcCount++; cntMu.Unlock()
enc.Encode(map[string]bool{"ok": true}); w.Flush()
}
if held { mu.Unlock() }
cmd.Wait()
}()
}
wg.Wait()
dur := time.Since(t0)
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
final := string(data[off : off+ln])
fmt.Printf("\n--- 结果 ---\n")
fmt.Printf("最终 final_text 长度 = %d\n", len(final))
counts := map[string]int{}
for _, t := range tags { counts[t] = strings.Count(final, t) }
fmt.Printf("各插件写入次数: %v\n", counts)
total := 0
for _, c := range counts { total += c }
fmt.Printf("总字符 = %d, 长度 = %d → %s\n", total, len(final),
map[bool]string{true:"一致 ✅ 无丢失/无撕裂", false:"不一致 ❌"}[total == len(final)])
fmt.Printf("RPC 锁操作 = %d 次, 总耗时 %v\n", rpcCount, dur)
fmt.Printf("\n注写入次数少于 5×300 是 arena 64KB 上限所致append-only 未压实),符合设计\n")
}

View File

@ -1,50 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/binary"
"encoding/json"
"fmt"
"os"
"strconv"
"golang.org/x/sys/unix"
)
// 模拟插件:拿锁 → 读 final_text → 追加自己的标记 → 写回 → 放锁
// 锁通过 stdio RPC 向内核申请(方案 3.7:锁仲裁回归内核,无 cgo
func main() {
tag := os.Args[1]
shmf := os.NewFile(3, "shm")
data, err := unix.Mmap(int(shmf.Fd()), 0, 65536, unix.PROT_READ|unix.PROT_WRITE, unix.MAP_SHARED)
if err != nil { fmt.Fprintln(os.Stderr, "mmap:", err); os.Exit(1) }
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
w := bufio.NewWriter(os.Stdout)
enc := json.NewEncoder(w)
rpc := func(method string) {
enc.Encode(map[string]string{"method": method}); w.Flush()
var r map[string]interface{}; dec.Decode(&r)
}
const iters = 300
for i := 0; i < iters; i++ {
rpc("stage.lock")
// --- 临界区:偏移解引用读写 final_text ---
off := binary.LittleEndian.Uint32(data[0:4])
ln := binary.LittleEndian.Uint32(data[4:8])
cur := string(data[off : off+ln])
add := tag
newS := cur + add
// append-only arena写到新位置
newOff := binary.LittleEndian.Uint32(data[8:12])
if int(newOff)+len(newS) > 65536 { rpc("stage.unlock"); break }
copy(data[newOff:], []byte(newS))
binary.LittleEndian.PutUint32(data[0:4], newOff)
binary.LittleEndian.PutUint32(data[4:8], uint32(len(newS)))
binary.LittleEndian.PutUint32(data[8:12], newOff+uint32(len(newS)))
rpc("stage.unlock")
}
fmt.Fprintln(os.Stderr, "worker "+tag+" done, iters="+strconv.Itoa(iters))
}

View File

@ -1,60 +0,0 @@
//go:build ignore
package main
import (
"bufio"
"encoding/json"
"fmt"
"os/exec"
"sync"
"time"
)
func run(name, arg string, mu *sync.Mutex, crashed *bool) {
cmd := exec.Command("go", "run", "exp9_worker.go", arg)
sin, _ := cmd.StdinPipe(); sout, _ := cmd.StdoutPipe()
cmd.Stderr = nil
cmd.Start()
dec := json.NewDecoder(bufio.NewReader(sout))
w := bufio.NewWriter(sin); enc := json.NewEncoder(w)
held := false
for {
var q map[string]string
if err := dec.Decode(&q); err != nil { break }
switch q["method"] {
case "stage.lock": mu.Lock(); held = true; fmt.Printf(" [%s] 获得锁\n", name)
case "stage.unlock": if held { mu.Unlock(); held = false; fmt.Printf(" [%s] 释放锁\n", name) }
}
enc.Encode(map[string]bool{"ok":true}); w.Flush()
}
err := cmd.Wait()
// 关键:进程死了,内核侧检测到 EOF/退出 → 强制释放它持有的锁
if held {
mu.Unlock()
*crashed = true
fmt.Printf(" [%s] 进程死亡(%v),内核强制释放其持有的锁 ← 自愈\n", name, err)
}
}
func main() {
fmt.Println("=== 实验 9持锁进程崩溃后的自愈验证无需 robust pthread_mutex===")
var mu sync.Mutex
crashed := false
fmt.Println("\n1) 插件 X 拿锁后 panic:")
run("X", "crash", &mu, &crashed)
fmt.Println("\n2) 插件 Y 随后申请同一把锁:")
done := make(chan bool, 1)
go func() { run("Y", "normal", &mu, new(bool)); done <- true }()
select {
case <-done:
fmt.Println("\n✅ Y 正常获得并释放锁 —— 无死锁")
fmt.Println(" → 内核持有锁的所有权,进程死亡由 Wait()/EOF 检测并强制释放")
fmt.Println(" → 不需要 PTHREAD_PROCESS_SHARED|ROBUST也不需要处理 EOWNERDEAD")
fmt.Println(" → 整个架构可做到零 cgo")
case <-time.After(15 * time.Second):
fmt.Println("\n❌ 死锁Y 拿不到锁(说明需要 robust 语义)")
}
_ = crashed
}

View File

@ -1,12 +0,0 @@
//go:build ignore
package main
import ("bufio";"encoding/json";"os")
func main() {
dec := json.NewDecoder(bufio.NewReader(os.Stdin))
w := bufio.NewWriter(os.Stdout); enc := json.NewEncoder(w)
rpc := func(m string) { enc.Encode(map[string]string{"method":m}); w.Flush(); var r map[string]interface{}; dec.Decode(&r) }
rpc("stage.lock")
if os.Args[1] == "crash" { panic("持锁时崩溃") } // 拿着锁死掉
rpc("stage.unlock")
}

View File

@ -1,90 +0,0 @@
//go:build ignore
package main
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
// 完全复刻内核 loader.go case 2 + templates.go go_invoke_stage 的链路
type StageCtx struct {
mu sync.RWMutex
LLMText string
ToolRes []string
}
func (c *StageCtx) Lock() { c.mu.Lock() }
func (c *StageCtx) Unlock() { c.mu.Unlock() }
func (c *StageCtx) RLock() { c.mu.RLock() }
func (c *StageCtx) RUnlock() { c.mu.RUnlock() }
// === 模拟外部插件(副本模型)===
func externalPlugin(tag string, ctxJSON string) string {
// go_invoke_stage: 新建全新对象
sc := &StageCtx{}
var m map[string]interface{}
json.Unmarshal([]byte(ctxJSON), &m)
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
// 插件 handlerctx.Lock() 锁的是这个新对象 → 空转
sc.Lock()
sc.LLMText = sc.LLMText + "[" + tag + "]"
sc.Unlock()
out, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
return string(out)
}
// === 模拟内核 case 2 handler ===
func kernelStageHandler(sc *StageCtx, tag string) {
sc.RLock()
snap, _ := json.Marshal(map[string]interface{}{"llm_text": sc.LLMText})
sc.RUnlock()
result := externalPlugin(tag, string(snap))
// applyStageResult
var m map[string]interface{}
json.Unmarshal([]byte(result), &m)
sc.Lock()
if v, ok := m["llm_text"].(string); ok { sc.LLMText = v }
sc.Unlock()
}
// === 内置插件:直接改同一对象 ===
func nativePlugin(sc *StageCtx, tag string) {
sc.Lock()
sc.LLMText = sc.LLMText + "[" + tag + "]"
sc.Unlock()
}
func runCase(name string, fn func(*StageCtx, string), tags []string, rounds int) {
lost := 0
for r := 0; r < rounds; r++ {
sc := &StageCtx{LLMText: "BASE"}
var wg sync.WaitGroup
for _, t := range tags {
wg.Add(1)
go func(t string) { defer wg.Done(); fn(sc, t) }(t)
}
wg.Wait()
// 检查是否所有 tag 都在
for _, t := range tags {
if !strings.Contains(sc.LLMText, "["+t+"]") { lost++; break }
}
}
fmt.Printf(" %-28s %d/%d 轮出现修改丢失 (%.1f%%)\n", name, lost, rounds, float64(lost)/float64(rounds)*100)
}
func main() {
tags := []string{"A", "B", "C", "D", "E"}
fmt.Println("5 个插件并发在 StageBeforeToolcall 追加标记,各 2000 轮:")
fmt.Println()
runCase("内置插件(共享同一对象)", nativePlugin, tags, 2000)
runCase("外部插件(快照-副本-写回)", kernelStageHandler, tags, 2000)
fmt.Println()
fmt.Println("→ 副本模型下 read-modify-write 非原子:快照与写回之间的窗口导致覆盖")
}

View File

@ -1,101 +0,0 @@
//go:build ignore
package main
// 精确复刻现网 AfterToolcall 上 sanitizer(Global,改写) + weather(OwnTools,只读) 的并发
import (
"encoding/json"
"fmt"
"strings"
"sync"
)
type ToolResult struct {
Name string `json:"name"`
Plugin string `json:"plugin"`
Result interface{} `json:"result"`
}
type Ctx struct {
mu sync.RWMutex
ToolRes []ToolResult
}
func (c *Ctx) Lock(){c.mu.Lock()}; func (c *Ctx) Unlock(){c.mu.Unlock()}
func (c *Ctx) RLock(){c.mu.RLock()}; func (c *Ctx) RUnlock(){c.mu.RUnlock()}
func cleanText(s string) string {
// 模拟 sanitizer去掉 ANSI/坏字节
return strings.ReplaceAll(s, "\x1b[31m", "")
}
// 内核 case 2 handler外部插件通用路径
func kernelExternal(sc *Ctx, pluginFn func(*Ctx)) {
// 1. 快照
sc.RLock()
snap, _ := json.Marshal(map[string]interface{}{"tool_results": sc.ToolRes})
sc.RUnlock()
// 2. go_invoke_stage: 插件进程内全新对象
local := &Ctx{}
var m map[string]interface{}
json.Unmarshal(snap, &m)
if v, ok := m["tool_results"]; ok {
b, _ := json.Marshal(v)
json.Unmarshal(b, &local.ToolRes)
}
// 3. 插件 handler 跑在副本上
pluginFn(local)
// 4. stageContextWritable: 无条件回传 tool_results
out := map[string]interface{}{}
if len(local.ToolRes) > 0 { out["tool_results"] = local.ToolRes }
rb, _ := json.Marshal(out)
// 5. applyStageResult 写回内核
var rm map[string]interface{}
json.Unmarshal(rb, &rm)
sc.Lock()
if v, ok := rm["tool_results"]; ok {
b, _ := json.Marshal(v)
var trs []ToolResult
if json.Unmarshal(b, &trs) == nil { sc.ToolRes = trs }
}
sc.Unlock()
}
func sanitizerStage(ctx *Ctx) {
ctx.Lock(); defer ctx.Unlock()
for i, tr := range ctx.ToolRes {
if s, ok := tr.Result.(string); ok {
ctx.ToolRes[i].Result = cleanText(s)
}
}
}
func weatherStage(ctx *Ctx) {
ctx.Lock(); defer ctx.Unlock()
// 只读打印不改own_tools scope 已匹配)
_ = len(ctx.ToolRes)
}
func main() {
const rounds = 3000
dirty := "\x1b[31m晴 25°C"
polluted := 0
for r := 0; r < rounds; r++ {
sc := &Ctx{ToolRes: []ToolResult{{Name:"weather_query", Plugin:"weather", Result: dirty}}}
var wg sync.WaitGroup
wg.Add(2)
go func(){ defer wg.Done(); kernelExternal(sc, sanitizerStage) }()
go func(){ defer wg.Done(); kernelExternal(sc, weatherStage) }()
wg.Wait()
if s, ok := sc.ToolRes[0].Result.(string); ok && strings.Contains(s, "\x1b[31m") {
polluted++
}
}
fmt.Printf("现网场景复刻:模型调用 weather_querysanitizer+weather 并发跑 AfterToolcall\n")
fmt.Printf(" %d 轮中 %d 轮清洗结果被覆盖 (%.1f%%)\n", rounds, polluted, float64(polluted)/rounds*100)
if polluted > 0 {
fmt.Printf("\n ⚠️ 确认weather 回传的未清洗快照覆盖了 sanitizer 的清洗结果\n")
fmt.Printf(" → 脏数据ANSI 转义)进入 LLM 上下文\n")
}
}

View File

@ -1,62 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void (*fn)(void);
static void call(void* f){ ((fn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"os/exec"
"runtime"
"time"
"unsafe"
)
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
fmt.Println("=== A. cgo 模型:插件死循环,超时后能回收吗? ===")
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
base := threads()
fmt.Printf(" 基线: goroutines=%d threads=%d\n", runtime.NumGoroutine(), base)
for i := 1; i <= 3; i++ {
done := make(chan string, 1)
go func() { C.call(f); done <- "ok" }() // 模拟 executeToolCallInner
select {
case <-done:
case <-time.After(600 * time.Millisecond): // 缩短的"60s 超时"
}
time.Sleep(200 * time.Millisecond)
fmt.Printf(" 第 %d 次超时后: goroutines=%d threads=%d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base)
}
fmt.Println(" ❌ 每次超时永久泄漏 1 goroutine + 1 OS 线程cgo 调用不可中断)")
fmt.Println("\n=== B. 子进程模型:同样死循环,可强杀 ===")
base2 := threads()
for i := 1; i <= 3; i++ {
cmd := exec.Command("sleep", "3600")
cmd.Start()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
case <-time.After(300 * time.Millisecond):
cmd.Process.Kill() // ← 可强制终止
<-done
}
fmt.Printf(" 第 %d 次超时+Kill 后: goroutines=%d threads=%d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base2)
}
fmt.Println(" ✅ 零泄漏进程被杀OS 回收全部资源")
}

View File

@ -1,43 +0,0 @@
//go:build ignore
package main
/*
#cgo LDFLAGS: -ldl
#include <dlfcn.h>
#include <stdlib.h>
typedef void (*fn)(void);
static void call(void* f){ ((fn)f)(); }
*/
import "C"
import (
"fmt"
"os"
"runtime"
"time"
"unsafe"
)
func threads() int { e,_ := os.ReadDir("/proc/self/task"); return len(e) }
func main() {
p := C.CString("./hang.so"); h := C.dlopen(p, C.RTLD_NOW); C.free(unsafe.Pointer(p))
n := C.CString("hang_forever"); f := C.dlsym(h, n); C.free(unsafe.Pointer(n))
base := threads()
fmt.Printf("基线 threads=%d goroutines=%d\n\n", base, runtime.NumGoroutine())
for i := 1; i <= 20; i++ {
done := make(chan string, 1)
go func() { C.call(f); done <- "ok" }()
select {
case <-done:
case <-time.After(120 * time.Millisecond):
}
if i%5 == 0 {
fmt.Printf(" %2d 次卡死调用后: goroutines=%2d threads=%2d (+%d)\n",
i, runtime.NumGoroutine(), threads(), threads()-base)
}
}
fmt.Printf("\n结论: 20 次超时 → 泄漏 %d goroutine, %d OS 线程\n",
runtime.NumGoroutine()-1, threads()-base)
fmt.Println("每个卡在 cgo 里的 goroutine 独占一个 MOS 线程),无法被抢占或回收")
}

View File

@ -1,2 +0,0 @@
#include <unistd.h>
void hang_forever(void) { while(1) sleep(1); }

View File

@ -1,137 +0,0 @@
# 实验 19迁移验证工具Part 6.3
外部插件从 C ABI 动态库迁移到子进程后的批量重编与开销实测工具。
与 01~18 的性质不同:那些是**决策前**的可行性验证,这两个是**迁移执行期**
反复使用的操作脚本。
## rebuild-plugins.sh
批量把 `example/` 下的插件重编为子进程模式(`plugin.bin`)。
```bash
PLUGINDEV=/tmp/plugindev ./rebuild-plugins.sh weather sanitizer qq
```
关键性质:**不修改任何插件源码**。`plg.json``entry` 仍写着 `"plugin.so"`
也无妨——工具链已不看这个字段Part 6.1)。
两个实现细节值得记:
- **成功判定看产物而非退出码**。plugindev 对部分错误只 `fmt.Printf`
`os.Exit`,单看 `$?` 会把失败当成功。
- 构建前清 `build/`+`dist/`。残留的 `.so` 不影响构建,但会让人误以为
还在用旧通道。
已知环境依赖:`rss` 插件需要 `github.com/mmcdole/gofeed`
`proxy.golang.org` 不通时用 `GOPROXY=https://goproxy.cn,direct`
## measure-plugin-overhead.sh
实测 homed + 插件子进程的常驻开销。
```bash
./measure-plugin-overhead.sh $(pgrep -f 'homed -data' | head -1)
```
### 一个统计口径的坑
第一版混用了两个来源RSS 读 `/proc/pid/status``VmRSS`
PSS 读 `smaps_rollup``Pss`。结果输出 `PSS=87.9MB > RSS=69.1MB`——
物理上不可能。
原因是两者对**共享内存段**的计入方式不同:`smaps_rollup``Rss`
`Pss_Shmem`(共享段的按比例份额),`VmRSS` 不含。现已统一从
`smaps_rollup` 读,保证 PSS ≤ RSS。
### 实测结果2026-09-0215 个真实插件)
```
15 个插件进程 RSS=88.0 MB PSS=87.9 MB 线程=82
均摊 5.87 MB 5.86 MB 5.5 线程
homed 本体 RSS=182 MB 线程=15
```
**与实验 5 基线17 进程 RSS=29.1MB / PSS=12.9MB / 线程=84的偏差解释**
实验 5 用的是 2.68MB 的最小插件,真实插件 3.1~14.8MBbrowser 依赖最多)。
RSS 随二进制体积线性增长,故绝对数字不可比。可比的是结构性指标:
| 指标 | 基线 | 实测 | 判断 |
|---|---|---|---|
| 均摊线程 | 4.9 | 5.5 | 同量级,无线程膨胀 |
| PSS/RSS | 44% | 99.9% | **明显差于基线** |
第二项是真实发现:基线里 PSS 远低于 RSS说明 Go runtime 只读代码页在
进程间共享。实测几乎不共享,因为 15 个插件是 15 个**不同**的二进制,
没有共同的物理页可映射。
这是「每插件独立二进制」的固有代价,不是缺陷,但意味着实际内存开销
高于评估文档§4.3)的乐观估计。若日后需要压这一项,方向是让插件共享
一个 launcher 二进制 + 各自的业务 plugin而非各自静态链接整个 runtime。
## 冒烟测试
自动化部分在 `internal/plugins/real_plugin_smoke_test.go`4 项):
- `ToolInvokeRoundTrip`:工具真实调用往返(不只是注册)
- `StageRewriteTakesEffect`sanitizer 改写型 stage 在真实内核装配下生效
- `MultiPluginShareOneSegment`:多插件共享一段,只读插件不覆盖改写结果
- `CrashDoesNotKillKernel`SIGKILL 插件进程homed 存活
这些测试用**真实 example 产物**而非 testdata 假插件,且 manifest 刻意写
`"entry":"plugin.so"`——验证「业务代码零改动」这一承诺在完整内核装配下成立。
未重编时 skip 而非 failCI 不强制先跑重编脚本。
## 压测与延迟Part 6.6 验收)
基准与压测在代码里而非独立脚本:
`internal/plugin/proc/bench_test.go` + `streaming_test.go`
```bash
go test -run '^$' -bench . ./internal/plugin/proc/
go test -run 'TestStreaming_' -v ./internal/plugin/proc/
```
### 实测2026-09-02AMD Ryzen 7 7840HS
| 项目 | 实测 | 基线 | 判断 |
|---|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
| 事件环写入 | 95 ns | — | 亚微秒 |
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——两者测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级(那部分是 RPC 往返)。
基准原名 `BenchmarkStageLockRoundTrip` 有误导性,已改为
`BenchmarkStageLockArbitration`
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs2.8%
其余是**一次 stage 要走 3 次进程间往返**——`stage.invoke` 加上插件侧反向的
`stage.lock` / `stage.unlock`。相对 LLM 往返 2-8 秒可忽略;若日后要优化,
方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答,省掉两次往返。
### 流式压测§4.3 标记「风险高」的那一项)
原文的担忧:「`Bus.Publish` 路径禁用任何锁/阻塞——流式输出逐 token 发布,
任何等待都会卡顿」。
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms5000 × 20µs
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 几乎不变,无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token——
post-and-forget 在实现中成立。
最后一项的意义:消费者完全停摆时写端覆盖最旧 slot这条路径仍是 O(1)
故「消费者卡住」不会连带拖慢内核主循环。

View File

@ -1,82 +0,0 @@
#!/usr/bin/env bash
# 子进程插件常驻开销实测Part 6.3 验收项)。
#
# 对照基线docs/zh/experiments/plugin-arch 实验 5 实测 17 子进程
# PSS=12.9MB / RSS=29.1MB / 线程=84原文档估计 50-70MB 偏高)。
#
# 用法:./measure-plugin-overhead.sh <homed-pid>
set -uo pipefail
pid=${1:-}
if [ -z "$pid" ]; then
echo "用法: $0 <homed-pid>" >&2
exit 1
fi
if [ ! -d "/proc/$pid" ]; then
echo "进程 $pid 不存在" >&2
exit 1
fi
# homed 本体
homed_rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$pid/status")
homed_thr=$(awk '/^Threads:/ {print $2}' "/proc/$pid/status")
echo "=== homed 本体 ==="
printf "RSS=%s kB 线程=%s\n" "$homed_rss" "$homed_thr"
# 插件子进程homed 的直接子进程中执行 plugin.bin 的
echo
echo "=== 插件子进程 ==="
total_rss=0
total_pss=0
total_thr=0
count=0
for child in $(pgrep -P "$pid" 2>/dev/null); do
exe=$(readlink "/proc/$child/exe" 2>/dev/null || true)
case "$exe" in
*plugin.bin*) ;;
*) continue ;;
esac
thr=$(awk '/^Threads:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
# RSS 与 PSS 统一从 smaps_rollup 读,保证口径一致。
# 混用 status 的 VmRSS 与 smaps 的 Pss 会得出 PSS > RSS 的荒谬结果——
# 两者对共享内存段Pss_Shmem的计入方式不同。
rss=$(awk '/^Rss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
pss=$(awk '/^Pss:/ {print $2}' "/proc/$child/smaps_rollup" 2>/dev/null || echo 0)
if [ -z "$rss" ] || [ "$rss" = "0" ]; then
rss=$(awk '/^VmRSS:/ {print $2}' "/proc/$child/status" 2>/dev/null || echo 0)
fi
binsz=$(stat -c%s "$(readlink "/proc/$child/exe" 2>/dev/null)" 2>/dev/null || echo 0)
name=$(basename "$(readlink "/proc/$child/cwd" 2>/dev/null || echo unknown)")
printf " %-16s pid=%-8s RSS=%-8s PSS=%-8s 线程=%-3s 二进制=%s MB\n" \
"$name" "$child" "$rss" "$pss" "$thr" \
"$(awk -v b="$binsz" 'BEGIN{printf "%.1f", b/1048576}')"
total_rss=$((total_rss + rss))
total_pss=$((total_pss + pss))
total_thr=$((total_thr + thr))
count=$((count + 1))
done
echo
echo "=== 合计($count 个插件进程)==="
awk -v rss="$total_rss" -v pss="$total_pss" -v thr="$total_thr" -v n="$count" '
BEGIN {
printf "RSS=%d kB (%.1f MB)\n", rss, rss/1024
printf "PSS=%d kB (%.1f MB)\n", pss, pss/1024
printf "线程=%d\n", thr
if (n > 0) printf "均摊 RSS=%.2f MB PSS=%.2f MB 线程=%.1f\n", rss/1024/n, pss/1024/n, thr/n
}'
echo
echo "注RSS/PSS 均取自 smaps_rollup口径一致PSS ≤ RSS。"
echo "PSS 低于 RSS 的部分即 Go runtime 只读代码页在进程间的共享收益。"
echo
echo "对照实验 5 基线17 进程 RSS=29.1MB PSS=12.9MB 线程=84"
echo
echo "⚠️ 该基线用的是 2.68MB 的最小插件;真实插件 3.3~15.2MBbrowser 依赖最多)。"
echo " RSS 随二进制体积线性增长,故不可直接与基线数字比较——"
echo " 要比的是「均摊线程数」与「PSS/RSS 比值(共享收益)」这两个结构性指标。"

View File

@ -1,56 +0,0 @@
#!/usr/bin/env bash
# 批量重编外部插件为子进程模式Part 6.3)。
#
# 用法:./rebuild-plugins.sh <插件名>...
#
# 关键性质:**不修改任何插件源码**。每个插件只需用新版 plugindev 重编,
# plg.json 的 entry 仍写着 "plugin.so" 也无妨——工具链已不看这个字段。
set -uo pipefail
PLUGINDEV=${PLUGINDEV:-/tmp/plugindev}
EXAMPLE_DIR=${EXAMPLE_DIR:-"$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../.." && pwd)/third_party/homeagent-sdk/example"}
export GOCACHE=${GOCACHE:-/tmp/gocache}
export GOPATH=${GOPATH:-/tmp/gopath}
if [ ! -x "$PLUGINDEV" ]; then
echo "plugindev 不存在或不可执行: $PLUGINDEV" >&2
exit 1
fi
ok=0
fail=0
failed_names=""
for name in "$@"; do
dir="$EXAMPLE_DIR/$name"
if [ ! -d "$dir" ]; then
echo "$name: 目录不存在"
fail=$((fail + 1))
failed_names="$failed_names $name"
continue
fi
# 清理旧 C ABI 产物:同目录残留 .so 不影响构建,但会让人误以为还在用旧通道
rm -rf "$dir/build" "$dir/dist"
out=$(cd "$dir" && "$PLUGINDEV" build 2>&1)
rc=$?
# 判定成功的依据是产物存在而非退出码plugindev 对部分错误只打印不退出
if [ $rc -eq 0 ] && ls "$dir"/build/plugin.bin* >/dev/null 2>&1; then
n=$(ls "$dir"/build/plugin.bin* 2>/dev/null | wc -l)
hmap=$(ls "$dir"/dist/*.hmap 2>/dev/null | head -1)
printf "✓ %-14s %s 个平台产物 %s\n" "$name" "$n" "$(basename "${hmap:- hmap}")"
ok=$((ok + 1))
else
printf "✗ %-14s 构建失败\n" "$name"
echo "$out" | tail -6 | sed 's/^/ /'
fail=$((fail + 1))
failed_names="$failed_names $name"
fi
done
echo
echo "成功 $ok / 失败 $fail"
[ -n "$failed_names" ] && echo "失败:$failed_names"
exit $([ $fail -eq 0 ] && echo 0 || echo 1)

View File

@ -1,132 +0,0 @@
#!/usr/bin/env python3
"""生产切换:经 pluginmgr 正规通道安装 17 个 hmapPart 6.5)。
与手工拷贝方案的区别 —— 这里复用内核自己的安装逻辑:
validatePackage 校验 manifest + 平台二进制齐全
StopAndUnload 停旧实例但**保留配置表**
os.Rename 备份 解包失败自动回滚到旧版本
platformBinary() 按 runtime 挑当前平台那份,重命名为 plugin.bin
chmod 0755 补执行位
手工拷贝会重新实现这一套,且必然实现得更差(第一版就漏了 platforms 字段
与配置保留语义)。
用法:
switch-production.py 演练
switch-production.py --apply 实际安装
"""
import json
import os
import sys
import urllib.error
import urllib.request
PROD_PLUGINS = "/home/newqqagent/plugins"
SDK_EXAMPLE = "/home/program/TrueAgent/third_party/homeagent-sdk/example"
PLUGINMGR = "http://127.0.0.1:9876/plugins"
def find_hmap(name):
"""找插件的 hmap 包。
bundle:true -> <snake>_bundle.hmap含多平台二进制
bundle:false -> <snake>_<goos>_<goarch>.hmapqq 是这种)
"""
dist = os.path.join(SDK_EXAMPLE, name, "dist")
if not os.path.isdir(dist):
return None
cands = [f for f in os.listdir(dist) if f.endswith(".hmap")]
if not cands:
return None
for c in cands:
if c.endswith("_bundle.hmap"):
return os.path.join(dist, c)
return os.path.join(dist, sorted(cands)[0])
def install(path):
"""POST 到 pluginmgr。overwrite=true 走原地更新分支,保留配置表。"""
body = json.dumps({"path": path, "overwrite": True}).encode()
req = urllib.request.Request(
PLUGINMGR, data=body,
headers={"Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=180) as resp:
return json.loads(resp.read().decode()), None
except urllib.error.HTTPError as e:
return None, "HTTP %d: %s" % (e.code, e.read().decode()[:300])
except Exception as e:
return None, str(e)
def main():
apply = "--apply" in sys.argv
targets = sorted(
d for d in os.listdir(PROD_PLUGINS)
if os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.so"))
or os.path.isfile(os.path.join(PROD_PLUGINS, d, "plugin.bin"))
)
print("生产外部插件: %d" % len(targets))
# 先全部校验,任一缺包就整批中止。
# 理由:新 homed 不认 .so「一半装了一半没装」的中间态最难排查。
plan = []
missing = []
for name in targets:
h = find_hmap(name)
if h is None:
missing.append(name)
else:
plan.append((name, h))
if missing:
print("\n✗ 中止:以下插件缺 hmap 包:")
for m in missing:
print(" " + m)
print("\n先跑 rebuild-plugins.sh 重编。")
return 1
print("✓ 全部 %d 个 hmap 就位\n" % len(plan))
for name, h in plan:
print(" %-16s %-44s %6d KB" % (
name, os.path.basename(h), os.path.getsize(h) // 1024))
if not apply:
print("\n[演练] 加 --apply 才实际安装")
return 0
print("\n经 pluginmgr 安装overwrite=true保留配置...")
ok = 0
failed = []
for name, h in plan:
result, err = install(h)
if err:
print("%-16s %s" % (name, err))
failed.append(name)
continue
if "error" in result:
print("%-16s %s: %s" % (
name, result["error"], result.get("details", "")))
failed.append(name)
continue
print("%-16s %-12s v%s -> v%s config_kept=%s" % (
name,
result.get("action", "?"),
result.get("previous_version", "?"),
result.get("version", "?"),
result.get("config_kept", False)))
ok += 1
print("\n成功 %d / 失败 %d" % (ok, len(failed)))
if failed:
print("失败: " + " ".join(failed))
return 1
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,111 +0,0 @@
# 插件架构评估实验
[`../../架构迁移评估.md`](../../架构迁移评估.md) 中所有数字的来源。
**18 项实验,一键复跑**,用于复核结论或在改动后验证回归。
```bash
./run.sh # 跑全部(约 3-5 分钟)
./run.sh 12 13 # 只跑指定实验
./run.sh 1 1c # dlclose/NODELETE 组
```
依赖:`go >= 1.21``gcc`、Linux用到 `eventfd`/`memfd_create`/`dlopen`)。
脚本在 `mktemp -d` 里构建,**不污染主仓 `go.mod`**;实验源码均带 `//go:build ignore`
拉取 `golang.org/x/sys` 需要网络(实验 1/2/4/8/10。本机走 clash
```bash
export HTTPS_PROXY=http://127.0.0.1:7890 HTTP_PROXY=http://127.0.0.1:7890
```
## 目录
| 目录 | 主题 | 对应章节 |
|---|---|---|
| `01-dlclose-nodelete/` | `dlclose``DF_1_NODELETE` 是 no-op | 1.1 / 1.2 |
| `02-feasibility/` | 新架构可行性 11 项 | 第七章 |
| `03-lost-update/` | 副本模型的 lost update | 8.4 / 8.6 |
| `04-cgo-uninterruptible/` | cgo 调用不可中断 | 9.3 |
## 实验清单与最近一次实测结果
复跑于 2026-08-31go1.25.12 linux/amd64192.168.2.6012 核)。
### 01 组dlclose / NODELETE
| # | 实验 | 结论 |
|---|---|---|
| 1a | Go 宿主经纯 C shim 加载/卸载第三层 `.so` | 纯 C 目标可卸载Go c-shared 目标仍不可 |
| 1b | `/proc/self/maps` 段数验证 | 纯 C: 5→**0**真卸载Go c-shared: 5→**5** |
| 1c | 版本化路径 dlopen | handle 不同,`ver=v2` 生效(方案可行但泄漏,已否决) |
**关键**`DF_1_NODELETE` 属于**被卸载对象自身**的 ELF 属性,
与谁调用 `dlopen` 无关——套任何层数的 C 中间件都绕不过去。
### 02 组:新架构可行性
| # | 实验 | 最近结果 |
|---|---|---|
| 1 | eventfd 是否走 Go netpoller | 200 goroutine 阻塞 → 线程 **+0~1** ✅ |
| 2 | 跨进程 eventfd + 偏移解引用 | 父子 mmap 基址不同偏移仍正确post **10.9 µs** |
| 3 | 锁仲裁 RPC 往返成本 | **19.4 µs/次**20000 次) |
| 4 | post-and-forget vs 同步 Publish | 5.07s → 2.29ms**2218x** |
| 5 | 17 子进程常驻开销 | **29.1MB RSS / 12.9MB PSS**84 线程 |
| 6 | 子进程崩溃隔离 | 退出码 **2**EOF **2.5ms** 感知,宿主存活 |
| 7 | 子进程热重载 | 同路径替换二进制 → v1→v2 立即生效 |
| 8 | **跨进程并发改写 StageContext** | 5 进程 × 300 轮,**零丢失零撕裂** |
| 9 | 持锁进程崩溃自愈 | 无死锁,**无需 robust mutex** |
| 10 | 二进制零拷贝 | 100KB/1MB/5MB → **14-22x**,体积 100% |
| 11 | 工具调用 RPC 延迟 | p50 **19.6 µs**,占 LLM 往返 0.00065% |
### 03 组:副本模型缺陷
| # | 实验 | 最近结果 |
|---|---|---|
| 12 | 副本模型 lost update 率 | 内置 **0%** vs 外部 **35.8~36.8%** |
| 13 | 现网 sanitizer+weather 冲突 | **1.6~4.3%** 清洗结果被覆盖 |
**实验 12 的对照设计是重点**:两组用**完全相同的并发扇出**
`stages.go:124``go func` + `wg.Wait()`),唯一差异是
「共享同一 `*StageContext`」vs「快照-副本-写回」。
内置组 0% 证明**并发扇出这个原始设计是正确的**
副本组 36% 证明**跨 C ABI 边界后锁语义失效**才是缺陷所在。
不要据此得出"应该取消并发"的结论。
⚠️ **13 的比率随机器负载波动**(观测区间 1.6%~4.3%)——它取决于两个插件
handler 的实际执行耗时比。文档正文引用 1.6% 是首次测量值,
**应理解为「量级在百分之几」而非精确常数**
### 04 组cgo 不可中断
| # | 实验 | 最近结果 |
|---|---|---|
| 14a | cgo 死循环 vs 子进程 Kill | cgo 泄漏;子进程 **零泄漏** |
| 14b | 泄漏增长曲线20 次) | 泄漏 **20 goroutine / 18 OS 线程**,线性 |
## 复跑时的注意事项
**结果会有波动,以下属正常**
- 实验 12/13 的丢失率随调度波动12 稳定在 35~37%13 在 1.6~4.3%
- 实验 1 的线程增长为 0 或 1取决于 netpoller 线程是否已存在)
- 实验 10 的加速比 14~22x受 CPU 缓存状态影响)
- 实验 5 的 PSS 受同机其他 Go 进程影响(共享页计算)
**结果不应变的**(若变了说明环境或结论有问题):
- 实验 1b 中纯 C `.so` 的段数必须归 **0**Go c-shared 必须**不归零**
- 实验 8 的「总字符数 == 最终长度」必须成立(零丢失)
- 实验 9 必须无死锁
- 实验 12 的内置模型必须 **0%**
- 实验 14b 的泄漏必须**线性增长**
## 已知限制
- 实验 8 的 arena 未实现压实64KB 用尽即停止写入(写入次数 < 5×300 属预期
见评估文档 3.3
- 实验 12/13 **链路复刻**而非直接调用生产代码
证明的是副本模型这一机制存在缺陷不能替代对 `sanitizer`/`weather`
的真实行为回归测试
- 实验 5 的插件是最小 stdio loop2.68MB真实插件 qq 7.5MB开销更高
- Windows 环境9.2 Windows DLL 缺陷**未经实测**仅代码阅读

View File

@ -1,127 +0,0 @@
#!/usr/bin/env bash
# 插件架构评估实验 —— 一键复跑
# 用法: ./run.sh [实验编号...] 例: ./run.sh 12 13 留空跑全部
# 依赖: go >= 1.21, gcc, Linux (eventfd/memfd/dlopen)
set -uo pipefail
cd "$(dirname "$0")"
ROOT=$(pwd)
PASS=0; FAIL=0
need() { command -v "$1" >/dev/null || { echo "缺少依赖: $1"; exit 1; }; }
need go; need gcc
# 统一的临时 module 环境(避免污染主仓 go.mod
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
banner() { echo; echo "════════ $* ════════"; }
# x/sys 只有 exp1/2/4/8/10 需要
prep_xsys() {
cat > "$1/go.mod" <<EOF
module exp
go 1.21
require golang.org/x/sys v0.20.0
EOF
(cd "$1" && GOFLAGS=-mod=mod go get golang.org/x/sys@v0.20.0 >/dev/null 2>&1)
}
prep_plain() { printf 'module exp\ngo 1.21\n' > "$1/go.mod"; }
run_go() { # <目录> <说明>
if (cd "$1" && go run . 2>&1); then PASS=$((PASS+1)); else echo " ❌ 失败: $2"; FAIL=$((FAIL+1)); fi
}
SEL="${*:-all}"
sel() { [ "$SEL" = "all" ] && return 0; case " $SEL " in *" $1 "*) return 0;; esac; return 1; }
# ── 01: dlclose / NODELETE ────────────────────────────────
if sel 1; then
banner "实验 1 组: dlclose 对 DF_1_NODELETE 是 no-op"
W=$WORK/e01; mkdir -p $W; cp 01-dlclose-nodelete/*.c $W/
gcc -shared -fPIC -o $W/probe_v1.so $W/probe_v1.c
gcc -shared -fPIC -o $W/probe_v2.so $W/probe_v2.c
gcc -shared -fPIC -o $W/shim.so $W/shim.c
cp $W/probe_v1.so $W/probe.so
for e in exp01a exp01b; do
mkdir -p $W/$e; cp 01-dlclose-nodelete/$e/main.go $W/$e/
sed -i '/^\/\/go:build ignore$/d' $W/$e/main.go; prep_plain $W/$e
(cd $W/$e && go build -o ../$e.bin . 2>&1 | head -3)
done
echo "--- 01a: Go 宿主经 C shim 加载/卸载纯 C so ---"
(cd $W && ./exp01a.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
echo "--- 01b: /proc/self/maps 段数验证(纯 C 归零Go c-shared 不归零)---"
(cd $W && ./exp01b.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
fi
# ── 01c: 版本化路径(需要两个真 Go c-shared────────────────
if sel 1c; then
banner "实验 1c: 版本化路径 dlopen 可加载新代码"
W=$WORK/e01c; mkdir -p $W/{v1,v2,host}
for V in v1 v2; do
cat > $W/$V/main.go <<EOF
package main
import "C"
//export lib_version
func lib_version() *C.char { return C.CString("$V-CODE") }
func main() {}
EOF
printf 'module gl%s\ngo 1.21\n' $V > $W/$V/go.mod
(cd $W/$V && go build -buildmode=c-shared -o ../gl$V.so . 2>&1|head -3)
done
cp 01-dlclose-nodelete/exp01c/main.go $W/host/
sed -i '/^\/\/go:build ignore$/d' $W/host/main.go; prep_plain $W/host
(cd $W/host && go build -o ../h.bin .) && (cd $W && ./h.bin) && PASS=$((PASS+1)) || FAIL=$((FAIL+1))
fi
# ── 02: 可行性 1-11 ───────────────────────────────────────
declare -A XSYS=([1]=1 [2]=1 [4]=1 [8]=1 [10]=1)
for n in 1 2 3 4 5 6 7 8 9 10 11; do
sel $n || continue
banner "实验 $n"
W=$WORK/f$n; mkdir -p $W
case $n in
1) cp 02-feasibility/exp1_eventfd.go $W/main.go ;;
2) cp 02-feasibility/exp2_parent.go $W/main.go; cp 02-feasibility/exp2_child.go $W/ ;;
3) cp 02-feasibility/exp3_parent.go $W/main.go; cp 02-feasibility/exp3_child.go $W/ ;;
4) cp 02-feasibility/exp4.go $W/main.go ;;
5) cp 02-feasibility/exp5b.go $W/main.go; cp 02-feasibility/exp5_plugin.go $W/ ;;
6) cp 02-feasibility/exp6.go $W/main.go; cp 02-feasibility/exp6_crash.go $W/ ;;
7) cp 02-feasibility/exp7.go $W/main.go ;;
8) cp 02-feasibility/exp8.go $W/main.go; cp 02-feasibility/exp8_worker.go $W/ ;;
9) cp 02-feasibility/exp9.go $W/main.go; cp 02-feasibility/exp9_worker.go $W/ ;;
10) cp 02-feasibility/exp10.go $W/main.go ;;
11) cp 02-feasibility/exp11.go $W/main.go; cp 02-feasibility/exp11_plug.go $W/ ;;
esac
# 去掉 main.go 的 build ignore它是入口
sed -i '/^\/\/go:build ignore$/d' $W/main.go
if [ "${XSYS[$n]:-}" = "1" ]; then prep_xsys $W; else prep_plain $W; fi
# 需要预编译的辅助二进制
case $n in
5) (cd $W && go build -o plugbin exp5_plugin.go 2>&1|head -3) ;;
6) (cd $W && go build -o crashbin exp6_crash.go 2>&1|head -3) ;;
11) (cd $W && go build -o plug11 exp11_plug.go 2>&1|head -3) ;;
esac
run_go $W "实验 $n"
done
# ── 03: lost update ───────────────────────────────────────
for e in 12 13; do
sel $e || continue
banner "实验 $e: 副本模型 lost update"
W=$WORK/l$e; mkdir -p $W
cp 03-lost-update/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
prep_plain $W; run_go $W "实验 $e"
done
# ── 04: cgo 不可中断 ──────────────────────────────────────
for e in 14a 14b; do
sel 14 || sel $e || continue
banner "实验 $e: cgo 调用不可中断"
W=$WORK/c$e; mkdir -p $W
cp 04-cgo-uninterruptible/hang.c $W/
gcc -shared -fPIC -o $W/hang.so $W/hang.c
cp 04-cgo-uninterruptible/exp$e/main.go $W/; sed -i '/^\/\/go:build ignore$/d' $W/main.go
prep_plain $W; run_go $W "实验 $e"
done
banner "汇总: 通过 $PASS, 失败 $FAIL"
[ $FAIL -eq 0 ]

View File

@ -541,9 +541,9 @@ v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 st
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
等价于旧行为L1
- 合回 `main` 前需完成的发布动作:
1. 同步更新 `docs/zh/plugin-interface-matrix.md`
2. 与 SDK 仓协同升 SDK 中版本;
3. 遵守“只增不减、签名不改”边界
1. 与 SDK 仓协同升 SDK 中版本(`docs/git-branching.md` §七)
2. 遵守“只增不减、签名不改”,并同步 hmapdev 模板接线
`docs/git-branching.md` §八)
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`
同步追加 `priority`,与公开 SDK 字段一一对应。

View File

@ -96,7 +96,7 @@ axis实际却只能用导出的那个长度运行。
| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 实测常驻 | **721MB / 1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 产物 / 实测内存 | **721MB / 稳态 0.89GB(峰值 1.59GB** | ~2GB / 2.23GB | 8GB / 峰值 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC不可商用 | Apache-2.0 |
| 中文 | 原生~2 亿中文图文对 | 多语言 | 多语言 |

View File

@ -1,284 +0,0 @@
# WebUI 布局与配置归位修复计划
## 一、背景
上一轮 SDK 接口化改造完成并部署后,用户指出三个问题:
1. **WebUI 窄屏布局损坏**:顶部 `<nav>` 为桌面式横排(标题 + 6 tab + 连接指示器 + 语言 + 主题),
窄屏断点仅缩小字号不换行,`body { overflow-x:hidden }` 直接把溢出的 tab 裁掉不可点击。
2. **OpenClaw skills 目录被注册为核心配置**`core.skills.path`"OpenClaw 技能存储目录")注册在核心
配置表(`internal/config/registry.go`),但全仓无任何读取方(死配置);实际生效路径是
clawhubadapter 自己的 `skills_dir` 配置(`config_clawhubadapter` 表 + `core.daemon.data_dir`/skills 兜底)。
技能目录是 clawhubadapter 适配加载的领域,不应属于核心配置。
3. **clawhubadapter 加载的微信插件成为独立配置项**:设置页出现 `channels.wechat.*`(核心表)、
`plugin.wechat.*`config_wechat 表)、`config_openclaw_weixin`(空表)等多处微信配置,
全部为历史残留——当前代码零引用OC 技能的配置实际在其自身 `~/.openclaw/openclaw.json`
设置页会把核心表全部键 + 全部插件表当作配置组展示,导致残留以"独立配置项"形态出现。
## 二、修复计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| A | 窄屏导航修复:<768px nav 横向滚动h1 缩写连接指示器简化body 溢出裁切改为 nav 内滚动 | `cmd/gui/renderer/style.css` | |
| B | 删除 `core.skills.path` 核心配置注册set + RegisterDef 两处 | `internal/config/registry.go` | 无读取方 |
| C | 备份后清理残留配置`channels.wechat.*` `config_wechat` / `config_openclaw_weixin` / `config_openclaw` 含微信 token先备份 | 生产库 `/home/newqqagent/config.db` | 当前代码不读 |
## 三、实施记录
### 步骤 Awebui 窄屏导航修复(已完成)
- 修复对象为 webui HTTP 服务真正前端 `internal/plugins/webui/dashboard.html``go:embed` 内嵌
登录后 `/` 返回104KBcmd/gui 是独立 electron 客户端 webui 一部分)。
- `<768px` 断点`nav { overflow-x:auto; scrollbar-width:none; flex-wrap:nowrap }` + `::-webkit-scrollbar { display:none }`
`nav a { white-space:nowrap; flex-shrink:0 }``nav h1 { font-size:0 }`保留 logo 隐藏文字弥补窄屏空间
`nav > div { flex-shrink:0 }` 右侧语言/主题/退出按钮不压缩
- 顺带在 cmd/guielectron 客户端同步了窄屏样式与消息来源徽标`app.js`/`style.css`客户端窗口缩放同样受益
客户端需另行构建 electron 应用才生效)。
- 验证部署后 `/` 返回的 dashboard `scrollbar-width:none`/`font-size:0`/`::-webkit-scrollbar` 规则
### 步骤 B删除 core.skills.path 核心配置(已完成)
- 删除 `internal/config/registry.go` 两处`set("core.skills.path", ...)`SeedDefaults
`reg(ConfigDef{Key:"core.skills.path", ...})`定义注册)。
- 理由该键全仓无读取方grep 仅命中注册处实际生效路径是 clawhubadapter `skills_dir`
config_clawhubadapter + `core.daemon.data_dir`/skills 兜底)。技能目录属 clawhubadapter 适配领域
- 验证`grep -rn "skills.path" --include="*.go"` 零命中部署后设置页无 `core.skills.path`
### 步骤 C清理生产库残留配置已完成
- 操作前 `sqlite3 .backup /tmp/opencode/config.db.pre-clean.bak`含微信 token 数据)。
- 删除`config` `channels.wechat.*` 3 + `core.skills.path` `DROP TABLE config_wechat /
config_openclaw_weixin / config_openclaw`三者均为历史残留当前代码零引用clawhubadapter 实际
使用 config_clawhubadapter 表OC 技能配置在其自身 `~/.openclaw/openclaw.json`)。
- 验证:设置页总键数 138→129无 wechat/weixin/skills.path 残留,`plugin.clawhubadapter.skills_dir /
simulator_dir` 正常;服务 healthcheck ready、clawhubadapter "OC plugin manager started"。
---
# SDK Stop 注册接口RegisterStopHandler计划
## 一、背景
2026-08-01 20:00 起生产 homeagent 进入崩溃循环(`fatal error: thread exhaustion`
systemd 重启计数 61+)。排查定位为 SDK 示例插件 `calendar`(示例源码在 SDK 仓库
`example/calendar`,生产以 plugin.so 形态加载)三个缺陷叠加:
1. **农历引擎 3 个 bug**`daysInLunarYear` 位循环 `i > 0` 应 `i > 0x8`、缺闰月天数、
`lunarToSolar` 内层重复加闰月)→ `lunarToSolar(2026,4,12)` 返回 **2062-11-16**(偏移 36 年),
农历重复事件(`lunar_yearly`)的 next 被生成到遥远错误日期。
2. **`cleanupPastEvents` 保留过时重复事件** → 每 30s ticker 对已到点的重复事件再生成一份 next
事件从 7 个爆炸到 **45612 个**15MB events.json
3. **无提醒投递保护**15018 份同时到点的事件一次性 `go sdk.InjectInterruptText(...)` 投递
→ interrupt 风暴 → goroutine/线程耗尽。
处置:修复农历引擎 3 处 + next 去重 + 清理过时重复事件,用**新版 SDK 仓库 + 新版 plugindev 工具链**
重建 `calendar_linux_amd64.hmap`,经 **webui `POST /api/v1/plugins`**(透明代理到 pluginmgr 安装接口)
重装,重启验证收敛(事件 4 个、next 正确生成 2027-05-17、0 崩溃)。
**过程中暴露的能力缺口**SDK 只有 `Plugin` 接口的 `Name/Start/Stop`**没有 stop 注册接口**
`RegisterStopHandler`/`OnStop` 均不存在SDK v0.7.2/v0.8.0/master 一致)。插件停止时只能在自己的
`Stop()` 里写清理逻辑SDK 层无法统一执行"停止时清理"回调calendar 的 `Stop() { p.saveEvents() }`
还会用陈旧内存把已清理的数据写回磁盘(曾导致删除的重复事件复活)。
## 二、计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| 1 | 公共 SDK `PluginSDK` 加 `RegisterStopHandler(fn func())` + `RunStopHandlers()`(幂等、后注册先执行),两处同步 | `third_party/homeagent-sdk/sdk/plugin.go`、SDK 仓库 `sdk/plugin.go` | 低(纯新增,内置 SDK 内嵌透传) |
| 2 | 内核 Registry 保存每插件 SDK 引用(`sdkRefs``StopAll`/`ReloadOne`/`DisablePlugin` 调 `Stop()` 前执行 `RunStopHandlers` | `internal/plugin/registry.go` | 中(生命周期路径,需回归 reload/disable |
| 3 | 工具链 plugindevz_bridge 模板 `bridgeState` 存 SDK`StopPlugin` 先 `RunStopHandlers()` 再 `plugin.Stop()`init 脚手架模板加演示 | SDK 仓库 `tools/plugindev/templates.go`、`templates/main.go.tmpl` | 低 |
| 4 | 内置示例插件演示(如 timerticker 停止改为 stop handler | `internal/plugins/timer/plugin.go` | 低 |
| 5 | 外部示例插件同步(`example/calendar` 的 `saveEvents` 改由 stop handler 执行,验证 z_bridge 链路;其余 example 加演示) | SDK 仓库 `example/*` | 低 |
| 6 | 文档同步SDK README 生命周期章节 + 主仓插件开发文档 | SDK 仓库 `README.md`/`README_EN.md` 等 | 无 |
## 三、实施记录
1. SDK 公共层(`RegisterStopHandler` + `RunStopHandlers`:后注册先执行、执行后清空幂等)已落地
`third_party/homeagent-sdk/sdk/plugin.go`,并同步到 SDK 仓库 `/tmp/opencode/sdk-repo/sdk/plugin.go`(两处一致)。
2. 内核 Registry`internal/plugin/registry.go`)新增 `sdkRefs map[string]*sdk.PluginSDK` + `runStopHandlers`
`loadOne` 注册、`StopAll`/`ReloadOne`/`DisablePlugin` 在 `Stop()` 前执行(共 4 处调用点)。
3. 工具链 plugindevSDK 仓库linux `tmplLinuxBridge` 的 `go_stop_plugin` 先 `RunStopHandlers()` 再 `plg.Stop()`
windows `tmplBridge` 的 `bridgeState` 加 `sdk` 字段、`StopPlugin` 同链路;`tmplPluginGo` + `main.go.tmpl`
脚手架加 `RegisterStopHandler` 演示。plugindev 重新编译通过GOPATH=/root/go
4. 内置 timer 插件演示:`close(p.stopCh)` 移入 stop handler`Stop()` 只 `wg.Wait()`。
5. 外部示例:`example/calendar` 的 `saveEvents` 改为 `s.RegisterStopHandler(p.saveEvents)`
`Stop()` 删除写盘调用(持久化交由 stop handler避免陈旧内存复活已删事件
6. 文档SDK 仓库 `README.md`/`README_EN.md` 生命周期章节补充 RegisterStopHandler 说明。
7. 构建测试:主仓 `go build ./...` + `go test ./internal/sdk/... ./internal/plugin/...` 全绿;
SDK 仓库 `go build ./...` + `go test ./sdk/...` 全绿。
8. 生产部署验证:新 plugindev--no-bundle重建 `calendar_linux_amd64.hmap`md5 084e97c0…strings 确认
`go_stop_plugin → RunStopHandlers → saveEvents` 编译进 plugin.sowebui API 删旧装新;重装新内核
homed含 sdkRefs/runStopHandlers两次重启事件稳定 3 个不复活、events.json mtime 与 stop 时刻吻合
saveEvents 经 stop handler 真实执行、0 次 thread exhaustion、服务 active。
---
# clawhubadapter OpenClaw 通道插件兼容修复计划
## 一、背景
生产 `core.llm.provider` 已是 mock LLM 源(`core.llm.sources.mocktest`base_url
`http://127.0.0.1:18080/v1`、model mock-model、adapter openaimock LLM 服务常驻运行。
借助 **mock 通道插件**`/tmp/opencode/mock-skills/mock-wechat/`,完全复刻 openclaw-weixin 的
真实注册格式 `register(api) → api.registerChannel({ plugin: ChannelPlugin })`)放入生产 skills 目录
端到端复现,得出如下结论:
**已验证可用链路**manager 加载 mock 插件 → Go 端识别 `ocplugin mock-wechat handled by manager` →
注册工具 `mock-wechat_read_mock_wechat_input`/`mock-wechat_mock_echo` → `RegisterOutputChannel("mock-wechat")`
→ mock 自推消息经 `channel_input` 通知 → `[agent] interrupt from manager/mock-wechat` →
mock LLM 正常回复195ms
**复现的核心缺陷**(真实通道插件 wechat/dingding"根本不可用"的根因):
1. **输出断链**manager `tools/call` 通道分支只认 `channelPlugin.outbound.sendText/sendMedia`
(旧格式),真实 ChannelPluginopenclaw-weixin 等)无 outbound →
`tools/call mock-wechat → error: "channel mock-wechat has no output handler"`。
2. **生命周期静止**manager mock api 从不调用 `gateway.startAccount/stopAccount`,也无
`api.runtime`/`channelRuntime` → 通道插件加载后永不启动(不登录、不轮询、不收消息)。
3. **输入依赖错位**:真实插件把消息经 `channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher`
推送manager 完全无此对象),而不是调 `api.submitInput`。
4. **stdout 污染**:插件 `console.log` 直接进 JSON-RPC 流Go 端 readLoop 跳过非 JSON 行,有丢通知风险。
**wechat 通道的心跳机制**`openclaw-weixin/dist/index.js` `pollLoop`448 行起):每账号一个常驻
`pollLoop`,循环 `POST ilink/bot/getupdates`body `{get_updates_buf}`,超时 35s——**长轮询即心跳**
服务器收到 poll 请求即知通道在线,新消息随 poll 响应 push 回来;超时视为空响应继续轮询,真错误延时
5s 重试。**与 gateway 生命周期强绑定**
- `pollLoop` 只由 `gateway.startAccount(ctx)` 启动;不被调用 → 心跳/收消息全断(服务器侧认为通道离线)。
- `startAccount` 末尾 `await new Promise(()=>{})` **永久挂起**——OC gateway 靠它配合 health-monitor
startAccount 退出 → 判定账号崩溃 → 重启账号。manager 调 `startAccount` 必须 **fire-and-forget**。
- 停靠 `gateway.stopAccount(ctx)``ctx.account.accountId` 定位),停止时经 `statusSinks` 调
`ctx.setStatus({running:false, connected:false, lastStopAt})`;启动即上报
`ctx.getStatus()/ctx.setStatus({...running:true, connected:true, lastStartAt})`——`connected` 是
gateway 判断账号存活的依据。
- `sendTyping`ilink/bot/sendtyping + typing_ticket是打字指示非心跳无需支持。
## 二、修复计划
| # | 动作 | 位置 | 风险 |
|---|------|------|------|
| A | manager 提供 **gateway 生命周期桥**channel 插件注册后自动 `gateway.startAccount(ctx)`fire-and-forget不等待挂起的 Promise构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`Go 端 `channel_stop` 通知 → `stopAccount` | `internal/plugins/clawhubadapter/manager/main.js` | 中 |
| B | 实现 **channelRuntime mock**`reply.dispatchReplyWithBufferedBlockDispatcher`deliver 回调 → `channel_output` 通知送 Go 端)、`getPolls`OC 通用通道轮询输入)、`call` 透传 | 同上 | 中 |
| C | `tools/call` 通道分支改造:无 `outbound` 的 ChannelPlugin 改走 channelRuntime 事件式发送agent 输出 → deliver不再报 "no output handler" | 同上 | 低 |
| D | 状态上报透传:`setStatus` 经 `channel_status` 通知 → Go 端可查health-monitor 语义startAccount 保持挂起) | 同上 | 低 |
| E | stdout 卫生:插件 `console.log` 重定向 stderr或 JSON-RPC 流感知封装),杜绝污染 | 同上 | 低 |
| F | Go 端:`channel_output`/`channel_status` 通知接入(事件分发),通道输出 handler 保持 `sp.CallTool` | `internal/plugins/clawhubadapter/registry.go`、`plugin.go` | 中 |
| G | 端到端验证mock 通道插件 + mock LLM生产环境临时放入/移出 skills 目录)复跑全链路(登录启动→收消息→回复→出站→停止) | 生产 | 低 |
## 三、实施记录
(逐步填写)
1. **manager/main.js — 通道运行时与生命周期桥(已完成,独立运行验证)**
- `makeChannelRuntime(chName, ch)``reply.dispatchReplyWithBufferedBlockDispatcher(opts)` 提取
`dispatcherOptions.deliver`/`typingCallbacks` 按 `ctx.AccountId` 挂到 `ch.deliverers`,随后
`notify('channel_input', {channel, payload:{content: BodyForAgent||Body, from, sessionKey, accountId,
messageSid, chatType, raw}})` 入站;返回 dispatchersendNow/addToBuffer/sendBuffer/closeBuffer
`chatPolls`/`getPolls` 空转(防断连误判)、`call` 转发 `channel_output` 通知。
- `startChannels(name)`channel 插件注册后自动枚举账号(`config.listAccountIds`→`resolveAccount`
缺省 `['default']`),构造完整 ctx `{account, cfg, channelRuntime, getStatus, setStatus}`
**fire-and-forget** 调 `gateway.startAccount`(真实插件会永久挂起,绝不等待);崩溃/状态变更经
`channel_status` 通知透传health-monitor 语义startAccount 不退出=账号存活)。
- `stopChannels(name)`:逐个账号 `gateway.stopAccount`;进程 SIGTERM/SIGINT 时统一执行优雅停靠。
- `tools/call` 通道分支:保留 outbound旧格式→ 新增 **deliver 事件式发送**
`deliverItem` 按 accountId 取 deliver + typingCallbacks.onReplyStart/onCleanup 包裹)→
无 deliver 时降级 `channel_output` 通知 → 兜底报错。不再出现 "has no output handler"。
- **stdout 卫生**:全局 `console.log` 重定向 stderrJSON-RPC 流仅承载协议帧。
2. **mock 插件升级(/tmp/opencode/mock-skills/mock-wechat/index.js**:完全复刻真实 weixin 行为——
`gateway.startAccount` 永久挂起 + `setStatus` 上报 + `setInterval` 心跳轮询 + 800ms 后经
`dispatchReplyWithBufferedBlockDispatcher` 推送入站deliver 本地记录发送);`stopAccount` 停轮询+状态置否。
3. **manager 独立运行验证(通过)**`channel_status` 启动上报running=true connected=true
`tools/call mock-wechat` → `{"status":"sent","via":"channelRuntime.deliver"}`,插件 deliver 收到
`text="hello from agent"` 且 typing onReplyStart/onCleanup 正确包裹;心跳 poll #1-4 常驻;
SIGTERM → `stopAccount called` 退出码 0插件 console 输出全部走 stderr协议流零污染
4. **Go 端通知接入plugin.go translateAndRegister + registry.go 状态缓存)**`channel_status` 存
`channelStatus` map可查+ 日志;`channel_output` 降级事件日志。`go build ./...` 通过。
5. **回复闭环修复(同步注入)**`channel_input → InjectInterruptText` 的 InputEvent 不带 ResponseCh
internal/agent/io/channel.go:276agent 回复在 emitResponseeventloop.go:384被静默丢弃。
改为 `s.InjectInputSync(pluginName, channel, "text", payload)`(内部 SDK 已有 4 参版本,返回
`*OutputEvent`)同步等待回复 → 提取 `Payload["content"]` → `sp.CallTool(channel, {payload, meta})`
→ manager `tools/call` → deliver → 插件发送 → 微信送达。公共 SDK IOInjector 同步补
`InjectInputSync(source, channel, text) string`ioAdapter 实现,供外部插件一致使用)。
6. **mock LLM 恒定文本化**/opt/llm-mock/mock_server.py `decide()` 删除工具调用分支,一律回文本
"无论收到什么消息都通过微信插件发送"),保证每条入站消息回复必然走通道输出。
7. **生产微信闭环验证(通过)**:用户微信发"你好..." → pollLoop 收到 → dispatchReply →
InjectInputSync → mock LLM 回文本 → CallTool(wechat) → deliver → `POST ilink/bot/sendmessage`
→ **status=200 message_id=7489545365740590088**,微信收到"mock已收到消息长度 324 字符。"
8. **通用性审查(无硬编码)**manager/plugin.go/registry.go 均无 weixin/wechat 特判,全部按 OC 规范
字段实现gateway/config/capabilities/channelRuntime/deliver/typingCallbacks。修正规范签名参数
约定:`listAccountIds(cfg)`、`resolveAccount(cfg, accountId)` 正确传 cfg。
9. **已知边界**非硬编码架构性a) `channelRuntime.getPolls/chatPolls` 返回空 msgs——依赖
runtime 轮询输入的通用通道型插件收不到消息weixin/dingding 类自带 pollLoop 的通道不受影响);
b) `gatewayMethods` 登录流程web.login.start/QR 扫码未实现CLI 有 stub通道凭 token
配置直连c) startAccount ctx 提供 account/channelRuntime/cfg/getStatus/setStatus 核心字段。
10. **补充边界 a) getPolls/chatPolls 消息源(已完成)**manager `makeChannelRuntime` 的
`chatPolls/getPolls` 改为读 `ch.pollQueues`(按 accountId 队列poll 取走即消费);新增
`channel/send` RPCGo 端注入 → 队列 → 插件轮询取走Go 端 `SendToChannel(channel, payload)`
+ `ChannelSender()` 单例Start 时置位。验证mock-poll 插件(纯 chatPolls 轮询型)——
`channel/send` → `{"status":"queued"}` → `[mock-poll] poll got msg` → dispatchReply →
`channel_input` 入站完整content/from/sessionKey/accountId/messageSid/chatType
微信链路回归正常Polling started + channel_status running=true
11. **边界 b) 登录流程核实(已解决,无需实现)**:真实登录机制是 **SKILL 脚本旁路**——
`weixin-openclaw-login` SKILL 的 `scripts/get-login-url.js`ilink 二维码 URL+
`poll-login-status.py`(轮询扫码状态)→ agent 经 exec 执行 → 拿 bot_token 写入
`~/.openclaw-weixin/account.json`2026-07-28 17:31 创建token 有效)→ 插件启动
`resolveAccountData` 直读。不依赖 manager gatewayMethodsweb.login.start 等 OC gateway
协议 stub 不影响真实使用)。
12. **clawhubadapter 全量管理接口(已完成并验证)**:向 agent 暴露完整插件/通道管理面——
- 新工具:`clawhubadapter_plugin_info`(类型/工具/关联通道详情)、`plugin_reload`reloadPlugin
`channel_list`(全部注册通道 + 实时状态)、`channel_send`SendToChannel 投递)、
`channel_start`/`channel_stop`manager `channel/start`、`channel/stop` RPC
- manager 新增 RPC`plugins/channels`registeredChannels 摘要含 status/accounts
`channel/start`fire-and-forget startChannels 恢复账号)、`channel/stop`stopAccount
按 channel 全停或按 accountId 单停,省略 channel 则全部停止)。
- Go 侧 `channelSummary(mgr)` 合并 manager 注册信息与 `channel_status` 实时缓存(缓存优先)。
- 端到端验证生产实例LLM 为本地 mock 源):微信发"你好通道..." → agent 执行
`channel_list` → `- wechat | plugin=openclaw-weixin type=text running=true connected=true
accounts=[default]` → 回复送达;发"注入..." → 执行 `channel_send` → "消息已投递到通道
wechat" → 回复送达。standalone manager 另验证 `channel/start`mock-wechat startAccount
重新执行、心跳恢复)与 `channel/stop`stopAccount called
13. **插件删除回调 onRemove 全套(已完成并验证)**`RegisterOnRemoveHandler`(仅卸载触发、
重载/禁用不触发,与 stop handler 互补——stop 每次停止都执行。registry.RemovePlugin 流程:
stop handlers → Stop → runOnRemoveHandlers → 移除 plugins/sdkRefs/instances →
UnregisterPluginTools → **配置清理**。配置清理含两层:`ConfigRegistry.RemovePlugin`
删除 defs 中 `plugin.<name>.*` 配置项定义 + DROP `config_<name>` 插件配置表
含用户设置值ListPlugins 基于 config_% 表枚举故配置区完全消失);已用临时程序验证
before: defs=1/plugins=[timer] → after: defs=0/plugins=[]。示例盘点SDK 仓库 15 个):
calendarevents.json、memomemos.json、rss订阅数据目录、weather缓存目录已加
filesfilesDir 为用户配置的访问根目录,默认 /、bili/qq用户下载资产
ocr函数内 defer RemoveAll 自清理按语义不加plugindev 模板 main.go.tmpl + README.tmpl
含 onRemove 演示SDK README/README_EN 生命周期文档补"删除清理onRemove"小节。
14. [2026-08-03] SDK 工具链/打包/重装 + dlclose 修复:
- 工具链源码位置澄清SDK 仓完整内容位于 third_party/homeagent-sdk主仓 .gitignore 仅跟踪
sdk/meta/go.mod"两个远程仓库各取所需"/tmp/opencode/sdk-repo 为工作克隆,远程=gitcode
- 工具链支持公共 IOInjector.InjectInputSyncCORE_INJECT_INPUT_SYNC=47C 桥 dispatchIO
callString 回传回复文本);主仓 cabi loader case 47 用内部 4 参版 InjectInputSync 取
OutputEvent.Payload["content"] setResultmeta.go ID 47 + loader.go主仓 3f252ed
- plugindev 构建环境GOMODCACHE=/root/go/pkg/modyaegi 缓存所在、GOPROXY=off。
- 工具链打包 memoplg.json BOM 去除、name_en "Memo/Notes"→"Memo"toSnake 不处理斜杠,
name_en 带 / 会使 hmap 名含子路径报错bundle=true 时走全平台交叉编译(本机无 darwin
工具链),打包用 `build --no-bundle --target linux/amd64`;产物 dist/memo_linux_amd64.hmap。
- 重装pluginmgr HTTP API127.0.0.1:9876DELETE /plugins/memo 卸载(走内核 RemovePlugin
+ onRemove→ POST /plugins binary body 传 hmap返回 installed+checksum生效用
webui `POST /api/v1/plugins/reload`X-API-Key生产 admin123
- 关键 bugLinux dlopen 同路径复用旧句柄——RemovePlugin/ReloadOne 只 Stop 不 dlclose
插件二进制更新后重载仍执行旧代码(生产 memo 装新版仍注册旧 3 工具)。修复:
cabiPlugin.Close()handle.Close+ Registry.closeDynamic 在卸载/重载时调用(主仓 649e312
生产 homed-new7 验证memo 6 工具memo_todo_add/complete/list + memo_memo_create/list/delete
注册正常wechat 通道 running。
- SDK 仓推送 b6e30f9工具链 47 + 重建 bin 二进制 + memo plg.json + sdk/plugin.go 注释精简)。
15. [2026-08-03] 嵌套 git 恢复 + 工作区清理 + codegraph 索引修正:
- 嵌套 git 恢复third_party/homeagent-sdk 原本是"单仓库双提交"(目录内嵌套 .git 推 gitcode
homeagent-sdk 仓,主仓 git 跟踪 sdk/meta/go.mod 推 HomeAgent 仓),嵌套 .git 此前被误删;
已从 /tmp/opencode/sdk-repo 复制 .git 恢复remote=homeagent-sdk.gitHEAD=b6e30f9工作区干净
主仓 git 不受影响。以后 SDK 改动直接在 third_party 内 git commit+pushSDK 仓推送仍用带凭据
URL https://JianFeeeee:BCkb32xBuLxWD9P4MmU8ydZ5@gitcode.com/JianFeeeee/homeagent-sdk.git
不再经 /tmp 中转。
- /tmp 清理:删除 /tmp/opencode/sdk-repo、hasdk-fresh、plugindev-new、plugindev_new、mock-run、
lunartestSDK 中转/临时目录);保留 homed-new*生产二进制备份、mock-skills 等非 SDK 内容。
- replace 修正go.mod 第 17 行已是 `./third_party/homeagent-sdk`正确package-linux.sh
prepare_gomod 优先用 $PROJECT_ROOT/third_party/homeagent-sdk仅缺失时才 clone /tmp/homeagent-sdk
兜底(主仓 108faac
- codegraph 索引修正:根目录 codegraph.jsonPROJECT_CONFIG_FILENAME
includeIgnored+include: ["third_party/homeagent-sdk"]codegraph index 后 Files 179→233
third_party 文件 7→61tools/plugindev 与 sdk/plugin.goInjectInputSync 等)均可查询
(此前嵌套 SDK 仓被主仓 .gitignore 挡在索引外codegraph sync 不感知配置变更,需 index 全量重建。

View File

@ -1,430 +0,0 @@
# 外部插件接口不变矩阵(多进程化整改基线)
> 状态:**完成 v3**2026-09-06——v2 的迁移已上生产(内核 v1.0.0v3 记录 v1.1.1 的公开接口**扩展**。
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
> 所有改造落在**核心homed 侧)+ 工具链hmapdev当时名为 plugindev**,外部插件业务代码零改动,只需用新工具链重编。
>
> **结果(已验证)**`git diff third_party/homeagent-sdk/sdk/` 全程为空17 个 `example/*/plugin.go` 逐字节未改
> `git status example/` 无输出);生产 17 插件全部经子进程通道运行。
>
> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的:
> 它要保的是「换运行模型不动业务代码」。迁移完成后SDK 需要能随功能演进而扩展,
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
> 必须同步更新本矩阵。
>
> 权威编号plan.md 第 11 节11.1~11.9)。本文档只做接口面盘点,不做实现。
---
## 一、迁移的形状(一句话)
```
今天: 外部插件 = example/*/plugin.go纯 Go ──hmapdev c-shared──> plugin.so
homed ──dlopen──> plugin.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──hmapdev go build──> plugin.bin
homed ──spawn──> plugin.binstdio JSON-RPC + shm + eventfd
```
**为什么接口可以不变**(已代码核实):
| 层 | 含 cgo | 迁移后动作 |
|---|---|---|
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go只 import 公开 SDK | **不动**(只重编) |
| bridge 模板 `tools/hmapdev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
| homed `internal/plugin/cabi/`1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2 |
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
---
## 二、合同面 A公开 SDK 类型与接口(迁移前后必完全一致)
文件:`third_party/homeagent-sdk/sdk/{plugin.go,memory.go,knowledge.go,llm.go,settings.go}`
### A1. 插件入口契约Plugin 接口)
```go
type Plugin interface {
Name() string
Start(sdk *PluginSDK) error
Stop() error
}
// 外部插件实现 NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error)
```
### A2. 插件可注册的 5 类组件PluginSDK 方法)
| PluginSDK 方法 | 签名 | 外部插件使用量example 实测) |
|---|---|---|
| `RegisterTool` | `(name string, def ToolDef, handler ToolHandler) error` | **86** |
| `RegisterStage` | `(stage Stage, handler StageHandler, scope ...StageScope)` | 6 |
| `RegisterOutputChannel` | `(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error` | 4 |
| `RegisterInputChannel` | `(name string, def ChannelDef) error` | 2 |
| `RegisterPluginAPI` | `(name string) error` | 0定义存在可用 |
### A3. 插件可调用的能力访问器PluginSDK 方法)
| 访问器 | 返回 | 外部插件使用量 |
|---|---|---|
| `Settings()` | `SettingsAPI` | **17 插件全部使用**Get/Set/List/GetCore/SetCore/ListCore/DataDir/GetPlugin/SetPlugin/ListPlugin/RegisterDef/Defs/Dump/Plugins |
| `Memory()` | `MemoryAPI`Recall/Commit/Introspect/MergeEntities/Purge | 低controllable |
| `DocMemory()` | `DocMemoryAPI`Query/Insert/**InsertWithMedia**/Remove/Stats | 低(`InsertWithMedia` v1.1.0 新增) |
| `TextMemory()` | `TextMemoryAPI`Append | 0 当前 |
| `Knowledge()` | `KnowledgeAPI`Search/Add/List | 2 |
| `LLM()` | `LLMAPI`ListSources/SetSource/CurrentSource | 0 当前 |
| `Social()` | `SocialAPI`**只读**GetPerson/GetTrait/GetRelations/GetNetwork/ListPersons | 0 当前 |
| `Events()` | `EventSubscriber`Subscribe | 0 当前(**C ABI 空实现**,迁移后可获得) |
| `PluginMgr()` | `PluginMgrAPI`ReloadOne/ListLoadedPlugins/IsPluginDisabled | 0 当前 |
| `AutoRestart()` | `bool` | 配套 SetAutoRestart 用 |
### A4. 生命周期 / 工具注入PluginSDK 方法)
| 方法 | 签名 | 备注 |
|---|---|---|
| `SetAutoRestart` / `AutoRestart` | `(bool)` / `() bool` | example 使用 16 次 |
| `InjectText` | `(source, channel, text string)` | → C ABI case 5 |
| `InjectInterruptText` | `(source, channel, text string)` | example 使用 6 次 → case 6 |
| `InjectTextNoMemory` | `(source, channel, text string)` | → case 7 |
| `InjectInputSync` | `(source, channel, text string) string` | → case 47qq 闭环) |
| `SetToolBlocks` | `(blocks []ContentBlock)` | ✅ **v1.1.1 已落地**`io.setToolBlocks`);同版补上 `PluginSDK` 侧一直缺失的便捷包装——接口里有、便捷方法里没有,插件此前只能自己去拿 injector |
| `InjectInputMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增**`io.injectMedia`。与 `SetToolBlocks` 的区别见下方说明 |
| `InjectInputMediaSync` | `(source, channel, text string, blocks []ContentBlock) string` | **v1.1.0 新增**`io.injectMediaSync` |
| `InjectInterruptMedia` | `(source, channel, text string, blocks []ContentBlock)` | **v1.1.0 新增**`io.injectInterruptMedia` |
**为何媒体注入不能搭 `SetToolBlocks` 的车**:后者只在**工具处理函数内部**可用,且媒体要等
**下一条 tool message** 才到模型手上。插件主动发起一轮带媒体的对话、以及中断注入,
需要各自的签名,且媒体在**本轮**就随消息发出,并自动落进 CAS、挂上媒体记忆引用。
| `RegisterStopHandler` / `RunStopHandlers` | `(func())` / `()` | 已有qq 等 1 次) |
| `RegisterOnRemoveHandler` / `RunOnRemoveHandlers` | `(func())` / `()` | example 使用 3 次 |
| `Set*`SetIOInjector/SetMemoryAPI/.../SetPluginMgrAPI | — | 供 bridge/核心启动时接线,插件不直接调 |
### A5. 核心数据类型(迁移前后结构体字段/JSON tag 不变)
| 类型 | 关键字段 | 备注 |
|---|---|---|
| `StageContext` | 16 字段RawMessage/UserID/GroupID/ContextMsgs/LLMText/ReasoningContent/TokenUsage/ToolCalls/ToolResults/FinalText/Response/Phase/Memory/NoMemory/Extra/Errors + Lock/RLock/Unlock/RUnlock/IsResponded | **注意**:外部插件经 C ABI 只能看到 10 个字段(见 C3迁移到共享内存后可看到全部 16 个 |
| `ToolDef` | Name/Plugin/Description/Parameters/NoMemory/Cleaner(func) | `Cleaner` 是函数,**无法过 C ABI**(迁移后经 RPC/进程内保留) |
| `ChannelDef` | NoMemory/Cleaner(func) | 同上 |
| `ToolCall` / `ToolResult` / `MemItem` | ID/Name/Plugin/ArgumentsCallID/Name/Plugin/Success/ResultRole/Content/Score | 全部纯 JSON 可序列化 |
| `ContentBlock` / `ImageURL` / `AudioURL` | Type/Text/ImageURL/AudioURLURL/DetailURL | 全部可偏移化(迁移评估 3.3 已核实) |
| `MediaAttachment`**v1.1.0 新增** | Digest/MIME/Data/Name/Description | 一个类型服务两个方向:给 `Data`+`MIME` 是新内容CAS 按字节去重),只给 `Digest` 是引用已有内容。**读路径不回 `Data`**——一次检索可能命中几十份媒体,全塞回去会撑爆跨进程消息 |
| `Event` / `EventHandler` / `EventSubscriber` | Type/Source/Payload/Timestamp | 迁移后才对外部插件真正可用 |
| `Triple` / `Entity` / `Relation` / `Doc` / `TextEvent` / `PersonProfile` / `SocialRelation` / `Knowledge` / `ConfigDef` | — | 全部 JSON 可序列化 |
| `Triple`**v1.1.0 扩展** | += `SentenceText` / `MediaDigests` | 媒体引用挂在**句子**上(`SentenceText``sentences``sentence_id``media_refs`),所以 `MediaDigests` 非空而 `SentenceText` 为空时内核会用媒体标记本身充当句子 |
| `Doc`**v1.1.0 扩展** | += `MediaDigests` / `Attachments` | `Query` 返回时由内核填充(仅元数据,不带字节) |
| `TextEvent`**v1.1.0 扩展** | += `Attachments` | 写入时内核把标记并进正文;`RecentEvents` 读回时从标记反解 |
**函数类型字段盘点(唯一无法跨进程序列化的东西)**
- `ToolDef.Cleaner func(string) string`
- `ChannelDef.Cleaner func(string) string`
- `StageContext.mu sync.RWMutex`~~锁~~ → 迁移后映射到跨进程锁仲裁)
- 各种 `ToolHandler`/`StageHandler`/`EventHandler`/`func()`(回调 → RPC 反向注册)
→ 这些正是共享内存 + RPC 要保的「留在进程内的回调型资源」(迁移评估 3.5)。
---
## 三、合同面 Bbridge 51 个 method id ↔ SDK 方法映射(改造基线)
> ⏹️ **已完成2026-09-03**:整数 method id 已全部平移为 RPC method 名字符串,
> 定义在 `internal/plugin/proc/protocol.go` 的 `Method*` 常量(共 60 个,含内核→插件方向)。
> 原 `tmplLinuxBridge` 与 `meta.Core<Method>` 整数表**均已删除**。
>
> 两个遗留点:`case 25``CoreFreeString`)无对应 method内存管理是 C 层特有问题);
> `io.setToolBlocks` 已定义但内核侧仍返回未实现C ABI 时代也是空实现,非回归)。
>
> 下表保留作为历史对照。
| # | method id今天 C ABI | SDK 背的方法 | 迁移后 RPC method 名(建议) |
|---|---|---|---|
| 1 | CORE_REGISTER_TOOL | RegisterTool | `tool.register` |
| 2 | CORE_REGISTER_STAGE | RegisterStage | `stage.register` |
| 3 | CORE_REGISTER_OUTPUT_CH | RegisterOutputChannel | `output.register` |
| 4 | CORE_REGISTER_PLUGIN_API | RegisterPluginAPI | `api.register` |
| 5 | CORE_INJECT_TEXT | InjectText | `io.injectText` |
| 6 | CORE_INJECT_INTERRUPT_TEXT | InjectInterruptText | `io.injectInterrupt` |
| 7 | CORE_INJECT_TEXT_NO_MEMORY | InjectTextNoMemory | `io.injectTextNoMem` |
| 47 | CORE_INJECT_INPUT_SYNC | InjectInputSync | `io.injectInputSync` |
| 8 | CORE_SET_AUTO_RESTART | SetAutoRestart | `lifecycle.autoRestart` |
| 9 | CORE_MEMORY_RECALL | Memory().Recall | `memory.recall` |
| 10 | CORE_MEMORY_COMMIT | Memory().Commit | `memory.commit` |
| 11 | CORE_MEMORY_INTROSPECT | Memory().Introspect | `memory.introspect` |
| 12 | CORE_MEMORY_MERGE | Memory().MergeEntities | `memory.merge` |
| 13 | CORE_MEMORY_PURGE | Memory().Purge | `memory.purge` |
| 14 | CORE_DOC_QUERY | DocMemory().Query | `doc.query` |
| 15 | CORE_KNOWLEDGE_SEARCH | Knowledge().Search | `knowledge.search` |
| 16 | CORE_SETTINGS_GET | Settings().Get | `settings.get` |
| 17 | CORE_SETTINGS_SET | Settings().Set | `settings.set` |
| 18 | CORE_SETTINGS_REGISTER_DEF | Settings().RegisterDef | `settings.registerDef` |
| 19 | CORE_LLM_LIST_SOURCES | LLM().ListSources | `llm.listSources` |
| 20 | CORE_LLM_SET_SOURCE | LLM().SetSource | `llm.setSource` |
| 21 | CORE_SOCIAL_GET_PERSON | Social().GetPerson | `social.getPerson` |
| 22 | CORE_SOCIAL_GET_NETWORK | Social().GetNetwork | `social.getNetwork` |
| 23 | CORE_SUBSCRIBE | Events().Subscribe | `events.subscribe`**今天空实现** |
| 24 | CORE_UNSUBSCRIBE | (退订闭包) | `events.unsubscribe`**今天空实现** |
| 25 | CORE_FREE_STRING | (内存释放) | 删除RPC 无此概念) |
| 26 | CORE_SETTINGS_GET_CORE | Settings().GetCore | `settings.getCore` |
| 27 | CORE_SETTINGS_SET_CORE | Settings().SetCore | `settings.setCore` |
| 28 | CORE_SETTINGS_LIST_CORE | Settings().ListCore | `settings.listCore` |
| 29 | CORE_SETTINGS_GET_PLUGIN | Settings().GetPlugin | `settings.getPlugin` |
| 30 | CORE_SETTINGS_SET_PLUGIN | Settings().SetPlugin | `settings.setPlugin` |
| 31 | CORE_SETTINGS_LIST_PLUGIN | Settings().ListPlugin | `settings.listPlugin` |
| 32 | CORE_DOC_INSERT | DocMemory().Insert | `doc.insert` |
| 33 | CORE_DOC_REMOVE | DocMemory().Remove | `doc.remove` |
| 34 | CORE_DOC_STATS | DocMemory().Stats | `doc.stats` |
| 35 | CORE_KNOWLEDGE_ADD | Knowledge().Add | `knowledge.add` |
| 36 | CORE_KNOWLEDGE_LIST | Knowledge().List | `knowledge.list` |
| 37 | CORE_LLM_CURRENT_SOURCE | LLM().CurrentSource | `llm.currentSource` |
| 38 | CORE_SOCIAL_GET_TRAIT | Social().GetTrait | `social.getTrait` |
| 39 | CORE_SOCIAL_GET_RELATIONS | Social().GetRelations | `social.getRelations` |
| 40 | CORE_SOCIAL_LIST_PERSONS | Social().ListPersons | `social.listPersons` |
| 41 | CORE_TEXT_MEMORY_APPEND | TextMemory().Append | `textmemory.append` |
| 42 | CORE_SETTINGS_LIST | Settings().List | `settings.list` |
| 43 | CORE_SETTINGS_DEFS | Settings().Defs | `settings.defs` |
| 44 | CORE_SETTINGS_DUMP | Settings().Dump | `settings.dump` |
| 45 | CORE_SETTINGS_PLUGINS | Settings().Plugins | `settings.plugins` |
| 51 | CORE_SETTINGS_DATA_DIR | Settings().DataDir | `settings.dataDir` |
| 46 | CORE_REGISTER_INPUT_CH | RegisterInputChannel | `input.register` |
| 48 | CORE_PLUGIN_RELOAD_ONE | PluginMgr().ReloadOne | `plugin.reloadOne` |
| 49 | CORE_PLUGIN_LIST_LOADED | PluginMgr().ListLoadedPlugins | `plugin.listLoaded` |
| 50 | CORE_PLUGIN_IS_DISABLED | PluginMgr().IsPluginDisabled | `plugin.isDisabled` |
**bridge 侧反向调用(内核 → 插件RPC 的另一半)**
| 今天 | 迁移后 |
|---|---|
| `go_invoke_tool(name, argsJSON)` | `tool.invoke`homed → pinvoke |
| `go_invoke_stage(stage, ctxJSON, resultOut)` | `stage.invoke`homed → pinvoke共享内存数据面 |
| `go_invoke_output(channel, type, payloadJSON)` | `output.invoke`homed → pinvoke |
| `go_free_string` | 删除 |
---
## 四、合同面 CStageContext 跨 ABI 现状 → 共享内存目标
> ✅ **已达成2026-09-03**:子进程插件现在看到全部 18 个字段(枚举见
> `internal/plugin/proc/shm.go`且可写回。生产实测sanitizer 在另一个进程里
> 改写 13590 字节文本,内核读到改写结果(`stage post_action 改写了 1 个字段`)。
### C1. 迁移前C ABI 副本模型):插件只看到 10 个字段
`stageContextWritable`templates.go:762下发/回传的字段:
```
raw_message user_id group_id phase llm_text final_text no_memory
+ response可选 + tool_calls有才传 + tool_results有才传
```
**看不到的 6 个字段**`ContextMsgs` / `ReasoningContent` / `TokenUsage` / `Memory` / `Extra` / `Errors`
### C2. 迁移后(共享内存 + 锁仲裁):插件可看到/改写全部字段 — ✅ 已实现
字段级 `Slice{Off,Len}` 描述符 + 内核仲裁锁。插件进程内保留原生 `StageContext`
handler 照常读写,`Lock/RLock` 映射到跨进程锁仲裁 RPC`stage.lock`/`stage.unlock`
handler 返回时脏字段写回共享段。
**关键设计决定**:全部子进程插件共享**同一块 memfd**。第一版设计是每插件一段,
那会退化成副本模型,复现 §8.4 的 35.8~36.8% lost update。
**接口形式不变,能力变强**(能力断层消除:外部插件拿回 ContextMsgs 等)。
Windows 同步受益:从「只下发 3 字段、无写回」升到全字段可见 + 写回,
与 Unix 共用同一套 RPC 实现与共享段布局。
### C3. lost update 的合同面定义 — ✅ 已消除
C ABI 时代 `stageContextWritable` **无条件回传 10 个字段的当前快照**——两个插件
sanitizer 改 ToolResults + weather 只读并行时weather 的回传会覆盖 sanitizer
的清洗结果(实测 1.6~4.3%,高并发下 35.8~36.8%)。
Part 0.2 先做了过渡补丁只回传真正变更的字段Part 4 的共享内存模型从根上解决
(字段级描述符 + 锁仲裁,并发改写同一对象)。
回归基线:`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`
---
## 五、外部插件实际触达面example 18 插件实测汇总)
> 这是「17 个存量插件业务代码零改动」的直接依据——它们**只用**下表这些 API全部在公开 SDK 合同面内。
| 插件 | 用到的 SDK 触达 |
|---|---|
| qq最复杂 | SetAutoRestart / RegisterDef×11 / RegisterOutputChannel(qq, 4 caps) / RegisterInputChannel(qq, NoMemory+Cleaner) / RegisterStage(BeforeToolcall, OwnTools) / RegisterTool×N / InjectInterruptText×2 / getSetting(p.sdk.Settings()) |
| weather / rss / bili / ocr / files / memo / music / a2a / acp / ai_image / browser / calendar / editdoc / recoverydiag / sanitizer / vanblog / luademo | RegisterTool / Settings / SetAutoRestart / (部分) RegisterStage / RegisterOutputChannel / InjectInputSync / Knowledge / RegisterStopHandler / RegisterOnRemoveHandler |
**结论**:外部插件触达面 ⊆ 公开 SDK 合同面;无任何插件直接使用方法 id 或 bridge 内部符号。
→ 只要公开 SDK 签名不变 + bridge 语义平移,接口不变约束成立。
---
## 六、迁移后外部插件「新获得」的能力(合同面扩展——只增不减)
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|---|---|---|---|
| 事件订阅 `Events().Subscribe`case 23/24 | ❌ 空实现 | ✅ 事件环EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ `io.setToolBlocks` | ✅ **v1.1.1 已落地**(走 JSON 而非共享段二进制通道,理由见 §九) |
| 媒体入记忆(`InsertWithMedia``Triple.MediaDigests` | ❌ 不存在 | ✅ CAS + 引用计数 GC | ✅ **v1.1.0 类型 / v1.1.1 内核实现** |
| 插件主动发起带媒体的一轮对话(`InjectInputMedia*` | ❌ 不存在 | ✅ 媒体在本轮就到模型手上 | ✅ **v1.1.1** |
| `ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` | ❌ 看不到 | ✅ 共享内存全字段 | ✅ 18 字段全可见可写 |
| 插件崩溃隔离 | ❌ panic 带崩 homed | ✅ 子进程独立崩溃 | ✅ 测试 + 生产验证 |
| 热重载 `.so` | ❌ `DF_1_NODELETE` no-op | ✅ 同路径替换 `.bin` 即生效 | ✅ 生产实测 |
| 工具超时取消 | ❌ cgo 不可中断(泄漏线程) | ✅ `Process.Kill()` 真取消 | ✅ 整套新架构零 cgo |
| `output_send` 结果 | ❌ 永远假成功 | ✅ 可同步等真实结果 | ✅ 生产实测 `map[status:sent]` |
| Lua/Windows DLL 路径 | ❌ 三套 ABI 分裂 | ✅ 收敛为单一 RPC 实现 | ⚠️ Windows 已收敛Lua 仍独立(留待后续) |
**三项未完全兼得的说明**
- `SetToolBlocks``io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`,但内核侧 handler
仍返回未实现。C ABI 时代它也是空实现§1.4),故**不是回归**,但也没兑现承诺。
- Lua`lua_plugin.go`/`dynamic_lua.go` 仍走自己的路径。Lua 经解释器不经 C ABI
不属于本轮要消除的 6 类缺陷,因此不阻塞。收敛第三套 ABI 是独立优化。
- 事件订阅:机制已完成(内核侧 `EvtRing` + 模板侧 `evtConsumerLoop`
但**无任何现有插件使用 `Events().Subscribe`**,所以生产上未经真实负载检验。
**刻意不给**(权限梯度显式化,非技术限制):`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/
`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`
(内核内部机制)。清单与理由记在 `internal/plugin/proc/capability.go`
`withheldCapabilities``TestCapability_WithheldListIsDocumented` 守护。
这一项从「C ABI 表达能力的意外产物」变成**显式策略**:以前拿不到是因为
C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 case 就能捅穿);
现在是三道闸:类型层(`procCore` 命名字段不嵌入)+ 能力集manifest 声明)
+ RPC 边界(返回明确错误而非静默忽略)。
---
## 七、接口冻结检查点(全部已通过)
1.**阶段 2子进程通道原型**`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
验收weather 业务代码逐字节未改(`git status example/` 无输出)。
2.**阶段 3共享内存**:子进程并发改写 StageContext 丢失率 = 0%
`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`)。
3.**阶段 5**17 个外部插件全部 `.bin` 化、cabi 删除(-3198 行);
`go build ./...` 与全仓 `go test ./...` 均通过。
4.**全程**`git diff third_party/homeagent-sdk/sdk/` 为零——接口冻结的硬证据。
5. ⚠️ **v1.1.x 起该检查项不再适用**:冻结是迁移期的约束,迁移完成即到期(见 §九)。
取代它的门禁是「存量插件零改动零重编」——见 §九的验证方式。
生产端到端2026-09-03真实 QQ 消息):
```
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
output_send__qq output_send__qq qq_mark_read])
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
[proc] sanitizer stage post_action 改写了 1 个字段
tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
```
---
## 九、v1.1.x 的接口扩展规则(冻结解除后的替代约束)
冻结约束是为**迁移期**设的:它要保的是「换运行模型不动业务代码」。迁移完成后继续冻结,
等于让 SDK 永远停在迁移那天的能力面——多模态这类功能永远到不了插件手上。
取代它的是三条更弱但仍然硬的约束:
### 1. 只增不减,签名不改
新增字段、新增方法可以;**改已有方法的签名、删字段、改字段语义不行**。
实例v1.1.0 想让插件能给三元组关联媒体,两条路——改 `Commit` 的签名加一个参数,
或新增 `CommitWithMedia`。选了后者。改签名会让每个调 `Commit` 的插件编译失败,
而那些插件根本不关心媒体。
### 2. 新增方法必须是「插件调用、内核实现」方向
这是**存量插件不需要重编**的技术原因:`IOInjector` 新增三个方法后,插件只是
*多了可以调的东西*,没有新的实现义务。反过来若在 `Plugin` 接口上加方法,
每个存量插件都会因未实现而编译失败。
因此 `SDKCompatibleVersion` 与 SDK 的 `CoreVersion` 都不必随之跃迁:
1.1.0 的 SDK 配 1.0.0 编的插件仍然成立。
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
E2E 用例编译失败),不是靠人工检查发现的。
完整接线链共六处:`protocol.go` 的 method 常量 → `capability.go` 的能力归属 →
`corehandler.go` 的分派分支 → `proc_core.go` 的委托 → `proc_main.go.tmpl` 的模板实现 →
测试替身(`fakeCoreSDK``injectCapture``capability_test.go` 的手工方法清单)。
还要同步 `yaegi/mocksdk`——它没有任何代码对着编译,所以漂移不会被编译器抓到
v1.1.1 修的时候发现它的 `Triple` 用的是 `Predicate`,而公开 SDK 一直叫 `Relation`)。
### 验证方式取代「diff 为零」)
| 检查 | 命令 | v1.1.1 结果 |
|---|---|---|
| 存量插件源码零改动 | `cd example/<n> && go vet ./...`17 个) | ✅ 17/17 通过 |
| 旧产物仍能建链 | 用 SDK 0.9.2 编的 `plugin.bin``TestRealPlugin_*` | ✅ 4/4 通过(握手校验 `ProtocolVersion=1`,不是 SDK 版本) |
| 模板已接线 | `cd tools/hmapdev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE13 例压测) |
### v1.2.x 的接口扩展2026-09-12
1.2.0 把「记不记入记忆 / 要不要据此裁剪上下文」从**只有工具与通道能声明**,扩到**注入侧也能声明**
| 新增 | 方向 | 说明 |
|---|---|---|
| `InjectOptions{NoMemory, ContextPolicy, CleanerName}` | 新增类型 | 单次注入的行为声明 |
| `ContextPolicyNone` / `ContextPolicyPrune` + `ValidContextPolicy` | 新增常量/函数 | 取值只有 `""` / `none` / `prune``prune` 必须显式声明 |
| 六个 `*Opts` 变体Text / InterruptText / InputSync / InputMedia / InputMediaSync / InterruptMedia | 插件调用、内核实现 | 旧的三参数方法保留为**零值糖**,与 `InjectOptions{}` 逐键等价 |
| `ChannelDef.ContextPolicy` + `ChannelDef` 的 JSON tag | 结构体字段 | 通道也可声明裁剪;补 tag 是因为通道定义要跨进程传给内核,而 `Cleaner` 是函数必须忽略——无 tag 时新增字段会被**静默丢掉** |
签名层面零变更(六个方法全是新增),满足第 1、2 条。
**但「接口纯追加」不等于「无需重编」**1.2.0 同时把插件运行协议升到 2
fd3 布局改变,不支持滚动升级),`ProtocolVersion` 不匹配会在握手时被明确拒绝
并提示用配套 plugindev 重编。两件事必须分开说,否则会被误读成「既然纯追加就还能用旧产物」。
#### 这次扩展自己抓出来的两处漂移(都是本节第 3 条要防的那类)
1. **模板接线守卫红了**`TestProcTemplate_CoversAllCoreMethods` 要求模板出现内核提供的
每一个 method id而注入标志位落地后模板不再发 `io.injectTextNoMem`(旧模板发它,
现在走 `io.injectText` + `NoMemory` 标志位)。内核保留该 id 是**刻意的向后兼容面**
(用那时模板编出的二进制仍在外面),不是漏接线——所以改的是判据:把它移入显式的
`deprecated` 表,并加**反向保护**(条目一旦重新出现在模板里就报错,避免这张表
退化成「永久豁免」的垃圾抽屉)。
2. **mocksdk 缺一个方法**:拿公共 SDK `IOInjector` 的 14 个方法名与 mock 的方法集
**机械求差**,差集恰好是旧的三参数 `InjectInputSync`——通道类插件qq / a2a完成
「入站 → agent 处理 → 回复取回」闭环要调的那个。`git log -S` 证实它**从来就缺**
不是本次引入;补齐后差集为空。(上次漂的是 `Triple.Predicate` vs `Relation`,同一类问题。)
#### 验证1.2.0,本机实测)
| 检查 | 命令 | 结果 |
|---|---|---|
| 存量插件源码零改动 | 逐个 `cd example/<n> && go vet ./...` | ✅ 17/17 通过(`luademo` 是 Lua、无 `go.mod`,跳过) |
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ 全绿(修复前为红;反向保护另用「把 id 塞回模板」验证过会报错) |
| 并发安全 | `go test -race -count=5 ./sdk/` | ✅ ok |
| mocksdk 未漂移 | 方法集求差14 个方法) | ✅ 差集为空 |
### 为何媒体块走 JSON 而不是共享段二进制通道
`SetToolBlocks` 的原设计是「二进制落 arenaSlice 描述符回传」。实际落地时改走 JSON
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要让这四个 method 跟其余
51 个分道扬镳。共享段的价值在于**并发改写同一份状态**StageContext 的 lost update
而媒体块是单向传递的不可变数据,没有这个问题。
---
## 八、关联文档
- `docs/zh/架构迁移评估.md` — 完整论证§3.2 method id 平移、§3.3 数据面、§3.4 SDK 封装、§3.5 回调型资源、§3.8 能力对齐)
- `docs/zh/plugin-migration-plan.md` — Part 0~6 执行计划与完成实录(含 Part 6.5 生产切换、Part 6.6 压测)
- `plan.md` §11 — 11.1~11.9 修复清单(唯一权威编号)
- `third_party/homeagent-sdk/sdk/` — 合同面 A 的代码实现(全程零 diff
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
- `internal/plugin/proc/capability.go` — 权限梯度capability 组 + `withheldCapabilities`
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具

View File

@ -1,632 +0,0 @@
# 外部插件多进程化适配计划(修改→审查→验证三步微循环)
> 分支:`update`
> 基线:`docs/zh/plugin-interface-matrix.md`(合同面 A/B/C+ `plan.md` §11 + `docs/zh/架构迁移评估.md`
> 每部分 = 一个「修改 → 审查 → 验证」三步微循环。所有验证在 **update 分支**完成,可独立交付、可回退。
>
> **循环的铁律**(每部分适用):
> - **修改**:只动核心侧 + 工具链,`third_party/homeagent-sdk/sdk/`(合同面 A**零 diff**。
> - **审查**:接口冻结检查(`git diff` 公开 SDK 为空)+ 代码 review + `go vet`。
> - **验证**`make test` + 针对性单测 + 端到端冒烟,产物 `.bin` 端到端可用。
>
> 标 `【M】`=修改部分、`【R】`=审查部分、`【V】`=验证部分。依赖前置部分完成后才可开始。
---
## 目录
- **Part 0** 脆弱基线先行(不依赖迁移,现网可直接受益)— 0.1 ✅ / 0.2 ✅ / 0.3 ⏭️ / 0.4 ⏭️
- **Part 1** 加载分派骨架(`entry` 双通道共存)— ✅ **已完成**
- **Part 2** 子进程通道原型spawn / JSON-RPC / procPlugin— ✅ **已完成**
- **Part 3** plugindev 工具链改造(`.bin` 产物)— ✅ **已完成**
- **Part 4** 共享内存数据面StageContext 跨进程并发改写)— ✅ **已完成**(段/编解码/锁仲裁 + RunStage 接线)
- **Part 5** 通知面(事件环 + eventfd— ✅ **核心已完成**
- **Part 6** 迁移与收尾17 插件逐个 + 删 cabi + 权限显式化)
- 最终验收清单
> **进度快照2026-09-02**:分支 `feature/plugin-proc-migration`。
> 已交付:现网止血 2 项11.1/11.3、entry 双通道分派、共享内存 stage 并发、
> 子进程控制面NDJSON RPC + 51 method 名平移、plugindev `.bin` 构建、
> registry 接线、**事件环§3.6**。**外部插件已可端到端跑在子进程 + 共享内存上**
> 且首次获得事件订阅能力C ABI 下 case 23/24 一直是空实现)。
> 测试:内核 `internal/plugin/proc` 38 项 + `internal/plugin` 16 项(含 `-race`
> SDK 仓 plugindev 16 项。
> 下一步Part 6 逐插件迁移 + 删 `internal/plugin/cabi/`。
---
## Part 0脆弱基线先行阶段 0~1 人日)
> 依据plan.md §11.1/11.3/11.6。不依赖任何新架构,独立交付,现网直接受益。
> 目的:在副本模型内部打补丁,止血,为后续迁移争取时间。
### 0.1 output_send 假成功修复11.1)— ✅ **已完成**2026-08-31
- 【M】✅ `internal/plugin/cabi/loader.go`——`CORE_REGISTER_OUTPUT_CH`:454的异步 output 从「goroutine 直接返回 queued」改为「goroutine + 带超时 channel 等真实结果」。
新增 `awaitOutputResult`:276+ 可注入版 `awaitOutputResultWith`:281+ 常量 `outputSendTimeout = 10s`
```go
resCh := make(chan error, 1)
go func() { resCh <- invoke(pid, channel, argsJSON) }()
select {
case err := <-resCh:
if err != nil { return nil, err } // 真实失败上报
return map[string]interface{}{"status": "sent"}, nil
case <-time.After(timeout):
return map[string]interface{}{"status": "unconfirmed", "note": "..."}, nil
}
```
关键:`dev.Execute` 由 `executeOutputSendTool` 从 Go 侧调起(不在 cgo 栈内goroutine 内的 `pluginInvokeOutput` 才是 cgo**不构成嵌套**。
- 【M】✅ `internal/agent/core/output.go` `executeOutputSendTool`:识别 `status=unconfirmed|queued` → 返回「发送结果未确认:<note>」而非「已发送」,把未确认状态透传给模型。
- 【R】✅ 无 cgo 嵌套(`awaitOutputResult` 只在 `RegisterOutputChannel` 的 handler 内被调用,该 handler 从 Go 侧调起);
「超时未确认」措辞与 11.2 的"已取消"谎言区分——用 `unconfirmed` + 显式 note不谎报成功也不谎报失败。
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空。
- 【V】✅ 新增 `internal/plugin/cabi/output_test.go` 三用例全绿:
- `TestAwaitOutputResult_Success` → `status=sent`
- `TestAwaitOutputResult_Failure`(模拟 meta 缺 user_id→ **返回 error**(旧实现会谎报成功)
- `TestAwaitOutputResult_Timeout` → `status=unconfirmed` 且不返回 error
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
### 0.2 stage lost update 补丁11.3)— ✅ **已完成**2026-08-31
- 【M】✅ `templates.go`**SDK 仓** update 分支 `5648519``go_invoke_stage` 改为 diff 回传:
- 新增 `snapshotWritable(sc) map[string]string`——handler 前的**序列化**快照
- 新增 `changedFieldsOnly(before, after)`——只回传变更字段,无变更零回传
- ❗ **第一版踩坑并修正**`stageContextWritable` 返回的切片字段与 `sc` **共享底层数组**handler 原地改元素(`sc.ToolResults[0].Result = clean`)时 before 快照跟着变diff 看不到变更 → 修复会静默失效。故 before 必须逐字段序列化成字符串。
- 【M】✅ `internal/plugin/cabi/loader.go` `applyStageResult` 配套(本仓 `9bb9cb3``tool_calls`/`tool_results` 去掉 `len(v)>0` 拦截——改为键存在即应用,使插件「清空全部工具调用」的显式 `[]` 能被表达(旧插件仅 len>0 才带键,不会被误清空)。
- 【R】✅ `changedFieldsOnly` 无竞态(纯函数,无共享状态);只读插件零回传(单测断言)。
- 【R】✅ 接口冻结:两仓 `git diff sdk/` 均为空(只改 bridge 模版 + 内核)。
- 【R】✅ bridge 模版可编译性:抽取 `tmplLinuxBridge` + 真实 `weather/plugin.go` 做 `go build -buildmode=c-shared` → exit 0。
- 【V】✅ SDK 仓 `tools/plugindev/stagediff_test.go` 6 用例全绿:
- `_ReadOnlyPluginReturnsNothing`(只读插件零回传——修复核心)
- `_WriterReturnsOnlyChanged`(原地改切片元素仅回传 tool_results
- `_ScalarChange` / `_NewResponseIsReturned` / `_ClearedSliceIsReturnedAsEmpty`
- `_ProductionScenarioNoOverwrite`**复刻实验 13 现网场景**sanitizer 清洗 + weather 只读,清洗结果不再被覆盖)
- 【V】✅ 内核侧 `output_test.go` 新增 `TestApplyStageResult_ClearedSlicesAreApplied` / `_OnlyPresentKeysApplied` 全绿。
- 【V】✅ `go build ./...` exit 0`go test ./internal/plugin/... ./internal/agent/...` 全绿。
- ⚠️ **待部署项**:需用新 plugindev 重编全部 17 个外部插件bridge 模版变更),走 `plugin_install(overwrite=true)`。
### 0.3 reload 语义修正11.6)— ⏭️ **已跳过**2026-08-31 用户决策:直接进入进程化重构)
> 子进程模型下 `DF_1_NODELETE` 议题**整体消失**§3.1)——同路径替换 `plugin.bin` 重启进程即生效。
> 在 cabi 路径上补 ELF 检测属于「给即将删除的代码打补丁」,性价比低。
> 现网仍受 reload 假成功影响,但 Part 1 的 entry 分派已为迁移铺路,迁移完成即根治。
- 【M】`dynamic_loader_unix.go`ELF 检测 `DF_1_NODELETE` → 标记"不可热重载"。
- 【M】`registry.go` 的 `ReloadOne`:对此类插件返回"需重启 homed"。
- 【M】`pluginmgr/plugin.go` 的 `plugin_install`:返回 `restart_required` 替代 `reload_required`。
- 【R】确认 `.so` 插件重载不再"假成功"。
- 【V】单测mock ELF 头带 NODELETE vs 不带 → 正确区分。
### 0.4 超时日志措辞修正 + 附带11.2 短期项 + 11.4)— ⏭️ **已跳过**(同上)
> 11.2 的 cgo 超时不可中断在子进程模型下由 `Process.Kill()` 真正解决§9.5
> 11.4 的 Lua 路径在迁移后统一走 RPC三套 ABI 收敛),锁语义天然有边界。
- 【M】`internal/agent/core/toolcall.go:41`:日志从"已取消"改为"已放弃等待(插件仍在后台运行,其占用的线程无法回收)"。
- 【M】`internal/plugin/lua_plugin.go:726`stage 快照加 `sc.RLock()`/`RUnlock()`11.4)。
- 【R】措辞语义诚实Lua 快照持锁。
- 【V】`make test` 全绿;超时日志不再撒谎。
**Part 0 出口条件**11.1/11.3/11.6 全部落地并有针对性测试;生产可先部署(现网止血)。
---
## Part 1加载分派骨架阶段 2.4S
> 依据:迁移评估 §2.4 / 3.2plan.md 11.7。目标:让 registry 能按 entry 把插件分派到 `.so`cabi或 `.bin`proc两条通道——**双通道共存是整个计划可回退的前提**。
### 修改(核心)
- 【M】`internal/plugin/manifest.go``PluginManifest.Entry` 注释与 `IsPluginDir` 支持 `plugin.bin`。
- 【M】`internal/plugin/dynamic.go`:新增 `binEntry = "plugin.bin"` 常量;`readManifest` 读取 entry。
- 【M】`internal/plugin/registry.go` `loadOne`~:376把「无工厂 → `tryDynamic`」的分支改为按 entry 分派:
```go
switch entry {
case soEntry, dllEntry: p, err = r.tryLoadSO(...) // 现有 cabi
case binEntry: p, err = r.tryLoadProc(...) // 新增Part 2 填充)
default: p, err = r.tryOther(...) // lua / skill
}
```
先保留一个 `tryLoadProc` 桩(返回"未实现"错误),保证分派骨架先成立、可测。
- 【M】`internal/plugin/dynamic_loader_unix.go`:把 `tryLoadSO` 从 `tryDynamic` 拆出成 registry 可独立调用的函数。
### 审查
- 【R】确认内置插件`hasFactory` 分支)完全不受影响——仍走 `RegisterNative` 进程内路径。
- 【R】确认 `.so` 路径行为与今天逐字节一致(无回归)。
- 【R】接口冻结`git diff` 公开 SDK 为空。
### 验证
- 【V】单元测试mock 三种 manifestso/dll/bin/lua→ 分派到正确通道;`.bin` 桩返回明确错误而非 panic。
- 【V】既有 `.so` 插件加载 e2e 不回归(带一个真实 .so 冒烟)。
**Part 1 出口条件**:分派骨架在,`.bin` 有明确桩位,`.so` 全回归。
#### ✅ **Part 1 已完成**2026-08-31commit `610e9d0`
- 【M】✅ `dynamic.go`:新增 `binEntry`/`skillEntry` 常量 + `entryKind` 枚举 + `classifyEntry` / `detectEntryKind`
- **manifest 的 entry 优先级最高**——把 entry 改回 `plugin.so` 即回退 cabi 通道(回退路径的保证)
- 无 manifest 时按目录探测,`.bin` 优先于 `.so`(迁移期同目录两产物共存时走新通道)
- 【M】✅ `registry.go` `tryDynamic`:按 entry 分派 proc/cabientry 声明 `.bin` 但二进制缺失时**报明确错误,不静默回退**
- 【M】✅ `registry.go` `pluginEntryHash`:候选顺序与 `detectEntryKind` 对齐(`.bin` 优先),否则增量重载会用错文件算 hash
- 【M】✅ `manifest.go``Entry` 字段注释补 `plugin.bin`
- 【M】✅ `dynamic_proc_unix.go` / `dynamic_proc_windows.go``tryLoadProc` 桩位(存在性/类型/可执行权限校验已实现)
- 【R】✅ 内置插件(`hasFactory` 分支)完全未受影响——仍走进程内 `RegisterNative`
- 【R】✅ `.so` 路径行为与改动前一致(既有测试全绿,无回归)
- 【R】✅ 接口冻结:`git diff third_party/homeagent-sdk/sdk/` 为空
- 【V】✅ `entry_dispatch_test.go` 9 项全绿:
- `TestClassifyEntry`8 种 entry 分类)
- `TestDetectEntryKind_ManifestWins` / `_ManifestCanForceRollback`**回退路径验证**
- `TestDetectEntryKind_ProbeOrderPrefersBin` / `_ProbeFallbacks`4 子例)
- `TestTryLoadProc_MissingBinaryReturnsNil` / `_NonExecutableRejected`
- `TestPluginEntryHash_PrefersBin` / `_EmptyForFactoryOnlyPlugin`
- 【V】✅ `go build ./...` exit 0`go test -race ./internal/plugin/...` 全绿;全量 32 个包测试通过
---
## Part 2子进程通道原型阶段 2.1~2.3/2.5/2.9~3 周,核心风险点)
> 依据:迁移评估 §4.1 阶段 2迁移评估指明可大幅参考 `clawhubadapter/sidecar.go:54-350`(已有 stdin/stdout + pending map + notifyCh
> 目标把单个外部插件weather以 `plugin.bin` 端到端跑通,验证"接口不变"假设。
### 修改(核心)
- 【M】新建 `internal/plugin/proc/`
- `process.go`——`procPlugin` 实现 `sdk.Plugin` 接口;`spawn`/健康检查/优雅停止/`Close()`=真 kill+wait。
- **可参考** `clawhubadapter/sidecarProcess``exec.Cmd` + `stdin *bufio.Writer` + `readLoop`scanner 大 buffer 64KB+ `pending map[int]chan<- []byte` + `notifyCh chan OCNotification` + readerStop/readerWg。
- `rpc.go`——双向 JSON-RPC 编解码7 个 kernel→plugin 调用(`tool.invoke`/`stage.invoke`/`output.invoke`+ 51 个 plugin→kernel 回调(平移自合同面 B 映射表)。
- 【M】`internal/plugin/dynamic_loader_unix.go`:实现 `tryLoadProc`spawn `.bin`,回连 stdio RPC
- 【M】`internal/plugin/registry.go` `closePlugin`/卸载路径:对 proc 插件 `Close()` 真 kill。
- 【M】`internal/agent/core/plugin_health.go` 调用侧:插件**退出码/EOF** → `recordCrash`**逻辑完全复用**,仅把"panic 捕获"换成"进程退出检测",见迁移评估 §2.3)。
### 审查
- 【R】`readLoop` 鉴权:只接受来自本进程 spawn 的 stdout防注入
- 【R】JSON-RPC 帧边界处理(`bufio.Scanner` 长行截断风险——沿用 sidecar 64KB buffer
- 【R】pending map 泄漏:超时清 map、退出时清 map。
- 【R】崩溃重启`SetAutoRestart(true)` 语义保留;`plugin_health` 冷却/自愈复用。
- 【R】接口冻结公开 SDK 零 diff。
### 验证
- 【V】单测spawn→握手→工具调用往返→正常 Stop→kill 崩溃→退出码捕获。
- 【V】weather `.bin` 端到端:`RegisterTool`/`Settings`/`InjectInputSync` 全部经 stdio RPC 打通。
- 【V】与 Part 1 的 entry 分派联动:同目录 `.so` 与 `.bin` 共存互不干扰。
**Part 2 出口条件**:一个真实外部插件 `.bin` 全链路可用,崩溃隔离生效,接口零改动。
#### ✅ **Part 2 已完成**2026-09-01commit `d62430a` + `82dcc86`
- `proc/protocol.go`NDJSON 帧、**51 个 method id 平移为 method 名**(编号扔掉)、握手/stage/tool/output 参数类型。
`case 25`(CORE_FREE_STRING) 无对应 methodGC 接管);`case 23/24`(事件订阅) 与 `io.setToolBlocks`
明确返回未实现,**不静默成功**。
- `proc/process.go`Spawn/readLoop/CallContext/Notify/Stop/Kill/markExited单帧上限 1MB。
- `proc/corehandler.go`51 case 平移 + `CoreSDK` 接口(**刻意排除**内核内部机制,见 Part 6 权限梯度)。
- `proc/host.go`**全部插件共享同一 memfd**。最初写成每插件一块段,尝试后发现
那等于**副本模型换壳**(各写各段、各自回读、最后回读者覆盖前者),已改正。
- `proc/stage.go`RunStage 接线 + lockRegistry`proc/plugin.go`Plugin 实体。
- 共享段分配按平台拆分(`shmalloc_linux.go` memfd / `shmalloc_darwin.go` 立即 unlink 的临时文件 /
`shmalloc_other.go` 明确报错)——不静默降级成「无共享段」,那会让 stage 静默失去数据面。
- registry 接线commit `11c1bbc``tryDynamic` → `Registry.loadProc`Host 惰创建且全局唯一;
`StopAll` **锁外**释放共享段(插件还持有映射时拆段 → SIGBUS持锁调与 onProcCrash 有锁序风险);
`onProcCrash` 只发 EventSystem 事件,**不在回调里直接重载**(重载需 registry 锁)。
- `proc_core.go` —— 权限梯度的类型系统落点:`procCore` 用**命名字段**持有 `*isdk.PluginSDK`
不是嵌入。嵌入会提升全部方法,外部插件就能经类型断言拿到
Supervisor/Tracker/Adapter/Indexer/Status/Selftest。
- 测试 36 项含 `-race``testdata/` 8 个假插件 + `e2e_template_test.go` 用**真实 plugindev 模板**
编译插件跑全链路(验证「模板 ↔ 内核」协议/布局真的对齐,不只是内核自己跟自己对齐)。
---
## Part 3plugindev 工具链改造(阶段 2.6/2.7/2.8MSDK 仓)
> 依据:合同面 B迁移评估 §4.1。此部分在**独立 SDK 仓**维护(用户决策 sdk_repo_only
> 目标:让外部插件能用普通 `go build` 产出 `.bin`,业务代码零改动。
### 修改(工具链)
- 【M】`tools/plugindev/templates.go`:新增 `tmplProcMain`——把 bridge 从「7 个 `//export` + `-buildmode=c-shared`」改为「`main()` + stdio JSON-RPC loop」注册逻辑`buildPluginSDK` 的 registar 闭包)从 `callVoid(id,...)` 改为 `sendRPC(methodName,...)`(合同面 B 的平移)。
- 【M】`tools/plugindev/cmd_build.go`
- 新增目标 `plugin.bin``go build`(去 `-buildmode=c-shared`、`CGO_ENABLED=0`)→ `plugin.bin`。
- bundle 平台表:`{"linux/amd64","plugin.bin"}`(替代 `.so`)。
- `resolveBuild`bin 分支不再需 C 编译器。
- 【M】`tools/plugindev/cmd_build.go` `validBinaries`/打包:`.hmap` 内条目支持 `plugin.bin``plugin.json` entry 写 `plugin.bin`)。
- 【M】`plg.json` 模板(`tmplPlgJSON``entry` 默认改为 `plugin.bin`(保留 `.so` 兼容)。
### 审查
- 【R】生成的 `tmplProcMain` 与旧 bridge 的 SDK 方法一一对应(对照合同面 B 51 行映射表逐行核对)。
- 【R】业务代码**零改动**证据:同一 `plugin.go`,仅入口文件/构建命令不同。
- 【R】交叉编译简化确认`.bin` 无需 cgo 工具链,跨 GOOS 仅需目标 toolchain。
### 验证
- 【V】用新 plugindev 重编 `example/weather` → 产出 `plugin.bin`。
- 【V】`.hmap` 打包/解包校验:`plugin.bin` 条目正确登记。
- 【V】与 Part 2 集成weather.bin 被 homed proc 通道正确加载运行。
**Part 3 出口条件**plugindev 一条命令产出 `.bin` + 正确 `.hmap`,外部插件源码零改动。
#### ✅ **Part 3 已完成**2026-09-02SDK 仓 commit `09b64dc`
**模板落地方式换了**:不是计划里的 `templates.go` 新增 `tmplProcMain` raw string
而是真实 `.go` 源文件 `templates/proc_main.go.tmpl` + `//go:embed``proc_runtime.go`)。
原因900+ 行代码塞在字符串里写错只能等生成插件时才炸,作为源文件可被
`go/parser`、`gofmt`、`go vet` 直接检查。这也是 `proc_runtime_test.go` 16 项
静态检查得以存在的前提。
- `templates/proc_main.go.tmpl`1113 行51 个 method 的插件侧 RPC 实现
`procIO`/`procMemory`/`procSettings`/`procSocial`/`procLLM`/`procKnowledge`/
`procDocMemory`/`procTextMemory`/`procPluginMgr`、共享段访问fd 3与 16 字段
StageContext 编解码、`handleStageInvoke`(拿锁 → 读段 → handler → **只写脏字段** → 放锁)。
- `cmd_build.go``resolveBuild(target, proc)` 分派proc 走 `go build -trimpath` + `CGO_ENABLED=0`
**交叉编译不再需要目标平台 C 工具链**。bundle 模式各平台产物同名(进程边界即 ABI 边界,
无平台扩展名),故 zip 内加平台后缀 `plugin.bin.linux.amd64`。
- `proc_runtime.go`:生成时清理残留 `z_bridge_gen.go`/`z_entry.c`——同目录两套 main 会编译冲突,
这让 `.so` → `.bin` 切换无需人工清理。
**计划外补的一个真缺口**`lifecycle.autoRestart` 没接线。公开 SDK 的 `SetAutoRestart`
是纯 setter`s.autoRestart = enabled`,无回调 hook。C ABI 下内核在 `Start` 返回后
直接读 `plgSDK.AutoRestart()`;子进程隔着进程边界读不到,插件调它只改自己进程内的副本。
修法:模板在 `plg.Start()` 返回后显式上报一次(内核侧 `corehandler.go:145` 早已就绪)。
**没有改公开 SDK 接口**。
验证(均已实测):
```
$ plugindev build # plg.json: entry = "plugin.bin"
compiling linux/amd64 (子进程模式CGO_ENABLED=0)...
packaged weather_linux_amd64.hmap
build/plugin.bin → ELF 64-bit executable, statically linked ← 零 cgo
dist/*.hmap → plugin.json + plugin.bin
$ diff example/weather/plugin.go <构建目录>/plugin.go
✅ 逐字节一致 ← 业务代码零改动的硬证据
$ git diff third_party/homeagent-sdk/sdk/
(空) ← 接口冻结保持
```
---
## Part 4共享内存数据面阶段 3.1~3.5~3 周,最高风险)
> 依据:迁移评估 §3.3 数据面 / 3.4 SDK 封装 / 3.7 锁仲裁;合同面 C。
> 目标:多插件并发改写同一 `StageContext` 语义与今天一致(丢失率 → 0外部插件看到全部 16 字段。
### 修改
- 【M】`internal/plugin/proc/` 新增 `shm.go`
- 共享段 schema`ShmStageCtx` + `Slice{off,len}` 偏移描述符 + arenaappend-only + 压实)。
- arena 分配器:插件把 `FinalText` 从 10B 改 10KB 时分配新区域、旧区域留垃圾、stage 结束后压实。
- 4 个 `Extra` 键media_blocks/media_type/input_source/output_channel提升为具名字段迁移评估 §3.3 已核实全部使用点)。
- 段生命周期:创建/挂载/插件崩溃后清理。
- 【M】`internal/plugin/proc/shmcodec.go``StageContext` ↔ 共享段编解码偏移↔Go 值转换)。
- 【M】`internal/plugin/proc/lock.go`**锁仲裁 RPC**——插件 `Lock/RLock` → `stage.lock`/`stage.unlock` → 内核 `sync.Mutex` 排队(迁移评估 §3.7 已裁定,实验 3+9 支撑)。
- 【M】`internal/agent/core/stages.go` `RunStage`:改造为跨进程并发扇出(**保留并发语义,最难一环**)——内置插件仍进程内 `go func`,外部插件走共享段 + 锁仲裁。
- 【M】SDK 侧(插件进程内)封装全部复杂度(迁移评估 §3.4):插件保留原生 `StageContext`handler 照常读写,脏字段写回共享段。
### 审查(最高优先级 review
- 【R】**并发语义一致性**内置0% 丢失)与外置(迁移前 35.8~36.8%)在共享内存下都收敛到 0% 丢失。
- 【R】锁仲裁死锁持锁进程崩溃自愈实验 9 已证无需 robust mutex
- 【R】arena 单 stage 写入上限:大写入在 SDK 层**报错**而非静默截断(迁移评估 §4.4)。
- 【R】`Extra` 不引入通用 tagged union 成本(维持 4 键具名字段)。
- 【R】接口冻结`sdk/` 零 diff`StageContext` 结构体字段序不变。
### 验证
- 【V】复刻实验 85 子进程 × 300 轮并发改写 → **零丢失零撕裂**。
- 【V】复刻实验 13 现网场景sanitizer改 ToolResults+ weather只读并发 → 清洗结果不再被覆盖。
- 【V】改写型插件行为基线测试`sanitizer`/`multimodal` 迁移前后行为对拍(迁移评估 §4.4 风险缓解)。
**Part 4 出口条件**:跨进程并发改写零丢失,内置/外置语义一致16 字段全可见。
#### ✅ **Part 4 核心已完成**2026-08-31commit `610e9d0`)—— 段 / 编解码 / 锁仲裁三件套
> 用户明确指出「基于共享内存的 stage 并发是最为关键的」,故先于 Part 2/3 落地数据面。
> `RunStage` 的跨进程接线3.4)待 Part 2 的进程通道就绪后进行。
- 【M】✅ `proc/shm.go` 段布局与 arena 分配器§3.3
- `Header(64B) + ShmStageCtx(描述符数组 + 标志位) + append-only arena`
- **相对偏移**:各进程 mmap 到不同虚拟地址仍能正确解引用
- `NewSegment` / `AttachSegment` 带魔数 + 版本校验(版本不匹配显式报错,不静默错读)
- **arena 用尽显式报错**而非静默截断§4.4 风险登记的硬要求)
- `Compact()` 回收 append-only 垃圾,须在无插件持锁时调用
- 【M】✅ `proc/shmcodec.go` StageContext 16 字段跨进程编解码§3.4
- **字段级描述符消除 lost update**:只改 `FinalText` 的插件完全不触碰 `ToolResults` 描述符
- `WriteDirty` 只写脏字段——**只读插件零写入**,不可能覆盖他人改写
- `Snapshot` 存**序列化字符串**切片共享底层数组的坑C ABI 侧修 11.3 时已踩过一次)
- `Extra` 4 键提升为具名字段;`Response` 用标志位区分 nil 与空串(短路语义)
- **全 16 字段可见**——今日经 C ABI 只有 10 个,`ContextMsgs`/`ReasoningContent`/`TokenUsage`/`Memory`/`Extra`/`Errors` 首次对外部插件可见
- 【M】✅ `proc/lock.go` 锁仲裁回归内核§3.7 已裁定,**零 cgo**
- `ForceRelease` 实现实验 9 的崩溃自愈 → 排除 robust pthread_mutex 必要性
- 重复加锁**显式拒绝**(否则死锁 30s比挂死更难排查
- 等待超时有补偿 goroutine 防锁永久泄漏
- 【R】✅ 并发语义:`TestSegment_ConcurrentAppend_NoLostUpdate` 断言「各标记计数之和 == 最终长度 且 == 期望写入次数」,同时排除丢失与撕裂
- 【R】✅ arena 上限报错(非静默截断):`TestSegment_ArenaExhaustionReturnsError`
- 【R】✅ `Extra` 维持 4 键具名字段,未引入通用 tagged union 成本
- 【R】✅ 接口冻结:`sdk/` 零 diff`StageContext` 结构体未改
- 【R】✅ `go vet` 干净(含 copylocks 检查)
- 【V】✅ proc 包共享段部分 **16 项测试全绿(含 `-race`**(全包现 36 项,含进程/端到端):
- 段:魔数/版本校验、全 16 字段往返、Response nil vs 空串
- 脏字段:只读零写回、原地改切片被识别、压实不破坏字段
- **现网场景复刻**`TestSegment_ProductionScenario_SanitizerNotOverwrittenByWeather`sanitizer 清洗 + weather 只读并发,清洗结果不被覆盖)
- **并发零丢失**5 插件 × 40 轮读-改-写同一字段200 次写入全部保留
- 锁:互斥、串扰拒绝、未持锁释放拒绝、重复加锁拒绝、**崩溃自愈**、定向强制释放、临界区串行化
#### ✅ **Part 4 RunStage 接线已完成**2026-09-0109-02
- `proc/stage.go` 把内核 `RunStage` 的并发扇出接到共享段:
`Host.beginStage`(首个到达者独占段并写入 StageContext→ `stage.invoke` RPC →
插件侧 `stage.lock` → 读段 → handler → 只写脏字段 → `stage.unlock` →
`Host.endStage`(最后离开者回读 + 压实 arena
- **并发扇出保留**§0.2 第 1 条:并发扇出是原始设计,不是缺陷);
`stageMu` 串行化整次 stage 对共享段的独占(内核可能在不同路径并发触发
RunStage而段只有一份
- 端到端验证(`e2e_template_test.go`,用**真实 plugindev 模板**编译的插件,
而非 `testdata/` 手写假插件——后者只能验证内核自己跟自己对齐):
- `TestE2E_RealTemplatePluginFullLifecycle`:握手 → init/start → 反向注册 →
工具调用 → stage 读改写;同时验证 `FinalText` 回传
**C ABI 下 after_toolcall 看不到此字段**§8.3 10→16
- `TestE2E_RealTemplateReadOnlyPluginDoesNotOverwrite`:两插件共享同一 Host 并发,
只读插件不覆盖改写插件的结果(若每插件一块段,此测试必然失败)
**Part 4 已整体完成**。
---
## Part 5通知面阶段 4.1~4.5~1.5 周)
> 依据:迁移评估 §3.6 事件环 / §2.4 约束 B / §3.8。目标:外部插件首次获得事件订阅能力,且不阻塞流式输出。
### 修改
- 【M】`internal/plugin/proc/eventring.go``EvtRing` + `Subscriber` schemawrite_seq/read_seq/dropped/type_mask/last_seen溢出计数、允许丢但让消费者知道丢了。
- 【M】eventfd 通知 + Go netpoller 消费:`unix.Eventfd(EFD_NONBLOCK|EFD_CLOEXEC)` + `os.NewFile` 注册 netpoller**不占 OS 线程**——实验 1 已证 200 goroutine 仅 +1 线程)。
- 【M】`internal/events/bus.go` `Publish`:加事件环投递(**post-and-forget绝不等待消费者**,满足约束 B
- 【M】实现 `case 23/24`(今天空实现)——`Events().Subscribe` 对外部插件真正可用。
- 【M】订阅者活性检测`last_seen` 超时 → `recordCrash`。
### 审查
- 【R】`Bus.Publish` 路径**禁用任何锁/阻塞**——流式输出逐 token 发布,任何等待都会卡顿(迁移评估 §4.3 风险高)。
- 【R】溢出语义drops 计数暴露,不静默丢。
- 【R】eventfd 计数合并1000 token 事件只唤醒几次。
### 验证
- 【V】流式压测长回复下 Publish 单次耗时不随订阅者数线性恶化。
- 【V】复刻实验 4post-and-forget 解耦5s → 2.3ms 量级)。
- 【V】外部插件订阅事件端到端原空实现 case 23/24 现在可用)。
**Part 5 出口条件**:事件订阅对外可用,流式输出无卡顿。
---
## Part 6迁移与收尾阶段 5.1~5.4~2 周)— ✅ **已完成**2026-09-03
> 依据:迁移评估 §4.5 双通道共存、§5 权限梯度。
>
> ⚠️ **实际执行偏离计划的一处**:原计划「逐插件迁移,随时回退」。
> 用户决策改为**彻底舍弃 `.so` 能力,无回退通道**(不做 `--cabi` 开关),
> 本轮直接删 `internal/plugin/cabi/`,生产全量切换。代价是某插件出问题
> 只能紧急修复或 `git revert` 整批。因此下方【V】的「`.so` ↔ `.bin` 混跑」
> 不再适用——新内核根本不认 `.so`。
### 修改
- ✅【M】**6.1** 工具链 entry 语义收敛SDK 仓 `9f84412``isProcEntry` 删除Go 插件一律产出 `plugin.bin` 不看 entry 值;`templates.go` 1296→516 行。
- ✅【M】**6.3** 17 插件全量重编(`1d7f011`16 个×3 平台 + qq×1`git status example/` 无输出(业务代码零改动)。
- ✅【M】**6.5** 生产切换(`62bdfa2`):经 `pluginmgr` 的 hmap 正规通道安装17/17 成功且 `config_kept=true`。
- ✅【M】**6.6** 压测 + 版本 1.0.0 + 文档(`2572688`、`670efcd`、tag `v1.0.0`)。
- ✅【M】**6.2** 内核侧 Windows`d027c96`+ 删 C ABI`b20121f`-3198 行):删 `internal/plugin/cabi/`(1156)、`dynamic_dll_windows.go`(272)、`dynamic_loader_unix.go`(79) + bridge 模板;新增 `shmalloc_windows.go` + `evtfd_windows.go` + `shmpass_{unix,windows}.go`;顺带修 macOS pipe 写端被 GC 回收的真 bug。
- ✅【M】**6.4** 权限梯度显式化(`2ebdb9a`54 个 method 划入 11 个 capability 组;`coreHandler.Handle` 入口强制;`withheldCapabilities` 表记录 10 项刻意不提供的内核机制及理由(`SelftestAPI`/`SupervisorAPI`/`TrackerAPI`/`StatusAPI`/`AdapterAPI`/`ConfigAPI`/`ToolAPI`/`IndexerAPI`/`OutputChanRaw`/`EventPublish`)。
- ⏭【M】`lua_plugin.go`/`dynamic_lua.go` 统一走 RPC —— **留待后续**。Lua 走解释器不经 C ABI不阻塞本轮目标消除 C ABI 前提缺陷)。收敛第三套 ABI 是独立优化。
- ✅【M】文档本文与 `plugin-interface-matrix.md` 更新;切换实录见下方。
### 审查
- ✅【R】每删一个 cabi 依赖项,`go build ./...` + `go vet ./...` 干净。
- ✅【R】权限梯度被拒 API 在 RPC 边界返回**明确错误**(非忽略)。错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。`TestCapability_DeniedErrorIsActionable` 守护。
- ✅【R】接口冻结`git diff third_party/homeagent-sdk/sdk/` 全程为空。
### 验证(全量回归)
- ✅【V】17 插件经 `plugin_install(overwrite=true)` 加载,工具/设置/通道/阶段 e2e。
- ⏭【V】~~`.so` ↔ `.bin` 混跑集群冒烟~~ —— 不适用(无回退通道,见上方偏离说明)。改为验证**新内核面对旧 `.so` 给可操作错误且不崩溃**,已在真实二进制上确认。
- ✅【V】`make test` 全量绿 + `go build ./...`。
- ⚠【V】内存**未达成计划目标**。15 个插件进程 RSS=88.0MB / PSS=87.9MB,远超「基线 +29MB」。根因是每插件静态链接整个 Go runtime15 个不同二进制无共同物理页可映射PSS/RSS 99.9% vs 基线 44%)。这是「每插件独立二进制」的固有代价,实际开销高于 §4.3 乐观估计。压缩方向:共享 launcher 二进制 + 各自业务模块。
- ✅【V】工具调用 RPC 延迟 24.1µs实验 11 基线 19.6µs同量级
**Part 6 出口条件**:全部外部插件 `.bin` 化 ✅cabi 删除 ✅,接口零改动 ✅,权限显式化 ✅,无回归 ✅。
---
## 最终验收清单(对照接口不变矩阵 §7 检查点)
| # | 检查点 | 通过标准 | 结果 |
|---|---|---|---|
| 1 | 公开 SDK 接口冻结 | `git diff third_party/homeagent-sdk/sdk/` **为空**(全程) | ✅ 每次审查均确认 |
| 2 | 外部插件业务代码零改动 | 17 个 `example/*/plugin.go` 与基线逐字节可比 | ✅ `git status example/` 无输出 |
| 3 | 17 插件 `.bin` 化 | 全部经 `plugin_install` 加载,工具/设置/通道/阶段 e2e | ✅ 17/17`config_kept=true` |
| 4 | cabi 删除 | `internal/plugin/cabi/` 与 bridge 模板不存在 | ✅ -3198 行(`b20121f` |
| 5 | 崩溃隔离 | 插件 kill 只退出自身homed 存活 | ✅ `TestRealPlugin_CrashDoesNotKillKernel` |
| 6 | 热重载 | 同路径换 `.bin` 即生效,无需重启 | ✅ 生产实测(`unloaded (config kept)` → 重载) |
| 7 | 并发改写 | 跨进程 stage 丢失率 0%(对照今天 35.8~36.8% | ✅ `TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate` |
| 8 | 事件订阅 | 外部插件 `Events().Subscribe` 可用 | ✅ 事件环已接线(当前零用户) |
| 9 | 多模态 | `SetToolBlocks` 非空实现 | ⚠️ method 已定义并划入 core 能力,内核侧仍返回未实现 |
| 10 | 超时取消 | 工具超时可 `Process.Kill()`,零泄漏 | ✅ 整套新架构零 cgo |
| 11 | output_send | 真实结果返回(非假成功) | ✅ 生产实测 `map[status:sent]` |
| 12 | 权限梯度 | 内部专属 API 在 RPC 边界拒绝 | ✅ 12 项测试(`2ebdb9a` |
| 13 | 内存/延迟 | 常驻 +≤29MBRPC p50 ≤20µs 量级 | ⚠️ 延迟 24.1µs 达标;内存 88MB **未达标** |
**两项未完全达标的说明**
- **#9 SetToolBlocks**`io.setToolBlocks` 已在 protocol 定义并划入 `CapCore`
但内核侧 handler 仍返回未实现。C ABI 时代它也是空实现§1.4
故**不是回归**,但也没兑现 §3.8 的承诺。当前无插件使用。
- **#13 内存**15 个进程 RSS=88.0MB,远超「基线 +29MB」。根因是每插件
静态链接整个 Go runtime15 个不同二进制无共同物理页PSS/RSS 99.9%
vs 基线 44%)。实验 5 的基线用的是 2.68MB 最小插件,而真实插件 3.1~14.8MB
绝对数字不可比。结构性指标(均摊线程 5.5 vs 4.9)同量级。
---
## 风险与回退
| 风险 | 缓解 | 回退 |
|---|---|---|
| Part 2/4 `RunStage` 并发语义漂移 | 复刻实验 8/13 + sanitizer/multimodal 对拍Part 4 review | entry 分派切回 `.so`Part 1 双通道) |
| Part 4 `Bus.Publish` 阻塞卡顿 | 专项流式压测Part 5 | 事件环投递后置,先降级进程内 |
| Part 3 工具链 `.bin` 产物问题 | 单插件 weather 先行验证 | 保留 `.so` 构建分支 |
| Part 6 17 插件回归 | 逐个迁移 + `plugin_install(overwrite)` | 任意一个失败立即回退该插件 entry |
| 接口意外漂移 | 每部分【R】强制 `git diff sdk/` 检查 | 立即 revert暴露合同面违约 |
---
*规划2026-08-31update 分支。Part 编号与其依赖的 plan.md/迁移评估阶段对应。*
---
## Part 6.5 生产切换实录2026-09-03
### 执行顺序(先换二进制,再装包)
```
1. systemctl stop homeagent
2. 换 /usr/local/bin/homed
3. 起服务 —— 15 个 .so 插件报可操作错误被跳过homed 与 16 个内置正常
4. 逐个 POST 装 17 个 hmapoverwrite=true
5. 重启核对
```
**为何不能反过来**:若先装包,旧 homed 的 `StopAndUnload` 会停掉 qq
消息通道,而它又无法加载 `.bin`,会卡在「插件全挂」的状态。
第 3 步顺带在真实二进制上验证了 Part 6.2 的可操作错误:
```
[plugin] dynamic weather: plugin weather: 检测到旧 C ABI 产物plugin.so/.dll/.dylib
外部插件已改为子进程模式,请用新版 plugindev 重编产出 plugin.bin业务代码无需修改
```
不崩溃,只跳过该插件。
### 走 hmap 正规通道,而非手工拷贝
第一版切换脚本是手工拷 `plugin.bin` + 手改 `plugin.json` 的 entry ——
那等于**重新实现了一遍 hmap 解包逻辑,且实现得更差**。漏掉的东西:
| | 手工拷贝 | hmap 正规通道 |
|---|---|---|
| `platforms` 字段 | 漏了 | 包内 manifest 本来就写对 |
| 平台二进制选择 | 硬编码 `_linux_amd64` | `platformBinary()` 按 runtime 选 |
| `overwrite` 语义 | 无 | `StopAndUnload` **保留配置表** |
| 失败回滚 | 无 | `os.Rename` 备份,解包失败自动恢复 |
| 校验 | 只查文件存在 | `validatePackage` 查 manifest + 各平台二进制齐全 |
配置保留那条尤其关键:生产 17 个插件都有配置qq 账号、weather 默认城市、
browser profile 路径)。手工脚本恰好没碰配置表所以侥幸不丢,但那是运气不是设计。
最终实现POST 到 `127.0.0.1:9876/plugins`,传 `{path, overwrite:true}`。
保留的一个设计是**先全部校验再动手**——任一插件缺 hmap 就整批中止,
因为新 homed 不认 `.so`,「一半装了一半没装」的中间态最难排查。
### 结果
```
17/17 成功,全部 config_kept=true
0 个残留 .so17 个 plugin.bin 均有执行位
17 个 manifest 的 entry 均为 plugin.bin无 .bak 残留
bundle 包正确挑了当前平台weather 目录只留 8.7MB 的 linux/amd64 那份)
```
备份:`/home/newqqagent-migration-backup-20260902-214812`
plugins 全目录 + homed.old + homeagent.service162MB
**唯一回滚路径**是恢复该目录 + 回滚 homed 二进制。
### 生产端到端验证(真实 QQ 消息)
```
input from qq → response (83293ms, tools=[qq_get_message qq_get_history
output_send__qq output_send__qq qq_mark_read])
```
逐环节:
- **输入**qq 子进程收 webhook → 经 RPC 报给内核 → agent 主循环
- **工具调用**5 次跨进程调用全部成功(内核反向调用进子进程执行)
- **stage 改写生效**(最关键的一条):
```
[sanitizer] cleanToolCallLeakage: 2 bytes removed
[sanitizer] cleaned 2 bytes (before=13590 after=13588)
[proc] sanitizer stage post_action 改写了 1 个字段
```
sanitizer 在**另一个进程里**改了 StageContext内核读到了改写结果。
13590 字节文本经共享段传递、被改写、写回,全程未拷贝整个上下文。
- **输出真的送达**`tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]`
—— 直接验证 Part 0.1 修的 output_send 假成功缺陷§9.4
- **arena 生命周期正常**:每次 stage 结束都压实回收(单次最高 15802 字节),无泄漏累积
这一次对话触发约 20 次 stage、5 次工具调用、2 次输出发送,跨越 15 个插件子进程。
旧架构下同样流程有三处会静默出问题stage 并发写丢字段§8.4 实测 35.8~36.8%
lost update、output_send 假成功、cgo 超时泄漏 goroutine。现在这些在日志里可见且正确。
---
## Part 6.6 压测与延迟实测
基准与压测在代码里(`internal/plugin/proc/bench_test.go` + `streaming_test.go`
非独立脚本——随代码演进自动跑,不会腐坏。
| 项目 | 实测 | 基线 | 判断 |
|---|---|---|---|
| 工具调用 RPC 往返 | 24.1 µs | 实验 11: 19.6 µs | 同量级 |
| 锁仲裁(内核侧) | 0.76 µs | — | 见下注 |
| 事件环写入 | 95 ns | — | 亚微秒 |
| 事件环并发写入 | 83 ns | — | 无锁竞争恶化 |
| 完整 stage 往返 | 132 µs | — | 含 3 次进程间往返 |
| 共享段编解码 | 3.7 µs | — | 占 stage 的 2.8% |
**锁仲裁 0.76µs 不可与实验 3 的 19.40µs 对照**——测的不是同一个东西:
实验 3 测插件经 RPC 请求锁的完整跨进程往返,本基准只测内核侧
`lockRegistry.acquire/release`。真实成本仍在 20µs 量级。基准原名
`BenchmarkStageLockRoundTrip` 有误导性,已改为 `BenchmarkStageLockArbitration`。
**stage 往返 132µs 的成本构成**:共享段编解码只占 3.7µs其余是
**一次 stage 要走 3 次进程间往返**`stage.invoke` + 插件侧反向的
`stage.lock` / `stage.unlock`)。相对 LLM 往返 2-8 秒可忽略;
要优化的方向是把 lock/unlock 合入 `stage.invoke` 的请求/应答。
### 流式压测§4.3 标记「风险高」的那一项)
```
5000 次 Publish + 每条睡 20µs 的慢消费者
实测 2.29ms,均摊 457 ns/token
同步语义理论下限 100ms
订阅者 1 个1.547ms515 ns/次)
订阅者 8 个1.518ms506 ns/次) ← 无线性恶化
环溢出(无消费者写 30000 次cap=8192均摊 35 ns/次 ← 仍 O(1)
```
2.29ms 与实验 4 的数字完全一致(那次也是 2.29ms / 0.46µs per token
post-and-forget 在实现中成立。第三项的意义:消费者完全停摆时写端覆盖
最旧 slot这条路径仍是 O(1),故「插件卡住」不会连带拖慢内核主循环。
---
## 版本号
v1.0.0tag 已打)。公开 SDK 接口零改动,但产物形态从 `plugin.so` 变为
`plugin.bin`0.9.x 内核不会识别——不可互操作的破坏性变化,故跃主版本号。
⚠️ **Makefile 陷阱**`VERSION ?= $(shell git describe --tags --dirty)`
意味着实际注入值来自 git tag`meta.go` 里的默认值只在不带 ldflags 时生效。
打 tag 前 `make build` 注入的是 `v0.9.1-56-g2572688-dirty`。
同时删掉 C ABI 时代的死常量(`ABIVersion`/`CABINum`/51 个 `Core<Method>`
整数 ID——随 Part 6.2 删 `internal/plugin/cabi/` 就已无使用者,
留着会让人以为 C 层协商还在生效,或以为加 method 要同步维护那张整数表。

View File

@ -340,6 +340,60 @@
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)****决策**(压还是收、收哪些)
在父的模型手里 —— 内核不替父决定。
### 7.1 积压任务的**及时反馈**(内核主动拉起分诊助手)[已定]
**背景2026-09-19 线上实测)**:主 agent 被一条长任务占住时当天现场13 分 5 秒、
8 次 cmd_run后来的 QQ 消息全部以 `level insufficient` 排进中断队列干等 ——
同级中断不能抢占同级运行任务(`scheduler.canPreempt`),只能等前一个跑完。
用户在这十几分钟里**收不到任何回复**。
**定性(用户明确)**:这不是"内核替父决定",而是**及时反馈** ——
主 agent 忙时不该让用户干等。分诊助手的职责是:
- **简单的、不需主 agent 介入的** → 直接处理并回复;
- **需要主 agent 介入的** → 立刻回「主 agent 忙碌中,请稍候」,**不勉强作答**。
**行为**:当运行任务已持续超过 `core.agent.offload_busy_after`(默认 5m
**且**排队输入积到 `offload_min_pending`(默认 3条时内核
1. 拉起(或在 `offload_max_residents` 内复用一个)**分诊助手**`OffloadOwned`
2. 把积压的**纯排队输入**交给它先行分诊;
3. 在原队列位置留下一条说明:`[系统] N 条积压消息已在主 agent 忙期间交由临时助手 X 先行分诊…`
**分诊助手的通道配置(刻意与人工创建的子不同)**
- **不配 inputch**:它是内核的干活 agent不接收任何插件的用户输入
- **持有全部输出通道**`AllowedOutputs` 为空 = 完整授权):它必须能把结果发回
qq/webui 等正确通道(否则干活结果无处可去)。
**为什么这套机制自然(用户观察)**:分诊助手就在**同一张登记表**里 ——
父能 `inspect` 它的处理表与轮次、能 `send`、能按需 `compress`/`reclaim`/`destroy`
控制面 6 个动作均按 id 生效、不区分来源,因此回收策略对它自动适用。
状态面额外暴露 `offload_owned`,让父能分清"我建的子"与"内核临时拉的助手"。
**残余任务由父显式决定**(用户 2026-09-19 要求):回收/销毁一个分诊助手时,
它手头可能还有尚未处理的消息。内核**不自己决定**这些消息的命运,而是:
- `residual=keep`(默认):逐条转回父自己的队列,父稍后处理;
- `residual=drop`:明确丢弃,**逐条记日志**(不可追溯的丢弃是不允许的);
- 两种路径都仍要给 `ResponseCh` 补终态,否则 cli/a2a 这类无超时同步调用方
会永久挂起(设计 §7 I5
**默认关闭**`core.agent.offload_enabled=false`):它改变的是系统行为而非修 bug
按「显式才是特权」(与 `scheduler.DefaultLevel` 同一条理由)由部署方打开。
**不做的事(边界)**
- 只转投 `TaskQueued` 纯排队输入。中断任务带级别语义(转投会打乱中断阶梯)、
self 任务是内核内部记账(与父的记忆面绑定)—— 两者都不动。
- 只对**根 agent** 生效:子再去拉孙子会形成无界增殖,而积压的源头是根那条链。
- 实现上检查跑在**独立 goroutine**`schedulerLoop` 是同步执行的,
放在那里在「正忙」期间根本不会回到循环顶部(等于永不触发)。
- 转投时**推原事件**`DeliverRouted`)而不是重建:重建会丢掉 `ResponseCh`
使同步调用方永久挂起。
💡 **两条容易重犯的坑(都已在实现里修掉并写进测试)**
1. 积压可能全堆在 `io.inputCh`(因为忙时 `pumpInbox` 没被调用),
只数 `sched.queue` 会得到 0 ⇒ 永不触发。
2. 分诊助手必须**继承父的 SystemPrompt**:它里面写着「面向 qq 等异步通道时
必须显式 `output_send`,纯文本会被静默丢弃」。缺了它,子处理完却发不出去。
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
@ -623,4 +677,4 @@ go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
- 若需要动公开 SDK例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
追加,并 `docs/git-branching.md` §八 的接线清单同步(含 hmapdev 模板)与 SDK 仓版本(§七)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
package api
import "testing"
// 回归2026-09-19模型名推断不出窗口时此前会静默退回 32768。
// 生产实际配的是 core.llm.model="AUTO",于是整个预算按 32768 算,
// 而该源真实窗口是 1M实测 990,034 token 的 prompt 通过)—— 小 30 倍。
//
// 这里钉死两点:① deepseek-v4 系能推断出真实窗口;② AUTO 仍走兜底
// (兜底值本身不猜大:猜大会让请求直接撞上游 400
func TestModelContextWindow(t *testing.T) {
cases := map[string]int{
"deepseek/deepseek-v4.1-flash": 1048576,
"deepseek-v4-flash": 1048576,
"deepseek-chat": 65536,
"claude-opus-5": 100000,
"gpt-4-turbo": 128000,
"llama-3-70b": 8192,
"AUTO": 32768, // 推断不出 → 兜底,靠 context_window 覆盖
}
for model, want := range cases {
if got := ModelContextWindow(model); got != want {
t.Errorf("ModelContextWindow(%q) = %d, want %d", model, got, want)
}
}
}
// 显式声明的 context_window 必须覆盖模型名推断 —— 这是部署方绕开
// “AUTO 推断不出窗口”的唯一手段,不能反过来被推断值盖掉。
func TestExplicitContextWindowWinsOverInference(t *testing.T) {
p := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO", ContextWindow: 1048576}}
if got := p.MaxContextTokens(); got != 1048576 {
t.Errorf("显式 context_window 未生效got %d, want 1048576", got)
}
p2 := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO"}}
if got := p2.MaxContextTokens(); got != 32768 {
t.Errorf("未声明时应走推断兜底got %d, want 32768", got)
}
}

View File

@ -8,9 +8,9 @@ import (
"fmt"
"io"
"log"
"os"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
@ -260,11 +260,21 @@ func ProviderSupportsAudio(p Provider) bool {
return false
}
// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。
//
// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO网关自己选上游
// ModelContextWindow 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口
// 去算全部预算实测deepseek-v4.1-flash 能吞 990,034 token而预算按 32768 算)。
// 因此推断不出来时留一条日志,并让部署方用 per-source context_window 显式声明。
const defaultInferredContextWindow = 32768
// ModelContextWindow 返回模型的最大上下文窗口token 数)
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率
func ModelContextWindow(model string) int {
model = strings.ToLower(model)
switch {
case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"):
return 1048576
case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"):
return 65536
case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")):
@ -296,7 +306,13 @@ func ModelContextWindow(model string) int {
case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"):
return 131072
default:
return 32768
// 模型名推断不出窗口(如 "AUTO"):不要静静退回一个比真实小得多的值。
// 报一行日志,让“窗口被低估”这件事可见;部署方用 per-source
// core.llm.sources.<name>.context_window 声明真实值即可覆盖。
log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d"+
"若真实窗口更大,请设置 core.llm.sources.<name>.context_window",
model, defaultInferredContextWindow)
return defaultInferredContextWindow
}
}
@ -1268,8 +1284,8 @@ func getFloat(m map[string]interface{}, key string) float64 {
// ToolOutput 是工具 handler 返回的结构化结果,支持多模态内容。
// 返回 string 时等价于 ToolOutput{Text: result}。
type ToolOutput struct {
Text string `json:"text"` // LLM 看到的文字描述
Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块image_url/audio_url追加到 tool message
Text string `json:"text"` // LLM 看到的文字描述
Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块image_url/audio_url追加到 tool message
}
func (t ToolOutput) String() string { return t.Text }

View File

@ -142,6 +142,8 @@ type Agent struct {
// 工具轮次硬上限0 = 不限);见 AgentConfig.MaxToolTurns。
maxToolTurns int
// offload 是积压任务自动转投的参数(见 offload.go
offload OffloadOptions
// 进行中的 LLM 请求取消函数interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
@ -176,6 +178,10 @@ type Agent struct {
noMergeMarkers map[string]int
noMergeMu sync.Mutex
// TerminalRegistry 是终端会话与命令历史的权威视图(“内核开,两个插件接”)。
// 内核订阅自己的事件总线归并而来WebUI/CLI 经 KernelStatus 读取。
terminalReg *TerminalRegistry
// 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time
@ -275,6 +281,12 @@ type AgentConfig struct {
// MaxToolTurns 是单个任务允许的工具轮次上限0 = 不限)。
// 设计文档 D6主循环必须有硬上限否则模型不停调用就永不完结。
MaxToolTurns int
// Offload 是「积压任务自动转投给驻留子」的参数(见 offload.go
//
// 默认关闭Enabled=false它让**内核替父做决策**,是设计 §7
//「决策在父的模型手里」的刻意例外,因此必须由部署方显式打开。
Offload OffloadOptions
}
func New(cfg AgentConfig) *Agent {
@ -365,6 +377,7 @@ func New(cfg AgentConfig) *Agent {
childTasks: make(map[string]*childTaskState),
sched: newScheduler(256),
maxToolTurns: cfg.MaxToolTurns,
offload: cfg.Offload,
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
@ -377,6 +390,13 @@ func New(cfg AgentConfig) *Agent {
lastInput: make(map[string]time.Time),
}
// 终端权威注册表只归**根 agent**(无 ParentID。驻留子共用同一事件总线
// 若每个子都建一份并订阅,一次工具调用会被 N+1 份重复记账;而终端本就是
// 内核级设备,不属于任何单个驻留子。
if cfg.ParentID == "" {
a.terminalReg = NewTerminalRegistry()
}
// 输入路由inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
// (设计 §4.1「路由发生在进内核之前」。io 层不认识 agent所以在这里把路由器
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
@ -396,11 +416,29 @@ func (a *Agent) Start() {
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
go a.offloadLoop()
a.subscribeTerminalRegistry()
a.reembedStaleMedia()
a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}
// subscribeTerminalRegistry 让内核的终端/命令历史权威视图归并事件流。
//
// 内核自己发 EventToolCallagent 路径agentcli 发 EventTerminalOutput
// 含生命周期事件。两者都进这份唯一真相WebUI/CLI 不再各自推导。
func (a *Agent) subscribeTerminalRegistry() {
if a.eventBus == nil || a.terminalReg == nil {
return
}
a.eventBus.Subscribe(events.EventToolCall, func(ev *events.Event) {
a.terminalReg.OnToolCall(ev.Payload)
})
a.eventBus.Subscribe(events.EventTerminalOutput, func(ev *events.Event) {
a.terminalReg.OnTerminalOutput(ev.Payload)
})
}
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()

View File

@ -11,10 +11,10 @@ func TestEntitySimilarity(t *testing.T) {
a, b string
want float64
}{
{"", "", 0}, // empty → 0
{"a", "b", 0}, // single char → 0
{"张三", "张三", 1.0}, // identical → 1.0
{"张三", "李四", 0}, // no common bigrams
{"", "", 0}, // empty → 0
{"a", "b", 0}, // single char → 0
{"张三", "张三", 1.0}, // identical → 1.0
{"张三", "李四", 0}, // no common bigrams
{"iPhone", "iPhone 15", 0.625}, // partial overlap
}
for _, tt := range tests {

View File

@ -235,3 +235,58 @@ func TestGetFloatInt(t *testing.T) {
t.Errorf("expected 5.0, got %f", got)
}
}
// TestDocToTriplesDropsNoiseEntities 钉住 doc→graph 的噪音闸门。
//
// 背景doc→graph 在 1cb3e87 从「CutExact 滑窗词链」换成 NLP 依存提取器后,
// 唯一还拦常用词的那层CutExact去停用词 + validEntityName失去调用点
// 闸门只剩 validEntityName——它只管名字像不像名字不管名字是不是常用词。
// 实测生产库里因此攒下「文档 --主题--> 来自 N 个来源的 M 条对话 …」这类
// 模板回声,以及 context_archived 这个内部标记。
func TestDocToTriplesDropsNoiseEntities(t *testing.T) {
doc := &document.Doc{
Summary: "来自 1 个来源的 2 条对话 (agent) 涉及: qq, 通道",
Content: "",
Source: "context_archived",
}
triples := docToTriples(doc, nil)
for _, tr := range triples {
if memory.IsNoiseEntity(tr.Subject) || memory.IsNoiseEntity(tr.Object) {
t.Errorf("docToTriples 漏出噪音实体: %+v", tr)
}
}
// 模板摘要不当「主题」、context_archived 不当「来源」:两条模板三元组都该被拦下。
for _, tr := range triples {
if tr.Relation == "主题" {
t.Errorf("模板摘要被写成主题: %+v", tr)
}
if tr.Relation == "来源" && tr.Object == "context_archived" {
t.Errorf("归档内部标记被写成来源: %+v", tr)
}
}
}
// TestDocToTriplesKeepsTemplateAnchors 保证闸门没把正常的模板三元组一起误杀。
func TestDocToTriplesKeepsTemplateAnchors(t *testing.T) {
doc := &document.Doc{
Summary: "多轮对话",
Content: "",
Source: "qq",
}
triples := docToTriples(doc, nil)
var hasTopic, hasSource bool
for _, tr := range triples {
if tr.Subject == "文档" && tr.Relation == "主题" && tr.Object == "多轮对话" {
hasTopic = true
}
if tr.Subject == "文档" && tr.Relation == "来源" && tr.Object == "qq" {
hasSource = true
}
}
if !hasTopic || !hasSource {
t.Errorf("正常模板三元组被误杀: topic=%v source=%v, triples=%+v", hasTopic, hasSource, triples)
}
}

View File

@ -3,6 +3,7 @@ package core
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -68,10 +69,38 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
//
// 注入时**回填已有事件**的稠密向量。为什么必须回填NewRelevanceContext 先
// load()、再 SetDenseSpace载入时 c.denseSpace 还是 nil旧事件只算了稀疏
// 向量若这里只赋值不回填Prune 里旧事件因 DenseFP 为空、长度不符而全部
// 走稀疏余弦,新事件走稠密余弦 —— 同一次排序里两种尺度混排,谁留下谁归档
// 取决于事件新旧而非相关性。对齐 DocStore.BuildDenseIndex 的做法。
//
// 注意 DenseVec/DenseFP 刻意不持久化json:"-"):这是每次启动一次性重算的
// 缓存,不落盘,因此这里也不需要 Save。
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
c.mu.Lock()
defer c.mu.Unlock()
c.denseSpace = ds
if ds == nil || !ds.Loaded() {
return
}
fp := ds.Fingerprint()
dim := ds.Dim()
filled := 0
for _, evt := range c.events {
if evt == nil {
continue
}
if evt.DenseFP == fp && len(evt.DenseVec) == dim {
continue
}
c.computeVector(evt)
filled++
}
if filled > 0 {
log.Printf("[agent] context dense backfill: %d events", filled)
}
}
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {

View File

@ -2,6 +2,7 @@ package core
import (
"os"
"path/filepath"
"testing"
"time"
@ -237,6 +238,41 @@ func containsStr(s, substr string) bool {
return false
}
// TestSetDenseSpaceBackfillsExistingEvents 锁死 L0 稠密回填:
//
// NewRelevanceContext 先 load()(此时 denseSpace 仍为 nil旧事件只算了稀疏
// 向量SetDenseSpace 才注入稠密空间。若不回填已有事件,它们的 DenseFP
// 为空、DenseVec 长度不符Prune 里旧事件走稀疏余弦、新事件走稠密余弦——
// 同一次排序里混排两种尺度,谁留下只取决于事件新旧。
func TestSetDenseSpaceBackfillsExistingEvents(t *testing.T) {
path := filepath.Join(t.TempDir(), "context.json")
// run1写入事件并落盘不注入稠密空间
c1 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "昨天的决定"})
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Response: "记为待办"})
if err := c1.Save(); err != nil {
t.Fatal(err)
}
// run2模拟重启——load() 发生在 SetDenseSpace 之前。
c2 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
if c2.Len() == 0 {
t.Fatal("重启后未读回任何事件")
}
c2.SetDenseSpace(fakeSpace{})
events := c2.Recent(c2.Len())
if len(events) == 0 {
t.Fatal("no events")
}
for i, e := range events {
if e.DenseFP != "fake-space" || len(e.DenseVec) != 2 {
t.Errorf("event %d 未回填稠密向量: fp=%q len=%d", i, e.DenseFP, len(e.DenseVec))
}
}
}
func splitLines(s string) []string {
var lines []string
start := 0

View File

@ -172,6 +172,19 @@ func (a *Agent) archiveColdDocs() {
}
}
// 场景记忆的「用进废退」:久未重现的关联按半衰期淡出。
//
// 不做衰减的后果不是"多记一点",而是**注入预算被一次性巧合吃光**——
// 场景是每轮都要注入的常驻内容,关联只增不减时,越老的库注入越糊。
// 半衰期取 30 天:比"这个月没做过这类事"更久,避免把季节性的事误删。
if a.memory != nil {
if n, err := a.memory.DecaySceneRefs(30*24*time.Hour, 0.05); err != nil {
log.Printf("[agent] scene decay error: %v", err)
} else if n > 0 {
log.Printf("[agent] 场景关联衰减:清理 %d 条长期未重现的引用", n)
}
}
if a.docStore != nil {
a.docStore.Reindex()
}
@ -207,7 +220,7 @@ func (a *Agent) archiveColdDocs() {
// 文档持有的一等块写入 L3并以 document --contains--> block 边关联;
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
if len(doc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks, memory.ChannelScene(doc.Source)); bound != len(doc.Blocks) {
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
doc.ID, bound, len(doc.Blocks))
continue
@ -423,6 +436,11 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
return nil
}
// 文档归档的知识是有**来源场面**的:来自 QQ 的对话归档,其三元组就该
// 钉在 chan:qq 上。这样「又来一条 QQ 消息」时,这批知识靠场景就能取回,
// 不必指望本轮措辞与它们字面重合。
docScene := memory.ChannelScene(doc.Source)
isArchivedContext := doc.Meta != nil && doc.Meta["is_archived_context"] == "true"
// 文档元数据:仅当 summary 合理(非空、非模板化、长度适中)时才写「主题」
@ -434,6 +452,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
Object: doc.Summary,
ObjectType: "Topic",
Confidence: 1.0,
Scene: docScene,
})
}
@ -451,6 +470,7 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
if mt.Subject != "" && mt.Relation != "" && mt.Object != "" {
mt.Scene = docScene
triples = append(triples, mt)
}
}
@ -465,10 +485,15 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
Object: doc.Source,
ObjectType: "Source",
Confidence: 1.0,
Scene: docScene,
})
}
return triples
// 噪音闸门NLP 提取器不认常用词(「结果 / 什么 / 待命」都能当主语),
// 而落库闸门 validEntityName 只管名字像不像名字。这一层是防止
// 「每个文档的常用词都变成实体」的唯一防线CutExact 时代的那层已随
// 提取器换代丢失,见 memory.IsNoiseEntity 的说明)。
return memory.FilterNoiseTriples(triples)
}
// isTemplateSummary 识别 summarizeEntries 生成的模板化摘要

View File

@ -31,9 +31,47 @@ func (a *Agent) interceptLoop() {
select {
case evt := <-a.io.InputInterruptChan():
text, _ := evt.Payload["content"].(string)
if text == "" {
stop, _ := evt.Payload["stop"].(bool)
if text == "" && !stop {
// 没有内容也不是停止指令:没有可处理的东西(旧行为)。
//
// 注意:**不能**把“空内容”一律当成空操作。客户端停止按钮
// 本来就不带消息(/chat/interrupt 收 body 空的 {}
// 旧代码在这里 continue 掉,于是停止按钮毫无反应,
// 而且接口还回 200 骗调用方——已实测HTTP 200 但内核零日志、
// 生成继续跑到自然结束。
continue
}
if stop {
// 停止:①立即结束当前 LLM 推理;②登记短路配额。
//
// 注意这里**只 arm、不 take**takeStop 必须由 stepLLM 去消费,
// 它才是决定“取消后不重跑”的那个人。曾经写成
//
// if n := armStop(); n > 0 || takeStop() { ... }
//
// 这个 `||` 在 queued=0 时会短路到 takeStop(),把标记先消费掉,
// 于是 stepLLM 永远看不到它 → 取消后照样重跑一轮。
// 实测:停止被正确记录(`stop requested ... queued=0`)但生成仍跑到自然结束。
// pending 必须算上**停在输入 channel 里**的那一段:停止时
// 调度器多在半路忙当前任务,其余消息还没被 pumpInbox 搬进队列,
// 只数 sched.queue 会得到 0配额随之失效实测过
pending := 0
if a.io != nil {
pending = a.io.PendingInputs()
}
n := a.sched.armStop(pending)
log.Printf("[agent] stop requested by %s/%s (queued=%d will be short-circuited at pre-action)",
evt.Source, evt.OutputChannel, n)
a.cancelCurrentLLM()
if text == "" {
// 纯停止:不进中断队列、不产生新任务。旧实现把空停止当成一条
// 中断入队,取消后会以空内容重跑一轮,停下之后又“活着”。
continue
}
// 带注释的停止(/stop 说句话):注释本身仍作为中断处理,
// 走下面的正常路径——用户想看模型对被停下话题的回应。
}
log.Printf("[agent] interrupt from %s/%s: %s", evt.Source, evt.OutputChannel, truncateStr(text, 80))
clone := &agentIO.InputEvent{
@ -275,15 +313,28 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
return blocks, alt
}
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
// emitSkippedReply 给被跳过任务的调用方一个终态。
//
// 为什么要单独一条路径而不是复用 emitResponse跳过意味着“我们没有处理这条输入”
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
//
// ❗异步来源qq / wechat / rss 等)**没有 ResponseCh**,于是这里以前是直接 return。
// 后果是任务被丢弃时**完全无声**:用户什么都没收到、日志里也没痕迹,
// 他只会以为消息丢了。转投子被回收/销毁时队列里的积压正落在这个盲区里
// (父可随时对子 reclaim/destroy而子手上可能还握着几条 QQ 消息)。
// 现在至少留一条带来源与通道的日志,让“这条消息为什么没回”可被追溯。
//
// 非阻塞写ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
if evt == nil || evt.ResponseCh == nil {
if evt == nil {
return
}
if evt.ResponseCh == nil {
// 无可回执的通道:不静默。异步来源本就靠 agent 主动 output_send
// 丢弃后没有任何东西会告诉用户,因此这条日志是唯一的线索。
log.Printf("[agent] %s: 丢弃一条无回执通道的输入source=%s channel=%s request=%s reason=%s",
a.id, evt.Source, evt.OutputChannel, evt.RequestID, reason)
return
}
ch := evt.OutputChannel
@ -379,7 +430,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
if !a.pruneDeclared(evt) {
return 0
}
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false).Archived
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived
}
// pruneDeclared 判定这次输入是否显式声明了裁剪。

View File

@ -47,7 +47,7 @@ func (a *Agent) migrateLegacyGraphMedia() {
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// seed 允许复用已持有块的 IDL2→L3 迁移保持块身份不变)。
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock, scene string) int {
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
return 0
}
@ -64,6 +64,11 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed
continue
}
}
// 块继承承载它的三元组的场景:块是流水线里最细的子项目,场景要落到它身上,
// 否则「那场对话里发过来的那张图」在场面重现时永远取不回来。
if b.Scene == "" {
b.Scene = scene
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
@ -79,7 +84,7 @@ func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed
// linkBlocksToDocument 把文档持有的块写入 L3并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock, scene string) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
@ -87,6 +92,20 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock)
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
// 文档层与场景模型兼容:文档节点也进场景,好让「这个场面有哪些文档」
// 可枚举、可统计(场景贯穿流水线的 doc 层落地)。
if scene != "" {
if err := a.memory.TagSceneDocument(scene, docID); err != nil {
log.Printf("[media] 文档挂场景失败 (%s): %v", docID, err)
}
}
// 文档层把场景传给块归档进图库的块属于该文档的来源场面QQ 归档的图
// 就该挂在 chan:qq 上),否则 L3 里这批块在场景召回中不可见。
for i := range blocks {
if blocks[i].Scene == "" {
blocks[i].Scene = scene
}
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
@ -135,7 +154,7 @@ func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string
if sid == 0 {
continue
}
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest, t.Scene)
}
return ec, rc, blocks, nil
}
@ -197,6 +216,28 @@ func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
}
// formatRecallRelations 渲染 memory_recall 的关系行,超 max 条截断。
//
// 带上原始句子(截断到 60 字三元组只是「A 关系 B」脱离原句往往看不出
// 语气、条件与指代——`sentence_text` 的存在意义就是「日后从图谱回到原文」,
// 而 Recall 已经把句子 JOIN 出来了。此前只回显实体名与关系类型,导致模型
// 填了 sentence_text 也永远拿不回来,这个能力形同虚设。
func formatRecallRelations(relations []memory.Relation, max int) []string {
var out []string
for i, r := range relations {
if max > 0 && i >= max {
out = append(out, "...更多关系被截断")
break
}
line := fmt.Sprintf("- %s →(%s)→ %s", r.SourceName, r.RelationType, r.TargetName)
if s := strings.TrimSpace(r.SentenceText); s != "" {
line += " 原句: \"" + truncateStr(s, 60) + "\""
}
out = append(out, line)
}
return out
}
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
//
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token

View File

@ -194,7 +194,7 @@ func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
a, g, _ := newGraphMediaAgent(t)
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil, ""); n != 0 {
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
}
blocks, err := g.BlocksForNode("sentence", "42")
@ -208,7 +208,7 @@ func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
a := &Agent{}
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil, ""); n != 0 {
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
}
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
@ -234,7 +234,7 @@ func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
sid := ids["迁移测试句。"]
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest, ""); n != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", n)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
@ -256,7 +256,7 @@ func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
t.Fatal("blockFromDigest 失败")
}
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}, ""); n != 1 {
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
}
blocks, err := g.BlocksForNode("document", "doc_42")
@ -376,7 +376,7 @@ func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
t.Fatalf("indexer sync: %v", err)
}
out := a.buildMemoryContext("测试图片", 0)
out := a.buildMemoryContext("测试图片", 0, nil)
if out == "" {
t.Skip("图库召回未命中indexer 检索策略所致),无法验证媒体段注入")
}
@ -759,3 +759,39 @@ func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
t.Fatal("删除后内容应已移除")
}
}
// TestFormatRecallRelations_SurfacesSentence 锁死「从图谱回到原文」:
// memory_recall 的关系行必须带上 sentence_text截断否则模型按工具
// schema 填了原始句子也永远取不回,该字段形同虚设。
func TestFormatRecallRelations_SurfacesSentence(t *testing.T) {
rels := []memory.Relation{
{SourceName: "张三", RelationType: "喜欢", TargetName: "咖啡", SentenceText: "张三说他每天早上一定要喝一杯手冲咖啡。"},
{SourceName: "张三", RelationType: "住在", TargetName: "北京"}, // 无原句:不应出现空的原句字段
}
lines := formatRecallRelations(rels, 10)
if len(lines) != 2 {
t.Fatalf("应渲染 2 行,实际 %d: %v", len(lines), lines)
}
if !strings.Contains(lines[0], "张三 →(喜欢)→ 咖啡") || !strings.Contains(lines[0], "原句:") {
t.Errorf("第一条应带原句,实际 %q", lines[0])
}
if strings.Contains(lines[1], "原句") {
t.Errorf("无 sentence_text 的关系不应出现原句字段,实际 %q", lines[1])
}
}
// TestFormatRecallRelations_Truncates 锁死关系条数上限:
// 超过 max 时截断并明确告知,避免刷屏。
func TestFormatRecallRelations_Truncates(t *testing.T) {
var rels []memory.Relation
for i := 0; i < 15; i++ {
rels = append(rels, memory.Relation{SourceName: "A", RelationType: "连", TargetName: "B"})
}
lines := formatRecallRelations(rels, 10)
if len(lines) != 11 {
t.Fatalf("10 条关系 + 1 条截断提示,实际 %d: %v", len(lines), lines)
}
if !strings.Contains(lines[10], "截断") {
t.Errorf("最后一行应为截断提示,实际 %q", lines[10])
}
}

View File

@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
},
},
},
})
}, nil)
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
},
},
})
}, nil)
if strings.Contains(out, "失败") {
t.Errorf("普通提交不该失败: %q", out)
}
@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
},
},
},
})
}, nil)
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回为空")
@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
},
},
},
})
}, nil)
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
}

View File

@ -88,7 +88,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_recall",
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
})
}, nil)
if !strings.Contains(got, "主记忆实体") {
t.Fatalf("子应看得到主记忆:%s", got)
}
@ -132,7 +132,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
Arguments: map[string]interface{}{
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
},
})
}, nil)
if !strings.Contains(got, "轻量内核") {
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
}

View File

@ -440,7 +440,7 @@ func TestMediaLive_AutoTriggerChain(t *testing.T) {
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
if mc := a.buildMemoryContext("测试图片", 0, nil); mc != "" {
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
} else {
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")

View File

@ -1,6 +1,63 @@
package core
import "log"
import (
"log"
"strconv"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// sceneKeysFor 推导本轮输入的**当前场景**。
//
// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定)
// 取回来。优先级:
// 1. 注入点显式声明payload.scene——插件最清楚自己在什么场面里
// 2. 通道evt.Source → chan:qq
// 3. 工具tool:qq_get_message——工具输出触发的召回只知道这一步
//
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
var keys []string
seen := make(map[string]bool)
add := func(k string) {
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
// 否则「chan:QQ」与「chan:qq」会变成两个场景各自只召回一半记忆。
k = memory.NormalizeSceneKey(k)
if k == "" || seen[k] {
return
}
seen[k] = true
keys = append(keys, k)
}
if evt != nil && evt.Payload != nil {
switch v := evt.Payload["scene"].(type) {
case string:
add(v)
case []string:
for _, s := range v {
add(s)
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
add(s)
}
}
}
}
if evt != nil {
add(memory.ChannelScene(evt.Source))
}
if toolName != "" {
add(memory.ToolScene(toolName))
}
return keys
}
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
type memoryPassOut struct {
@ -26,7 +83,7 @@ type memoryPassOut struct {
// 稠密/词向量给已有事件打分recall 用图 + TF-IDF 实体索引)。真正的
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPassOut {
func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut {
var out memoryPassOut
if a == nil || (!prune && !recall) {
return out
@ -35,7 +92,7 @@ func (a *Agent) memoryPass(query, trigger string, prune, recall bool) memoryPass
out.Archived = a.pruneByQuery(query)
}
if recall && query != "" {
out.RecallText = a.recallTextFor(query, trigger)
out.RecallText = a.recallTextFor(query, trigger, scenes)
}
if out.Archived > 0 || out.RecallText != "" {
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
@ -63,3 +120,141 @@ func (a *Agent) pruneByQuery(query string) int {
}
return a.context.Prune(query, topK, a.docStore)
}
// ──────────────────────────────────────────────
// 场面指纹:场景**涌现**的原料
//
// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的
// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时
// 已有量,不需要模型配合,也不需要人工标注。
// ──────────────────────────────────────────────
// situationFeaturesFor 采集一轮交互的场面指纹。
//
// 特征权重由种类决定(见 memory.SituationFeature.Weight通道与对象是
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
var feats []memory.SituationFeature
if evt != nil {
if evt.Source != "" {
feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source})
}
// 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道)
for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} {
if v, ok := evt.Payload[k]; ok {
if s := payloadString(v); s != "" {
// 群与私聊要能区分:同一 id 在两种场景下不是同一个对象
kind := "peer"
if k == "group_id" {
kind = "peer_group"
}
feats = append(feats, memory.SituationFeature{Kind: kind, Value: s})
break
}
}
}
// 时段:弱信号。人的记忆确实带时间气味(「早上那件事」),
// 但它不该主导场面判定,所以权重最低。
feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())})
}
if tool != "" {
feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool})
}
// 话题:取清洗后输入的内容词做软特征(最多 3 个)。
if cleanInput != "" {
for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) {
if i >= 3 {
break
}
feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw})
}
}
return feats
}
// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number
func payloadString(v interface{}) string {
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return strconv.FormatInt(int64(t), 10)
}
return strconv.FormatFloat(t, 'f', -1, 64)
case int64:
return strconv.FormatInt(t, 10)
case int:
return strconv.Itoa(t)
default:
return ""
}
}
// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。
func partOfDay(t time.Time) string {
switch h := t.Hour(); {
case h < 6:
return "night"
case h < 12:
return "morning"
case h < 18:
return "afternoon"
default:
return "evening"
}
}
// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**
//
// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入
// 本轮指纹(声明场景因此慢慢学会自己认自己)
// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景
//
// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景
// 兜底Keys 用于**读**(两条路的并集,去重)。
//
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
var out memory.TurnScene
if a == nil || a.memory == nil {
return out
}
if f != nil && f.sceneDone {
return f.turnScene
}
declared := sceneKeysFor(evtOf(f), tool)
feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
sig := memory.NewSituation(feats...)
turn, err := a.memory.EnterSceneWithHint(sig, declared)
if err != nil {
log.Printf("[agent] scene enter failed: %v", err)
// 出错时至少把声明场景交给召回,不让整条召回链一起失效
turn = memory.TurnScene{Keys: declared}
if len(declared) > 0 {
turn.Primary = declared[0]
}
}
if turn.Emergent {
log.Printf("[agent] 场景涌现/命中: %q指纹 %v", turn.Primary, sig.Keys())
} else if len(turn.DeclaredCreated) > 0 {
log.Printf("[agent] 声明场景成立: %v指纹 %v", turn.DeclaredCreated, sig.Keys())
}
if f != nil {
f.turnScene = turn
f.Scene = turn.Primary
f.sceneDone = true
}
return turn
}
// cleanInputOf 安全取出清洗后输入f 为 nil 时为空)。
func cleanInputOf(f *TaskFrame) string {
if f == nil {
return ""
}
return f.CleanInput
}

View File

@ -22,7 +22,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
}
out := a.memoryPass("咖啡", "test", false, false)
out := a.memoryPass("咖啡", "test", false, false, nil)
if out.Archived != 0 || out.RecallText != "" {
t.Fatalf("未声明任何策略时不应有任何输出,实际 %+v", out)
}
@ -31,7 +31,7 @@ func TestMemoryPass_NoPolicyIsNoOp(t *testing.T) {
func TestMemoryPass_PruneAndRecallTogether(t *testing.T) {
a := newMemoryPassAgent(t)
before := a.context.Len()
out := a.memoryPass("咖啡", "tool:test", true, true)
out := a.memoryPass("咖啡", "tool:test", true, true, nil)
if out.Archived == 0 {
t.Fatal("声明 prune 应归档低相关事件")
}
@ -50,7 +50,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
maxContextSize: 4,
indexer: newTestIndexer(t, "咖啡", "张三"),
}
if out := onlyPrune.memoryPass("咖啡", "test", true, false); out.RecallText != "" {
if out := onlyPrune.memoryPass("咖啡", "test", true, false, nil); out.RecallText != "" {
t.Fatalf("只声明 prune 不应召回,实际 %q", out.RecallText)
}
// 只召回不裁剪:输出只有召回文本,上下文条数不变。
@ -60,7 +60,7 @@ func TestMemoryPass_PoliciesAreOrthogonal(t *testing.T) {
indexer: newTestIndexer(t, "咖啡", "张三"),
}
before := onlyRecall.context.Len()
out := onlyRecall.memoryPass("咖啡", "test", false, true)
out := onlyRecall.memoryPass("咖啡", "test", false, true, nil)
if out.Archived != 0 {
t.Fatalf("只声明 recall 不应裁剪,实际归档 %d", out.Archived)
}

View File

@ -0,0 +1,411 @@
package core
// 积压任务的**自动转投**:主 agent 长时间忙时,把排队中的任务改投给内核拉起的
// 驻留子,并在原队列位置留一条"已转投"提示。
//
// 为什么要这个(用户 2026-09-19 提出的实际需求):
// 实测主 agent 被一条长任务占住时当天现场12 分 8 秒、69 次工具调用),
// 后来的 QQ 消息全部以 "level insufficient" 排进中断队列干等 —— 同级中断
// 不能抢占同级运行任务scheduler.canPreempt只能等前一个跑完。
// 而内核明明有驻留子(独立 agent + 独立调度器 + 共享输出通道视图)可以并行干活。
//
// 与设计文档 §7 的关系(**这是刻意的例外,必须显式记录**
// 设计原文写「创建/销毁/回收/查看/发送是父可调用的原语;**决策在父的模型手里**——
// 内核不替父决定」。本特性让**内核**主动创建并使用驻留子,属于对该原则的例外。
// 之所以可接受:父此刻正忙(无法做决策),而积压任务**本来就是空的**——
// 转投只是把"排队干等"换成"有人在做",不改变任何已提交决策的语义。
// 若不做例外,这个能力就只能由父的模型发起,而它恰恰是忙不过来的那个。
import (
"fmt"
"log"
"strings"
"time"
"runtime/debug"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// OffloadOptions 是自动转投的判定与执行参数。
type OffloadOptions struct {
// BusyAfter运行任务已持续多久算"长时间工作"0 = 用默认)。
BusyAfter time.Duration
// MinPending至少要积压多少条才值得拉起驻留子0 = 用默认)。
MinPending int
// MaxResidents为转投而拉起的驻留子上限0 = 用默认)。
MaxResidents int
// Enabled 为 false 时完全关闭(默认关:见 DefaultOffloadOptions 的说明)。
Enabled bool
}
// 默认参数。
//
// 为什么默认**关闭**:自动拉起是"内核替父做决策",改变的是系统行为而非修 bug
// 且它会让日志/账单里凭空多出一个 agent 在干活。默认关闭、由部署方显式打开,
// 与「显式才是特权」scheduler.DefaultLevel 的同一条理由)一致。
const (
defaultOffloadBusyAfter = 5 * time.Minute
defaultOffloadMinPending = 3
defaultOffloadMaxResident = 2
)
// DefaultOffloadOptions 返回默认参数Enabled=false
func DefaultOffloadOptions() OffloadOptions {
return OffloadOptions{
BusyAfter: defaultOffloadBusyAfter,
MinPending: defaultOffloadMinPending,
MaxResidents: defaultOffloadMaxResident,
Enabled: false,
}
}
func (o OffloadOptions) normalized() OffloadOptions {
if o.BusyAfter <= 0 {
o.BusyAfter = defaultOffloadBusyAfter
}
if o.MinPending <= 0 {
o.MinPending = defaultOffloadMinPending
}
if o.MaxResidents <= 0 {
o.MaxResidents = defaultOffloadMaxResident
}
return o
}
// offloadNotice 是替换被转投任务的那条提示的正文。
//
// 它必须**自己说清是系统做的**:用户看到队列里出现一条没人发过的消息时,
// 唯一能解释这件事的就是这句话本身。
//
// 同时要说清"不必重复处理":那些消息已由子 agent 回复(或已回复"忙碌中"
// 主 agent 再处理一遍会让用户收到重复回复。
func offloadNotice(count int, residentID string) string {
return fmt.Sprintf(
"[系统] %d 条积压消息已在主 agent 忙期间交由临时助手 %s 先行分诊"+
"(简单的已直接处理并回复,需要你的那些已告知用户「忙碌中,请稍候」)。"+
"它们**不需要你再处理**了;若其中有需要你后续跟进的,请查看上述通道的会话记录。"+
"本提示仅用于说明情况,无需回复。",
count, residentID)
}
// offloadCandidate 是一条可被转投的排队任务。
//
// 只有**纯排队输入**TaskQueued + KindInput可转投
// - 中断任务带级别语义(可能正在等待抢占时机),转投会打乱中断阶梯;
// - self 任务是内核内部记账(记忆整理等),与父的记忆面绑定,不能换 agent。
type offloadCandidate struct {
Event *agentIO.InputEvent
}
// drainInboxToQueue 把 io 输入 channel 里**已经到达但尚未被搬运**的输入
// 搬进就绪队列(非阻塞;取空为止)。
//
// 它与 pumpInbox 做的事一样,但**不要求 hasRoom**pumpInbox 在队列满时
// 会停下以保留背压,而转投场景恰恰是「队列空/不满、但输入堵在 channel 里」
// (因为调度器正忙于执行任务、根本回不到 pumpInbox
//
// 队列上限仍由 enqueue 把关:满了就停下,超出的输入留在 channel 里。
func (a *Agent) drainInboxToQueue() {
for {
select {
case evt := <-a.io.InputChan():
if !a.sched.enqueue(newInputTask(evt)) {
// 队列满:放不进去。不能丢,也不能阻塞(我们是后台 goroutine
// 阻塞会把这个循环永远卡住)——给同步调用方一个终态后丢弃,
// 与 pumpInbox 的 queue_full 处置一致。
a.sched.noteBackpressure()
a.emitSkippedReply(evt, "queue_full")
return
}
default:
return
}
}
}
// 注:转投不走 inputch 名字(那会在投递时重建事件、丢掉 ResponseCh
// 而是直接跨 agent 推原事件 —— 见 forwardInputToResident。
// offloadPendingTasks 检查是否需要转投,需要则拉起/复用一个驻留子并搬运任务。
//
// 返回实际转投的任务条数0 = 未触发/未转投)。
//
// ❗并发前提:本函数会被 offloadLoop 在**任务执行期间**调用(那正是它的意义),
// 因此它与 schedulerLoop 是并发跑的。所有对队列的读写都经 scheduler 的锁,
// 而"取走哪些任务"与"放回什么"都在同一次锁内完成,不存在丢任务的窗口。
func (a *Agent) offloadPendingTasks(opts OffloadOptions) int {
opts = opts.normalized()
if !opts.Enabled {
return 0
}
// ① 判定:运行任务是否已忙够久。运行任务为空说明压根不忙,不做。
running := a.sched.runningTask()
if running == nil {
return 0
}
busyFor := a.sched.runningFor()
if busyFor < opts.BusyAfter {
return 0
}
// ② 先把积压从 io 的输入 channel **搬进就绪队列**。
//
// !!这是本特性最容易写错的一步(我第一版就错了,写完后线上实测永不触发):
// schedulerLoop 是**同步执行**任务的,所以「正忙」期间它根本不会回到循环顶部
// 去调 pumpInbox —— 这时后到的输入全部堆在 io.inputCh容量 256
// **压根没进 sched.queue**。只数 s.queue 会得到 0转投就永远不触发。
//
// 仓库里早记过同一个坑armStop 的注释写着「pending 是还没被 pumpInbox 搬进
// 队列的那一段……只数 s.queue 会得到 0实测配额随之失效」。
// 这里必须在同一层把这件事做对,而不是重犯。
a.drainInboxToQueue()
// ③ 收集可转投的排队任务;不够量就不值得拉起一个 agent。
cands := a.sched.takeQueuedInputs(opts.MinPending)
if len(cands) == 0 {
return 0
}
// ③ 找或拉起一个"转投专用"驻留子。
residentID, err := a.ensureOffloadResident(opts)
if err != nil {
// 拉不起来就把任务**放回队列**,绝不能丢:丢一条输入比多处理一条更糟
// (与 routeInputByOwner 的兜底同一条理由)。
a.sched.requeueFront(cands)
log.Printf("[offload] 无法为 %d 条积压任务准备驻留子,已放回队列: %v", len(cands), err)
return 0
}
// ④ 搬运:逐条投进子的 inputch。
//
// 注意这里**逐条转发原文**而不是打包成一条:任务本身带 Source/OutputChannel
// 等路由信息打包会让子无法把回复发回正确的通道qq 私聊 vs 群聊不同)。
var moved int
for _, c := range cands {
if c.Event == nil {
continue
}
if err := a.forwardInputToResident(residentID, c.Event); err != nil {
// 某条投不进去:放回原队列,其余继续(部分成功好过全部回滚)。
a.sched.requeueFront([]offloadCandidate{c})
log.Printf("[offload] 转发任务给驻留子 %s 失败,已放回队列: %v", residentID, err)
continue
}
moved++
}
if moved == 0 {
return 0
}
// ⑤ 在**原队列位置**留下提示(用户要求的那条说明)。
//
// 为什么留在队列里而不是只记日志:队列顺序就是主 agent 接下来要处理的事;
// 用户看会话记录时,需要在这里就看到"那几条去哪儿了",而不是去翻内核日志。
a.sched.requeueFront([]offloadCandidate{
{Event: a.syntheticEvent(offloadNotice(moved, residentID))},
})
log.Printf("[offload] 主 agent 已忙 %s把 %d 条积压任务转投给驻留子 %s队列留 1 条说明)",
busyFor.Truncate(time.Second), moved, residentID)
return moved
}
// forwardInputToResident 把一条输入**原文**投给指定驻留子的队列。
//
// ❗必须推**原事件**DeliverRouted不能重建原事件带 ResponseCh
// 而 cli / a2a / webui 这些**同步**调用方正阻塞等它。重建事件(如用
// InjectInputTo会把 ResponseCh 丢掉 ⇒ 任务被子处理完、调用方却永远收不到回执。
// 实测:转投生效、子也正常处理(各 ~3s 日志可见),但 HTTP 请求一直挂着不返回。
// 仓库反复警告同一件事(见 Agent.Stop 对 drainPendingInterrupts 的注释:
// “带 ResponseCh 的同步注入方会永久挂起”),这里必须走既有的跨 agent 投递原语。
//
// 用排队语义isInterrupt=false转投的是"待办工作",不是"打断子"。
func (a *Agent) forwardInputToResident(residentID string, evt *agentIO.InputEvent) error {
a.residentMu.Lock()
rc := a.residents[residentID]
a.residentMu.Unlock()
if rc == nil || rc.agent == nil || rc.agent.io == nil {
return fmt.Errorf("驻留子 %s 不存在或不可用", residentID)
}
// 带上来源线索(不重建事件,只补充 payload保留 ResponseCh/RequestID
if evt.Payload == nil {
evt.Payload = map[string]interface{}{}
}
evt.Payload["offloaded_from"] = string(a.id)
evt.Payload["offloaded_at"] = time.Now().Format(time.RFC3339)
rc.agent.io.DeliverRouted(evt, false)
return nil
}
// ensureOffloadResident 返回一个可用于转投的驻留子 id必要时拉起一个新的。
//
// 复用规则:优先复用"内核为转投而建"且仍 running、还没满的驻留子
// 都不可用时(在 MaxResidents 内)新建一个。
func (a *Agent) ensureOffloadResident(opts OffloadOptions) (string, error) {
a.residentMu.Lock()
var reusable []string
for id, rc := range a.residents {
if rc == nil || !rc.offloadOwned {
continue
}
rc.mu.Lock()
state := rc.state
rc.mu.Unlock()
if state == "running" {
reusable = append(reusable, id)
}
}
a.residentMu.Unlock()
// 复用:按 id 稳定排序后取第一个,避免每次挑到不同的子(可预测性)。
if len(reusable) > 0 {
sortStrings(reusable)
return reusable[0], nil
}
// 计数:只为转投而建的子是否已达上限(人工建的子不计入)。
a.residentMu.Lock()
owned := 0
for _, rc := range a.residents {
if rc != nil && rc.offloadOwned {
owned++
}
}
a.residentMu.Unlock()
if owned >= opts.MaxResidents {
return "", fmt.Errorf("转投专用驻留子已达上限 %d", opts.MaxResidents)
}
id := fmt.Sprintf("offload-%d", time.Now().Unix())
if a.dataDir == "" {
return "", fmt.Errorf("未配置 DataDir无法为驻留子分配 temp 图库路径")
}
info, err := a.SpawnResident(ResidentOptions{
ID: id,
// 不配 inputch它是内核的**干活** agent不接收任何插件的用户输入
// (用户要求"不配输入通道")。它只由父经转投拿到任务。
InputChs: nil,
// 全部输出通道:它要能把结果发回 qq/webui 等正确通道
// (用户要求"持有全部输出通道"。nil = 完整授权。
AllowedOutputs: nil,
TempPath: a.residentTempPath(id),
OffloadOwned: true,
TaskPrompt: offloadTaskPrompt(),
})
if err != nil {
return "", err
}
return info.ID, nil
}
// offloadTaskPrompt 是转投专用驻留子的**分诊职责**说明。
//
// 为什么必须给:不给的话子完全不知道自己为什么存在(只知道自己是"小宅"
// 拿到一条转投消息时不知道它是"用户正在等回复的请求"
// 也不知道自己只有两条路可走(直接办 / 报忙碌)。
//
// 用户的定位2026-09-19 明确):这不是"内核替父决定",而是**及时反馈** ——
// 主 agent 忙时不该让用户干等(实测有 13 分钟的现场)。
// 子的职责是**分诊**triage
// - 简单、不需主 agent 介入的 → 直接办完并回复;
// - 需要主 agent 介入的 → 立刻回「忙碌中,请稍候」,**不要勉强做**。
func offloadTaskPrompt() string {
return `你是主 agent 的临时助手,负责在主 agent 忙不过来时**分诊**它的积压消息。
背景:主 agent 正在执行一个长任务,短时间无法处理新消息。你被临时拉起,
专门承接这些积压的请求,**避免用户干等**(此前用户可能要等十几分钟)。
对每一条消息,你只有两条路:
1. 【直接办】如果这件事简单、明确、不需要主 agent 的全局上下文或长期规划
(例如:查个信息、跑个小命令、读个文件、简单问答)——
**直接做完,并把结果发回原通道**。
2. 【报忙碌】如果这件事需要主 agent 介入(需要它的长期记忆、正在进行的任务上下文、
需要它做多步决策,或你无法确定怎么做)——
**不要勉强尝试**。立刻回复用户:主 agent 当前忙碌中,请稍候。
重要约束:
- **必须把回复发到用户原本的通道**。面向 qq、wechat 等异步通道时,
纯文本返回会被丢弃 —— 必须显式调用 output_send__{通道名},否则用户收不到,
而你会以为已经回过了。
- 不要向用户暴露"我是被临时拉起的助手"这类内部细节,用主 agent 的口吻回复。
- 拿不准属于哪一类时,选【报忙碌】。宁可让用户稍后得到准确答复,
也不要给出错误的直接回答。`
}
// residentTempPath 计算某个驻留子的 temp 图记忆路径(与既有约定一致)。
func (a *Agent) residentTempPath(id string) string {
return strings.TrimRight(a.dataDir, "/") + "/residents/" + id + "/graph.db"
}
// syntheticEvent 造一条"内核自己发的"输入事件(用于队列里的转投说明)。
//
// Source 取 kernel这条消息不是任何用户发来的日志与用户界面里都应看得出。
// 不带 ResponseCh没有同步调用方在等它它只是给主 agent 看的一句说明)。
func (a *Agent) syntheticEvent(text string) *agentIO.InputEvent {
return &agentIO.InputEvent{
Source: "kernel",
Type: "text",
OutputChannel: "kernel",
Payload: map[string]interface{}{"content": text},
}
}
// sortStrings 是一个不引入 sort 依赖的小排序(候选集极小,插入排序足够)。
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j] < s[j-1]; j-- {
s[j], s[j-1] = s[j-1], s[j]
}
}
}
// offloadLoop 周期性检查「主 agent 是否被长任务占住 + 是否有积压」。
//
// 为什么必须是**独立 goroutine**而不是 schedulerLoop 里的一步:
//
// schedulerLoop 是**同步执行**任务的executeNewTask 会一直阻塞到任务结束),
// 所以「正忙」期间它根本不会回到循环顶部 —— 把检查放在那里等于永不触发。
// 这正是本特性存在的理由(主 agent 忙时无人处理积压),不能在实现上重犯。
//
// 检查间隔取 BusyAfter 的 1/5不低于 1 秒):保证在跨过阈值后能在合理时间内
// 触发,又不至于空转打日志。
func (a *Agent) offloadLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] offloadLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.offloadLoop()
}
}()
if !a.offload.Enabled {
return // 未启用:不占 goroutine也不打日志默认关闭是常态
}
// 只让**根 agent** 做转投:驻留子自己也可能忙,但让子再去拉孙子会形成
// 无界增殖(每层都能拉 MAX 个),而积压的源头是根那一条调度链。
if a.parentID != "" {
return
}
interval := a.offload.BusyAfter / 5
if interval < time.Second {
interval = time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.offloadPendingTasks(a.offload)
case <-a.ctx.Done():
return
}
}
}

View File

@ -0,0 +1,698 @@
package core
import (
"path/filepath"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// newRootWithoutSchedulerLoop 造一个**不启动后台循环**的根 agent。
//
// 为什么测试必须用它newRootWith 会 a.Start(),于是真实的 schedulerLoop
// 与测试**并发**跑,它会瞬间把测试排进队列的任务执行掉并清空 running
// ⇒ "主 agent 正忙"这个前提会被后台循环消掉,转投判定随机失效
// (实测:同一测试两次运行结果不同,一个过一个不过)。
// 本特性测的是**判定 + 搬运**这两步的语义,不需要真的把任务跑起来。
func newRootWithoutSchedulerLoop(t *testing.T) (*Agent, *memory.GraphDB) {
t.Helper()
dir := t.TempDir()
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
a := New(AgentConfig{
ID: "parent",
Provider: &countingProvider{},
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
Memory: main,
DataDir: dir,
})
t.Cleanup(func() { a.Stop(); main.Close() })
return a, main
}
// makeQueuedInput 造一条排队输入任务Event 非空Class=TaskQueued
func makeQueuedInput(id int) *Task {
return newInputTask(&agentIO.InputEvent{
RequestID: "req",
Source: "qq",
Type: "text",
OutputChannel: "qq",
Payload: map[string]interface{}{"content": "hello"},
})
}
// 转投只应该动**纯排队输入**中断任务带级别语义、self 任务是内核内部记账,
// 搬走它们会分别破坏中断阶梯与记忆整理。
func TestTakeQueuedInputsOnlyTakesQueuedInputs(t *testing.T) {
s := newScheduler(64)
// 混合1 条排队输入 + 1 条中断 + 1 条 self + 3 条排队输入
s.enqueue(makeQueuedInput(1))
s.enqueue(newInterruptTask(&agentIO.InputEvent{Source: "qq", OutputChannel: "qq"}, LevelMessage))
s.enqueue(newSelfTask(selfInputMsg{text: "distill", channel: "cli"}))
s.enqueue(makeQueuedInput(2))
s.enqueue(makeQueuedInput(3))
s.enqueue(makeQueuedInput(4))
if len(s.queue) != 6 {
t.Fatalf("就绪队列应有 6 条4 排队输入 + 1 中断 + 1 self实际 %d", len(s.queue))
}
got := s.takeQueuedInputs(3)
if len(got) != 3 {
t.Fatalf("应取走 3 条排队输入,实际 %d", len(got))
}
for _, c := range got {
if c.Event == nil {
t.Fatal("取出的候选不得为空事件")
}
}
// self 与中断必须还在
var hasSelf, hasInterrupt bool
for _, tt := range s.queue {
if tt.Kind == TaskKindSelf {
hasSelf = true
}
if tt.Class == TaskInterrupt {
hasInterrupt = true
}
}
if !hasSelf {
t.Error("self 任务被误取(会破坏记忆整理)")
}
if !hasInterrupt {
t.Error("中断任务被误取(会破坏中断阶梯)")
}
}
// 不够量时**一条都不取**:拉起一个 agent 的成本不该为一条任务付。
// 这条保证「要么不动、要么成批移动」。
func TestTakeQueuedInputsIsAllOrNothing(t *testing.T) {
s := newScheduler(64)
s.enqueue(makeQueuedInput(1))
s.enqueue(makeQueuedInput(2))
if got := s.takeQueuedInputs(3); got != nil {
t.Fatalf("不足 3 条时不应取走任何任务,实际取走 %d", len(got))
}
if len(s.queue) != 2 {
t.Errorf("队列不应被改动,实际剩 %d", len(s.queue))
}
}
// ★ 安全不变量:转投失败必须把任务**放回队列**。
// 吞掉一条输入比多处理一条更糟——用户会看到"消息发出去了却没人理"。
func TestRequeueFrontKeepsAllTasks(t *testing.T) {
s := newScheduler(64)
s.enqueue(makeQueuedInput(1))
s.enqueue(makeQueuedInput(2))
s.enqueue(makeQueuedInput(3))
taken := s.takeQueuedInputs(3)
if len(taken) != 3 {
t.Fatalf("应取走 3 条,实际 %d", len(taken))
}
if len(s.queue) != 0 {
t.Fatalf("取走后队列应空,实际 %d", len(s.queue))
}
s.requeueFront(taken)
if len(s.queue) != 3 {
t.Fatalf("★ 放回后必须一条不少:期望 3实际 %d", len(s.queue))
}
// 放回的是**前端**:它们比队列里原有的一切都早
s.enqueue(makeQueuedInput(4))
if s.queue[len(s.queue)-1].Event.Payload["content"] != "hello" {
t.Error("放回的任务应在队列前端")
}
}
// 转投说明必须自己说清是系统做的:用户看到队列里出现一条没人发过的消息时,
// 唯一能解释这件事的就是这句话本身。
func TestOffloadNoticeExplainsItself(t *testing.T) {
msg := offloadNotice(3, "offload-123")
// 用词按用户口径:这是**分诊**(及时反馈),不是"内核替父决定"。
for _, want := range []string{"系统", "3 条", "offload-123", "分诊", "不需要你再处理"} {
if !strings.Contains(msg, want) {
t.Errorf("说明缺少 %q%s", want, msg)
}
}
}
// 默认必须是**关闭**:自动拉起是内核替父做决策(设计 §7 的例外),
// 不能默默改变系统行为。
func TestOffloadDisabledByDefault(t *testing.T) {
opts := DefaultOffloadOptions()
if opts.Enabled {
t.Error("默认必须关闭")
}
s := newScheduler(64)
s.enqueue(makeQueuedInput(1))
s.enqueue(makeQueuedInput(2))
s.enqueue(makeQueuedInput(3))
a := &Agent{sched: s}
if n := a.offloadPendingTasks(opts); n != 0 {
t.Errorf("关闭时不得转投,实际转了 %d", n)
}
if len(s.queue) != 3 {
t.Errorf("关闭时队列不得被改动,实际 %d", len(s.queue))
}
}
// 不忙(无运行任务)时不转投:没有"长任务占住"这个前提,排队就是正常的。
func TestOffloadSkippedWhenIdle(t *testing.T) {
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 1
s := newScheduler(64)
s.enqueue(makeQueuedInput(1))
a := &Agent{sched: s}
if n := a.offloadPendingTasks(opts); n != 0 {
t.Errorf("空闲时不应转投,实际 %d", n)
}
}
// ★ 端到端:主 agent 忙时,积压任务应真的被搬到驻留子,且队列里留下说明。
// 这是本特性的核心行为 —— 只测"判定函数返回 0/非 0"不够,
// 必须证明任务**换了 agent 且原队列留下了可读的交代**。
func TestOffloadMovesTasksToResidentEndToEnd(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond // 立即算"忙"
opts.MinPending = 2
opts.MaxResidents = 1
// 伪造"正在跑一条长任务":转投判定要求 running 非空。
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef() // 把它变成 running
// 再排 2 条积压
root.sched.enqueue(makeQueuedInput(2))
root.sched.enqueue(makeQueuedInput(3))
moved := root.offloadPendingTasks(opts)
if moved != 2 {
t.Fatalf("应转投 2 条,实际 %d", moved)
}
// ① 确实拉起了一个驻留子,且标记为"为转投而建"
list := root.Residents()
if len(list) != 1 {
t.Fatalf("应拉起 1 个驻留子,实际 %d", len(list))
}
resident := list[0]
if !strings.HasPrefix(resident.ID, "offload-") {
t.Errorf("驻留子应为转投专用命名,实际 %s", resident.ID)
}
// ② 它不配任何插件 inputch用户要求但持有全部输出通道nil=全授权)
if len(resident.InputChs) != 0 {
t.Errorf("转投驻留子不应配 inputch实际 %v", resident.InputChs)
}
if len(resident.AllowedOutputs) != 0 {
t.Errorf("转投驻留子应持有全部输出通道(空=全授权),实际 %v", resident.AllowedOutputs)
}
t.Logf("驻留子 %s: inputch=%v outputs=%v", resident.ID, resident.InputChs, resident.AllowedOutputs)
// ③ 原队列里留下说明(且说明是内核发的)
if len(root.sched.queue) != 1 {
t.Fatalf("原队列应只剩 1 条说明,实际 %d", len(root.sched.queue))
}
notice := root.sched.queue[0]
if notice.Event == nil || notice.Event.Source != "kernel" {
t.Fatalf("留下的应是内核说明,实际 %+v", notice.Event)
}
content, _ := notice.Event.Payload["content"].(string)
for _, want := range []string{"2 条", resident.ID, "分诊"} {
if !strings.Contains(content, want) {
t.Errorf("说明缺少 %q%s", want, content)
}
}
t.Logf("队列说明: %s", content)
}
// 达到上限后不得无界增殖:每个 tick 都拉一个新子会把机器拖垮。
func TestOffloadRespectsResidentCap(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 1
opts.MaxResidents = 1
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
// 第一轮:拉起 1 个
root.sched.enqueue(makeQueuedInput(2))
if n := root.offloadPendingTasks(opts); n != 1 {
t.Fatalf("第一轮应转 1 条,实际 %d", n)
}
// 第二轮:已达上限,但**会复用**刚建的那个子,所以仍能转投
root.sched.enqueue(makeQueuedInput(3))
if n := root.offloadPendingTasks(opts); n != 1 {
t.Fatalf("第二轮应复用已有驻留子,实际转 %d", n)
}
if got := len(root.Residents()); got != 1 {
t.Fatalf("★ 不得越过上限增殖:期望 1 个驻留子,实际 %d", got)
}
}
// ★ 回归:检查必须发生在**独立 goroutine** 里。
//
// schedulerLoop 是同步执行任务的executeNewTask 阻塞到任务结束),
// 所以"正忙"期间它根本不会回到循环顶部 —— 把检查放在那里的实现
// 永远不会触发(我第一版就是这么写的,测出来才发现)。
// 本测试钉死offloadLoop 确实起了自己的 goroutine 并能被唤醒干活。
func TestOffloadLoopRunsWhileBusy(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
root.offload = OffloadOptions{
Enabled: true, BusyAfter: 10 * time.Millisecond,
MinPending: 1, MaxResidents: 1,
}
// 伪造"正忙":直接占住 running不启动真实调度循环避免它把任务跑掉
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
root.sched.enqueue(makeQueuedInput(2))
go root.offloadLoop() // 独立 goroutine正是被测的点
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if len(root.Residents()) > 0 {
return // 成功:忙时后台循环把积压转走了
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("offloadLoop 在忙时没有转投:检查没有跑在独立 goroutine 里?")
}
// ★★ 回归:积压可能**全在 io 输入 channel 里**,不在 sched.queue。
//
// 这是本特性最容易写错、而且我在线上真踩了的一步schedulerLoop 是同步执行
// 任务的,所以「正忙」期间它根本回不到 pumpInbox —— 后到的输入全堆在
// io.inputCh容量 256sched.queue 恒为 0。
//
// 只数 s.queue 的实现在线上**永不触发**(实测:主 agent 跑着 6×45s 的任务、
// 我连发 4 条消息,队列始终显示 0、residents 始终 0
// 仓库里 armStop 早记过同一个坑("只数 s.queue 会得到 0"),这里钉死不重犯。
func TestOffloadSeesInputsStuckInChannel(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 3
opts.MaxResidents = 1
// 伪造"正忙"
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
// 关键:把 3 条消息注入 **io 输入 channel**,不碰 sched.queue。
// 这精确复现"调度器忙于执行任务、pumpInbox 没被调用"的现场状态。
for i := 0; i < 3; i++ {
root.io.InjectInputTo("webui", "webui", "text",
map[string]interface{}{"content": "stuck"})
}
if len(root.sched.queue) != 0 {
t.Fatalf("前置条件:此时 sched.queue 应为 0输入还没被搬运实际 %d", len(root.sched.queue))
}
if root.io.PendingInputs() != 3 {
t.Fatalf("前置条件:输入应堆在 channel 里,实际 %d", root.io.PendingInputs())
}
// 转投必须能看到它们(先搬进队列再取)
moved := root.offloadPendingTasks(opts)
if moved != 3 {
t.Fatalf("★ 堆在 channel 里的积压必须被看见并转投:期望 3实际 %d", moved)
}
if len(root.Residents()) != 1 {
t.Fatalf("应拉起 1 个驻留子,实际 %d", len(root.Residents()))
}
}
// ★★ 回归:转投必须保留 ResponseCh否则同步调用方永久挂起。
//
// 这是我在线上真踩的第二个 bug第一版用 InjectInputTo 重建事件 ⇒ ResponseCh
// 被丢掉 ⇒ 日志显示子**正常处理完了**(各 ~3s但 webui 的 HTTP 请求一直挂着
// 不返回,最终 504。仓库反复警告同一件事Agent.Stop 的注释:"带 ResponseCh 的
// 同步注入方cli / clawhubadapter 均无超时)会永久挂起")。
//
// 正确做法是走既有的跨 agent 投递原语 DeliverRouted它推**原事件**。
//
// 断言方式是**最强的那个**:真的等同步回执回来。
// (不用读子的 InputChan 来断言SpawnResident 会启动子自己的调度循环,
//
// 它会与测试抢同一个 channel —— 那样写出来的测试是 flaky 的,实测过一次挂死。)
func TestForwardKeepsResponseCh(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 1
opts.MaxResidents = 1
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
// 一条**带同步回执通道**的输入(模拟 cli/webui 这类调用方)
respCh := make(chan *agentIO.OutputEvent, 1)
evt := &agentIO.InputEvent{
RequestID: "sync-1", Source: "webui", Type: "text",
OutputChannel: "webui",
Payload: map[string]interface{}{"content": "sync request"},
ResponseCh: respCh,
}
root.sched.enqueue(newInputTask(evt))
if n := root.offloadPendingTasks(opts); n != 1 {
t.Fatalf("应转投 1 条,实际 %d", n)
}
// 转投的是**同一个事件对象**(所以 payload 上的标注能在这里被看到),
// 而不是重建的副本 —— 副本会丢掉 ResponseCh。
if evt.Payload["offloaded_from"] != "parent" {
t.Errorf("应标注转投来源,实际 %v", evt.Payload["offloaded_from"])
}
if evt.ResponseCh == nil {
t.Fatal("★ 原事件的 ResponseCh 被清掉了")
}
// ★ 决定性断言:同步调用方真的收到回执。
select {
case out := <-respCh:
if out == nil {
t.Fatal("收到空回执")
}
if out.RequestID != "sync-1" {
t.Errorf("回执应带原 RequestID实际 %q", out.RequestID)
}
case <-time.After(10 * time.Second):
t.Fatal("★ 同步调用方没收到回执:转投丢了 ResponseCh线上表现为 HTTP 挂起 504")
}
}
// ★★ 回归:转投子必须拿到**分诊职责**提示词。
//
// 我第一版没给 TaskPrompt于是子完全不知道自己为什么存在只知道自己叫小宅
// 用户对这个特性的定位是**及时反馈**:主 agent 忙时不能让用户干等十几分钟
// (实测现场 785,951ms。子的职责是分诊 —— 简单的直接办,需要主 agent 的
// 立刻回「忙碌中,请稍候」,而不是勉强作答。
func TestOffloadResidentGetsTriagePrompt(t *testing.T) {
p := offloadTaskPrompt()
for _, want := range []string{"分诊", "直接办", "忙碌中", "output_send"} {
if !strings.Contains(p, want) {
t.Errorf("分诊提示词缺少 %q", want)
}
}
// 拿不准时的默认动作必须是保守的那条(报忙碌),不能是"勉强作答"
if !strings.Contains(p, "选【报忙碌】") {
t.Error("必须写明拿不准时选报忙碌(避免给用户错误答复)")
}
}
// ★★ 回归:驻留子必须**继承父的 SystemPrompt**。
//
// 我第一版没传 SystemPrompt子只能用 buildSystemPrompt 的一句兜底文案。
// 而父的提示词里有「回复投递规则」:面向 qq/wechat 等**异步**通道时,
// 纯文本返回会被静默丢弃,必须显式 output_send__{通道名}。
// 缺了它,子处理完 QQ 积压却发不出去且自己不会意识到实测webui 这类
// **同步**通道能回是因为走 ResponseCh掩盖了这个缺陷
func TestResidentInheritsParentSystemPrompt(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
root.systemPrompt = "父的提示词:异步通道必须显式 output_send"
info, err := root.SpawnResident(ResidentOptions{
ID: "inherit-test", TempPath: root.residentTempPath("inherit-test"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
rc := root.residents[info.ID]
root.residentMu.Unlock()
if rc == nil || rc.agent == nil {
t.Fatal("驻留子不可用")
}
if rc.agent.systemPrompt != root.systemPrompt {
t.Fatalf("★ 驻留子未继承父的 SystemPrompt子=%q 父=%q",
rc.agent.systemPrompt, root.systemPrompt)
}
// 真正要看的是提示词里确实带上了投递规则
built := rc.agent.buildSystemPrompt("", "x")
if !strings.Contains(built, "output_send") {
t.Errorf("子拼出的系统提示词里没有投递规则:%s", built)
}
}
// ★★ 残余任务必须由父**显式**决定保留还是丢弃(用户 2026-09-19 要求)。
//
// 现场问题:回收/销毁驻留子时它手头可能还有没处理的消息。异步通道qq
// 没有 ResponseCh静默丢弃时用户零反馈、日志也无痕迹 —— 消息就像没发过一样。
// 所以内核只负责「把残余任务列清楚」,处置由父的模型决定(设计 §7
func TestResidualKeepReturnsTasksToParent(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-keep", TempPath: root.residentTempPath("res-keep"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
child := root.residents[info.ID].agent
root.residentMu.Unlock()
// 给子塞两条尚未处理的残余任务(一条带同步回执、一条不带=模拟 qq
syncCh := make(chan *agentIO.OutputEvent, 1)
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "r1", Source: "webui", OutputChannel: "webui",
Payload: map[string]interface{}{"content": "a"}, ResponseCh: syncCh,
}))
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "r2", Source: "qq", OutputChannel: "qq",
Payload: map[string]interface{}{"content": "b"},
}))
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
if err != nil {
t.Fatalf("ApplyResidual(keep): %v", err)
}
if n != 2 {
t.Fatalf("应处置 2 条,实际 %dmsg=%s", n, msg)
}
// keep = 转回父自己:两条都要出现在父的队列里,且**带 ResponseCh 的那条仍带**
if len(root.sched.queue) != 2 {
t.Fatalf("两条残余任务应转回父队列,实际 %d", len(root.sched.queue))
}
var hasResponseCh bool
for _, task := range root.sched.queue {
if task.Event != nil && task.Event.ResponseCh != nil {
hasResponseCh = true
}
}
if !hasResponseCh {
t.Error("★ keep 丢了 ResponseCh同步调用方会永久挂起")
}
// keep 后子队列应清空(已交出去):再取一次应为空
if left, _ := root.TakeResidual(info.ID); len(left) != 0 {
t.Errorf("交接后子队列应清空,实际剩 %d", len(left))
}
}
// drop 也必须给同步调用方一个终态,否则 cli/a2a 会永久挂起。
func TestResidualDropNotifiesSyncCaller(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-drop", TempPath: root.residentTempPath("res-drop"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
root.residentMu.Lock()
child := root.residents[info.ID].agent
root.residentMu.Unlock()
respCh := make(chan *agentIO.OutputEvent, 1)
child.sched.enqueue(newInputTask(&agentIO.InputEvent{
RequestID: "d1", Source: "cli", OutputChannel: "cli",
Payload: map[string]interface{}{"content": "x"}, ResponseCh: respCh,
}))
n, _, err := root.ApplyResidual(info.ID, ResidualDrop)
if err != nil {
t.Fatalf("ApplyResidual(drop): %v", err)
}
if n != 1 {
t.Fatalf("应处置 1 条,实际 %d", n)
}
select {
case out := <-respCh:
if out == nil || !out.Done {
t.Error("drop 应给同步调用方一个终态")
}
case <-time.After(3 * time.Second):
t.Fatal("★ drop 未通知同步调用方cli/a2a 会永久挂起")
}
// drop 后父队列不应多出东西
if len(root.sched.queue) != 0 {
t.Errorf("drop 不应把任务转回父队列,实际 %d", len(root.sched.queue))
}
}
// 无残余任务时应明确说"无",而不是让父以为丢了什么。
func TestResidualEmptyIsReported(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
info, err := root.SpawnResident(ResidentOptions{
ID: "res-empty", TempPath: root.residentTempPath("res-empty"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
n, msg, err := root.ApplyResidual(info.ID, ResidualKeep)
if err != nil {
t.Fatalf("ApplyResidual: %v", err)
}
if n != 0 || msg != "无残余任务" {
t.Errorf("应报告无残余任务,实际 n=%d msg=%q", n, msg)
}
}
// ★ 回归offload_owned 必须真的**被填上**。
//
// 我第一版加了字段、加了状态面映射,却漏了在 rc.info() 里赋值 ⇒ 父读到的
// 永远是 false实测线上转投子明明存在offload_owned 却是 null
// 这类"加了字段但没接线"的缺陷不会报错,只会让上层判断悄悄失效。
func TestOffloadOwnedIsReported(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
// 人工建的子offload_owned 应为 false
manual, err := root.SpawnResident(ResidentOptions{
ID: "manual-child", TempPath: root.residentTempPath("manual-child"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
if manual.OffloadOwned {
t.Error("人工创建的子不应被标记为 offload_owned")
}
// 内核为转投拉起的子(应为 true
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 1
opts.MaxResidents = 1
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
root.sched.enqueue(makeQueuedInput(2))
if n := root.offloadPendingTasks(opts); n != 1 {
t.Fatalf("应转投 1 条,实际 %d", n)
}
list := root.Residents()
var foundOffload *ResidentInfo
for i := range list {
if strings.HasPrefix(list[i].ID, "offload-") {
foundOffload = &list[i]
}
}
if foundOffload == nil {
t.Fatal("未找到转投子")
}
if !foundOffload.OffloadOwned {
t.Error("★ 转投子必须被标记 offload_owned=true父据此决定回收策略")
}
}
// ★★ 回归:内核自己留的说明**不能再被转投**,否则自我循环。
//
// 我第一版漏了这一步:转投会在队列里留一条 [系统] 说明source=kernel
// 而转投条件("排队输入够了")又会把这条说明算进去 ⇒ 每次转投都产生下一轮
// 要转投的东西。实测5 秒内连续触发两次,分诊助手不断收到这类噪音。
func TestKernelNoticeIsNeverOffloaded(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 1
opts.MaxResidents = 1
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
// 队列里放一条"内核说明"+ 一条真实积压
root.sched.enqueue(newInputTask(root.syntheticEvent("2 条积压已交由临时助手分诊")))
root.sched.enqueue(makeQueuedInput(2))
moved := root.offloadPendingTasks(opts)
if moved != 1 {
t.Fatalf("★ 只应转投真实积压 1 条(说明不可转投),实际 %d", moved)
}
// 说明必须还在队列里(留给主 agent 看),不能被搬走
var noticeLeft bool
for _, task := range root.sched.queue {
if task.Event != nil && isKernelNotice(task.Event) {
noticeLeft = true
}
}
if !noticeLeft {
t.Error("内核说明应留在队列里给主 agent 看,不应被转投走")
}
}
// 转投自身产生的说明也不能构成下一轮的积压(循环必须终止)。
func TestOffloadDoesNotLoopOnOwnNotice(t *testing.T) {
root, main := newRootWithoutSchedulerLoop(t)
defer main.Close()
opts := DefaultOffloadOptions()
opts.Enabled = true
opts.BusyAfter = time.Nanosecond
opts.MinPending = 2
opts.MaxResidents = 1
root.sched.enqueue(makeQueuedInput(1))
root.sched.nextRef()
root.sched.enqueue(makeQueuedInput(2))
root.sched.enqueue(makeQueuedInput(3))
if n := root.offloadPendingTasks(opts); n != 2 {
t.Fatalf("第一轮应转 2 条,实际 %d", n)
}
// 再调若干次:队列里只剩一条说明,不够 MinPending ⇒ 不该再转
for i := 0; i < 5; i++ {
if n := root.offloadPendingTasks(opts); n != 0 {
t.Fatalf("第 %d 次仍在转投(自我循环):转了 %d 条", i+1, n)
}
}
if got := len(root.Residents()); got != 1 {
t.Errorf("不应反复拉起新子,实际 %d 个", got)
}
}

View File

@ -7,9 +7,9 @@ import (
)
const (
maxPluginCrashes = 3
crashWindow = 5 * time.Minute
reloadCooldown = 30 * time.Second
maxPluginCrashes = 3
crashWindow = 5 * time.Minute
reloadCooldown = 30 * time.Second
)
type pluginHealthTracker struct {

Some files were not shown because too many files have changed in this diff Show More