148 Commits

Author SHA1 Message Date
918f29899c chore(version): release/v1.3.x 路牌推到 1.3.6(本版内容:人格文本去版本化 + 存量迁移) 2026-09-13 14:34:32 +08:00
c4659342b3 fix(config): 播种时不再把版本号写进人格文本 + 存量实例一次性去版本化
用户发现:agent 自报版本 **1.0.3**,内核早已 1.3.x。

根因(两层):
1. `SeedDefaults` 当年用 `fmt.Sprintf("…HΔ-Kernel v%s…", meta.Version)` **在播种时**
   就把版本写进了 `core.agent.system_prompt` —— 装完即冻住,之后每次升级都不会
   去改配置里的文本,于是实例终生自称装机那天的版本。默认模板用 meta.Version
   插值本是"不写死"的做法,但**播种 = 把插值结果固化**,等于写死。
2. `core.agent.personal_prompt`(新人格机制)本身是对的(DefaultPersonaPrompt
   无版本字面量,由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住),
   但旧键仍在系统提示词里说话,模型就照抄旧键的版本。

改动:
- **不再播种** `core.agent.system_prompt`:留空 → 组装时取 cmd/homed 的内置底座
  提示词;人格由 personal_prompt 承载。全新安装不再预置会腐坏的文本。
- 新增 `migrateSeededSystemPrompt()`(在 SeedDefaults 最前,故不被播种标记早退):
  只对"当年那段播种模板"(前缀 + `HΔ-Kernel v<数字>` 字面量双判据)做
  `v<数字>` → `v{{kernel_version}}`;用户自己写的人格卡一律不碰。
  一次性标记 `core.internal.system_prompt_deversion_v1` 守住幂等 ——
  幂等语句不等于语义幂等,重复执行会把用户后来手写的版本号也改掉。
- 与 v1.3.5 的占位符展开配套:占位符在组装系统提示词时按真实构建展开。

回归判据 4 条(`prompt_migration_test.go`):存量卡被去版本化且正文不动 /
迁移只跑一次 / 用户自写卡不动 / 全新安装不播种该键。
2026-09-13 14:34:15 +08:00
02600a3b90 chore(version): release/v1.3.x 路牌推到 1.3.5(本版内容:系统提示词版本占位符)
发布线的 meta.Version 始终是**该线已发的最后一个 patch**(release/v1.2.x 末态为 1.2.2)。
1.3.1–1.3.4 我是用 -ldflags -X 把版本打进二进制的,文件里的路牌一直停在 1.3.0 ——
源码与产出的版本号对不上账,属于偏离规范,本次一并纠正。
2026-09-13 14:31:14 +08:00
02861f5d64 fix(prompt): 系统提示词支持版本占位符 —— 人格卡不再写死版本号
现象(用户发现):agent 向用户自报版本是 **1.0.3**,而内核早已 1.3.x。
根因:**人格卡是配置项**,线上 `core.agent.system_prompt` 里写死了
「HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手」——那是当年装机的文本,
之后每次发版都不会去改它,模型于是照抄给用户。默认模板用 `meta.Version`
拼接(`registry.go` 的 `fmt.Sprintf`)所以一直是对的,**只要被自定义过就会漂**。

修法:在 `buildSystemPrompt` 组装处展开占位符,让这类文本跟随真实构建:

  {{kernel_version}} → meta.Version(如 1.3.5)
  {{kernel_commit}}  → 构建 commit
  {{sdk_version}}    → 所兼容 SDK 版本(如 1.3.0)

- 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串;
- 不含 `{{` 时原样返回(提示词在热路径上);
- 覆盖所有路径:主 agent 与驻留子都经 `buildSystemPrompt`,
  `persona_set` 写入的文本同样在读取时展开(存的是模板,不是渲染结果);
- 配置项描述里写明可用占位符,引导用户别再写死版本。

回归判据 `TestExpandPromptVars`:展开正确 / 内置占位符不残留 /
线上真实人格卡文本能被纠正 / 未知占位符不被吞 / 无占位符不改写。
2026-09-13 14:31:13 +08:00
cea8011f3d feat(pluginmgr): plugin_install 支持本机 path(配合 plugindev_build 的产物)
背景:Agent 现在能自己构建插件了(plugindev 插件封装了 hmapdev),但安装只支持
http(s) URL —— 本地刚构建出来的 `dist/*.hmap` 装不上,链路断在最后一步。
pluginmgr 的 HTTP API 本来就接受 `{path}`(installFromPath),只是工具面没暴露。

改动:`plugin_install` 增加可选 `path`(本机 .hmap 路径),与 `url` 二选一,
同时给出时以 `path` 为准;`path` 必须存在且不是目录。描述里写明
「配合 plugindev_build 的产物用这个」。

于是 Agent 的完整闭环成立:
  plugindev_init → plugindev_build → plugin_install(path) → plgreload
2026-09-13 14:17:08 +08:00
68835c18db perf(memory): 静态词向量改用 float32 存储(省 ~0.65GB 常驻)
生产实测:`[static_embedder] loaded 200000 words`(zh) + `378151 words`(en) = 57.8 万词 × 300 维,
`map[string][]float64` 光向量本体就 **1.29GB**(外加 map 开销 ~0.1-0.2GB),占 homed
4.14GB RSS 的约三分之一。

源数据(fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益:
- `words map[string][]float32` / `unkVec []float32`;
- 加载时按 `ParseFloat(..., 32)` 解析(与源精度一致);
- 相似度累加仍在 float64(`sum []float64`,读时提升),计算精度不受影响。

⇒ 向量本体 1.29GB → 0.65GB,**省 0.65GB**。(与配置侧 `#topN` 可叠加:
生产把两份 vec 各限 5 万词后,向量降到 ~0.22GB。)

防复发:`TestStaticEmbedder_VectorMemIsFloat32` 用**编译期类型断言**
(`var typed []float32 = vec`)+ 字节数断言(词数×维数×4)钉住 —— 改回 float64 会直接编译失败。

验证:`go test ./internal/memory/ ./internal/agent/core/ ./internal/nlp/` 全绿。
2026-09-13 14:09:31 +08:00
89db544671 fix(plugin): "只声明出站通道"的告警改为插件加载完成后判定(此前按注册顺序误报 qq)
## 现象

生产日志(v1.3.1 启动)出现:
`[plugin] qq 只声明了输出通道 "qq",已按双向通道兜底登记 inputch;若要明确意图请显式 RegisterInputChannel`
用户据此问"qq 插件你没更新?"

## 查证:qq 没漏,是我的判据错了

- SDK 示例 `example/qq/plugin.go`:`RegisterOutputChannel("qq")` 在 368 行、
  `RegisterInputChannel("qq", {NoMemory:true, Cleaner: inputCleaner})` 在 399 行 —— **先出站后入站**;
- 生产 `plugins/qq/plugin.bin`:版本 1.4.0,且二进制里含 `inputCleaner` 痕迹 ⇒ 确实调用了入站声明;
- 我的兜底告警在 **RegisterOutputChannel 的那一刻**判"有没有入站声明" ⇒ 对"先出站后入站"
  这种完全合法的写法必然误报(a2a/acp/weather 同理)。

## 修法

告警判据从"注册时刻"改为"**插件 Start 结束后最终声明了什么**":

- `regOutput` 只保留兜底登记(功能不变),不再告警;
- 新增 `warnOutputOnlyChannels(plugin)`,在插件加载/重载成功后统一判定:
  遍历该插件**最终**声明过的出站通道,只有始终没有对应入站声明的才告警,
  且措辞改为"内核已兜底登记 inputch,若这是有意为之可忽略"。
- 判据与顺序解耦后,告警才代表真实缺口(例:weather 的 `weather_out` 与
  `weather_in` 名字不同,出站名从未被声明为入站 —— 那条告警就是真的)。

## 验证

- 新增 `TestWarnOutputOnlyChannels`:①先出站后入站(qq 写法)**不告警**;
  ②只声明出站(weather 写法)**告警且只报那一个通道**。
- `go test ./internal/plugin/ ./internal/plugins/...` 全绿。
2026-09-13 13:40:46 +08:00
a021055011 chore(sdk-mirror): 同步 SDK 仓 v1.3.1 的文档 —— 通道名会进 LLM 函数名(命名约束)
镜像文件:`third_party/homeagent-sdk/sdk/plugin.go`(`RegisterOutputChannel` 的命名约束)。
SDK 仓对应提交/tag:v1.3.1。

说明:内核 v1.3.1 的 tag 已指向功能修复提交 d17c186,本镜像提交在其之后 ——
文档镜像不参与二进制构建,故不影响已部署产物;功能与文档的对应关系见两仓 tag 说明。
2026-09-13 13:10:25 +08:00
d17c18665c fix(remotedevice): 设备通道名改用 - 分隔并派生合规名(v1.3.0 部署后 agent 完全不应答的根因)
## 事故

v1.3.0 部署到生产后,**整个 agent 不应答**:任何对话都返回
`all 3 providers failed, last error: api error 403: model "claude-opus-5" is not allowed for this key`。
回滚到 1.2.2 立即恢复(部署前 403=0/成功对话=10,部署后 403=5/成功对话=0)。

## 根因(网关日志给出的原文)

```
tier 3 gozen/deepseek-v4.1-flash: api error 400: [invalid_request_error]
  Invalid 'tools[299].function.name': string does not match pattern '^[a-zA...
```

设备的每设备输出通道名叫 `device/<id>`,内核按 `output_send__<通道名>` 生成工具 ⇒
`output_send__device/<id>` 里的 `/` 违反上游函数名规范 `^[a-zA-Z0-9_-]{1,64}$`。
上游不是"拒掉这一个工具",而是**整条请求 400** ⇒ 网关 auto tier 全链条失败
(400/429/503 混在一起)⇒ 内核只能报"所有 provider 都失败"。
两台真实设备(waiter-fnnas / waiter-mainnas)一上线就登记了这种通道,于是必然触发。

## 修法(改插件,不改内核)

初版我在内核里加了"通道名净化 + 反向解析"层。用户否掉了这个方向,理由对:
**通道名是插件自己的声明,不合契约就该改插件**,不该让内核替插件擦屁股。
内核侧改动已全部回退(HEAD 干净)。

插件侧两处:
1. 分隔符 `device/<id>` → `device-<id>`(源码与来源标签统一,不留两套名字)。
2. 设备 id 是**外部输入**(设备自己声明),可能含空格/非 ASCII/超长 ⇒
   `deviceChannelName()` 把它派生为**合规且唯一**的通道名:
   保留 `[A-Za-z0-9_-]`、其它折成 `-`、主体截断到 32 字符(预算 64 = 13+7+32+7+…)、
   发生截断或撞名时追加 id 的 6 位短哈希。同一 id 恒定同名;真名仍用于路由与日志。

核心契约写进了插件注释与 SDK 文档(见 SDK 仓同批提交):名字若来自外部输入,
**在插件侧派生合规名**,内核不会替你净化。

## 验证

- 新增 `TestDeviceChannelNameIsLLMFunctionNameSafe`:恶意 id(空格/符号/非 ASCII/超长/
  会折成同名的两个 id)都必须派生出**合法且互不重复**的通道名与工具名。
  反向验证:把分隔符改回 `/` 即 FAIL。
- 生产两台设备派生结果:`device-waiter-fnnas`、`device-waiter-mainnas`
  ⇒工具名 `output_send__device-waiter-fnnas`(37 字符,合规)。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(remotedevice + core)无 DATA RACE。
2026-09-13 13:07:24 +08:00
201a02839e chore(meta): SDKCompatibleVersion 升到 1.3.0(内核已实现 SDK 1.3.0 的全部新增面)
SDK 1.3.0 相对 1.2.1 的公开面新增(`git diff v1.2.1..main -- sdk/` 逐条核对):
- `InjectOptions.Priority` + `PriorityL1/PriorityL2/PriorityL3/PriorityL4`
- `UnregisterOutputChannel` + `OutputChannelUnregistrar` + `SetOutputChannelUnregistrar`

内核侧两样都已实现,故按既有先例(d1959cb 随"实现 SDK 1.2.0 新增方法"同步声明)在此声明:
- Priority:`applyInjectOpts` 落 payload → 调度器 `interruptLevel` 按四级中断阶梯调度,
  L4 归内核自身与内核级插件;
- UnregisterOutputChannel:插件登记表的 `regOutputUnreg` + io 设备表注销
  (远程设备 `device/<id>` 掉线即注销,不留死通道)。

不调新面的存量插件照旧可用(新增方法由插件调用、内核实现),无需重编。
2026-09-13 12:46:31 +08:00
f0be8cbaa5 feat(remotedevice): 设备能力 outputch 化 —— 每设备一个 device/<id> 通道 + 设备指令类工具补授权闸
用户指出 remotedevice 的能力应当 outputch 化。查证后发现比"应当"更严重:
设备方向**根本没有出站实现**。

## 查到的三个缺口

1. `devicectlDevice` 一直声明 `OutputCapabilities() = CapStructured`(对外宣称可作输出目标),
   但 `Execute` 的 switch 里**没有 "output" 分支** ⇒ `output_send__devicectl` 必然拿到
   `unknown device tool output`,回模型"通过 [devicectl] 通道发送失败"。
2. 全插件没有 `RegisterOutputChannel`,也没有任何 EmitOutput/output_send 路径:
   设备方向只有「工具(请求-响应)」与「设备→agent 注入」,**agent → 设备是断的**
   (唯一的下行通道是个 HTTP 端点 `/api/v1/device/push`,不在 agent 的工具/通道模型里)。
3. 寻址是聚合的:所有设备共用一个名字 `devicectl`,没有 `device/<id>`;且设备 caps 只在
   插件内部软检查(`SupportsTool`),**绕过**了内核的 `AllowedOutputs` 授权闸 ——
   驻留子只要拿到 `device_ctl_cmdrun` 就能指挥**任意**设备。

## 按方向切分(不是一刀切)

**出站/消息类 → 每设备一个输出通道 `device/<id>`**(与入站同名):
- 上线注册、掉线注销(caps 由设备声明的 caps 映射:文本恒有;有屏→图/文件;
  speaker→音频;可跑命令(cmd/cmdrun/cmdresult)或未声明已知能力→全能力,与
  `deviceSupportsTool` 的旧设备兼容规则一致)。断连不注销会留下死通道骗模型。
- 于是自动获得:内核按 caps 在**发送前**拦(送图给纯文本音箱直接拒);
  `output_list_channels` 能列出设备;`AllowedOutputs` 可按设备收窄给驻留子。
- 上下线钩子用 `Registry.SetPresenceHandler`(**同步回调**)而不是既有的 `ChangeChan`
  (那是 select+default,缓冲满会丢事件;丢一次就留下死通道或漏注册)。
- `devicectl` 保留为聚合通道,并把它"声明了却不实现"的 output 补实:按
  `meta.device_id`(或 meta 就是设备 id / args.device_id)路由;缺省时返回**可执行**的
  报错(列出在线设备),而不是含糊失败。

**RPC 类保留为工具**(`devicedetect`/`screensee`/`computeruse`/`clipboard*`/
`device_ctl_status|cmdrun|cmdresult`):它们的返回值(图像/命令输出/状态)必须进模型
上下文,做成通道会丢掉这个语义。

**并给设备指令类工具补上同一道授权闸**(core/toolcall.go):`device_id` 指向的设备
必须是本 agent 被授权的 `device/<id>`。根 agent 默认完整授权 ⇒ 无行为变化;
驻留子收窄后,"拿到工具就能指挥任意设备"的缺口被堵上(新增 3 条 core 测试钉住)。

**设备端参考实现**(`internal/devicebridge/client/bridge.go` + waiter):新增 `op=push`
分发与 `OnPush` 回调(文本/结构化;二进制走既有 `cmd_speech_*` → `DataHandler`),
waiter 把它打到终端。

## 设计口径(用户当场纠偏,已写进代码注释与 harness README)

**主动转发只有 webui 与 cli 两个交互界面**(webui 订阅 EventAgentOutput 渲染气泡、
cli 用同步回程写回终端)。其它通道一律要求 agent **显式** `output_send__<通道>`。
我第一版给 remotedevice 加了 `EventAgentOutput` 订阅来自动回投设备——那是凭空造了
第三个转发者,违背"输出是 agent 的主动调用",已撤回(该测试一并删除)。

## 验收

- 单测:caps 映射词表;设备上线→注册通道(含同名 inputch)/掉线→注销;push 真落到
  WS 设备;聚合通道 `devicectl` 的寻址(无 device_id 报可执行错误、按 meta 投递、
  指定不存在设备报错);core 授权闸 3 例。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugins/plugin/devicebridge/sdk)干净。
- **真二进制端到端**(私有 netns + mock LLM + 真 WS 设备客户端 scripts/kernel-stress/devclient.py):
  设备上线 → 通道表出现 `device/pydev-1`(caps=[text file image audio structured],由
  `caps:["cmd"]` 映射)→ agent 经 `output_send__device/pydev-1` 主动发送 → 设备收到
  `{"op":"push","payload":"内核推给你的消息","type":"text"}` → 设备掉线 → 通道从表中消失。
2026-09-13 12:23:28 +08:00
9f80289a89 test(kernel-stress): 把"真二进制压力测试"harness 固化进仓库(私有 netns + mock LLM + 多连接驱动)
本轮压测是从 /var/tmp 里现写脚本跑的,能复现才能长期用 —— 固化到这。

为什么要有它(与 go test 的分工):单测都在进程内、参数显式给,抓不到集成面问题;
这一轮它抓到了三个只有真二进制才暴露的问题(DataDir 漏接线、插件通道未登记为
inputch、create 后子不开工),以及两处生成器缺陷(旧 SDK 导致生成工程编译失败;
模板工程从不示范通道登记)。

- `launch.sh`:在**私有 netns** 里同时起 mock LLM 与内核实例。
  必须隔离的原因:生产实例占着 *:8080/*:9890/*:9876 且插件设了 SO_REUSEADDR,
  同机再起一个实例会在 127.0.0.1 上与之并存绑定(首次实测抢到 9890 约 1 分钟);
  netns 里只有 lo,结构上不可能碰到生产端口。unix socket 是文件系统对象,
  所以驱动脚本在 netns 外照样能连。
- `mockllm.py`:OpenAI 兼容 mock(可控延迟 + SSE 分块 + 按 `!resident`/`!notify`
  标记回工具调用),让无外网也能跑、且**流式段可被中断**(压抢占路径的前提)。
  注意必须实现 `do_HEAD`:内核探活用 HEAD,501 会被判不可达 → degraded → rollback 循环。
- `stress.py`:多并发连接轰炸(单连接是串行的,造不出队列压力)+ 中断线程 + 峰值采样。
- `kcli.py`:CLI socket 客户端(`/auth` → `/kernel` / 广播)。
- README:前置配置(含探活端点、rollback 关掉)、用法、指标读法、已知坑。

实测结果(详见 6e60351 提交信息):密集 274 任务 executed=274/rejected=0;
稀疏中断下 suspended=resumed=preempted=27;驻留子全链路 + 优雅退出不留孤儿。
2026-09-13 11:38:31 +08:00
ae9b06976c chore(sdk-mirror): 同步 SDK 仓 4cb3a0b —— 通道方向契约 + 示例插件显式登记 inputch
镜像文件(外层仓按策略只跟这些;`example/` 被 .gitignore 忽略,已跟踪文件用 -f 提交):
- `sdk/plugin.go`:RegisterInputChannel/RegisterOutputChannel 的方向契约文档
  (入站 vs 出站分开登记;用哪个通道名注入就要登记哪个)。
- `example/{a2a,acp,browser,memo,calendar,rss}/plugin.go`:补 RegisterInputChannel。

未镜像(外层 .gitignore 忽略,按策略不入镜像):
`README.md`(README*)、`tools/hmapdev/{templates.go,cmd_sdk.go,cmd_build.go}`(tools/)
—— 即"模板工程"与"`sdk install --from`"两项改动只存在于 SDK 仓,详见该仓提交 4cb3a0b。

注:这 6 个示例此前未 gofmt(`example/*` 共有 9 个未格式化文件),
本次改动文件被 gofmt 顺带规范,diff 里含格式噪音(calendar 49 行、a2a 33 行)。
2026-09-13 11:38:10 +08:00
6e6035141a fix(resident/plugin): 二进制级压测暴露的三个真问题(DataDir 漏接线 / inputch 未登记 / create 不开工)+ 通道双向登记贯穿全部内建插件
在真实内核二进制(私有 netns + mock LLM + CLI unix socket)上做压力测试时,
下面三个问题**只有跑真二进制才暴露** —— 单元测试里都显式传了参数、没走插件加载,
所以全绿也照样漏。

## ① 根 agent 的 DataDir 没接线 ⇒ 驻留子永远建不出来

现象:模型调用 `resident_agents` 成功,但结果是
`创建驻留子需要 data_dir 或显式 temp_path`。
根因:`cmd/homed/main.go` 构造 AgentConfig 时没有 `DataDir`,
而驻留子的 temp 图库需要 `<data>/residents/<id>/graph.db` 这个锚点。
(单测里 `AgentConfig{DataDir: dir}` 显式给了,所以测不出来。)

修:main.go 接线 `DataDir: cfg.Daemon.DataDir`;并在工具层加**兜底 + 告警** ——
data_dir 为空时从主图库路径反推(`<data>/memory/graph.db` ⇒ `<data>`),
失败才报错。静默失败会让线上表现成"工具能调但永远建不出来"。

## ② 插件通道没登记为 inputch ⇒ "划入 inputch"必然失败

现象:`划入 inputch cli: inputch 未注册`。
根因:`cli` 插件只调 `RegisterOutputChannel("cli", ...)`,却用同一个名字
`InjectTextSync("cli", ...)` 注入输入 —— 内核 inputch 登记表里根本没有它。
(实测审计:内建 6 个插件里只有 0 个登记过入站通道;SDK 示例里只有 qq/weather 是对的。)

修两处:
- **全部内建插件显式登记入站通道**:`cli`/`agentcli`/`timer`/`webui`(+`http`, NoMemory)/
  `clawhubadapter`(每个 OC 通道声明处)/`remotedevice`(`device/<id>` 懒登记,幂等)。
- `registry.go` 把隐式兜底改成**留痕的兼容网**:只有当该名字还没登记为 inputch 时
  才兜底登记,并打日志说明"建议显式 RegisterInputChannel"。
  实测:改完内建插件后,启动日志里兜底告警 **0 次**。

## ③ create 之后子不开工 ⇒ rounds 恒为 0

现象:`[agent] r1 started, waiting for IO interrupts` 之后什么都没有,登记表里 rounds=0。
根因:`TaskPrompt` 只进了子的**系统提示词**,从没作为输入投给子。
修:create 即开工 —— 把任务提示词作为**第一条排队输入**投给子(排队而非中断:
创建是"安排工作",不是"打断它正在做的事")。

## 测试

- `TestResident_InputchTableAutoAndProactive` / `TestLightKernel_TraditionalContextNoTrimming`
  随行为更新:create 会多跑一轮(任务提示词那轮也会写处理表),
  断言改为"以创建时的表长为基线 + 等待新的一轮"。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugin/plugins)干净。

## 真实二进制压力测试结果(修复后)

私有 netns 里跑 mock LLM + 内核,用 CLI socket 驱动多并发连接:
- 密集:16 连接×12 输入 + 4 线程×20 次 L4 中断 → **274 任务 executed=274 / rejected=0 / errors=0**,
  峰值排队 15、峰值待处理中断 76;
- 稀疏(中断每 3s 一次,压在排队任务的流式段上)→ **suspended=27 / resumed=27 / preempted=27**;
- 驻留子全链路:父建子(inputchs=["cli"])→ 子开工 → 子 `notify_parent` → 父侧收到
  `interrupt from r1/child/r1`(L3,且父被抢占 suspended/resumed=1);
- 优雅退出:SIGTERM 后驻留子 temp 目录被清除、无残留进程。
2026-09-13 11:38:03 +08:00
aba1770701 fix(lightkernel): 传统上下文落到实处 —— 子不做策略性裁剪(并修掉裁剪估算的字节/rune 单位混用)
用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走
`formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。
即"动态上下文"(父专属能力)漏进了轻量内核。

## 三处闸门(按 isLightKernel() 判)

1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`,
   子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`;
   漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。
2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。
3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。

## "不裁"的准确含义

不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束;
且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒
**丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。

## 顺带修掉的既有 bug(单位混用)

`formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 ——
`len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍:
实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、
把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。

## 测试

- 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider
  断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段,
  消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上);
  填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。
- 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。

全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。
2026-09-13 10:36:04 +08:00
cba33d2a6f test(resident): 压力规模可用环境变量放大(RESIDENT_STRESS_N / RESIDENT_STRESS_ROUNDS)
默认仍是 8 子 × 12 轮(CI 友好);要跑加重压力就放大:
  RESIDENT_STRESS_N=64 RESIDENT_STRESS_ROUNDS=40 go test -race -count=2 ./internal/agent/core/ -run TestResident_E2EAndStress

实测已跑:
- 24 子 × 25 轮(-race):通过
- 64 子 × 40 轮(-race -count=2,即 5120 轮次):通过
2026-09-13 10:25:49 +08:00
2ebbdadcd6 feat(resident): N3–N7 驻留式子 agent 全量落地(生命周期/双向投递/处理表/contextfull/e2e+压力)
设计:docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。

## N3 生命周期(resident.go)

- `SpawnResident`:主库**受限句柄** + 自己的 temp 实例(`LightMemory`)⇒ 子的轻量内核;
  划入 inputch(登记归属)、授权输出通道、注入任务提示词;为父登记 `child/<id>` 入站 inputch;
  建独立 `IOManager`(共享通道登记表);启动子。
- `DestroyResident`:停子内核、归还划入的 inputch(回到未分配)、丢弃 temp 目录、出登记表。
- `Stop()` → `StopResidents()`:**父退出必须销毁全部子、不留孤儿**(设计 §10 硬约束)。
- `Residents()` 登记表快照;`ResidentTable(id)` 父 pull 子的处理表(不打断)。

## N4 跨 agent 投递

- 子→父:`notify_parent` → 投进父的 `child/<id>` inputch,优先级 **L3**。
- 父→子:`SendToResident` → 投进子的 inputch,优先级 **L4**;
  `isKernelLevelSource` 泛化为"该 agent 的上级"(`AgentConfig.KernelSource`)⇒
  只有父能在子的阶梯上产生 L4(子内部一律 ≤L3)。
- 子的 contextfull → 父侧 `raiseKernelInterrupt`(内核级事件,带子标识,父侧 L4)。

## N5 inputch 处理表

- 子持有;`inputch_note` 主动写**优先**,轮末 `autoRecordInputch` 兜底 ⇒ 每轮必有记录。
- 压缩时清表(表记的是被压掉那段窗口的逐轮处理)。

## N6 contextfull(判据修正)

初版判据是"拼好的 `f.Msgs` 估算 > 90% 窗口",**结构上永不成立**:
`buildMessages` 拿到的 `budget.ContextTokens` 由 `targetUsage = 0.8 × 窗口` 推出,
时间线**在拼进消息之前就被预算裁过**,`f.Msgs` 封顶在 ~80% 窗口。
(初版测试用一个比系统提示词还小的窗口才勉强越线 —— 那等于什么都没测。)
现判据 = **未裁剪的积累上下文**(`a.context.Recent(0)`)超过窗口 90%:
它超过就说明下一轮必须丢事件,这正是"上下文满"。

三处置:`CompressResident`(保留语义:`TrimKeepRecent` 保留最近 N 条 + 清表)/
`ReclaimResident`(取消语义:`ExportTriples` 读 temp → 父选出要保留的 → `Commit` 进 main → 取消该子)/
`DestroyResident`(立刻销毁并移除)。

## 工具面

`resident_agents`(父,单工具多动作:list/create/send/inspect/compress/reclaim/destroy)、
`notify_parent` + `inputch_note`(子)。声明条件式:父才有前者,子才有后两者。

## 验收

`resident_test.go` 五项:生命周期与不留孤儿、双向投递(含"子的主动消息不得以 L4 出现")、
处理表(自动写 vs 主动写优先)、contextfull + 三处置、
**压力 8 子 × 12 轮(父→子 L4 与普通输入各半)+ 双向汇报 + 父退出清理**。
全仓 go test ./... 37 包 ok / 0 FAIL;`-race`(agent/memory/plugin)干净;
压力 `-race -count=3` 通过。
2026-09-13 10:20:06 +08:00
48cfa8fb6c feat(lightkernel): N2c —— 轻量内核 profile(窄接口 GraphMemory + nil 即禁用整理面)
按用户指出的关键点(a.memory 多数使用点属"主 agent 整理记忆"与"记忆整理流水线",
子不该有那些路径)实现,方案见设计 §16.0。

## 窄接口:只有 Recall + Commit

新增 `GraphMemory` 接口(memoryface.go)—— 按调用方实测分类后,真正"根与子都要"的只有这两个:
- `Recall`:上下文检索 / memory_recall
- `Commit`:自动写入路径的图部分 / memory_commit

新增 `Agent.graph`(共同面)与 `Agent.graphMem()` 访问器:
- `graph` 显式为 nil 时**回落**到 `memory` ⇒ 既有"只用 Agent 字面量设 memory"的测试无需改动
  (原本会出现"必须同时设两个字段"的脚坑,实测踩到后去掉了)
- 轻量内核:`graph = *memory.LightMemory`,`memory = nil`

## nil 即禁用:整理面自动消失,不需要受限包装

子的 `a.memory == nil` ⇒ 既有的 22 处 `if a.memory != nil` 关卡自动禁掉全部整理面:
- 记忆整理流水线(distill.go 的 archive/review/merge 循环)
- 记忆块 + 媒体桥(graphmedia.go / medialoop.go)
- 记忆整理工具(memory_merge / memory_delete_entity / memory_block_merge /
  memory_purge / memory_edit / memory_introspect)—— 它们本就在 `if a.memory != nil` 块内

唯一拆开的一处是自动写入 `commitTriplesWithMedia`:
图部分走 `graphMem().Commit`(父落 main、子落 temp),块/媒体部分仍由 `a.memory != nil` 守卫。
`executeMemoryTool` 的读路径改走 `graphMem().Recall`;整理类 case 加 `requireFull()` 闸门,
被直调时明确报"本 agent 是轻量内核:记忆整理不可用",不静默降级。

## 验收(3 项新测试)

- `TestLightProfile_MemoryFaceWiring`:整理面为 nil、写入只落 temp(主库无子痕迹)、
  读是并集(主库实体 + temp 实体都看得到)
- `TestLightProfile_OrganizeToolsAbsentAndRefused`:整理类工具**不进工具表**;
  即便被直调也明确报"轻量内核不支持"
- `TestFullProfile_KeepsOrganizeFace`:对照,根 agent 仍保留整理面与整理工具

全仓 go test ./... 37 包 ok / 0 FAIL;-race(agent/memory)干净;gofmt 干净。
2026-09-13 10:02:02 +08:00
93aa942e1e test(plugins): deepsearch E2E 不再带走共享搜索后端(附回归判据)
背景:E2E 临时目录里拉起的插件实例在 teardown 时执行了 `docker compose stop -t 2`,
把线上正在用的 SearXNG 关掉,表现为「搜索后端起不来」。

- integration_test.go:把 ConfigRegistry 暴露给测试环境(其余不变)
- deepsearch_e2e_test.go:
  - forbidStoppingSharedBackend:测试实例一律 stop_searxng_on_exit=false
    (配置表按内核约定先 RegisterDef 再 Set)
  - 新增 TestRealPlugin_DeepSearchKeepsSharedBackendOnStop:停掉插件实例后,
    6 秒内 healthz 必须始终 200 —— 判据落在网络层,直接钉住「跑测试不能断线上搜索」

验证:三条 E2E 全过,且跑完 healthz 仍 200、容器 StartedAt 未变(未重启)。
2026-09-13 09:55:33 +08:00
c5b1242980 docs(resident-subagent): 更正 N2c 方案 —— 窄接口(只剩 Recall/Commit)+ nil 即禁用
用户指出:a.memory 的多数使用点属于**主 agent 整理记忆**与**记忆整理流水线**,子根本不该有那些
代码路径。据此把上一版"约 20 方法的接口 + 受限包装"改成**实测分类 + 窄接口**。

按调用方实测分类(42 处):
- A 记忆整理流水线(distill.go 10 处:archive/review/merge 循环)→ root-only
- B 记忆块 + 媒体桥(graphmedia.go 18 + medialoop.go 4)→ root-only
- C 记忆整理工具(executeMemoryTool 8 处:merge/delete/block_merge/purge/edit/stats)→ root-only
- D 共同面:**只有 Recall + Commit**(自动写入路径 + memory_recall 工具)
- E 22 处 if a.memory != nil 既有关卡 + 状态/工具表判空

更正后的方案:
- `GraphMemory` 接口**只含 Recall + Commit**
- 根:a.graph = a.memory = 同一个 *GraphDB
- 子:a.graph = *LightMemory,**a.memory = nil** ⇒ 既有 22 处 nil 关卡自动禁掉全部 root-only 路径
  (executeMemoryTool 开头本来就是 `if a.memory == nil { return "图记忆系统不可用" }`)
- 唯一要拆的:自动写入 commitTriplesWithMedia(图部分走 a.graph.Commit;块/媒体部分用 a.memory != nil 守卫)
- 整理类工具**不进子的工具表**(而不是进去再报不可用)

⇒ 不必写"18 个方法都返回错误"的受限包装 —— 子压根没有那些路径。
2026-09-13 09:42:43 +08:00
f0562915db feat(memory): N2a 第二块砖 + N2d 数据面 —— 轻量图记忆装配(temp 可写/主库只读/并集)与回收合入
按用户确认的形态:**独立存储实例**(不给共享记忆层加 space 列)。

## LightMemory:子的图记忆装配(设计 §5.6)

    temp 实例(独立存储,读写)   ← 子的一切图记忆写入落这里,与子同生共死
    主库受限句柄(只读)          ← 子只能读(query_only 结构性拒绝写入)
    子的查询 = 两实例各查一次 + **应用层合并**(并集)

- 写入**只落 temp**(`Commit` 不接受 main 方向)
- 并集合并规则:实体按**名字**去重(同名保留 mention_count 较大者)、
  关系按 (源名, 关系, 目标名) 去重;结果排序确定(便于断言与展示稳定)
- 单侧查询失败不影响另一侧(只有两侧都失败才报错)
- `AllowWrite=false`(用户给的备选简化):**没有 temp 实例**,子对图记忆完全只读,
  写入被拒;读主库照常

## ExportTriples + 回收合入(设计 §9,N2d 数据面)

- `GraphDB.ExportTriples(limit)`:导出**活跃**三元组,把实体名一并带出
  ⇒ 合入侧直接复用 `Commit`(按实体名 upsert + 关系唯一约束)
- 回收主流程:子写 temp → 父导出 → **父选哪几条** → 写进 main。
  未选中的**不进**主库;重复收割**幂等**(不产生重复实体)

## 验收(7 项新测试)

`light_memory_test.go`(5):
- 写只落 temp、查询是并集、主库无子的痕迹
- **两个子的 temp 互不可见**(只有 main 共享)
- 写禁用时完全只读(无 temp 实例、写入被拒、读照常)
- 并集去重(同名实体只出现一次)
- 合并确定性(去重 + 排序 + mention_count 取大)

`reclaim_test.go`(2):
- 导出只含活跃关系且带实体名、limit 生效
- 回收主流程(选中的进主库、未选中的不进、重复收割幂等)

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:38:54 +08:00
818ce2698f feat(memory): N2a 第一块砖 —— 主图记忆的受限句柄(query_only),子是"读得到写不进"
按用户确认的形态:**独立存储实例**(不是给共享记忆层加 space 列)。

## 结构性保证

新增 `OpenGraphDBReadOnly(path)`:以**受限句柄**打开图库 ——
连接保持正常打开能力(可读、可恢复 WAL),但 `PRAGMA query_only=1` 让
任何 INSERT/UPDATE/DELETE 被 SQLite **直接拒绝**。

为什么不用 DSN 的 `mode=ro`:只读连接在 WAL 库上无法自行恢复 -wal,
而主库在父 agent 手里是持续写入的。query_only 只堵写、不堵读,语义正好。

⇒ "子改不了主记忆"是**结构性**的,不靠调用方自觉;也不建表、不迁移
(库由父建好,受限句柄不会凭空造出一个空主库)。

## 设计文档

新增 §5.6「实现形态:独立存储实例(不做 space 列)」,写明轻量内核的记忆装配:

    子的轻量内核
    ├─ temp 图记忆实例(独立存储,读写)  ← 与子同生共死
    └─ 主图记忆的受限句柄(只读)
    子的查询 = 两个实例各查一次 + 应用层合并(并集)
    回收时由父读 temp、选记录、写进主图记忆

并记录用户给的备选简化:`AllowTempGraphWrite` 开关(默认开);设为 false 时
子对图记忆完全只读,没有 temp 实例、没有合入。

## 验收

`internal/memory/graph_readonly_test.go`(2 项):
- 受限句柄读得到、写被拒(Commit/Purge 双双报错),且**主库不留痕迹**
- 库不存在时受限句柄的查询报错,而不是凭空建表后返回空结果

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:32:29 +08:00
36e7556a02 docs(resident-subagent): 子的记忆面收窄为「传统上下文 + 图记忆」
用户澄清:**doc 记忆**与 **context 动态上下文**是内核独立设计的记忆能力(父专属),
子 agent 的记忆面只有 **图记忆**。据此更正设计:

- §5 开头加适用范围界定:两级空间(读 temp∪main / 写 temp)**只针对图记忆**
- 新增 §5.5「子的记忆面」对照表:
    | 能力 | 根 | 驻留子 |
    | 图记忆(含向量检索) |  main 读写 |  作用域化 |
    | doc 记忆 / context 动态上下文 / 蒸馏 / 归档 / consolidation / 文本 / 知识库 / 媒体 / 社交 |  |  父专属 |
- 修正「轻量内核」的表述:不是"记忆变轻",而是**记忆面裁到只剩图记忆 + 图记忆被作用域化**
- §16.1 记忆面清单加「子可用?」列;**v1 只给图记忆(含其向量检索)加 space 维度**
  —— 其余面子根本够不到,加 space 是白工
- 里程碑:N2a = 图记忆 space 维度;N2b = 图记忆的向量检索接入同一过滤
- 决策表补 R3b

纯文档更正;全仓 go test ./... 37 包 ok。
2026-09-13 09:24:09 +08:00
069552e921 docs(resident-subagent): 把 N2 拆成 N2a–N2d(记忆作用域逐面铺开)+ 记忆面清单
N2(轻量内核 + 记忆作用域)是目前最大的一块:记忆子系统有 7 个面
(图记忆/向量索引/文档/知识库/文本/媒体/社交),每个面都要加 space 维度。

拆成可独立验收的四步:
- N2a 作用域对象 + **图记忆** space 维度(先做这一条纵切)
- N2b 其余记忆面加 space(逐面验收)
- N2c Agent 级作用域接线(根 = {main,[main]};驻留子 = {sub/<id>,[sub/<id>,main]})
- N2d 晋升与丢弃(回收时父把选中的 temp promote 进 main;销毁/回收丢弃 temp)

文档新增 §16.1 记忆面清单(面 → 载体 → 表),说明为何先做图记忆这条纵切。
2026-09-13 09:15:58 +08:00
f7c3a4e81d feat(channel): N1b —— 输出通道授权集合(三处过滤一致)+ 输出通道→目标 agent 的 inputch 解析
设计:docs/zh/resident-subagent-design.md §4.5(里程碑 N1b)。

## 输出通道授权集合(默认完整授权,父可收窄)

`AgentConfig.AllowedOutputs`(nil/空 = 完整授权)。三处过滤点必须一致,
否则会出现「列表里看不到、按名字还能调」的裂缝:

1. **工具表**:不为未授权的通道生成 output_send__X(模型看不到就不会调)
2. **列表工具**:output_list_channels 只列授权的
3. **调用点**:凭名字直调未授权的输出门必须被拒(纵深防御)

## 输出通道 → 目标 agent 的 inputch 解析

`ChannelRegistry.BindOutputTarget / ResolveOutputTarget`:
把输出通道解析成「目标 agent + 目标 inputch」,这是"输出可寻址到具体 agent"
(子→父、父→指定子)的**数据面**;真正的跨 agent 投递在里程碑 N4。
未登记的输出通道 ok=false —— 表示由传输层 device 自行处理(qq/webui 这类)。
已登记目标的输出通道,在 output_list_channels 里会标出「目标: <agent> / inputch <名字>」。

## 验收

`internal/agent/core/output_grant_test.go`(3 项):
- 默认完整授权:全部输出门生成 + 列表含全部
- 白名单收窄:三个过滤点同时生效(工具表 / 列表 / 直调被拒)
- 目标解析:绑定/解析、未登记由传输层处理、列表标出目标、空名报错

全仓 go test ./... 37 包 ok / 0 FAIL。
2026-09-13 09:13:19 +08:00
a2dcd96fee chore: 撤回误提交的未跟踪文件 internal/plugins/deepsearch_e2e_test.go
该文件是工作区里**别人 in-flight** 的未跟踪文件(我上一步用 git add -A internal/ 时被顺带收进来)。
它不是我这次改动的一部分,因此从索引里撤出,文件本体保留在工作区(仍为未跟踪状态),
由它的作者决定何时提交。
2026-09-13 09:07:02 +08:00
3c961b7bd7 feat(channel): N1a —— inputch 一等化(归属插件/归属 agent/容量/共享登记表)+ 单工具多视图总览
设计:docs/zh/resident-subagent-design.md §4.6(里程碑 N1a)。

## 用户要求

「父 agent 可以看到所有已注册的 inputch 以及 inputch 的划分情况,用**单工具多视图**方式构筑」

## 登记层(internal/agent/io/inputch.go)

inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个),
所以登记表以 inputch 为键,每条记录:

    名字 / 归属插件 / 归属 agent(被划给谁) / 容量 / 默认回程 outputch / 记忆策略(ChannelDef)

- 新增 `ChannelRegistry`,设计成**可共享对象**(`*ChannelRegistry`):
  根 agent 与驻留子共用同一份,"划入/授权"才有意义;`SetChannelRegistry` 注入。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
  保留已有 Owner/Capacity/Output(否则一次 reload 就把父做的划分清空)。
- `Assign` 语义按设计 R6 默认:**读写授权,不转移所有权**(Plugin 与 Owner 分别记录)。
- 原 `inputChannels map[string]ChannelDef` 被登记表取代;`GetInputChannelDef` 保持兼容。
- `plugin.Registry` 注册时带上**归属插件名**(此前完全无归属信息)。

## 总览工具(internal/agent/core/inputch.go)

单工具 **`input_channels`** + `view` 参数(不是一堆小工具):

    all(默认)= 全部已注册(带归属插件)
    mine       = 划给本 agent 的
    unassigned = 尚未划出的
    by_agent   = 划分情况总览(按归属分组)
    detail     = 单个 inputch 全字段(需 name)

未知 view **报错并列出可用值**(拼错不得被静默当成默认视图);登记表为空时明确说明。

## 验收

- `internal/agent/io/inputch_test.go`:归属记录、重载保划分、Assign/视图数据面、
  **跨 manager 共享登记表**、策略查询向后兼容 —— 5 项
- `internal/agent/core/inputch_test.go`:单工具多视图逐视图断言(含未知 view 与空表)—— 2 项
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/... ./internal/plugin/...` 干净
2026-09-13 09:06:50 +08:00
f2ec46480e refactor(core)!: N0 无状态化 —— 删除 Agent.currentOutputChannel,通道只跟输入事件/帧走
驻留式子 agent 设计(docs/zh/resident-subagent-design.md)的里程碑 N0。

## 问题

`a.currentOutputChannel` 是 **agent 级可变字段**,只在 prepare 段写入,而被打断任务
恢复时**不重新 prepare**(resumeTask 只 rebase 前缀)。于是中断任务 prepare 时把它
覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两个任务串台。

后果不只是标签错:工具提示词里那句"当前输入来源通道是 X,对应输出门工具是
output_send__X"会诱导模型**把回复主动发到错误的通道**。

## 两处一起改(用户指出的两件事)

1. **内核不应持有"当前通道"**:通道是随输入事件带进来的,路由发生在**进内核之前**,
   输出是 agent 的**主动调用**。删除该字段,改为一律从输入事件推导
   (`outputChannelOf(evt)`)或读本任务的帧(`f.OutputChannel`)。
2. **提示词不应预设 outputch**:删掉"当前输入来源通道是 X → 用 output_send__X"那两行,
   改为"不要假设当前通道是固定值;先看消息本身与上下文的来源信息,不确定时先调
   output_list_channels"。

## 改动面(把通道一路显式传下去,而不是读共享状态)

- `agent.go`:删字段
- `task.go`:新增 `outputChannelOf` / `isCriticalChannel`;帧记录通道;
  安全点与 setCritical 用帧/事件推导;步骤内事件标签改用 `f.OutputChannel`;
  `executeToolCall(f.CurTool, f.OutputChannel)`;`callLLMWithFallback(..., f.OutputChannel)`
- `process.go`:`chatStreamWithFallback` / `accumulateStream` 增加 channel 参数
  (增量事件的 channel 标签由此而来)
- `stage.go`:`runStage` 从 `ctx.Extra["output_channel"]` 读(发起方写入)
- `eventloop.go`:`emitResponse` 用 `outputChannelOf(evt)`;stageCtx 带上通道
- `spawn.go` / `toolcall.go`:`executeSpawnChild` 的 parentChannel 由调用方(帧)传入
  (子任务完成通知要回到**发起这次 spawn 的那个任务**的通道)
- `distill.go`:删掉 consolidation 路径里的赋值
- `tooldefs.go`:删掉提示词里的通道预设

## 验收

- `scheduler_channel_routing_test.go`(N0 守卫):中断任务跑过之后,被恢复任务的
  输出通道仍是它自己的(改前实测为 cli,期望 qq)
- `TestCriticalSection_ConsolidationMarked`:补上推导链
  「输入事件 → 通道 → isCriticalChannel → scheduler.critical」的集成断言
- 全仓 `go test ./...` 37 包 ok / 0 FAIL;`-race ./internal/agent/...` 干净
- 残留 `currentOutputChannel` 引用为 0(只剩描述历史的注释)
2026-09-13 08:59:15 +08:00
3956610134 docs(resident-subagent): inputch 是最基本的输入路由单位(插件可注册多个)
按用户补充收窄定义:
- inputch 是**最基本的输入路由单位** —— 路由粒度到此为止(比「插件」细、比「通道名字符串」实)
- **由插件注册,且一个插件可注册多个**(登记接口即现有 RegisterInputChannel,
  调 N 次就是 N 个 inputch;同一插件的多个 inputch 彼此独立,可绑给不同 agent、可分别限额)
- 「划入输入通道」的单位 = **inputch**(不是插件、不是通道组)
- 通道注册层以 inputch 为键(一个插件 → N 个 inputch),每个 inputch 带
  归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析

新增测试点 S21(同一插件两个 inputch 分别划给父与子,互不串台)。
2026-09-13 08:40:04 +08:00
fc41e160db docs(resident-subagent): 驻留式子 agent 设计稿(轻量内核 · 两级记忆 · 父子中断)
把用户口述的设计固化成文档,与已落地的 input-scheduler-design.md 配套。

定下来的核心:
- inputch = 对「中断输入/排队输入」两者的高层抽象,是**路由与分配**单位
  (路由发生在进内核之前;类别与级别是每条输入的属性,不是 inputch 的属性)
- 输出是 agent 的**主动调用**(output_send__{通道});内核不持「当前通道」可变状态、
  提示词不预设 outputch
- 通道不配对:父**划入若干输入通道** + 授权**一组可用输出通道**;输出可寻址到具体 agent
- 驻留子 = **独立轻量内核**:读 temp∪main、写 temp(记忆层按 agent 作用域化 ——
  这才是「轻量」的真正理由,不是砍功能)
- 两条独立中断阶梯:父侧 子的主动消息=L3 / contextfull=L4;子侧 **父的消息=L4**
  (L4 通则:某 agent 的 L4 只属于它的内核 ⇒ isKernelLevelSource 泛化为「该 agent 的上级」;
  子内部一切来源被夹到 ≤L3,所以父的消息确定能打断子)
- 父对子 6 个动作:创建/发送消息/查看/压缩/回收/销毁(原语在内核、决策在父的模型);
  插件与工具由父授权,**默认完整授权**
- contextfull 三处置:压缩=保留(清处理表)/ 回收=取消(promote 选中的 temp 进 main)/
  销毁=立刻销毁并移除
- inputch 处理表:子持有、父 pull;主动写入优先,否则系统自动写;生命周期 = 当前上下文窗口
- 登记表 + 硬约束:父可随时销毁,父退出必须销毁全部,子不得比父活得久
- 工作式轻量子 agent **原样保留**,两类并存

含 20 个测试点(S1–S20)与 8 个里程碑(N0–N7,每步独立可验收)、
12 条待确认决策(全部带可逆默认值)。
2026-09-13 08:39:40 +08:00
158395bfdf test(scheduler): 优先级压力测试(100 排队 + 100 中断各级混合、嵌套到 4 帧上限)
按需造一个固定内容的 fakeprovider,做两件事:

E4:混合载荷(用户指定的形状)
  100 条排队输入 + 100 条中断(L1/L2/L3/L4 各 25)混合打入。每条中断都等到
  “该被它打断的受害者正在跑”时才注入——调度器是单线程的,闭着眼睛猛灌只会
  让绝大多数中断退化成排队,压力就压不到抢占/挂起路径上。
  判据:200 个任务全部到达终态、Rejected=0、各级登记数=25、**各级抢占数都>0**、
  排空后 Suspended==Resumed、每次“取消流式段”都换来一次挂起。

E5:嵌套到结构上限
  排队任务运行中依次注入 L1→L2→L3→L4(每级都等上一级在跑)。判据:栈深峰值
  恰好 4(= 结构上限,此时 canSuspend()=false),恢复顺序严格 LIFO
  [L3,L2,L1,排队],Suspended==Resumed==4。

顺带修掉两处:
- **Resumed 双计**:nextRef 弹栈与 resumeTask 各计一次,使“排空后
  Suspended==Resumed”这条不变量失真。现在只在 resumeTask 计(nextRef 只负责选出)。
- 新增分级可观测:SchedulerStats.InterruptsByLevel[1..4] / PreemptsByLevel[1..4]。
  只看总数会掩盖“总数一样但级别分布完全不同”,按级别验收才是这套调度器的判据。
  注意 PreemptsByLevel 是“判定可抢占并进入 immediate”的次数,与 Suspended 不等价:
  受害者可能在让位生效前就自行结束,此时抢占者只是“下一个运行”,没有挂起发生。

教训:provider 必须感知 ctx 取消。第一版没做,结果 LLM完成==任务数、挂起≈0——
抢占全落在“步骤之间”,流式段(真正需要保存/恢复现场的地方)一次都没压到。

验收:go test ./... 37 包 ok 0 FAIL;-race 全绿;压力连跑 3 次稳定;
新内核 go build 通过并真实启动冒烟 OK。
2026-09-13 07:31:32 +08:00
f3232000f4 feat(scheduler): L4 也归内核级插件 —— WebUI 终止按钮可用“立即打断”
上一提交把 L4 写成“内核独占(panic / selfip)”,漏了内核级插件这类来源。
用户澄清:**内核级插件应当能声明 L4,用于实现中断能力**,例如 WebUI 的终止按钮。

判据(两道闸,纵深防御):
  1. proc 桥(外部进程唯一入口)一律把 L4 夹到 L3。在这里夹而不是只按 source 判,
     是因为 source 是插件自报字段、可以冒名;本函数所在位置能确知“来自外部进程”。
  2. core:isKernelLevelSource(source) 查 pluginReg.IsBuiltinPlugin,只有编译期内置
     插件(init() 自注册的工厂)才承认 L4。
source 约定 `插件名` 或 `插件名/实例`(webui/<deviceID>),判据取第一段——
否则带设备身份的 WebUI 来源会被误判成外部插件而拿不到 L4。

改动:
- core: interruptLevel(evt, privileged bool);新增 isKernelLevelSource;
  requestPreempt 不再夹取(级别已由 interruptLevel 解析,否则内核级插件的 L4 被削掉)。
- eventloop: 传入 a.isKernelLevelSource(evt.Source)。
- proc 桥: 新增 clampExternalPriority,pubSdkInjectOpts 一律夹取。
- internal/sdk: 再导出 PriorityL1..L4(内置插件用 sdk.PriorityL4)。
- webui handleChatInterrupt(终止按钮)声明 PriorityL4。
- timer 声明 PriorityL3:定时器是“时钟那种实时工作”,比 QQ 那类可无限等待的
  异步消息高(L1)——这是对用户“它不是时钟那种实时工作”的直接推论,可改。
- 测试: L4 特权矩阵(非特权夹取 / 特权承认)、source 判据(内置、内置/实例、
  外部、空、前缀不误匹配)、内核级插件 L4 一路到达调度器、proc 夹取两条。
- 设计稿 §2/§3.2/§11.1/§14/§15 按“L4 = 内核 + 内核级插件”更正。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿(含 webui/timer)。
2026-09-13 07:18:03 +08:00
cb32032f76 feat(scheduler)!: 中断/排队两类别模型 + 插件声明 L1-L3、L4 内核独占
用户澄清推翻了早期设计的三处前提,本提交按新模型重做调度核心(行为有意变化):

1) 类别由注入 API 决定,与通道名无关
   - InjectInterrupt*                      -> TaskInterrupt(带级别,可被严格更高级中断打断)
   - InjectText*/InjectInputSync*/内核自循环 -> TaskQueued(无级别,可被任何中断打断)
   - 删除按通道名推断的 taskLevel():qq 走 InjectInterruptTextOpts,本就是中断

2) 级别只属于中断
   - 插件在 InjectOptions.Priority 声明 L1-L3(空/非法降级 L1,声明 L4 夹到 L3)
   - L4 内核独占:新增 raiseKernelInterrupt(panic / selfip);requestKernelPreempt 不夹取
   - panic 现在产生一条带 kernel 标记的 L4 中断;L4 自身 panic 不再产生新 L4(防自我放大)

3) 选择结构:四容器固定次序,删除统一比较器
   - immediate(抢占者立即运行)-> 中断队列 L4..L1 -> 栈顶(与队头比级别) -> 排队 FIFO
   - 删除 pickTaskIndex/taskBefore 与“同级 pending 优先”补丁(根因是抢占者进了队列)
   - 中断栈上界改为结构推论 = 4(= 中断级数);删除“超限转 pendingInterrupts”降级

公开 SDK(feature 分支有意新增,纯追加):InjectOptions.Priority + PriorityL1/2/3;
内核 io / proc 桥 / 插件模板同步透传。

设计稿 §2/§3/§4.1/§6.3/§9/§11/§12/§13/§15 按新模型重写。

验收:go build/vet 干净;go test ./... 37 包 ok 0 FAIL;-race 全绿;
e2e(抢占-挂起-恢复)+ 压力(200 排队 + 50 中断,L1/L2/L3 轮转)通过。
2026-09-13 07:00:51 +08:00
86a702b3b0 fix(scheduler)!: 中断栈语义(嵌套抢占 LIFO),并修掉抢占空转
用户指正:存在**中断被中断**的场景,所以被打断的现场要进**中断栈**。
我此前把 suspendPool 明确写成“不是栈、按优先级取”,是错的。

改动:
- suspendPool 改名 suspendStack,恢复纪律改为**严格 LIFO(只比栈顶)**;
  栈内不做优先级重排——嵌套抢占天然使栈自底向上基础级递增,
  且“后被打断的先恢复”才是栈语义。取出即弹栈。
- 修掉一个由此暴露的真 bug(抢占空转):一次抢占生效后,被挂起的原任务
  会因饥饿防护提升有效级,与抢占者同级;此时若按“先到先服务”,原任务
  (入队更早)会被立刻选回,抢占者永远排不到 —— 抢占等于没发生。
  现在**同级时 pendingInterrupts 优先于其它两类**,保证抢占必然生效。
- 状态 DTO:SuspendPool/suspend_pool → SuspendStack/suspend_stack
- 设计稿:§2 用语更正(它**就是**中断栈)、§4.1 选择函数(候选只含栈顶 +
  pending 同级优先,并说明为何必需)、§6.2/§6.3/§9/§11 用例同步

测试新增 scheduler_stack_test.go 3 项:
- 嵌套 L1→L2→L3,恢复严格 LIFO(B 先于 A)
- 只比栈顶:人为构造“栈底 L3、栈顶 L2”,必须取栈顶(区分两种实现)
- 嵌套下的深度上限

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:30:23 +08:00
d4764e3682 fix(scheduler)!: D1 更正为「中断从上一个任务之前的完整状态开始」,并实现现场合回
用户明确语义(我此前对 D1 的解析就是错的——当时回答里的“A”指的是 git 选项,
D1 实际要的是方案 B):

  中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall),
  然后中断在「上个任务前的那个完整状态」上开始运行;
  中断结束后再把被挂起的任务与其上下文现场加载回中断任务之上,并继续运行。

实现:
- 删除 SeedMsgs 与 D1=A 的“只读前缀”机制:中断任务不再继承被打断任务的任何内容,
  它就是普通新任务,正常走完整 prepare(system prompt + timeline + 自己的输入)
- TaskFrame 新增 PrefixLen(基础前缀长度)与 InputBlocks;
  stepPrepare 在 buildMessages 之后记录 PrefixLen
- 新增 rebaseFramePrefix:恢复时重建基础前缀(中断已提交进 a.context,
  重建的 timeline 含中断效果=“加载回中断之上”),再把本任务自己的尾部
  (Stage 上下文 + 工具轮产物 + 占位)接回;并补回 IsInterrupt 标记与多模态块
- resumeTask 在 runTaskSteps 之前调用 rebaseFramePrefix
- 设计稿 §5.3 改写为「已定:D1=B」并写明实现对应;§6.2 补“重建前缀→接回尾部”;
  §12 的 D1 行更新

测试:
- TestPreempt_HigherPreemptsAndResumes 改为断言「中断不继承、恢复后看得见中断内容」
- 新增 TestPreempt_ResumeRebaseRestoresTailDecorations(前缀重建后尾部装饰补回)
- 原 TestPreempt_SeedPathDoesNotLeakInterruptFlag 随之删除(机制已不存在)

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:24:20 +08:00
8372f5bd8f fix(scheduler)!: 撤掉“优先级=可配置策略表”的错误设计,回归内核内部属性
用户指正:**优先级是内核内部属性**,不是配置项,更不该由插件声明。
我此前把它建模成“策略表 + 字符串解析”,甚至准备接配置中心
(core.agent.priority.<channel>)——方向性错误,故整体撤销。

撤销:
- 删除 ParseLevel(字符串解析只服务于“外部可配”这个错误前提)
- 删除 AgentConfig.PriorityLookup / Agent.priorityLookup 及 taskLevel 中的查表分支;
  taskLevel 回归为纯内核内部规则(cli/webui/http→L3,system/_consolidation_→L1,
  其余 L1),注释明确“不对外暴露、不做运维可调项”
- 设计稿 §3.2 改写为“内核内部属性,不做成配置项”,并删除 §15 里
  “ChannelDef.Priority / InjectOptions.Priority 进公开 SDK”这一方向(同属外化)
- 未触碰配置中心(registry.go/main.go 的优先级配置一行未加)

同时落地 D6(与本撤销无关、此前遗漏的承诺):
- AgentConfig.MaxToolTurns + runTaskSteps 在发起新一轮 LLM 前按 f.Turn 收尾;
  0 = 不限;cmd/homed/main.go 从既有 core.agent.max_tool_turns 取值
- 新增 task_test.go 2 项:上限 3 时恰好跑 3 批工具/3 次 LLM 并收尾;
  0 = 不限(跑完脚本)

验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 06:17:49 +08:00
98d67559d1 fix(scheduler): 补齐 M3 与设计稿的两处语义偏离(发现即修)
两处都不是风格差异,而是真的偏离设计语义(其一为回归),
已按“先写判据确认失败、再修”的方式处理,判据保留为回归测试。

1. 空闲时到达的中断永远不会被处理(设计 §5.1 ③ 未落地)
   调度器空闲时只阻塞在 select{InputChan, selfInputCh, ctx.Done},
   而 pendingInterrupts 不是 channel——interceptLoop 把中断入队后
   没有任何东西唤醒调度器,中断要等“下一条输入”才被看到。
   修复:调度器加 wake channel,enqueueInterrupt 非阻塞 signalWake,
   空闲分支增加 wake 分支。

2. 临界区内只“不让位”却仍被“取消”(设计 §4.3/§5.2)
   requestPreempt 不判临界区,interceptLoop 照常 cancelLLM,
   于是正在流式的记忆整理被中断,stepLLM 以 error 提前结束——
   整理任务被砍掉一半,而设计要求的是“请求排队等它结束”。
   修复:scheduler 增加原子 critical 标志(帧仍只由调度器读写),
   runInputTask 在 prepare 后设置、结束(含挂起)时清除;
   requestPreempt 在临界区内不 arm、不取消,中断只入队。

- 新增 scheduler_regression_test.go 2 项(先失败后通过)
- 验收:agent 全量 + -race;全仓 vet 通过
2026-09-13 06:10:44 +08:00
6bd3313a22 docs(scheduler): 回写 M1–M7 实现状态与 5 处实现期偏差
- 里程碑表补提交号与验收结果
- 记下与设计稿的偏差:M3 拆分、a.mu 移除、interceptCh 删除、
  M0 伪时钟未做(用阻塞 provider 替代)、工具执行临界区由结构保证
2026-09-13 00:48:20 +08:00
f11de37bf2 feat(scheduler): M7 可观测性 + 压力测试 + 端到端测试
设计依据 docs/zh/input-scheduler-design.md §11.5(O1/O2)、§11.6(E1/E2)。

- 可观测性:KernelStatus 新增 Scheduler 段(running/三集合深度/计数/
  深度上限),由 GetKernelStatus 从 DumpScheduler 原子快照填充;
  新增 events.EventScheduler,挂起/恢复各发一条(action/task/level)
- TaskKind.String() 便于日志与状态输出
- 新增 scheduler_e2e_test.go 3 项:
  · 压力:200 排队输入 + 50 中断全部经真实 loop 执行,结束时三集合排空、
    LLM 调用数精确等于输入数、无 Rejected
  · 可观测性:挂起/恢复事件齐备,状态快照计数一致
  · 端到端:完整启动 schedulerLoop+interceptLoop,经真实 channel 投递
    L1 任务与 L4 中断,验证「LLM 流式中断 → 挂起 → 中断先完成 → 原任务恢复」
    整条链路(LLM 调用数 = 丢弃1+中断1+恢复1+常规1)
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:45:28 +08:00
4e4e0ad656 feat(scheduler): M6 任务级回执 + 断链点统一为终态事件
设计依据 docs/zh/input-scheduler-design.md §7(I5)、§11.3(X1/X2/X4)。

- 新增 emitSkippedReply:跳过路径(去重命中、空输入)给同步调用方一个
  skipped 终态,但**不发 agent_output 事件**(避免 WebUI 聊天记录凭空多出
  空消息)。修复前 cli/clawhubadapter 这类无超时的同步注入在去重命中时永久挂起
- 回执按任务归属:中断任务的回执只写自己的 ResponseCh,绝不误投给被挂起的等待者;
  被挂起任务恢复并结束后才拿到自己的回执
- 新增 task_terminal_test.go 3 项:X1 回执不误投、X2 空输入有 skipped 终态、
  X4 无超时同步调用方 1s 内拿到终态(回归判据)
- 更新 M3a 去重用例:由「不得回执」改为「必须有 skipped 终态」
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:39:56 +08:00
a971fc8877 feat(scheduler): M5 饥饿防护(抢占计数提升有效级 + 抢占冷却)
设计依据 docs/zh/input-scheduler-design.md §9、§11.5(G1/G2)。

- Task 增加 PreemptCount / LastPreemptAt;effectiveLevel(t) =
  min(L4, Level + min(PreemptCount, 2)):被抢占越多越“值钱”,
  逐步追上抢占它的流,但封顶 L4 因而抢不过真正的紧急输入
- 选择函数 taskBefore 改用有效级;requestPreempt 与 preemptGrantedFor
  同样以有效级比较
- 抢占冷却 preemptCooldown=2s:刚被抢占的任务期内不再被抢占,
  避免同一任务被反复打断到永不完结
- 新增 scheduler_starvation_test.go 4 项:提升与封顶、冷却期内不得再抢占、
  提升后同级不得抢占而更高可、选择函数确实用有效级
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:38:30 +08:00
7565248f61 feat(scheduler): M4 临界区显式化 + 清掉被 pendingInterrupts 取代的 interceptCh
设计依据 docs/zh/input-scheduler-design.md §4.3、§11.1(P5/P6)。

- 临界区语义显式化:让位检查**只在 step 之间**做,执行中的 step
  (工具 RPC / ONNX / CAS 落盘)天然不可抢占;_consolidation_ 整任务
  经 inCriticalSection() 判为不可抢占(它直接改图库)
- 删除 interceptCh 与 drainInterrupts:M3b 起中断一律走 pendingInterrupts,
  旧的「同行注入 + 三处 drain + 批次放弃」已无写入者,属死代码
- 新增 scheduler_critical_test.go 3 项:
  P5/P6 工具执行中 arm 了让位信号也不得挂起、必须等工具返回后的安全点;
  _consolidation_ 判为临界区;无抢占时同批工具必须全部执行(新语义回归)
- 验收:agent 全量 + -race;全仓 build/vet 通过
2026-09-13 00:34:39 +08:00
c69a1f11af feat(scheduler): M3a+M3b 任务生命周期重构 + 四级优先级抢占
设计依据 docs/zh/input-scheduler-design.md §3–§8、§14。

M3a(行为等价的所有权重构):
- processInput 拆为 prepareInputTask / runTaskSteps / finishInputTask,
  帧覆盖 prepare→step…→finish;提交与回执只在 finish 段发生一次,
  为安全点挂起做准备(挂起不重复提交)
- process() 不再持 a.mu(挂起不能持锁),a.mu 字段随之移除
- TaskFrame 增加任务层现场(Evt/CleanInput/IsInterrupt/StartedAt/Terminal/
  Level/SeedMsgs)与 taskTerminal / outcomeSuspended
- 新增 task_lifecycle_test.go 5 项:正常恰好一次终态、去重 skipped、
  on_input 短路、错误终态、consolidation 路由

M3b(优先级与抢占):
- interceptLoop 重写:只做「收中断 → 定级 → requestPreempt → 必要时取消
  LLM」,绝不触碰帧(不变量 I2);三条降级路径与 interceptCh 兜底退场,
  改为统一的 pendingInterrupts
- scheduler:pendingInterrupts / suspendPool / 让位信号,nextRef 在三集合上
  按统一排序键取值;深度上限 4(canSuspend 在安全点拦下)
- 抢占判据 incoming.level > running.level;相等与更低只入队
- 安全点只在 step 之间;执行中的 step(工具 RPC/ONNX/CAS)天然不可抢占;
  _consolidation_ 整任务视为临界区
- 恢复走 resumeTask:从 frame.Step 继续,不重跑 prepare
- D1=A:suspend 把被打断任务的只读前缀交给抢占比它的中断任务(SeedMsgs)
- 新增 scheduler_preempt_test.go 5 项:抢占-挂起-恢复(含 R1/R5)、同级更低
  不抢占、深度上限、空闲中断不丢、seed 路径不污染标志位

验收:agent 全量 + -race 通过;全仓 build/vet 通过
2026-09-13 00:25:52 +08:00
716ee46471 docs(scheduler): M3 拆为 M3a(所有权重构)/M3b(抢占语义)
真正的中途挂起要求帧跨越 prepare→step…→finish 全生命周期;若只让
process() 可挂起,processInput 会在挂起返回后继续 context.Append 与
emitResponse,造成重复提交。故 M3 分为两步:M3a 行为等价的所有权重构
(含移除 process() 整轮持有的 a.mu),M3b 再引入优先级与抢占。
2026-09-12 23:51:13 +08:00
7082a50365 feat(scheduler): M2 调度器骨架(就绪队列 + 选择函数 + 快照 + 任务级 panic 隔离)
设计依据 docs/zh/input-scheduler-design.md §14 M2。

- 新增 scheduler.go:四级 Level 常量(默认 L1,显式才是特权)、
  Task/TaskKind、scheduler(有界队列/running/统计)、纯函数 pickTaskIndex
  (排序键 -Level → EnqueuedAt → ID)、schedulerLoop/pumpInbox/executeTask、
  DumpScheduler 原子快照
- eventLoop 退场,职责由 schedulerLoop 承担;M2 全部任务为 L1,
  因而行为等价于原先的 channel FIFO
- 每任务 panic 隔离(不变量 I6):panic 只失败该任务,调度器存活,
  取代原先「重启整个循环」
- Start() 改起 schedulerLoop;interceptLoop 暂不动(M3 重写)
- 新增 scheduler_test.go:Q1 排序 4 组、Q4 背压、生命周期、K1 panic 隔离、
  O1 快照一致性、Level 取值契约
- 验收:agent 全量 + -race 通过
2026-09-12 23:49:55 +08:00
9a588780b9 refactor(scheduler): M1 把 process() 拆成 step 状态机 + TaskFrame(行为等价)
设计依据 docs/zh/input-scheduler-design.md §14 M1。

- 新增 task.go:Step 游标、TaskFrame,以及 stepPrepare/stepLLM/
  stepToolBegin/stepToolExec/stepToolAfter/stepTurnEnd 六个 step;
  原函数内嵌的 provider 回退/重试抽为 resolveProviders + callLLMWithFallback
- process() 改为驱动状态机的薄壳:签名不变,调用方(processInput/
  processConsolidation/测试)零改动
- StepToolExec 显式标注为临界区:工具副作用不可回滚,执行中不是安全点
- 步数上限护栏:转移缺失时以错误退出而非死循环
- 新增 task_test.go:R3(工具往返结果与配对正确)、X3(多轮必然终止)、
  批中途中断必须放弃剩余工具、未知 step 必须失败退出
- 验收:既有 agent 全量测试通过;-race 通过(TMPDIR 指向真实磁盘,
  /tmp tmpfs 已 98% 满会导致链接失败,与代码无关)
2026-09-12 23:47:12 +08:00
06edff1af2 docs(scheduler): 输入调度器设计稿(四级优先级/可抢占/现场保存 + 测试点与里程碑)
背景:现状排队与中断两条语义建立在串行 eventLoop 上,存在队头阻塞、
中断三条隐式降级路径、回执无任务归属、断链点静默、背压策略分裂、
假取消、不可观测七个已确认问题。

设计:四级内核预定义优先级(严格大于才抢占)、显式临界区、
单调度线程 + 单中断线程、step 化任务帧与安全点、suspendPool/pendingInterrupts/
readyQueue 三集合统一选择函数、任务级 responseCh。

含 11 组测试点(优先级/恢复/回执/队列/深度饥饿并发/端到端),
测试方式与预期结果逐条写明;M0–M7 里程碑逐步实现。
公开 SDK 在 v1 保持冻结(diff 必须为 0)。
2026-09-12 23:39:10 +08:00
163c5f70b3 chore(sdk-mirror): 同步 vendored SDK 到 SDK main(browser_search 现代 Bing 版式解析修复)
镜像追平 SDK main(`ebd700e`):
- browser 示例:browser_search 三处根因修复(www.bing.com 302 → cn.bing.com;
  标题取 h2 > a 而非块内第一个 <a>;摘要兼容 p.b_lineclamp*)并解开 /ck/a 跳转包装,
  解析不出结果时显式报错;附真实响应夹具与 5 项单测;版本 2.4.0 → 2.4.1。

注:SDK 仓新增的 example/deepsearch(联网检索 + SearXNG 生命周期托管)与
example/vikunja(任务管理)按本仓策略(.gitignore 忽略 example/ 下未跟踪文件)不入镜像。
2026-09-12 23:39:10 +08:00
ca5e62f775 docs(plan): 记忆与多模态目标纠正(媒体为一等节点、同一指纹空间、音频 unsupported)
把计划里过时的描述式索引路线改正:媒体/媒体块是 L3 一等节点与原生边,
multimodal doc/context 的向量与裁剪须与 text/媒体块在同一指纹空间共同参与;
音频在该模型下明确 unsupported(不做文本描述式索引)。
2026-09-12 20:21:01 +08:00
07e08352b9 feat(waiter,devicebridge): 设备桥连接带授权态,命令处理器类型统一
- `runDeviceBridgeLoop`/`connectDeviceBridge` 增加 `authorized` 入参(未授权不再尝试建立桥连);
- `Bridge.OnCmd` 的处理器类型改为具名 `BridgeCmdHandler`,与 waiter 侧签名对齐。
2026-09-12 20:21:01 +08:00
8f40b91dea feat(ohos): 设备桥会话管理、状态/设备信息载荷与能力路由完善
- 新增 `DeviceBridgeSession.ets`:会话生命周期(创建/复用/回收)与 TTS 会话显式 shutdown;
- `BridgeCaps`/`BridgeRouter` 与内核实际支持的本机命令保持一一对应,补齐 status/deviceinfo;
- 页面与状态存储调整(DevicePage/Index/SettingsPage/StatusStore/SubPage)、新增 ScreensuePage;
- module.json5 与 string.json 同步(新增页面与文案)。
2026-09-12 20:21:01 +08:00
d1662e77cf chore(sdk-mirror): 同步 vendored SDK 到 SDK main(qq 插件重写、browser 会话超时必填、路牌 1.3.0)
核心仓 `third_party/homeagent-sdk` 是 SDK 仓的 vendored 副本,内容以 SDK 仓 main 为准
(本仓 `.gitignore` 忽略 example/ 下的未跟踪文件,已跟踪的镜像文件用 `git add -u` 更新)。
本次把镜像追平到 SDK main(`8c10b7e`):

- qq 插件重写(+498 行):`output_send` 回声/自激路径的根因修复与去重;
- browser 示例:交互式会话 `timeout` 由默认 10m 改为**必填**(附参数校验与测试);
- `meta.Version` 1.2.0 → **1.3.0**(SDK v1.2.0 已定版,SDK main 按纪律推进到下一个中版本)。
2026-09-12 20:21:01 +08:00
dc4982464d fix(packaging): SHA256SUMS 只列本批产物,且用平铺名(此前会带上历史版本、且与附件名不符)
v1.2.2 出包时发现:dist/ 跨多次构建累积,而清单用 `find $DIST_DIR` 全目录扫,
于是 SHA256SUMS 里混进了 1.2.0/1.2.1 的包名——用户从发布页下载这份清单后
`sha256sum -c` 必然报「文件缺失」(那些包并不在本页)。

两处一起修:
- 按本批 `$PKG_VERSION` 过滤(只列这次真正打出来的产物);
- 名字用 basename(平铺名),与发布页附件名一致;哈希取真实路径(此前若直接
  对 basename 求哈希会找不到文件——就在 `deb/`、`tar/` 子目录里)。

验证:对含 1.2.0/1.2.1/1.2.2 的 dist/ 跑新逻辑 → 4 条(旧逻辑 12 条);
并拿发布页真下载的 SHA256SUMS 逐条核对四个产物的实际哈希 → 全部一致。
2026-09-12 19:18:26 +08:00
fe883d5362 test(plugins): 真实插件产物先校验平台再 exec,错误平台给可读提示而不是 exec format error
排查知识库改动是否引入回归时,internal/plugins 6 个测试全红、报
"fork/exec .../plugin.bin: exec format error"。查了半小时才发现与代码无关:
早前验证跨平台示例构建时,最后一次构建(darwin/arm64)把
`example/*/build/plugin.bin` 覆盖成了 Mach-O arm64,而 realPluginBinary
只按文件名找候选、**不校验平台**,于是拿 macOS 产物在本机 exec。

两个陷阱一起堵:
- 校验魔数(ELF / Mach-O / PE),平台不符则 **SKIP 并给出可直接粘贴的重建命令**
  (`hmapdev build --target <host> --no-bundle`),不再用误导性的 exec format error;
- 明确写出「产物缺失即 skip」的语义,避免"全绿其实什么都没验"
  (本次对照实验里,改动前的 worktree 因无产物而全绿,看着像通过)。

反向验证:把 weather 产物换成 PE 假头 → 相关测试转为 SKIP 且提示平台与重建命令 ✓。
2026-09-12 18:38:24 +08:00
5fbd6514c2 fix(knowledge): 知识库检索改为「稠密 + 词法」两路融合(真实 KB 自检索 MRR 0.271→0.376)
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手:

- **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致;
- **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索
  top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声;
- 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。

先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、
去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。

改动:
- `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。
  权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** /
  0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目
  (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build);
- `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级
  只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的
  差距来源,且当时没有任何报错;
- Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。

**顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为
writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志,
且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush
(buildTreeLocked / writeIndexLocked)。

判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索
top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。
另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、
词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。

**反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、
把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值,
注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据
(语义层由 vector 包测试证明、接线层由知识库测试钉住)。

顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量,
生产改动当时已提交,测试一直未入库)。
2026-09-12 18:14:22 +08:00
5aaae93367 feat(healthcheck): 内核状态快照报出内核版本号与 ONNX 模型启用状态
healthcheck_kernel 此前没有任何「ONNX 模型是否在用」的信息,只报「向量可用/不可用」,
分不清「统一多模态空间已加载」与「退回到词嵌入/TF-IDF 路径」;人格卡要求
「版本以运行时快照为准」,也缺一个可查字段(build.version 早就在,但没人知道)。

- KernelStatus 新增 onnx 段:enabled / provider / dim / fingerprint / modalities / reason。
  判据取 Loaded()(provider 真正打开且元数据合法),**不是**「配置里写了 provider」
  —— 后者在模型缺失 / 运行时缺失时也为真,报出去就是假绿。
- 未启用时 reason 给**具体原因**:未配置(说明会走回退路径)/ 打开失败的具体错误。
  homed 把「配置的 provider 名」与「打开失败原因」透传给 Agent,仅供状态报告。
- ProviderAdapter 新增 Modalities()(可选能力,按接口断言取用,不改公开契约)。
- healthcheck_kernel 的工具描述同步说明它回答这两件事。

**顺带修一个真实 panic**:collectKernelStatus 的 knowledge 是**接口**参数,
(*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic,
而 healthcheck_kernel 正是走这条路径(panic 发生在工具 goroutine 里)。
GetKernelStatus 改为先按具体指针判空、再赋给接口;并加刻画测试钉住这个成因
(一旦不再 panic 说明参数形状已变,守卫与该测试应同步删除)。

验证:单测 4 例(已启用 / 打开失败 / 未配置 / 未加载)+ 刻画测试;
隔离实例 E2E 7/7:正例 provider=chineseclip → enabled=true、dim=512、模态 2;
反例 provider=nonexistent → enabled=false 且 reason 含具体错误与 provider 名,
真实对话仍通。
2026-09-12 14:56:25 +08:00
46e014a8ca feat(persona): 首启人格门禁跨通道化 + 内核 persona_set 工具
WebUI 首启向导只覆盖 WebUI 这一条通道,而「人格该问一次」是所有通道的事:
走 QQ / CLI / ACP / 邮件来的人永远见不到那个向导,人格就永远是没确认过。

- 门禁移到 buildSystemPrompt(每轮重建 → WebUI/QQ/CLI/ACP/邮件全覆盖),
  以 core.internal.persona_initialized 为准:未确认时要求模型主动询问用户
  (默认 / 自定义 / 以后再说),确认后该段消失;personaStore 为 nil 时静默关闭。
- 新增内核内置工具 persona_set(mode=default|custom|later[, content]),
  落库逻辑与 WebUI 向导**共用 internal/config**(一个实现 + 两个薄入口:
  ConfigRegistry 直连 / 插件侧 SettingsAPI),避免两套语义各自漂移。
- AgentConfig 增加 PersonaStore 接口,cmd/homed 用 RegistryPersonaStore 实现。
- 非法输入(未知 mode / custom 空内容)在打标记**之前**拒绝:否则标记置位、
  向导被跳过,用户再没机会设。

E2E(隔离实例 + 真实 LLM 往返走 /v1/chat/completions,/var/tmp/persona/e2e.sh)9/9 PASS:
未确认时模型主动询问 → 用户答「用默认的」→ 模型调用 persona_set 落库并置位标记
→ 之后不再追问;反向对照(清标记 + 清会话上下文 + 重启)重新开始询问,
排除了「同一段对话里已问过」这一混淆。
2026-09-12 13:57:06 +08:00
ea21803acb docs(branching): 明确开发者文档的发布归属——以 rel 分支的形态为准,再合入 main
用户裁定:开发者文档应当在每个 rel 分支被修正为对应 rel 的形式,随后合入 main。

新增 §二.7,写清:
- 规则与做法(release 上按本版口径改 → cherry-pick 到 main,遵守 §三 只 pick 不 merge)
- 为什么不能直接改 main:main 语义是「下一个未发布版本」;assets/docs 会随发行包
  分发并在 WebUI 被阅读,服务的是「这一版」;版本号/工具名/机制有无都随版变动
- main 上描述「下一版才有」的行为必须显式标注(如「(下一版)」)
- 反例表(本仓真实踩过):人格卡写死 v0.9.0 + 已删除的 C ABI、架构文档把已移除的
  描述式索引/引用计数写成现行、README 停在旧版本
- 配套硬约束:任何会被当作事实的文本不得写死版本号,须插值或读运行时快照并加测试
2026-09-12 13:05:24 +08:00
10367b384e feat(webui): 首启人格向导(默认 / 自定义 / 稍后)+ 一次性标记
接续人格配置项化(597f07c):现在人格是 core.agent.personal_prompt,
本次加上「首启问一次」的界面,之后不再打扰。

后端(GET/POST /api/v1/persona):
- GET  → {initialized, current_prompt, file_override}
        未设置时 current_prompt 回落到内置默认模板;存在 personal/personal.md
        时报告 file_override(它会覆盖配置项,向导据此提示用户)
- POST → {"mode":"default"|"custom"|"later","content":"…"}
        写配置 + 打一次性标记 core.internal.persona_initialized;
        custom 返回 restart_required=true(人格在启动时载入);
        「稍后」= 保留当前默认 + 打标记,**绝不阻塞任何流程**
- 空内容的 custom 与未知 mode 一律 400,且**不打标记**(否则向导会被跳过)

前端(dashboard.html):
- 首启拉一次 /api/v1/persona,未初始化则弹向导(复用一直没人用的 .confirm-* 样式)
- 「自定义…」第一次点击展开文本域并预填当前人格,再次点击才提交(避免误提交)
- 中英双语走既有 __() 机制;保存失败/空内容用 toast 提示

测试:TestPersonaWizardFlow(首启状态、later 打标记不改人格、custom 写入 + 需重启、
空内容与未知 mode 被拒且不打标记)、TestPersonaWizardReportsFileOverride。

E2E(真实实例):首启 initialized=false → POST later → initialized=true,
config 中标记=1、人格键为默认模板;前端页面含向导函数。
2026-09-12 13:02:18 +08:00
ce8bc27db8 docs(architecture): 记忆流转图对齐统一多模态空间
流程图里 Context/Prune/DocStore 三行仍只写 StaticEmbedder 与 TF-IDF,
读起来像"向量化只有词嵌入一条路",与 1.2.0 实际(多模态统一空间为主,
带 fingerprint;词嵌入/TF-IDF 是降级层)不符。

- Context Append:补三层向量层级说明
- Context Prune:改为 DenseCosine(仅同指纹比较)→ StaticEmbedder 回退
- DocStore:改为稠密向量 + dense_fp 同指纹要求(不符即重算)
- 中英双版同步
2026-09-12 12:46:21 +08:00
7318a10828 feat(persona): 人格设定配置项化 + 默认模板契约测试 + 腐坏告警
起因(v1.2.0 压测):线上实例内核日志/接口都报 1.2.0,agent 被问版本时却按人格卡
自述 v0.9.0 + C ABI v2(该机制 v1.0.0 已删除)。根因是人格只有「文件」一个来源且无人
维护——写死的版本号必然随发版腐坏。

改动:
1. 新增配置项 core.agent.personal_prompt(多行文本),默认值为内置模板
   config.DefaultPersonaPrompt,随其它默认值同批播种(老安装不注入,语义不变)
2. 默认模板**不含任何版本号字面量**,并显式要求「被问到版本/构建信息时以运行时快照
   (healthcheck_kernel)为准」——从根上消掉这类腐坏
3. 人格来源优先级:personal/personal.md(存在且非空)> 配置项 > 无
   启动日志明确打印来源;文件含腐坏内容(版本号字面量 / 已删除机制的说法)时告警并
   建议迁移到配置项
4. internal/agent.PersonaStaleHints:腐坏检测(版本号正则 + 已删除机制词表)

契约测试(防复发):
- TestDefaultPersonaPromptHasNoVersionLiterals:默认模板不得含 v?\d+\.\d+\.\d+,
  且必须含「运行时快照」要求
- TestPersonaPromptRegisteredWithDefault:注册存在、默认值一致、播种真的写入
- TestPersonaStaleHints:线上人格卡原文必须被识别(v0.9.0 / C ABI v2),干净文本不误报

验证:go build ./cmd/homed ok;go vet 三个包 ok;go test ./internal/config ./internal/agent ok;
端到端两场景(无文件→来源=配置项 1307 字节;有旧文件→来源=文件 + 告警列出 v0.9.0 与 C ABI v2)。
2026-09-12 12:42:43 +08:00
b15d0bd114 refactor(plugin)!: 重编提示与模板路径改用 hmapdev;文档全面对齐 1.2.0
工具链在 SDK 1.2.0 更名为 hmapdev(原 plugindev)。核心侧三处功能耦合同步:

1. 用户可见报错:旧 C ABI 产物 / 协议版本不匹配 / 共享段版本不匹配
   三处「请用配套 plugindev 重编」→ hmapdev(对应两条测试断言同步)
2. e2e_template_test 的模板路径改为 tools/hmapdev/templates,
   并保留旧路径回退(旧 SDK 检出仍能跑测试)
3. 注释与文档同步

文档更新(用户可见面):
- assets/docs/{zh,en}/PLUGIN_DEV.md:工具链章节整体改为 hmapdev,
  补改名说明与 SDK 存储目录迁移;命令示例全部更新
- assets/docs/{zh,en}/ARCHITECTURE.md:**流程图与章节对齐 v1.2.0** ——
  · 向量化章节改为三层降级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退),
    写明「同指纹且同维度才参与融合」
  · 媒体记忆章节重写:媒体是一等记忆块(无独立 GC / 无引用计数 / 无描述式索引 /
    正文不再写 media marker),并写明 reembedStaleMedia 的跨空间迁移与写回
- README{,_EN}.md、docs/zh/plugin-interface-matrix.md:工具名与模板路径同步
  (历史条目标注「当时名为 plugindev」)

验证:go test ./internal/plugin/ ./internal/plugin/proc/ ok,
含 4 条真实模板 E2E(模板路径切换后仍通过)。
2026-09-12 12:42:43 +08:00
34628720f2 fix(memory): 修 beta.2 压测发现的三个向量/文档缺陷
来源:v1.2.0-beta.2 全方位压测(报告 /var/tmp/stress/REPORT.md)

1. 文档向量迁移结果不落盘(生产已复现)
   - BuildDenseIndex 改完内存不置 dirty;docStore.Stop() 全仓无调用者 → flush 成死代码
   - 后果:每次启动重算同一批文档(线上 496 篇约 17s),磁盘 dense_fp 永不收敛
   - 修:迁移当场落盘(抽出 flushLocked 以免重入锁)+ main.go 关停链 defer docStore.Stop()
   - 生产证据:线上 488 篇文档仅 4 篇含 dense_fp,且这 4 篇均为运行期 Insert 的新文档
2. 块指纹对但维度错时污染文档向量(健壮性缺口)
   - denseFor 只校验 b.Fingerprint,不校验长度;FuseVectors 取最大维度并跳过长度不符者
     → 512 维文本 + 2048 维块 = 2048 维且打上当前指纹
     → 该文档在检索侧被长度守卫永久跳过,且每次启动重算(不收敛)
   - 修:denseFor 要求 len(b.Vector) == 空间维度
   - 可达性:内核两个块产出点均成对取自同一行(it.Vec ↔ it.VecModel),故属防御性修复
3. Insert 与 loadAll 的 ID 约定不对称(低)
   - Insert 落盘任意 <id>.json,loadAll 只加载 doc_ 前缀 → 自定义 ID 文档重启后静默消失
   - 修:loadAll 只要求 .json(空 ID 仍跳过)

回归测试 4 条:迁移跨重启落盘 + 已对齐 0 重算(用向量空间调用计数判定,
不靠日志)、坏块不参与融合且同维度正常块仍参与、自定义 ID 可加载、Stop 落盘。

反向验证(纪律要求):临时回退本次修复后,前 3 条均变红且报错正是缺陷签名
(dim=999/space-OLD-999 永不收敛、文档向量 2048 维、文件重启后消失);恢复后全绿。

验证:go build ./cmd/homed ok;go vet ./internal/memory/document ./cmd/homed ok;
go test ./internal/memory/... 7 包全绿。
2026-09-12 11:05:11 +08:00
85223fc1c9 docs(matrix): 记录 v1.2.x 的接口扩展,以及本次审计抓出的两处漂移
§九 原本只写到 v1.1.x。补上 1.2.0 的接口增量(注入侧的 InjectOptions 与六个
*Opts 变体、ContextPolicy 取值、ChannelDef.ContextPolicy 与它的 JSON tag),
并明确一件容易被误读的事:**「接口纯追加」不等于「无需重编」**——同版把插件运行
协议升到了 2(fd3 布局改变),协议不匹配会在握手时被明确拒绝,这两件事必须分开说。

同时把这次扩展自己抓出来的两处漂移入档(都属于本节第 3 条要防的类型):

1. 模板接线守卫 `TestProcTemplate_CoversAllCoreMethods` 红了:模板不再发
   io.injectTextNoMem(改走 io.injectText + NoMemory),而内核保留该 id 是刻意的
   向后兼容面。修的是判据(显式 deprecated 表 + 反向保护)。
2. mocksdk 与公共 SDK 机械求差,差集为旧的三参数 InjectInputSync(通道类插件闭环
   要调的方法);git log -S 证实从来就缺,已补齐。

验证表按**本机实跑结果**填写:示例 vet 17/17、plugindev 测试全绿、
sdk -race -count=5 通过、mocksdk 差集为空。
2026-09-12 09:41:39 +08:00
dc80855540 feat(status): 暴露构建源码地址,WebUI 状态页给出 AGPL §13 的源码入口
选了 AGPL-3.0-only 之后,§13(Remote Network Interaction)就不只是声明问题:
把修改过的版本作为网络服务提供出去时,必须给使用者取得 Corresponding Source 的机会。
只在仓库里放 LICENSE 并不自动满足这一条——**使用者拿到的是服务,不是仓库**。

所以把它做进产品里,而不是写进文档就算完:

- `internal/meta.SourceURL`:本次构建对应的源码地址,默认指向本仓库。
  注释里写明「修改后对外部署的分支必须改指向自己的仓库」,并给出 -ldflags 覆盖方式
  (`-X .../internal/meta.SourceURL=<你的仓库>`),不需要改源码。
- `BuildStatus.SourceURL`(`json:"source_url,omitempty"`)+ 内核状态填充:
  走已有的 `/api/v1/kernel` 构建身份链路,不新增端点。
- WebUI 状态页在「版本」行下渲染「源码 / Source」链接(URL 经 escHtml 转义后进属性;
  `target="_blank" rel="noopener noreferrer"`)。
- 用 `omitempty`:未注入该值的旧构建不会在 JSON 里多出一个空字段。

验证:
- `internal/plugins/webui/dashboard.html` 的两个内联 script 块 `node --check` 均通过;
- 全仓库 `go build ./...` 通过,`internal/meta/meta.go` gofmt 干净
  (`internal/agent/core/status.go` 有**既有**的 gofmt 差异,与本改动无关,按纪律未整体重排);
- 端到端:本地构建 homed → 冷启动 → `GET /api/v1/kernel` 的 `build.source_url`
  实测为 `https://gitcode.com/JianFeeeee/HomeAgent`。
2026-09-12 09:41:39 +08:00
0b1c201b5c docs: README 更新到 v1.2.0,并纠正已在 v1.2.0 删除的机制
README 的「项目状态」停在 **v1.1.1**,而且其中两条描述与当前实现**相反**:
「媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中,描述是可检索的
语义记忆」与「引用计数式 GC(有引用者绝不删)」——这两套机制正是 v1.2.0 拆掉的。

本次不重写历史条目(它们记录了演进),而是:

1. 「项目状态」顶部新增 **v1.2.0** 条目:统一多模态向量空间(模型中立 SPI +
   Chinese-CLIP 默认,含「纯文本语义弱于 MLLM 型嵌入器」这一已知代价)、
   媒体升为图记忆一等节点(并写明具体删掉了什么)、数据面全量走共享内存
   (协议 2、不支持滚动升级)、注入标志位、发行包默认启用 ONNX 且随包模型、
   homed 改走 WSL2、以及三个安装链静默失败的修复。
2. 在 v1.2.0 条目后加一条显式提示:下方历史条目中的「描述式索引」与
   「媒体引用计数式 GC」**已在 v1.2.0 移除**——避免读者按旧文档理解现行行为。
3. 「下载」:Windows 安装器的版本号改到 v1.2.0,并写明自 v1.2.0 起因 homed
   不再支持 Windows 原生,安装器改为引导到 WSL2 并在其中按 Linux 方式安装;
   同时注明安装器含 AGPL 许可页。

中英双份同步。SDK 仓 README 另在 SDK 仓提交(d893bfa)。
2026-09-12 09:27:28 +08:00
5e10f012cb docs(license): 核心仓采用 AGPL-3.0-only,并写进包内与安装器
本仓此前**没有任何许可文件**,README 里也没有许可声明,而 rpm 元数据里甚至写着
`--license "Proprietary"`(与我们实际的分发意图相反)。

## 选择了什么

`LICENSE`:GNU Affero 通用公共许可证第 3 版官方全文(gnu.org 正本,
661 行 / 34523 字节,
sha256 0d96a4ff68ad6d4b6f1f30f713b18d5184912ba8dd389f86aa7710db079abcb0)。

选 AGPL-3.0-only 的理由:GPL 家族里**传染性最强**的一档,并且不允许选后续版本。
它比 GPL-3.0 多出 §13(Remote Network Interaction)——通过网络提供服务时也要向
使用者提供源码。这正是「最严格」在 GPL 家族里的落点。

依赖许可已核对为全部宽松且兼容:go-sqlite3 / gojieba / gopher-lua / bubbletea /
bubbles / lipgloss / yaml.v3(MIT)、golang.org/x/{sys,text}(BSD-3)、
Chinese-CLIP 产物(Apache-2.0,与 GPLv3+/AGPLv3 双向兼容)、ONNX Runtime(MIT)。
没有 GPL-2.0-only 这类与 AGPL 不兼容的依赖。

## 落在哪些地方

- `README.md` / `README_EN.md`:新增「许可 / License」章节,写明 §13 的含义、
  插件因**静态链接 SDK 源码**而成为衍生作品须同许可发布、以及随包第三方组件清单
- `deploy/packaging/package-linux.sh`:
  · 新增 `stage_license()`,**四个变体(full/server/client/tar)全带**
    `/usr/share/doc/homeagent/{LICENSE,copyright}`(copyright 为 DEP-5 机器可读格式,
    含第三方条目)
  · fpm 的 `--license "Proprietary"` → `"AGPL-3.0-only"`
- `deploy/packaging/installer.nsi`:新增 MUI 许可页
  (`..\..\LICENSE`,NSIS 以 .nsi 所在目录解析相对路径)
- `third_party/homeagent-sdk/LICENSE`:vendored SDK 的许可一并入库 —— 本仓
  `.gitignore` 有意不镜像 SDK 的 README/tools/package/example,但依赖的许可
  应当随依赖可见

## 待办(下一步)

README 的「项目状态」仍停在 v1.1.1,且写着已被 v1.2.0 **删除**的机制
(引用计数式 GC、`[<mime> <digest>] <描述>` 描述式索引)——单独一个提交修。
2026-09-12 09:17:03 +08:00
727768e083 docs(git): 修正上一提交的错字(但声两仓 → 但两仓) 2026-09-12 09:01:59 +08:00
0247206dfe docs(git): 写清「两仓 main 同步推进」的前提,修正 SDK 路牌
§七.4 原来只说「在 1.1.x 线发布期间,两仓 main 上的值都是 1.2.0」,容易被读成
「两仓 main 永远同值」,我正是据此把 SDK main 也推到了 1.3.0(已回退为 1.2.0)。

补写前提:推进以**该中版本已正式发布**为条件。
- 核心切出 release/v1.2.x 后 1.2.0 归发布线所有 → main 立即到 1.3.0(beta 也算占号)
- SDK 因 §七.2(beta 不发 SDK)要等核心正式 tag 才定版 → 在那之前 main 停在 1.2.0

并明确:**此阶段核心 main(1.3.0) 与 SDK main(1.2.0) 故意不对称**,
不是遗漏同步。§三 的 SDK 表行同步修正。
2026-09-12 09:01:34 +08:00
fbd3adabea fix(sdk-mirror): vendored SDK 路牌回到 1.2.0(SDK 不跟 beta 发版)
SDK 仓 44bd915 把 main 的 meta.Version 推到 1.3.0 是错的(12cabcb 已改回 1.2.0)。
按 §七.2,beta 不伴随 SDK 发版:SDK 1.2.0 要等核心的**正式** tag 才定版打 tag
(§七.3),在那之前 1.2.0 仍是 SDK 尚未发布的中版本,路牌不得越过它。

核心 main 的 1.3.0 不受影响(1.2.0 已归 release/v1.2.x 所有),两仓在此阶段
故意不对称——这一点已写进 SDK meta.go 的注释,避免再被「对齐」回去。

本提交只同步 core 里跟踪的镜像:sdk/ 目录与 SDK 仓仍然逐字节一致。
2026-09-12 09:00:41 +08:00
f778c613f1 docs(git): 1.2.x tag 历史补上 v1.2.0-beta.1
release/v1.2.x 已按 §2.4 走出 beta 通道:tag v1.2.0-beta.1(提交 215804c),
注释里写明通道、不兼容点与验收证据;beta 阶段不发 SDK(§七.2)。
正式 tag 待试运行无回退问题后再打,届时同步 SDK 仓。

同时把 tag 名资产整理记录在案:历史大写 tag V0.8.0 / V0.7.1 已按用户确认删除
(提交本身未受影响),现在主仓 tag 全部符合 SemVer 小写 v 约定。
2026-09-12 08:54:09 +08:00
7a418346a4 build(packaging): 统一 -buildvcs=false,版本/提交只认 ldflags 注入
发布分支的产物上出现了 `vcs.revision=1715b5c`——一个本机任何仓库都不存在的提交。

原因:VCS 信息**不进 build cache key**(Go 文档明确说明 VCS 变化不会触发重建),
命中缓存时会把上一次的 revision 一并带回来。

而 build.sh 本来就用 ldflags 注入 meta.Version / meta.Commit(权威来源),
所以这个额外信号既不可靠又会误导溯源:拿 `go version -m` 去查源码提交,会指向
一个幽灵提交——正是本项目一直在治的「静默不一致」。

处置:三处 go build 统一 `-buildvcs=false`,并在 LDFLAGS 旁写明溯源方法
(`strings homed | grep -x '<短 hash>'`,meta.Commit 是字符串常量)。

验证:重新构建 homed linux/amd64 →
  · `go version -m` 中 vcs.revision 0 处(此前 1 处且是错误值)
  · strings 中恰好 1 处等于当前 HEAD 短 hash
  · `-tags=onnxruntime` 仍在
2026-09-12 08:42:18 +08:00
a070cb563d docs(git): 分支对齐更新到 2026-09-12(1.2.x 线开启)
- main 路牌调到 1.3.0;release/v1.2.x 承载 1.2.0(vendored SDK 定版 1.2.0)
- release/v1.1.x 按 §2.6 退役(保留供追溯);两个历史 feature 分支
  (memory-media / plugin-proc-migration)已从远端删除,旧表待删项清掉
- 新增「1.2.x 发布线 tag 历史」表(当前尚无 tag),并写明它与存量插件
  **不兼容**(RPC 协议 2、fd3 布局改变、不支持滚动升级)以及
  **不满足 §2.4 跳级条件**的理由(改动面大,非单点修复)
- SDK 仓的 release/v1.2.x 尚未创建:按 §七.3 随核心**正式** tag 一起做
2026-09-12 08:39:05 +08:00
685ed7e4a0 fix(knowledge): 覆盖同名条目时摘掉旧向量
从一次真实的知识库更新里发现:在线实例更新一个已有条目之后,
knowledge_count=32 而 vector_count=33——多出来的那一条是上一版的副本。

成因:vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id 去重;
而 Store.Add 走的是「写 content.md + 覆盖 items[id] + Insert 向量」。
文件与内存条目都被正确替换了,只有向量索引多留了一份。

危害不在于多占内存:**检索可能命中已被替换掉的旧内容**,而且完全静默——
条目数看起来是对的,只有向量数比条目数多。

修法:Insert 之前先 s.vec.Remove(id)(Remove 已按 id 过滤 docs 与倒排索引)。
回归测试 TestAddOverwriteReplacesVector 钉住 knowledge_count / vector_count /
content.md 三者都必须只剩新版。

注:该文件在 origin/main 上本就有 32 行 gofmt 差异(结构体字段注释对齐),
不属本次改动,按纪律不做整体重排。
2026-09-12 08:30:22 +08:00
dd99105d66 fix(build): GUI 输出目录用 --config.directories.output,-o 是 --mac 的别名
electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的
Building 段),不是 output。于是 `-o "$BUILD_DIR"` 被当成 macOS 的 target
列表,报:

  ⨯ Unknown target: /home/program/trueagent/build

路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的
——这也是它看起来像「路径错」而实际是「参数位置错」的原因,v1.0.1 与
v1.0.3 两次发布都因此手工组装过 GUI。

改用 --config.directories.output=<dir>,已实测确认产物落在指定目录。

同时把 GUI 构建失败降级为警告:homed/waiter/initconfig 是发布主体,
而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
set -euo pipefail 下不接住的话,一个可选组件会让整轮跨平台构建全废——
v1.0.3 就是这样只产出了 linux/amd64 三个二进制、arm64 与 windows
压根没跑到。
2026-09-12 08:26:12 +08:00
1fb5700662 chore(meta): main 的版本路牌推到 1.3.0
按 docs/git-branching.md §2.1,main 的 meta.Version 始终是**下一个未发布中版本**。
1.2.x 线已开(release/v1.2.x 承载 1.2.0),所以 main 指向 1.3.0。

它标记「main 正在积攒 1.3 的东西」,不表示 1.3.0 已经存在——1.3.0 没有任何 tag。
1.2.0 已由 release/v1.2.x 承载;main 不能再挂着 1.2.0,否则 main 的版本号
就与某一次发布的版本号相同,违反 §2.1「main 的版本号不是任何一次发布的版本号」。

两仓同步:SDK 仓 main 也在同一时刻推到 1.3.0(commit 44bd915)——SDK 版本跟随
核心中版本(§七.1),且两仓 main 都表示下一个未发布中版本(§七.4)。
本 commit 同时把 vendored SDK 镜像(third_party/homeagent-sdk/meta/meta.go)
更新到与 SDK 仓一致。

SDKCompatibleVersion 保持 1.2.0:那是本内核**实际实现并兼容的最高 SDK 接口版本**,
与「下一个未发布中版本」是两件事(参见 e4be966 时 main 的 1.2.0 / SDK 兼容 1.1.0)。

**此 commit 不 cherry-pick 到发布分支**(§五:版本号 bump 不跨分支搬)。
2026-09-12 08:25:30 +08:00
f1091676f8 style: gofmt 两处对齐(graphmedia_test.go 注释、tfidf.go 结构体字段)
这两处是本次特性分支带入的格式回归(origin/main 上不脏)。
纯空白/对齐调整,无语义变化;不整体重排文件。
2026-09-12 08:23:30 +08:00
eb4762a2b8 merge: 多模态统一向量空间与子进程数据面收敛(feature/multimodal-embedding)
合入 43 个提交,主线内容:
- 统一多模态向量空间:公共 provider SPI(pkg/embedding)+ 注册表,
  chineseclip 为 text+image 默认空间(512 维,Apache-2.0),qwen3vl 保留
- 共享内存接管全部数据面:工具调用帧、Cleaner、输入/输出 lane、媒体块、
  文档/知识正文;RPC 只传偏移描述符(协议版本 2)
- 图记忆原生媒体节点:媒体是一等节点与边,删除 media_refs 与描述式索引
- 注入行为可声明 no_memory / context_policy(默认不裁剪)
- SDK 1.2.0:注入标志位纯追加 + 示例 hmap 随发版
- 发行包默认启用 ONNX 向量空间;本批起模型与运行库随 server/full 包发布
- homed 放弃 Windows 原生支持,改走 WSL2
2026-09-12 08:21:34 +08:00
4d2028fbef chore(sdk): 同步 vendored SDK 镜像到 1.2.0
core 仓里跟踪的 SDK 镜像落后于 third_party/homeagent-sdk(那是个独立仓库):
已提交的 sdk/memory.go 仍带 MediaAttachment.Description,而 SDK 仓 b2eafdf 已把它
删掉(媒体不再以文本描述参与索引)。磁盘上的文件其实就是 SDK 仓当前的版本,
只是 core 的索引没有跟上——于是**干净检出 main 拿到的是一个与它构建所用 SDK
不一致的镜像**。

clean worktree 能编译(内核已不再引用 Description),所以这个不一致不会被构建
发现,只能靠比对发现——属于本项目一直在治的「静默不一致」。

同步内容:sdk/memory.go 与 SDK 仓 HEAD 逐字节一致。
2026-09-12 08:21:21 +08:00
d4c5e808c7 feat(packaging): 模型与 ONNX Runtime 随 server/full 包发布
模型与运行库是发行版能力的一部分,不做成「装完再自己下载」:

- package-linux.sh:新增 stage_multimodal_assets(),打 server/full 前校验产物
  SHA256SUMS、逐文件非空、运行库架构与目标一致,缺一即失败;client 包不含。
  顺带修掉三个让打包在最后一步才炸的既有缺陷:
  · 版本串直接取 git describe(v1.0.0-68-gxxx-dirty)不是合法包版本——deb 要求
    数字开头、rpm 不允许 '-'。以前只有显式 VERSION=1.0.3 才打得出来;默认路径
    从来没通过过。现在归一化,非数字开头时显式报错。
  · 三处 mktemp -d 落在 /tmp(本机 9.8GB tmpfs),而 staging 要复制 719MB 模型,
    中途 ENOSPC;报错文本指向某个 .onnx 文件,看着像资产坏了。改为落在与构建产物
    同盘的 build/.stage-tmp。
  · 开工前删掉旧的 SHA256SUMS:失败时脚本直接退出、不重算,留着像在为残缺产物背书。
- setup.sh:把包内 /usr/lib/homeagent/models/chinese-clip-vit-b16-onnx 软链到
  <dataDir>/models/…(不复制 754MB、保持 dataDir 可迁移、已有自定义目录不覆盖)
- homeagent.service:ExecStart 改 /usr/bin/homed(deb 装在那里,此前写 /usr/local/bin,
  装了也不会被 unit 用上)、加 ONNXRUNTIME_DIR 与 StateDirectory、MemoryMax 2G→8G
  (实测常驻约 4.5GB,2G 会在首次全量建索引时被 cgroup OOM)
- control-{full,server}:补 libstdc++6 / libgcc-s1(libonnxruntime.so 需要)
- providers/{chineseclip,qwen3vl}:findOnnxLib 支持 ONNXRUNTIME_DIR / ONNX_ML_DIR
  与包内 /usr/lib/homeagent/onnxruntime,随包的运行库才真的会被用上
- postinst:修掉两个让「装完即用」失效的点——它检查 /lib/systemd/system/ 下的 unit
  而 deb 装到 /etc/systemd/system/,于是 daemon-reload/enable **从未执行**;以及
  setup.sh 的失败被 `|| true` 吞掉(正是 initconfig 静默缺陷被藏住的原因)。现在
  三个候选路径都查、失败可见并给出补救命令、首装 start / 升级 restart。
- docs/zh/multimodal-space.md:新增「随包分发」一节,并修正播种判据的说明

验收(从真实 deb 走一遍,不是读脚本):
- 包内 initconfig 已是动态链接,凭据真的写进 config.db
- 解包 → 按 postinst 顺序跑 setup.sh → 包内 homed 冷启动:
  multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990
  modalities=[text image]
- 全新安装的默认值确实被播种(core.plugin.dir / provider / model_dir 都在)
- 真实对话拿到回复(3.4s,回复中含唯一标记)
- 包内模型 SHA256SUMS 5/5 通过;包内 ORT 与源同 sha256;server 包 722MB
  (旧版 17MB,差额即模型与运行库);full 包同样含全部资产;client 包不含
2026-09-12 08:10:44 +08:00
5299e18cd8 fix(config): 全新安装的默认值播种判据改为显式标记
播种判据曾经是「config 表为空」。而发行包的 postinst 先跑 setup.sh →
initconfig,后者会写一行 webui.listen_addr,于是**全新安装**被判定为
"已有配置"并整体跳过播种:没有 core.plugin.dir(装完 0 个插件)、没有
core.memory.* 路径、也没有随包模型对应的多模态 provider——754MB 产物与
24MB 运行库全成死重量。同样的机制此前已在协议 2 迁移演练里被观察到。

也不能改成"每次都补缺键":老安装升级时被注进新默认值,会让它突然去加载
一个 1.8GB 的模型,那是刻意要避免的静默变重。

故改为显式标记 core.internal.seed_version:
  有标记                        → 已播种,返回
  无标记但有 core.daemon.data_dir → 老安装,只补标记、不播种
  两者都没有                    → 全新安装,播种并打标记

测试:TestSeedDefaultsAfterInitconfigPrepopulate(精确复现 initconfig 的
那一行写入)、TestSeedDefaultsDoesNotInjectIntoLegacyInstall。
2026-09-12 08:10:34 +08:00
6a3439a49c fix(initconfig): 必须带 cgo 构建,且失败不再静默
cmd/initconfig 通过 database/sql 使用 mattn/go-sqlite3,而 build.sh 一直用
CGO_ENABLED=0 构建它:该库在非 cgo 下退化成 static_mock.go 里的桩,sql.Open
是惰性的所以不报错、第一次 Exec 才失败,而 main.go 丢掉了所有返回值。合起来
是一个完全静默的空操作——打印凭据、退出码 0、config.db 里一个字节都没写。
安装脚本把这份凭据写进 credentials.txt,用户照着登录必然失败,全程无报错。

- build.sh: initconfig 改 CGO_ENABLED=1,并写明为何不能图省事去掉 cgo
- main.go: 每个 Exec 都检查;写完**回读比对**(不看返回码,看真实落盘内容),
  不一致即非零退出

反向验证:仍用 CGO_ENABLED=0 构建时,现在 stderr 报
"Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work"
且 exit 1(此前是 exit 0 并把凭据照打印出来)。
2026-09-12 08:10:26 +08:00
ba0b5a1fe0 feat(release): 发行版默认启用本地向量空间(onnxruntime 标签 + chineseclip 默认)
用户要求:后续发行版默认带 ONNX 模型能力。这条要求落到两处,并顺带修掉一个
被它**暴露出来**的真缺陷。

## 1. 构建默认带 onnxruntime(deploy/packaging/build.sh)

`HOMED_TAGS` 默认 `onnxruntime`,需要极简构建时显式 `HOMED_TAGS=` 关闭。
不带标签时 provider 仍注册、但打开即报「requires build tag」并优雅降级——
不静默假装成功。运行期还需要 `libonnxruntime.so`(provider 按
/opt/onnxruntime、/usr/local/lib、/usr/lib 顺序查找),缺失时同样是
「日志里明确错误 + 降级」。

## 2. 新装默认选 chineseclip(internal/config/registry.go)

`SeedDefaults` 写入:
  core.memory.multimodal_space.provider = chineseclip
  core.memory.multimodal_space.options.model_dir = <dataDir>/models/chinese-clip-vit-b16-onnx

选它而不是 qwen3vl:后者实测常驻 9.4GB,多数机器装不下;chineseclip 是
1.99GB(实测,见下)。同时更新两个 ConfigDef 的默认值与描述(WebUI 显示用)。

**老安装不会自动拿到这两个默认值**,这是有意的:`seedDBValues` 对非空配置库
直接返回,`GetString` 缺键时回落到调用方默认值(main.go 传的是空串)。
升级就静默加载 ~1.8GB 模型不是无副作用的事,应由部署显式开启。已写进文档。

## 3. 修掉 ORT 环境被重复初始化 + 误销毁(internal/nlp/onnx.go)

这是「默认带标签」才暴露的缺陷:此前不带标签时进程内不会有多个 ORT 消费者。

- `NewONNXParser` 无条件 `InitializeEnvironment()` → 若多模态 provider 先初始化,
  这里报「The onnxruntime has already been initialized」并**降级**(实测日志:
  `ONNX parser init: init onnx env: ... using fallback`)。
- 更严重的是失败路径与 `Close()` 里的 `DestroyEnvironment()`:它会把别人
  (多模态 provider)正在用的进程级环境一起拆掉,让对方的会话失效。

改为:初始化前先 `IsInitialized()`;**任何消费者都不销毁环境**(随进程存活),
只销毁自己的会话。providers/chineseclip 与 providers/qwen3vl 本来就是这个约定,
现在三处一致。

## 验证(实测)

- 全新数据目录启动:配置库出现上述两个默认值。
- 模型未安装:`multimodal space active` 不出现,代之以明确错误
  (点名缺失的 embed_config.json 路径 + 已注册 provider 列表)+ 降级,不静默。
- 模型就位:`multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990
  modalities=[text image]`。
- 内存:同一份 homed,启用时 RSS **1.99GB**(峰值 2.09GB),不启用 **0.17GB**。
- NLP 修复:日志由 `ONNX parser init: ... using fallback` 变为 `dep parser initialized`。
- 构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
  `bash -n deploy/packaging/build.sh` 通过。

## 未做(明确记录)

- `libonnxruntime.so`(24MB)与 Chinese-CLIP 产物(754MB)目前都需自行安装/导出,
  发行版尚未打包它们。若要让「默认启用」在干净机器上真正开箱可用,需要决定
  是随包分发、安装时下载、还是保持文档指引。
2026-09-12 00:10:32 +08:00
bfdb395731 feat(memory): 新增 chineseclip provider —— text+image 的小体积可商用向量空间
## 为什么

用户决定「本轮不覆盖 video,先支持 text+image」。这一刀正好解锁了此前
「小 + 可商用 + 覆盖视频」三者不可兼得的僵局:不要求视频后,唯一同时满足
**小、可商用、中文原生** 的选项是 Chinese-CLIP ViT-B/16。

实测对比(同机、真实跑出来的数字):

| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 常驻内存 | 754MB / **1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC(不可商用) | Apache-2.0 |
| 视频 | 无 | 有 | 有 |

本机可用内存只有 5.3GB,Qwen 的 9.4GB 无法进程内使用;而 ORT format + mmap
那条路被证实当前不通(转换器对三段图段错误;走通还需同时升 ORT 运行时与
Go 绑定,v1.36 要求 API 29 而本机只有 28)。1.15GB 则可以直接进程内跑。

**代价已写进包注释与文档**:CLIP 是双塔对比学习,text↔image 是强项,但纯文本
语义明显弱于 MLLM 型嵌入器;文本检索仍由既有词向量/TF-IDF 路径兜底。
需要更强文本语义或视频时切回 qwen3vl。

## 内容

- `providers/chineseclip/`:按公共 SPI 实现的 provider(注册名 `chineseclip`),
  含 BERT WordPiece 分词器、图像预处理、ONNX 双塔推理、无标签 stub。
- `scripts/export_chineseclip_onnx.py`:从官方权重导出规范产物 + 冻结参考,
  自带逐用例 PyTorch 对比与覆盖度断言(计划集合≠执行集合即非零退出)。
- `cmd/homed/main.go`:空白导入两个 provider,由配置选其一。
- `go.mod`:`golang.org/x/text` 由间接依赖转为直接依赖(删音标需要 NFD)。

## 实现要点

- **分词器逐 token 对齐官方**。第一版探针自己拼 BertTokenizer(只给 vocab.txt、
  没删音标、中文没逐字切),中文被整体切成 [UNK],三个不同句子产出几乎相同的
  向量(余弦 0.98)——差点把「模型坏了」当成结论。官方配置是 do_lower_case=true
  + 删音标生效 + 中文逐字切分;`TestTokenizerMatchesOfficialReference` 钉住
  逐 token 一致。
- **图像缩放自写 bicubic**(复刻 PIL 的 precompute_coeffs + a=-0.5 核),不引
  golang.org/x/image:它未进本机模块缓存,且最新版要求把整个工具链升到 Go 1.26,
  为一个缩放函数动工具链不划算。
- **归一化在 provider 侧**(两个塔的图里都没归一化),检索按余弦。
- **指纹覆盖全部影响语义的产物**:两个 ONNX 图 + vocab.txt + embed_config.json,
  读不到就写 MISSING(跳过等于对缺件不敏感)。
- 会话 Run 用 runMu 串行化(ORT 会话不保证并发安全),创建/销毁用 mu。

## 模态范围

只声明 `text` 与 `image`;`audio`/`video` 明确返回 `ErrUnsupportedModality`,
绝不用别的模型向量冒充(这是「音频明确 unsupported」纪律的落地)。

## 验证(实测)

导出侧:10 个用例(5 文本 + 5 图像)ONNX vs 官方 PyTorch 全部
`cos = 1.000000000`,覆盖度断言 10/10 通过。

Go 侧(`CHINESECLIP_MODEL_DIR=... go test -tags onnxruntime ./providers/chineseclip/ -v`):
11/11 通过,其中
- 文本 5 用例 `cos = 1.000000000000`(逐位一致)
- 图像 4 纯色用例 `cos = 1.000000`(与官方预处理在 6 位小数内一致)
- 跨模态判别:红图对「红色」文本高于「蓝色」文本
- 模态拒绝 / 空输入 / 指纹稳定 / 产物缺失报错

顺带修掉测试自身的一个假通过:参考向量是**未归一化**的原始输出(模长 10~36),
原先「点积当余弦 + 单侧下界」会让 13.6 也判过,已改为真余弦 + 双侧容差。

构建矩阵:`go build/vet ./...` 与 `-tags onnxruntime` 两种都过;
`providers/... pkg/... internal/config/... internal/memory/vector/...` 回归通过
(qwen3vl 的 TestVideoModelInputMRope 需要 QWEN_ONNX_MODEL_DIR 指向含视频档的
v3 目录,缺该环境变量时用的是只有文本+图像的目录,与本改动无关)。

## 未做(明确记录)

- 发行版默认 provider 与构建标签变更:留下一提交(涉及打包与模型分发策略)。
- 模型产物(754MB)不进仓库,由导出脚本生成。
2026-09-11 23:58:53 +08:00
d1959cbe80 feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。

## 1. 注入标志位(内核侧)

- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
  no_memory / context_policy / cleaner_name;策略在入口**校验**,
  非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
  (零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。

## 2. 修掉「输入无条件裁剪」这个真缺陷

eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:

  优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
          > 默认**不裁剪**

查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。

## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)

原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。

现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。

顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。

## 4. homed 放弃 Windows 原生,改走 WSL2

插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。

- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
  **不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
  procEnvForShm 返回 nil。顺手修掉两处长期编译错误
  (cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
  注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
  **linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
  「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
  确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
  linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
  (/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
  (否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
  stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
  目标下明确拒绝。

## 5. 插件调用点统一写明意图

- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
  **带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。

## 6. 版本

meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。

## 测试

- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
  nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
  payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
  **跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
  到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
  落盘幂等、内容哈希稳定。

验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
      go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
      ./internal/agent/{core,io}/... ./pkg/...
2026-09-11 20:31:50 +08:00
a37bc7333e refactor(memory): 核心不再适配具体模型——公共 embedding provider SPI + 注册表
问题:cmd/homed 里 `case "onnx": qwen.New(modelDir)` 把模型适配写进了核心,
`type=onnx` 名义上是格式、实际写死了一个模型家族;2117 行 Qwen 专属代码
(BPE、chat template、M-RoPE、Vision_gN 命名)住在内核树里,还带着一对
`//go:build onnxruntime` 的 stub。加任何新模型都要改内核。

现在核心只认一个模型无关的公共契约(pkg/embedding):
- 输入是不透明的 Data+MIME,解码/预处理/时序分组全归 provider
- 能力是数据(Info.Modalities),不是接口方法——新增模态无需改核心接口
- 不支持的模态返回 embedding.ErrUnsupportedModality(可 errors.Is 识别)
- 按名字注册,重复注册 panic;Options 是 provider 私有命名空间,核心不解释

改动:
- 新增 pkg/embedding:Modality/Purpose/Input/Info/Provider/Config + 注册表
  (Open 校验 Info,ValidateVector 在入库前拦下维度错与非有限值)
- providers/qwen3vl:Qwen 实现整体移出内核(git mv),实现公共 SPI 并自注册
- internal/memory/vector:新增 ProviderAdapter(公共 SPI → 内部小接口);
  ErrModalityUnsupported 改为公共哨兵别名;删除 VideoEmbedder 可选接口
  (那正是「核心为每个新模态长方法」的坏味道)
- http embedder 也变成普通 provider(注册名 http)
- cmd/homed:删除 qwen import 与 onnx/http 分支,改为按 provider 名打开 +
  透传 options.*;provider 打开失败只警告并禁用多模态检索,不影响启动
- config:multimodal_space.type/onnx./http.* → provider + options.*
- 删除 internal/memory/qwen(整体搬迁)

测试:
- pkg/embedding:注册表隔离/未知名字/非法 Info 自动关闭/ValidateVector
- vector:适配器原样透传字节与 MIME、维度错被拦、Close 幂等且停止使用、
  两个哨兵 errors.Is 互通
- providers/qwen3vl:新增公共 SPI 全链路集成测试(Open→Info→Embed→
  未知模态哨兵),并明确断言 Info 不声明 video

已知未完成(不得当作已验证):
- 视频冻结回归 TestEmbedderVideoMatchesONNXReference **显式跳过**:Go 侧
  video 模板缺少 processor 按时间组插入的字面时间戳文本
  (<0.0 seconds>/<1.0 seconds>),同一输入 Python seq=1190(1152+38)、
  Go 只有 22 个文本 token。时间戳也占 M-RoPE 位置,故现有 M-RoPE 自洽断言
  通过不能证明与官方实现一致。修复属 provider 内部工作。
- 视觉侧三档已导出并逐档校验通过(cos 1.000000119/1.000000119/1.000000000)

验证:go build ./... ;go vet -tags onnxruntime ./... ;
go test -short ./internal/memory/... ./internal/agent/core/... ./internal/sdk/... ./pkg/...
;onnxruntime 下 providers/qwen3vl 全绿(视频为显式 skip)
2026-09-11 18:26:19 +08:00
1a02971f88 feat(memory): 千问三段式 ONNX 嵌入补齐——可复现导出脚本 + Go 侧首次完整验证
此前三段式拆分后 ONNX 路径从未从 Go 侧跑通:embedder_onnx_test.go 仍引用
分段前的 API(e.renderInput、TextTower.onnx、旧目录),go vet -tags onnxruntime
直接编译失败。导出脚本只在 /tmp 且硬编码本机路径、从第三个目录拷贝固定形状的
Vision.onnx,完全不可复现。音频会被视觉塔编码,静默往统一空间灌入错误坐标。

本提交补齐这些缺口:

一、可复现导出脚本(scripts/export_qwen3vl_embedding_onnx.py)
- 自动拉取模型(HuggingFace 优先,失败回落 ModelScope,支持 HF_ENDPOINT 镜像);
- 导出 TokenEmbedding + Transformer + Vision 三段图,图文共用同一 token
  embedding、28 层 Transformer、last-token 池化与 fingerprint;
- 双重自检(不可省):分段 PyTorch vs 完整模型 + 导出后的 ONNX vs 完整模型,
  cos < 0.999999 即非零退出——「能加载」不等于「算得对」;
- 默认把 L2 归一化后的冻结参考向量写入产物目录(qwen_reference.json)——
  Go 测试据此做逐维冻结回归,且「该目录是哪次导出的」从文件本身可追溯;
- --verify-only 校验既有产物不重新导出,可用来确认线上在用的图没坏。

关键实测结论(已写入 docs/zh/multimodal-space.md 与长期记忆):
原生多帧视频不可行——Qwen3-VL 视觉塔把 grid_thw 当 Python 值消费
(grid_thw.tolist()),legacy tracer 固化为常量,导出后图中根本没有 grid_thw
输入,换帧数调用直接 Invalid input name: grid_thw。故视觉塔固定 (1,48,48),
视频由上层抽帧后逐帧按图像编码(同模型/同维度/同 fingerprint),音频明确
unsupported。

二、模态边界(vector.ErrModalityUnsupported)
- 新增 vector.ErrModalityUnsupported:表示「该模态不在本统一空间的原生覆盖
  范围内」,与普通错误语义不同——调用方应把它当「永远不会有向量」而非
  「本次失败、下次重试」;
- qwen.EmbedImageDense 按 mime 拒绝 audio/* 与 video/*:此前它会拿视觉塔
  去解音频字节,往统一空间灌入语义错误的坐标且静默;
- reembedStaleMedia 对 ErrModalityUnsupported 不计失败、不重试、不用别的
  模型向量顶替(TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 守住)。

三、Go ONNX 测试首次完整通过
- 重写 embedder_onnx_test.go:修复编译 + 文本冻结回归 + 图像冻结回归 +
  两条阴性对照(不同输入必须不同、图像与文本必须不同)+ 不支持模态断言;
- 参考值从产物目录的 qwen_reference.json 读取(不在测试里硬编码浮点);
- 用线上部署产物实测全部通过(text cos=0.999999940, image cos=0.999999762)。

四、.gitignore 修复
- /scripts/ 此前被列在「运行时产物」下,但它是作者维护的工具目录
  (模型导出、侧车、部署校验),deploy/systemd/embed-sidecar.service 直接
  引用 scripts/embed_sidecar.py,忽略它会让那份 unit 在别人的机器上指向
  不存在的文件。改为只忽略 __pycache__。

五、文档(docs/zh/multimodal-space.md)
- 获取/启用/产物契约/模态边界/验证/资源成本/与现有部署产物的等价性。

验证:go build ./...、go vet ./...、go vet -tags onnxruntime ./...、
go test -short 全部通过;ONNX 标签测试对线上部署产物全部通过。
2026-09-11 13:45:25 +08:00
5836c2ce5c refactor(memory): 拆除描述式媒体索引,媒体成为一等块并按原生向量融合
背景:此前媒体是靠「生成的描述文本」将就进记忆的——写 marker 进正文、
再由正则反解成 media_refs 与图库里的 type=Media 实体。这条链路有三个
致命缺陷:描述由异步模型生成(未生成前媒体等于不存在)、语义检索实质上
只搜描述文字、图库里的「媒体节点」是描述文本的投影而不是媒体本身。

本提交把这条链路整体拆除,媒体改为按自己的原生向量参与记忆:

一、描述链彻底删除(无残留、无兼容分支)
- media.Item 去掉 Description/DescribedBy 与对应列;
- 删除 Store.Describe / Store.Search / Store.Pending;
- 删除 Agent.mediaDescribeLoop / describePendingMedia 与配置项
  core.memory.media.describe_on_ingest;
- SDK 侧 MediaAttachment 去掉 Description(见 SDK 仓独立提交)。

二、marker 机制删除,媒体归属改为结构化块边
- 删除 mediaMarkerLine/parseMediaMarkers/mediaEntityName/mediaTriplesFromText/
  extractMediaDigests/sentenceWithMediaMarkers/docMediaContext;
- memory.Triple 新增 MediaDigests 结构化字段;句子文本保持原样,
  不再被 marker 污染;
- 块以 sentence --contains--> block / document --contains--> block 结构边
  挂到承载节点(新增 documents 表与 document 节点种类);
- 模型未给原句时用「主谓宾。」拼一句自然语言作落点,不造 marker 文本。

三、旧数据迁移(幂等)
- 新增 GraphDB.MigrateLegacyMediaEntities:把 type=Media 的旧实体按短 digest
  还原成原生块、挂回原句子、删除旧实体与描述关系;Agent 启动时执行;
- CleanupOrphanedSentences 同时看关系引用与块边,避免把只靠块存活的句子
  连同块边一起删掉。

四、向量融合:媒体按图本身被召回
- 新增 vector.FuseVectors(逐维求和 + L2 归一化);
- Doc.DenseVec = 文本向量 ⊕ 文档块的媒体向量(同 fingerprint 才融合),
  新增 Doc.DenseFP,指纹变化触发重算;
- ContextEvent.DenseVec 同理融合事件块;事件新增 DenseFP,Prune 只在
  同一统一空间内比稠密余弦;
- 跨模态视觉路只召回「仍被某层记忆块持有」的媒体,CAS 全库字节不再
  直接充当记忆检索结果。

五、同时纳入本分支既有的嵌入基础改造(此前工作区未提交,缺它 HEAD 不可构建)
- internal/tfidf 懒回退包、千问三段式多模态 ONNX 空间的 Go 侧
  (qwen/embedder.go、image.go、model_input.go)、CLIP 移除、
  sdk.NewStore 分词器签名与调用点、embed 侧车 systemd 单元。

验证:go build ./... 、go vet ./...(含 -tags medialive)均通过;
在 HEAD 的独立 worktree 上重放本次暂存集后 go test -short ./internal/...
全部通过(端口冲突类用例在隔离环境中亦通过)。未提交工作区中与本改造
无关的改动(HarmonyOS、waiter、devicebridge、plan.md 等)。
2026-09-11 11:45:24 +08:00
dae01f9c06 refactor(memory): 移除 media_refs/引用计数,媒体成为一等记忆块
媒体此前是"文本块 + digest 引用 + owner 账本 + 独立 GC":ContextEvent.Media
记 digest,media_refs 表用 owner_kind/owner_id 保活,ref_count 决定 GC 能否清。
这与文本记忆块的管理方式不一致,也是本次一并纠正的核心偏差。

改为与文本块完全一致的生命周期:

1. 一等记忆块直接由所在层持有
   - ContextEvent.Blocks / Doc.Blocks / GraphDB memory_blocks
   - 块带 modality/digest/MIME/size/vector/fingerprint,文本、图片、视频同构
   - Context→Document→Graph 迁移的是块本身(ID 不变),迁移后清空源容器,
     同一块不同时存在于两层

2. 删除平行生命周期账本
   - media.Store 去掉 media_refs 表、OwnerKind 常量、RefCount 字段、
     AddRef/DropRef/DropOwner/Refs、ref_count 列与索引
   - 删除 mediaGCLoop、GC(keep,minAge)、容量上限与 media.gc_* / media.max_mb 配置
   - 媒体内容在块被永久删除时一并删除(media.Store.Delete + forgetPayloads),
     与"删除文本块即删除内容"同一语义

3. L3 原生结构
   - memory_blocks / memory_block_edges(contains/depicts/derived_from)
   - 边端点必须是真实图节点,不再用 owner 字符串伪装关系
   - BlocksForNode 支持 sentence --contains--> block 反查

4. SDK 与检索同步
   - 插件附件/标记直接变成块,不再 AddRef
   - 跨模态检索改用 QueryMediaScored(CAS 内不再有孤儿缓存需要过滤)

测试全部改写为块语义:删除 refcount/media_refs/GC 断言,新增块迁移、
单层不变量、Delete 语义与并发删除回归。

注:cmd/homed/main.go 同时携带工作区中既有的 CLIP→Qwen 模型目录接线改动。
2026-09-11 10:57:22 +08:00
e44164f5bd feat(memory): Graph 原生一等记忆块节点与结构边(§13.12)
媒体/文本在 L3 不再是正文标记反解出的代理实体,而是带原生
modality/digest/MIME/size/vector/fingerprint 的 memory_blocks 节点;
contains/depicts/derived_from 等语义边落在 memory_block_edges,
端点必须是真实图节点(block/entity/sentence),不复用 owner 字符串。

本步只建立存储与查询能力,不接入 media_refs,也不改变 L0/L2 路径。
2026-09-11 10:08:59 +08:00
96c1d7baae fix(memory): 千问文本塔补齐 tokenizer post_processor 与真实 ONNX 回归
验证 Go 端到端路径时发现:HuggingFace 的 tokenizer.json 带 TemplateProcessing
post_processor,规则是 `$A <|endoftext|>`——即每段输入末尾都会追加一个
`<|endoftext|>`(151643)。它正是图内 last-token 池化的锚点:

- 漏掉它:ONNX 仍能运行(不会报错),但池化取到的是模板末尾的 assistant
  起始符,而非 post token,整条嵌入向量与上游不一致;
- 截断语义:HuggingFace 在 truncation=true 时先把正文截到 maxLen-1,
  再保留末尾 post token(实测 600×"记忆"→ [511 正文][151643])。

改动:
1. Tokenizer 新增 encodeModelInput(text, maxLen):Encode 后追加 post token,
   并在超长时先截到 maxLen-1;缺 <|endoftext|> 直接报错(防静默错误)。
2. Embedder.VectorizeDense 改用 encodeModelInput。
3. 测试:
   - TestEncodeModelInputPostProcessor:短文本+post token;长文本按 512 截断
     且尾 token 为 post token(用 600×"记忆"确保真的触发截断分支)。
   - embedder_onnx_test.go(onnxruntime 标签):用 Python onnxruntime 1.28
     生成的冻结参考向量验证完整 Go 路径(模板渲染→BPE→ONNX→L2 normalize),
     逐维 diff ≤ 2e-5;同时断言模型输入恰好 23 token 且末尾是 post token。
     产物不在时跳过(与 tokenizer_test 相同约定)。
   - 修正先前测试误用 400×"记忆":BPE 把"记忆"合并为单 token,400 次只有
     400 token 不触发截断;改用 600 次后确实走到 maxLen-1 分支。

其余(ORT 库路径、指纹纳入 .onnx.data)一并随本提交带上。

验证:go test ./internal/memory/qwen 与 -tags onnxruntime 全绿;
FP32 图与 PyTorch 在短/长/等长批次/真实 padding 批次上余弦均 ≥0.99999994。
2026-09-11 03:09:01 +08:00
8e88ae789f feat(memory): 千问文本塔 ONNX 嵌入器(onnxruntime 标签,含 stub)
与 internal/memory/clip 同模式:`//go:build onnxruntime` 编真实实现,无标签时
走 stub,默认构建不链接 onnxruntime、行为不变。

加载契约(目录由 core.memory.multimodal_space.model_dir 指定):
TextTower.onnx + 外部权重分片、tokenizer.json、embed_config.json。
图内已含 last-token 池化,输出即 [batch, dim];L2 归一化在 Go 侧做。

两个刻意的设计选择:

1. **EmbedImageDense 明确报错,不返回零向量**
   导出的是文本塔,视觉塔未导出。返回零向量会让「写入了但检索不到」,
   把跨模态检索失效变成静默故障;明确报错则调用方(mediaref.go)log 后
   跳过写向量,文本路径不受影响。

2. **Fingerprint 只哈希图文件 + 配置 + 外部权重的文件名与大小**
   该目录有 6.5GB 权重分片,启动时全读一遍要几十秒、会阻塞 homeagent 启动。
   换模型必然改变文件集合或大小,足以识别切换;代价是理论上存在
   「大小相同但内容不同」的漏判,对本地单机部署可接受。已写入注释。

模板渲染(renderInstructionInput)放在无构建标签的 tokenizer.go,因此可被
测试覆盖:参考数据里有该模板串的用例,逐 token 对齐验证——模板差一个字符,
池化取到的「最后一个有效 token」位置就变,向量就不同,且不会报错。

验证:6 个测试全绿;go build ./...(stub)与 go build -tags onnxruntime
(真实实现)均通过。
2026-09-11 00:16:45 +08:00
e985151da9 feat(memory): 千问字节级 BPE 分词器 + 与上游逐条对齐的回归测试
为「千问嵌入模型导出 ONNX 并内嵌」的 Go 侧准备。CLIP 那套 tokenizer 不能复用:
CLIP 是「小写化 + 空白规整 + 词表 BPE」,千问是 **GPT-2 式字节级 BPE**
(先按字节映射到安全 unicode,再对映射结果做合并),中文与空白输入的切分
完全不同。

实现中撞到两个与上游对齐的坑,都由测试暴露:

1. **`\s+(?!\S)` 的语义依赖正则回溯**,不是「等价于 `\s+`」。
   `\s+` 先贪婪吃完整段空白,发现后面是非空白导致 `(?!\S)` 失败,于是回退
   一个字符,**正好留下末尾一个空白**给前面以 ` ?` / `[^…]?` 开头的分支合并。
   这直接决定切分点:`"   leading"` 会切成 `"  "` + `" leading"`,
   而不是 `"   "` + `"leading"`。RE2 不支持 lookaround,近似改写必然对不上,
   所以改成按分支顺序**显式实现**(含 `\s*[\r\n]+` 的回溯语义)。
   实测:近似改写时 26 条里错 3 条,全部是空白串用例。

2. **Go 的 `\s` 只有 ASCII,且 regexp 不支持二进制属性 `\p{White_Space}`**
   (只支持 script/category,直接写会报 invalid character class range)。
   而上游 Rust regex 的 `\s` 正是 White_Space。改用 `unicode.IsSpace` 作为
   唯一判据,避免全角空格/NBSP/行分隔符的切分点漂移。

另外特殊 token(`<|im_start|>` 等 24 个 AddedToken)必须**整体优先匹配**并
按长度降序,否则会被 BPE 拆成子 token,模型收到的输入就变了——且不会报任何错。

验证:`testdata/qwen_tokenizer_reference.json` 由 HuggingFace 真实 tokenizer
生成(26 条用例,覆盖中/英/中英混排/数字/各类空白形态/标点/emoji/特殊 token/
长文本/空串/单字符边界),Go 实现逐条精确对齐。字节↔unicode 映射另测双射性
(有碰撞会让不同字节编成同一 token,静默产生错误输入)。
2026-09-11 00:11:58 +08:00
b393b7072c fix(ctx): prune 的查询向量改用插件 Cleaner 清洗后的有效内容(§13.8)
ContextPolicy=prune 上线时直接把**原始**工具结果传给 RelevanceContext.Prune,
而 Prune 的入参是**相关性查询向量**——它决定保留/归档哪些上下文事件。于是
ANSI 转义、base64、JSON 包装等噪声全被编进查询向量,打分失真,裁掉本该
保留的事件。

而 ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」,裁剪正是
在向量化——所以这是**回归契约**,不是新增能力。此前只在构建事件向量
(context.go 的 toolOutputClean)时用了 Cleaner,裁剪查询这一处漏了。

回退规则(Cleaner 是计算层优化,不能因它失效而丢内容):
- 未注册 Cleaner → 原文
- RPC 失败 → 原文(proc 侧 cleanerProxy 已有此保证)
- 返回空串 → 原文(空串会让查询向量退化成零向量,所有事件相关性相同,
  等于随机裁剪)

验证:TestToolOutputForQueryAppliesCleaner(Cleaner 被调用恰好一次且用其
结果;无 Cleaner / nil stageHost 回退原文)、
TestToolOutputForQueryEmptyCleanFallsBack。

顺带把 §13.13 第 5 条(反向大结果)按核实结论结掉为「不做」:核实发现根本
不存在 llm.chat(llm.* 只映射切换 LLM 源),唯一可能返回大结果的 doc.query
没有任何外部插件使用且已被 CapDocMemory 能力门限制。留成永久 TODO 只会误导。
2026-09-10 23:59:12 +08:00
50ba4a64cb feat(shm): 文档/知识正文入共享内存 + 协议版本 bump 到 2(§13.13)
## 数据面补齐

- doc.insert / doc.insertWithMedia:新增 doc_ref / attachments_ref,
  模板序列化后 putValueInArena
- knowledge.add:新增 content_ref(内容是 JSON 字符串,读出后再解一层)
- 抽出通用 resolveJSONRef(resolveBlocks 也改用它),三处共用一套
  “共享优先、内联回退”逻辑

## 协议版本 bump:让错配显式失败,而不是静默坏

这是本轮更重要的部分。§13.6/§13.13 改了内核→插件 payload 的承载方式,
两种错配都不会报错、只会静默失效:

- v1 插件只读内联 args(tool/cleaner/output)→ 遇到 v2 内核拿到空参数
- v2 插件发 blocks_ref → v1 内核反序列化时静默忽略(旧内核
  io.setToolBlocks 还是桩实现)

现场表现为“输出变空 / 图注入没反应”,极难定位。所以把 ProtocolVersion
与模板 procProtocolVersion 一起 bump 到 2:双方都是等值校验,v1 插件遇上
v2 内核会在建链时明确报“协议版本不匹配…请用配套 plugindev 重编”。

测试里把“错误必须带出重编指令”也断言上了——生产上碰到它的现场就是
“只更新了内核没重编插件”,光报“不匹配”定位不到行动。

testdata 8 个插件的 protocol 同步更新(badprotoplugin 仍用 999 验证拒绝)。
工具链已重建并安装(协议 2,内嵌 blocks_ref/doc_ref/content_ref),
回滚副本 plugindev.bak-20260910-232544。

验证:-race 全绿。新增 KnowledgeAddViaArena(12000B 正文)、
KnowledgeAddInline、DocInsertViaArena、SetToolBlocks 三例 +
真实模板 e2e + 协议不匹配断言强化。

§13.13 第 5 条(反向大结果)范围更大——需把内核→插件的应答路径整体改成
“大结果写段 + 返回 ref”,涉及 callCore 的返回处理与 doc.query/llm.chat 等
所有读大结果的 method。已在 plan.md 标注未做,不冒充完成。
2026-09-10 23:26:16 +08:00
15d912ef34 feat(shm): 媒体块入共享内存 + 实现 setToolBlocks 桩(§13.13)
两个问题叠在一起,先发现的是第二个:

1. **io.setToolBlocks 在内核侧是桩实现**——直接返回“多模态注入待共享段
   二进制通道落地”。也就是**子进程插件调 SetToolBlocks 必然失败**(模板
   只 log 一行),只有内置插件(multimodal)能用。之前还有测试把这个错误
   当作预期行为断言着。
2. 媒体块(injectMedia 系列 + setToolBlocks)把 blocks 内联在 RPC JSON 里,
   而本地生成的图/音频是 base64 data URL,一张图可达数 MB。

顺带修掉一条与设计相悖的旧注释:injectMediaParams 写着“data URL 已是
base64 文本、再套一层二进制不会更小,所以走 JSON”。那只算了体积,漏了
两件更重要的事——内联时整份 base64 要在 RPC 报文里再编码/再拷贝一遍;
以及内容本体不在共享段里,插件回调就无法就地改写,只能各持一份拷贝。
共享内存的意义是后者。

实现:
- injectMediaParams 加 BlocksRef;新增 resolveBlocks(共享优先、内联回退)
- 真正实现 MethodIOSetToolBlocks(空块明确报错,不静默成功)
- CoreSDK 接口加 SetToolBlocks,procCore 转调 internal/sdk
- 模板:SetToolBlocks / injectMedia 三兄弟经 putValueInArena 传 blocks_ref;
  同步调用用 mediaArgsOwned 拿释放函数,**不能在应答返回前释放槽**,
  否则内核读到已释放内存

验证(-race 全绿):
- TestCoreHandler_SetToolBlocksViaArena / Inline / EmptyRejected
- TestE2E_RealTemplateSetToolBlocksViaArena:真实 SDK 模板编译的插件,
  9000 字节 base64 图经 blocks_ref 完整送达

工具链已同步:/usr/local/bin/plugindev 重建为新协议(内嵌 blocks_ref),
回滚副本 plugindev.bak-20260910-224255。注意内核与全部插件必须同批替换
——只换一边会静默坏(新内核+旧插件=输出 payload 变空;新插件+旧内核=
媒体注入静默失效),已记入长期记忆。
2026-09-10 22:45:19 +08:00
2a4315cfe4 docs(plan): 修正 §13.13 判据——按「回调能否就地改写」而非 payload 大小
之前把共享内存的理由写浅了(当成“省管道 / 避免大 payload”)。真实目的是
找回 .so 时代的能力:插件回调(Cleaner / Stage / 工具)与内核同进程时可以
直接就地改写参数与结果;多进程化后若靠 RPC 来回发消息,回调就只能
“读一份、回发一份”,丢失就地改写语义。共享内存是把内容放进段里、回调就地
改、只回描述符。

因此判据应是「插件回调要能就地改写的内容有没有留在段里」,不是 payload 大小。

按新判据核实:回调主线已达成——
- Stage:invokeStage 只发 {Stage, Seq},插件就地改写,应答只回 DirtyFields 计数
- Cleaner:发 {Scope,Name,Frame,InputLen},回 TextRef(16B 描述符),内容不随 RPC 走
- tool.invoke:内核标定 Frame,结果 ResultRef,after_toolcall 可在段内再改

未入内存的重新排序,第一条换成 io.setToolBlocks:它直接破坏“结果媒体能被
after_toolcall 就地改写”——插件只能推一份 base64 拷贝过去(ai_image 大图)。
2026-09-10 21:47:23 +08:00
b09f459aea docs(plan): 修正 §13.5 的错误判断,建账 §13.13 剩余内联路径
§13.5 之前写的「内核侧未接线」是方向性错误:我把分配方认错了。
实际是**插件侧** putInArena 做 arena.alloc + 写入 + 传 text_ref,内核侧
resolveText 读回。分配在插件侧并非缺口——§13.2 的模型就是「插件经 RPC
向内核申请/归还」,内核独占分配器;procCore.InjectText 拿到普通字符串
也是对的,因为共享内存是跨进程的内部实现、不对插件开发者暴露。

新增 §13.13,把「全量数据交互入共享内存」这个总目标的尾巴显式建账,
区分数据面(要入)与控制面(不该入,几十字节搬进去反而多两次 RPC):

已入:StageContext / 事件环 / tool.invoke / cleaner.invoke /
      output.invoke(本次 §13.6)/ io.injectText(§13.5)
未入:① 媒体块 io.injectMedia / injectMediaSync /
      injectInterruptMedia / setToolBlocks(本地大图 base64 可达数 MB,
      最大一条)② doc.insert / insertWithMedia ③ knowledge.add
      ④ 插件反向调内核读大结果仍内联
2026-09-10 21:34:42 +08:00
e2672c56d6 feat(shm): 输出通道 payload 走共享内存调用帧(§13.6)
§13.3 给 ToolInvokeParams 加了调用帧,但 OutputInvokeParams 没跟上——
payload 仍内联在 RPC JSON 里。核实后确认这个 lane 只做了半边:注入侧
(injectParams.TextRef + resolveText)可用,输出侧从内核到插件仍是内联。

改法照搬工具调用的 funccall 帧模型:

- OutputInvokeParams 加 Frame/ArgsLen(Args 仅留给直连 RPC 的测试)
- invokeOutput 序列化参数 → Alloc 帧 → 写帧 → 只传偏移描述符 → defer Free
- 帧尾不预留结果区:output 应答很小("ok"/status map),走 RPC 应答字段即可。
  但 OutputInvokeResult 支持插件把大结果写回帧(ResultRef),并识别
  sharedRefFlagExpand 单独归还扩容块——与 invokeTool 一致。
- 模板 output.invoke 用 frameInput 从帧读参数,无帧才回退内联 Args

为什么要做:output payload 在真机上可能含图片/文件描述等大字段,内联会
撑爆 stdin/stdout 管道。

验证:
- TestPlugin_OutputPayloadViaArena:9000 字节 payload 经帧完整送达(插件回报
  实收长度,不是只看返回值)+ 调用后 arena 归零
- TestE2E_RealTemplateOutputPayloadViaFrame:同上,但用真实 SDK 模板编译的
  插件——生产插件走的就是模板,模板不读帧则此改动等于没做
- TestPlugin_OutputChannelReportsRealFailure 仍绿:同步等真实结果、失败必须
  上报(§9.4)的语义未被破坏
- go test -race ./internal/plugin/... 全绿

plan.md §13.5 也如实标注:注入 side 内核→插件方向仍未接线(procCore.InjectText
直接传字符串,从不 Alloc/Put 构造 TextRef),不是仅打勾的项。
2026-09-10 21:28:56 +08:00
b71a88a471 chore(shm): 补齐 §13 验证项,删除统一区域遗留死代码
三件事,都是 plan.md §13 里未打勾的项:

1. §13.3「Bench: ToolInvoke 延迟对比」补测(bench_test.go)
   改造后原有的 BenchmarkToolInvoke 仍走 legacy 内联 Args,已不代表生产
   路径。拆成 inline / frame 两个子基准并各测两个尺寸(-count=3 取中位):

     payload      inline(RPC 报文)   frame(共享内存帧)   差
     16 B          26.4 µs/op           48.0 µs/op        +21.6 µs
     32 KiB       695.6 µs/op          391.7 µs/op       −303.9 µs

   取舍随尺寸翻转:小 payload 多付 ~22µs 帧固定开销,大 payload 省掉整份
   JSON 编解码与管道拷贝、快 44%。因为线上工具结果动辄几十 KB 且 22µs
   相对 LLM 往返 2-8 秒可忽略,所以统一走帧而不按大小分叉(§13.3 第 4 条)
   是对的。

2. 删除 §13.1 遗留死代码(evtring.go)
   allocEvtRing 自统一共享区域之后从未被调用——旧版它单独建 memfd + eventfd,
   现在只有一个 memfd,事件环只是区内的一个 segment(NewEvtRing 操作区内
   切片)。留着会让人误以为事件环还有独立段。

3. 修正误导性注释(plugin.go)
   仍写着旧 3-fd 布局「3=StageContext, 4=事件环, 5=通知」,与实现
   (procExtraFilesForShm 只返回 memfd+evtfd)和 shmpass_unix.go 的权威
   注释互相矛盾。

plan.md:§13.1/13.3/13.4/13.8 的验证项按实测打勾,并注明证据(提交号 /
测试名 / 实测数字),不留无依据的勾。
2026-09-10 21:18:16 +08:00
e218d0f100 fix(agent): 工具循环占位不再驱动重复发送,输出回执/子任务结果幂等
生产现象:单轮内 output_send__qq 被调用 34 次、持续 514 秒,直到 QQ 插件
自己的循环保险拒绝发送才停下(problem.md)。根因是多环节叠加,核心侧修四处:

1. 工具轮补位文案(process.go)
   通用占位「请根据以上工具结果继续。」对纯输出通道调用是错的:异步通道
   (qq/wechat)的回复只能经 output_send__* 交付,所以模型「已完成回复」的
   表达形式就是一个工具调用,紧随其后的「请继续」会被读成「还要再做一步」,
   而能做的「一步」恰好还是再发一条消息。
   改为按上一批工具的性质选文案:全部是 output_send__* 时补
   「若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。」
   同时每轮先移除旧占位再补一条,避免占位在 prompt 前缀里线性累积。
   (该占位是 zen 网关「最后一条必须是 user」的传输层附加物,HEAD 版本是
   无条件内联追加、从不移除。)

2. 输出成功回执(output.go)
   「已通过 [qq] 通道发送: map[status:sent]」这类富回执会被读成「这步成功,
   继续下一步」。成功改为只回极简标记。

3. proc 桥标量透传(internal/plugin/proc/plugin.go)
   插件返回 "ok" 时不再伪造 {status:sent} 覆盖插件真实返回值,否则只改
   output.go 不生效。

4. 子任务结果幂等(spawn.go)
   child_result 原先读到即删,而完成通知长期留在持久上下文里
   (formatMergedTimeline 每轮重新注入),第二次查询必然得到
   「不存在或已过期」这个永久失败信号,模型据此认为任务未完成而反复重试。
   改为保留结果 + delivered 标记,重复查询返回明确提示;结果按上限有界淘汰。

顺带:agent.go 去掉文档层显式向量器注入(TF-IDF 已内置为 fallback),
cmd/homed/main.go 同步 document.NewStore 的 tokenizer 参数。

测试:internal/agent/core/tooloop_test.go(5 例)、spawn_test.go(3 例)。
2026-09-10 20:36:39 +08:00
b096e8ba2c refactor(shm): 变长块分配器 + 工具调用 funccall 调用帧(§13.2/§13.3)
按架构约束推进两步:

1) 分配器收回内核后,定长槽失去了唯一存在理由
   (定长槽只是为了绕开"跨进程无法安全变长分配"),
   于是 arena 换成**变长块分配器**:first-fit + 邻块合并,
   块头 16B(size/state/owner/prevSize)。内核可以按需标定每块大小,
   payload 不再受固定槽容量限制(旧上限 16KB)。

   - Alloc/Put/Read/Free/ReclaimOwner 全在内核进程内,一把 sync.Mutex
   - Free 走块链校验 offset 是已分配块的数据起点 + owner 匹配,
     伪造引用不能改动分配器状态
   - Read 允许块内偏移(调用帧的结果区就在帧块中间),但不许跨块边界
   - arena 4MB,底层 memfd 惰性分配:未触碰的页不占物理内存

2) 工具调用 payload **始终**走共享内存,取消按大小切内联的分支。
   按 funccall 模型,内核(caller)标定调用帧交给插件(callee):

       [0, ArgsLen)              参数 JSON
       [ArgsLen, Frame.Length)   结果区(内核预留的预算)

   结果超出预算时插件才 `arena.alloc` 扩容块,引用上打
   sharedRefFlagExpand 让内核单独归还。Cleaner 复用同一帧模型。
   ToolInvokeParams.Args / ToolInvokeResult.Result 仅剩给直连 RPC 的
   测试(process/bench 不建 Host);生产路径永远走帧。

测试:
- TestArena_*(分配/归属/回收/并发唯一/耗尽/超限/伪造 offset/
  相邻合并/对齐/布局校验)
- TestPlugin_ToolInvokeArgsResultViaArena:大**小** payload 都经帧往返,
  结果一致且 arena 归零("小 payload 同样走调用帧"是本轮行为变更)
- TestPlugin_ArenaAllocFreeAcrossProcess、TestE2E_* 保持通过
- 顺手修 process_test.go 一处预存 doc-comment 分段
2026-09-10 18:54:36 +08:00
f6f92a8a6e fix(plugins): 修复两处预存缺陷(agentcli 临界区 / localuse 脚本转义)
agentcli cleanupLoop:
持锁期间为每个过期终端 go func 调 term.Close()(内部会 Kill 进程并等
<-t.done),临界区被拉长且与 TerminalSession 退出路径交错。改为持锁
只做筛选与摘除,锁外再逐个关闭。

localuse 脚本转义(两处真实 bug):
1. AppleScript 注入:handleScreensue 只把 `"` 转义为 `\"`,未转义 `\`。
   内容结尾的反斜杠会吃掉闭合引号,使后续内容逃逸出字符串字面量。
   改为 escapeAppleScriptString:**先转义反斜杠再转义双引号**(顺序
   不可颠倒,否则刚插入的反斜杠会被二次转义)。
2. PowerShell 路径被错误加倍反斜杠:handleScreensee 把 Windows 临时
   路径的 `\` 写成 `\\`。但 PowerShell 单引号串里反斜杠是普通字符
   (转义符是反引号),加倍会让截图保存到错误路径。
   新增 psSingleQuote 统一处理,并把 4 处零散的 `ReplaceAll(x,"'","''")`
   收敛到该 helper。
2026-09-10 17:56:57 +08:00
c03de9878d fix(proc): EvtConsumer 生命周期——消除 host.Close 后的 SIGSEGV
预存缺陷(非本次重构引入,但会稳定复现崩溃):

Stop() 只 close 了 stop channel,而 Run() 阻塞在 evtfd.Read 里,
根本没有机会检查 stop。调用方在 Stop 后释放 ringData(host.Close
会 munmap 整个区域),Run 一旦从 Read 恢复就会读已解除映射的内存:
**SIGSEGV,recover 捕不到**。实测 TestEventRing_OverflowStillDelivers
约 50% 概率触发。

两次尝试与结论:
1. os.File.SetReadDeadline 无效——eventfd/pipe 经 os.NewFile 包装后
   **不会**注册进 Go netpoller(os.NewFile 对非 open 得到的 fd 一律按
   非 pollable 处理),Read 是阻塞 syscall,SetReadDeadline 返回错误。
2. 改为 poll(2) 显式加超时(evtpoll_unix.go),消费循环每 100ms 回到
   stop 检查。

新增 API 契约:
- Stop() 非阻塞,仅请求退出
- Wait() 阻塞至 Run 退出;**返回后才能释放 ringData**
- drainEvents 每条事件后检查 stop,避免慢 handler 拖延退出

其他:
- evtring_test.go 三个用例改为 defer Wait() → defer Stop()(LIFO 保证
  Wait 先于 host.Close 完成)
- 溢出用例的 handler 改为非阻塞投递:写入了 8292 条事件而 channel 只
  消费 1 条,阻塞投递会让 drainEvents 卡在 handler 里,Stop 无法退出
2026-09-10 17:56:54 +08:00
ca584bda3f refactor(shm): 内核独占共享槽池,插件经 RPC 申请/归还(§13.2/§13.3)
推翻前两版跨进程分配器设计,根因是"共享内存里放了只被单一进程
更新的可变游标":

- v1 在 SuperBlock 放 arenaUsed 游标,内核 CAS bump。但插件模板里
  arenaUsed 是**进程本地变量**,两进程各自 bump 必写同一段内存;
  arenaReset 还会重置共享游标覆盖对方数据。
- v2 把位图 CAS 下沉到插件模板,正确但把分配器实现泄漏进插件运行时,
  且插件必须与内核保持位图布局同步。

新设计(用户明确的架构约束):内核全权管理共享内存,插件通过
syscall 风格 RPC 申请/归还,内核返回偏移与大小。分配器只存在于内核
进程内,一把 sync.Mutex 即可。共享内存是内部实现,不对插件开发者
暴露——SDK 公开 API 仍是普通字符串/Map。

主要改动:
- arena.go: 定长槽 + 位图,Alloc/Put/Read/Free/ReclaimOwner,内核独占;
  槽头记录 owner,Free 校验归属;payload 超槽容量退回内联 RPC
- protocol.go: 新增 arena.alloc / arena.free(CapCore)
- capability.go: 登记两个新 method(checkAllMethodsClassified 要求)
- corehandler.go: 实现 arena.alloc/free;Cleaner 改为内核预分配
  请求槽 + 响应槽(插件完全不分配)
- plugin.go: Start 领 ownerID;handleExit 调 ReclaimOwner 回收残留槽
- unified.go: 移除坏的 bump 分配器;arena 基址 8 字节对齐
- 删除 toollane.go: ring 状态机是死代码(从未接线),且把 RPC 已有的
  请求 ID 关联/错误传递/ctx 取消重新实现了一遍。控制面保留 RPC,
  只把 payload 搬进共享槽。
- 测试: TestArena_*(归属/回收/并发唯一/耗尽/超限/非法引用/布局)+
  TestPlugin_ArenaAllocFreeAcrossProcess(真进程申请→写入→随业务
  RPC 回传→归还→内核读回一致且池归零)
2026-09-10 17:56:50 +08:00
cf3c99233b fix(shm): 审核修复——arena 并发安全 + ToolCall ring 抢占 + arenaRead 校验
§13.2: arenaAlloc 改 CAS bump(修复并发覆盖);arenaRead 增加 generation + arena 边界校验
§13.3: Reserve 从 Store+Load 改 CAS-free→Reserved 状态机抢占帧(修复并发分配重复);
  增加 RESERVED 中间态;SetReading/SetReady 改 CAS 返回 bool;ReleaseFrame 清零跳过 state
§13.5/13.6: 注入方法接入 resolveText(从 arena 读 SharedRef);cleanerProxy 加 arenaMu 串行化+每次重置
§13.8: ContextPolicy prune topK 改为 maxContextSize-1(不再硬编码 20)
  tool.register 校验 context_policy 只允许 none/prune
2026-09-10 17:03:32 +08:00
2bc813be13 feat(shm): InputChannel/OutputChannel lane 基础设施(§13.5/§13.6)
- injectParams 加 TextRef SharedRef(兼容旧 Text 字段)
- resolveText 辅助函数:优先 SharedRef,否则内联 Text
- 核心注入方法待迁移至 resolveText
2026-09-10 16:14:17 +08:00
9cfdfc9de8 feat(shm): arena + ContextPolicy(§13.2/§13.8)
- unified.go: arena 分配器 (alloc/write/read/reset)
- NewHost: 分配空间含 arena
- SDK ToolDef 加 ContextPolicy string
- StageAfterToolcall 后检查 prune 策略触发 RelevanceContext.Prune
2026-09-10 15:49:47 +08:00
772a494223 feat(ctx): ContextPolicy tool 上下文策略(§13.8)
- SDK ToolDef 加 ContextPolicy string(默认 none / 可设 prune)
- StageAfterToolcall 后检查工具 ContextPolicy=prune → RelevanceContext.Prune
- 所有现有测试通过
2026-09-10 15:41:23 +08:00
5608abdf5a feat(shm): Cleaner 迁移到 SharedRef(§13.4)
- CleanerInvokeParams/Result 的 Text 改为 TextRef SharedRef
- cleanerProxy: 写 text 到 arena,传 SharedRef,读结果 SharedRef
- 插件侧 cleaner.invoke: 从 SharedRef 读 input,清洗后写回 arena 返回 TextRef
- NewHost(): 分配空间含 arena(256KB)
2026-09-10 15:30:00 +08:00
cf098a1d4f feat(shm): arena allocator for unified region (§13.2)
- SuperBlock 新增 arenaOff/arenaCap/arenaUsed 字段
- arenaAlloc: append-only bump 分配,offset 0 保留给空语义
- arenaWrite: 写入并返回 SharedRef(含 generation)
- arenaRead: 按 SharedRef 切片读取
- arenaReset: 压实后重置游标
2026-09-10 14:33:44 +08:00
a8e3633174 feat(shm): ToolCall ring buffer(§13.3) — 栈帧模型 ring,push/pop 帧状态机
- ToolCallRing: 定长帧 ring buffer (64 frames × 112B),状态机 FREE→CALLING→READING→READY→FREE
- Reserve: 环形扫描找 FREE 帧,全忙返回 ErrToolCallRingFull 背压
- SharedRef: pack/unpack 辅助函数
- unified.go: arena 分配器(arenaAlloc/arenaWrite/arenaRead)
- 5 个单测覆盖 init/reserve/背压/find/reuse
2026-09-10 12:33:58 +08:00
ad016e4410 feat(shm): 统一共享内存区域(§13.1) — 单 memfd 容纳 SuperBlock+StageContext+EvtRing
- unified.go: SuperBlock 布局 + SharedRef 类型
- NewHost(): 两段 memfd 合并为单一 memfd,fd 3 = 统一区域,fd 4 = eventfd
- 子进程侧: testdata 插件从 SuperBlock 解析 ctxOff/evtOff
- streaming 测试: EvtConsumer 协程在 host.Close 前 Stop+Wait 防 SIGSEGV
2026-09-10 11:42:22 +08:00
7a328679da feat(proc): Cleaner 跨进程修复(tool/input/output) + 共享内存安全模式 + plan §13
- Cleaner 协议统一为 cleaner.invoke(scope,name,text),覆盖工具/输入/输出三类
- 新增 ShmSecurityMode (safe/debug/full),Linux 审计探针,Windows crypto nonce
- plan.md 追加 §13 步骤分组
2026-09-10 10:21:50 +08:00
580d5f5501 feat(doc): dense vector index for unified text+media retrieval
文档层引入稠密向量索引,与媒体检索共享同一多模态空间:
- Doc 加 DenseVec 字段(json:-,运行时计算)
- Consume/QueryScored 优先使用 denseSearchScored(brute-force cosine),
  未配置时退化到 TF-IDF 倒排检索
- buildDenseIndex 在 Agent 启动时为全部文档一次性计算稠密向量
- L0 RelevanceContext 支持 denseSpace(Prune 使用稠密余弦),
  退化到 fastText 稀疏余弦

vector 包新增 DenseCosine([]float64 brute-force cosine)。

验证:492 篇文档 brute-force ~300ms,全部测试通过。
2026-09-09 18:56:44 +08:00
6c2039f5c9 feat(vector): pluggable multimodal vector space
核心暴露 MultimodalEmbedder 接口,两条路径共享同一套 L0/L2/L3
向量缓存、media.Store 坐标、QueryMemoryMediaScored 检索:
  - onnx:内嵌 ONNX 模型(CLIP 等),通过 build tag 编译
  - http:外部向量 API 服务(Jina v5 / OpenAI / 自建)

跨模态融合权重改为 CrossModalFusionConfig 可配置结构体,
移除所有模型特定硬编码(CLIP/Jina),版本切换只需改配置。

模型切换自动迁移:
  - StaleVecDigestsAll 支持全模态(image+audio+video)
  - 启动时并发重算(ONNX 4 workers / API 8 workers)
  - 修复 SQL 运算符优先级导致 kind 过滤失效的 bug

实测对比(492 篇生产文档 + 3 张真实图片):
  - TF-IDF:MRR 0.457(精确匹配快,语义差)
  - fastText:MRR 0.530(语义中等,延迟 8ms)
  - Jina v5-omni:MRR 0.900(全面领先,延迟 40ms)
  - 中文文本→图片:Jina MRR 0.833 vs CLIP 0.611

See docs/embedding-comparison.md for full benchmark.
2026-09-09 17:38:34 +08:00
6f8056d236 refactor(clip): CLIP 收敛为稠密 MultimodalEmbedder,不污染稀疏 Vectorizer/TF-IDF 语义
文本相似度检索是层次化系统:TF-IDF 高频削弱加权(idf<0.1 丢弃)+
倒排剪枝(只召回共享特征者)+ cosine。CLIP 512 维稠密向量若以
map[string]float64 稀疏形式实现 vector.Vectorizer 并塞进 vector.Store,
会让 512 维全部成为倒排 key → 候选集≈全库、剪枝失效,且绕过 TF-IDF
高频削弱,与既有文本检索语义错配。

收敛:
- vector.MultimodalEmbedder 改为独立稠密接口(VectorizeDense/
  EmbedImageDense/Fingerprint/Dim/Loaded/Close),不再继承稀疏 Vectorizer
- clip.Embedder 删除稀疏垫片 Vectorize/EmbedImage/denseToVector,
  只产出稠密向量;文档/知识/上下文层继续用 TF-IDF/fastText 稀疏路径
- 分层明确:文本→文本走 TF-IDF/fastText;文本↔图像、图像↔图像走
  CLIP 稠密 QueryMedia(媒体层独立稠密余弦,原样保留)
2026-09-09 10:26:10 +08:00
98365f3a55 feat(clip): 多模态向量器(CLIP ONNX)——文本/图像 512 维共享空间 + 媒体向量写入与重算
- internal/memory/clip:CLIP ONNX 向量器(onnxruntime 构建标签控制,默认构建不链接 ONNX)
  - clip.New(modelDir) 加载 text.onnx/vision.onnx(输出 text_embed/image_embed [batch,512])
  - 实现 vector.Vectorizer + vector.MultimodalEmbedder(Vectorize/EmbedImage + Dense 变体)
  - 词级 BPE tokenizer:merges 合并后词末片段带 </w> 查 vocab,与官方 encode 逐 id 对齐
  - EmbedImage:解码→resize 224→NCHW→normalize→vision session
  - Fingerprint(text+vision 文件 sha256)供模型切换检测
  - stub 版(无 onnxruntime 标签)保持默认构建行为不变
- vector/store.go:新增 MultimodalEmbedder 接口
- media.Store:新增 StaleVecDigests(currentModel)——查 vec_model 不匹配/缺失的图片
- agent core:AgentConfig.ClipEmbedder + Agent.clipEmb 接线;
  describePendingMedia 描述成功后 EmbedImageDense→SetVec;
  新增 reembedStaleMedia 启动补算历史无向量图片
- config:core.memory.media.clip_model_dir(未配置退化为现有 fastText/TF-IDF 行为)
- cmd/homed:读 clip_model_dir 加载 CLIP,失败仅记日志不阻塞启动

测试:TestSmokeLoadAndEncode(文本语义 cat>dog 0.914>physics 0.740)、
TestCrossModalAlignment(red-image vs red-text 0.063>blue -0.009,与 Python 一致)、
TestTokEnd(与官方 encode 逐 id 对齐)、TestStaleVecDigests,含 -race 全绿
2026-09-09 10:23:31 +08:00
8c9d96e065 feat(media): media.Store 加向量存储与跨模态检索
media.Item 新增 Vec []float64 和 VecModel 字段,视觉嵌入向量以 JSON
TEXT 存库(为什么不存 BLOB:Go 的 json.Marshal 对 []float64 是自然的,
而 SQLite BLOB 是 []byte 多一层序列化;单条最多 23KB,TEXT 够用)。

initSchema 加 ALTER TABLE 迁移 vec/vec_model 两列(幂等)。scanItem
扩展读回。新增 SetVec 和 QueryMedia 方法。

QueryMedia 对所有已嵌入媒体做余弦相似度检索,维度不一致的项自动跳过
——这是跨模态检索的核心:查询可以是图片也可以是文本,被查的媒体库
里每个 item 也有视觉向量,两者在同一空间比对,谁的相似度更高就召回谁。

配套 5 个单测覆盖:基本相似度排序、无向量项被跳过、维度不匹配过滤、
空查询安全、向量持久化正确性。
2026-09-07 22:31:39 +08:00
e9856c8f65 feat(media): media.Item 加视觉嵌入字段,Store 加 QueryMedia 跨模态检索
路线 3(完整跨模态检索)的媒体层基础:

- Item 新增 Vec []float64(视觉嵌入向量,JSON 序列化存库)和 VecModel
  (模型标识,用于模型切换后触发重算)
- initSchema 加 ALTER TABLE 迁移 vec/vec_model 两列(幂等,列已存在可忽略)
- scanItem 扩展读回 vec/vec_model
- SetVec(digest, vec, model) 写入向量
- QueryMedia(queryVec, model, topK):对所有已嵌入媒体做余弦相似度检索,
  维度不一致的项自动跳过(防不同模型空间的向量被混算)

混合检索的设计意图:查询可以是图片也可以是文本(经向量化后调用 QueryMedia),
被查的媒体库里每个 item 也有视觉向量。两者在同一空间比对,谁相似度更高召回谁。
不再区分「图片查询」还是「文本查询」——向量空间的相似度自动决定。
2026-09-07 17:11:10 +08:00
0af38a29c6 feat(vector): Vectorizer 接口加 EmbedImage,支持多模态嵌入扩展
Vectorizer 新增可选的 EmbedImage(img []byte, mime string) (Vector, error):
支持视觉嵌入的实现者(如 CLIP/MobileCLIP)覆写此方法;不支持的
(StaticEmbedder、TFIDFVectorizer)返回 ErrNotSupported 调用方按文本降级。

这是路线 3(完整跨模态检索)的接口基础:后续在 media.Store 里,
Describe 时同时算视觉向量并存库,QueryMedia 做跨模态混合检索。
2026-09-07 17:00:29 +08:00
b889934bba docs: 同步仓库文档到 v1.1.1,补媒体记忆与接口扩展规则
五处文档此前停在 v1.0.0,而 v1.1.0/v1.1.1 都已发布并在现网运行。
本轮补齐三层记忆的媒体架构、模型工具的媒体参数、通信面 method 数,
以及冻结解除后的替代约束。

## 中英双语 OVERVIEW.md

三层记忆段只写了 Context/Document/Graph 三层,而 v1.1.0 起图片/音频是
三层里的一类节点。补上媒体记忆的架构概要:CAS + 引用计数 GC + 标记格式
(描述文本才是持久语义记忆,blob 是可淘汰的缓存)+ 插件边界贯通。

## 中英双语 ARCHITECTURE.md

- 「记忆工具」表补 `memory_commit`/`doc_commit` 的 `media_digests` 与
  `sentence_text`(后者从未暴露给模型,而它是媒体绑定链的必经环节)
- 「三层记忆」段新增媒体记忆子节:CAS 设计表(寻址/完整性/写入原子性/引用/GC)、
  标记格式、可选性(`enabled=false` 时整条链路静默退化)
- 「三个通信面」从 51 改为 55 个 method,新增四个 media method 的说明
- 「SDK 四通道」代码示例补媒体注入三方法 + 与 SetToolBlocks 的区别

## plugin-interface-matrix.md

- 状态从「完成 v2」改「完成 v3」,v3 记录 v1.1.1 的接口扩展
- §二 A3 DocMemoryAPI 补 InsertWithMedia
- §二 A4 补三个媒体注入方法 + 为何不能搭 SetToolBlocks 的车
- §二 A5 补 MediaAttachment 类型 + Triple/Doc/TextEvent 的扩展字段
- §六「新获得的能力」补 SetToolBlocks 已落地、媒体入记忆、插件主动发起
  带媒体的对话
- §七 冻结检查点补第 5 条(冻结已解除,取代它的是 §九)
- 新增 §九「v1.1.x 的接口扩展规则」:冻结解除后的三条硬约束
  (只增不减签名不改 / 新增方法方向 / 模板接线六处失败链)+ 验证方式
  (存量插件 17/17、旧产物 4/4 建链、模板断言、压测 -race)

## 为何分立两笔 commit

前一笔(SDK 仓 README + 内核 README)改的是给**插件开发者**看的文本,
本笔改的是给**架构师与维护者**看的技术文档。受众与改动层次不同,
放在同一个 commit 会让追溯时看不出"文档在哪一层跟上了代码"。
2026-09-06 15:07:14 +08:00
8cd884027f docs: 项目状态与 plan 同步到 v1.1.1,vendored SDK meta 注释对齐 SDK 仓
三处失同步,都会让读者拿到错的现状:

## README / README_EN

「项目状态」段的最新条目还停在 v1.0.0,而 1.1.0 与 1.1.1 都已发布。补上两条,
并把顶部特性摘要与下载段的 Windows 安装器版本号一起更新(那里写死了
`HomeAgent_v1.0.0_*_win64.exe`,照着它去 release 页面找是找不到文件的)。

## plan.md §12.1

标题还是「待用户决策后执行」,而四个决策点早已全部落定并执行完毕。改为已完成,
并记下实际演进已超出当初设想的地方(这些后来都写进了 docs/git-branching.md):
发布分支改为一个中版本一条、三级通道由 tag 区分、SDK 版本跟随核心中版本且
patch 位恒为 .0、beta 阶段不发 SDK、main 永不作发版分支。

同时修掉一处会误导追溯的陈述:§12.1 原文说 `v1.0.0` tag 指向 feature 分支中间点
`670efcd` 需要重打——那件事早已做完,现在指向 `release/v1.0.x` 上的 `9b92a04`。

## plan.md §12.5

「无 Windows 真机验证」这条仍然成立,但补一句区分:Windows NSIS 安装器从 v1.0.0
起就随每个正式版作为 release 资产发布了。**能打出包 ≠ 包里的共享内存/事件对象在
真机上能跑通**,混为一谈会让人以为这项已经关闭。

## vendored SDK meta

主仓跟踪 `third_party/homeagent-sdk/meta/meta.go`,而该文件在 SDK 仓 main 上刚补了
版本路牌的说明注释。两仓这份文件必须逐字一致——否则下次谁改了哪边说不清,
而它正是「两仓中版本对齐」这条纪律的载体。仅注释差异,无行为变化。
2026-09-06 10:56:42 +08:00
e4be9667ec chore(meta): main 的版本路牌推到 1.2.0
按 docs/git-branching.md §2.1,main 的 meta.Version 始终是**下一个未发布中版本**。
1.1.x 线正在发布中(release/v1.1.x 承载 v1.1.0 / v1.1.1),所以 main 指向 1.2.0。

它标记「main 正在积攒 1.2 的东西」,不表示 1.2.0 已经存在——1.2.0 没有任何 tag。
已发布的版本号一律看对应的 release/vX.Y.x 分支与 tag。

两仓同步:SDK 仓 main 也是 1.2.0(SDK 版本跟随核心中版本,见 §七.1),
而 release/v1.1.x 上 SDK 定版 1.1.0。

**此 commit 不 cherry-pick 到发布分支**(§五:版本号 bump 不跨分支搬)。
2026-09-06 09:54:55 +08:00
1b4643089c Merge branch 'feature/sdk-multimodal' — 多模态贯通插件边界
公开 SDK 新增媒体字段与媒体注入接口,内核实现对应 RPC 与桥接层,
text/image/audio 三条输入路径归一成一条 processInput 主干。
同步 docs/git-branching.md:SDK 版本语义、beta 不发 SDK、main 永不作发版分支。

存量插件零改动零重编(17 个 example 类型检查通过)。
2026-09-06 09:54:03 +08:00
8c6b593a54 docs(git): SDK 仓版本语义与发版联动,并明确 main 永不作发版分支
三条此前没写进规范、于是被我在实际操作中做错的规则:

## 一、SDK 版本号跟随核心的中版本,patch 位恒为 .0(新 §七.1)

整条核心 1.1.x 线共用 SDK 1.1.0;只有核心进入 1.2.0 这种中版本跃迁时 SDK 才升。
核心的 patch 位专用于 bugfix 与漏洞修复,这类改动不碰公开 SDK 接口,SDK 版本号
没有理由跟着动。

**为什么不逐位对齐**:SDK 版本号是插件开发者的依赖声明。若核心每发一个 bugfix
就给 SDK 推一个新号,开发者要么被迫跟版、要么怀疑自己版本过时,而接口其实一个
字都没变。让 SDK 号只在**接口可能变化的中版本边界**上跳,开发者只需关心「我在
为哪个中版本写插件」。

因此「两仓版本对齐」在本规范里指**中版本对齐**(核心 1.1.x ↔ SDK 1.1.0),
不是三位全等。核心 1.1.1 配 SDK 1.1.0 就是对齐状态。§四 的措辞同步修正。

## 二、beta 阶段不发 SDK(新 §七.2)

核心的 alpha/beta tag 不伴随 SDK 仓发版:SDK 仓在这一阶段不打 tag、不建 release。

**为什么**:beta 是核心自己的测试阶段,此时 SDK 接口尚未固定。若此刻给 SDK 发版,
插件开发者会照着一个还会变的接口写代码——那是无效开发。接口没定就没有可依赖的
契约,发出去的版本号是一个假承诺。

这条约束的对象是 **SDK 仓的发版动作**,不是核心二进制里有没有 SDK 代码。主仓用
`replace => ./third_party/homeagent-sdk`,任何核心构建都必然含 vendored SDK 源码,
那是构建机制决定的,不在约束范围内。

核心打**正式** tag 时 SDK 才随之发版(§七.3):SDK 仓也有自己的 `release/vX.Y.x`,
定版为 `X.Y.0`,打 tag、建 release、传 5 平台 plugindev 产物。同一中版本内的后续
核心 patch 不重复发 SDK。

## 三、main 永远不是发版分支(补进 §四)

版本号 bump、打 tag、构建产物、上传附件,全部只在 `release/vX.Y.x` 上做。
**即使某个改动刚合进 main、即使 main 此刻可部署,也不从 main 打 tag。**
main 的版本号是「下一个未发布中版本」的路牌,不是任何一次发布的版本号。

这条本该是 §2.1「main 的 meta.Version 始终是下一个未发布版本」的直接推论,但
只写了状态、没写禁令,于是留下了「main 可部署 ⇒ 可以从 main 发版」的误读空间。
§三 的分支表补上 main 现值 `1.2.0` 与 1.1.x 线的 tag 历史,让路牌语义有实例可对。

## 四、公开接口改动是 feature,不是发布准备(补进 §六)

它必须走 `feature/xxx` → 合回 main → cherry-pick 到发布分支,不允许当成「发布
分支上的 bug 修复」直接提交进 release——发布分支冻结功能(§2.3),而接口是最
典型的功能面。§五 补上这条路径的命令示例,以及 SDK 仓同步发版的命令。
2026-09-06 09:52:52 +08:00
687e5655bc feat(sdk): 多模态贯通插件边界——公开接口、内核桥接与统一输入主干
记忆系统在 1.1.0 支持了二进制多媒体节点,但那条链路只对**内核自己**开放:
用户在 qq 发图能落进 CAS、能被记忆引用,而插件调 Commit / DocMemory().Insert
交进来的媒体一律无处安放。原因是三层都断着,且**每一层都不报错**。

## 一、公开 SDK:补上媒体的表达能力(全部新增,无签名变更)

- `Triple` += `SentenceText`、`MediaDigests`
- `Doc` += `MediaDigests`、`Attachments`;新增 `MediaAttachment`
- `TextEvent` += `Attachments`
- `DocMemoryAPI` += `InsertWithMedia`
- `IOInjector` += `InjectInputMedia` / `InjectInputMediaSync` / `InjectInterruptMedia`
- `PluginSDK` 补上一直缺失的 `SetToolBlocks` 包装(接口里有、便捷方法里没有)

`MediaAttachment` 一个类型服务两个方向:给 `Data`+`MIME` 是新内容(CAS 按字节
去重),只给 `Digest` 是引用已有内容。读路径**只回元数据不回字节**——一次检索
可能命中几十份媒体,全塞回去会把跨进程消息撑爆。

媒体注入不能搭 `SetToolBlocks` 的车:那个方法只在工具处理函数内部可用,且媒体
要等下一条 tool message 才到模型手上。插件主动发起一轮带媒体的对话、以及中断
注入,需要自己的签名,且媒体在**本轮**就送到模型。

## 二、内核桥接层:原先在静默裁字段

`internal/sdk/memory_impl.go` 此前只搬自己认识的几个字段,其余丢弃且返回 nil:

- 图记忆丢 `Confidence`/`SubjectType`/`ObjectType`/`SentenceText`,又走 `Commit`
  而非 `CommitWithMedia`(不回 sentenceIDs)→ 媒体绑定链 `SentenceText → sentences
  → sentence_id → media_refs` 一步都走不通,插件即便按格式写好标记也永远挂不上;
- 知识库 `Query` 只回 ID/Title/Content,`Insert` 只写这三个;`Remove` 不解引用,
  于是那些媒体永久处于「被引用」状态,GC 收不掉、磁盘只增不减
  (内核的归档路径 `releaseDocMedia` 做了这一步,插件路径漏了同一步)。

规则改为:**内部结构有的字段一律透传**。标记格式处理作为包级私有辅助留在桥接
层自己手里,但必须与内核 `mediaSummaryForEvent` 字节兼容——两边要能互读对方
写下的标记。

标记插入必须在 `ds.Insert` **之前**(向量索引取 `Summary + " " + Content`,
之后补的标记检索不到),引用绑定必须在**之后**(owner_id 是 Insert 生成的 ID)。

## 三、跨进程链路:不接线就是全体外部插件编译失败

`go test` 直接把这一层拍出来了——`procIO does not implement sdk.IOInjector`。
公开接口加方法后,生成模板不跟上,**每个外部插件都编不过**,是硬失败不是软降级。
六处接线:`protocol.go` 四个 method 常量、`capability.go` 能力归属、
`corehandler.go` 四个分派分支、`proc_core.go` 委托、`proc_main.go.tmpl` 模板侧
实现、以及三个测试替身。

## 四、统一输入主干:把模态从「函数选择」降级为「字段」

`processTextInput` / `processMediaInput` 合并为 `processInput`。这个分叉是历史
产物而非设计:`processTextInput` 本来就处理媒体(`bindEventMedia` +
`mediaSummaryForEvent`,与媒体路径尾部完全相同),`process()` 只看
`stageCtx.Extra["media_blocks"]`、根本不认识 `evt.Type`。模态是输入的**属性**,
不是输入的**种类**。

媒体路径由此获得它一直缺的六项:去重、`no_memory`、通道 `Cleaner`、中断语义、
`_consolidation_` 路由、正确的 `EventRawInput`。

最后一项是个真 bug:媒体路径发布 `"content": evt.Payload`(一个 map),而
`webui/handler.go` 断言 `.(string)` → 断言失败、`content == ""`、提前返回。
**用户发的图从来没出现在 WebUI 聊天记录里。**

`media_blocks` 同时接受 `[]agentAPI.ContentBlock` 与 `[]pubsdk.ContentBlock`:
字段一致但 Go 不自动转换,只认一种的后果是另一种被静默丢弃。

## 五、模型可调用的三个工具

`memory_commit` 的 `sentence_text` **从未暴露给模型**,而它是绑定链上的必经环节;
连同 `media_digests` 一起补进 JSON schema 与工具文档。`doc_commit` 加
`media_digests`。`doc_query` 把关联媒体单独一行附在结果末尾(正文按 2000 字截断,
标记通常就在尾部)。

标记由**内核**生成而非插件/模型拼装:要求调用方知道格式,等于让一个拼写错误
静默切断引用绑定,而全链路无人报错。

## 六、WebUI 上传走真实媒体链路

图片/音频读回字节拼 data URL 注入 `media_blocks`(8MB 上限,超限退回按路径处理)。
此前只注入一句「文件已保存到 <路径>」,指望模型自己调 `files_read`——但那返回
文本,图片字节对模型永远不可见。附件类型识别扩展到 audio 并在缺 Content-Type
时按扩展名兜底(判错不只是卡片样式问题,图片被当普通文件就进不了视觉链路)。

## 测试

- `internal/sdk/memory_impl_test.go`(12 例,此前该包**没有任何测试文件**)
- `internal/agent/core/inputunify_test.go`(统一主干 + 双静态类型 + 三工具媒体)
- `third_party/homeagent-sdk/sdk/stress_test.go`(13 例并发压测)

压测抓到两处**真**竞态(不是理论风险):`PluginSDK` 的 API 字段与 `autoRestart`
无锁,而写方(内核注入 API、插件 `SetAutoRestart`)与读方(插件后台 goroutine
注入、内核 registry 读 `AutoRestart`)天然跨 goroutine。加 `apiMu` 修掉;约定
只在持锁期间取字段值,取完即释放再调用——持锁调用会把 `InjectInputSync` 这类
阻塞到 agent 回复(可达数分钟)的方法与 `SetIOInjector` 串起来,让插件重载卡死。

测试还抓出两个自身缺陷:`bindDocMedia` 把同一份媒体数两次(`AddRef` 幂等所以表
是对的,但日志说「绑定 2 个」而实际 1 条——误导后续排查),以及用单字符实体名
时 `validEntityName` 静默跳过、`Commit` 返回 nil 却什么都没写。

存量插件不需要改一行也不需要重编:新增方法由插件调用、内核实现,不调就不受影响。
17 个 example 插件源码零改动通过类型检查。
2026-09-06 09:51:31 +08:00
e8d12db871 fix(packaging): amd64 GUI 从未走过 electron 缓存,且空壳 node_modules 被当作已安装
干净 worktree 上打包时 GUI 被静默跳过。两个缺陷叠加,都属于「所有外层
检查都通过,只有嵌套的运行时缺失,而没有任何东西喊出来」。

## 一:electron 架构名与 Debian 架构名混用

electron 官方发布物命名用 x64/arm64,Debian 用 amd64/arm64。缓存查找
一直统一用 TAR_ARCH(amd64),于是 electron-v*-linux-x64.zip 永远命中
不到。arm64 两边恰好同名,所以上次修 arm64 GUI 架构污染(743b963)时
这个不一致没暴露。

推论:v1.0.3 的 amd64 GUI 实际是靠「回退到 host node_modules/electron/
dist」这条路组装的,不是走缓存——那条回退只在目标架构 == host 架构时
才允许,恰好成立所以没出错。干净 checkout 里没有完整 node_modules,
回退路径也没有,GUI 就消失了。

修法:单独映射 ELECTRON_ARCH(amd64→x64,arm64→arm64)。

## 二:判 node_modules 目录存在,而非判 electron 包存在

npm install 失败(离线/网络受限)会留下只有一两个条目的空壳
node_modules。原判据 [ ! -d node_modules ] 认为「已安装」,于是跳过
install → ever 读不到版本 → 缓存匹配退化到通配 → host dist 也没有 →
静默跳过 GUI。包名、目录名、变体名全部正确,只是没有 GUI。

修法:判据改为 electron/package.json 是否存在;目录在而包缺失时明确
说明「疑似上次 npm install 未完成」再重试;install 失败给出明确提示
而不是继续往下走。

顺带给 ever 加兜底:读不到已安装版本时从 package.json 的依赖声明取
数字部分(那里是 "^33.0.0" 这类范围,仅用于给缓存匹配一个提示)。

## 验证

干净 worktree(/tmp/rel104,release/v1.0.x)上重跑:
  node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)
  electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
  electron runtime: electron-v33.4.11-linux-x64.zip
  GUI built: build/homeagent-gui-linux-amd64 (263M, x86-64)

file -b 确认 electron 二进制为 x86-64,与目标架构一致(该硬校验由
743b963 引入,此处继续生效)。
2026-09-05 14:58:30 +08:00
aac88ce5ee Merge branch 'feature/memory-media' — 记忆系统支持二进制多媒体节点
四层实现 + 五个缺陷修复。方案 B+C(用户选定):内容寻址存储 + 描述文本
作为持久语义记忆。

## 四层

L1 CAS(internal/memory/media)
  元数据进 SQLite,blob 落盘 blobs/ab/cdef…,.tmp + rename 保证不会把
  半写文件当完整内容读。Get 每次重验 digest——CAS 的全部保证都建立在
  「文件名 == 内容摘要」上,喂一张损坏的图给模型会得到无法追溯的幻觉。
  AddRef 幂等且只在真插入时才涨计数(虚高则 GC 永远不敢清理),
  DropRef 用 MAX(0, ref_count-1)。GC 两段式 + minAge 保护刚落盘还没来得及
  AddRef 的项;被引用的内容即便超容量也永不删除——宁可超限也不能悬空。

L0/L2 接入(internal/agent/core/mediaref.go)
  只捕获 data URL:http(s) 会把一次对话变成一次网络请求(超时/鉴权/SSRF)。
  digest 先 stage 后 bind——媒体在 process() 期间被捕获,而承载它的
  ContextEvent 要等 process() 返回后才 Append,此刻还没有 owner_id。
  归档时先 AddRef 到新 owner 再 DropOwner 旧的:反序会让计数瞬时归零,
  并发 GC 会把仍被引用的内容当孤儿清掉。

后台循环(internal/agent/core/medialoop.go)
  GC 定时清理让容量上限真正生效(此前 max_mb 注册了却无调用方)。
  描述生成走后台而非对话路径:视觉模型一次调用生产实测 9.6s,放在对话里
  会给每张图的回复加十几秒,而描述的价值是几个月后还能检索到——这一轮
  模型本来就直接看着图。逐条而非批量:批量拿回来是一整段文字,无法可靠
  切分回各自的 digest。默认关闭,开启后每 30s 最多 4 条。

L3 图库反查(internal/agent/core/graphmedia.go)
  只做引用不建描述节点(方案 A):图库的实体与关系来自描述文本的 NLP
  提取,检索能力已具备;若节点名取自描述,描述重新生成后同一张图会留下
  多个语义模糊的节点。媒体实体名用「图片 <短digest>」——digest 不变则
  名字不变。CommitWithMedia 新增而非改 Commit 签名(后者有 31 个调用点)。

## 五个缺陷

1. rc.SetMediaStore 从未被调用 → L0→L2 引用转移在生产静默失效
2. 三元组全被实体名校验拒绝时仍释放引用并删文档 → 数据丢失
3. 媒体入 L3 依赖 NLP 提取器碰巧提出合规三元组 → 时好时坏
4. L3 媒体检索没有任何调用方 → 能存进去,agent 拿不出来
5. 两处数据竞争(remotedevice bufio.Writer / agentcli 共享读缓冲)

前四个都是「手工调 API 的单测无法发现」的类型:函数正确,但没接上,
或只在理想输入下正确。第 2 个做了反向验证(回退修复后测试确实 FAIL)。

## 验证

medialive 自动触发链实测(-tags medialive,源/模型/密钥由调用方经环境
变量显式指定):只注入一个 image 事件,七个阶段全由生产代码自己触发。
真实 claude-opus-5 通过——第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。
配阴性对照:不给记忆时不该「记得」,否则阳性用例可能只是模型猜配色。

551 篇生产归档文档干跑:媒体正则零误命中;28 篇文档在旧逻辑下会被删除
而信息并未进图库,新逻辑保留。

全仓 go build / go vet / go test / go test -race 全绿,SDK 冻结 diff = 0。
2026-09-05 13:56:57 +08:00
a7b54fef7f test(memory): 修正数据丢失回归用例的构造——它被媒体三元组修复本身弄失效了
两个修复之间产生了耦合:TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty
的前提是「三元组全被 validEntityName 拒绝」,而同批引入的
mediaTriplesFromText 会为正文里的 [image/png <digest>] 标记产出合规的
「图片 <digest>」三元组,于是 ec=4 rc=2、绑定成功、释放引用变成正确行为,
用例的前提消失。

(注意当时 GC 断言并未触发——内容没丢,只是"引用被释放"这条断言不再
适用于该构造。)

改法:正文不再含媒体标记,媒体引用直接 AddRef 挂上。这模拟的是更危险的
组合——文档持有媒体引用,但正文里的媒体标记已在清洗中丢失,于是有引用
要释放却没有句子能承载它。那正是这个守卫要防的情形。

反向验证重做后仍成立:回退守卫 → FAIL(引用被释放 + GC 删掉了本该保留的
内容);恢复修复 → ok。

教训:只跑针对性测试不足以发现修复之间的耦合。提交前我跑的是
internal/agent/core 与 internal/memory,当时通过是因为缺陷二尚未修完;
两个修复都落地后的第一次全仓回归才暴露它。
2026-09-05 12:08:33 +08:00
387b28ce09 fix(memory): 媒体归档三缺陷——数据丢失、L3 入库不可靠、L3 检索未接线
自动触发链实测(medialive)连续暴露的三个缺陷,全部是「手工调 API 的
单测无法发现」的类型。附带该实测本身。

## 缺陷一:三元组全被拒时仍释放引用并删文档(数据丢失)

archiveColdDocs 只检查 len(triples) > 0 就释放媒体引用、删除文档。
但 Commit 会静默跳过实体名不合法的三元组(validEntityName 要求
2–50 字符),于是「无错但一条也没写进去」真实发生:

  [agent] doc→graph: doc_xxx → 0 entities, 0 relations
  [media] 文档 doc_xxx 入图库,释放 1 个媒体引用(描述已留在图库)
  被记忆引用的内容被 GC 删除了(清 1 条/318 字节)

图库里没有任何句子承载引用,文档也被删,blob 被 GC 回收 → 图片与描述
彻底消失。我在上一层写的注释「Commit 之后引用已挂到 graph_sentence」
是错的:ec=0 rc=0 时它什么也没挂。

修法(用户选定 B+A):
  B. ec==0 && rc==0 时保留文档、跳过归档——归档的实质是「信息从 L2
     搬到 L3」,搬不过去就不该删源,下轮再试。
  A. bindSentenceMedia 返回实际绑定数,commitTriplesWithMedia 透出为
     mediaBound;释放前四路判断(查引用出错→保守不释放/本无引用→无需
     释放/mediaBound==0→保留并记录原因/否则释放)。宁可留一条悬空
     引用(内容还在,可由后续一致性检查清理)也不能丢内容。

反向验证:旧行为下新测试确实 FAIL,报「引用被释放了」+「GC 删掉了本该
保留的内容」;恢复修复后 PASS。

## 缺陷二:媒体入 L3 依赖 NLP 提取器运气(可靠性)

媒体能否进图库,取决于提取器碰巧从描述文本里提出合规三元组。实测 LLM
的 477 字图片描述只产出「水平 -分割-> 成」,obj 仅 1 字被拒 → 整条媒体
记忆进不了图库。表现为「阶段 5 时好时坏」,取决于描述文本。

但媒体自身的 digest / mime / 描述都是确定的,不该受提取器支配。

新增 parseMediaMarkers + mediaTriplesFromText:从文档正文的媒体标记
直接产出确定三元组,先于 NLP 提取。同一份真实文档由 0 entities 0
relations 变为 ec=4 rc=2 且拿到句子 id。

三个设计点:
  - 实体名用「图片 <短digest>」而非描述:描述会被重新生成(换视觉模型、
    补描述),若名字取自描述,同一张图会在图谱上留下多个节点。digest
    不变则名字不变,长度也天然合规。
  - SentenceText 用原始标记段,保证 bindSentenceMedia 的正则必然能反解
    到 digest——绑定从概率事件变成确定行为。
  - 描述为空时仍产出「类型」三元组:描述是后台异步补的,媒体节点不该
    因为还没描述就不存在于图谱。
  - summarizeForEntity 按 rune 截断而非字节:按字节切会破坏 UTF-8,
    图库里会留下乱码实体名。同时清 Markdown 强调符。

这是过渡方案,用户已定:下个 feature 换多模态嵌入后不再依赖
「描述文本 → 提取三元组 → 图谱节点」这条链路。

## 缺陷三:L3 媒体检索没有任何调用方(接线缺失)

第四层实现的 RecallMediaForSentence / mediaContextForSentences 从未被
调用——媒体能存进 L3、能反查,但 agent 拿不出来。实测第二轮 agent 显式
调了 doc_query,回答「没有找到那张图片的任何记录」。

接两个入口:
  - buildMemoryContext(自动注入,每次 LLM 调用都走)
  - memory_recall 工具结果末尾(显式查询)

关系行只有实体名和关系类型,看不出「这条记忆当时还带了一张图」,
媒体挂在句子上,必须经 关系→句子→media_refs 反查。

一处折返:最初直接用 injected.Relations 取 sentence_id,测试失败。
Indexer.BuildContext 刻意把 Relations 置 nil(自动注入只给实体索引以省
token,细节留给 memory_recall)。改为用命中的实体名再查一次关系,
深度固定 1——媒体是「这条记忆当时带的图」,顺关系网扩散只会带出无关
媒体并挤占 token。

## medialive 自动触发链实测

internal/agent/core/medialive_test.go,medialive build tag,默认
go test 不收录。源/模型/密钥全部由调用方经环境变量显式指定,缺任何一项
Skip 并列出缺哪个——刻意不提供 fallback,猜一个 base_url 可能打到调用者
机器上不相干的服务,而失败会被误报成「媒体记忆有问题」。

  MEDIALIVE_BASE_URL=... MEDIALIVE_API_KEY=... \
  MEDIALIVE_MODEL=... MEDIALIVE_ADAPTER=... \
  go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v

只注入一个 image 事件,之后七个阶段全由生产代码自己触发:CAS 落盘 →
引用绑定 → 描述生成 → L0→L2 转移 → L2→L3 绑定 → GC 保护 → 第二轮召回。
另有阴性对照:不给记忆时不该「记得」,否则阳性用例的通过可能只是模型
猜常见配色。上游不可用时 Skip 而非假 PASS。

真实 claude-opus-5 实测通过:第二轮不给图,agent 答出
「上:紫罗兰色 #8800DD / 中:蓝色 #0055EE / 下:纯红 #EE0000」。

## 测试

graphmedia_test.go 新增 8 例:数据丢失回归(反向验证过)、mediaBound
计数、媒体标记解析、实体名生成、描述截断、确定性三元组必然可入库、
关系→句子映射、L3 检索接线(自动注入与显式查询两路)。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全绿,SDK 冻结 diff = 0。
2026-09-05 12:03:30 +08:00
775d9e8a2e fix(memory): core.New 漏接 rc.SetMediaStore,L0→L2 引用转移在生产从未生效
RelevanceContext.transferMediaRefs 依赖 c.mediaStore,而该字段只有
SetMediaStore() 能设置。搜遍全仓非测试代码,调用点为零——core.New() 里
没有,cmd/homed/main.go 里也没有。

上一层(f855893)把 AgentConfig.MediaStore 接到了 Agent.mediaStore,
漏了 rc 这一路。

后果是静默的:Prune 归档时 c.mediaStore 为 nil,transferMediaRefs 直接
return,而携带引用的 ContextEvent 已被归档删除 → 引用永久悬空在
context owner 上、计数永不归零 → 对应 blob 永远不会被 GC 回收。

## 为什么测试没抓到

mediaref_test.go 里我手工调了 rc.SetMediaStore(ms) 才测转移逻辑。
**测试验证了函数正确,没验证它被接上了。** 与 findPluginPID 那次同一
个教训:测了一件不会自然发生的事。

## 顺带全字段审计

写脚本比对 AgentConfig 的 32 个字段与 New() 函数体的引用情况,
确认无第二处漏接。
2026-09-05 12:02:34 +08:00
968b01f26e fix(plugins): 修复隔离全量测试暴露的两处数据竞争
-go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。
两处独立缺陷,互不相关。

## 缺陷一(remotedevice,8 处 race):连接写无串行化

WARNING: DATA RACE
  Read at 0x... by goroutine 28:
    bufio.(*Writer).Available() / writeFrameHeader / PushData
  Previous write at 0x... by goroutine 27:
    bufio.(*Writer).Flush() / writeFrame / handleWS

同一连接的 bufio.Writer 被两条并发路径写:
  - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong
  - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine

bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这
不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的
hello_ack 回写并发时被 -race 稳定抓到。

修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条
下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途
被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走
同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。

关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。

## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写

WARNING: DATA RACE
  Write at 0x... by goroutine 26:
    os.File.Read / (*linuxPty).Read / reader
  Previous read at 0x... by goroutine 25:
    runtime.slicecopy / readLoop

readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine
(t.session.Read(buf) 持续覆写),自己又在读到结果后
copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层
fd,专门在 reader 写下一段时读,跑 -race 稳定复现。

修法:readResult 携带 data field,reader 每次读完后把数据复制进自己
分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf
保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。

## 验证

  - 两个插件包 -race -count=2 全过
  - 全仓 go build / go vet / go test 通过
  - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL
  - SDK 冻结 diff = 0

其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd
/ TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。
2026-09-05 07:26:10 +08:00
6190a5a587 fix(plugins): 修复隔离全量测试暴露的两处数据竞争
-go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。
两处独立缺陷,互不相关。

## 缺陷一(remotedevice,8 处 race):连接写无串行化

WARNING: DATA RACE
  Read at 0x... by goroutine 28:
    bufio.(*Writer).Available() / writeFrameHeader / PushData
  Previous write at 0x... by goroutine 27:
    bufio.(*Writer).Flush() / writeFrame / handleWS

同一连接的 bufio.Writer 被两条并发路径写:
  - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong
  - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine

bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这
不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的
hello_ack 回写并发时被 -race 稳定抓到。

修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条
下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途
被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走
同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。

关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。

## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写

WARNING: DATA RACE
  Write at 0x... by goroutine 26:
    os.File.Read / (*linuxPty).Read / reader
  Previous read at 0x... by goroutine 25:
    runtime.slicecopy / readLoop

readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine
(t.session.Read(buf) 持续覆写),自己又在读到结果后
copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层
fd,专门在 reader 写下一段时读,跑 -race 稳定复现。

修法:readResult 携带 data field,reader 每次读完后把数据复制进自己
分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf
保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。

## 验证

  - 两个插件包 -race -count=2 全过
  - 全仓 go build / go vet / go test 通过
  - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL
  - SDK 冻结 diff = 0

其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd
/ TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。
2026-09-05 05:53:17 +08:00
28fc833a6f feat(memory): L3 图库媒体反查 + 修 L2→L3 引用泄漏
媒体记忆四层收尾。方案 A:只做引用,不建媒体实体节点。

## 为何不把媒体建成图库实体

图库里的实体与关系全部来自**描述文本**的 NLP 提取——描述经
mediaSummaryForEvent 进 L0 事件的 Input,随归档进 L2 文档的 Content,
蒸馏时提取器自然从描述文字里抽出实体和关系。检索能力已经具备。

若再把媒体本身建成节点,节点名只能从描述里取,而描述会被重新生成
(换个视觉模型、补一次描述,名字就变了),于是同一张图会在图谱上留下
多个语义模糊的节点。代价换不来能力。

所以这一层只做一件事:**反查**。图库句子写着「[image a1b2c3d4e5f6]
一张紫蓝红三色带图」,要能从这条句子取回那份字节。

## CommitWithMedia:新增方法而非改签名

Commit 有 10 个非测试调用点 + 21 个测试调用点。为一个多数调用方都不需要
的返回值改全部签名不划算。新增 CommitWithMedia 返回
map[句子文本]sentences.id,Commit 内部转调同一份落库逻辑。

## digest 靠正则从文本反解

三元组由 NLP 提取器从纯文本产出(nlp.ToMemoryTriple 只填 Subject/
Relation/Object/Confidence/SentenceText),提取链路上没有任何位置能塞进
结构化的 digest。要贯通就得改 internal/nlp 的整条数据流。而媒体标记本身
是我们自己按固定格式写进文本的,反解是最省的可靠做法。

配套加 media.ResolvePrefix:文本里是 12 位短 digest(完整 64 位会把一行
撑爆且无助人眼辨认),media_refs 主键要完整 digest。

**前缀歧义视为错误而非"取第一个"**:挂错引用会让 GC 删掉仍被引用的内容。
完整但不存在的 digest 也报错,否则调用方会挂一条孤儿引用。

## 顺带修掉 L2→L3 的引用泄漏

这是上一层(f855893)留下的缺口:我当时只处理了 L0→L2 的引用转移,
漏了 L2→L3 这一跳。archiveColdDocs 调 docStore.Remove(doc.ID) 时不注销
媒体引用——文档一旦消失就再没有任何东西能告诉我们它引用过哪些 digest,
media_refs 里那条记录永久悬空、引用计数永不归零,对应 blob 永远不会被
GC 回收。

新增 releaseDocMedia。L2→L3 这一跳是**释放**而非转移,因为图库存的是从
描述文本抽出的实体与关系,不再持有字节;媒体此时已完成使命。

顺序有讲究:必须在 commitTriplesWithMedia 之后释放。那一步已把引用挂到
graph_sentence owner 上,先销后挂会让引用计数瞬时归零,此时若后台 GC
正在跑就会把内容当孤儿清掉。

## 顺带修 Pending 的排除逻辑遗漏(承上一提交)

## 测试

graphmedia_test.go 11 例。核心是 TestBindSentenceMedia_RoundTrip:
写入 → 提交 → 从句子 id 反查 digest → 取回字节逐字节比对 → 跑 GC(0)
确认被引用的内容不被清。

其余覆盖:正则不误命中普通方括号([注意]/[TODO] 不能当 digest,否则会拿
假前缀去 ResolvePrefix)、无法补全的 digest 不挂引用、媒体关闭时全链路
静默 no-op、releaseDocMedia 释放后 GC 真能回收、200 个样本的前缀补全
要么唯一命中要么明确报歧义。

TestCommit_StillWorksAfterRefactor 记录一个既有行为:重复提交时
entitiesCreated 不归零,因为 SQLite 的 ON CONFLICT DO UPDATE 也算一行
affected。用 main 分支的 graph.go 单独跑过基线确认与本次重构无关,
该字段只用于日志,故记录现状不改行为。

全仓 go build / go vet / go test 通过,internal/agent/core 与
internal/memory 全部 -race -count=2 通过,SDK 冻结 diff = 0。
2026-09-04 22:52:30 +08:00
74a24f93d7 feat(memory): 媒体 GC 与描述生成两条后台循环
补齐媒体记忆的最后两块:容量上限真正生效,描述文本成为持久语义记忆。

## mediaGCLoop:让容量上限不再形同虚设

CAS 的 GC 只在被显式调用时执行,Put 路径不触发它。此前配置项
core.memory.media.max_mb 注册了却没有任何调用方——一次 see_video 抽 10 帧,
帧本身在工具结果被 Prune 后就没人引用了,若无人清理会一直堆在磁盘上。

现在按 gc_interval(默认 6h)周期调 GC(gc_min_age)。两个不变量:
  - 有引用的内容永不删除,即使超容量(宁可超限也不断引用)
  - gc_min_age(默认 1h)保护刚 Put 还没来得及 AddRef 的项——它们
    refcount 也是 0

## mediaDescribeLoop:描述才是能活过 GC 的那部分

blob 会被容量 GC 淘汰,而描述留在 media 表里,并经 mediaSummaryForEvent
写进 L0 事件、随归档进 L2 文档、经蒸馏进 L3 图库。于是「那张紫蓝红三色
带图」在原始字节早已被清掉之后仍然可被检索到。

复用既有的视觉回退链(resolveModalFallback + chatModalFallbackBatch),
不新造一套模型调用。

三个刻意的决定:

  - **走后台而非入库时同步**:视觉模型一次调用生产实测 9.6s。放在对话
    路径上会让每张图都给回复加十几秒,而描述的价值是几个月后还能检索到,
    不是这一轮——这一轮模型本来就直接看着图。
  - **逐条而非批量**:批量拿回来是一整段文字,无法可靠切分回各自的
    digest(模型未必按序号输出,也可能把两张图合并成一句)。宁可多几次
    往返也要保证「描述 ↔ digest」的对应关系确定。
  - **默认关闭**(describe_on_ingest=false):它消耗视觉模型配额。开启后
    每 30s 最多处理 4 条,不跟对话抢额度。

失败处理分三类:
  - 网络抖动/配额 → 不标记,下轮重试
  - 空回复 → 视作失败(上游剥离媒体时通常回空,与 modalfallback 同理)
  - 不可描述(kind=other、blob 已丢失)→ 标记 described_by=unsupported/
    content-missing,退出队列

## 顺带修掉 Pending 的一个真缺陷

测试写出来才发现:Pending 原先只看 `description = ''`,于是被标记为
described_by=unsupported 但 description 仍空的项**每轮都会被重新取出来
重试**,永久占着 LIMIT 的名额,真正需要描述的新项永远轮不到。
改为同时要求 described_by 也为空。

这是「先写断言再看它是否成立」抓到的——原本我以为标记一下就够了。

## 测试

medialoop_test.go 7 例:两条循环在禁用时立即返回(nil store / 零间隔 /
describe 关闭三种形态,不留空转 goroutine)、GC 清孤儿保留有引用项、
minAge 保护新项、无可用源时不误标记、不可描述大类被标记后退出队列。
media_test.go 补 1 例专测 Pending 的排除逻辑。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。
2026-09-04 22:00:03 +08:00
2879e76883 fix(test): 崩溃隔离测试误杀同机生产插件,且断言无效
## 现象

生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律
(126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/
journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax=
infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run
命令,以及 Pdeathsig 绑创建线程的可能——全部排除。

## 真因:测试杀了生产的进程

用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline:

  /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel

findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含
editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足
这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test
的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。

之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。

## 更严重的是这个测试本身无效

旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了
对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。
**它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了
这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。

## 修法

findPluginPID 增加 root 硬约束:/proc/<pid>/exe 必须以测试自己的 plgDir
为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal
——这不是可选过滤器,是防误杀的前提。

  - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。
  - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀
    比较永远不命中,退化成静默 Skip(那样测试就白跑了)。

断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。
两步都成立才能证明隔离生效。

## 验证

修复后跑 TestRealPlugin_CrashDoesNotKillKernel:
  - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓
  - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓
  - 四个 TestRealPlugin_* 全部 PASS
2026-09-04 21:45:17 +08:00
98dcb2556c fix(test): 崩溃隔离测试误杀同机生产插件,且断言无效
## 现象

生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律
(126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/
journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax=
infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run
命令,以及 Pdeathsig 绑创建线程的可能——全部排除。

## 真因:测试杀了生产的进程

用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline:

  /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel

findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含
editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足
这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test
的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。

之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。

## 更严重的是这个测试本身无效

旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了
对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。
**它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了
这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。

## 修法

findPluginPID 增加 root 硬约束:/proc/<pid>/exe 必须以测试自己的 plgDir
为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal
——这不是可选过滤器,是防误杀的前提。

  - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。
  - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀
    比较永远不命中,退化成静默 Skip(那样测试就白跑了)。

断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。
两步都成立才能证明隔离生效。

## 验证

修复后跑 TestRealPlugin_CrashDoesNotKillKernel:
  - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓
  - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓
  - 四个 TestRealPlugin_* 全部 PASS
2026-09-04 21:44:05 +08:00
f855893d1c feat(memory): 媒体接入 L0/L2——digest 挂到对话事件,归档时引用随之转移
a822674 的 CAS 层之上把媒体真正接进记忆链路。此前 CAS 只是个孤立的
存储包,没有任何写入方。

## 媒体进入对话有两条路,两条都只把文字留给记忆

  1. 用户直接发图 → processMediaInput → mediaToBlocks
     ContextEvent.Input 只存 alt 文本("[从 qq 收到了 image]"),
     base64 随 message 数组发给模型后就丢了。
  2. 插件注入 → SetToolBlocks → process.go 的 mediaMsg
     ToolResultItem.Output 只存那句 "[已将图片注入后续对话] /tmp/x.png"。

于是下一轮起,模型能看到的只剩一句路径或一句 alt。那个文件被删、被覆盖,
或者本来就是 /tmp 下的临时产物,连线索都断了。

现在两条路在同一处收口(captureBlockMedia):从 ContentBlock 的 data URL
取出字节存进 CAS,digest 挂到当轮 ContextEvent。

## 改动

internal/agent/core/mediaref.go(新)
  - captureBlockMedia:ContentBlock → CAS。只处理 data URL——http(s) URL
    拿不到字节就无法内容寻址,而「下载它再存」会把一次对话变成一次网络
    请求(超时、鉴权、SSRF 全来了),不在本层解决。
  - stage/drainMediaDigests:媒体在 process() 期间被捕获,而承载它的
    ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id,
    故先缓存。与既有 pendingMedia 同一手法,同受 a.mu 保护。
  - bindEventMedia:双向落地。evt.Media 让事件记得引了什么(随
    context.json 持久化),media_refs 让 CAS 知道谁在引用(GC 的判断依据)。
    只写一边的话,要么 GC 误删仍被引用的内容,要么孤儿永远清不掉。
  - mediaSummaryForEvent:把已有描述拼成一行写进 Input。这是方案 C 的
    落点——**描述文本才是持久语义记忆,blob 只是缓存**。blob 可能被容量
    GC 淘汰,但描述会一直留在 L0/L2/L3 的文本里,让「那张紫蓝红三色带图」
    几个月后仍可被检索。

ContextEvent 新增 ID 与 Media 两个字段,都是 omitempty:
  - ID 懒生成,只有真要挂媒体时才赋值。绝大多数对话没有媒体,全量生成
    会让每条事件都多一个字段进 context.json。
  - 存量 context.json 读回来两字段皆空,不影响任何既有行为(有测试)。

RelevanceContext.Prune 归档时转移引用(transferMediaRefs):
  **先挂到归档文档、再注销原事件引用**。顺序不能反——先销后挂会让引用
  计数瞬时归零,若此刻后台 GC 正在跑就会把仍被记忆引用的内容当孤儿清掉。
  为此把 Prune 内的局部类型 scored 提为包级 scoredEvent(局部类型无法
  出现在方法签名上)。

media 包新增 OwnerContext/OwnerDocument/OwnerGraphSentence 常量:
  owner_kind 进了主键,拼错一个字符就是一条永远对不上的孤立引用——
  AddRef 不报错,DropOwner 也永远匹配不到。

## 配置

core.memory.media.enabled(默认 true)、.dir、.max_mb(2048)、
.gc_interval(6h)、.gc_min_age(1h)。

关闭后全链路静默跳过,对话行为与本特性上线前完全一致(有测试)。
mediaStore 为 nil 时同理——它是记忆增强,不是对话必需品,开不起来
只记一条 warning 不阻止启动。

## 测试(11 例)

入库与 MIME 归类、http URL 跳过、nil store 全链路 no-op、音视频混合、
stage/drain 清空语义、懒生成 ID、描述作为持久记忆、**归档转移期间内容
始终可读且 refcount 不归零**、无媒体存储时归档照常、context.json
向后兼容往返。

全仓 go build / go vet / go test 通过,SDK 冻结 diff = 0。

## 尚未接入

L3 图库的 graph_sentence owner(常量已备好,无写入方)、
描述生成的后台任务(Pending() 已就绪,尚无消费者)、
媒体 GC 的定时触发(配置项已注册,尚未接 ticker)。
2026-09-04 20:53:32 +08:00
5c214cac23 fix(packaging): arm64 GUI 塞了 x86-64 electron——按目标架构取运行时并强制校验
## 现象

v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的
aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的
homeagent-full_1.0.1_arm64.deb:

  usr/bin/homed                    ELF 64-bit ARM aarch64   ✓
  usr/bin/waiter                   ELF 64-bit ARM aarch64   ✓
  usr/lib/homeagent-gui/electron   ELF 64-bit x86-64        ✗

在 arm64 机器上装完,双击 GUI 得到 Exec format error。

## 根因

build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里
永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是
命名,内容从未跟着目标架构变。

这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容
不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、
主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。

## 修法:三层取 + 一道强制校验

1. 优先从 electron 缓存取目标架构的 zip
   (~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)。
   版本号从已安装的 node_modules/electron/package.json 读,保证运行时
   与 app 依赖一致。
2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标
   架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。
3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构,
   不符就删掉 GUI 目录并跳过。

第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」——
宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构
标注,日常构建就能看见。

## 验证

下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB,
unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包,
三个 arm64 deb 实测:

  server   homed=aarch64  waiter=aarch64
  full     homed=aarch64  waiter=aarch64  electron=aarch64
  client                  waiter=aarch64  electron=aarch64
  amd64 对照                              electron=x86-64

arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。
2026-09-04 20:12:35 +08:00
9b7b2675da fix(packaging): arm64 GUI 塞了 x86-64 electron——按目标架构取运行时并强制校验
## 现象

v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的
aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的
homeagent-full_1.0.1_arm64.deb:

  usr/bin/homed                    ELF 64-bit ARM aarch64   ✓
  usr/bin/waiter                   ELF 64-bit ARM aarch64   ✓
  usr/lib/homeagent-gui/electron   ELF 64-bit x86-64        ✗

在 arm64 机器上装完,双击 GUI 得到 Exec format error。

## 根因

build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里
永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是
命名,内容从未跟着目标架构变。

这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容
不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、
主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。

## 修法:三层取 + 一道强制校验

1. 优先从 electron 缓存取目标架构的 zip
   (~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)。
   版本号从已安装的 node_modules/electron/package.json 读,保证运行时
   与 app 依赖一致。
2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标
   架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。
3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构,
   不符就删掉 GUI 目录并跳过。

第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」——
宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构
标注,日常构建就能看见。

## 验证

下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB,
unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包,
三个 arm64 deb 实测:

  server   homed=aarch64  waiter=aarch64
  full     homed=aarch64  waiter=aarch64  electron=aarch64
  client                  waiter=aarch64  electron=aarch64
  amd64 对照                              electron=x86-64

arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。
2026-09-04 20:12:35 +08:00
e2d04a31a7 docs(git): 发布分支改为一个中版本一条,补三级发布通道规范
## 一个中版本一条发布分支

原规范写 release/vX.Y.Z(含 patch 位),实践中 1.0.0 与 1.0.1 各建了一条
分支,导致同一条 1.0.x 发布线被切成互不相连的碎片——追溯时无法用一条
分支看完整条线的演进。改为 release/vX.Y.x,patch 位用 x 占位,
承载该中版本全部 patch 直到下一条中版本分支切出。

## 三级发布通道(alpha / beta / 正式)

通道由 tag 区分而非分支:三者共用同一条 release/vX.Y.x。

  alpha  vX.Y.Z-alpha.N  功能齐了未充分验证    仅内部自测
  beta   vX.Y.Z-beta.N   alpha 问题已修        小范围试用
  正式   vX.Y.Z          通过验证可上现网      所有用户

这是 semver 标准预发布语义(1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0),
版本比较逻辑天然认得,无需额外约定。允许跳级但要在发布说明写明理由;
alpha/beta 产物不上现网——预发布通道的存在就是为了不拿 24/7 服务冒险。

## 回流仍是 cherry-pick

明确不改 merge:merge 会把已发布的版本号带进 main,与「main 的
meta.Version 始终是下一个未发布版本」直接矛盾。

新补一条:修复落地当天要 pick 到所有活跃 feature 分支,否则它们合回
main 时可能带回旧代码(2026-09-04 的 stage 双重解锁修复即同时 pick 到
main 与 feature/memory-media)。

## 运维纪律沉淀

把今天走通的部署流程写进第四节,其中两条是踩过的坑:
  - 备份配置库用 sqlite3 .backup 而非 cp(WAL 模式下 cp 可能拿到
    不一致快照)
  - install -m 0755 替换而非 cp(原子 rename,不写坏运行中的进程镜像)
健康检查列出六项,含「一次真实对话」与「fatal error 计数为 0」。

## 当前分支对齐

第三节更新为 2026-09-04 的实际状态,并记录 1.0.x 的 tag 历史表
(含 v1.0.2 未使用的原因、v1.0.3 直接跳正式 tag 的理由)。
按 patch 号命名的历史发布分支标注为应当删除的遗留形态。
2026-09-04 20:11:19 +08:00
aa3ff5a12f fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:45:19 +08:00
e272c686d3 fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:43:05 +08:00
4ef3371701 test(memory): 媒体存储的压力、冒烟与长稳测试
a822674 的 CAS 层之上补齐三类验证。

## 压力测试(stress_test.go,9 例)

核心不是吞吐数字,而是并发下的不变量。为此写了 checkRefIntegrity:
用 SQL 对比每个 digest 的 ref_count 与 media_refs 实际行数。这条对不上
就意味着 GC 的判断依据是错的——计数偏低会误删有引用的内容,虚高会让
孤儿永远清不掉。所有并发用例收尾都验它。

  - 32 goroutine 并发 Put 同一内容 → digest 一致、磁盘只 1 份
  - 400 个不同内容并发入库 → 无丢条目、逐条回读无内容串位
  - 24 worker × 40 轮引用增删风暴(含故意重复 AddRef 验并发下的幂等)
  - GC 与读写并发 1.5s → 实测 11508 次 Put / 604 轮 GC,受保护内容零失败
  - Describe 与 Search/Pending 并发 → 无 database is locked
  - 容量上限持续加压 → 上限 256KB 收尾 98KB,有引用项全存活
  - 重度 churn 后重开 → 磁盘文件数 == 元数据条数,无双向孤儿
  - 4MB 单文件往返(see_video 10 帧 × 2MB 是现实上限附近)
  - data URL 往返 ×50(SetToolBlocks 给出的实际形态)

-race -count=3 干净。

## 冒烟测试(smoke_test.go,6 场景)

走真实数据路径:真 PNG(自建 IHDR/IDAT/IEND + zlib)、真 data URL、
真 sha256、真 GC、真重启,而不是随机字节。

  - 同一张截图连问 5 轮 → 磁盘 1 份、5 个 context 引用
  - see_video 6 帧内容各异 → 各存一份、共享一个 owner
  - 描述落库后按关键词检索命中(方案 C 最关键的一环:blob 可被淘汰,
    描述会长期留在记忆里)
  - L0→L2 归档时引用从 context owner 转到 document owner,期间内容可读
  - 别的工具留下的一次性图被 GC 清掉,被记忆引用的一个不少
  - 全生命周期跨重启:描述、引用、内容、磁盘一致性全部完好

第一次跑挂在「6 帧只搜到 5 条」,看着像存储丢帧,实际是夹具的
palette[(variant+y*3/h)%5] 只有 5 色,variant=0 与 5 产出逐字节相同的
PNG,被 CAS 正确去重。已把 variant 写进像素保证帧间真不同,并把这段
经过记进注释——误报本身证明了去重在工作,也证明冒烟确实有能力发现
「帧数对不上」这类问题。

冒烟原先是 internal/memory/media/smoke/ 下带 //go:build smoke 的独立
main,得记着加 -tags smoke 才跑得到,那种早晚被忘掉。已搬成普通测试,
随 go test ./... 一起跑,冒烟的意义才真正成立。

## 长稳测试(soak_test.go,-short 下跳过)

60 秒五路混合负载。实测:put=281885 get=1644084 gc=9142
describe=53875 search=23268 refOps=187926,零失败。收尾 20 个受保护项
内容字节一致、ref_count 全为 1;8MB 上限下实际占用 139KB / 48 条,
说明 28 万次写入产生的孤儿被持续清理,无无界增长。

描述者从 Pending() 取项再 Describe(),GC 随时可能在这两步之间清掉它。
这是正常竞态,故忽略 unknown digest 并注明原因;5 万多次调用没把它
升级成计数错位,印证了 Describe 对已删项返回错误而非静默建条目的选择。

全仓 go build / go vet / go test 通过,SDK 接口冻结 diff 为 0。
2026-09-04 11:29:18 +08:00
a82267484a feat(memory): 内容寻址媒体存储(CAS)——图记忆支持二进制多媒体节点的底座
此前四层记忆全是纯文本载体,没有任何一层能存二进制:
  L0 ContextEvent  — Input/Response/ToolResults[].Output 全 string
  L1 text.Event    — 同上
  L2 document.Doc  — Summary/Content/Tags 全 string
  L3 图库           — sentences.text TEXT UNIQUE,节点身份就是那串文本

于是 multimodal 插件注入的图只在本轮对话内可见(走 message 数组,不经
记忆),下一轮起只剩 ToolResultItem.Output 里那句
"[已将图片注入后续对话] /tmp/x.png"——一条路径字符串。那个文件被删或
被覆盖之后连线索都断了。

## 为何内容寻址而不是存路径

- 路径会失效。/tmp 下的探针图、下载缓存、别的进程的临时产物,记忆里
  留个路径等于留个悬空指针。
- 同一内容常被反复注入(连问几轮同一张截图、see_video 相邻帧高度相似),
  按 sha256 寻址天然去重。
- 内容即身份,与 L3 图库 sentences.text UNIQUE 思路一致:文本节点用文本
  本身做身份,媒体节点用内容摘要做身份。

## 结构

元数据(SQLite media.db)与内容(磁盘 blobs/ 两级前缀分桶)分离,不把
blob 塞进库:单张图动辄几 MB,塞进去让每次 VACUUM/备份都拖着几百 MB 走,
WAL 也会迅速膨胀。

  media(digest PK, kind, mime, size, width, height, origin_path, tool,
        description, described_by, ref_count, first_seen, last_seen)
  media_refs(digest, owner_kind, owner_id, created_at, PK 三列)

digest 既是主键也是文件名,所以没有 Path 字段——路径由 digest 推导,
不落库(落了就又是个会失效的引用)。origin_path 仅供人类溯源,注释里
明确标注不可用于读取。owner_kind 预留 context/document/graph_sentence。

## 几处刻意的决定

- Get 强制校验 digest:CAS 的全部保证建立在「文件名 == 内容摘要」上,
  位翻转或外部误改必须被发现——把损坏的图喂给模型只会得到无从追溯的幻觉。
- 先写 .tmp 再 rename:中途崩溃不留半个 blob 被当成完整内容读走。
- AddRef 幂等:只有真插进 media_refs 才递增,否则计数虚高会让 GC 永远
  不敢清。DropRef 用 MAX(0,...) 兜底防负数。
- Put 的空描述不冲掉已有描述(先到的可能来自更强的模型),但尺寸/工具名
  这类前一次缺失的信息会被补写。Describe 是显式操作,允许覆盖。
- GC 两段 + minAge 保护:刚 Put 还没 AddRef 的项 refcount 也是 0,minAge
  防「落地后还没挂上就被清掉」。有引用的项永不删除,即使超容量——宁可
  超限也不断引用。

## 测试

21 例,覆盖去重 / MIME 归类 / 损坏检测 / 无残留临时文件 / AddRef 幂等 /
计数不为负 / DropOwner / GC 保留有引用项 / minAge 保护 / 容量淘汰 /
描述覆盖与补写策略 / Search 按描述与 kind 过滤 / Pending / data URL
往返 / Stats / 跨重启持久化。

本 commit 只加存储层,尚未接入 L0/L2/L3 与描述生成。
2026-09-04 11:03:36 +08:00
6bf34e051b fix(release): upload_assets.py 按 go.mod 定位仓库根,不再数 dirname
脚本从 scripts/ 移到 deploy/scripts/ 后目录深度 1→2,而两层 dirname
是写死的,于是资产目录解析成 deploy/dist/release,上传直接
FileNotFoundError(v1.0.1 首次上传即因此失败)。

这与 v0.7.2 的 2c5f9ff 把 package/ 移到 deploy/packaging/ 打断
build.sh 的 PROJECT_ROOT 是同一个坑:目录搬家没更新相对路径。改成
向上找 go.mod,以后脚本放哪都不会错。

顺带把两个静默失败改为显式报错:目录不存在、目录下无可识别产物
(原先前者抛裸 FileNotFoundError,后者会打出 ALL OK 却一个都没传)。
2026-09-04 10:34:51 +08:00
4f31f942a5 fix(build): arm64 交叉编译补 CXX——「刻意不设 CXX」的注释判断是错的
build.sh 的 linux/arm64 分支此前刻意不设 CXX,注释理由是「设了会让
Go 用 aarch64 的 g++ 去链接,而它对 host 产生的 .o 报 file format
not recognized」。

那个判断是错的。那个报错的真因是 cmd/{homed,waiter}/*.syso(x86-64
COFF Windows 资源对象)被 Go 无条件链进了目标,与 CXX 无关。四组对照:

  syso 在   + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 在   + 有 CXX → 000000.o: file format not recognized
  syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 隐藏 + 有 CXX → 成功,ELF aarch64

两个条件缺一不可。之前诊断时只单独试了其中一个,得出错误结论后写进
注释固化了下来,于是 arm64 的 homed 一直编不出(v1.0.0 发布时 arm64
deb 里只有 waiter/initconfig)。

本脚本的 hide_syso_for_target 已处理 syso 那半,这里补上 CXX 那半。
实测 v1.0.1:build.sh linux/arm64 直接产出 ELF aarch64,arm64 的
full/server deb 里 homed 与 waiter 均为 aarch64。
2026-09-04 10:24:55 +08:00
283 changed files with 651427 additions and 2828 deletions

7
.gitignore vendored
View File

@ -48,7 +48,12 @@ codegraph.json
/adapters/
/knowledge/
/memory/
/scripts/
# 注:/scripts/ **不**忽略。它是作者维护的工具目录(模型导出、侧车、部署校验),
# 不是运行期产物deploy/systemd/embed-sidecar.service 直接引用
# scripts/embed_sidecar.py忽略它会让那份 unit 在别人的机器上指向不存在的文件。
# 只忽略其中的缓存。
/scripts/__pycache__/
__pycache__/
terminal_locked_log.txt
dist/

661
LICENSE Normal file
View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -12,6 +12,8 @@
homed内核零 IO PluginSDK 插件所有 IO 能力
```
**v1.1.1 起媒体贯通插件边界**:插件与模型都能读写记忆里的图片/音频(`InsertWithMedia``InjectInputMedia`),媒体以 `[<mime> <短digest>] <描述>` 标记存在于纯文本记忆中——描述是可检索的语义记忆digest 是回到字节的钥匙。
**v1.0.0 起外部插件是独立子进程**:经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)与内核通信。插件崩溃不影响内核且自动重启,换 `plugin.bin` 即生效的真热重载。
## 设计要点
@ -188,12 +190,43 @@ internal/
├── config/ SQLite 配置中心
├── events/ 事件总线
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
```
## 项目状态
**v1.0.0**外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**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
`qwen3vl` 保留2048 维、常驻约 9.4GB,供内存充足或将来要视频的机器切回)。
文本检索仍由既有词向量 / TF-IDF 兜底CLIP 双塔的**纯文本语义弱于 MLLM 型嵌入器**
这是已知并写进文档的代价。
- **媒体是图数据库的一等节点与边****彻底删除**「用文本描述式索引图片」这套将就机制,
以及 `media_refs` 与媒体引用计数。记忆块遵循单层不变量——Context → Document → Graph
是块的**迁移**,不是复制、也不靠引用保活。
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
RPC 只传偏移描述符;**RPC 协议升到 2**fd3 布局改变,**不支持滚动升级**——
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`**默认仍记入记忆、默认不裁剪**
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**
- **发行包默认启用** ONNX 向量空间并把模型754MB与 ONNX Runtime24MB
server/full 包发布;`homed` 放弃 Windows 原生支持改走 WSL2jieba 词库内嵌进二进制。
- 修掉三个**安装链静默失败**`initconfig``CGO_ENABLED=0` 是空操作(打印凭据却一个字节
没写)、全新安装被误判「已有配置」而整体跳过默认值播种(装完 0 插件、deb 的 `postinst`
查错 unit 路径导致 `enable` 从未执行。
- 自本版起以 **AGPL-3.0-only** 发布(含网络条款;插件静态链接 SDK 故须同许可,见「许可」)。
> 以下历史条目保留原文以呈现演进,其中两条机制**已在 v1.2.0 移除**
> 「媒体以 `[<mime> <短digest>] <描述>` 标记参与检索」(描述式索引)与「媒体引用计数式 GC」。
**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 解引用崩溃)。
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储CAS + SQLite 元数据 + 磁盘 blob`Get` always 重校 digest贯通 L0上下文事件/L2文档/L3图谱句子三层引用计数式 GC有引用者绝不删。视觉模型生成的描述文本是持久语义记忆blob 只是可被容量 GC 淘汰的缓存。
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版工具链重编;该工具链当时名为 `plugindev`**现名 `hmapdev`**)。外部插件需重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。**
@ -218,7 +251,7 @@ internal/
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
- Linux`.deb`amd64/arm64)、`.rpm`x86_64)、`.tar.gz`
- Windows`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`NSIS 安装向导
- Windows`HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe`NSIS 安装向导 AGPL 许可页)。 v1.2.0 起因 `homed` 不再支持 Windows 原生依赖 fd 继承与共享内存段内偏移解引用安装器改为引导到 **WSL2**并把 Linux 包送进发行版里按 Linux 方式安装
- 免安装`homeagent-bin-<os>_<arch>.tar.gz` homed/waiter/initconfig
- 校验`SHA256SUMS`
@ -233,3 +266,26 @@ make install # 安装到系统
```
依赖Go 1.25+, CGo (go-sqlite3), Linux/Windows
## 许可
本项目以 **GNU Affero 通用公共许可证第 3 版AGPL-3.0-only** 发布全文见 [LICENSE](LICENSE)
它是 GPL 家族里**传染性最强**的一档不仅分发时须提供完整对应源码
**通过网络提供服务时也要向使用者提供源码**(§13 Remote Network Interaction)。
任何人把改过的 HomeAgent 对外提供网络服务都必须让该服务的使用者拿到改动后的源码
插件与本项目通过公开 SDK **静态链接**SDK 源码会进入插件二进制因此插件是本项目的
衍生作品需以相同许可发布子进程隔离不改变这一点因为被链接的是 SDK 代码本身
### 随包分发的第三方组件
| 组件 | 许可 | 位置 |
|---|---|---|
| Chinese-CLIP ViT-B/16ONNX 产物 | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
| ONNX Runtime`libonnxruntime.so` | MIT | `/usr/lib/homeagent/onnxruntime/` |
| jieba 词库内嵌进二进制 | MIT | 源码 `internal/memory/jiebadict/` |
| Go 依赖go-sqlite3gojiebabubbletea | MIT / BSD-3 / Apache-2.0 | 均为宽松许可 AGPL-3.0 兼容 |
这些组件**保持各自原有许可**不在本项目的 AGPL 授权范围内发行包把它们的许可全文放在
`/usr/share/doc/homeagent/licenses/`并在 dep/rpm 元数据里声明本包许可为 `AGPL-3.0-only`

View File

@ -12,6 +12,11 @@ Combined with a **three-layer memory architecture** (Context → Document → Gr
homed (kernel, zero IO) PluginSDK plugins (all IO capabilities)
```
**Since v1.1.1 media reaches the plugin boundary**: plugins and the model can both read and
write images/audio in memory (`InsertWithMedia`, `InjectInputMedia`). Media lives in plain-text
memory as a `[<mime> <short digest>] <description>` marker — the description is the searchable
semantic memory, the digest is the key back to the bytes.
**Since v1.0.0 external plugins are independent subprocesses**, communicating with the kernel over
stdio JSON-RPC (control plane) + a shared memory segment (data plane) + an event ring (notification
plane). A plugin crash cannot take down the kernel and it restarts automatically; swapping
@ -174,12 +179,68 @@ internal/
├── config/ SQLite config center
├── events/ Event bus
└── internal/lua/adapters/ 8 LLM protocol adapter scripts
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
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.0.0**External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**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);
`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.
- **Media are first-class nodes and edges in the graph DB**: the "index images via generated
text descriptions" stopgap, `media_refs` and media reference counting are **removed**.
Memory blocks follow a single-layer invariant — Context → Document → Graph is a **migration**,
not a copy, and not kept alive by references.
- **The entire data plane goes through shared memory** (tool-call frames, Cleaners, input/output
lanes, media blocks, document and knowledge bodies); RPC carries only offset descriptors.
**RPC protocol is now 2**: the fd3 layout changed and there is **no rolling upgrade**
kernel and all plugins must be rebuilt and installed together.
- Injections can declare `InjectOptions{NoMemory, ContextPolicy}` (**defaults: still recorded,
not pruned**); pruning must be requested explicitly and goes through the plugin's registered
`Cleaner`. SDK 1.2.0 is **purely additive** over 1.1.0.
- **Release packages enable the ONNX space by default** and bundle the model (754MB) plus
ONNX Runtime (24MB) in the server/full packages; `homed` drops native Windows support in
favour of WSL2; the jieba dictionary is embedded in the binary.
- Fixed three **silent install-chain failures**: `initconfig` was a no-op (`CGO_ENABLED=0` stub)
that printed credentials without writing any, fresh installs were misdetected as "already
configured" so default seeding was skipped entirely (0 plugins installed), and the deb
`postinst` looked for the unit in the wrong path so `enable` never ran.
- Licensed **AGPL-3.0-only** from this version on (network clause included; statically linked
plugins must match — see License).
> 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.
**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
plugins and the model. The public SDK gains media fields and three media injection methods
(paired with [SDK v1.1.0](https://gitcode.com/JianFeeeee/homeagent-sdk/releases/tag/v1.1.0),
shared by the whole 1.1.x line), and the kernel implements the four matching RPCs. The bridge
layer had been **silently dropping fields**: `Confidence`/types/`SentenceText` handed in by a
plugin were discarded, `Doc` kept only three fields, and `Remove` never released references
(media stayed "referenced" forever, so GC could never reclaim it). `processTextInput` and
`processMediaInput` were unified into a single `processInput`, which finally gives the media
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
chat log** (the media path published a map while the subscriber asserted a string),
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
**v1.1.0** — Memory system supports **binary multimedia nodes**. Content-addressed media store
(CAS + SQLite metadata + on-disk blobs, `Get` always re-verifies the digest) wired through L0
(context events) / L2 (documents) / L3 (graph sentences), with reference-counted GC (referenced
items are never deleted). The description text produced by the vision model is the durable
semantic memory; the blob is only a cache that capacity GC may evict.
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new toolchain — called `plugindev` back then, **now `hmapdev`** — though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
@ -204,7 +265,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer)
- Windows: `HomeAgent_v1.2.0_{Full,Server,Client}_win64.exe` (NSIS installer, includes the AGPL license page). Since v1.2.0 `homed` no longer supports native Windows (it relies on fd inheritance and in-segment offset dereferencing), so the installer bootstraps **WSL2** and installs the Linux packages inside the distribution the same way a Linux host would.
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
- Verification: `SHA256SUMS`
@ -219,3 +280,30 @@ make install # Install to system
```
Dependencies: Go 1.25+, CGo (go-sqlite3), Linux/Windows.
## License
This project is released under the **GNU Affero General Public License, version 3
(AGPL-3.0-only)** — see [LICENSE](LICENSE).
This is the strongest copyleft in the GPL family: besides shipping the complete corresponding
source when you distribute the software, **you must also offer the source to users who interact
with it over a network** (§13, Remote Network Interaction). Anyone running a modified HomeAgent
as a network service therefore has to make the modified source available to that service's users.
Plugins are **statically linked** against this project through the public SDK (the SDK source
ends up inside the plugin binary), so plugins are derivative works and must be released under
the same license. Process isolation does not change this — what is linked is the SDK code itself.
### Third-party components shipped with the packages
| Component | License | Location |
|---|---|---|
| Chinese-CLIP ViT-B/16 (ONNX artifacts) | Apache-2.0 | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
| ONNX Runtime (`libonnxruntime.so`) | MIT | `/usr/lib/homeagent/onnxruntime/` |
| jieba dictionary (embedded in the binary) | MIT | `internal/memory/jiebadict/` |
| Go dependencies (go-sqlite3, gojieba, bubbletea, …) | MIT / BSD-3 / Apache-2.0 | permissive, AGPL-3.0-compatible |
These components keep their own licenses and are not relicensed by this project. Full texts are
shipped in `/usr/share/doc/homeagent/licenses/`, and the package metadata declares this package
as `AGPL-3.0-only`.

View File

@ -90,8 +90,8 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
RelevanceContext — In-memory events[] + JSON persistence
Append: Each input, CleanTemplateText → three-branch vector(textForVector)
agent→Response, user→Input, cold_storage→Input+Response
StaticEmbedder pretrained word embedding / TF-IDF fallback
Prune: StaticEmbedder CosineSimilarity, keep topK + last 10
Vector layers: unified multimodal space (primary, with fingerprint) → StaticEmbedder word embedding TF-IDF (fallback)
Prune: DenseCosine (compared only within the same fingerprint) → StaticEmbedder CosineSimilarity fallback; keep topK + last 10
├── Keep → timeline → chronologically sorted → system prompt
└── Low score → Document layer archive (original timestamp)
Save: 5s debounce write to disk
@ -99,7 +99,7 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
↓ Prune archive ↑ LLM active recall
② Document (File Memory)
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: TF-IDF InvertedIndex)
DocStore — JSON files + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
Write: Prune archive / doc_commit / Graph snapshot (syncGraphToDocs)
Read:
├── Auto-inject: Query(input, top3) → similarity summary under same vector space → [Related Memory Docs] → system prompt (read-only)
@ -132,11 +132,22 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
→ triples → GraphDB.Commit
```
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
### Vectorization: Unified Multimodal Space (primary) → Word Embedding TF-IDF (fallback)
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
error and never pretends to succeed**:
**Primary Strategy — Pretrained Word Embedding (aligned 300d)**
**① Unified multimodal space (primary path, since v1.2.0)**
Text and images share **one model, one dimension, one fingerprint** (default `chineseclip`: 512d,
Apache-2.0, Chinese-native; `qwen3vl` or an external `http` provider are alternatives).
Providers register through the public `pkg/embedding` SPI — **the kernel hardcodes no model**.
Vectors persist together with their fingerprint (`dense_fp` / `vec_model`); any mismatch with the
current fingerprint triggers recomputation, and only blocks with the **same fingerprint and the same
dimension** participate in fusion (mixing coordinate systems yields a direction resembling neither).
**② Word embedding (text fallback)** — `StaticEmbedder` (`internal/memory/static_embedder.go`):
**Model sources** (aligned 300d)
- Model sources: ConceptNet Numberbatch (77-language aligned) / fastText Chinese / fastText English
- Configured via `core.agent.embedding_model_path` (comma-separated multi-model)
- Path containing `numberbatch` → auto-download ConceptNet; `cc.zh.` → fastText Chinese; `cc.en.` → fastText English
@ -189,6 +200,43 @@ All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedd
| `doc_query` | Search from Document |
| `doc_commit` | Write to Document |
Since v1.1.1 `memory_commit` and `doc_commit` accept `media_digests`, and the kernel appends the
`[<mime> <short digest>] <description>` marker into the sentence/body — **the kernel builds the
marker, the model only supplies the digest**. Requiring the caller to know the format would mean a
single typo silently breaks reference binding with no error anywhere in the chain. `memory_commit`
also gained `sentence_text`: media references hang off a sentence, so with no sentence there is
nowhere to attach them.
### Media Memory (since v1.2.0: first-class memory blocks)
Media is not attached content but a **first-class memory node**: `internal/memory/media/` is a
content-addressed store (CAS), and graph `block` nodes carry its digest plus its own vector, while
structural edges (e.g. `sentence --contains--> block`) express ownership.
| Concern | Approach | Why |
|---|---|---|
| Addressing | sha256 digest; metadata in SQLite, blobs on disk (`blobs/<first2>/<rest>`, two-level fanout) | Identical bytes stored once; metadata must be queryable, blobs must not live in the database |
| Integrity | Every `Get` re-verifies the digest | Silently returning corrupt data on disk damage is far worse than an error |
| Write atomicity | `.tmp` + rename | A half-written file taken as complete content would permanently poison that digest |
| Retrieval | Blocks carry **their own multimodal vector and fingerprint** and are searched directly | No description text is needed as an intermediary |
| Lifecycle | **No separate GC, no refcounts, no keep-set**; deleting the block deletes the content | Media is a memory node, not a cache that needs keeping alive |
**Description-based indexing is gone**: the old implementation embedded a
`[<mime> <short digest>] <description>` marker in the body and treated the description as the
semantic memory (retrieval used it). That path was removed wholesale in v1.2.0: a description is
second-hand model output, and retrieving "someone else's paraphrase of an image" is strictly worse
than retrieving the image's own vector. Images are now retrieved only by their own vector in the
unified space, and no media marker is written into the body.
**Cross-space vector migration**: media rows store their vector together with `vec_model` (the space
fingerprint). At startup `reembedStaleMedia()` recomputes and **writes back** every row whose
`vec_model` is empty (never embedded) or differs from the current space (model/dimension switched).
Modalities outside the space return `ErrModalityUnsupported` — the kernel **never substitutes
another model's vector**.
The media store is **optional throughout**: with `core.memory.media.enabled=false` or no
configuration, the whole chain silently degrades to plain-text behaviour — no errors, no panics.
### Other Memory Layers
- **Social** (`internal/memory/social/social.go`) — Persona traits and relationship network, wraps GraphDB entity types
@ -259,7 +307,7 @@ VM built-ins: `json.encode` / `json.decode` / `log` / `http_get` / `http_post`.
| Method | Registration Mechanism | Compilation | Usage |
|--------|----------------------|-------------|-------|
| Built-in | `init()``RegisterFactory` | `internal/plugins/` compiled into kernel | webui/cli/timer/mcp etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `hmapdev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| Lua script plugin | Execute `main.lua` to register tools | No compilation, takes effect after restart/reload | luademo etc. |
| SKILL plugin | Parse `SKILL.md` | Markdown definition | Loaded via clawhubadapter |
@ -278,7 +326,7 @@ Lua script plugin loading: `internal/plugin/` → the gopher-lua interpreter exe
| Dimension | Built-in Plugin | External Plugin |
|-----------|----------------|-----------------|
| Registration | `init()` calls `plugin.RegisterFactory(name, factory)` | Implements `NewPluginFactory(name, config) (sdk.Plugin, error)` entry function |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `plugindev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Compilation | Compiled into `homed` binary, no separate build | Compiled via `hmapdev build` to `plugin.bin` (ordinary Go binary, zero cgo); the kernel spawns it as a subprocess |
| Distribution | Bundled with kernel, not independently installable | `.hmap` package (ZIP archive), installed via WebUI or pluginmgr API |
| Metadata | `plugin.RegisterPluginMeta()` for display name | `plugin.json` manifest file (name, version, entry, platforms, capabilities, etc.) |
| Plugin directory | No separate directory, compiled into binary | `plugins/<name>/` independent directory with `plugin.json` + `plugin.bin` |
@ -296,7 +344,7 @@ Common ground:
| Plane | Mechanism | Why this choice |
|---|---|---|
| Control | stdio JSON-RPC (NDJSON frames), 51 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
| Control | stdio JSON-RPC (NDJSON frames), 55 `core.*` methods | The process boundary *is* the ABI boundary—no need to maintain three platform-specific dynamic-library loaders |
| Data | Shared memory segment, **one segment shared by all subprocesses** | One segment per plugin would degrade "kernel ctx → segment → plugin mutates → read back" into the copy model under concurrency, reproducing lost updates exactly |
| Notification | Event ring + platform notify (Linux eventfd / macOS pipe / Windows Event) | The kernel must never block on a consumer: streaming output publishes per token, so any wait shows up as stutter |
@ -334,8 +382,23 @@ sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ channel, see output_send__qq_help for details", handler)
// v1.1.1 media APIs (all additive, no signature changes)
sdk.DocMemory().InsertWithMedia(doc, attachments) // attachments with Data land in CAS; Digest-only ones reference existing content
sdk.InjectInputMedia(source, channel, text, blocks) // media reaches the model in *this* turn
sdk.InjectInputMediaSync(...) // same, and waits for the reply
sdk.InjectInterruptMedia(...) // media-bearing interrupt, can preempt current processing
```
How media injection differs from `SetToolBlocks`: the latter is only callable inside a tool handler
and its media reaches the model with the **next** tool message; these three let a plugin
**initiate a turn that carries media** — it goes out with this turn's message and is automatically
stored in CAS with a memory reference attached. `Triple` and `Doc` gained `MediaDigests` /
`Attachments` correspondingly.
`internal/sdk/` is the bridge implementation for this layer and is not subject to the public
interface freeze (see `docs/git-branching.md` §6).
### Plugin Interface
```go

View File

@ -25,6 +25,19 @@ The significance lies in clear responsibility boundaries: the kernel focuses on
Three progressive layers — context, cold archive, long-term graph memory — form an information decay and consolidation pipeline from short-term to persistent storage.
**Media Memory (since v1.1.0)** — Images and audio are not attachments; they are a kind of node in all three layers:
- **Content-addressed store (CAS)**: addressed by digest, metadata in SQLite and blobs on disk, identical bytes
stored once. Every `Get` re-verifies the digest (silently returning corrupt data is worse than an error).
- **Reference-counted GC**: `owner_kind/owner_id/digest` is the primary key; context events, documents and graph
sentences each hold their own references. **Referenced items are never deleted** — only unowned content past
`minAge` is reclaimed.
- **The description text is the durable semantic memory**: what the vision model produced is written into
plain-text memory as a `[<mime> <short digest>] <description>` marker and participates in vector retrieval and
distillation; the blob is only a cache that capacity GC may evict. Months later "that purple-blue-red
three-band chart" is still findable — via the description, not the bytes.
- **Reaches the plugin boundary since v1.1.1**: plugins read and write media through `InsertWithMedia` /
`InjectInputMedia`; the model attaches media via the `media_digests` argument of `memory_commit` / `doc_commit`.
## What It Actually Does
Code is in the project root, implemented in Go.

View File

@ -29,75 +29,80 @@ type Plugin interface {
| Method | Use Case | Complexity |
|--------|----------|------------|
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 1. Quick Start: Using the plugindev Toolchain
## 1. Quick Start: Using the hmapdev Toolchain
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
`hmapdev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua
plugin types, and producing `.hmap` plugin bundles (the tool is named after that package format).
> Rename note: as of 1.2.0 the toolchain was renamed from `plugindev` to `hmapdev`; the SDK store moved from
> `~/.homeagent/plugindev/sdk` to `~/.homeagent/hmapdev/sdk` (the old directory keeps working automatically).
### Installation
```bash
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# Add plugindev to PATH or use directly
cd homeagent-sdk/tools/hmapdev
go build -o hmapdev
# Add hmapdev to PATH or use directly
# Prebuilt binaries also ship as release assets (hmapdev_linux_amd64, ...)
```
### SDK Version Management
`plugindev sdk` manages local SDK versions:
`hmapdev sdk` manages local SDK versions:
```bash
plugindev sdk list # list installed SDK versions
plugindev sdk current # show current SDK version
plugindev sdk latest # show latest available version
plugindev sdk install v0.8.0 # install a specific version
plugindev sdk use v0.8.0 # switch to a version
plugindev sdk path # show current SDK path
hmapdev sdk list # list installed SDK versions
hmapdev sdk current # show current SDK version
hmapdev sdk latest # show latest available version
hmapdev sdk install v1.2.0 # install a specific version
hmapdev sdk use v1.2.0 # switch to a version
hmapdev sdk path # show current SDK path
```
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
### Source Debugging
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
```bash
plugindev debug [dir] # dir defaults to the current directory
hmapdev debug [dir] # dir defaults to the current directory
```
### Creating a Go Plugin
```bash
plugindev init myplugin
hmapdev init myplugin
cd myplugin
# Edit plugin code
vim plugin.go
# Build and package (default is a multi-platform bundle, see below)
plugindev build
hmapdev build
# Output: dist/myplugin_bundle.hmap
# Single-platform build:
plugindev build --no-bundle
hmapdev build --no-bundle
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
```
### Creating a Lua Plugin
```bash
plugindev init myluaplugin --lua
hmapdev init myluaplugin --lua
cd myluaplugin
# Edit plugin code
vim main.lua
# Local test
lua main.lua
# Build and package
plugindev build
hmapdev build
# Output: dist/myluaplugin_lua.hmap
```
@ -128,16 +133,16 @@ myluaplugin/
### Build & Package
`plugindev build` automatically handles compilation and packaging:
`hmapdev build` automatically handles compilation and packaging:
```bash
cd myplugin
plugindev build # default bundle mode (multi-platform)
plugindev build --no-bundle # single-target build (per plg.json targets)
plugindev build --target linux/amd64 # append a target on top of plg.json targets
plugindev build --outdir dist # output directory (default: dist)
plugindev build --sdk-path <path> # SDK path override (go.mod replace)
plugindev build --replace <mod@path> # append a go.mod replace directive (repeatable)
hmapdev build # default bundle mode (multi-platform)
hmapdev build --no-bundle # single-target build (per plg.json targets)
hmapdev build --target linux/amd64 # append a target on top of plg.json targets
hmapdev build --outdir dist # output directory (default: dist)
hmapdev build --sdk-path <path> # SDK path override (go.mod replace)
hmapdev build --replace <mod@path> # append a go.mod replace directive (repeatable)
```
Execution process:
@ -171,7 +176,7 @@ the kernel picks the one matching the current platform and renames it to `plugin
> - `plugin.so` / `plugin.dylib` / `plugin.dll` are **no longer loaded**. The new kernel
> skips legacy artifacts with an actionable error instead of crashing.
> - **Business code needs no changes** — the public SDK interface is unchanged; just
> rebuild with the new `plugindev`.
> rebuild with the new `hmapdev` (formerly `plugindev`).
> - The `entry` field in `plg.json` is **meaningless for Go plugins** now (leaving
> `plugin.so` there is harmless); it only distinguishes Lua plugins.
> - Artifacts no longer need cgo, so cross-compiling requires no target C toolchain.
@ -180,12 +185,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
### Build Targets & Multi-platform Bundle
**`plugindev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
**`hmapdev build` defaults to bundle mode** (unless `plg.json` explicitly sets `"bundle": false`): it builds linux/amd64 + darwin/amd64 + windows/amd64 in one pass, producing a single `.hmap` with all platform binaries. The output manifest includes a `platforms` field. The kernel auto-selects the correct binary during installation.
```bash
plugindev build # default bundle, outputs dist/myplugin_bundle.hmap
plugindev build --bundle # explicitly enable bundle (same as above)
plugindev build --no-bundle # disable bundle, build per plg.json targets
hmapdev build # default bundle, outputs dist/myplugin_bundle.hmap
hmapdev build --bundle # explicitly enable bundle (same as above)
hmapdev build --no-bundle # disable bundle, build per plg.json targets
```
Notes:
@ -275,7 +280,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### Entry Point
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
which is the entry point when the kernel loads the plugin:
```go
@ -284,7 +289,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
At build time, `plugindev build` auto-generates subprocess runtime code
At build time, `hmapdev build` auto-generates subprocess runtime code
(`z_proc_gen.go` for the platform-independent part, plus `z_proc_shm_unix.go` /
`z_proc_shm_windows.go`). All three platforms share the same entry point and the same
RPC logic; only the cross-process resource-passing mechanism differs (inherited fds on

View File

@ -90,8 +90,8 @@ eventLoop() → processTextInput()
RelevanceContext — 内存 events[] + JSON持久化
Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
向量层级:统一多模态空间(主,带 fingerprint StaticEmbedder 词嵌入 TF-IDF回退
Prune: DenseCosine仅同指纹才比较→ 退化 StaticEmbedder CosineSimilarity保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档 (原始时间戳)
Save: 5s debounce 写盘
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
↓ Prune 归档 ↑ LLM 主动召回
② Document (文件记忆)
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex
DocStore — JSON文件 + 稠密向量统一多模态空间dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
读取:
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
@ -132,11 +132,20 @@ eventLoop() → processTextInput()
→ 三元组 → GraphDB.Commit
```
### 向量化:预训练词嵌入 + TF-IDF 回退
### 向量化:统一多模态空间(主)→ 词嵌入 TF-IDF回退
所有向量化统一使用 `StaticEmbedder``internal/memory/static_embedder.go`
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**
**主策略 — 预训练词嵌入(词对齐 300 **
**① 统一多模态空间主路径v1.2.0 **
文本与图像共用**同一模型、同一维度、同一指纹**(默认 `chineseclip`512 维、Apache-2.0、中文原生;
亦可选 `qwen3vl` 或外部 `http` provider。provider 经 `pkg/embedding` 公共 SPI 注册,
**内核不硬编码任何模型**。向量与指纹一起持久化(`dense_fp` / `vec_model`
与当前指纹不一致即触发重算;融合时只接受**同指纹且同维度**的块向量
(跨坐标系的向量混进去会算出两边都不像的方向)。
**② 词嵌入(文本兜底)** — `StaticEmbedder``internal/memory/static_embedder.go`
**模型来源**(词对齐 300 维)
- 模型来源ConceptNet Numberbatch77 语对齐)/ fastText 中文 / fastText 英文
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
- 路径名含 `numberbatch` → 自动下载 ConceptNet`cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
@ -189,6 +198,37 @@ eventLoop() → processTextInput()
| `doc_query` | 从 Document 搜索 |
| `doc_commit` | 写入 Document |
`memory_commit``doc_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
### 媒体记忆v1.2.0 起:一等记忆块)
媒体不是外挂内容,而是**记忆的一等节点**`internal/memory/media/` 是内容寻址仓储CAS
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
| 关注点 | 做法 | 为何 |
|---|---|---|
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘(`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
正文里不再有 media marker。
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
`reembedStaleMedia()``vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`
**绝不拿别的模型的向量顶替**
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为,
不报错不 panic。
### 其他记忆层
- **Social** (`internal/memory/social/social.go`) — 人格特质和关系网,包装 GraphDB 实体类型
@ -257,7 +297,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------|
| 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
@ -276,7 +316,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 维度 | 内置插件 | 外部插件 |
|------|----------|----------|
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 分发方式 | 随内核分发,不可独立安装/卸载 | `.hmap`ZIP 归档),通过 WebUI 或 pluginmgr API 安装 |
| 元数据 | 通过 `plugin.RegisterPluginMeta()` 注册显示名 | `plugin.json` manifest 文件name, version, entry, platforms, capabilities 等) |
| 插件目录 | 无独立目录,编译进二进制 | `plugins/<name>/` 独立目录,包含 `plugin.json` + `plugin.bin` |
@ -294,10 +334,14 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 面 | 机制 | 为何这么选 |
|---|---|---|
| 控制面 | stdio JSON-RPCNDJSON 帧51`core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
| 控制面 | stdio JSON-RPCNDJSON 帧55`core.*` method | 进程边界即 ABI 边界,无需维护三套平台特定的动态库加载代码 |
| 数据面 | 共享内存段,**全部子进程共用一块** | 每插件一段会让「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下退化成副本模型lost update 原样复现 |
| 通知面 | 事件环 + 平台通知Linux eventfd / macOS pipe / Windows Event | 内核发事件绕不等消费者,流式输出逐 token 发布时任何等待都会造成卡顿 |
v1.1.1 新增 4 个 method51 → 55`doc.insertWithMedia``io.injectMedia`
`io.injectMediaSync``io.injectInterruptMedia`。**媒体块走 JSON 而非共享段二进制通道**——
data URL 本身已是 base64 文本,包进二进制传输省不了空间,还要跟其余 51 个 method 分道。
**子进程生命周期管理**
- 每子进程一根专职 `waitLoop``cmd.Wait()` 唯一调用点)——不依赖 stdout EOF
因为插件 fork 的孙子进程browser 拉 chromium、editdoc 拉 python继承同一 stdout
@ -330,8 +374,20 @@ sdk.Memory().Recall/Commit
sdk.Knowledge().Search/Create
sdk.Settings().Get/Set/List
sdk.RegisterOutputChannel("qq", sdk.CapText|sdk.CapAudio|sdk.CapImage, "QQ消息通道详见 output_send__qq_help", handler)
// v1.1.1 媒体接口(全部新增,无签名变更)
sdk.DocMemory().InsertWithMedia(doc, attachments) // 带 Data 的落进 CAS只给 Digest 的引用已有内容
sdk.InjectInputMedia(source, channel, text, blocks) // 媒体在「本轮」就发给模型
sdk.InjectInputMediaSync(...) // 同上并同步等回复
sdk.InjectInterruptMedia(...) // 带媒体的中断,可抢占当前处理
```
媒体注入与 `SetToolBlocks` 的区别:后者只能在工具处理函数内部调用,且媒体要等**下一条**
tool message 才到模型手上;前三个是插件**主动发起一轮带媒体的对话**,媒体随本轮消息发出,
并自动落进 CAS、挂上媒体记忆引用。`Triple``Doc` 相应新增 `MediaDigests``Attachments`
`internal/sdk/` 是这层的桥接实现,不受公开接口冻结约束(见 `docs/git-branching.md` §六)。
### Plugin 接口
```go

View File

@ -25,6 +25,17 @@ HomeAgent 是一个持续运行的个人智能 Agent 框架。
三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。
**媒体记忆v1.1.0 起)** — 图片/音频不是附属物,而是三层里的一类节点:
- **内容寻址存储CAS**digest 寻址,元数据在 SQLite、blob 在磁盘,相同字节只存一份,
每次 `Get` 重校 digest磁盘损坏静默返回脏数据比报错更危险
- **引用计数 GC**`owner_kind/owner_id/digest` 三元组为主键,上下文事件/文档/图谱句子各自持引用;
**有引用者绝不删除**,仅回收无主且超过 `minAge` 的内容
- **描述文本才是持久语义记忆**:视觉模型生成的描述以
`[<mime> <短digest>] <描述>` 标记形式写进纯文本记忆,参与向量检索与蒸馏;
blob 只是可被容量 GC 淘汰的缓存。几个月后“那张紫蓝红三色带图”仍可检索,靠的是描述而不是字节
- **v1.1.1 起贯通插件边界**:插件可通过 `InsertWithMedia` / `InjectInputMedia` 读写媒体,
模型可用 `memory_commit` / `doc_commit``media_digests` 参数关联媒体
## 它实际做了什么
代码位于项目仓库根目录Go 语言实现。

View File

@ -30,75 +30,80 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 |
|------|---------|--------|
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 一、快速开始:使用 plugindev 工具链
## 一、快速开始:使用 hmapdev 工具链
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
> 改名说明1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`SDK 存储目录同时由
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
### 安装
```bash
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# 将 plugindev 加入 PATH 或直接使用
cd homeagent-sdk/tools/hmapdev
go build -o hmapdev
# 将 hmapdev 加入 PATH 或直接使用
# 也可从 SDK 的 release 附件下载预编译二进制hmapdev_linux_amd64 等)
```
### SDK 版本管理
`plugindev sdk` 子命令管理本地 SDK 版本:
`hmapdev sdk` 子命令管理本地 SDK 版本:
```bash
plugindev sdk list # 列出已安装的 SDK 版本
plugindev sdk current # 显示当前使用的 SDK 版本
plugindev sdk latest # 显示最新可用版本
plugindev sdk install v0.8.0 # 安装指定版本
plugindev sdk use v0.8.0 # 切换使用版本
plugindev sdk path # 显示当前 SDK 路径
hmapdev sdk list # 列出已安装的 SDK 版本
hmapdev sdk current # 显示当前使用的 SDK 版本
hmapdev sdk latest # 显示最新可用版本
hmapdev sdk install v1.2.0 # 安装指定版本
hmapdev sdk use v1.2.0 # 切换使用版本
hmapdev sdk path # 显示当前 SDK 路径
```
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/``plugindev init` 自动读取当前 SDK 版本填充 `go.mod`
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/``hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`
### 源码调试
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
```bash
plugindev debug [dir] # dir 默认当前目录
hmapdev debug [dir] # dir 默认当前目录
```
### 创建 Go 插件
```bash
plugindev init myplugin
hmapdev init myplugin
cd myplugin
# 编辑插件代码
vim plugin.go
# 编译打包
plugindev build # 默认多平台 bundle见下节
hmapdev build # 默认多平台 bundle见下节
# 输出: dist/myplugin_bundle.hmap
# 单平台构建:
plugindev build --no-bundle
hmapdev build --no-bundle
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
```
### 创建 Lua 插件
```bash
plugindev init myluaplugin --lua
hmapdev init myluaplugin --lua
cd myluaplugin
# 编辑插件代码
vim main.lua
# 本地测试
lua main.lua
# 编译打包
plugindev build
hmapdev build
# 输出: dist/myluaplugin_lua.hmap
```
@ -129,16 +134,16 @@ myluaplugin/
### 编译打包
`plugindev build` 会自动完成编译和打包:
`hmapdev build` 会自动完成编译和打包:
```bash
cd myplugin
plugindev build # 默认 bundle 模式(多平台合集)
plugindev build --no-bundle # 单平台构建(仅当前 plg.json targets
plugindev build --target linux/amd64 # 在 targets 基础上追加一个目标
plugindev build --outdir dist # 指定输出目录(默认 dist
plugindev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace
plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
hmapdev build # 默认 bundle 模式(多平台合集)
hmapdev build --no-bundle # 单平台构建(仅当前 plg.json targets
hmapdev build --target linux/amd64 # 在 targets 基础上追加一个目标
hmapdev build --outdir dist # 指定输出目录(默认 dist
hmapdev build --sdk-path <path> # 指定 SDK 路径(覆盖 go.mod replace
hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
```
执行过程:
@ -154,7 +159,7 @@ plugindev build --replace <mod@path> # 追加 go.mod replace 指令(可多次
| 文件 | 用途 | 关键字段 |
|------|------|---------|
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
@ -169,7 +174,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
@ -178,12 +183,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
### 构建目标与多平台打包bundle
**`plugindev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
**`hmapdev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
```bash
plugindev build # 默认 bundle输出 dist/myplugin_bundle.hmap
plugindev build --bundle # 显式开启 bundle同上
plugindev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
hmapdev build # 默认 bundle输出 dist/myplugin_bundle.hmap
hmapdev build --bundle # 显式开启 bundle同上
hmapdev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
```
注意:
@ -273,7 +278,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### 入口点
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -281,7 +286,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
### PluginSDK 核心 API

View File

@ -25,9 +25,11 @@ import (
luapkg "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/pipeline"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
"gitcode.com/JianFeeeee/HomeAgent/internal/nlp"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -41,10 +43,21 @@ import (
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/embedding"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
// 空白导入内置 provider它们各自在 init 里注册到 pkg/embedding。
// 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main
_ "gitcode.com/JianFeeeee/HomeAgent/providers/chineseclip"
_ "gitcode.com/JianFeeeee/HomeAgent/providers/qwen3vl"
)
func main() {
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
// platform_windows.go。
requireSupportedPlatform()
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
@ -319,10 +332,73 @@ func main() {
// 文档记忆 + 知识库
// ========================================================================
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"))
docStore := document.NewStore(filepath.Join(cfg.Daemon.DataDir, "memory", "documents"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
log.Printf("[homed] warning: document store: %v", err)
}
// 关停时落盘。文档记忆的内存态变更(迁移结果、访问计数等)只在 flush
// 里写盘,而 flush 的唯一入口是 Stop()——此前全仓无人调用它,
// 于是迁移结果永不落盘、每次启动白算一遍。
defer docStore.Stop()
// 媒体存储(内容寻址):记忆块的内容后端。
// 开关默认开;关闭后全部媒体接线静默跳过,对话行为与本特性上线前一致。
var mediaStore *media.Store
if cfgReg.GetBool("core.memory.media.enabled", true) {
mediaDir := cfgReg.GetString("core.memory.media.dir",
filepath.Join(cfg.Daemon.DataDir, "memory", "media"))
ms, err := media.New(mediaDir)
if err != nil {
// 媒体存储开不起来不该阻止启动——它是记忆增强,不是对话必需品
log.Printf("[homed] warning: media store: %v媒体记忆已禁用", err)
} else {
mediaStore = ms
defer mediaStore.Close()
st := mediaStore.Stats()
log.Printf("[homed] media store active: %v 条 / %v 字节",
st["count"], st["total_bytes"])
}
}
// 统一多模态向量空间。
//
// 核心**不**知道任何具体模型:它只按配置里的 provider 名从公共注册表
// pkg/embedding打开一个 provider并把 options.* 原样交给它。模型文件
// 布局、预处理、解码、运行时全部属于 provider 内部实现。
// provider 名为空时禁用多模态向量检索,退回纯 fastText 文本路径。
var multimodalSpace vector.MultimodalEmbedder
// 这两个值只用于状态报告healthcheck_kernel 的 onnx 段):
// 「配了哪个 provider」与「为什么没启用」避免只能看到 false 却不知原因。
var mmProviderName, mmErr string
if mmProvider := cfgReg.GetString("core.memory.multimodal_space.provider", ""); mmProvider != "" {
mmProviderName = mmProvider
opts := map[string]string{}
const optPrefix = "core.memory.multimodal_space.options."
for _, key := range cfgReg.List("core.memory.multimodal_space.options.") {
opts[strings.TrimPrefix(key, optPrefix)] = cfgReg.GetString(key, "")
}
provider, err := embedding.Open(mmProvider, embedding.Config{Options: opts})
if err != nil {
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 打开失败: %v多模态向量检索已禁用已注册: %s",
mmProvider, err, strings.Join(embedding.Names(), ", "))
} else if adapted, err := vector.AdaptProvider(provider); err != nil {
provider.Close()
mmErr = err.Error()
log.Printf("[homed] warning: 多模态向量 provider %q 元数据不合法: %v多模态向量检索已禁用", mmProvider, err)
} else {
multimodalSpace = adapted
defer adapted.Close()
info := provider.Info()
// 指纹可能很长(模型文件哈希),日志里只取前 12 个字符便于对照。
shortFP := info.Fingerprint
if len(shortFP) > 12 {
shortFP = shortFP[:12]
}
log.Printf("[homed] multimodal space active: provider=%s dim=%d fp=%s modalities=%v",
mmProvider, info.Dimension, shortFP, info.Modalities)
}
}
ks := knowledge.NewStore(filepath.Join(cfg.Daemon.DataDir, "knowledge"))
if err := ks.Start(); err != nil {
@ -335,13 +411,29 @@ func main() {
// 人格设定
// ========================================================================
// 人格来源优先级personal/personal.md高级覆盖存在且非空才生效
// > 配置项 core.agent.personal_prompt默认模板 = config.DefaultPersonaPrompt
//
// 曾经只有「文件」一个来源且无人维护,导致人格卡写死旧版本号与已删除的 C ABI、
// 反过来让实例自称旧版本v1.2.0 压测发现)。故:
// - 配置项化 + 内置默认模板(不含版本号字面量)
// - 文件仍在时生效,但扫到腐坏内容就在启动日志里明确告警
personalPath := filepath.Join(cfg.Daemon.DataDir, "personal", "personal.md")
personality, err := agentPkg.LoadPersonality(personalPath)
if err != nil {
log.Printf("[homed] warning: load personality: %v", err)
}
if personality != nil && personality.Content != "" {
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
log.Printf("[homed] 人格来源=文件 %s优先于配置项%d 字节", personalPath, len(personality.Content))
if hints := agentPkg.PersonaStaleHints(personality.Content); len(hints) > 0 {
log.Printf("[homed] warning: 人格文件含会腐坏的内容 %v — 建议迁到配置项 core.agent.personal_prompt"+
"(默认模板不含版本号,被问版本时以运行时快照为准)", hints)
}
} else if pv := cfgReg.GetString("core.agent.personal_prompt", internalConfig.DefaultPersonaPrompt); strings.TrimSpace(pv) != "" {
personality = &agentPkg.Personality{Content: pv, Path: "(core.agent.personal_prompt)"}
log.Printf("[homed] 人格来源=配置项 core.agent.personal_prompt%d 字节", len(pv))
} else {
log.Printf("[homed] 人格来源=无(配置项为空且无人格文件)")
}
// ========================================================================
@ -356,6 +448,7 @@ func main() {
pluginReg.SetMemory(memDB)
pluginReg.SetTextMemory(textMem)
pluginReg.SetDocStore(docStore)
pluginReg.SetMediaStore(mediaStore) // 插件写入的记忆也走媒体链路nil 时静默降级
pluginReg.SetKnowledge(ks)
pluginReg.SetProviderManager(providerMgr)
pluginReg.SetConfigRegistry(cfgReg)
@ -419,28 +512,39 @@ func main() {
}
agent := agentCore.New(agentCore.AgentConfig{
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
Personality: personality,
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
ID: "main",
SystemPrompt: sysPrompt,
Provider: provider,
ProviderManager: providerMgr,
IO: iom,
Memory: memDB,
Indexer: memIdx,
Tracker: trk,
DocStore: docStore,
Knowledge: ks,
SocialStore: socialStore,
TextMemory: textMem,
MediaStore: mediaStore,
Personality: personality,
// 人格落库面:首启门禁(任何通道都问一次)与 persona_set 工具用。
// 与 WebUI 向导共用 internal/config 的同一份落库逻辑。
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
// DataDir驻留子的 temp 图库锚点(<data>/residents/<id>/graph.db
// 漏接时的现象是"工具存在、可调用、但创建必失败"——只有真实二进制才看得出来。
DataDir: cfg.Daemon.DataDir,
DistillInterval: cfgReg.GetDuration("core.agent.distill_interval", 30*time.Minute),
ArchiveInterval: cfgReg.GetDuration("core.agent.archive_interval", 60*time.Minute),
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),
ContextSavePath: filepath.Join(cfg.Daemon.DataDir, "memory", "context.json"),
EmbeddingModelPath: cfgReg.GetString("core.agent.embedding_model_path", ""),
Embedder: embedder,
MultimodalSpace: multimodalSpace,
EmbeddingProvider: mmProviderName,
EmbeddingError: mmErr,
StageHost: stageHost,
EventBus: evBus,
ThinkingEnabled: cfg.LLM.ThinkingEnabled,

View File

@ -0,0 +1,9 @@
//go:build !windows
package main
// requireSupportedPlatform 在受支持的平台上不做任何事。
//
// 平台策略见 platform_windows.go只有 homed 放弃 Windows 原生支持
// (插件体系依赖 fd 继承与共享内存段内偏移Windows 用户走 WSL2。
func requireSupportedPlatform() {}

View File

@ -0,0 +1,44 @@
//go:build windows
package main
import (
"fmt"
"os"
)
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
//
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
//
// homed 的插件体系建立在两个原语上——**继承的 fd**Single memfd: 统一共享
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
//
// Windows 的等价物是命名内核对象CreateFileMappingW / OpenEventW句柄表
// 没有 fd 继承语义os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
// 导致改写型插件在某个平台上静默失效」的教训§9.2)。
//
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
// WSL2 里就是普通 linux/amd64走与我们测试矩阵完全相同的那条路径。
//
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
func requireSupportedPlatform() {
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "请改用 WSL2")
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap")
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed与 Linux 主机完全相同,无需额外配置")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
os.Exit(2)
}

View File

@ -17,6 +17,36 @@ func randomSecret(n int) string {
return hex.EncodeToString(b)
}
// must 让失败真正停下来。
//
// 这里曾经把所有 db.Exec 的返回值丢掉,配合 CGO_ENABLED=0 构建go-sqlite3
// 退化成静态桩),得到的是一个**完全静默的空操作**:打印凭据、退出码 0、
// config.db 里一个字节都没写。调用方(安装脚本)无法区分成败,用户装完
// 照着 credentials.txt 登录必然失败。
func must(err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "initconfig: %v\n", err)
os.Exit(1)
}
}
// verify 回读刚写入的值。
//
// 只看 Exec 有没有报错不够:驱动被换掉(如上面的桩)、路径不对、写入被丢弃,
// 都可能返回 nil 而什么都没落下。这里把真实落盘的值读回来,与预期逐一比对,
// 不一致就非零退出——"初始化脚本说自己成功了"必须由数据库内容佐证。
func verify(db *sql.DB, table, key, want string) {
var got string
if err := db.QueryRow(fmt.Sprintf(`SELECT value FROM %s WHERE key = ?`, table), key).Scan(&got); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 回读 %s.%s 失败: %v\n", table, key, err)
os.Exit(1)
}
if got != want {
fmt.Fprintf(os.Stderr, "initconfig: %s.%s 与写入值不一致(读回 %q\n", table, key, got)
os.Exit(1)
}
}
func main() {
dataDir := flag.String("data", "", "data directory")
webuiUsername := flag.String("username", "admin", "webui username")
@ -33,16 +63,24 @@ func main() {
dbPath := *dataDir + "/config.db"
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "open db: %v\n", err)
os.Exit(1)
}
must(err)
defer db.Close()
db.Exec("PRAGMA journal_mode=WAL")
// 尽早验证数据库真的可用sql.Open 是惰性的,不碰一次不会暴露驱动问题。
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 打开数据库 %s 失败: %v\n", dbPath, err)
os.Exit(1)
}
db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`)
db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", ":8080")
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 创建 config 表失败: %v\n", err)
os.Exit(1)
}
const listenAddr = ":8080"
if _, err := db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, "webui.listen_addr", listenAddr); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 写入 webui.listen_addr 失败: %v\n", err)
os.Exit(1)
}
pw := *webuiPassword
if pw == "" {
@ -53,13 +91,28 @@ func main() {
apiKey = randomSecret(16)
}
pt := "config_webui"
db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt))
const pt = "config_webui"
if _, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value TEXT NOT NULL)`, pt)); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 创建 %s 表失败: %v\n", pt, err)
os.Exit(1)
}
ws := fmt.Sprintf(`INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)`, pt)
db.Exec(ws, "api_key", apiKey)
db.Exec(ws, "username", *webuiUsername)
db.Exec(ws, "password", pw)
db.Exec(ws, "session_ttl_hours", "24")
for _, kv := range [][2]string{
{"api_key", apiKey},
{"username", *webuiUsername},
{"password", pw},
{"session_ttl_hours", "24"},
} {
if _, err := db.Exec(ws, kv[0], kv[1]); err != nil {
fmt.Fprintf(os.Stderr, "initconfig: 写入 %s.%s 失败: %v\n", pt, kv[0], err)
os.Exit(1)
}
}
verify(db, pt, "api_key", apiKey)
verify(db, pt, "username", *webuiUsername)
verify(db, pt, "password", pw)
verify(db, "config", "webui.listen_addr", listenAddr)
fmt.Printf("API_KEY=%s\n", apiKey)
fmt.Printf("WEBUI_USERNAME=%s\n", *webuiUsername)

View File

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

View File

@ -32,6 +32,10 @@ export class ApiClient {
this.conn = conn;
}
clearConnection(): void {
this.conn = null;
}
getConnection(): ConnectionConfig | null {
return this.conn;
}

View File

@ -4,16 +4,63 @@ import { pasteboard } from '@kit.BasicServicesKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
// ===== 能力结果 =====
/** 与 BridgeRouter 实际支持的本机命令保持一一对应。 */
export const LOCAL_DEVICE_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
export interface CapResult {
status: string; // 'ok' | 'error'
output: string;
error: string;
}
interface DeviceStatusPayload {
device_id: string;
status: string;
hostname: string;
platform: string;
arch: string;
uptime: number;
}
interface DeviceDetails {
hostname: string;
platform: string;
arch: string;
os_release: string;
version: string;
cpus: number;
brand: string;
manufacturer: string;
model: string;
series: string;
sdk_api_version: number;
security_patch: string;
abi_list: string;
device_type: string;
}
interface DeviceInfoPayload {
device_id: string;
name: string;
kind: string;
caps: string[];
info: DeviceDetails;
}
const APP_STARTED_AT: number = Date.now();
function okResult(output: string): CapResult {
const r: CapResult = { status: 'ok', output: output, error: '' };
return r;
@ -44,53 +91,79 @@ async function captureScreenPixelMap(): Promise<image.PixelMap> {
* 此处回传应用自身前台画面;应用在前台运行时即为用户正在看到的界面。
*/
export async function capScreensee(): Promise<CapResult> {
let full: image.PixelMap | null = null;
let packer: image.ImagePacker | null = null;
try {
const full: image.PixelMap = await captureScreenPixelMap();
full = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 720;
let targetW: number = info.size.width;
let targetH: number = info.size.height;
if (targetW > maxW) {
targetH = Math.floor(targetH * maxW / targetW);
targetW = maxW;
const maxW: number = 420;
const maxH: number = 640;
let scale: number = 1;
if (info.size.width > maxW) {
scale = maxW / info.size.width;
}
let packed: ArrayBuffer;
if (targetW !== info.size.width) {
await full.scale(targetW / info.size.width, targetH / info.size.height);
if (info.size.height * scale > maxH) {
scale = maxH / info.size.height;
}
const packer: image.ImagePacker = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
packed = await packer.packing(full, opt);
packer.release();
full.release();
if (scale < 1) {
await full.scale(scale, scale);
}
packer = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 55 };
const packed: ArrayBuffer = await packer.packing(full, opt);
const helper: util.Base64Helper = new util.Base64Helper();
const b64: string = helper.encodeToStringSync(new Uint8Array(packed));
if (b64.length > 950000) {
return errResult('当前画面数据过大,请稍后重试');
}
return okResult('data:image/jpeg;base64,' + b64);
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('screensee failed: ' + msg);
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
} finally {
if (packer !== null) {
packer.release();
}
if (full !== null) {
full.release();
}
}
}
// ===== clipboardsee / clipboardsue =====
const CLIPBOARD_PERMISSIONS: Array<Permissions> = ['ohos.permission.READ_PASTEBOARD'];
/**
* READ_PASTEBOARD 是 user_grant 权限:仅在 agent 真正请求 clipboardsee 时弹出系统授权,
* 不在应用启动时抢先索权。已授权时系统会直接返回,不会重复打扰用户。
*/
async function ensureClipboardPermission(context: common.UIAbilityContext): Promise<boolean> {
try {
const atManager = abilityAccessCtrl.createAtManager();
const result: PermissionRequestResult =
await atManager.requestPermissionsFromUser(context, CLIPBOARD_PERMISSIONS);
return result.authResults.length > 0 && result.authResults[0] === 0;
} catch (e) {
return false;
}
}
export async function capClipboardSee(context: common.UIAbilityContext): Promise<CapResult> {
// 说明READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
// 这里直接尝试读取;系统拒绝时回错误信息。
const granted: boolean = await ensureClipboardPermission(context);
if (!granted) {
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
}
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData();
if (!has) {
const empty: CapResult = { status: 'ok', output: '', error: '' };
return empty;
return okResult('');
}
const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText();
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
return out;
return okResult(txt ?? '');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
}
}
@ -99,10 +172,9 @@ export async function capClipboardsue(text: string): Promise<CapResult> {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const data: pasteboard.PasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
await clip.setPasteData(data);
return okResult('written ' + text.length + ' chars');
return okResult('clipboard written');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsue failed: ' + msg);
return errResult('剪贴板写入失败,请保持应用在前台后重试');
}
}
@ -112,13 +184,11 @@ class TtsSession {
private engine: textToSpeech.TextToSpeechEngine | null = null;
async speak(text: string): Promise<CapResult> {
if (text.length > 4000) {
return errResult('朗读内容过长,请缩短到 4000 字以内');
}
try {
if (this.engine === null) {
const extra: Record<string, Object> = {
'style': 'interaction-broadcast',
'locate': 'CN',
'name': 'EngineName',
};
const params: textToSpeech.CreateEngineParams = {
language: 'zh-CN',
person: 0,
@ -133,8 +203,7 @@ class TtsSession {
this.engine.speak(text, sp);
return okResult('speaking');
} catch (e) {
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('speakeruse failed: ' + msg);
return errResult('语音服务暂时不可用,请稍后重试');
}
}
@ -156,20 +225,49 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
return ttsSession.speak(text);
}
// ===== deviceinfo =====
export function shutdownSpeakerUse(): void {
ttsSession.shutdown();
}
export function capDeviceInfo(): CapResult {
const lines: string[] = [];
lines.push('brand=' + deviceInfo.brand);
lines.push('manufacturer=' + deviceInfo.manufacture);
lines.push('model=' + deviceInfo.productModel);
lines.push('series=' + deviceInfo.productSeries);
lines.push('osFullName=' + deviceInfo.osFullName);
lines.push('sdkApiVersion=' + deviceInfo.sdkApiVersion.toString());
lines.push('securityPatch=' + deviceInfo.securityPatchTag);
lines.push('abiList=' + deviceInfo.abiList);
lines.push('deviceType=' + deviceInfo.deviceType);
return okResult(lines.join('\n'));
// ===== status / deviceinfo =====
export function capStatus(deviceId: string): CapResult {
const payload: DeviceStatusPayload = {
device_id: deviceId,
status: 'online',
hostname: 'ohos-phone',
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
uptime: Math.floor((Date.now() - APP_STARTED_AT) / 1000),
};
return okResult(JSON.stringify(payload));
}
export function capDeviceInfo(deviceId: string, deviceName: string): CapResult {
const details: DeviceDetails = {
hostname: 'ohos-phone',
platform: 'OpenHarmony',
arch: deviceInfo.abiList,
os_release: deviceInfo.osFullName,
version: '1.1.1',
cpus: 0,
brand: deviceInfo.brand,
manufacturer: deviceInfo.manufacture,
model: deviceInfo.productModel,
series: deviceInfo.productSeries,
sdk_api_version: deviceInfo.sdkApiVersion,
security_patch: deviceInfo.securityPatchTag,
abi_list: deviceInfo.abiList,
device_type: deviceInfo.deviceType,
};
const payload: DeviceInfoPayload = {
device_id: deviceId,
name: deviceName,
kind: 'ohos-phone',
caps: LOCAL_DEVICE_CAPS,
info: details,
};
return okResult(JSON.stringify(payload));
}
// ===== screensue 内容解析 =====
@ -182,14 +280,18 @@ export interface ScreensuePayload {
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
let rest: string = rawArgs.trim();
const tokens: string[] = rest.split(/\s+/);
if (tokens.length > 1 && /^\d+$/.test(tokens[0])) {
p.duration = parseInt(tokens[0], 10);
rest = tokens.slice(1).join(' ');
} else {
rest = tokens.join(' ');
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.trim();
p.content = rest;
return p;
}

View File

@ -1,4 +1,4 @@
import { deviceBridge, CmdReply } from './DeviceBridge';
import { deviceBridge } from './DeviceBridge';
import {
CapResult,
capScreensee,
@ -6,12 +6,14 @@ import {
capClipboardsue,
capSpeakerUse,
capDeviceInfo,
capStatus,
parseScreensue,
ScreensuePayload,
} from './BridgeCaps';
import { connStore } from './ConnStore';
import { common } from '@kit.AbilityKit';
// screensue 展示回调由 UI 注册Index 挂全局悬浮层)
// screensue 展示回调由 UI 注册:窄屏整页,宽屏右侧内容栏。
export type ScreensueHandler = (payload: ScreensuePayload) => void;
let screensueHandler: ScreensueHandler | null = null;
@ -25,10 +27,23 @@ export function setBridgeAppContext(ctx: common.UIAbilityContext): void {
appContext = ctx;
}
/** 解析 homeagent-* 命令:返回能力名与参数串。 */
/** 解析裸能力名或过渡期 homeagent-* 命令;参数正文不裁剪,避免改变推送内容。 */
function splitCapability(command: string): string[] {
const cmd: string = command.trim();
const idx: number = cmd.indexOf(' ');
let start: number = 0;
while (start < command.length && isCommandSpace(command.charAt(start))) {
start = start + 1;
}
let cmd: string = command.substring(start);
if (cmd.startsWith('homeagent-')) {
cmd = cmd.substring('homeagent-'.length);
}
let idx: number = -1;
for (let i: number = 0; i < cmd.length; i++) {
if (isCommandSpace(cmd.charAt(i))) {
idx = i;
break;
}
}
if (idx < 0) {
return [cmd];
}
@ -36,51 +51,73 @@ function splitCapability(command: string): string[] {
return out;
}
function isCommandSpace(ch: string): boolean {
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
}
function hasArgs(args: string): boolean {
return args.trim().length > 0;
}
async function executeCommand(reqId: string, command: string): Promise<CapResult> {
const parts: string[] = splitCapability(command);
const name: string = parts[0];
const args: string = parts.length > 1 ? parts[1] : '';
// screensee 截屏回传data URL 走文本结果,服务端兼容)
if (name === 'screensee') {
if (hasArgs(args)) {
return errRes('screensee 不接受额外参数');
}
return capScreensee();
}
if (name === 'screensue') {
if (!hasArgs(args)) {
return errRes('screensue 需要展示内容');
}
const payload: ScreensuePayload = parseScreensue(args);
if (payload.content.length === 0) {
return errRes('screensue 需要展示内容');
}
if (screensueHandler !== null) {
screensueHandler(payload);
return okRes('shown');
return okRes('内容已显示');
}
return errRes('screensue: display layer not ready');
return errRes('展示界面尚未就绪,请保持应用在前台后重试');
}
if (name === 'clipboardsee') {
if (hasArgs(args)) {
return errRes('clipboardsee 不接受额外参数');
}
if (appContext === null) {
return errRes('clipboardsee: app context missing');
return errRes('应用界面尚未就绪,请保持应用在前台后重试');
}
return capClipboardSee(appContext);
}
if (name === 'clipboardsue') {
if (args.length === 0) {
return errRes('clipboardsue: empty text');
if (!hasArgs(args)) {
return errRes('clipboardsue 需要写入文字');
}
return capClipboardsue(args);
}
if (name === 'speakeruse') {
if (args.length === 0) {
return errRes('speakeruse: empty text');
if (!hasArgs(args)) {
return errRes('speakeruse 需要朗读文字');
}
return capSpeakerUse(args);
}
if (name === 'deviceinfo' || name === 'status') {
return capDeviceInfo();
if (name === 'status') {
if (hasArgs(args)) {
return errRes('status 不接受额外参数');
}
return capStatus(connStore.getDeviceId());
}
if (name === 'camerasue') {
return errRes('camerasue: camera capture not supported on this build');
if (name === 'deviceinfo') {
if (hasArgs(args)) {
return errRes('deviceinfo 不接受额外参数');
}
return capDeviceInfo(connStore.getDeviceId(), connStore.getDeviceName());
}
if (name === 'computeruse') {
return errRes('computeruse: not applicable to touch-only device');
}
return errRes('unsupported homeagent capability: ' + name);
return errRes('不支持的本机能力:' + name);
}
function okRes(output: string): CapResult {

View File

@ -1,5 +1,4 @@
import { webSocket } from '@kit.NetworkKit';
import { DeviceInfo } from '../model/Model';
import { CapResult } from './BridgeCaps';
// ===== 协议消息(与 remotedevice 插件对齐)=====
@ -33,13 +32,6 @@ interface BindMessage {
token: string;
}
interface CmdMessage {
op: string;
req_id: string;
command: string;
cmd_type: string;
}
export interface CmdReply {
op: string; // 'cmd_result'
req_id: string;
@ -81,14 +73,15 @@ export class DeviceBridgeClient {
private caps: string[] = [];
private hostname: string = 'ohos';
private connected: boolean = false;
private everConnected: boolean = false;
private bound: boolean = false;
private manualClose: boolean = false;
private reconnectTimer: number = -1;
private connectionGeneration: number = 0;
private cmdHandler: BridgeCmdHandler | null = null;
private onStateChange: ((open: boolean) => void) | null = null;
isConnected(): boolean {
return this.connected;
return this.connected && this.bound;
}
getDeviceId(): string {
@ -117,37 +110,40 @@ export class DeviceBridgeClient {
}
private async openAndRegister(authorized: boolean): Promise<void> {
// 每次连接使用新的 WebSocket 实例,避免旧实例事件残留
try {
this.ws.off('open');
this.ws.off('message');
this.ws.off('close');
this.ws.off('error');
this.ws.close().catch(() => {
// ignore stale socket close failure
});
} catch (e) {
// ignore
// ignore stale socket cleanup failure
}
this.ws = webSocket.createWebSocket();
this.bindWsEvents(authorized);
// 鉴权必须走请求头,不能拼 ?token=
// 1) webui 的 /api/v1/device/* 反代包在 requireAPI 里,
// validAPIKey 只认 X-API-Key 头或 Authorization: Bearer
// 查询参数一律视为未授权 → 握手被 401 顶掉,
// 表现为 NETSTACK 日志 "Lws client connection error HS: ws upgrade unauthorized"。
// 2) 反代到 remotedevice 时会自行注入网关的 ws_token
// 如果我们再带 ?token=<webui apiKey>remotedevice 的 ServeWS
// 会拿它和 ws_token 比对并 401。留空反而放行。
this.connectionGeneration = this.connectionGeneration + 1;
const generation: number = this.connectionGeneration;
const socket: webSocket.WebSocket = webSocket.createWebSocket();
this.ws = socket;
this.connected = false;
this.bound = false;
this.bindWsEvents(socket, authorized, generation);
const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(),
};
try {
await this.ws.connect(this.url, opts);
await socket.connect(this.url, opts);
} catch (e) {
this.connected = false;
this.scheduleReconnect();
if (generation === this.connectionGeneration && !this.manualClose) {
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
}
}
/** 握手请求头X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
private authHeader(): Record<string, string> {
const h: Record<string, string> = {};
if (this.token.length > 0) {
@ -157,38 +153,48 @@ export class DeviceBridgeClient {
return h;
}
private bindWsEvents(authorized: boolean): void {
this.ws.on('open', (err: Error, value: Object) => {
private bindWsEvents(socket: webSocket.WebSocket, authorized: boolean, generation: number): void {
socket.on('open', (err: Error, value: Object) => {
if (generation !== this.connectionGeneration || this.manualClose) {
socket.close().catch(() => {
// ignore stale socket close failure
});
return;
}
this.connected = true;
this.everConnected = true;
this.cancelReconnect();
this.bound = false;
this.sendHello(authorized);
this.sendBind();
if (this.onStateChange !== null) {
this.onStateChange(true);
}
});
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
if (typeof value === 'string') {
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
if (generation === this.connectionGeneration && typeof value === 'string') {
this.handleTextFrame(value);
}
});
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
this.handleSocketEnd(generation);
});
this.ws.on('error', (err: Error) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
socket.on('error', (err: Error) => {
this.handleSocketEnd(generation);
});
}
private handleSocketEnd(generation: number): void {
if (generation !== this.connectionGeneration) {
return;
}
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
private notifyState(open: boolean): void {
if (this.onStateChange !== null) {
this.onStateChange(open);
}
}
private scheduleReconnect(): void {
if (this.manualClose || this.reconnectTimer >= 0) {
return;
@ -211,18 +217,20 @@ export class DeviceBridgeClient {
}
}
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized;
if (this.connected) {
if (this.connected && this.bound) {
this.sendHello(authorized);
}
}
disconnect(): void {
this.manualClose = true;
this.connectionGeneration = this.connectionGeneration + 1;
this.cancelReconnect();
this.connected = false;
this.bound = false;
try {
this.ws.off('open');
this.ws.off('message');
@ -234,9 +242,7 @@ export class DeviceBridgeClient {
} catch (e) {
// ignore
}
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.notifyState(false);
}
private sendHello(authorized: boolean): void {
@ -246,7 +252,7 @@ export class DeviceBridgeClient {
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.0',
version: '1.1.1',
cpus: 0,
};
const device: HelloDevice = {
@ -280,37 +286,50 @@ export class DeviceBridgeClient {
return;
}
const op: string = obj['op'] as string ?? '';
if (op === 'cmd') {
const reqId: string = obj['req_id'] as string ?? '';
const command: string = obj['command'] as string ?? '';
if (reqId.length === 0 || command.length === 0) {
return;
}
if (!this.lastAuthorized) {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
return;
}
this.dispatchCommand(reqId, command);
} else if (op === 'hello_ack' || op === 'bind_ack') {
if (this.onAck !== null) {
this.onAck(op);
if (op === 'bind_ack') {
const accepted: boolean = obj['ok'] === true;
if (accepted && this.connected && !this.manualClose) {
this.bound = true;
this.cancelReconnect();
this.notifyState(true);
} else {
this.bound = false;
this.notifyState(false);
try {
this.ws.close().catch(() => {
// ignore bind rejection close failure
});
} catch (e) {
this.scheduleReconnect();
}
}
return;
}
if (op !== 'cmd' || !this.bound) {
return;
}
const reqId: string = obj['req_id'] as string ?? '';
const command: string = obj['command'] as string ?? '';
if (reqId.length === 0 || command.length === 0) {
return;
}
if (!this.lastAuthorized) {
this.sendResult(reqId, 'error', '', '设备未授权:请在设备页开启远程控制授权');
return;
}
this.dispatchCommand(reqId, command);
}
onAck: ((op: string) => void) | null = null;
private dispatchCommand(reqId: string, command: string): void {
if (this.cmdHandler === null) {
this.sendResult(reqId, 'error', '', 'no capability handler registered');
this.sendResult(reqId, 'error', '', '本机能力尚未就绪,请保持应用在前台后重试');
return;
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
const msg: string = e instanceof Error ? e.message : String(e);
this.sendResult(reqId, 'error', '', msg);
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
});
}
@ -388,7 +407,3 @@ export class DeviceBridgeClient {
}
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
return [];
}

View File

@ -0,0 +1,105 @@
import { common } from '@kit.AbilityKit';
import { deviceBridge } from './DeviceBridge';
import { installCmdRouter, setBridgeAppContext } from './BridgeRouter';
import { LOCAL_DEVICE_CAPS, shutdownSpeakerUse } from './BridgeCaps';
import { connStore } from './ConnStore';
import { ConnectionConfig } from '../model/Model';
export { LOCAL_DEVICE_CAPS } from './BridgeCaps';
let bridgeStarting: boolean = false;
let foregroundActive: boolean = false;
let rootUIReady: boolean = false;
let bridgeGeneration: number = 0;
let stateTrackingReady: boolean = false;
function ensureBridgeStateTracking(): void {
if (stateTrackingReady) {
return;
}
stateTrackingReady = true;
AppStorage.setOrCreate<boolean>('deviceBridgeConnected', false);
deviceBridge.setStateListener((open: boolean) => {
AppStorage.set<boolean>('deviceBridgeConnected', open);
});
}
/** 把当前后端 HTTP 地址转换为同源设备桥 WebSocket 地址。 */
export function deviceGatewayUrl(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 应用进入前台后建立全局设备桥。它不再依赖用户先打开“设备”Tab
* 因而 screensue、clipboardsee 等前台能力从主页面加载后即可接收。
*/
export async function startForegroundBridge(context: common.UIAbilityContext): Promise<void> {
foregroundActive = true;
setBridgeAppContext(context);
installCmdRouter();
ensureBridgeStateTracking();
if (!rootUIReady || deviceBridge.isConnected() || bridgeStarting) {
return;
}
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur === null || cur.url.length === 0 || cur.apiKey.length === 0) {
return;
}
bridgeStarting = true;
bridgeGeneration = bridgeGeneration + 1;
const generation: number = bridgeGeneration;
const deviceId: string = connStore.ensureDeviceId();
try {
await deviceBridge.connect(
deviceGatewayUrl(cur.url), cur.apiKey, deviceId,
LOCAL_DEVICE_CAPS, 'ohos-phone', connStore.getDeviceAuth(), connStore.getDeviceName());
if (!foregroundActive || generation !== bridgeGeneration) {
deviceBridge.disconnect();
}
} catch (e) {
// DeviceBridge 自己会安排重连;前台启动不弹技术错误打扰用户。
}
if (generation === bridgeGeneration) {
bridgeStarting = false;
}
}
/** 根页面挂载完成后才连接,避免首条 screensue 到达时展示层尚未注册。 */
export function markForegroundBridgeUIReady(context: common.UIAbilityContext): void {
rootUIReady = true;
startForegroundBridge(context);
}
/** 后台不接受需要前台 UI/剪贴板授权的命令。 */
export function stopForegroundBridge(): void {
foregroundActive = false;
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
shutdownSpeakerUse();
deviceBridge.disconnect();
}
/** 连接配置切换或修改后立即让设备桥使用新地址和 Token。 */
export async function restartForegroundBridge(context: common.UIAbilityContext): Promise<void> {
bridgeGeneration = bridgeGeneration + 1;
bridgeStarting = false;
deviceBridge.disconnect();
await startForegroundBridge(context);
}

View File

@ -67,8 +67,11 @@ class StatusStore {
this.fail(noConnectionMessage());
return;
}
AppStorage.setOrCreate<boolean>(K_LOADING, true);
AppStorage.setOrCreate<string>(K_ERR, '');
// setOrCreate 只负责首次建键,键已存在时不会覆盖旧值。
// init() 已把所有键种好,刷新阶段必须用 set否则摘要卡会永远停在
// 版本 "-"、插件 0 的初始状态。
AppStorage.set<boolean>(K_LOADING, true);
AppStorage.set<string>(K_ERR, '');
try {
const resp = await apiClient.getWithTimeout('/status', 8000);
const obj: Record<string, Object> = JSON.parse(resp.body) as Record<string, Object>;
@ -79,10 +82,10 @@ class StatusStore {
const agents: number = obj['agents'] as number ?? 0;
const startedAt: string = obj['startedAt'] as string ?? '';
AppStorage.setOrCreate<boolean>(K_UP, true);
AppStorage.setOrCreate<string>(K_VERSION, version);
AppStorage.setOrCreate<number>(K_AGENTS, agents);
AppStorage.setOrCreate<string>(K_STARTED, startedAt);
AppStorage.set<boolean>(K_UP, true);
AppStorage.set<string>(K_VERSION, version);
AppStorage.set<number>(K_AGENTS, agents);
AppStorage.set<string>(K_STARTED, startedAt);
const systemFields: StatField[] = [
{ label: '版本', value: version },
@ -103,7 +106,7 @@ class StatusStore {
} catch (e) {
this.fail(userMessage('status.refresh', e));
}
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.set<boolean>(K_LOADING, false);
}
/** /kernel 可能不存在(旧后端),失败不影响 /status 已取到的部分 */
@ -119,8 +122,8 @@ class StatusStore {
const pluginCount: number = pluginsArr !== undefined ? pluginsArr.length : 0;
const toolCount: number = toolsArr !== undefined ? toolsArr.length : 0;
AppStorage.setOrCreate<number>(K_PLUGINS, pluginCount);
AppStorage.setOrCreate<number>(K_TOOLS, toolCount);
AppStorage.set<number>(K_PLUGINS, pluginCount);
AppStorage.set<number>(K_TOOLS, toolCount);
const kernelFields: StatField[] = [
{ label: 'Agent ID', value: agentId },
@ -219,9 +222,9 @@ class StatusStore {
}
private fail(msg: string): void {
AppStorage.setOrCreate<string>(K_ERR, msg);
AppStorage.setOrCreate<boolean>(K_UP, false);
AppStorage.setOrCreate<boolean>(K_LOADING, false);
AppStorage.set<string>(K_ERR, msg);
AppStorage.set<boolean>(K_UP, false);
AppStorage.set<boolean>(K_LOADING, false);
this.groups = [];
this.bump();
}
@ -229,7 +232,7 @@ class StatusStore {
/** 明细数组不进 AppStorage用一个自增版本号触发订阅组件重取 */
private bump(): void {
const cur: number = AppStorage.get<number>(K_REV) ?? 0;
AppStorage.setOrCreate<number>(K_REV, cur + 1);
AppStorage.set<number>(K_REV, cur + 1);
}
}

View File

@ -0,0 +1,103 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, ANIM_NORMAL } from '../common/Constants';
import { GradientBackground } from './GradientBackground';
import { PageTopBar } from './PageTopBar';
import { MotionBase } from './MotionBase';
/**
* agent 主动推送的前台内容页。
*
* 调用方负责决定页面宽度:窄屏占满窗口,宽屏只占右侧内容栏,
* 从而让左侧一级页面和主导航保持可见、可操作。
*/
@Component
export struct ScreensuePage {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop pushedText: string = '';
@Prop countdown: number = 0;
onClose: () => void = () => {
};
build() {
Stack({ alignContent: Alignment.Bottom }) {
GradientBackground()
Scroll() {
Column({ space: 14 }) {
Row({ space: 8 }) {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
Blank()
if (this.countdown > 0) {
Text(this.countdown.toString() + 's')
.fontSize(12)
.fontColor(this.palette().textMuted)
} else {
Text('常驻')
.fontSize(12)
.fontColor(this.palette().textMuted)
}
}
.width('100%')
Column() {
Text(this.pushedText)
.fontSize(16)
.lineHeight(25)
.fontColor(this.palette().textPrimary)
.width('100%')
.textAlign(TextAlign.Start)
.copyOption(CopyOptions.LocalDevice)
}
.width('100%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, right: 18, top: 82, bottom: 96 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.align(Alignment.Top)
PageTopBar({ title: '推送内容' })
Row() {
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('关闭')
.height(42)
.padding({ left: 22, right: 22 })
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.backgroundColor(this.palette().accent)
.borderRadius(21)
.onClick(() => {
this.onClose();
})
}
}
.width('100%')
.padding({ left: 18, right: 18, bottom: 22 })
.justifyContent(FlexAlign.End)
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 18 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
.width('100%')
.height('100%')
.backgroundColor(this.palette().bgPrimary)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}

View File

@ -13,7 +13,11 @@ import { GradientBackground } from './GradientBackground';
export const KEY_SUBPAGE_OPEN: string = 'subPageOpen';
export function markSubPageOpen(open: boolean): void {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
if (AppStorage.has(KEY_SUBPAGE_OPEN)) {
AppStorage.set<boolean>(KEY_SUBPAGE_OPEN, open);
} else {
AppStorage.setOrCreate<boolean>(KEY_SUBPAGE_OPEN, open);
}
}
/** pushPathByName 的参数载体ArkTS 不允许把 string 断言成 object */

View File

@ -4,6 +4,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { themeIsDark, seedTheme, seedSystemIsDark, resolveIsDark, applyThemeMode } from '../common/Constants';
import { startForegroundBridge, stopForegroundBridge } from '../common/DeviceBridgeSession';
/** Read the persisted theme mode ('system'|'dark'|'light'), defaulting to 'system'. */
function storedThemeMode(): string {
@ -31,6 +32,7 @@ export default class EntryAbility extends UIAbility {
}
onDestroy(): void {
stopForegroundBridge();
console.info('[HomeAgent] ability onDestroy');
}
@ -86,6 +88,7 @@ export default class EntryAbility extends UIAbility {
apiClient.setConnection(cur);
}
this.reapplyStoredTheme();
startForegroundBridge(this.context);
startUI();
}).catch(() => {
startUI();
@ -107,6 +110,7 @@ export default class EntryAbility extends UIAbility {
// init 之后持久化的主题模式才可读,这里按存量设置重新解析并刷新系统栏
this.reapplyStoredTheme();
this.applySystemBar();
startForegroundBridge(this.context);
startUI();
}).catch((e: Error) => {
console.error('[HomeAgent] connStore init failed: ' + e.message);
@ -129,10 +133,12 @@ export default class EntryAbility extends UIAbility {
}
onForeground(): void {
startForegroundBridge(this.context);
console.info('[HomeAgent] ability onForeground');
}
onBackground(): void {
stopForegroundBridge();
console.info('[HomeAgent] ability onBackground');
}
}

View File

@ -1,27 +1,14 @@
import { deviceBridge } from '../common/DeviceBridge';
import { installCmdRouter, registerScreensueHandler, setBridgeAppContext } from '../common/BridgeRouter';
import { connStore } from '../common/ConnStore';
import { apiClient } from '../common/ApiClient';
import { noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { DeviceInfo } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { common } from '@kit.AbilityKit';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
const LOCAL_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
/** 二级页面标识 */
const SUB_NONE: string = '';
@ -39,19 +26,17 @@ export struct DevicePage {
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@State activeSub: string = SUB_NONE;
@State bridgeConnected: boolean = false;
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
@State bridgeUrl: string = '';
@State bridgeToken: string = '';
@State deviceId: string = '';
@State authorized: boolean = false;
@State devices: DeviceInfo[] = [];
@State loadingDevices: boolean = false;
@State lastError: string = '';
@State toastMsg: string = '';
@State toastIsError: boolean = false;
/** 二级页面导航栈:系统返回手势/三键返回直接作用于它 */
private navStack: NavPathStack = new NavPathStack();
private autoConnectTried: boolean = false;
aboutToAppear(): void {
this.deviceId = deviceBridge.getDeviceId();
@ -70,25 +55,9 @@ export struct DevicePage {
// Gateway URL derives from current connection
const cur = connStore.getCurrentConnection();
if (cur !== null) {
this.bridgeUrl = this.gatewayUrlOf(cur.url);
this.bridgeUrl = deviceGatewayUrl(cur.url);
this.bridgeToken = cur.apiKey;
}
installCmdRouter();
try {
setBridgeAppContext(getContext(this) as common.UIAbilityContext);
} catch (e) {
// ignore context errors
}
deviceBridge.setStateListener((open: boolean) => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.bridgeConnected = open;
});
if (open) {
this.lastError = '';
this.showToast('设备网关已连接', false);
this.refreshDevices();
}
});
this.refreshDevices();
// 登记导航栈:返回手势由 Index.onBackPress 按当前 Tab 精确派发过来
registerNavStack(2, this.navStack, () => {
@ -104,37 +73,6 @@ export struct DevicePage {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
/**
* 把后端 HTTP 地址转成设备桥的 WebSocket 地址。
*
* 关键:@ohos.net.webSocket 只接受 ws:// / wss:// 协议头,
* 直接把 http:// 传进 connect() 会在 native 层报
* "protocol failed" + "ParseUrl failed"NETSTACK websocket_exec.cpp
* 表现为设备通道永远连不上。所以这里必须做协议替换。
*/
private gatewayUrlOf(base: string): string {
let trimmed: string = base.trim();
while (trimmed.length > 0 && trimmed.charAt(trimmed.length - 1) === '/') {
trimmed = trimmed.substring(0, trimmed.length - 1);
}
let scheme: string = 'ws://';
let rest: string = trimmed;
if (trimmed.startsWith('https://')) {
scheme = 'wss://';
rest = trimmed.substring('https://'.length);
} else if (trimmed.startsWith('http://')) {
scheme = 'ws://';
rest = trimmed.substring('http://'.length);
} else if (trimmed.startsWith('wss://')) {
scheme = 'wss://';
rest = trimmed.substring('wss://'.length);
} else if (trimmed.startsWith('ws://')) {
scheme = 'ws://';
rest = trimmed.substring('ws://'.length);
}
return scheme + rest + '/api/v1/device/ws';
}
/**
* 打开二级页面。
*
@ -170,45 +108,9 @@ export struct DevicePage {
// ignore persist failure
}
deviceBridge.updateAuthorized(on);
if (!this.bridgeConnected) {
this.connectBridge();
}
this.showToast(on ? '已授权agent 可下发能力命令' : '已取消授权', false);
}
// ===================== gateway connection =====================
private async connectBridge(): Promise<void> {
if (this.bridgeUrl.length === 0 || this.bridgeToken.length === 0) {
this.lastError = noConnectionMessage();
return;
}
this.lastError = '';
if (this.deviceId.length === 0) {
this.deviceId = 'ohos-' + Date.now().toString(36);
}
try {
connStore.saveDeviceId(this.deviceId);
} catch (e) {
// ignore
}
const name: string = 'HomeAgent OHOS';
await deviceBridge.connect(
this.bridgeUrl, this.bridgeToken, this.deviceId,
LOCAL_CAPS, 'ohos-phone', this.authorized, name);
}
/** 首次进入自动尝试连接(静默,失败不打扰)。 */
private maybeAutoConnect(): void {
if (this.autoConnectTried || this.bridgeConnected) {
return;
}
this.autoConnectTried = true;
if (this.bridgeUrl.length > 0 && this.bridgeToken.length > 0) {
this.connectBridge();
}
}
private showToast(msg: string, isError: boolean): void {
// 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
this.toastIsError = isError;
@ -287,9 +189,6 @@ export struct DevicePage {
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
.onAppear(() => {
this.maybeAutoConnect();
})
}
.width('100%')
.height('100%')
@ -479,7 +378,7 @@ export struct DevicePage {
}
private capsCount(): number {
return LOCAL_CAPS.length;
return LOCAL_DEVICE_CAPS.length;
}
// ===================== 二级:本机设备 =====================
@ -542,7 +441,7 @@ export struct DevicePage {
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_CAPS, (cap: string) => {
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
@ -567,39 +466,8 @@ export struct DevicePage {
this.KvRow('状态', this.bridgeConnected ? '已连接' : '未连接')
}
if (this.lastError.length > 0) {
Text(this.lastError)
.fontSize(12)
.fontColor('#E84026')
.padding({ left: 4 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -8 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
PlainCard({ caption: '操作' }) {
Row() {
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
// 因为父容器的 .animation() 到不了子节点。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button(this.bridgeConnected ? '断开' : '连接网关')
.height(34)
.fontSize(12)
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.border({
width: this.bridgeConnected ? 1 : 0,
color: this.palette().btnGhostBorder,
})
.onClick(() => {
if (this.bridgeConnected) {
deviceBridge.disconnect();
} else {
this.connectBridge();
}
})
}
Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备')
@ -615,7 +483,7 @@ export struct DevicePage {
}
.width('100%')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态bind 携带 Token 完成身份绑定。')
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })

View File

@ -11,7 +11,9 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR
import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from '../components/MotionBase';
import { GradientBackground } from '../components/GradientBackground';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
import { ScreensuePage } from '../components/ScreensuePage';
import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
@ -110,14 +112,14 @@ struct Index {
this.syncSystemBar();
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44
this.resolveSafeArea();
// 设备桥:注册命令路由与 screensue 悬浮层回调
installCmdRouter();
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
registerScreensueHandler((payload: ScreensuePayload) => {
this.showScreensue(payload);
});
markForegroundBridgeUIReady(getContext(this) as common.UIAbilityContext);
}
/** agent 下发的 screensue 内容展示(悬浮卡片,倒计时自动关闭0=常驻)。 */
/** agent 下发的 screensue 内容展示(窄屏整页、宽屏右栏0=常驻)。 */
private showScreensue(payload: ScreensuePayload): void {
this.screensueText = payload.content;
this.screensueCountdown = payload.duration;
@ -218,7 +220,7 @@ struct Index {
private updateWideScreen(w: number): void {
const wide: boolean = w >= WIDE_MIN_WIDTH;
if (wide !== this.isWide) {
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
AppStorage.set<boolean>('isWideScreen', wide);
}
}
@ -230,6 +232,10 @@ struct Index {
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/
onBackPress(): boolean {
if (this.screensueVisible) {
this.closeScreensue();
return true;
}
return handleBackPress(this.currentTab, this.isWide);
}
@ -339,51 +345,42 @@ struct Index {
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent)
// screensue 悬浮层agent 推送给用户看的内容(置顶展示)
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
// 左侧一级页面与主导航保持可见、可操作。
if (this.screensueVisible) {
Column() {
if (this.isWide) {
Row() {
Circle({ width: 8, height: 8 })
.fill(this.palette().accent)
Text('agent 推送')
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor(this.palette().textSecondary)
.margin({ left: 8 })
Blank()
if (this.screensueCountdown > 0) {
Text(this.screensueCountdown.toString() + 's')
.fontSize(11)
.fontColor(this.palette().textMuted)
}
Text('关闭')
.fontSize(12)
.fontColor(this.palette().accent)
.padding({ left: 10, right: 2, top: 4, bottom: 4 })
.onClick(() => {
Column()
.width(WIDE_NAV_BAR_WIDTH)
.height('100%')
.hitTestBehavior(HitTestMode.None)
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
this.closeScreensue();
})
},
})
.layoutWeight(1)
.height('100%')
.padding({ top: this.topInset })
}
.width('100%')
.margin({ bottom: 10 })
Scroll() {
Text(this.screensueText)
.fontSize(15)
.fontColor(this.palette().textPrimary)
.width('100%')
}
.constraintSize({ maxHeight: 320 })
.scrollBar(BarState.Auto)
.align(Alignment.Top)
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
} else {
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
this.closeScreensue();
},
})
.width('100%')
.height('100%')
.padding({ top: this.topInset })
}
.width('86%')
.padding(18)
.borderRadius(18)
.backgroundColor(this.palette().bgCard)
.border({ width: 1, color: this.palette().glassBorder })
.shadow({ radius: 32, color: this.palette().shadow, offsetY: 10 })
}
}
.width('100%')

View File

@ -13,6 +13,7 @@ import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../co
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
import { StatusSummaryCard, StatusDetailContent } from '../components/StatusCards';
import { statusStore } from '../common/StatusStore';
import { restartForegroundBridge } from '../common/DeviceBridgeSession';
/** One settings key card rendered in the editor list. */
interface SettingEntry {
@ -201,6 +202,7 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('已切换连接', false);
});
}
@ -224,6 +226,7 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已添加', false);
});
}
@ -242,6 +245,7 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已更新', false);
});
}
@ -278,10 +282,13 @@ export struct SettingsPage {
private deleteConnection(id: string): void {
connStore.deleteConnection(id).then(() => {
this.loadConnections();
const cur = connStore.getCurrentConnection();
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
} else {
apiClient.clearConnection();
}
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已删除', false);
});
}

View File

@ -17,6 +17,14 @@
},
{
"name": "ohos.permission.GET_NETWORK_INFO"
},
{
"name": "ohos.permission.READ_PASTEBOARD",
"reason": "$string:read_pasteboard_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
],
"abilities": [

View File

@ -3,6 +3,10 @@
{
"name": "app_name",
"value": "HomeAgent"
},
{
"name": "read_pasteboard_reason",
"value": "用于在应用前台按你的授权响应 agent 的剪贴板读取请求"
}
]
}

View File

@ -39,7 +39,7 @@ type msgEntry struct {
type daemonHandler struct {
// homed 连接
homeMu sync.Mutex
homeMu sync.Mutex
homeConn net.Conn
homeR *bufio.Reader
homeCfg *Config
@ -304,14 +304,14 @@ func startDaemonDeviceBridge(cfg *Config) {
if dg == "" || dt == "" {
return
}
// 设备桥重连循环WS 断开时自动重连
go runDeviceBridgeLoop(dg, dt)
// 设备桥重连循环WS 断开时自动重连,并保留配置中的本地授权状态。
go runDeviceBridgeLoop(dg, dt, cfg.DeviceAuthorized)
}
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
func runDeviceBridgeLoop(gateway, token string) {
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
for {
bridge, err := connectDeviceBridge(gateway, token)
bridge, err := connectDeviceBridge(gateway, token, authorized)
if err != nil {
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
time.Sleep(5 * time.Second)
@ -325,7 +325,7 @@ func runDeviceBridgeLoop(gateway, token string) {
}
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "local"
@ -355,12 +355,17 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
}
bridge := client.New(gw, token, deviceID, hostname, caps, info)
bridge.SetAuthorized(authorized)
// 注册命令处理器
// 注册命令处理器cmd_type 是主信号,同时兼容旧版 homeagent-* 文本前缀。
cr := client.NewCmdRouter()
cr.Handle("homeagent-", handleHomeagentCmd)
cr.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command string) {
bridge.OnCmd(func(reqID, command, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
cr.Dispatch(reqID, command)
})
@ -371,8 +376,6 @@ func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
// 设置全局变量供 sendBridgeResult 使用
deviceBridge = bridge
deviceBridgeID = deviceID
auth := true // daemon 模式默认授权(配置已指定)
bridge.SetAuthorized(auth)
return bridge, nil
}

View File

@ -61,13 +61,22 @@ func startDeviceBridge(addr, token string) error {
bridge := client.New(gateway, token, deviceID, "HomeAgent CLI", caps, info)
cmdRouter = client.NewCmdRouter()
// 注册命令处理器
// cmd_type 是主路由信号;保留 homeagent-* 文本前缀兼容旧服务端。
cmdRouter.Handle("homeagent-", handleHomeagentCmd)
cmdRouter.HandleDefault(handleShellCmd)
bridge.OnCmd(func(reqID, command string) {
bridge.OnCmd(func(reqID, command, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
cmdRouter.Dispatch(reqID, command)
})
// agent 主动投递output_send__device/<id>)→ 终端显示。
// 设备侧参考实现:文本/结构化直接打出来;二进制负载走 OnDataTTS 音频等)。
bridge.OnPush(func(reqID, typ, payload, meta string) {
printlnC("cyan", fmt.Sprintf("[push:%s] %s", typ, payload))
})
if err := bridge.Start(); err != nil {
return fmt.Errorf("device bridge: %w", err)
}
@ -767,4 +776,4 @@ func sanitizeID(s string) string {
}
}
return sb.String()
}
}

View File

@ -6,9 +6,11 @@ Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/homed -data /var/lib/homeagent
ExecStart=/usr/bin/homed -data /var/lib/homeagent
Restart=always
RestartSec=10
Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime
StateDirectory=homeagent
StartLimitBurst=3
StartLimitInterval=60s
@ -26,7 +28,9 @@ DeviceAllow=/dev/dsp rw
# Resource limits
LimitNOFILE=65536
LimitNPROC=256
MemoryMax=2G
# Chinese-CLIP 本身实测约 1.15GB;加文档稠密索引、词向量、插件与
# ORT arena 后生产实例约 4.5GB。2GB 会在首次全量建索引时被 cgroup OOM。
MemoryMax=8G
CPUQuota=100%
[Install]

View File

@ -15,6 +15,13 @@ BUILD_TIME="${BUILD_TIME:-$(date -u '+%Y-%m-%dT%H:%M:%SZ')}"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
LDFLAGS="-X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=${VERSION} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Commit=${COMMIT} -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.BuildTime=${BUILD_TIME}"
# 版本与提交的**权威来源**是上面注入的 meta.Version / meta.Commit不是 Go 自带的
# VCS 戳。后者不进 build cache keyGo 文档明确说明 VCS 变化不会触发重建),
# 命中缓存时会把上一次的 revision 一并带回来——实测发布分支的产物上就出现了
# 1715b5c本机任何仓库都不存在的提交用 `go version -m` 溯源会指向幽灵提交。
# 统一 -buildvcs=false宁可没有这个信号也不要一个错的。
# 溯源请用:`strings homed | grep -m1 '^<短 hash>$'`meta.Commit 是字符串常量)。
TARGET="${1:-native}"
COMPONENT="${2:-all}"
@ -27,10 +34,19 @@ COMPONENT="${2:-all}"
case "$TARGET" in
native) GOOS="" GOARCH="" ;;
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
# arm64 刻意不设 CXX设了会让 Go 用 aarch64 的 g++ 去链接,
# 而它对 host 产生的 .o 报 "file format not recognized"
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译gcc 能编 C++)。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
# arm64 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 用宿主 g++ 编出
# x86-64 的 .o链接时报 "Relocations in generic ELF (EM: 183)"183 = aarch64
#
# 此处曾有一条注释写着「arm64 刻意不设 CXX」理由是设了会报
# "file format not recognized"。那个判断是错的:那个报错的真因是
# cmd/{homed,waiter}/*.sysox86-64 COFF Windows 资源对象)被链进了目标,
# 与 CXX 无关。四组对照:
# syso 在 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 在 + 有 CXX → 000000.o: file format not recognized
# syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 隐藏 + 有 CXX → 成功ELF aarch64
# 本脚本的 hide_syso_for_target 已处理前一个条件,这里补上后一个。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
# Windows 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++
@ -46,7 +62,7 @@ case "$TARGET" in
;;
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
echo " [all|homed|waiter|initconfig|gui]"
exit 1
esac
@ -109,8 +125,21 @@ build_homed() {
# Go 用 CC 驱动 CGO 编译与链接,用 CC 指定的交叉工具链来决定目标架构。
# 必须同时 export CC 给 Go 的 CGO 代码生成器,否则 CGO_ENABLED=1 下的
# 目标文件与 host 的 ld 不兼容(如 arm64 的 .o 给了 x86_64 的 ld
#
# HOMED_TAGS 默认带 onnxruntime发行版**默认启用**本地向量空间。
# 不带这个标签时 providers/chineseclip 与 providers/qwen3vl 仍会注册,
# 但打开时报「requires build tag」并优雅降级不静默假装成功
# 需要极简构建时可显式 HOMED_TAGS= 关掉。
#
# 运行期还需要 libonnxruntime.soprovider 按 /opt/onnxruntime、
# /usr/local/lib、/usr/lib 顺序查找);缺失时同样是「日志里的明确错误 +
# 降级」,不会假装启用。
local _cc="${CC:-cc}"
CGO_ENABLED=1 CC="$_cc" "$GO" build -trimpath -installsuffix dynlink \
local _tags="${HOMED_TAGS-onnxruntime}"
local -a _tagargs=()
if [ -n "$_tags" ]; then _tagargs=(-tags "$_tags"); fi
CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
${_tagargs[@]+"${_tagargs[@]}"} \
-ldflags "$LDFLAGS" -o "$out" ./cmd/homed/
echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))"
}
@ -122,28 +151,74 @@ build_waiter() {
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] waiter ${plat}$out"
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
CGO_ENABLED=0 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/waiter/
echo " OK ($(du -h "$out" | cut -f1))"
}
# ---- initconfig (CGO-free 配置初始化器) ----
# ---- initconfig(必须 cgo写 config.db 用的是 go-sqlite3----
#
# NSIS 安装包installer.nsi:220 File "..\build\initconfig.exe")与
# package-linux.sh 的 stage_variant 都引用它,但此前 build.sh 从不构建它——
# Windows 安装包构建会直接失败在缺文件上。
# 这里**必须** CGO_ENABLED=1。此前写的是 CGO_ENABLED=0而 cmd/initconfig 通过
# database/sql 使用 mattn/go-sqlite3CGO_ENABLED=0 时该库退化成 static_mock.go
# 里的桩sql.Open 是懒的所以不报错、第一次 Exec 才失败;而 main.go 当时忽略
# 了所有错误——于是 initconfig 打印凭据、退出码 0、一个字节都没写进 config.db。
# 安装脚本把这份凭据写进 credentials.txt用户照它登录必然失败全程无报错。
#
# NSIS 安装包installer.nsi与 package-linux.sh 的 stage_variant 都引用它,
# 但此前 build.sh 从不构建它——Windows 安装包构建会直接失败在缺文件上。
build_initconfig() {
local plat="${GOOS:-linux}/${GOARCH:-amd64}"
local out="$BUILD_DIR/initconfig${SUFFIX:+_$SUFFIX}"
if [ "$GOOS" = "windows" ]; then out="${out}.exe"; fi
echo "[BUILD] initconfig ${plat}$out"
CGO_ENABLED=0 "$GO" build -trimpath -installsuffix dynlink \
CGO_ENABLED=1 "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \
-ldflags "$LDFLAGS" -o "$out" ./cmd/initconfig/
echo " OK ($(du -h "$out" | cut -f1))"
}
# ---- linux-payload给 Windows 安装器用的 Linux 包)----
#
# Windows 不再安装 homed.exehomed 依赖 fd 继承 + 统一共享内存区的段内偏移
# 解引用Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go
# Windows 安装器改为引导到 WSL2并把 **Linux 包**送进发行版里安装。
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
# build/linux-payload/installer.nsi 从这里 File /r 打进安装包)。
#
# 复用 package-linux.sh 的产物而不是在这里另行编译WSL 里跑的就是普通
# linux/amd64安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
stage_linux_payload() {
local src="$PROJECT_ROOT/dist/linux"
local out="$BUILD_DIR/linux-payload"
rm -rf "$out"
mkdir -p "$out"
local found=0
for f in "$src"/*.deb "$src"/*.tar.gz; do
[ -f "$f" ] || continue
cp "$f" "$out/"
found=$((found + 1))
done
if [ "$found" -eq 0 ]; then
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
echo " Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
return 1
fi
echo "[BUILD] linux-payload ← $found 个包"
ls -1 "$out" | sed 's/^/ /'
}
# ---- gui (Electron) ----
#
# 输出目录必须用 --config.directories.output**不能用 -o**
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
# Unknown target: /home/program/trueagent/build
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
build_gui() {
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
@ -161,22 +236,52 @@ build_gui() {
# 不传 --configelectron-builder 默认从 package.json 的 "build" 键读配置。
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
(cd "$gui_dir" && npx electron-builder \
--linux --win --mac \
--x64 --arm64 \
-p never \
-o "$BUILD_DIR")
echo " OK"
#
# GUI 失败不中断整体构建homed/waiter/initconfig 是发布的主体,
# 而 GUI 依赖 electron 运行时下载离线机器、arm64 缺缓存都会失败)。
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
if (cd "$gui_dir" && npx electron-builder \
--linux --win --mac \
--x64 --arm64 \
-p never \
--config.directories.output="$BUILD_DIR"); then
echo " OK"
else
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig"
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
return 0
fi
}
# ---- dispatch ----
case "$COMPONENT" in
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
esac
if [ "${GOOS:-}" = "windows" ]; then
# Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
# 需要的是Linux 包(送进 WSL 安装)+ Windows 侧客户端waiter CLI / GUI
case "$COMPONENT" in
all) build_waiter; stage_linux_payload; build_gui ;;
waiter) build_waiter ;;
payload) stage_linux_payload ;;
gui) build_gui ;;
homed|initconfig)
echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2或用 linux/amd64 目标)。" >&2
echo "原因见 cmd/homed/platform_windows.go。" >&2
exit 1
;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
else
case "$COMPONENT" in
all) build_homed; build_waiter; build_initconfig; build_gui ;;
homed) build_homed ;;
waiter) build_waiter ;;
initconfig) build_initconfig ;;
gui) build_gui ;;
*)
echo "Unknown component: $COMPONENT"
exit 1
;;
esac
fi

View File

@ -76,6 +76,10 @@ Function GenKey
FunctionEnd
!insertmacro MUI_PAGE_WELCOME
; 许可页AGPL-3.0-only全文在仓库根 LICENSE
; NSIS 的 File/!insertmacro 相对路径以**本 .nsi 所在目录**为基准解析,
; 而本文件在 deploy/packaging/,故仓库根是 ..\..\ 。
!insertmacro MUI_PAGE_LICENSE "..\..\LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!if "${HAS_CREDENTIALS}" == "1"
@ -216,17 +220,26 @@ FunctionEnd
Section "Install" SEC_INSTALL
SetOutPath "$INSTDIR"
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
CreateDirectory "$INSTDIR\data"
CreateDirectory "$INSTDIR\data\log"
CreateDirectory "$INSTDIR\data\plugins"
CreateDirectory "$INSTDIR\data\adapters"
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
; 解引用Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go
; Windows 侧改为引导到 WSL2把 **Linux 包**送进发行版里按 Linux 的方式安装。
; 所以这里带的是 linux/amd64 的 payload不是 homed.exe。
!if "${HAS_CORE}" == "1"
File "..\..\build\initconfig.exe"
File "..\..\build\homed.exe"
SetOutPath "$PLUGINSDIR\linux-payload"
File /r "..\..\build\linux-payload\*.*"
SetOutPath "$INSTDIR"
!endif
!if "${HAS_WAITER}" == "1"
; waiter 是 CLI 客户端WSL 侧会装上 Linux 版Windows 侧仍可保留原生版
; (它只是个客户端,不走插件体系)。
File "..\..\build\waiter.exe"
!endif
@ -237,11 +250,24 @@ Section "Install" SEC_INSTALL
!endif
!if "${HAS_CORE}" == "1"
DetailPrint "初始化配置数据库..."
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"'
; 在 WSL2 里安装 homed。凭据页面上收的那三个透传进去避免
; 「界面显示一份、config.db 里另一份」导致登录不上。
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
Pop $0
Pop $1
${If} $0 != 0
DetailPrint "警告: 数据库初始化可能未成功完成"
; 退出码含义见 install-via-wsl.ps120/21 是「WSL 或发行版缺失,需要先装」,
; 属于可指引的用户动作,不当成安装失败来恐吓人。
${If} $0 == 20
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows再重新运行本安装程序。"
${ElseIf} $0 == 21
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
${Else}
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0。$\r$\n$\r$\n可进入 WSL 手动排查wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
${EndIf}
${Else}
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
${EndIf}
!endif

View File

@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
Architecture: ARCH_PLACEHOLDER
Maintainer: HomeAgent Team <team@homeagent.ai>
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
Depends: libc6 (>= 2.28)
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
Section: utils
Priority: optional
Homepage: https://github.com/trueagent/HomeAgent

View File

@ -3,7 +3,7 @@ Version: VERSION_PLACEHOLDER
Architecture: ARCH_PLACEHOLDER
Maintainer: HomeAgent Team <team@homeagent.ai>
Installed-Size: INSTALLED_SIZE_PLACEHOLDER
Depends: libc6 (>= 2.28)
Depends: libc6 (>= 2.28), libstdc++6, libgcc-s1
Section: utils
Priority: optional
Homepage: https://github.com/trueagent/HomeAgent

View File

@ -2,24 +2,63 @@
set -e
SERVICE_NAME="homeagent"
SERVICE_FILE="/lib/systemd/system/${SERVICE_NAME}.service"
HOMED_BIN="/usr/bin/homed"
DATA_DIR="/var/lib/homeagent"
SETUP_SH="/usr/lib/homeagent/setup.sh"
# unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查
# /lib/systemd/system/merged-usr 下等于 /usr/lib/systemd/system那里没有
# 这个文件)——于是 daemon-reload 与 enable **从未执行过**:装完不会开机自启,
# 而 postinst 全程无报错。这里三个候选位置都看一下。
find_unit() {
for p in "/etc/systemd/system/${SERVICE_NAME}.service" \
"/usr/lib/systemd/system/${SERVICE_NAME}.service" \
"/lib/systemd/system/${SERVICE_NAME}.service"; do
if [ -f "$p" ]; then
printf '%s' "$p"
return 0
fi
done
return 1
}
case "$1" in
configure)
if [ -f "$HOMED_BIN" ]; then
mkdir -p "$DATA_DIR"
# 初始化凭据和数据库
if [ -x /usr/lib/homeagent/setup.sh ]; then
HOMEAGENT_DATA="$DATA_DIR" /usr/lib/homeagent/setup.sh || true
# 初始化凭据和数据库
#
# 这里不能再用 `|| true` 吞失败setup.sh 靠 initconfig 写 config.db
# 而 initconfig 曾因 CGO_ENABLED=0 静默空操作(凭据只进了
# credentials.txt、没进数据库用户拿它登录必然失败安装却一声不响。
# 失败必须看得见,并给出可直接执行的补救命令。
if [ -x "$SETUP_SH" ]; then
if ! HOMEAGENT_DATA="$DATA_DIR" "$SETUP_SH"; then
echo "E: homeagent 初始化失败——凭据可能未写入 config.db。" >&2
echo "E: 请手动重试HOMEAGENT_DATA=$DATA_DIR $SETUP_SH" >&2
fi
else
echo "W: 未找到 $SETUP_SH跳过凭据初始化。" >&2
fi
# 注册 systemd 服务
if [ -f "$SERVICE_FILE" ]; then
systemctl daemon-reload 2>/dev/null || true
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
if command -v systemctl >/dev/null 2>&1; then
if find_unit >/dev/null; then
systemctl daemon-reload 2>/dev/null || true
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
# 首次安装就拉起来,装完即可用;升级时重启以真正加载新二进制
# (仅 enable 不会让已在运行的进程换用新文件)。
if [ -z "${2:-}" ]; then
systemctl start "$SERVICE_NAME" 2>/dev/null || \
echo "W: homeagent 服务未能启动请检查systemctl status $SERVICE_NAME" >&2
else
systemctl restart "$SERVICE_NAME" 2>/dev/null || \
echo "W: homeagent 服务未能重启请检查systemctl status $SERVICE_NAME" >&2
fi
else
echo "W: 未找到 ${SERVICE_NAME}.service未启用服务。" >&2
fi
fi
fi
;;

View File

@ -8,16 +8,32 @@ CRED_FILE="${DATA_DIR}/credentials.txt"
CONFIG_DB="${DATA_DIR}/config.db"
WAITER_CONF="${DATA_DIR}/waiter.yaml"
INITCONFIG_BIN="/usr/bin/initconfig"
BUNDLED_MODEL_DIR="/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
MODEL_LINK="${DATA_DIR}/models/chinese-clip-vit-b16-onnx"
# 如果已经初始化过,跳过
# 模型随 server/full 包安装到只读的 /usr/lib配置默认仍指向 dataDir/models。
# 用符号链接把两者接起来,既不复制 754MB也保持 dataDir 可迁移语义。
# 用户已有自定义目录时绝不覆盖;升级时既有链接自然指向新版包内容。
if [ -d "$BUNDLED_MODEL_DIR" ]; then
mkdir -p "${DATA_DIR}/models"
if [ ! -e "$MODEL_LINK" ] && [ ! -L "$MODEL_LINK" ]; then
ln -s "$BUNDLED_MODEL_DIR" "$MODEL_LINK"
fi
fi
# 如果已经初始化过,只跳过凭据/数据库生成;上面的模型链接仍须在升级时补齐。
if [ -f "$CONFIG_DB" ] && [ -f "$CRED_FILE" ]; then
exit 0
fi
mkdir -p "$DATA_DIR"
# 生成随机凭据
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")
# 生成随机凭据
#
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
WEBUI_USER="${WEBUI_USER:-admin}"
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"

View File

@ -5,23 +5,56 @@ PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BUILD_DIR="${PROJECT_ROOT}/build"
DIST_DIR="${PROJECT_ROOT}/dist/linux"
VERSION="${VERSION:-$(git -C "$PROJECT_ROOT" describe --tags --dirty 2>/dev/null || echo "0.8.0")}"
# git describe 给出的是 v1.0.0-68-gba0b5a1-dirty 这类描述串,它不是合法的包版本:
# deb 的 Version 必须以数字开头rpm 的 Version 不允许 '-'(那是版本/发布的分隔符)。
# 以前只有显式传 VERSION=1.0.3 才打得出来,默认路径一跑就死在 dpkg-deb 上——
# 而且死在 stage 之后,前面每条日志都是真的,只有最后一个产物没生成。
PKG_VERSION="${VERSION#v}"
case "$PKG_VERSION" in
[0-9]*) ;;
*) echo "ERROR: 包版本必须以数字开头(得到 '$VERSION')。请显式设置 VERSION=x.y.z 后重试。" >&2; exit 1 ;;
esac
PKG_VERSION="$(printf '%s' "$PKG_VERSION" | sed -e 's/-/+/g')"
PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
ARCH="${1:-amd64}" # amd64 or arm64
# server/full 发行包默认带 Chinese-CLIP ONNX 产物与 ONNX Runtime。
# 二进制大资产不进 git发布环境通过这两个目录提供已验证的产物若缺失
# server/full 打包必须明确失败,不能生成一个「默认启用但装完不能用」的假包。
CHINESECLIP_BUNDLE_DIR="${CHINESECLIP_BUNDLE_DIR:-$BUILD_DIR/model-assets/chinese-clip-vit-b16-onnx}"
ONNXRUNTIME_ASSET_DIR="${ONNXRUNTIME_ASSET_DIR:-$BUILD_DIR/runtime-assets/$ARCH}"
ONNXRUNTIME_LIB="${ONNXRUNTIME_LIB:-$ONNXRUNTIME_ASSET_DIR/libonnxruntime.so}"
ONNXRUNTIME_LICENSE="${ONNXRUNTIME_LICENSE:-$ONNXRUNTIME_ASSET_DIR/LICENSE}"
ONNXRUNTIME_NOTICES="${ONNXRUNTIME_NOTICES:-$ONNXRUNTIME_ASSET_DIR/ThirdPartyNotices.txt}"
# 打包 staging 会把 719MB 模型真的复制一份,临时目录必须落在构建目录所在的磁盘,
# 不能落在系统临时目录:本机 /tmp 是 9.8GB tmpfs一次 full 包 staging 就能写满,
# 而且失败发生在 cp 进行到一半,报出来是 "No space left on device"——看上去像
# 资产/版本有问题,实际只是临时目录选错了文件系统。
STAGE_TMP="${BUILD_DIR}/.stage-tmp"
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
# 两者在 arm64 上恰好同名amd64 上不同——此前缓存查找统一用 TAR_ARCH
# amd64于是 electron-v*-linux-x64.zip 永远命中不到amd64 GUI 只能
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
# node_modulesGUI 就被静默跳过。故单独映射。
ACTION="${2:-all}" # all, build, deb, tar, rpm
DEB_ARCH="$ARCH"
RPM_ARCH="$ARCH"
TAR_ARCH="$ARCH"
case "$ARCH" in
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;;
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
esac
echo "=== HomeAgent Linux Packager ==="
echo "Version: $VERSION"
[ "$PKG_VERSION" = "$VERSION" ] || echo "Package: $PKG_VERSION (normalized for deb/rpm)"
echo "Arch: $ARCH"
echo ""
@ -83,7 +116,7 @@ restore_syso() {
}
# ensure both are always restored on exit
restore_all() { restore_gomod; restore_syso; }
restore_all() { restore_gomod; restore_syso; rmdir "$STAGE_TMP" 2>/dev/null || true; }
trap restore_all EXIT
# ---- build Go binaries via existing build.sh ----
@ -93,35 +126,42 @@ build_go() {
prepare_gomod || true
hide_syso
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed" 2>&1 || {
echo "WARNING: homed build failed (CGO/sqlite3 issue). Server/full packages may be incomplete."
}
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter" 2>&1 || {
echo "WARNING: waiter build failed."
}
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig" 2>&1 || {
echo "WARNING: initconfig build failed包内将缺少首次配置初始化器。"
}
local suffix="linux_${ARCH}"
local homed_bin="$BUILD_DIR/homed_$suffix"
local waiter_bin="$BUILD_DIR/waiter_$suffix"
local initconfig_bin="$BUILD_DIR/initconfig_$suffix"
if [ ! -f "$homed_bin" ]; then
echo "ERROR: homed binary not found at $homed_bin"
exit 1
fi
if [ ! -f "$waiter_bin" ]; then
echo "ERROR: waiter binary not found at $waiter_bin"
exit 1
# 先删旧产物:否则本次构建失败后,残留文件会让「产物存在」判据假绿。
rm -f "$homed_bin" "$waiter_bin" "$initconfig_bin"
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "homed"
test -x "$homed_bin"
if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包:$homed_bin" >&2
return 1
fi
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1))"
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "waiter"
test -x "$waiter_bin"
bash "$PROJECT_ROOT/deploy/packaging/build.sh" "linux/$ARCH" "initconfig"
test -x "$initconfig_bin"
echo " homed: $homed_bin ($(du -h "$homed_bin" | cut -f1), onnxruntime)"
echo " waiter: $waiter_bin ($(du -h "$waiter_bin" | cut -f1))"
echo " initconfig: $initconfig_bin ($(du -h "$initconfig_bin" | cut -f1))"
echo ""
}
# ---- build GUI (manual directory assembly, avoids electron-packager network issues) ----
#
# electron 运行时必须按**目标架构**取,不能用 host 的
# node_modules/electron/dist——那里永远是 host 架构(本机 x64
# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带
# -arm64、homed/waiter 确实是 aarch64但里面的 electron 是 x86-64
# 在 arm64 机器上一启动就是 Exec format error从未被交叉验证过
#
# 现在改为优先从 electron 缓存里取对应架构的 zip并在最后做
# 一道强制校验:架构不符就删掉目录并跳过 GUI宁可不发也不发坏包。
build_gui() {
local gui_dir="$PROJECT_ROOT/cmd/gui"
local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
@ -133,22 +173,81 @@ build_gui() {
echo ">>> Building GUI directory for linux/$ARCH..."
if [ ! -d "$gui_dir/node_modules" ]; then
# 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
#
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
# node_modules目录存在但 electron 缺失。只看目录会以为"已安装"
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
# 静默跳过 GUI——包名和目录名全都正确只是没有 GUI没有任何一步报错。
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
if [ -d "$gui_dir/node_modules" ]; then
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
fi
echo " npm install..."
(cd "$gui_dir" && npm install --production)
if ! (cd "$gui_dir" && npm install --production); then
echo " WARNING: npm install 失败——离线环境下这是预期的。"
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
fi
fi
local electron_dir="$gui_dir/node_modules/electron/dist"
if [ ! -f "$electron_dir/electron" ]; then
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
return
# electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
local ever
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
if [ -z "$ever" ]; then
ever=$(python3 -c "
import json, re
d = json.load(open('$gui_dir/package.json'))
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
print(m.group(1) if m else '')
" 2>/dev/null || true)
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
fi
mkdir -p "$gui_out"
# 优先:缓存里的目标架构 zip~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip
local zip=""
if [ -n "$ever" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -z "$zip" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -n "$zip" ]; then
echo " electron runtime: $(basename "$zip")"
unzip -q -o "$zip" -d "$gui_out"
else
# 回退:仅当目标架构 == host 架构时才能用 host 的 dist
local host_arch
case "$(uname -m)" in
x86_64) host_arch=amd64 ;;
aarch64|arm64) host_arch=arm64 ;;
*) host_arch=unknown ;;
esac
if [ "$TAR_ARCH" != "$host_arch" ]; then
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
rm -rf "$gui_out"
return
fi
local electron_dir="$gui_dir/node_modules/electron/dist"
if [ ! -f "$electron_dir/electron" ]; then
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
rm -rf "$gui_out"
return
fi
echo " electron runtime: host node_modules (同架构 $host_arch)"
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
fi
mkdir -p "$gui_out/resources/app/node_modules"
mkdir -p "$gui_out/resources/app/renderer"
# copy electron runtime (binary + shared libs)
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
rm -f "$gui_out/resources/default_app.asar" 2>/dev/null
# copy app source
@ -190,7 +289,28 @@ LAUNCHER
chmod +x "$gui_out/homeagent-gui"
chmod +x "$gui_out/electron"
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))"
# 最后一道强制校验electron 二进制的实际架构必须匹配目标架构。
# 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、
# homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上
# 双击才发现 Exec format error。
local want_pat
case "$TAR_ARCH" in
amd64) want_pat="x86-64" ;;
arm64) want_pat="aarch64" ;;
*) want_pat="" ;;
esac
if [ -n "$want_pat" ]; then
local got
got=$(file -b "$gui_out/electron" 2>/dev/null || echo "")
if ! printf '%s' "$got" | grep -q "$want_pat"; then
echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}"
echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。"
rm -rf "$gui_out"
return
fi
fi
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))"
echo ""
}
@ -215,6 +335,8 @@ stage_variant() {
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
stage_setup "$staging"
stage_license "$staging"
stage_multimodal_assets "$staging"
stage_gui "$staging"
;;
server)
@ -223,9 +345,12 @@ stage_variant() {
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
[ -f "$initconfig_bin" ] && cp "$initconfig_bin" "$staging/usr/bin/initconfig"
stage_setup "$staging"
stage_license "$staging"
stage_multimodal_assets "$staging"
;;
client)
cp "$BUILD_DIR/waiter_$suffix" "$staging/usr/bin/waiter"
stage_license "$staging"
stage_gui "$staging"
;;
esac
@ -260,6 +385,127 @@ stage_setup() {
fi
}
# 项目自身的许可:**所有变体**都要带client 也分发 waiter 与 GUI
#
# deb 按 Debian 惯例给 /usr/share/doc/homeagent/copyrightDEP-5 机器可读格式),
# 同时把 LICENSE 全文放进去rpm 的许可走 fpm 的 --license 元数据。
# 与 stage_multimodal_assets 的 licenses/ 分工:那里放**第三方**(模型/运行库)的
# 许可全文,这里放本项目自己的。
stage_license() {
local staging="$1"
local docdir="$staging/usr/share/doc/homeagent"
mkdir -p "$docdir"
cp "$PROJECT_ROOT/LICENSE" "$docdir/LICENSE"
cat > "$docdir/copyright" <<'EOF'
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: HomeAgent
Source: https://gitcode.com/JianFeeeee/HomeAgent
Files: *
Copyright: HomeAgent contributors
License: AGPL-3.0-only
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU Affero General Public License as published by the Free
Software Foundation, version 3 of the License.
.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
details.
.
You should have received a copy of the GNU Affero General Public License along
with this program. If not, see <https://www.gnu.org/licenses/>.
.
The license is AGPL-3.0-only: no later version may be chosen. Note the network
clause (§13 Remote Network Interaction) — offering modified versions of this
software to users over a network also requires offering them the source.
.
Full text: /usr/share/doc/homeagent/LICENSE
Files: usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/*
Copyright: OFA-Sys / Chinese-CLIP authors
License: Apache-2.0
Full text: /usr/share/doc/homeagent/licenses/Chinese-CLIP-Apache-2.0.txt
Comment: pre-trained model artifacts; NOT covered by this package's AGPL grant
Files: usr/lib/homeagent/onnxruntime/*
Copyright: Microsoft Corporation
License: MIT
Full text: /usr/share/doc/homeagent/licenses/ONNX-Runtime-MIT.txt
Comment: license texts and third-party notices under licenses/ONNX-Runtime-*
EOF
chmod 644 "$docdir/LICENSE" "$docdir/copyright"
}
# server/full 的 ONNX 资产。模型与运行库是发行版能力的一部分,不是可选下载:
# 只要打 server/full 包两者缺一就失败。client 包不运行 homed故不携带。
stage_multimodal_assets() {
local staging="$1"
local model_dst="$staging/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx"
local ort_dst="$staging/usr/lib/homeagent/onnxruntime"
local licenses="$staging/usr/share/doc/homeagent/licenses"
if [ ! -d "$CHINESECLIP_BUNDLE_DIR" ]; then
echo "ERROR: Chinese-CLIP 产物目录不存在:$CHINESECLIP_BUNDLE_DIR" >&2
echo "先运行 scripts/export_chineseclip_onnx.py再通过 CHINESECLIP_BUNDLE_DIR 指向产物。" >&2
return 1
fi
for f in TextEncoder.onnx VisionEncoder.onnx embed_config.json vocab.txt reference.json SHA256SUMS; do
if [ ! -s "$CHINESECLIP_BUNDLE_DIR/$f" ]; then
echo "ERROR: Chinese-CLIP 产物缺少或为空:$CHINESECLIP_BUNDLE_DIR/$f" >&2
return 1
fi
done
if ! (cd "$CHINESECLIP_BUNDLE_DIR" && sha256sum -c SHA256SUMS); then
echo "ERROR: Chinese-CLIP SHA256SUMS 校验失败,拒绝打包。" >&2
return 1
fi
if [ ! -s "$ONNXRUNTIME_LIB" ]; then
echo "ERROR: ONNX Runtime 不存在:$ONNXRUNTIME_LIB" >&2
echo "通过 ONNXRUNTIME_ASSET_DIR 或 ONNXRUNTIME_LIB 指向与目标架构匹配的资产。" >&2
return 1
fi
for notice in "$ONNXRUNTIME_LICENSE" "$ONNXRUNTIME_NOTICES"; do
if [ ! -s "$notice" ]; then
echo "ERROR: ONNX Runtime 许可证资产缺失:$notice" >&2
return 1
fi
done
local runtime_desc
runtime_desc=$(file -b "$ONNXRUNTIME_LIB")
case "$ARCH" in
amd64) printf '%s' "$runtime_desc" | grep -qE 'x86-64|x86_64' || {
echo "ERROR: ONNX Runtime 架构不是 amd64$runtime_desc" >&2; return 1; } ;;
arm64) printf '%s' "$runtime_desc" | grep -qE 'aarch64|ARM aarch64' || {
echo "ERROR: ONNX Runtime 架构不是 arm64$runtime_desc" >&2; return 1; } ;;
esac
mkdir -p "$model_dst" "$ort_dst" "$licenses"
cp -a "$CHINESECLIP_BUNDLE_DIR/." "$model_dst/"
install -m 0755 "$ONNXRUNTIME_LIB" "$ort_dst/libonnxruntime.so"
# 许可证随二进制分发Chinese-CLIP = Apache-2.0ONNX Runtime = MIT
# 同时携带其 ThirdPartyNotices含 MKL/protobuf/zlib 等第三方条款)。
cp /usr/share/common-licenses/Apache-2.0 "$licenses/Chinese-CLIP-Apache-2.0.txt"
cp "$ONNXRUNTIME_LICENSE" "$licenses/ONNX-Runtime-MIT.txt"
cp "$ONNXRUNTIME_NOTICES" "$licenses/ONNX-Runtime-ThirdPartyNotices.txt"
cat > "$licenses/MODEL-SOURCES.txt" <<EOF
Chinese-CLIP ViT-B/16
upstream: https://huggingface.co/OFA-Sys/chinese-clip-vit-base-patch16
license: Apache-2.0
exported-by: scripts/export_chineseclip_onnx.py
dimensions: 512
modalities: text,image
ONNX Runtime
upstream: https://github.com/microsoft/onnxruntime
license: MIT (see ONNX-Runtime-MIT.txt and ONNX-Runtime-ThirdPartyNotices.txt)
EOF
echo " ONNX assets: model=$(du -sh "$model_dst" | cut -f1) runtime=$(du -h "$ort_dst/libonnxruntime.so" | cut -f1)"
}
# ---- create .deb ----
build_deb() {
local variant="$1"
@ -267,9 +513,9 @@ build_deb() {
local deb_dir="${DIST_DIR}/deb"
mkdir -p "$deb_dir"
local pkg_name="homeagent-${variant}_${VERSION}_${DEB_ARCH}.deb"
local pkg_name="homeagent-${variant}_${PKG_VERSION}_${DEB_ARCH}.deb"
local deb_root
deb_root="$(mktemp -d)"
deb_root="$(mktemp -d "$STAGE_TMP/deb.XXXXXX")"
mkdir -p "$deb_root/DEBIAN"
@ -277,7 +523,7 @@ build_deb() {
local installed_size_kb
installed_size_kb=$(du -sk "$staging" | cut -f1)
sed -e "s/VERSION_PLACEHOLDER/$VERSION/g" \
sed -e "s/VERSION_PLACEHOLDER/$PKG_VERSION/g" \
-e "s/ARCH_PLACEHOLDER/$DEB_ARCH/g" \
-e "s/INSTALLED_SIZE_PLACEHOLDER/$installed_size_kb/g" \
"$control_file" > "$deb_root/DEBIAN/control"
@ -306,13 +552,13 @@ build_tar() {
local tar_dir="${DIST_DIR}/tar"
mkdir -p "$tar_dir"
local archive_name="homeagent_${VERSION}_linux_${TAR_ARCH}.tar.gz"
local archive_dir="homeagent-${VERSION}-linux-${TAR_ARCH}"
local archive_name="homeagent_${PKG_VERSION}_linux_${TAR_ARCH}.tar.gz"
local archive_dir="homeagent-${PKG_VERSION}-linux-${TAR_ARCH}"
# build combined staging
local staging
staging="$(mktemp -d)"
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent"
staging="$(mktemp -d "$STAGE_TMP/tar.XXXXXX")"
mkdir -p "$staging/usr/bin" "$staging/usr/lib/homeagent" "$staging/etc/systemd/system"
# copy all available binaries
for bin in homed waiter initconfig; do
@ -323,6 +569,9 @@ build_tar() {
# setup script
local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh"
[ -f "$setup_src" ] && cp "$setup_src" "$staging/usr/lib/homeagent/setup.sh"
cp "$PROJECT_ROOT/deploy/homeagent.service" "$staging/etc/systemd/system/homeagent.service"
stage_license "$staging"
stage_multimodal_assets "$staging"
# GUI if available
local gui_src="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
@ -351,7 +600,7 @@ build_rpm() {
local rpm_dir="${DIST_DIR}/rpm"
mkdir -p "$rpm_dir"
local pkg_name="homeagent-${variant}-${VERSION}-1.${RPM_ARCH}.rpm"
local pkg_name="homeagent-${variant}-${PKG_VERSION}-1.${RPM_ARCH}.rpm"
# find fpm
local fpm_bin="$(command -v fpm 2>/dev/null || true)"
@ -400,7 +649,7 @@ build_rpm() {
-a "$RPM_ARCH" \
--description "HomeAgent ${variant^} package" \
--url "https://github.com/trueagent/HomeAgent" \
--license "Proprietary" \
--license "AGPL-3.0-only" \
-C "$staging" \
-p "$rpm_dir/$pkg_name" \
. 2>&1
@ -411,7 +660,7 @@ build_rpm() {
main() {
local target_arch="$ARCH"
mkdir -p "$BUILD_DIR"
mkdir -p "$BUILD_DIR" "$STAGE_TMP"
case "$ACTION" in
all|build)
@ -428,6 +677,10 @@ main() {
mkdir -p "$DIST_DIR"
# 上次成功构建留下的校验和必须在本次开工前删掉:本次若中途失败,脚本直接退出、
# 不重算 SHA256SUMS旧的它会一直躺在 dist 里,看上去像在为这一批残缺产物背书。
rm -f "$DIST_DIR/SHA256SUMS"
for variant in full server client; do
echo ""
echo "=============================================="
@ -435,7 +688,7 @@ main() {
echo "=============================================="
local staging
staging=$(mktemp -d)
staging=$(mktemp -d "$STAGE_TMP/stage.XXXXXX")
stage_variant "$variant" "$staging"
case "$ACTION" in
@ -457,9 +710,26 @@ main() {
echo "=== Done! Packages in: $DIST_DIR ==="
echo ""
echo "Summary:"
find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) 2>/dev/null | sort | while read -r f; do
# 只列**本批**产物dist/ 会跨多次构建累积,用 find 全目录会让清单SHA256SUMS
# 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。
# v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "homeagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort)
for f in "${release_files[@]}"; do
echo " $(du -h "$f" | cut -f1) $f"
done
# 全部包生成之后一次计算,避免边打边算漏掉后生成的产物。
# 名字用**平铺名**basename下载页的附件名就是平铺的
# 清单里若写 ./deb/xxx.deb用户下载后 `sha256sum -c` 会找不到文件。
if [ ${#release_files[@]} -gt 0 ]; then
(
cd "$DIST_DIR"
# 哈希取**真实路径**,标签用**平铺名**:两者不能混(直接对 basename 求哈希会找不到文件)。
for f in "${release_files[@]}"; do
printf '%s ./%s\n' "$(sha256sum "$f" | awk '{print $1}')" "$(basename "$f")"
done | sort -k2 > SHA256SUMS
)
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
fi
}
main

View File

@ -0,0 +1,259 @@
<#
.SYNOPSIS
在 WSL2 中安装 HomeAgenthomed + 插件 + WebUI
.DESCRIPTION
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
本脚本因此把 Windows 安装流程变成一条引导链:
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
「WSL 里就是普通 linux/amd64」这一点必须保持成立否则等于又开了第三个平台。
.PARAMETER PayloadDir
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
优先取 *.deb没有 deb 时回退 *.tar.gz。
.PARAMETER Distro
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
.PARAMETER DataDir
WSL 内的数据目录。默认 /var/lib/homeagent与 Linux 原生安装一致)。
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
.NOTES
⚠️ 本脚本在开发环境Linux中只能做语法/逻辑审查,**未在真实 Windows + WSL
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
便于定位到具体哪一步与预期不符。
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$PayloadDir,
[string]$Distro = "",
[string]$DataDir = "/var/lib/homeagent",
[string]$ApiKey = "",
[string]$WebUIUser = "",
[string]$WebUIPass = "",
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$script:StageNo = 0
$script:DistroName = $Distro
function Write-Stage([string]$Text) {
$script:StageNo++
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor DarkGray
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
Write-Host ("=" * 64) -ForegroundColor DarkGray
}
function Write-Ok([string]$Text) { Write-Host "$Text" -ForegroundColor Green }
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
function Fail([string]$Text, [string]$Hint = "") {
Write-Host ""
Write-Host " 安装中止:$Text" -ForegroundColor Red
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
exit 1
}
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
Write-Stage "前置检查"
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
}
Write-Ok "管理员权限"
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
Write-Warn2 "未找到 wsl.exe"
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
Write-Host ""
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
Write-Host " wsl --install" -ForegroundColor White
Write-Host " 然后重启 Windows再重新运行本安装程序。"
Write-Host ""
Write-Host " Windows 10 需 2004+ 且启用虚拟机平台Windows 11 开箱可用)"
exit 20
}
Write-Ok "wsl.exe 可用"
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
Write-Stage "检测 WSL 与发行版"
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
# 用 --status 取默认发行版,再单独枚举列表。
$distros = @()
try {
$raw = (& wsl.exe -l -q 2>$null | Out-String)
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
} catch {
$distros = @()
}
if ($distros.Count -eq 0) {
Write-Warn2 "WSL 已安装,但没有任何发行版"
Write-Host ""
Write-Host " 请先安装发行版(推荐 Ubuntu" -ForegroundColor Yellow
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
Write-Host ""
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
exit 21
}
if ($script:DistroName -eq "") {
try {
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
ForEach-Object { ($_ -split ":")[1].Trim() })
} catch { }
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
}
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', ')"
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
Write-Stage "确保使用 WSL2"
# WSL1 没有真正的 Linux 内核、没有 systemd且在共享内存/事件语义上与 WSL2 不同。
# homed 依赖 eventfd + mmap 语义WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
try {
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
if ($verLine -match "\b1\b") {
Write-Warn2 "该发行版当前是 WSL1正在升级为 WSL2 ..."
& wsl.exe --set-version $script:DistroName 2
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行wsl --set-version $($script:DistroName) 2" }
}
} catch { }
& wsl.exe --set-default-version 2 | Out-Null
Write-Ok "已使用 WSL2"
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
Write-Stage "准备 Linux 安装包"
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
if ($deb) {
$pkg = $deb.FullName
$pkgKind = "deb"
} elseif ($tar) {
$pkg = $tar.FullName
$pkgKind = "tar"
} else {
Fail "$PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
}
Write-Ok "使用 $(Split-Path $pkg -Leaf)$pkgKind"
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
Write-Stage "把安装包送入 WSL"
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
$winPath = (Resolve-Path $pkg).Path
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
Write-Host " 源:$mntPath"
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动wsl -d $($script:DistroName)" }
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
Write-Ok "已送到 /tmp/homeagent-install/"
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
Write-Stage "在 WSL 内安装 homed"
# 凭据经环境变量传给 setup.sh它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
$credEnv = ""
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
# 安装逻辑复用 Linux 侧deb 走 aptpostinst 会调用 setup.sh 生成凭据与 config.db
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
# 「WSL 里就是普通 linux/amd64」必须保持成立否则等于又开了第三个平台。
if ($pkgKind -eq "deb") {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
export HOMEAGENT_DATA='$DataDir'
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
"@
} else {
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
set -e
$credEnv
mkdir -p /opt/homeagent /tmp/homeagent-extract
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
cd /tmp/homeagent-extract
# deb /usr/bin/homed + /usr/lib/homeagent/setup.sh
# /
install -m 0755 homed /usr/bin/homed
install -m 0755 waiter /usr/bin/waiter
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
if [ -f homeagent.service ]; then
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
fi
mkdir -p /usr/lib/homeagent
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
export HOMEAGENT_DATA='$DataDir'
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
"@
}
if ($LASTEXITCODE -ne 0) {
Fail "WSL 内安装失败(退出码 $LASTEXITCODE" "可进入 WSL 手动排查wsl -d $($script:DistroName)"
}
Write-Ok "安装完成"
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
Write-Stage "启动 homed 与自启配置"
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
systemctl enable homeagent 2>/dev/null || true
systemctl restart homeagent
echo ' systemd homeagent '
else
# systemdWSL2 nohup Windows
pkill -f '/usr/bin/homed' 2>/dev/null || true
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
echo ' nohup systemd'
fi
"@
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " HomeAgent 已在 WSL2$($script:DistroName))内安装完成" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
Write-Host ""
Write-Host " WebUIhttp://localhost:8080" -ForegroundColor White
Write-Host " WSL2 会把 WSL 内的端口映射到 Windows 的 localhost无需额外配置"
Write-Host ""
if ($creds) {
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt" -ForegroundColor Yellow
Write-Host $creds
} else {
Write-Host " 未读到凭据文件,请进入 WSL 检查cat $DataDir/credentials.txt" -ForegroundColor Yellow
}
Write-Host ""
Write-Host " 常用操作(在 PowerShell 中):"
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
Write-Host ""
Write-Host " 注意WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
Write-Host " 可创建一个登录时触发的计划任务执行:"
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
exit 0

View File

@ -66,19 +66,41 @@ def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
return 0, f"{type(e).__name__}: {e}"
def project_root() -> str:
"""向上找带 go.mod 的目录作为仓库根。
为何不数 dirname本脚本初版在 scripts/(深度 1移到 deploy/scripts/
(深度 2后写死的两层 dirname 就指向了 deploy/dist/release上传直接
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
"""
d = os.path.dirname(os.path.abspath(__file__))
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "go.mod")):
return d
d = os.path.dirname(d)
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd给 ASSET_DIR 一个机会
return os.getcwd()
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
tag, token = sys.argv[1], sys.argv[2]
outdir = os.environ.get("ASSET_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dist",
"release",
project_root(), "dist", "release"
)
if not os.path.isdir(outdir):
print(f"error: 资产目录不存在: {outdir}")
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
return 2
files = sys.argv[3:] or sorted(
f for f in os.listdir(outdir) if is_artifact(f)
)
if not files:
print(f"error: {outdir} 下没有可识别的发布产物")
return 2
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
failed = []
for name in files:

View File

@ -0,0 +1,20 @@
[Unit]
Description=Jina v5-omni-nano Embedding Sidecar for HomeAgent
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/home/newqqagent
ExecStart=/usr/local/bin/python3 /home/program/TrueAgent/scripts/embed_sidecar.py
Restart=on-failure
RestartSec=5
Environment=JINA_MODEL_DIR=/home/newqqagent/models/jina-v5-omni-nano
Environment=JINA_PORT=18999
Environment=JINA_DIMENSION=768
Environment=OMP_NUM_THREADS=8
Environment=MKL_NUM_THREADS=8
Environment=TOKENIZERS_PARALLELISM=false
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,124 @@
# 检索方案对比报告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

@ -1,7 +1,8 @@
# Git 分支管理规范
> 生效2026-08-31。适用:**本仓TrueAgent/HomeAgent与 third_party/homeagent-sdkSDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用
> 核心原则一句话:**main 唯一长命、永远可部署;一切新工作在特性分支;版本发布走 release 分支 + taghotfix 只进 released 分支并 cherry-pick 回 main。**
> 生效2026-08-312026-09-04 修订(三级发布通道 + 单条发布分支2026-09-06 修订SDK 仓版本语义与发版联动,见 §七)
> 适用:**本仓TrueAgent/HomeAgent与 third_party/homeagent-sdkSDK 仓)**——两仓协作时分支策略必须一致,本规范两仓同用。
> 核心原则一句话:**main 唯一长命、永远可部署一切新工作在特性分支一个中版本一条发布分支alpha/beta/正式由 tag 区分hotfix 只进发布分支并 cherry-pick 回 main。**
---
@ -11,18 +12,24 @@
|---|---|---|---|---|
| `main` | **唯一长命分支** | — | — | ✅ **永远可部署** |
| `feature/xxx` | 短命(本次特性完成即删) | main | 合回 main | ❌ 不部署 |
| `release/vX.Y.Z` | 中命(从切出到下个版本发布 | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
| hotfix直接提交 release 分支) | 随 release 分支 | release 分支 | **cherry-pick 回 main** | ✅ |
| `release/vX.Y.x` | 中命(**整个中版本生命周期** | main | 打 tag → 构建发布 | ✅ **发布产物来源** |
| hotfix直接提交发布分支) | 随发布分支 | 发布分支 | **cherry-pick 回 main** | ✅ |
```
main ──────────────── E ──────────────── G ────────────────(永远可部署)
│ ▲
│ feature/xxx │ cherry-pickhotfix 逐个 pick 回)
│ feature/xxx │ cherry-pick修复逐个 pick 回)
├── A ── B ──(合回)───────────────────┤
│ │
└── release/v1.2.0 release/v1.2.0
├─(tag v1.2.0)→ 构建发布 ├─(hotfix) F ← 版本特定严重 bug
└─ 退役(可删可留) └─ F 被 separately cherry-pick 到 main
└── release/v1.0.x ────────────────────────────────────────────────
│ │ │
├─(tag v1.0.0-alpha.1) 内部验证 │ │
├─(tag v1.0.0-beta.1) 小范围试用 │ │
├─(tag v1.0.0) 正式发布 │ │
├─(hotfix) F ─────────────────────┤ │
├─(tag v1.0.1) patch 发布 │ │
├─(hotfix) H ────────────────────────────────────┤
└─(tag v1.0.3) patch 发布
```
---
@ -32,8 +39,9 @@ main ──────────────── E ────────
### 1. `main`(唯一长命分支)
- **唯一长期存在且永远可部署**。任何时刻 `git checkout main` 出来都是可构建、可上线的状态。
- 积攒**下一个版本**的功能feature 分支完成即合回main 持续向前。
- 积攒**下一个版本**的功能feature 分支完成即合回main 持续向前。
- **main 上不直接开发**。所有改动经 feature 分支合入hotfix 经 cherry-pick 注入。
- **main 的 `internal/meta.Version` 始终是下一个未发布版本**,不随 patch 发布变动。
- 合入门禁(**单人直推也遵守**,不强制 PR 但强制验证):
- `make test` 全绿
- 涉及插件/工具链时:接口冻结检查 `git diff third_party/homeagent-sdk/sdk/` 为空
@ -41,80 +49,199 @@ main ──────────────── E ────────
### 2. `feature/xxx`(新特性/修复)
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration``feature/webui-narrow-fix`
- 命名:`feature/<短横线描述>`,如 `feature/plugin-proc-migration``feature/memory-media`
- **从 main 开出**`git checkout -b feature/xxx main`
- 完成后合回 main
- 单人:直推(`git merge --no-ff` 保留特性边界,或 squash 成一个 commit二选一在团队内固定
- 多人:走 PRreview 后合入)。
- 合回后删除 feature 分支(避免累积)。
### 3. `release/vX.Y.Z`(发布)
### 3. `release/vX.Y.x`(发布分支:一个中版本一条
- **从 main 的某个可部署点切出**`git checkout -b release/v1.2.0 main`
- 切出后**冻结功能**——release 分支上只做:版本号 bump、发布准备、bug 修复、文档。
- 打 tag → 构建发布安装包 → 上传(附件命名规范见历史记录)。
- **现网部署永远用 release tag 的构建产物**,不是 main 头部、更不是 feature
- **命名用 `x` 占位 patch 位**`release/v1.0.x` 承载 1.0.0 → 1.0.1 → … → 1.0.N 全部发布,
直到 `release/v1.1.x` 切出为止。**不要按 patch 号建分支**`release/v1.0.1``release/v1.0.3` 各一条会把
同一发布线切成互不相连的碎片,追溯时无法用一条分支看完整条线的演进)。
- **从 main 的某个可部署点切出**`git checkout -b release/v1.0.x main`
- 切出后**冻结功能**——发布分支上只做:版本号 bump、发布准备、bug 修复、文档。
- **现网部署永远用发布分支上 tag 的构建产物**,不是 main 头部、更不是 feature。
### 4. hotfix只属于此版本的严重 bug
### 4. 三级发布通道alpha / beta / 正式
通道**由 tag 区分,不由分支区分**——三者共用同一条 `release/vX.Y.x`
| 通道 | tag 形式 | 含义 | 受众 |
|---|---|---|---|
| alpha | `vX.Y.Z-alpha.N` | 功能齐了但未充分验证,可能有已知缺陷 | 仅内部/开发者自测 |
| beta | `vX.Y.Z-beta.N` | alpha 问题已修,等待真实环境暴露长尾问题 | 小范围试用、愿意承担风险的用户 |
| 正式 | `vX.Y.Z` | 通过验证,可上现网 | 所有用户 |
- **推进顺序**alpha → beta → 正式,逐级向前,**每级都是同一条分支上的新 tag**。
这也是 semver 的标准预发布语义(`1.1.0-alpha.1 < 1.1.0-beta.1 < 1.1.0`
包管理器与版本比较逻辑天然认得,无需额外约定。
- **允许跳级**:若改动小、验证充分(如仅一处已定位并有回归测试覆盖的内核修复),
可直接打正式 tag。跳级要在发布说明里写明理由。
- alpha/beta 的构建产物**可以上传 release 附件**,但必须在 gitcode release 上勾选
"预发布"标记,且发布说明首行标注通道与已知风险。
- **beta 未清零的严重问题不得进正式**:正式 tag 意味着"我们认为它能上 24/7 现网"。
- **发版动作只在发布分支上做**:版本号 bump、打 tag、构建产物、上传 release 附件,
全部发生在 `release/vX.Y.x` 上。**main 永远不是发版分支**——即使某个改动刚刚合进 main、
即使 main 此刻可部署,也不从 main 打 tag、不拿 main 的构建产物发布。
main 的版本号是「下一个未发布中版本」的路牌,不是任何一次发布的版本号。
### 5. hotfix发布后发现的严重 bug
- **场景**:版本已发布后,发现只存在于该版本(或该发布线)的严重 bug。
- **动作**:直接把修复提交到 **release 分支**(不收进 main 的开发流)→ 该 release 分支重新构建、打 patch tag`v1.2.1`)发布。
- **动作**:直接把修复提交到**发布分支** → 该分支重新构建、打下一个 patch tag`v1.0.4`)发布。
- **关键hotfix 必须 cherry-pick 回 main**
```bash
# 在 release 分支上提交修复(代码部分与版本号 bump 分开提交)
# 在发布分支上提交修复(代码部分与版本号 bump 分开提交)
git checkout release/v1.0.x
git commit -m "fix(x): ..." # ① 修复本身
git commit -m "chore: bump v1.2.1" # ② 版本号(此 commit 不 pick 回 main
git commit -m "chore(release): bump v1.0.4" # ② 版本号(此 commit 不 pick 回 main
git tag -a v1.0.4 -m "..."
# 回到 main只挑修复本身
git checkout main
git cherry-pick <修复commit的sha> # 只 pick ①,不 pick ②
git cherry-pick <修复①的sha> # 只 pick ①,不 pick ②
```
> **为什么 cherry-pick 而不是 merge**release 分支只承载该版本特有的补丁merge 会把 release 分支的版本号/发布相关改动一并带进 main 造成冲突。逐个 cherry-pick 修复 commit 让 main 精确地只获得修复本身。**版本号 bump 不要 pick 回 main**main 的版本号应始终是下一个未发布版本)。
> **为什么 cherry-pick 而不是 merge**发布分支只承载该版本特有的补丁merge 会把
> 版本号/发布相关改动一并带进 main 造成冲突,并让 main 的 `meta.Version` 变成
> 已发布的旧版本号。逐个 cherry-pick 让 main 精确地只获得修复本身。
> **版本号 bump 不要 pick 回 main。**
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并 release 回 main**。这是本规范刻意为之——除非 release 分支上有 main 想要的**功能级**改动(罕见),否则 release 永不 merge 回 main
- **同时存在多个活跃 feature 分支时**:修复也要 pick 到那些分支,否则它们合回 main
可能带回旧代码。实践做法是修复落地当天就 pick 到全部活跃分支
(如 2026-09-04 的 stage 双重解锁修复同时 pick 到 `main` 与 `feature/memory-media`)。
### 5. release 分支退役
- **hotfix 已逐个 pick 回 main ⇒ main 已含全部修复 ⇒ 无需再合并发布分支回 main**。
这是本规范刻意为之——除非发布分支上有 main 想要的**功能级**改动(罕见),
否则发布分支永不 merge 回 main。
- **下个版本发布 = 此 release 分支生命周期结束**(不再维护)。
### 6. 发布分支退役
- **下个中版本发布 = 上一条发布分支生命周期结束**`release/v1.1.x` 出现即 `release/v1.0.x` 退役)。
- 退役后可删可留:
- 删除保持仓库干净tag 已保留全部历史,删分支不丢东西)。
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好,推荐与本仓库一样保留已打 tag 的历史分支做对照)。
- 本仓对现网多代版本并行维护时,保留近期 release 分支是合理的。
- 保留:便于追溯该发布线的历史构建(对 24/7 现网友好)。
- **按 patch 号命名的历史发布分支应当合并/删除**:它们是本规范修订前的遗留形态,
内容已被对应的 `release/vX.Y.x` 完全包含,保留只会让"哪条才是这条线"变得含糊。
---
## 三、当前分支对齐2026-08-31 执行
### 7. 开发者文档的发布归属(以 rel 分支的形态为准
**规则:面向使用者的开发者文档,先在对应的 `release/vX.Y.x` 上修正成「这一版的实际行为」,
再 cherry-pick 合入 `main`。**(文档属 §二.3 所列的发布分支允许事项之一)
为什么不能直接改 main
- `main` 的语义是**下一个未发布版本**(§二.1)。在那儿写的文档要么描述尚未发布的行为,
要么与当前 rel 的实际行为**相反**,而文档的读者(包括模型自身)会把它当事实。
- `assets/docs/**` 会**随发行包分发并在 WebUI 里被阅读**——它服务的是“这一版”,不是“下一版”。
- 版本号、工具名、机制的有无都是**随版变动的**:同一个文件在两个分支上就应该是两种口径。
做法:
```bash
git switch release/v1.2.x
# 按这一版口径修改版本号、当前工具名hmapdev、已移除机制不再写成现行
# ... 编辑 assets/docs/**、README{,_EN}.md、docs/zh/** ...
git commit -m "docs: 按 v1.2.x 口径修正 …"
git switch main && git cherry-pick <sha> # 遵守 §三:只 pick不 merge
```
`main` 上若需要描述“下一版才有的行为”,必须显式标注(如「(下一版)」或附版本号),
不得让读者以为它已发布。
**反例(本仓真实踩过,均为“文档当成事实后反向误导”)**
| 现象 | 后果 |
|---|---|
| 人格卡写死 `v0.9.0C ABI v2` | 内核接口/日志报 1.2.0agent 却向用户自述旧版本(且该机制 v1.0.0 已删除) |
| 架构文档在 1.2.0 后仍把“描述式索引 + 引用计数 GC”写成现行机制 | 读者按已删除的设计理解现行行为 |
| README 停在 v1.1.1 并描述已被删除的机制 | 同上 |
配套硬约束:**任何“模型或用户会当作事实”的文本,都不得写死版本号**——
要么用 `meta.Version` 插值,要么要求读运行时快照,并用测试钉住
(如 `TestDefaultPersonaPromptHasNoVersionLiterals`)。
---
## 三、当前分支对齐2026-09-12 更新)
### 主仓TrueAgent
| 现存分支 | 状态 | 处理 |
| 分支 | 状态 | 处理 |
|---|---|---|
| `main` | `48b5c24` [origin/main] | ✅ 保持不变(规范基线) |
| `feature/plugin-proc-migration` | 原 `update``69a138c`(领先 main 5文档基线 + Part 0.1/0.2 + 本规范) | ✅ **已对齐重命名**2026-08-31 |
| `backup-local`SDK 仓) | `7092d15`ahead 3, behind 14含 `ignore example/recoverydiag` 敏感提交) | ⚠️ 遗留本地分支,功能已合入 main**保留不删**(无远端,删除即永久丢失) |
| `main` | 含全部回流修复;`meta.Version` = 下一个未发布中版本(现为 `1.3.0` | ✅ 保持 |
| `release/v1.2.x` | **本条发布线**`meta.Version` = `1.2.0`vendored SDK 定版 `1.2.0`已载入两个发布前修复GUI 输出目录、知识库同名覆盖) | 🆕 2026-09-12 从 main 切出;**尚无 tag** |
| `release/v1.1.x` | 承载 `v1.1.0-beta.1` / `v1.1.0` / `v1.1.1` | 📦 已退役§2.6:下个中版本发布即退役),保留供追溯 |
| `release/v1.0.x` | 承载 1.0.x 全部 tag | 📦 保留 |
| `feature/multimodal-embedding` | 已合入 main`eb4762a`43 提交,`--no-ff` | ⏳ 待删(删远端分支需用户确认,§执行守则 3 |
> `feature/memory-media`、`feature/plugin-proc-migration` 均已从远端删除(旧表里的待删项已处理)。
### SDK 仓homeagent-sdk
| 现存分支 | 状态 | 处理 |
| 分支 | 状态 | 处理 |
|---|---|---|
| `main` | `61f307b` v1.2.0 | ✅ 保持不变 |
| `update` | `5648519`(领先 main 1Part 0.2 模板修复) | ⚠️ 与主仓 `update` 对齐重命名 |
| `backup-local` | `7092d15`ahead 3, behind 14遗留调试分支 | ⚠️ 可选清理 |
| `main` | `meta.Version` = 下一个未发布中版本(现为 **`1.2.0`**——SDK **不跟 beta 发版**(§七.21.2.0 要等核心的**正式** tag 才定版(§七.3),在那之前路牌不得越过它。此阶段与核心 main`1.3.0`**故意不对称**,详见 §七.4 | ✅ 保持 |
| `release/v1.1.x` | `meta.Version` = `1.1.0`,承载 tag `v1.1.0` | ✅ 与核心对应 |
| `release/v1.2.x` | **尚未创建** | ⏳ 随核心**正式** tag 一起建(§七.3:分支上把版本定为 `1.2.0` 再打 `v1.2.0`beta 阶段不发 SDK |
| `release/v1.0.0` | 旧 patch 号命名形态,内容已被 main 完全包含 | 📦 保留(供追溯 1.0 线构建) |
> `update` 整改工作分支按规范应为 `feature/plugin-proc-migration`多进程插件化整改8-9 周大特性)。
> 是否重命名由执行人确认;不重命名则视为偏离规范的既有分支,须在文档记录其存在。
### 1.0.x 发布线 tag 历史
| tag | 提交 | 通道 | 说明 |
|---|---|---|---|
| `v1.0.0` | `9b92a04` | 正式 | 外部插件从 C ABI 迁移到子进程 + 共享内存 |
| `v1.0.1` | `e671a8c` | 正式 | 多模态 bugfix假成功、能力声明与回退链、see_video 帧数语义) |
| `v1.0.3` | `26dc76f` | 正式 | 内核 stage 协调器双重解锁(直接跳正式:单点修复 + 反向验证 + 全类审计) |
> `v1.0.2` 未使用:该号从未发布也无 tag留空以免与任何本地构建混淆。
### 1.1.x 发布线 tag 历史
| tag | 提交 | 通道 | SDK | 说明 |
|---|---|---|---|---|
| `v1.1.0` | `579d7db` | 正式 | 1.0.0 | 记忆系统支持二进制多媒体节点CAS 媒体存储 + L0/L2/L3 贯通) |
| `v1.1.0-beta.1` | `7a57a14` | beta | 不发 | 打包链路验证GUI 架构污染 + 空壳 node_modules。按 §七.2beta 不伴随 SDK 发版 |
| `v1.1.1` | 见发布说明 | 正式 | **1.1.0** | 多模态贯通插件边界SDK 首次随核心正式版发布 |
> `v1.1.0-beta.1` 的提交序在 `v1.1.0` **之后**(它多含一个打包修复),
> 而 semver 预发布语义里 `1.1.0-beta.1 < 1.1.0`。这是「一条发布分支 + tag 区分通道」的
> 已知代价beta 是为验证**打包链路**而补打的,不代表源码更旧。发布说明里已注明。
### 1.2.x 发布线 tag 历史
| tag | 提交 | 通道 | SDK | 说明 |
|---|---|---|---|---|
| `v1.2.0-beta.1` | `215804c` | beta | 不发 | 统一多模态向量空间 + 媒体升为图记忆一等节点 + 数据面全量迁到共享内存RPC 协议 **2**,与 1.x 不兼容)。按 §七.2beta 不伴随 SDK 发版 |
| (正式 tag 待打) | — | — | — | 试运行 beta 无回退问题后打 `v1.2.0`,并同步 SDK 仓 `release/v1.2.x` + `v1.2.0` |
> 1.2.x 与存量插件**不兼容**RPC 协议升到 2fd3 布局改变),存量外部插件必须用
> 新版 plugindev 重编为 `plugin.bin`——**不支持滚动升级**,内核与插件须同批重建、同批安装。
> 按 §2.4,跳级直发正式版需在发布说明里列明「单点修复 / 反向验证 / 全类审计」三项;
> 本次改动面大(统一多模态向量空间 + 协议 2 + 数据面全量迁移),不满足跳级条件。
---
## 四、现网部署与版本对应(运维纪律)
- **现网 homed 永远部署 `release/vX.Y.Z` 分支打出的 tag 构建**,路径见 `Makefile``make build` → `build/homed`)。
- systemd 服务(`/usr/local/bin/homed`)替换前:备份旧二进制 → 停服 → 替换 → 起服 → 健康检查(`scripts/verify_deploy.sh`)。
- **改造期间update 整改)现网不得部署 main 或 feature 的中间态**——只有发版才用 release。
- **现网 homed 永远部署 `release/vX.Y.x` 分支 tag 构建产物**,路径见 `Makefile``make build` → `build/homed`)。
- systemd 服务(`/usr/local/bin/homed`)替换流程:
1. 备份旧二进制(`homed.bak.pre<版本>.<时间戳>`
2. 备份配置库(**用 `sqlite3 .backup`,不用 `cp`**——WAL 模式下 cp 可能拿到不一致快照)
3. 记录当前插件建链清单,供重启后逐项比对
4. `install -m 0755` 替换(原子 rename不会写坏正在运行的进程镜像
5. `systemctl restart homeagent`
6. 健康检查:版本号、插件清单无缺失、`/api/v1/status`、一次真实对话、`fatal error` 计数为 0
- **改造期间现网不得部署 main 或 feature 的中间态**——只有发版才用发布分支的 tag。
- alpha/beta tag 的产物**不上现网**(现网是 24/7 服务,预发布通道的存在就是为了不拿它冒险)。
- 涉及 SDK 仓时:主仓 `go.mod` 的 `replace => ./third_party/homeagent-sdk` 指向本地 vendored 副本,
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(两仓版本对齐是第一优先级)。
发版前确认 vendored SDK 与 SDK 仓 release tag 一致(**两仓版本对齐是第一优先级**,见 §七)。
---
@ -128,28 +255,112 @@ git checkout -b feature/xxx
git checkout main && git merge --no-ff feature/xxx # 或 squash
git branch -d feature/xxx
# 发布
git checkout -b release/v1.2.0 main
git commit -am "chore: bump v1.2.0" # 版本号
git tag v1.2.0
# ... 构建发布 ...
# 开一条新中版本的发布线
git checkout -b release/v1.1.x main
git commit -am "chore(release): bump v1.1.0-alpha.1"
git tag -a v1.1.0-alpha.1 -m "..." # alpha内部验证
# ... 修问题 ...
git commit -am "chore(release): bump v1.1.0-beta.1"
git tag -a v1.1.0-beta.1 -m "..." # beta小范围试用
# ... 真实环境验证 ...
git commit -am "chore(release): bump v1.1.0"
git tag -a v1.1.0 -m "..." # 正式
# hotfix发布后
git checkout release/v1.2.0
# hotfix发布后——注意是同一条 release/v1.0.x不新建分支
git checkout release/v1.0.x
git commit -am "fix(x): 严重 bug" # ① 修复
git commit -am "chore: bump v1.2.1" # ② 版本号
git tag v1.2.1
git commit -am "chore(release): bump v1.0.4" # ② 版本号
git tag -a v1.0.4 -m "..."
git checkout main
git cherry-pick <修复①的sha> # ③ 只挑修复
# 若有活跃 feature 分支,也 pick 过去
git checkout feature/xxx && git cherry-pick <main 上那个 pick 的 sha>
# release 退役(可选)
git branch -d release/v1.2.0 # tag 已保存历史,删分支不丢东西
# 公开 SDK 接口改动feature不是 hotfix先进 main再 pick 到发布分支
git checkout -b feature/sdk-xxx main
# ... 改 third_party/homeagent-sdk/sdk/ 与内核桥接层 ...
git checkout main && git merge --no-ff feature/sdk-xxx
git checkout release/v1.1.x
git cherry-pick <feature 的各 sha> # 只挑改动,不挑 main 的版本号
git commit -am "chore(release): bump v1.1.1" # 发布分支自己的版本号
git tag -a v1.1.1 -m "..."
# SDK 仓同步(仅在核心打正式 tag 时,见 §七.2/§七.3
cd third_party/homeagent-sdk
git checkout -b release/v1.1.x main
git commit -am "chore(release): SDK 1.1.01.1.x 线全程共用)"
git tag -a v1.1.0 -m "..."
# 发布分支退役(下个中版本发布后,可选)
git branch -d release/v1.0.x # tag 已保存历史,删分支不丢东西
```
---
## 六、本规范与「接口冻结」约束的关系
- feature 分支合回 main 的门禁(`git diff sdk/` 为空)是本仓特有的硬约束,独立于 Git 流程本身。
- 插件多进程化整改(`feature/plugin-proc-migration` 或现 `update`**不满足接口冻结不等于不能合并**——
接口冻结约束的是「公开 SDK 不变」,整改若突破需走变更评审(见 `docs/zh/plugin-interface-matrix.md` §七)
- 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。
- **公开接口的改动本身是 feature不是发布准备**:它必须走 `feature/xxx` → 合回 main 的路径,
再 cherry-pick 到发布分支。不允许把接口新增当成"发布分支上的 bug 修复"直接提交进 release
——发布分支冻结功能§2.3),接口是最典型的功能面。
---
## 七、SDK 仓的版本语义与发版联动
### 1. SDK 版本号跟随核心的中版本patch 位恒为 `.0`
| 核心版本 | 对应 SDK 版本 |
|---|---|
| 1.1.0 / 1.1.1 / 1.1.2 / … / 1.1.N | **1.1.0**(全线共用,不随核心 patch 变动) |
| 1.2.0 起 | **1.2.0** |
- 核心的 patch 位(`x`)专用于 **bugfix 与漏洞修复**,这类改动不触碰公开 SDK 接口,
因此 SDK 版本号没有理由跟着动。
- **为什么不逐位对齐**SDK 版本号是插件开发者的依赖声明。若核心每发一个 bugfix 就把 SDK
也推一个新号,开发者要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
让 SDK 号只在**接口可能变化的中版本边界**上跳,开发者只需关心「我在为哪个中版本写插件」。
- 因此「两仓版本对齐」在本规范里指**中版本对齐**(核心 1.1.x ↔ SDK 1.1.0
不是三位全等。核心 1.1.1 配 SDK 1.1.0 就是对齐状态。
### 2. beta 阶段不发 SDK
- **核心的 alpha/beta tag 不伴随 SDK 仓发版**SDK 仓在这一阶段**不打 tag、不建 release**。
- **为什么**beta 是核心自己的测试阶段,此时 SDK 接口尚未固定。若此刻给 SDK 发版,
插件开发者会照着一个还会变的接口写代码——**那是无效开发**。接口没定就没有可依赖的契约,
发出去的版本号是一个假承诺。
- 这条约束的对象是 **SDK 仓的发版动作**,不是核心二进制里有没有 SDK 代码。
主仓 `go.mod` 用 `replace => ./third_party/homeagent-sdk`,任何核心构建都必然含 vendored
SDK 源码,这是构建机制决定的,不在本条约束范围内。
### 3. 正式发布时 SDK 随核心一起发
核心打**正式 tag**`vX.Y.Z`无预发布后缀SDK 仓同步执行:
1. SDK 仓也有自己的 `release/vX.Y.x`(与核心同名,一个中版本一条);
2. 在该分支上把 `meta.Version` 定为 `X.Y.0`
3. 打 tag `vX.Y.0`(首次进入该中版本时),并建 gitcode release
4. 上传 5 平台 plugindev 产物 + `SHA256SUMS`。
同一中版本内的后续核心 patch1.1.1 → 1.1.2 …)**不重复发 SDK**——SDK 已经是 1.1.0
没有新东西要发。只有接口再次变化并进入下一个中版本时SDK 才发 1.2.0。
### 4. 版本号在两仓 main 上的含义
两仓的 `main` 都遵守 §2.1`meta.Version` 是**下一个未发布中版本**。
所以在 1.1.x 线发布期间,两仓 main 上的值都是 `1.2.0`——它标记「main 正在积攒 1.2 的东西」,
而不是「1.2.0 已经存在」。已发布的版本号一律看对应 `release/vX.Y.x` 分支与 tag。
**但两仓「同步推进」是有条件的**(这一点曾导致误判,现补写清楚):
推进的前提是**该中版本已经正式发布过**。具体到当前:
- 核心:切出 `release/v1.2.x` 后1.2.0 就归发布线所有main 立即推进到 `1.3.0`
**即使 1.2.0 目前只有 beta tag**beta 不上现网,但发布线已占住这个号)。
- SDK因为 §七.2 **beta 不发 SDK**SDK 1.2.0 要等核心的**正式** tag 才定版、
建 `release/v1.2.x`、打 `v1.2.0`(§七.3。在那之前SDK 的「下一个未发布中版本」
仍然是 `1.2.0`,其 main 不得越过它。
→ 因此在这一阶段,**核心 main = `1.3.0` 而 SDK main = `1.2.0` 是正确的**
不是遗漏同步。(曾按本节的例子把 SDK main 也推到 1.3.0,等于宣称 SDK 1.2.0 已发布。)

View File

@ -0,0 +1,631 @@
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
> **模型更正2026-09-13据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12**
>
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名
> qq→L2、cli→L3由内核推断级别。那是错的。正确模型是**两类别 + 四级**
>
> | | 中断输入interrupt | 排队输入queued |
> |---|---|---|
> | 注入 API | `InjectInterrupt*` | `InjectText*` / `InjectInputSync*` / 内核自循环 |
> | 级别 | L1L4 | **无级别** |
> | 定位 | 需要及时处理 | 不需要及时处理 |
> | 可被谁打断 | 仅**严格更高级**的中断 | **任何**中断 |
>
> 级别(“这项工作有多不能等”)由来源在 `InjectOptions.Priority` 里声明。
> L1L3 任何插件可声明;**L4 是“立即打断”能力**,只有**内核自身**panic /
> 内核事件 selfip经 `raiseKernelInterrupt`)与**内核级插件**(编译期内置插件,
> 如 WebUI 的终止按钮)能用。外部插件的 L4 会被夹到 L3。
> 类别由**用哪个注入 API**决定与通道名无关——QQ 走的是 `InjectInterruptTextOpts`
> 所以它是**低级别中断L1**,不是排队输入。
> 分支:`feature/input-semantics`
> 状态:**设计稿 v1**(待确认项见 §12未确认处按 §12 的「默认取值」推进)
> 影响面:`internal/agent/core`、`internal/agent/io`、`internal/sdk`**仅内部**
> 公开 SDK**v1 不改**`third_party/homeagent-sdk/sdk/` diff 必须保持为 0理由见 §13
---
## 1. 背景:现状与要解决的问题
现有两条输入语义(见审查结论),都建立在**串行 `eventLoop`** 之上:
| 语义 | 入口 | 路径 |
|---|---|---|
| 排队 | `InjectText` / `InjectInput*` | `io.inputCh``eventLoop``processInput``process()` |
| 中断 | `InjectInterruptText` / `InjectInterrupt*` | `io.interruptCh``interceptLoop``cancelLLM` + `a.interceptCh` |
已确认的具体问题(均为源码事实):
1. **队头阻塞**`eventLoop` 单 goroutine`process()` 全程持 `a.mu``internal/agent/core/process.go:103-104`),一轮对话(含 N 轮工具)期间后续输入全部排队。
2. **中断只在一种时刻成立**`interceptLoop` 有三条降级回排队的路径——当前无 LLM 在跑、当前轮是 `_consolidation_``a.interceptCh` 满(`internal/agent/core/eventloop.go:74-116`)。它不是独立管线,而是"抢占 + 三次降级"。
3. **回执误投风险**`ResponseCh` 只由全局 `emitResponse` 写(`eventloop.go:475`),无任务归属。一旦引入抢占,中断的回执会写进被挂起任务的等待者。
4. **断链点静默**`processInput` 有多条提前 `return` 而不 `emitResponse` 的路径(`resolveInput` 失败 `:309`、10s 去重命中 `:317``_consolidation_` `:326`),同步调用方(`cli``clawhubadapter` 无超时)永久挂起。
5. **背压策略分裂**`inputCh` 满 → 阻塞发送方;`selfInputCh` 满 → 静默丢弃;`a.interceptCh` 满 → 降级回 `inputCh`
6. **假取消**:工具超时只是放弃等待,内层继续执行且副作用照做(`internal/agent/core/toolcall.go:33-45`)。
7. **不可观测**:没有任何"当前在跑什么、谁被挂起、降级了多少次"的统一入口。
**目标**:把上述隐式行为收敛成一个**显式、可 dump、可单测、可断言**的调度器。
---
## 2. 术语与模型
```
Task = { id, class, level, origin, frame, state, enqueueAt, preemptCount, responseCh }
class: queued | interrupt // **类别由注入 API 决定,与通道名无关**
queued —— 无级别;用于“不需及时处理”的场景;可被**任何**中断打断
interrupt —— 带级别 L1..L4仅被**严格更高级**的中断打断(被打断则压入中断栈)
level: 仅 interrupt 有意义queued 恒无级别effectiveLevel 视作 0
state: ready | running | suspended | done
TaskFrame = {
msgs []Message // 本任务自己的 LLM 消息序列
step Step // 下一个要执行的 step安全点游标
turn int // 已完成的工具轮数
toolIdx int // 当前工具批内的下标
toolResults []ToolResultItem
toolsUsed []string
lastBatchReplyOnly bool
stageCtx *sdk.StageContext
budget TokenBudget
outputChannel string // 该任务的输出通道
responseCh chan<- *OutputEvent // 任务级回执信道(可为 nil
startedAt time.Time
input string // 触发本任务的输入文本COMMIT 时写记忆)
noMemory bool
originSource string
}
Step枚举顺序执行步与步之间是安全点
S_PREPARE 构建 msgs / 应用中断标记 / 合并 stage 上下文
S_LLM LLM 流式调用(**可抢占**cancel 即丢弃)
S_POST StagePostAction
S_LLM_JUDGE 无 tool_call → 去 S_BEFORE_OUTPUT有 → 去 S_TOOL_BEGIN
S_TOOL_BEGIN replyOnly 判定 + 取当前 tc
S_TOOL_BEFORE StageBeforeToolcall
S_TOOL_EXEC 工具执行(**临界区,不可抢占**
S_TOOL_AFTER StageAfterToolcall
S_TOOL_NEXT 批内下一个 / 批结束 → S_LLM
S_BEFORE_OUTPUT / S_AFTER_OUTPUT
S_COMMIT context.Append + emitMemoryCandidate**原子,不可抢占**
S_FINISH 写 responseCh、发事件
```
四个容器(**不是**“三集合并成一个比较器”):
| 容器 | 内容 | 取出规则 |
|---|---|---|
| `interruptQueues[1..4]` | **中断队列**,每条队列一个级别 | 从 L4 到 L1 依次扫描;同级 FIFO |
| `immediate` | 刚抢占成功的那一条中断(**至多一个** | 最先取出——抢占必须**立即生效** |
| `queue` | **排队输入**形成的新任务 | 纯 FIFO无级别可比 |
| `suspendStack`**中断栈** | 被打断、保存了现场的任务 | **LIFO只比栈顶**;栈内不做重排 |
> 用词(已更正):它**就是中断栈**。用户明确存在「中断被中断」的场景,被打断的现场必须压栈;
> 因此恢复纪律是**严格 LIFO只比栈顶**,栈内不做优先级重排。
> 早期稿把它写成“不是栈、按优先级取”是错的。
>
> 早期稿还让 `immediate` 与别的容器共用同一个比较器,于是出现“抢占成功后,
> 抢占者与被挂起者同级 → 原任务被立刻选回 → 抢占空转”——为此打的
> “同级 pending 优先”补丁已删除:抢占者根本不进队列。
---
## 3. 优先级(只属于中断)
### 3.1 两类别 + 四级
**类别(`TaskClass`)由注入 API 决定,与通道名无关**
| 类别 | 注入入口 | 级别 | 可被谁打断 |
|---|---|---|---|
| `queued` 排队 | `InjectText*` / `InjectInputSync*` / `InjectInputMedia*` / 内核自循环(`selfInputCh` | **无** | **任何**中断L1 也能) |
| `interrupt` 中断 | `InjectInterrupt*` | L1L4 | 仅**严格更高级**的中断 |
**级别(`Level`)语义是“这项工作有多不能等”**
| Level | 名称 | 语义 | 典型来源 |
|---|---|---|---|
| `L4` | CRITICAL | 内核紧急 | **内核独占**panic 中断、内核事件中断selfip |
| `L3` | INTERACTIVE | 需及时处理 | 时钟/定时器到达、终端输出、交互输入 |
| `L2` | MESSAGE | 一般提醒 | 插件希望尽快看到、但不紧急的提示 |
| `L1` | BACKGROUND | 完全可等 | 异步消息QQ/微信)、批量通知 |
- **`queued` 没有级别**:它本就是“不需及时处理”的那一类,
所以“可被任何中断打断”不是漏洞而是定义(`effectiveLevel(queued) == 0`)。
- **默认级 = `L1`**:未声明一律最低级(“显式才是特权”,新插件不会默认拿到抢占权)。
### 3.2 级别从哪来
| 来源 | 可达级别 | 入口 |
|---|---|---|
| 普通插件(外部,独立进程/动态库) | L1L3 | `InjectOptions.Priority`(空/非法 → L1L4 被夹到 L3 |
| **内核级插件**(编译期内置,`init()` 自注册) | L1**L4** | 同上L4 用于实现**中断能力**,例如 WebUI 的终止按钮 |
| 内核自身 | L4 | `(*Agent).raiseKernelInterrupt`panic / selfip |
-**不是运维可调项**。不引入 `core.agent.priority.<channel>` 这类配置键,
也不把 `PriorityLookup` 做成可注入的策略表。
- ✅ 插件**可以声明**自己中断的级别(这不是“把内核内部属性外化”,
而是调用方声明它自己那件事有多不能等)。
-**L4 给“立即打断”能力**内核自身panic / selfip与**内核级插件**
(编译期内置插件,如 WebUI 终止按钮)可声明。为什么必须给内置插件:
用户按下终止按钮时,内核需要一条能立刻打断当前任务的中断;这条能力不能给
外部插件,否则任何第三方插件都能随时打断用户的一切工作。
- **判据是“这个插件是不是编译期内置”,不是它自报的名字**
- 第一道闸在 **proc 桥**(外部进程的唯一入口):走它的一律把 L4 夹到 L3。
在这里夹而不是只按 `source` 判,是因为 `source` 是插件自报字段、可以冒名。
- 第二道闸在 **core**`isKernelLevelSource(source)`
`pluginReg.IsBuiltinPlugin`,只有内置工厂才承认 L4纵深防御
- `source` 的约定是 `插件名``插件名/实例`(如 `webui/<deviceID>`
判据取第一段——否则带设备身份的 WebUI 来源会被误判成外部插件。
### 3.3 抢占判据
```go
effectiveLevel(queued) == 0
canPreempt(incoming, running) = incoming.Class == TaskInterrupt
&& effectiveLevel(incoming) > effectiveLevel(running)
```
因为 `queued` 的有效级恒为 0这一个比较同时覆盖两条规则
```
running 是排队任务 → 任何中断≥L1都抢占
running 是中断 Li → 只有 Lj > Li 的中断抢占(严格大于)
incoming 是排队输入 → 永不抢占
```
**严格大于才抢占**;相等一律入队——这条保证确定性,也是“较低无法打断较高”的字面实现。
## 4. 调度规则
### 4.1 选择函数(四容器 · 固定次序)
任务结束、或运行任务到达安全点且存在待处理抢占请求时,执行:
```
1. immediate 非空 → 取它(刚抢占成功的中断,抢占必须立即生效)
2. 中断队列非空 → 取 L4→L1 中最高级非空队列的队头(同级 FIFO
3. 中断栈非空(与 2 比高) → 栈顶有效级 ≥ 队头级别 ? 弹栈顶 : 取队头
4. queue 非空 → 取队头(纯 FIFO
5. 都没有 → 空闲(阻塞等新输入 / 新中断)
```
- **中断栈只把栈顶**放进比较(严格 LIFO——栈内更老的任务即使因饥饿防护
提升了有效级,也不得越过栈顶;“后被打断的先恢复”才是栈语义。
- 第 3 步就是用户给的规则:“先判断中断队列是否为空,同时判断中断栈中任务的
优先级,哪个优先级高取出哪个”。栈顶是 `queued`(有效级 0任何中断都赢。
- 第 1 步的存在,使“抢占者与被抢占者同级”这个比较**根本不会发生**
抢占者不经队列。这是删除早期“同级 pending 优先”补丁后的正确形态。
- 排队任务只在中断与挂起现场都处理完后才执行——这正对应“排队输入用于
不需要及时处理的场景”。
### 4.2 安全点(可切换点)
**只有 step 与 step 之间是安全点。** 明确:
-`S_LLM` 之后、`S_TOOL_BEFORE` 之后、`S_TOOL_EXEC` **之后**`S_TOOL_AFTER` 之后……
-`S_TOOL_EXEC` **执行中不是安全点**:工具副作用不可回滚,无法"保存现场"。
### 4.3 临界区
```
CriticalSectionstep 标记 nonPreemptible = true或任务进入声明区间
```
- 实现方式:**调度器在临界区期间不求值抢占**(协作式单线程下即"不 yield"),不使用 `sync.Mutex`
- 资源互斥不用锁,而是**调度器持有的资源表**(若某资源被 running 占用,则不会选出同样占用它的任务)——纯数据判定,天然无优先级反转。
- **v1 临界区清单**(显式列出,避免"隐式临界区"
| 临界区 | 理由 |
|---|---|
| `S_TOOL_EXEC`(单次工具执行全程) | 副作用不可回滚;插件 RPC 不可取消 |
| `S_COMMIT` | 上下文/记忆写入必须原子 |
| 需 ONNX 嵌入的 `S_PREPARE` 片段 | ONNX `Run` 不可取消 |
| 媒体 CAS 落盘 | 同上 |
| 显式声明的 `_consolidation_` 类任务 | 记忆一致性 |
- 临界区期间到达的抢占请求**不丢失**:按级别进入中断队列,在临界区结束后的第一个安全点重新求值。
### 4.4 背压v1 统一为一种)
- `readyQueue` 有界(默认 256可配
- 满时:**阻塞发送方**(与现状 `inputCh` 一致,避免静默丢用户输入),但必须**计数并打日志**。
- 中断队列合计有界(默认同 `maxQueue`);满时**丢弃最低级别里最老的一条并计数**(中断是提示性输入,宁可丢旧保新)。
- 中断栈帧数上界是**结构推论 = 4**(见 §6.3),不是配置项。
---
## 5. 中断语义
### 5.1 中断产生线程的职责(钉死)
`interruptLoop` 只做三件事,**绝不触碰任何 TaskFrame**
```
① 从 io.interruptCh 收中断 → 定级(读 payload["priority"],插件声明 L1..L3
② 决策scheduler.registerInterrupt 内):
canPreempt(incoming, running) 且 running 不在临界区
→ 置让位信号 + 把 incoming 放进 immediate 槽,并返回 true调用方据此
取消当前可取消的 step即 LLM 流式)
否则
→ 按级别进入对应的中断队列
③ 唤醒调度器scheduler.wakecap 1
```
共享面仅三处:让位信号(`preemptArmed`/`preemptLevel`)、中断队列、`critical` 原子标志。
**帧的保存与恢复只能由调度器做。**
### 5.2 三种情形的统一
现状的三条降级路径在新模型里不再需要特殊分支:
| 情形 | 旧模型 | 新模型 |
|---|---|---|---|
| LLM 在跑,正常 | 真抢占(同轮 continue | 真抢占:`S_LLM` 取消,任务 A **压入中断栈**,中断任务 B 从 `S_PREPARE` 启动 |
| LLM 没在跑 | 降级为排队 | B 按其级别入中断队列(空闲时即被 `wake` 唤醒并选出) |
| `_consolidation_` 中 | 降级为排队 | `_consolidation_` 是后台**临界区**(且它是排队任务)→ B 入中断队列,临界区结束后求值 |
| `a.interceptCh` 满 | 降级为排队 | 不存在该队列;中断队列有界,满则丢最低级别里最老的一条 |
### 5.3 中断任务与被打断任务的关系(**已定D1 = 方案 B**
> 用户明确:
> *“中断打断时,上个任务到达以来的所有上下文现场被保护(含 toolcall
> 然后中断在**上个任务前的那个完整状态**上开始运行。中断运行结束,再把被挂起的
> 任务与其上下文现场**加载回中断任务之上**,并继续运行。”*
因此语义是:
1. **被挂起任务的现场 = 它自到达以来累积的全部上下文(含 toolcall 结果)**
原样保存在 `TaskFrame` 里。
2. **中断任务从「上一个任务之前的完整状态」开始运行**——它**看不到**被打断
任务的任何部分进展。等价于:中断任务就是一个普通新任务,正常走 `S_PREPARE`
(重建 system prompt + timeline + 自己的输入)。
3. **中断结束后,把被挂起任务与其现场加载回「中断任务之上」再继续**
中断已提交的那段上下文留在**下面**(成为重建前缀的一部分),本任务自己的
现场接回**其上**。
实现对应(`internal/agent/core/task.go`
- `TaskFrame.PrefixLen` 记录 prepare 段构建的**基础前缀**长度
system + timeline + 用户输入);其后的 Stage 上下文与工具轮产物都是“自己的现场”。
- `rebaseFramePrefix(f)`:恢复时重建基础前缀(因中断结束已把它的输入/输出提交进
`a.context`,重建出的 timeline 已含中断效果),再把 `f.Msgs[PrefixLen:]` 原样接回;
并补回 prepare 段的両处尾部改写(`IsInterrupt``[中断消息]` 标记、输入多模态块)。
- 调用点:`resumeTask``runTaskSteps` **之前**调用它。
> 代价(已知且接受):中断看不到“进行到哪一步”,所以“别搜了改成 X”这类指令
> 只能靠它自己重新理解;换来的是中断起点总是一个**一致的完整状态**。
---
## 6. 保存现场与恢复
### 6.1 保存
在安全点被抢占时:
```
suspendStack.push(Task{frame: running.frame, state: suspended,
step: running.frame.step, enqueueAt: running.enqueueAt})
running.state = done_for_now
```
- **只保存数据帧**,不保存 goroutine 栈(这正是"单调度 + 隐式状态机"优于"park goroutine"的地方)。
- `S_LLM` 被抢占时:**不完整的 LLM 请求直接丢弃**LLM 调用幂等、无持久副作用);恢复时从 `S_LLM` **重发**`msgs` 与抢占前一致(即"请求前"的状态)。
- 已提交的副作用(已执行的工具、已 append 的 context**不回滚**——帧里记录的 `toolResults` 会保留,恢复后继续。
### 6.2 恢复
从**中断栈栈顶**取出后:
1. **重建基础前缀**`rebaseFramePrefix`)—— 此时中断任务已结束并提交,
重建出的 timeline 包含中断的输入/输出,即“现场加载回中断任务之上”;
2. 把本任务自己的尾部Stage 上下文 + 工具轮产物 + 占位)原样接回;
3.`frame.Step` 继续执行。
被丢弃的只有那次**不完整的 LLM 请求**(幂等),已执行的工具与已累积的
`toolResults` 全部保留。
### 6.3 嵌套
- 允许中断任务自身被更高级中断抢占(嵌套)。
- **中断栈帧数上界 = 4是结构推论而不是配置项**
链条 = `排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)`
被挂起 4 帧L4 之上没有更高级别,链到此为止。
(插件可达级别只到 L3所以插件链最多挂起 3 帧 + 底层排队任务;
第 4 帧只能由内核 L4 制造。)
- 栈自底向上的**基础级**天然递增(能被抢占者必然级别更高),因此栈顶通常就是最高级任务。
- 超限在正确模型下不可达:`susp` 处只做**防御性计数**`Rejected++`
**不降级、不丢弃帧**——帧丢了会丢副作用记录。早期稿写的“超限转 pendingInterrupts”已删除。
## 7. 回执路由(任务级)
**必须改**`ResponseCh` 从"全局 `emitResponse` 的对象"上升为 `TaskFrame.responseCh`
```
emitResponse(task, ...) // 写 task.frame.responseCh而不是"当前全局通道"
```
- 抢占场景下,中断任务 B 的 `S_FINISH` 只可能写 `B.responseCh`,绝不会写进被挂起的 `A.responseCh`
- **不变量****每个任务在 `S_FINISH` 必然产生且仅产生一个终态事件**(无论成功、失败、被跳过)。`processInput` 现有的三个提前 return解析失败、去重、consolidation在新模型里都必须转成"任务以 `skipped` 终态结束并回执"。
- 这顺带修掉现有缺陷:`cli``internal/plugins/cli/plugin.go:242`)与 `clawhubadapter``internal/plugins/clawhubadapter/plugin.go:1045`)的同步注入在断链时会永久挂起。
---
## 8. 并发结构(两个 goroutine
```
schedulerLoop唯一持有任务状态与帧
for {
if 可切换 && preemptionRequest 有效 → 执行抢占(保存现场)
if running == nil → pick from 三集合;无候选则等待 inbox
runOneStep(running) // 可能是阻塞调用(见 §8.2
处理 step 结果 → 推进或结束任务
}
interruptLoop不持有任何帧
收 interruptCh → 定级 → 决策 → 置 preemptionRequest + cancel + wake scheduler
```
### 8.1 不变量
| # | 不变量 |
|---|---|
| I1 | 任意时刻至多一个 `running` 任务("一个 running"约束的是**副作用**,不只是 CPU |
| I2 | 任务帧只由 `schedulerLoop` 读写;`interruptLoop` 只写 `preemptionRequest` / 读 `stepCancel` |
| I3 | 任何跨挂起点的状态都是纯数据,不持有锁 |
| I4 | 安全点只在 step 边界;工具执行中与 COMMIT 不是安全点 |
| I5 | 每个任务恰好一次终态事件(含 `responseCh` 写入) |
| I6 | 调度器本身永不退出panic 只使当前任务失败) |
### 8.2 关于"调度器不被阻塞"**待确认 D2**
> **M3 拆分的理由**:真正的挂起要求帧跨越 `prepare → step… → finish` 全生命周期。
> 若只把 `process()` 改成可挂起,`processInput` 会在挂起返回后继续执行
> `context.Append` 与 `emitResponse`——造成重复提交。故 M3 分为 M3a所有权重构
> 行为等价)与 M3b抢占语义两步。
v1 采纳:**`S_TOOL_EXEC` / ONNX / CAS 属于临界区,调度器在这些 step 上会阻塞进插件 RPC / 原生调用。** 这是有意的取舍:
- 好处:与"两个 goroutine 就够"一致,实现简单,无临时 goroutine。
- 代价:这些临界区期间**中断只能排队,不能抢占**。换言之,**中断的有效窗口 = `S_LLM`**(与今天的实际行为相同,但现在是显式声明而非隐式结果)。
- 演进v2`S_TOOL_EXEC` 改成异步 step临时 goroutine + 完成事件),并给插件协议加 `tool.cancel`。此路径在文档保留,不在 v1 实现。
### 8.3 panic 隔离与 panic 中断
- `runOneStep` 外包 `recover`panic → 当前任务标记 `failed`**调度器继续**。
- panic 同时**产生一条内核 L4 中断**`reportTaskPanic``raiseKernelInterrupt`
内核把自己发生了 panic 这件事作为最高级中断通知给调度器,让 agent 能知情/善后。
- 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,不再产生新的 L4——
否则同一个 panic 会自我放大成中断风暴。
- 取代现有 `eventLoop`/`interceptLoop``recover → sleep 1s → go loop()` 无退避重启(`eventloop.go:19-22,38-42`)。
---
## 9. 失效模式与防御
| 失效 | 防御 |
|---|---|
| 饥饿(高优先级流反复抢占) | `preemptCount` 提升有效级:`effectiveLevel = min(4, baseLevel + min(preemptCount, 2))`;被抢占 +1。**只对中断生效**——排队任务无级别,按定义可被任何中断打断 |
| 无界下潜 | 中断栈帧数上界 4结构推论 = 中断级数);超限只做防御性计数,**不降级不丢帧** |
| 中断请求堆积 | 中断队列合计有界,满则丢最低级别里最老的一条并计数 |
| 就绪队列满 | 阻塞发送方 + 计数(不静默丢) |
| 同一任务反复被打断 | `preemptCount` 达阈值后有效级提升;另设**抢占冷却**:刚被抢占的任务在 `cooldown` 内不再被同级/更低级抢占 |
| 任务永不结束 | 每任务 `maxTurns`(主循环目前缺失,见审查 P0+ 每步超时 |
| 不可观测 | `Scheduler.Dump()` 原子快照 + 事件(切换原因、降级次数、丢弃次数) |
---
## 10. 非目标v1 明确不做)
1. 工具级取消 / 可抢占工具(`tool.cancel`)。
2. 多 agent 并行(仍是单 agent 单调度器)。
3. 公开 SDK 接口变更。
4. 微抢占(任意指令级)。
5. 跨进程恢复(帧不落盘)。
---
## 11. 测试点、测试方式与预期结果
测试基础设施(先于 M1 落地):
- **假时钟** `Clock` 接口(`Now()` / `AfterFunc`),生产用真实实现,测试注入可控时钟。
- **假 Provider**:实现 `agentAPI.Provider`,返回脚本化的 `tool_calls` 序列(支持"第 N 次调用时挂起直到放行")。
- **假工具**:测试内 `StageHost.RegisterTool` 注册,可控制每次执行耗时、是否返回错误、是否触发中断注入。
- **同步栅栏**:测试通过 `scheduler.Inbox` 注入中断并用 `runtime.Gosched` + 显式 `waitFor(state)` 断言,不用 sleep 猜时序。
- **快照断言**`scheduler.Dump()` 返回 `{running, queue, interruptQueues[1..4], immediate, suspendStack, counters}`,测试对纯数据断言。
### 11.1 优先级与抢占
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| P1 | 更高中断抢占中断 | running=L2 在 `S_LLM`;注入 L3 中断 | L2 压入中断栈step=S_LLML3 进 `immediate` 并变 running |
| P2 | 相等级别不抢占 | running=L2 中断在 `S_LLM`;注入 L2 | 不抢占;请求入 L2 中断队列running 不变 |
| P3 | 更低级别不抢占 | running=L3 中断;注入 L2 | 同上,不抢占 |
| P4 | 逐级抢占嵌套 | 排队任务 → L1 → L2 → L3 → L4均在 `S_LLM` | 中断栈深度依次 1/2/3/4每层 step 均为 S_LLM |
| P5 | 抢占后在安全点才生效 | running=排队任务在 `S_TOOL_EXEC`;注入 L4 | 抢占**不立即生效**;工具返回后才保存/切换;`deferredPreemptions==1` |
| P6 | 临界区不可抢占 | running 声明临界区;注入 L4 | 同上L4 请求留在中断队列,临界区结束立即被选中 |
| **P7** | **排队任务被任何中断打断** | running=排队任务;注入 **L1** 中断 | L1 也抢占成功(排队任务有效级 0 |
| **P8** | **排队输入永不抢占** | running=任意任务;注入排队输入 | 不抢占,入排队队列 |
| **P9** | **外部插件不能声明 L4** | 外部来源声明 `Priority="L4"` | 被夹到 L3proc 桥 + core 双重) |
| **P11** | **内核级插件可用 L4** | 内置插件(如 webui声明 `L4` | 得到 L4 并立即打断当前任务(终止按钮) |
| **P10** | **panic 产生 L4 中断** | 任务 panic | 产生一条带 `kernel=true` 的 L4 中断L4 自身 panic 不再递归 |
### 11.2 保存现场与恢复
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| R1 | 在 `S_LLM` 抢占后恢复 | 构造 A 在 `S_LLM` 被 B 抢占B 结束 | A 恢复后**重新发起** LLM 请求;`msgs` 与 A 被抢占前**逐字节相同**;不重复执行已完成的工具 |
| R2 | 在 `S_TOOL_BEGIN` 抢占后恢复 | A 完成 1 个工具批后于 `S_TOOL_BEGIN` 被抢占 | A 恢复后继续**下一批**工具;`toolResults` 长度不变 |
| R3 | 恢复结果与不中断一致 | 同一脚本跑两次:一次中途注入中断,一次不注入 | 两次最终 `context` 事件序列**除"中断任务自身的事件"外一致**A 的 `toolsUsed` 顺序相同 |
| R4 | 嵌套恢复顺序 | L4→L3→L2 依次抢占后依次结束 | 按有效级/到达序恢复;每个任务的 `frame.step` 与其被挂起时一致 |
| R5 | 不完整的 LLM 请求被丢弃 | 假 Provider 在流式途中触发中断 | 该次请求被 cancel**不产生任何 `msgs` 追加、不产生 tool_call**;恢复后重发次数 = 1 |
### 11.3 回执路由
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| X1 | 任务级回执不误投 | A 为同步任务并已挂起B 为同步中断 | B 的回执只到 `B.responseCh``A.responseCh` 在 A 恢复并结束后才收到自己的回执 |
| X2 | 断链路径必有终态 | 分别构造解析失败、10s 去重命中、`_consolidation_` | 三种都产生 `skipped` 终态事件并回执;同步调用方**不挂起** |
| X3 | 每任务恰一次终态 | 统计 `S_FINISH` 次数 vs 任务数 | 相等I5无重复写入 |
| X4 | 无超时同步注入不再永久挂起 | `cli` 路径(无超时)注入一条会被去重的输入 | 返回 `skipped` 回执而非永久阻塞 |
### 11.4 队列与选择
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| Q1 | 中断队列按级别扫 | 四条中断队列各放一个,入队顺序与级别相反 | 取出顺序 L4→L3→L2→L1中断耗尽后才是排队任务FIFO |
| Q2 | 挂起现场优先于新排队工作 | A 被抢占挂起 + B 为新排队输入 | A栈顶先被选中 |
| Q3 | 栈顶 vs 中断队头 | 栈顶 L3 + 队头 L2 / 栈顶 L3 + 队头 L4 / 栈顶为排队任务 + 队头 L1 | 分别取 栈顶 / 队头 / 队头 |
| Q4 | 就绪队列背压 | readyQueue 满后注入排队输入 | 发送方阻塞 + 计数 +1不静默丢弃 |
| Q5 | 中断队列溢出 | 中断队列合计满后注入更多 | 丢**最低级别里最老**的一条 + 计数;其余保持 |
| **Q6** | **immediate 最优先** | `immediate` 非空且中断队列里有更高级别 | 取 `immediate`(抢占必须立即生效) |
### 11.5 深度、饥饿与并发
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| D1T | 下潜深度上界(结构推论) | 挂起 3 帧后继续注入;再挂起到 4 帧 | 3 帧时 `canSuspend()==true`4 帧(全链:排队+L1+L2+L3L4 运行中)时为 `false` |
| G1 | 饥饿防护(抢占提升) | 对同一 **L1 中断**连续抢占 5 次(同级/高级交替) | `effectiveLevel` 提升至 `min(4, 1+2)=3`;第 3 次后不再被 L1/L2 抢占 |
| G2 | 冷却生效 | 同一中断刚被抢占后立刻再注入同级中断 | 冷却期内不抢占,请求入中断队列 |
| **G3** | **提升也必须只在中断间生效** | 排队任务被连续抢占 | 排队任务有效级恒 0不被提升它按定义可被任何中断打断 |
| K1 | panic 隔离 | 假工具 panic | 只有该任务变 `failed`;调度器存活;后续任务正常执行 |
| K2 | 竞态检查 | 全部调度用例加 `-race` | 无数据竞争报告 |
| O1 | 快照一致性 | 在任意 step 边界调 `Dump()` | 返回的 `running/ready/pending/suspend` 三集合互不重叠且总数守恒 |
| O2 | 切换可观测 | 每次抢占/恢复 | 产生一条事件(任务 id、原因、from→to、level |
### 11.6 端到端
| 编号 | 测试点 | 方式 | 预期结果 |
|---|---|---|---|
| E1 | 真实 provider + 假长工具 | 启动内核,用一个会阻塞 5s 的假工具跑 L1 任务,途中经 `interceptCh` 注入 L4 中断 | 中断**在工具执行期间不被处理**;工具返回后立即抢占;中断任务先完成;原任务恢复并完成 |
| E2 | LLM 流式中断 | 假 Provider 慢速流式返回 | 中断后当前流被 cancel任务挂起中断任务完成原任务恢复并重新请求 |
| E3 | 现有 e2e 回归 | 跑 `internal/plugins/integration_test.go``real_plugin_smoke_test.go` | 行为不变(除文档化的语义变化) |
| **E4** | **优先级压力(用户指定形状)** | 固定内容假 provider**记延迟,且被取消时立刻返回**100 条排队输入 + 100 条中断L1/L2/L3/L4 各 25混合打入每条中断都等到“该被它打断的受害者正在跑”时才注入 | 200 个任务全部到达终态;`Rejected=0`;各级登记数 = 25**各级抢占数都 > 0**;排空后 `Suspended == Resumed`;每次“取消流式段”都换来一次挂起 |
| **E5** | **嵌套到结构上限并 LIFO 展开** | 排队任务运行中依次注入 L1→L2→L3→L4每级都等上一级在跑 | 栈深峰值恰好 **4**= 结构上限,`canSuspend()==false`);恢复顺序严格 LIFO `[L3, L2, L1, 排队]``Suspended==Resumed==4` |
> E4/E5 的 provider 必须**感知 ctx 取消**:否则抢占只能等任务自然结束,
> 测到的全是"步骤之间让位",流式段的取消路径(真正的现场保存/恢复)压不到。
> 实测:不感知取消时 `LLM完成 == 任务数`、挂起接近 0感知后取消次数与挂起次数一一对应。
---
## 12. 待确认决策(含默认取值)
> 未获异议时按"默认取值"实现;每项单独一个 commit便于回退。
| 编号 | 问题 | 默认取值 |
|---|---|---|
| **D1** | 中断任务的上下文 | **方案 B已定**:中断从上一个任务之前的完整状态开始;恢复时把被挂起任务的现场加载回中断之上 |
| **D2** | 阻塞 step 处置v1 全部声明为临界区(调度器可被阻塞)还是引入异步 step | **v1 = 临界区**;异步 step 留到 v2 |
| **D3** | 中断队列与排队队列是否合一 | **完全分离**中断按级别分四条队列L4→L1 扫描),排队队列纯 FIFO两者不共用比较器 |
| **D7** | 任务类别怎么定 | **由注入 API 决定**`InjectInterrupt*` = 中断;`InjectText*`/`InjectInputSync*`/自循环 = 排队),**不按通道名推断** |
| **D8** | L4 归谁 | **内核独占**。唯一入口 `(*Agent).raiseKernelInterrupt`panic / selfip`clampPluginLevel` 把插件声明夹到 L3 |
| **D9** | L1L3 归谁 | **插件在 `InjectOptions.Priority` 里声明**(纯追加字段);空/非法降级到 L1 |
| **D10** | 抢占者进入队列还是立即运行 | **立即运行**`immediate` 槽)。这消除“抢占者与被挂起者同级”的比较,删除了早期的“同级 pending 优先”补丁 |
| **D11** | 中断栈帧数上界 | **结构推论 = 4**(排队 L0 + I1 + I2 + I3 挂起I4 运行中),不是配置项;超限只计防御性计数 |
| **D4** | readyQueue 满时:阻塞发送方 or 返回错误 | **阻塞发送方 + 计数**(与现状一致,避免丢用户输入) |
| **D5** | 饥饿防护:抢占计数提升 or 时间老化 | **抢占计数提升**(确定性、易测);时间老化留待需要时 |
| **D6** | 主循环 `max_tool_turns` 是否在本特性一并落地 | **是**(审查 P0且调度器需要"任务可终止"这一前提) |
---
## 13. 与发布纪律的关系
- 本特性在 `feature/input-semantics` 上开发,完成后合回 `main`**不碰 `release/v1.2.x`**。
- **公开 SDK 在本特性上有意新增**feature 分支不受 rel 分支的接口冻结约束):
`sdk.InjectOptions.Priority``sdk.PriorityL1/L2/L3`。这是为了让插件能声明
自己中断的级别§3.2)。
- **追加是唯一的形态**:不改既有字段、不改签名、不改语义;`Priority` 的零值
等价于旧行为L1
- 合回 `main` 前需完成的发布动作:
1. 同步更新 `docs/zh/plugin-interface-matrix.md`
2. 与 SDK 仓协同升 SDK 中版本;
3. 遵守“只增不减、签名不改”边界。
- 内核侧接口(`internal/agent/io`、proc 桥的 `injectParams`/`injectMediaParams`
同步追加 `priority`,与公开 SDK 字段一一对应。
## 14. 实现里程碑(逐个实现,每个 = 一个可独立验收的提交)
| 里程碑 | 内容 | 验收 |
|---|---|---|
| **M0** | 测试基础设施:`Clock` 接口、假 Provider、假工具、`waitFor``Dump()` 骨架 | 新测试可运行;`go vet` 干净 |
| **M1** | **纯重构**:把 `process()` 拆成显式 step 状态机 + `TaskFrame`;仍由现有 `eventLoop` 驱动,无优先级/无抢占 | R3、X3 通过;既有全部 agent 测试通过(行为等价) |
| **M2** | 调度器骨架:单 `schedulerLoop` + `readyQueue`,取代 `eventLoop` 的输入处理;无优先级(全部 L1纯 FIFO | Q1/Q4 通过integration 测试通过 |
| **M3a** | **前置重构(本次拆分引入)**:把一轮对话的所有权从 `processInput` 移到调度器——帧覆盖 `prepare → step… → finish`;同时移除 `process()` 整轮持有的 `a.mu`(挂起不能持锁) | 既有全部 agent 测试 + 既有 e2e 通过(行为等价);`-race` 干净 |
| **M3b** | `interruptLoop` 重写 + 四级优先级 + 严格大于抢占 + 中断栈 LIFO只支持 `S_LLM` 抢占 | P1P4、R1、R5、K1K2 通过;嵌套 LIFO 判据通过 |
| **M4** | 临界区 + `S_TOOL_EXEC` 声明 + 中断队列 + 深度上界(**后经模型更正重做,见下** | P5P6、D1T、Q3、Q5 通过 |
| **M5** | 饥饿防护(抢占计数提升 + 冷却) | G1G2 通过 |
| **M6** | 任务级 `responseCh` + 断链点统一为终态事件 | X1X4 通过;`cli`/`clawhub` 不再挂起 |
| **M7** | 可观测性(`Dump()`/事件/状态页)+ 既有回归 | O1O2、E1E3 通过;`go test -race ./internal/agent/... ./internal/plugin/...` 全绿 |
每步收尾命令:
```bash
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
go build ./... && go vet ./...
go test -race -count=1 ./internal/agent/... ./internal/plugin/... ./internal/sdk/...
```
### 实现状态2026-09-13 完成)
| 里程碑 | 提交 | 验收结果 |
|---|---|---|
| M1 | `9a58878` | ✅ agent 全量 + `-race`;新增 `task_test.go` 4 项 |
| M2 | `7082a50` | ✅ 新增 `scheduler_test.go` 6 组(含 O1/K1 |
| M3a+M3b | `c69a1f1` | ✅ 新增 `task_lifecycle_test.go` 5 项、`scheduler_preempt_test.go` 5 项 |
| M4 | `7565248` | ✅ 新增 `scheduler_critical_test.go` 3 项 |
| M5 | `a971fc8` | ✅ 新增 `scheduler_starvation_test.go` 4 项 |
| M6 | `4e4e0ad` | ✅ 新增 `task_terminal_test.go` 3 项 |
| M7 | `f11de37` | ✅ 新增 `scheduler_e2e_test.go` 3 项(压力/可观测/端到端) |
#### 模型更正后的重构2026-09-13同一特性分支
用户逐条澄清后重做调度核心(**行为有意的语义变化**,非等价重构):
| 项 | 内容 | 验收 |
|---|---|---|
| 类别化 | `TaskClass{queued,interrupt}`;类别由注入 API 决定;`newInputTask`/`newSelfTask` 为 queued`newInterruptTask` 为 interrupt | `scheduler_kernel_test.go` P7/P8 |
| 级别归位 | `Level` 语义改为“中断级别”;`taskLevel()`(按通道名推断)删除,改为 `interruptLevel(evt, privileged)``payload["priority"]` | P9、P11、Q1 |
| L4 内核独占 | `raiseKernelInterrupt`panic/selfip`requestKernelPreempt` 不夹取panic 报告为 L4 且带递归保护 | P10、`TestKernel_PanicRaisesL4Interrupt` |
| 选择结构 | `immediate` + 四条中断队列 + 排队 FIFO + 中断栈;删除统一比较器 `pickTaskIndex`/`taskBefore` 与“同级 pending 优先”补丁 | Q1Q3、Q6 |
| 栈上界 | `maxSuspendDepth`(配置语义)→ `maxInterruptFrames = int(LevelCritical)`(结构推论);删除“超限转 pending”降级 | D1T |
| 公开 SDK | `InjectOptions.Priority` + `PriorityL1..L4`io/proc 桥/插件模板同步透传;`example/qq` 声明 L1、`timer` 声明 L3、`webui` 终止按钮声明 L4 | `go test ./...` 全绿 |
| 分级可观测 | `SchedulerStats.InterruptsByLevel[1..4]` / `PreemptsByLevel[1..4]`(按级别分桶,见 §11.6 E4 | 压力测试按级别断言 |
| 计数修正 | `Resumed` 原本在 `nextRef``resumeTask` **各计一次**(双计),使"排空后 Suspended==Resumed"失真;现只在 `resumeTask` 计 | E4 断言 |
| 压力测试 | `scheduler_stress_test.go`100 排队 + 100 中断(各级 25混合另加嵌套到 4 帧上限并验证 LIFO | E4/E5 |
实现期与设计的差异(均已回写本文档):
1. **M3 拆为 M3a/M3b**:真正挂起要求帧跨 `prepare→run→finish`,否则 `processInput`
会在挂起返回后继续提交。
2. **`a.mu` 整体移除**:它原本只包住整轮 `process()`(同一 goroutine
移除后所有任务状态由 schedulerLoop 独占(不变量 I2/I3 可落地)。
3. **`interceptCh` 被删除**M3b 起中断一律走中断队列(当时叫 `pendingInterrupts`),旧的
“同行注入 + 三处 drain + 批次放弃” 已无写入者属死代码M4 清理)。
4. **v1 未做 M0 的伪时钟**:所有抢占测试用“单次调用阻塞到 ctx 取消”的
provider 达到确定性,无需注入时钟。时序型判据(老化式提升)留待需要时。
5. **工具执行中不可抢占是被结构保证的**:让位检查只在 step 之间;
不需要在 step 内部再判一次。
---
## 15. 开放问题(后续版本)
1. 异步 step + `tool.cancel`(真正让工具可抢占)。
2. 帧落盘(跨进程/崩溃恢复)。
3. 多 agent 并行调度。
4.`plan.md` §13.7 的 `RuntimeManager + 分组 worker` 合并(本设计是其前置)。
> **已更正**:早期稿写“`InjectOptions.Priority` 进入公开 SDK 已被删除”,
> 前提是“优先级是内核内部属性、不应由插件声明”。用户澄清后该前提被推翻:
> **L1L3 就是给插件声明使用的**。L4 的归属后来也明确了——不是“只有
> panic/selfip”而是**内核 + 内核级插件**(编译期内置)都能用,用于实现
> “立即打断”panic、内核事件、WebUI 终止按钮)。因此公开 SDK 同时导出了
> `PriorityL4`(附“仅内核级插件”的说明)。
>
> 仍**不做**的是“运维可调的策略表”(`core.agent.priority.<channel>`)——
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。

455
docs/zh/multimodal-space.md Normal file
View File

@ -0,0 +1,455 @@
# 统一多模态向量空间
核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个
向量空间。仓库内自带两个:
| provider | 模态 | 维度 | 实测常驻 | 许可 | 适用 |
|---|---|---|---|---|---|
| `chineseclip` | text + image | 512 | **1.15 GB** | Apache-2.0 | 默认(内存受限 / 中文图文) |
| `qwen3vl` | text + image视频已实现未纳入契约 | 2048 | 9.4 GB | Apache-2.0 | 内存充足 / 需要更强文本语义或视频 |
| `http` | 由外部服务决定 | 由外部服务决定 | 由外部服务决定 | — | 侧车部署(如 jina-v5-omni-nano注意其 CC BY-NC 许可) |
下面第一节是 Qwen3-VL2048 维,最强但最重),第二节是 Chinese-CLIP512 维,
默认推荐)。两者互斥启用,改配置后重启生效。
文本、图像、**视频帧** 在同一模型、同一维度、同一 fingerprint 空间里被编码。
记忆系统用它做三件事多模态图记忆的跨模态召回、multimodal doc 的向量融合、
multimodal context 的相关性裁剪/淘汰。
统一空间取代了此前「把图片交给视觉模型生成文字描述、再按描述检索」的做法。
那条链路有三个致命缺陷:描述是异步生成的(未生成前媒体等于不存在)、语义检索
实际上只搜描述文字、图库里的「媒体节点」只是描述文本的投影而不是媒体本身。
**不要再引入任何描述式索引。**
## 一、产物与获取
产物约 8 GB含外部权重**不进仓库**;用导出脚本自动拉取模型并导出:
```bash
# 默认导出 图像 + 视频 G=2,3,4即 4/6/8 帧)
python3 scripts/export_qwen3vl_embedding_onnx.py \
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
# 只要 4 帧的视频档(省磁盘、省内存)
python3 scripts/export_qwen3vl_embedding_onnx.py --video-groups 2 --out ...
# 已下载过模型:跳过拉取
python3 scripts/export_qwen3vl_embedding_onnx.py \
--model-dir /path/to/Qwen3-VL-Embedding-2B \
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
# 参考向量默认直接写进产物目录(<out>/qwen_reference.json无需额外参数
python3 scripts/export_qwen3vl_embedding_onnx.py --model-dir ... --out ...
```
国内镜像:导出脚本沿用 `huggingface_hub` 的约定,直接 `export HF_ENDPOINT=https://hf-mirror.com` 即可。
依赖:`torch`CPU 版即可)、`transformers>=4.57``onnx``onnxruntime``pillow``numpy`
以及可选的 `huggingface_hub` / `modelscope`。显存不需要,内存建议 ≥ 16 GBFP32 加载约 8 GB
导出脚本**会清空 --out 目录**后重写,避免旧图/旧外部权重污染 fingerprint
fingerprint 变化会触发一次无意义的全量向量重算)。因此不要直接覆盖线上正在使用的目录,
先导出到新目录再切换。
### 产物契约Go 侧按此读取)
| 文件 | 输入 | 输出 |
|---|---|---|
| `TokenEmbedding.onnx` | `input_ids` int64 `[1,seq]` | `hidden` float `[1,seq,2048]` |
| `Transformer.onnx` | `hidden``deepstack_0/1/2` `[1,seq,2048]``rotary_cos/sin` `[1,seq,128]``causal_mask` `[1,1,seq,seq]` | `embedding` `[1,2048]` |
| `Vision.onnx(+.data)` | `pixel_values` `[2304,1536]` | `deepstack_feature_0/1/2``vision_hidden_states` `[576,2048]` |
| `Vision_g{N}.onnx` | `pixel_values` `[N×2304,1536]` | 同上,`[N×576,2048]` |
外加 `tokenizer.json``tokenizer_config.json``chat_template.jinja``embed_config.json`
`qwen_reference.json`
`Vision.onnx` 是图像(单时间组);`Vision_g{N}.onnx` 是视频N 个时间组 = 2N 帧)。
**没有 `Vision_g1.onnx`**——单组就是图像那张。
三段只是部署形式,不是三个向量空间:图文共用同一 token embedding、同一 28 层
Transformer、同一 last-token 池化。RoPE 与视觉特征散射故意留在 Go 计算,
因为旧式 tracer 会把 `seq=598 / visual=576` 烘焙进图里——签名上写着 dynamic
axis实际却只能用导出的那个长度运行。
### ⚠️ max_length 必须按最大视频档推导
`embed_config.json``max_length` 是**整条序列**的上限,包含视觉占位符:
图像只需 598 token1×576 + 模板),而视频是 G×576——G=2 就要 1190G=4 要 2342。
沿用图像的 1024 会让处理器静默截断,然后在 transformers 内部报
`Mismatch in video token count between text and input_ids`
导出脚本因此用 `max_length_for(video_groups) = max(1024, max(G)×576 + 256)` 自动推导,
并在构造视觉输入后显式断言视觉 token 数,把错误提前到导出阶段。
### 导出脚本自检(不可省)
脚本内部跑两道校验,任一道 cos < 0.999999 就以非零码退出
1. 分段 PyTorch三段组合对比完整模型前向
2. onnxruntime **导出后**的三段图再对比完整模型前向
能加载不等于算得对」:形状错输入名错池化位置错的图都能正常 load
## 一·补、text+image 默认空间Chinese-CLIP ViT-B/16
**为什么它是默认**text+image 只需要一个向量空间时同时满足可商用中文原生
的选项只有一个
| | Chinese-CLIP | jina-v5-omni-nano | Qwen3-VL-Emb-2B |
|---|---|---|---|
| 参数量 | 188M | 1.04B | 2B |
| 产物 / 实测常驻 | **721MB / 1.15GB** | ~2GB / 2.23GB | 8GB / 9.4GB |
| 维度 | 512 | 768 | 2048 |
| 许可 | **Apache-2.0** | CC BY-NC不可商用 | Apache-2.0 |
| 中文 | 原生~2 亿中文图文对 | 多语言 | 多语言 |
| 文本语义 | 双塔对比 | | 最好 |
| 视频 | | | |
**要诚实记录的代价**CLIP 是双塔对比学习textimage 是强项**纯文本语义
texttext明显弱于 MLLM 型嵌入器**。文本检索仍由既有词向量/TF-IDF 路径兜底
本空间主要用于跨模态召回与相关性裁剪需要更强文本语义或视频时切回 `qwen3vl`
### 产物与获取
产物约 754MB**不进仓库**用导出脚本从官方权重导出脚本入库保证可复现
```bash
python3 scripts/export_chineseclip_onnx.py \
--model-dir /path/to/chinese-clip-vit-base-patch16 \
--out /home/newqqagent/models/chinese-clip-vit-b16-onnx
```
国内下载本机 `huggingface.co` 走代理会被 reset `hf-mirror.com` **不设代理**
```bash
curl -4 -L --retry 3 -o vocab.txt \
https://hf-mirror.com/OFA-Sys/chinese-clip-vit-base-patch16/resolve/main/vocab.txt
```
### 产物契约Go 侧按此读取)
| 文件 | 输入 | 输出 |
|---|---|---|
| `TextEncoder.onnx` | `input_ids` int64 `[B,52]``attention_mask` int64 `[B,52]` | `text_features` float `[B,512]` |
| `VisionEncoder.onnx` | `pixel_values` float `[B,3,224,224]` | `image_features` float `[B,512]` |
外加 `embed_config.json`维度/预处理/分词超参/文件名——provider 的唯一权威)、
`vocab.txt``reference.json`冻结参考逐文本 token id + 逐样本向量)、`SHA256SUMS`
图像预处理缩放到 224×224双三次复刻 PIL 系数)→ `(x/255 - mean) / std`
不裁剪文本BERT WordPiece`max_length=52` `[PAD]`超长截断尾部
两个塔的输出**都没有在图中归一化**归一化由 provider 负责检索按余弦)。
### 启用
```bash
core.memory.multimodal_space.provider = chineseclip
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/chinese-clip-vit-b16-onnx
```
**新装默认就是这个**`SeedDefaults` 写入 `chineseclip` + `<dataDir>/models/chinese-clip-vit-b16-onnx`
发行版构建也默认带 `onnxruntime` 标签`deploy/packaging/build.sh` `HOMED_TAGS`
需要极简构建时显式 `HOMED_TAGS=` 关闭)。
**老安装不会自动拿到**播种判据是显式标记 `core.internal.seed_version`
老安装已播种过下次启动只会被补上标记**不会**被注入新默认值——
升级就静默加载 1.8GB 模型不是无副作用的事要启用请显式写上面两个键
> 这个判据曾经是「`config` 表为空才播种」。而发行包的 postinst 会先跑
> `initconfig`,它写一行 `webui.listen_addr` ——于是**全新安装**被误判为
> "已有配置",整个播种被跳过:没有 `core.plugin.dir`(装完 0 个插件)、
> 也没有多模态 provider随包的模型与运行库成了死重量。回归测试
> `TestSeedDefaultsAfterInitconfigPrepopulate` 与
> `TestSeedDefaultsDoesNotInjectIntoLegacyInstall` 钉住了这两种情形。
同样要求 `homed` `onnxruntime` build tag
### 随包分发server / full 包自带模型与运行库)
模型与运行库是发行版能力的一部分不做成可选下载」:
| 内容 | 包内路径 |
|---|---|
| Chinese-CLIP 产物754MB | `/usr/lib/homeagent/models/chinese-clip-vit-b16-onnx/` |
| ONNX Runtime24MB | `/usr/lib/homeagent/onnxruntime/libonnxruntime.so` |
| 许可证 | `/usr/share/doc/homeagent/licenses/`Apache-2.0MITThirdPartyNotices模型来源 |
- `deploy/packaging/package-linux.sh` `stage_multimodal_assets()` 在打 server/full
会校验产物 `SHA256SUMS`逐文件非空运行库架构与目标一致**缺一即失败**
不生成默认启用但装完不能用的假包`client` 包不含它不跑 homed)。
- 安装时 `setup.sh` 把包内模型目录软链到 `<dataDir>/models/chinese-clip-vit-b16-onnx`
既不复制 754MB也保持 dataDir 可迁移已存在的自定义目录绝不覆盖)。
- 服务单元设 `Environment=ONNXRUNTIME_DIR=/usr/lib/homeagent/onnxruntime`
provider 的查找顺序是 `ONNXRUNTIME_DIR` `ONNX_ML_DIR` 包内路径
`/opt/onnxruntime` `/usr/local/lib` `/usr/lib`
- 构建机需自备产物`build/model-assets/chinese-clip-vit-b16-onnx/`
`build/runtime-assets/<arch>/{libonnxruntime.so,LICENSE,ThirdPartyNotices.txt}`
可用 `CHINESECLIP_BUNDLE_DIR` / `ONNXRUNTIME_ASSET_DIR` 覆盖)。
实测从真实 deb 解包 postinst 顺序跑 `setup.sh`再冷启动包内 homed
`multimodal space active: provider=chineseclip dim=512 fp=cd2a495cf990 modalities=[text image]`
并完成一次真实对话`homeagent-server` 722MB旧版 17MB差额即模型与运行库
#### ORT 环境是进程级单例(单主不析构)
进程内可能有多个 ORT 消费者 provider`qwen3vl``internal/nlp` 的依存解析器)。
`onnxruntime_go` 的行为是第二次 `InitializeEnvironment` 报错
`DestroyEnvironment` 会把别人正在用的环境一起拆掉约定
- 初始化前先 `IsInitialized()`只有未初始化时才初始化
- **任何消费者都不销毁环境**环境随进程存活只销毁自己的会话
这个缺陷是发行版默认带 onnxruntime 标签后才暴露的不带标签时多个消费者不会
同时存在此前 `internal/nlp` 会重复初始化并降级失败路径还会误销毁环境)。
### 模态范围
只声明 `text` `image``audio`/`video` **明确返回 `ErrUnsupportedModality`**——
本空间没有它们的原生编码器用别的模型向量冒充会污染整个向量空间
这正是音频明确 unsupported那条纪律的落地)。
### 验证
Go 侧回归对着官方 PyTorch 参考`reference.json`模型目录由
`CHINESECLIP_MODEL_DIR` 指定缺失时 skip
```bash
CHINESECLIP_MODEL_DIR=/home/newqqagent/models/chinese-clip-vit-b16-onnx \
go test -tags onnxruntime ./providers/chineseclip/ -v
```
实测结果文本 5 个用例 `cos = 1.000000000000`与官方逐位一致
图像 4 个纯色用例 `cos = 1.000000`自写 bicubic PIL 6 位小数内一致
另有跨模态判别模态拒绝指纹稳定性产物缺失报错等用例
### 两个已踩过的坑(都在测试里钉住了)
1. **分词器不能自己拼**第一版探针用 `BertTokenizer(vocab_file=..., do_lower_case=True)`
手工分词中文被整体切成 `[UNK]`三个不同句子产出几乎相同的向量余弦 0.98
差点把模型坏了当成结论官方配置是 `do_lower_case=true` + **删音标生效** +
**中文逐字切分**Go 侧实现必须与官方** token** 对齐`TestTokenizerMatchesOfficialReference`)。
2. **参考向量是未归一化的原始输出**模长 10~36)。点积当余弦 + 单侧下界判定
会得到 13.6 通过」——测试里因此改成真余弦 + 双侧容差
## 二、启用
核心不识别任何具体模型它只按配置里的 **provider **从公共注册表
`pkg/embedding`打开一个 provider并把 `options.*` 原样交给它
模型文件布局预处理媒体解码运行时都在 provider 内部
```bash
# 配置库config.db或 WebUI 设置页
core.memory.multimodal_space.provider = qwen3vl
core.memory.multimodal_space.options.model_dir = /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
# 或换成一个外部向量服务(任何语言写的都行)
core.memory.multimodal_space.provider = http
core.memory.multimodal_space.options.endpoint = http://127.0.0.1:18999/embed
core.memory.multimodal_space.options.dimension = 2048
```
`options.*` provider 自己的命名空间核心不做任何解释 `qwen3vl`
`model_dir` `http` `endpoint`/`dimension`/`api_key`/…)。第三方 provider
可以定义自己的选项无需改核心
注意事项
- 内置 provider `qwen3vl` 要求 `homed` `onnxruntime` build tag 构建
`libonnxruntime.so` 可被找到`/opt/onnxruntime/libonnxruntime.so` )。
未带 tag 时该 provider 会注册但打开时报requires build tag」,而不是静默降级
- `provider` 为空时禁用多模态向量检索退回纯 fastText 文本路径
- 改配置后需重启进程生效
- 未配置时优雅降级文档层退到 TF-IDF 稀疏检索媒体块仍按结构边关联只是没有跨模态召回
## 二·补、给核心接自己的模型
核心只依赖一个很小的公共接口`pkg/embedding`
```go
// 输入对核心是不透明字节modality 决定语义Data+MIME 由 provider 解释。
type Input struct {
Modality Modality // text / image / audio / video / …
Purpose Purpose // query / document
Text string
Data []byte
MIME string
Metadata map[string]string
}
type Provider interface {
Embed(ctx context.Context, in Input) ([]float64, error)
Info() Info // Dimension, Fingerprint, Modalities
Close()
}
```
接入步骤新建一个包 `init()` `embedding.Register("your-model", factory)`
再把这个包空白导入你的发行版 `main`或替换内置 provider 的导入行)。
分词预处理解码显存/内存管理模型文件命名全部由你的 provider 决定
两条原则值得强调
- **能力是数据不是接口方法**支持哪些模态写在 `Info().Modalities`
这样新增模态不需要改核心接口核心也不需要为每个新模态做类型断言
- **不支持的模态返回 `embedding.ErrUnsupportedModality`**而不要拿别的模型顶替
也不要降级成一个普通错误——调用方靠它区分永远不会有向量本次失败可重试」。
## 三、模态覆盖范围
### Qwen3-VL-Embedding-2B本空间2048 维)
模型卡明载支持 **Text / images / screenshots / videos**`config.json`
`image_token_id` `video_token_id`**没有 `audio_token_id`/`audio_config`**。
| 模态 | 状态 | 说明 |
|---|---|---|
| 文本 | 原生 | `VectorizeDense` |
| 图像 | 原生 | `EmbedImageDense``Vision.onnx`固定 768×768 |
| 视频 | 视觉侧已导出并校验**Go 模板未完成** | `EmbedVideoDense` + `Vision_g{N}.onnx`见下节 |
| 音频 | 本轮明确不做 | 决策结果该模型也不具备 `audio_token_id` |
### 视频:帧 → 时间组 → M-RoPE均已实测对齐
| | | 验证方式 |
|---|---|---|
| 占位符 | `<|video_pad|>` = **151656**图像是 `<|image_pad|>` = 151655 | 处理器实测 |
| 模板 | 与图像同构只换占位符 | `apply_chat_template` repr 逐字符比对 |
| 槽位 | g tp0帧2gtp1帧2g+1 | PyTorch `torch.equal == True`maxdiff=0反向对照 False |
| patch 布局 | `[G,24,24,2,2,3,2,16,16]`即图像排列以 grid_t 为最外层堆叠 | 纯色视频于图像张量 `torch.equal == True` |
| 视觉 token | `G×576` | 处理器实测G=2 1152 |
| M-RoPE | 每组独立`base=start+24g``t=base``h=base+j/24``w=base+j%24` | 对应 `get_rope_index` video grid 展开成 G `t=1` |
| 用错档 | onnxruntime `InvalidArgument`维度不符 | 实验实测**不会静默算错** |
同步注意事项
- **帧数必须恰好是 `2×G`**G 取已导出的档)。奇数帧时只用得上前 `2×floor(n/2)`
多出的丢弃——不补重复帧那会改变跳帧注意力看到的运动
- **`video/*`视频文件不能直接喂给图像入口**Go 侧没有视频解码器
`EmbedImageDense(raw, "video/mp4")` 返回 `ErrModalityUnsupported`调用方必须先抽帧
- 视觉图按需懒加载每张约 1.6GB未用到的档位不占内存
### 导出视频时踩过的两个坑(都已加断言)
两个坑都会让产物看起来正常实际是错的」,且都不会在导出时报错
1. **处理器会静默重采样帧**不给 `video_metadata` 时它回落到 `fps=24`
**任何**帧数都改成 `grid_t=2`实测 4/6/8 帧全部得到 1152 个视觉 token
修法`processor(..., videos=[frames], do_sample_frames=False)`
2. **`max_length` 只按图像算是不够的**。它是整条序列含视觉占位符的上限
图像只需 598 token而视频是 `G×576`——G=2 1190G=4 2342
沿用 1024 会截断并报
`Mismatch in video token count between text and input_ids`
修法`max_length_for(G) = max(1024, max(G)×576 + 256)`
两个坑都会在导出脚本里显式断言视觉 token `video_grid_thw` 的组数
把错误提前到导出阶段而不是留给运行时
### 音频(本轮决策:不加)
**Qwen3-VL 不支持音频**由模型卡与 `config.json` 双重确认
```
模型卡Supported Input Modalities: Text, images, screenshots, videos, and …
configimage_token_id ✓ / video_token_id ✓ / audio_token_id ✗ / audio_config ✗
```
本机有音频能力的是另一个模型**jina-v5-omni-nano**768
`modeling_llava_eurobert_audio.py` `audio_token_id=128256` Qwen 空间
**不同维度、不同坐标系,绝不可互相比较**决定**本轮不接入**
其侧车`scripts/embed_sidecar.py`也仍只实现 `text`/`image``audio` 返回 400
无论何时接入**不允许**拿视觉塔去编码音频字节或用另一个模型的向量
冒充某空间的音频向量——那会把两套坐标系混进同一空间且错误是静默的
音频在原空间返回 `vector.ErrModalityUnsupported`使调用方区分
永远不会有向量本次失败可重试」。
Qwen3-VL 视觉塔把 `grid_thw` Python 值消费源码里是 `grid_thw.tolist()`
legacy tracer`dynamo=False`会把它固化成常量实测把 `grid_thw` 声明为图输入后
导出的 ONNX 图里**根本没有该输入**换帧数调用直接报 `Invalid input name: grid_thw`
导出时的 TracerWarning 明确提示
`Converting a tensor to a Python list might cause the trace to be incorrect`
因此视频的可行做法是**在导出时固定时间组数 G每个 G 一张 Vision **
grid = `[G, 48, 48]`Go 侧按实际帧数选用匹配的图 G=2 的图去喂 G=3
数据属于未定义行为视频文件本身不能直接喂进本空间`video/*` 返回
`ErrModalityUnsupported`必须由上层先抽帧
## 四、验证
```bash
# Go 侧ONNX 路径(模型目录缺失时自动 skip
QWEN_ONNX_MODEL_DIR=/home/newqqagent/models/qwen3-vl-embed-multimodal-onnx \
go test -tags onnxruntime ./internal/memory/qwen/ -v
# 排除二进制交付问题的替代:先单独验证模型与 CSV 无关的 ONNX 图
go vet -tags onnxruntime ./...
```
Go 测试覆盖冻结参考向量文本/图像各 12 )、同输入确定性不同输入敏感性
图像与文本向量必须不同以及音频/视频必须返回 `ErrModalityUnsupported`
冻结参考向量由导出脚本写入**产物目录本身**`<out>/qwen_reference.json`
来源可追溯同一脚本既产出模型也产出这个模型对固定输入应有的输出」。
重新导出后若参考值变化说明权重或图结构变了必须显式更新参考而不是放宽阈值
> **参考向量是 L2 归一化后的值。** ONNX 图返回的是 final norm 之后的原始
> last hidden量级约 100而 Go 侧 `VectorizeDense` / `EmbedImageDense`
> 返回归一化向量。写参考时忘归一化Go 测试会全线不匹配,而现象看起来
> 像“模型不对”,实际只是两边对“向量”的定义不同。
验证既有产物不重新导出
```bash
python3 scripts/export_qwen3vl_embedding_onnx.py --verify-only --model-dir <model> \
--out /home/newqqagent/models/qwen3-vl-embed-multimodal-onnx
```
脚本会顺便把归一化后的参考向量写入该目录
### 与现有部署产物的等价性
本仓库脚本对同一源模型导出时`TokenEmbedding.onnx` `Transformer.onnx`
线上在用的产物**逐字节相同**sha256 一致`Vision.onnx` 差异仅在打包形式
旧产物把权重量到外部 `Vision.onnx.data`新脚本内联在图里两者数值等价
注意这会带来一个**操作性**差异Go 的结构指纹`computeFingerprint`
`*.onnx.data` 的文件名与大小算在内因此外部权重版 内联版互换会让
fingerprint 变化从而触发一次全量向量重算重算不会**算错**数值等价
只是白花一次 CPU若不想触发就保持产物打包形式不变
## 五、资源成本
- 产物磁盘约 8 GB导出过程峰值内存约 1012 GBFP32 加载)。
- 单次 CPU 推理文本约几十毫秒量级图像2304 patch 24 层视觉塔 + 28 层语言模型
明显更重因此入库时不阻塞对话 `reembedStaleMedia` 在启动时并发迁移
ONNX 路径 4 worker)。
- fingerprint 由三段图 + `embed_config.json` + 外部权重文件名/大小共同决定
换模型或重新导出都会让它变化从而触发历史向量重算——这是预期行为
## 视频:当前状态(未完成,不得当作已验证)
**视觉侧**`Vision_g2/g3/g4.onnx` 已导出且每一档都与完整 PyTorch 模型逐档对过
`cos` 分别为 1.000000119 / 1.000000119 / 1.000000000覆盖度断言通过)。
**Go 侧模板** HuggingFace processor 产出**不相等**因此冻结回归
`TestEmbedderVideoMatchesONNXReference`当前**显式跳过**并注明原因不算通过
已定位的差异processor 会按时间组插入字面时间戳文本 token 实测
```
<|vision_start|> <0.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
<1.0 seconds> <|vision_start|> {576×<|video_pad|>} <|vision_end|>
```
Go 侧只生成 `<|vision_start|>{G×576 pads}<|vision_end|>`同一输入下
Python `seq=1190`1152 视觉 + **38** 文本Go 侧只有 **22** 个文本 token
注意两点
- 时间戳文本**也占用 M-RoPE 位置**所以 `TestVideoModelInputMRope` 的自洽断言
通过**不能**证明与官方实现一致它是拿自己算的序列验自己算的位置)。
- 修复位置在 provider 内部模型专属模板本就属于 provider不是核心
另外公共 provider 契约把 `Data+MIME` 交给 provider 自行解码 provider
没有视频解码器Go 标准库不含 H.264/MP4因此 `Info().Modalities` **不声明 video**
`Embed(video)` 返回 `ErrUnsupportedModality`视频走 provider 自己的
`EmbedVideoDense`接收已解码帧)。待核心有了对 provider 不透明的多帧容器后
再把视频纳入公共契约

View File

@ -1,13 +1,17 @@
# 外部插件接口不变矩阵(多进程化整改基线)
> 状态:**完成 v2**2026-09-03)——迁移已落地并上生产内核 v1.0.0。
> 状态:**完成 v3**2026-09-06)——v2 的迁移已上生产内核 v1.0.0v3 记录 v1.1.1 的公开接口**扩展**
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
> 所有改造落在**核心homed 侧)+ 工具链plugindev**,外部插件业务代码零改动,只需用新 plugindev 重编。
> 所有改造落在**核心homed 侧)+ 工具链(hmapdev当时名为 plugindev**,外部插件业务代码零改动,只需用新工具链重编。
>
> **结果(已验证)**`git diff third_party/homeagent-sdk/sdk/` 全程为空17 个 `example/*/plugin.go` 逐字节未改
> `git status example/` 无输出);生产 17 插件全部经子进程通道运行。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
> ⚠️ **v1.1.x 起冻结约束被有意解除**,因为「接口不变」这条约束本身是为**迁移期**设的:
> 它要保的是「换运行模型不动业务代码」。迁移完成后SDK 需要能随功能演进而扩展,
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
> 必须同步更新本矩阵。
>
> 权威编号plan.md 第 11 节11.1~11.9)。本文档只做接口面盘点,不做实现。
@ -17,9 +21,9 @@
## 一、迁移的形状(一句话)
```
今天: 外部插件 = example/*/plugin.go纯 Go ──plugindev c-shared──> plugin.so
今天: 外部插件 = example/*/plugin.go纯 Go ──hmapdev c-shared──> plugin.so
homed ──dlopen──> plugin.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──plugindev go build──> plugin.bin
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──hmapdev go build──> plugin.bin
homed ──spawn──> plugin.binstdio JSON-RPC + shm + eventfd
```
@ -29,8 +33,8 @@
|---|---|---|
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go只 import 公开 SDK | **不动**(只重编) |
| bridge 模板 `tools/plugindev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
| 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` |
@ -67,7 +71,7 @@ type Plugin interface {
|---|---|---|
| `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/Remove/Stats | 低 |
| `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 当前 |
@ -85,7 +89,14 @@ type Plugin interface {
| `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)` | **当前空实现**C ABI 无对应),迁移后经 arena 二进制注入可实现 |
| `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/核心启动时接线,插件不直接调 |
@ -99,8 +110,12 @@ type Plugin interface {
| `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`
@ -252,7 +267,9 @@ Part 0.2 先做了过渡补丁只回传真正变更的字段Part 4 的
| 能力 | 迁移前 | 迁移后 | 实际结果 |
|---|---|---|---|
| 事件订阅 `Events().Subscribe`case 23/24 | ❌ 空实现 | ✅ 事件环EvtRing + eventfd + 独立游标) | ✅ 已接线(当前零用户) |
| `SetToolBlocks` 多模态注入 | ❌ 空实现 | ✅ 二进制落 arenaSlice 描述符回传 | ⚠️ method 已定义,内核侧仍未实现 |
| `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` 即生效 | ✅ 生产实测 |
@ -283,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
## 七、接口冻结检查点(全部已通过)
1.**阶段 2子进程通道原型**`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
1.**阶段 2子进程通道原型**`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
验收weather 业务代码逐字节未改(`git status example/` 无输出)。
2.**阶段 3共享内存**:子进程并发改写 StageContext 丢失率 = 0%
`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
@ -291,6 +308,8 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
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 消息):
@ -304,6 +323,100 @@ 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 能力对齐)
@ -313,5 +426,5 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
- `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/plugindev/templates/` — 子进程运行时模板(三文件)
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具

View File

@ -0,0 +1,603 @@
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
> 现场保存/恢复、中断栈LIFO结构上界 4 帧、任务级回执、panic→L4。
> 本文只描述**驻留式子 agent** 这一新能力,以及它对既有实现的改动。
>
> 标记:**[已定]**= 用户明确拍板;**[默认]**= 本文给出的可逆默认取值,实现时在提交信息里标注。
---
## 1. 背景与目标
现状的子 agent 是**工作式轻量子**`spawn_child` 起一个一次性 goroutine临时 `msgs`
跑完把结果经 `selfInputCh` 回投父,然后销毁。它**没有**自己的中断机制、没有通道分配、
没有可查询的状态面,父也无法在它运行途中干预它。
要让父 agent 能**长期派驻**一个下属去持续处理某类工作(一个 inputch 上的来源、一段长期目标),
就需要一种新的子 agent**驻留子**。它必须满足:
- 有自己的**轻量内核**(自己的调度器、中断机制、上下文),所以父能"打断它"、"查它"、"回收它"
- 有自己的**记忆空间**(可写),但**改不了父的主记忆** —— 记忆入库的决策权留在父手里;
- 与父之间有**双向、可寻址**的通道(子→父、父→指定子),且父对子的消息是**最高级中断**
- 有一张**可被父查询的状态面**inputch 处理表),使父不打断它也能知道它做到哪;
- 生命周期由父掌握:父可随时**销毁**它,父退出时**必须**销毁全部。
**两类子 agent 并存**[已定]:工作式轻量子**原样保留**(它是"内核自循环"的一部分,
不是对等 agent驻留子是新增的第二类。
---
## 2. 术语
| 术语 | 含义 |
|---|---|
| **根 agent** | 进程级主 agent拥有**完整内核**(含记忆读写、全部通道) |
| **驻留子** | 父创建、长期驻留的子 agent拥有**轻量内核**与**临时记忆空间** |
| **工作式轻量子** | 现状 `spawn_child` 的一次性子任务(**不是内核实例** |
| **inputch** | **最基本的输入路由单位**:对"中断输入 / 排队输入"两者的高层抽象,是**路由与分配**的单位;**由插件注册,一个插件可注册多个** |
| **outputch** | 输出通道;输出是 agent 的**主动调用**,并**可寻址到具体 agent** |
| **main 空间** | 主记忆空间:父读写、所有子只读、全部子共享 |
| **temp 空间** | 子临时记忆空间:该子读写、子之间互不可见 |
| **状态面** | 子对外(对父)可查询的状态:含 **inputch 处理表**与产出 |
| **处理表** | 按 inputch 记录每轮处理信息的状态表(子持有,父 pull |
| **登记表** | 父持有的全部子 agent 名录id / 状态 / 通道 / 状态面句柄) |
| **contextfull** | 子的上下文窗口满,产生 L4 中断通知父 |
| **压缩 / 回收 / 销毁** | 父对 contextfull 的三种处置(见 §9 |
---
## 3. 角色与内核形态
| | **根 agent** | **驻留子** | **工作式轻量子**(保留不动) |
|---|---|---|---|
| 内核 | 完整 | **轻量** | 无(不是内核实例) |
| 调度器 / 四级中断 / 中断栈 | ✅ | ✅(**L4 只来自父** | ❌ |
| 上下文 | 完整(记忆介导) | 传统上下文(消息序列) | 临时 `msgs` |
| 记忆 | main 读写 | **读 temp main写 temp** | 无 |
| 通道 | 全部 | 父**划入**的输入通道 + **授权**的输出通道 | 无(结果回投父) |
| 插件与工具 | 全部 | **父授权,默认完整授权**[已定] | 现有剔除规则不变 |
| 状态面 / 处理表 | — | ✅(父 pull | ❌ |
| 子→父 | — | 主动消息 = **L3 中断** | `injectSelfChannel``selfInputCh`(排队) |
| 主→子 | — | **L4 中断**(取消当前状态 + 插入新消息) | 无 |
| 生命周期 | 进程级 | 父持登记表;父可随时销毁;**父退出必须全部销毁** | 跑完即销毁 |
---
## 4. 通道模型
### 4.1 `inputch` 的定义 [已定]
`inputch` **不是**通道名标签,也**不是**与 outputch 配对的东西。它是:
1. **最基本的输入路由单位** —— 路由粒度到此为止:比"插件"细、比"通道名字符串"实;
2. **由插件注册,且一个插件可注册多个** —— 同一个插件的多个 inputch 是**彼此独立**的
路由单位(可以绑给不同 agent、可以分别授权。登记接口即现有的
`RegisterInputChannel(name, def)`,调 N 次就是 N 个 inputch
3. **对"中断输入"与"排队输入"两者的高层抽象** —— 两类输入都从 inputch 进出;
4. **路由与分配的单位**
- **路由**:一条输入投给哪个 inputch就是"该由谁处理"的**既定事实**
**路由发生在进内核之前**
- **分配**inputch 是**可分配资源**(父把输入通道划给子)。
「中断 vs 排队」是**每条输入自己的类别**(由注入 API 决定级别L1L4也是
**每条输入的属性**,都不是 inputch 的属性。
### 4.2 三个入场(沿用现有实现)
| 入口 | 承载 |
|---|---|
| `inputCh` | 外部客户端 / 插件的**排队与中断**输入(已预寻址) |
| `interruptCh` | 中断入站(外部 / 插件 / 内核自身) |
| `selfInputCh` | **内核自循环**consolidation、工作式轻量子回投 |
### 4.3 输出是**主动调用** [已定]
- 异步通道qq / 微信 / 群聊…):必须显式调用 `output_send__{通道名}` 才真正送达;
- 同步通道webui / cli / 终端):返回纯文本,内核把文本交给等待方 ——
走输入事件自带的 `ResponseCh`,这是**事前定好的回程**。
**内核不持有"当前通道"可变状态****提示词也不预设 outputch**。
(要删 `Agent.currentOutputChannel`;要删 `tooldefs.go` 里"当前输入来源通道是 X
对应输出门工具是 output_send__X"那两行。)
> 之前把这件事说成"内核路由"是错的:内核只负责**投递到既定的回程**与**执行显式的输出调用**。
### 4.4 通道分配:不对称 [已定]
- **不按对划分**。父为子:**划入若干 inputch**(单位是 inputch可以来自同一个插件的不同 inputch
+ 授权**一组可用输出通道**(授权集合,不是一对一)。
- **输出通道可寻址到具体 agent**
- **子 → 主**:子直接打到主(经输出通道投进主的 inputch
- **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。
### 4.5 已经落地/待落地的两件事
**已落地N1a**inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表
+ 单工具多视图总览(`input_channels`,见 §4.6)。
**已落地N1b**
- **输出通道授权集合**[已定:默认完整授权,父可收窄]:
`AgentConfig.AllowedOutputs`nil/空 = 全部)。三处过滤点必须一致,
否则会出现"列表里看不到、按名字还能调"的裂缝:
1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调);
2. **列表工具**`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch"
3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。
- **输出通道 → 目标 agent 的 inputch 解析**`ChannelRegistry.BindOutputTarget` /
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
### 4.5.1 通道的一等化(后续要求)
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**
`inputChannels` 只是策略表(`ChannelDef`NoMemory / Cleaner / ContextPolicy
**没有归属、没有绑定、没有容量**。要实现 §4.4 需要新增:
- **通道注册层**`inputch` 成为一等对象。它已经是**最基本的输入路由单位**
所以注册表以 **inputch 为键**(一个插件 → N 个 inputch并给每个 inputch 带上:
**归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析**
⇒ 同一个插件的两个 inputch 可以**分别划给不同 agent**、**分别限额**
- **输出通道 → 目标 agent 的 inputch** 的解析;
- **授权过滤**`output_list_channels` 只列该 agent 被授权的通道。
### 4.6 inputch 总览:**单工具多视图** [已定]
父 agent 必须能看清两件事:**有哪些 inputch 已注册(谁注册的)**、**它们是怎么划分的**。
按用户要求构筑为**单工具多视图**(一个工具 + 一个 `view` 参数),而不是一堆小工具 ——
视图切换比工具增殖更好用,也更省提示词预算。
工具:**`input_channels`**
| view | 内容 |
|---|---|
| `all`(默认) | 全部已注册 inputch名字 / **归属插件** / 归属 agent / 容量 / 记忆策略标记 |
| `mine` | 划给**本 agent** 的 |
| `unassigned` | **尚未划出**的(可按需分配) |
| `by_agent` | **划分情况总览**:按归属 agent 分组列出各自拥有哪些 inputch |
| `detail`(需 `name` | 单个 inputch 的全字段(注册插件 / 归属 / 容量 / 默认回程 / 记忆策略) |
- 未知 `view` **必须报错并列出可用值**(拼错不得被静默当成默认视图)。
- 登记表是**可共享对象**`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
这样"划入/授权"才有意义(默认每个 agent 自带一份,向后兼容)。
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
保留已有的 Owner / Capacity / Output。
---
## 5. 记忆模型:两级空间 [已定]
> **适用范围**:这套"两级空间"是针对**图记忆**的。子 agent 的记忆面是
> **传统上下文 + 图记忆****doc 记忆**与 **context 动态上下文**是内核独立设计的
> 记忆能力,**只有根 agent 有**(子不可见、不可用,见 §5.5)。
```
主记忆空间main ← 子【只读,可看到全部】;父【读写】;所有子共享
子临时记忆空间temp ← 该子【读写】;每个子独立、互不可见
子的记忆查询 = temp main 子的记忆写入 → 只落 temp
```
| | 根 agent | 驻留子 |
|---|---|---|
| main | **读写** | **只读**(可见全部) |
| temp | —(它自己就是 main 的所有者) | **读写**(自己的空间) |
| 查询范围 | main | **temp main** |
### 5.1 "轻量内核"的真正理由
不是砍功能,而是**记忆层被作用域化**
- **写目标**被限定到 `temp`(子自己的命名空间);
- **读视图**被扩成 `temp main`(两个空间的并集)。
完整内核的记忆层**硬绑定在单一 main 空间**上。要让每个 agent 都有自己的空间 +
并集读视图,记忆层就必须接受**每个 agent 一份的作用域参数** —— 这就是轻量内核存在的理由。
### 5.2 推论:记忆写工具不禁用,而是**重定向**
子是**能写**的(写自己的 temp。因此
- 记忆写工具(图记忆写、文本记忆写、**文档记忆写**、**向量索引写**、**媒体落盘**
**不禁用,而是重定向到 temp 命名空间**
- 检索注入算"读",范围 `temp main`
- 子**不能**写 main ⇒ "哪些内容进入主记忆"的决策**结构上**只在父手里§9.2)。
### 5.3 三种处置对记忆的作用
| 动作 | temp | main | 子 |
|---|---|---|---|
| **压缩** | 保留 | 不写 | 继续 |
| **回收** | 父读 → **选中的 promote 进 main** → 丢弃 temp | 父写 | 取消 |
| **销毁** | 直接丢弃 | 不写 | 立刻移除 |
### 5.5 子的记忆面:**传统上下文 + 图记忆** [已定]
| 记忆能力 | 根 agent | 驻留子 |
|---|---|---|
| **图记忆**(实体/关系/句子,含其向量检索) | ✅ main 读写 | ✅ **作用域化**(读 tempmain写 temp |
| **doc 记忆**(文档记忆) | ✅ | ❌ **不可用**(内核独立设计的记忆能力,父专属) |
| **context 动态上下文**(动态上下文装配/裁剪) | ✅ | ❌ **不可用**;子用**传统上下文**(纯消息序列) |
| 蒸馏 / 归档 / consolidation | ✅ | ❌(属上述父专属能力) |
| 文本记忆 / 知识库 / 媒体 / 社交 | ✅ | ❌(同上) |
⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** ——
不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。
### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定]
轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**
```
子的轻量内核
├─ temp 图记忆实例(独立存储,**读写** ← 子的一切图记忆写入落这里,与子同生共死
└─ 主图记忆的**受限句柄**(只读) ← 子只能读
· OpenGraphDBReadOnly连接可读/可恢复 WAL但 SQLite 层 `PRAGMA query_only=1`
把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉
子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集)
```
- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。
- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。
- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。
- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关
`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**
没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。
### 5.4 待钉的边界 [默认]
- temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度),
不是独立存储 ⇒ "并集读"就是一次查询里的两个 space 条件;
- 父**可读**子的 temp要决定 promote 什么),属"查看状态面"的一部分;
- temp **与子同生共死**;子之间 temp **互不可见**
---
## 6. 中断模型:两条独立阶梯 [已定]
```
在【父的】中断阶梯上:
子的主动消息 = L3 中断 (子主动汇报,带子标识)
子的 contextfull = L4 中断 (资源耗尽,需父立即决策,带子标识)
在【子的】中断阶梯上:
父的消息(发送消息) = L4 中断 ← 子的 L4 归父独占
```
### 6.1 L4 归属通则
> **某个 agent 的 L4 只属于它的"内核"。**
- 根 agent 的内核 = 内核自身panic / 内核事件 selfip+ 内核级插件WebUI 终止按钮);
- 驻留子的内核 = **父 agent**
⇒ 现有 `isKernelLevelSource`(只认编译期内置插件)**泛化为"该 agent 的上级"**,不为子开特例。
### 6.1.1 **父消息 = 子的 L4**(落地机制,钉死)
父 → 子的"发送消息"是一条 **L4 中断**,它是**子的阶梯上唯一的 L4 来源**。具体落地:
```
父【发送消息】到指定子
└─ 经输出通道寻址到该子的某个 inputch
└─ 在该子的调度器里按 L4 登记(子的内核级来源 = 父)
└─ 子的 L4 > 子内部一切 ⇒ 立即打断子的当前任务
```
- **子内部任何来源都够不到 L4**:子自己的输入注入、工具、定时器、插件……
一律被夹到 **≤ L3**(与根 agent 里"外部插件被夹到 L3"完全同一条规则,
只是"内核级来源"从"编译期内置插件"变成了"父 agent")。
- 后果一:**子内部任何东西都压不过父**(父的话是子的最高级输入)。
- 后果二:父的"取消当前状态 + 插入新消息"因此是**确定能生效**的
—— 不会因子内部正在跑什么而被挡住(除非子处于不可抢占临界区,
此时按调度器既有规则在安全点生效)。
**子侧内核级事件如何上报**(推论,待确认):子的 panic / contextfull 属于**子侧的内核级事件**
但在**子的**阶梯上 L4 归父独占,所以它们不能作为"子自己的 L4";应当**上报给父**
在**父的阶梯上以 L4带子标识**出现 —— 与 §9 的 contextfull 同一条通路。
(即:父侧 L4 = 子侧内核级事件的接收位;子侧 L4 = 父控制语的发射位。)
### 6.2 为什么 contextfull 是 L4 而不是 L3
它是**必须由父立刻决策**的场合(三个处置都与子的存续有关),且决策要读**一整个状态面** ⇒
只能"**推信号 + 拉状态**",不能把状态塞进中断消息里。
---
## 7. 父对子的控制面6 个动作)[已定:原语在内核,决策在父的模型]
| 动作 | 语义 | 子是否继续存在 | 走哪条路 |
|---|---|---|---|
| **创建** | 划入输入通道 + 授权输出通道 + 授权插件/工具(默认完整)+ 在固定提示词之上注入任务提示词 | — | 内核原语 |
| **发送消息** | 经输出通道寻址到子的 inputch**取消当前状态 + 插入新消息** | 是 | 对子 = **L4 中断** |
| **查看** | **pull** 子的状态面(处理表 + temp 产出),**不打断**子 | 是 | 纯查询,**不走中断** |
| **压缩** | **保留语义**:压上下文 + **清理处理表** | **是**(同一驻留子) | 见 §9 |
| **回收** | **取消语义**:父看状态面 → 选择哪些 **promote 进 main** → **取消**该驻留子 | 否 | 见 §9 |
| **销毁** | **立刻销毁并从登记表移除**(不收割) | 否 | 立即 |
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)****决策**(压还是收、收哪些)
在父的模型手里 —— 内核不替父决定。
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
---
## 8. inputch 处理表
### 8.1 归属与方向
- **子是持有者**;父**主动查看pull****不是**推给父。
- **内容**:按 inputch 记录**每一轮**子对该 inputch 的处理信息。
- **存在意义**:长期驻留子的**进度可见性** —— 父不必打断它就能知道它做到哪。
### 8.2 写入规则
- 子**主动写入**时,系统**不**自动写;
- 子**未主动写入**时,系统**自动**把该轮 inputch 对应的信息写进去;
-**每一轮必有记录**,父不会看到空洞。
[默认]"主动写入"的动作形态 = 子调用一个 `inputch_note` 类**工具**
自动写入在轮次结束时由内核兜底。
### 8.3 生命周期 = 上下文窗口
处理表记的是"**当前这段上下文窗口**里每轮 inputch 做了什么"。
因此:
- **压缩必须清表**(窗口被压成摘要后,逐轮记录被摘要取代;留着会让父看到与当前窗口
不对应的陈旧状态);
- **回收不必清表**(表就是父刚读过的收割材料,子都没了,表自然作废);
- ⇒ 处理表天然有**大小上界**(窗口多大、表最长多长),不需要额外容量策略。
---
## 9. contextfull 的处置
```
子的上下文窗口满
└─ 产生 contextfull → **L4 中断**通知父(中断信息里标明是哪个子)—— 只推信号
└─ 父【查看】子的状态面(处理表 + temp 产出)
├─ 【压缩】压成摘要 → 清处理表 → 子续用 (保留语义)
├─ 【回收】选择 temp 中哪些 promote 进 main (取消语义)
│ → 丢弃 temp → 取消该驻留子
└─ 【销毁】立刻销毁并移除 (不收割)
```
| 动作 | 语义 | 子上下文/成果 | 子 agent | 处理表 |
|---|---|---|---|---|
| **压缩** | **保留** | 压成摘要 | **继续存在** | **必须清理** |
| **回收** | **取消** | 选中的 promote 进 main | **取消** | 不必清 |
| **销毁** | 立刻销毁并移除 | 不收割 | 立刻销毁 + 出登记表 | 无关 |
[默认]压缩由**子的轻量内核自己执行**(它拥有自己的上下文与 LLM
---
## 10. 登记表与生命周期硬约束
- 父持 **agent 登记表**,记录全部子:`id / 状态 / 划入的输入通道 / 授权的输出通道 /
授权的插件与工具 / 状态面句柄`。
- 它是**查看 · 发送 · 压缩 · 回收 · 销毁**的寻址依据。
- **硬约束(必须写成测试)**
1. 父 `Stop()` ⇒ 销毁全部子(取消运行中的任务、停轻量内核、释放其通道),
**登记表清空、不留孤儿**
2. **子不得比父活得久**(无孤儿 goroutine / 无悬空通道 / 无残留 temp
---
## 11. 并发与不变量
沿用输入调度器的并发模型,并按多 agent 扩展:
- **每个 agent 一个调度器 goroutine**(根 agent 与每个驻留子各一个),
它**独占**自己的队列 / running / 中断栈 / 帧。
- 跨 agent 投递只经**通道**(值传递),**不共享帧**;父**永远不能**直接改子的帧。
- **子不得比父活得久**§10
- 父的"查看"是**只读快照**,不阻塞子、不参与子的调度决策。
- **L4 独占**:子的调度器只接受来自父的 L4§6.1)。
---
## 12. 与现有实现的接合点(差距清单)
| 设计项 | 现状 | 要做 |
|---|---|---|
| 两类别 + 四级中断 + 抢占/挂起/恢复/中断栈 | ✅ 已落地(见 input-scheduler-design.md | 复用 |
| inputch 一等化(归属/容量/授权) | ❌ `inputCh` 是全局单 channel`ChannelDef` 只是策略表 | **新增通道注册与分配层** |
| 输出通道可寻址到 agent | ❌ 只有字符串标签;`output_list_channels` 列全部 | 通道解析表 + 授权过滤 |
| 子的 L4 = 父 | ⚠️ `isKernelLevelSource` 只认内置插件 | 泛化为"该 agent 的上级"(分层) |
| 轻量内核(记忆作用域化) | ❌ 记忆层绑定单一 main 空间 | 记忆子系统加 **space 维度**`AgentConfig` 加**作用域参数** |
| 记忆写路径重定向到 temp | ❌ 写路径无空间概念 | 所有写入口带 space子的一切写 → temp |
| 驻留子生命周期 + 登记表 | ❌ 只有一次性 `runChildTask` | 驻留子 + 父的登记表 + 退出清理 |
| 跨 agent 投递(子→父 L3 / 父→子 L4 | ❌ 无 | 投递原语(复用注入层 + 通道寻址) |
| inputch 处理表 | ❌ 无 | 新数据结构 + 主动写入工具 + 自动写兜底 |
| contextfull 检测 | ❌ **完全没有** | 检测 + L4 通知(带子标识)+ 三处置 |
| 内核不持有"当前通道" | ❌ `currentOutputChannel` + 提示词预设 | 删字段、删预设 |
| 工作式轻量子 | ✅ | **不动** |
### 16.0 N2c 施工方案(轻量内核接线)[已定方案:**窄接口 + nil 即禁用**
**先按"谁在调"把 `a.memory` 的 42 处使用分类**`grep` 实测,非估计):
| 分组 | 位置 | 方法 | 谁用 |
|---|---|---|---|
| **A 记忆整理流水线** | `distill.go`10`archiveLoop` / `reviewLoop` / `mergeLoop` / `detectEntityMerge` / `reviewRelations` / `archiveColdDocs` | `Recall`, `ClearSentenceID`, `CleanupOrphanedSentences` | **root-only**(后台定时器) |
| **B 记忆块 + 媒体桥** | `graphmedia.go`18、`medialoop.go`4 | `PutMemoryBlocks`, `AddMemoryBlockEdge`, `BlocksForNode`, `PutDocumentNode`, `MemoryBlocks`, `MigrateLegacyMediaEntities`, `mediaContextFor*` | **root-only** |
| **C 记忆整理工具** | `toolcall.go::executeMemoryTool`8 | `Introspect`, `MergeEntities`, `DeleteEntity`, `Purge`, `Commit` | **root-only**`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats` |
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go:130``memory_recall` | **只有 `Recall` + `Commit`** | 根与子都要 |
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
(用户指出的关键点),**子根本不该有那些代码路径**。
#### 设计:窄接口 + nil 即禁用(不写"18 个方法返回错误"的受限包装)
```go
// core 内部:共同面(根与子都要)
type GraphMemory interface {
Recall(keywords, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
}
```
| | `a.graph`(共同面) | `a.memory`(整理面:块/媒体/流水线/整理工具) |
|---|---|---|
| **根 agent** | 同一个 `*GraphDB` | `*GraphDB` |
| **驻留子** | `*LightMemory` | **`nil`** |
- 子把 `a.memory` 设为 `nil` ⇒ **既有的 22 处 nil 关卡自动禁掉全部 root-only 路径**
`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
- 工具表:`memory_recall`(读)对子开放;整理类
`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`
**不进子的工具表**(而不是让它们进去再报"不可用")。
- 轻量 profile 另外不接线的装配doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
### 16.0.0 传统上下文的实现口径(子 vs 父)
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|---|---|---|---|
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53% | **整个窗口** `budget.MaxContext` | `contextTokenBudget()``stepPrepare` 与 `rebaseFramePrefix` 两处) |
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
**硬上限**约束而且在撞到硬上限之前contextfull90% 窗口)已按 L4 上报父 agent ——
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
**顺带修掉的既有 bug**`formatMergedTimeline` 逐事件估算原用 `len()`**字节**)再 ×2
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192。已改为统一的
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
### 16.0.1 工具面(已实现)
| 工具 | 谁用 | 作用 |
|---|---|---|
| `resident_agents` | 父 | **单工具多动作**`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4/ `inspect`pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` |
| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断** |
| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) |
> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。
### 16.1 N2 的记忆面清单(现状)
`internal/memory/` 下需要加 space 维度的面:
| 面 | 载体 | 表 | 子 agent 可用? |
|---|---|---|---|
| **图记忆** | `memory.GraphDB` | `entities` / `sentences` / `relations` | ✅ **作用域化**(读 tempmain写 temp |
| **图记忆的向量检索** | `memory.Indexer` | (索引侧,与图记忆同步) | ✅ 同图记忆(需按 space 过滤) |
| 文档记忆doc 记忆) | `document.Store` | `documents` | ❌ 父专属 |
| context 动态上下文 | 内核上下文装配/裁剪(`pruneOnInput` 等) | — | ❌ 父专属;子用传统上下文 |
| 知识库 | `knowledge.Store` | 各自表 | ❌ |
| 文本记忆 | `text.Memory` | 各自表 | ❌ |
| 媒体 | `media.Store` | 落盘 + 索引 | ❌ |
| 社交 | `social` | 各自表 | ❌ |
⇒ N2a 做**图记忆 + 其向量检索**这一条纵切(子唯一可用的记忆面,
也是"子写 temp / 父 promote 进 main"的主战场);其余面在 v1 **不加 space 维度**
(子根本够不到,加了是白工)——若将来子扩展记忆面再逐面补。
---
## 13. 非目标(本文明确不做)
1. 跨进程 / 跨主机的驻留子v1 只在同进程内)。
2. 子的**子**(驻留子再创建驻留子)——先不做,保留扩展位。
3. main 空间的**多写者**(父是唯一写者,不做并发合并)。
4. temp 空间的持久化(跟子同生共死,不落盘)。
5. 工作式轻量子的任何行为变更。
---
## 14. 测试点、方式与预期
| 编号 | 测试点 | 方式 | 预期 |
|---|---|---|---|
| S1 | 创建:划入输入通道 + 授权输出通道 | 创建子,向划入的 inputch 投输入 | 子处理它;未划入的 inputch 投不进(或报错) |
| S2 | 授权收窄 | 创建时只授权部分工具/插件 | 子的工具表恰为该子集;`output_list_channels` 只列授权的 |
| S3 | 默认完整授权 | 不传授权参数创建 | 子拿到全部插件/工具 |
| S4 | 子→父 L3 | 子在工作中主动发消息 | 父侧收到 **L3 中断**且带子标识;父可被打断(非临界区时) |
| S5 | contextfull → L4 | 灌满子的上下文 | 父侧收到 **L4 中断**、带子标识,且**只推信号** |
| S6 | 父→子 L4 | 父"发送消息"到指定子 | 子在收到时被打断L4 > 子内部一切),按 §15 的默认挂起/恢复 |
| S7 | 查看pull 不打断) | 子在跑长任务时父"查看" | 返回状态面快照;**子的 step/帧不变**、未被抢占 |
| S8 | 处理表:自动写兜底 | 子一轮不主动写 | 该轮仍有记录(系统自动写) |
| S9 | 处理表:主动写优先 | 子主动写 `inputch_note` | 该轮只有主动写的内容,无自动写 |
| S10 | 压缩(保留语义) | 父选压缩 | 上下文变短;**处理表被清空****子继续存在**且能继续干活 |
| S11 | 回收(取消语义) | 父选回收并挑若干条 promote | 选中内容进 **main**、其余丢弃;**temp 被丢弃****子被取消** |
| S12 | 销毁(立刻) | 父销毁(含子正在跑工具/LLM 时) | 子立刻消失、出登记表、其 temp 丢弃、通道释放 |
| S13 | 子不得写 main | 子调记忆写工具 | 落在 **temp**main 无新增 |
| S14 | 子查询范围 = temp main | 子查只在 main 里的内容 / 只在 temp 里的内容 | 两者都能查到 |
| S15 | 子之间 temp 隔离 | 两个子各写 temp互相查 | 查不到对方的 temp |
| S16 | 父退出清理 | 父 `Stop()`(多个子、有子在工作中) | 全部子被销毁;登记表空;**无孤儿 goroutine / 无悬空通道 / 无残留 temp** |
| S17 | L4 独占 | 子内部(子自己的输入/工具/定时器)试图产生 L4 | 被夹到 **≤L3**;只有父的消息是 L4 |
| S21 | 一个插件多个 inputch 可分别路由 | 同一插件注册 2 个 inputch分别划给父与子后各投一条输入 | 各自只到被划给的 agent互不串台 |
| S22 | inputch 总览(单工具多视图) | 一个插件注册 2 个 inputch、另一插件 1 个;把其中若干划给本 agent | `view=all` 列出全部(带归属插件);`mine`/`unassigned` 各自正确;`by_agent` 给出划分总览;`detail` 给出单条全字段;未知 view 报错并列出可用值 |
| S23 | 插件重载不抹划分 | 先划分 inputch再重复登记模拟插件重载 | Owner/Capacity 保留,仅策略被更新 |
| S19 | 父消息必能打断子 | 子在长任务中LLM 流式段)时父发送消息 | 子按 L4 被打断;若子在不可抢占临界区,则在安全点生效 |
| S20 | 子的内核级事件上报 | 子 panic / 子 contextfull | 在**父的阶梯上以 L4带子标识**出现;子侧不自己产生 L4 |
| S18 | 内核不持有"当前通道" | 抢占/中断后被打断任务恢复并发响应 | 提示词与事件标签都**只来自输入事件**(不再有被覆盖的字段) |
---
## 15. 待确认决策(含默认取值)
| 编号 | 问题 | 取值 |
|---|---|---|
| **R1** | 驻留子的内核形态 | **[已定]独立轻量内核**(自己的调度器/中断栈/上下文/记忆作用域) |
| **R2** | 插件与工具 | **[已定]父授权,默认完整授权**(可收窄) |
| **R3** | 记忆模型 | **[已定]两级空间**:读 tempmain写 temp**范围 = 图记忆** |
| **R3b** | 子的记忆面 | **[已定]传统上下文 + 图记忆**doc 记忆与 context 动态上下文是**父专属**(内核独立设计的记忆能力) |
| **R13** | inputch 总览的形态 | **[已定]单工具多视图**`input_channels` + `view` |
| **R11** | 父消息的级别 | **[已定]对子 = L4**(子的阶梯上唯一 L4 来源;子内部一律 ≤L3 |
| **R12** | 子的内核级事件panic / contextfull上报级别 | **[默认/推论]在父的阶梯上以 L4带子标识上报** |
| **R4** | 父→子消息落地 | **[默认]挂起/恢复**(现场不丢);一处开关可改"直接丢弃" |
| **R5** | "主动写入处理表"的形态 | **[默认]子调用 `inputch_note` 类工具**;未调用则轮末自动写 |
| **R6** | 输入通道"划入"的语义与容量 | **[默认]读写授权(不转移所有权)+ 创建时给定容量****划入单位 = inputch**(不是插件、不是通道组) |
| **R7** | 压缩由谁执行 | **[默认]子的轻量内核自己压**(它有自己的上下文与 LLM压缩后清表 |
| **R8** | 压缩前父是否"查看后决定" | **[默认]纯机械压缩**(父只在选"压缩 vs 回收"时决策) |
| **R9** | 回收时处理表 | **[默认]不必清**(子都没了);若日后要留作审计需单独策略 |
| **R10** | 默认授权是否含输出门 | **[默认]含**"默认完整授权"的字面含义);若嫌宽,改默认即可 |
---
## 16. 实现里程碑(每步 = 一个可独立验收的提交)
| 里程碑 | 内容 | 验收 |
|---|---|---|
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18既有全部测试通过这是纯收敛不含新能力 |
| **N1a** | **通道登记层**inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21S23 |
| **N1b** | 输出通道授权过滤 + 目标解析outputch → 目标 agent 的 inputch | S1S3 |
| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**`OpenGraphDBReadOnly`query_only 受限句柄)+ `LightMemory`temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13S15 ✅ |
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
| — | 其余记忆面doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
| **N2c** | ~~Agent 级 profile~~ **已完成**`GraphMemory` 窄接口Recall/Commit+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13S15 ✅ |
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
| **N3** | ~~驻留子生命周期~~ **已完成**`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`L3投父的 `child/<id>` inputch父→子 `SendToResident`L4`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
| **N5** | ~~inputch 处理表~~ **已完成**`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main/ `DestroyResident` | S5、S10、S11 ✅ |
| **N7** | ~~e2e + 压力~~ **已完成**`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1S20 覆盖 ✅ |
每步收尾命令:
```bash
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
go build ./... && go vet ./...
go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal/plugin/...
```
---
## 17. 与发布纪律的关系
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
- 若需要动公开 SDK例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。

3
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/sys v0.38.0
golang.org/x/text v0.3.8
)
require (
@ -40,8 +41,6 @@ require (
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/text v0.3.8 // indirect
)
replace gitcode.com/JianFeeeee/homeagent-sdk => ./third_party/homeagent-sdk

View File

@ -14,8 +14,10 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/social"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
@ -24,19 +26,47 @@ import (
// ContextEvent 和 RelevanceContext 定义在 context.go
// Agent — 单 agent不区分会话/实例
//
// 并发现状M3a 起):所有任务状态只由 **schedulerLoop goroutine** 独占读写,
// 因此不再有保护整轮执行的互斥量——挂起不能持锁(见 docs/zh/input-scheduler-design.md §8.1 I3
// 仍需跨 goroutine 保护的是childMu/llmMu/lastInputMu/noMergeMu 与各子系统自己的锁;
// interceptLoop 只允许触碰 preemptionRequest 与 cancelLLM经 llmMu
type Agent struct {
mu sync.Mutex
id types.AgentID
provider agentAPI.Provider
providerManager *agentAPI.ProviderManager
io *agentIO.IOManager
memory *memory.GraphDB
indexer *memory.Indexer
tracker *tracker.Tracker
context *RelevanceContext
systemPrompt string
ctx context.Context
cancel context.CancelFunc
// graph 是本 agent 的**图记忆共同面**(根 = 同一个 GraphDB驻留子 = LightMemory
// 整理面仍走 memory 字段(子为 nil ⇒ 既有的 nil 关卡自动禁用整理面)。
graph GraphMemory
// kernelSource/parentID/taskPrompt/dataDir驻留子相关的层级信息见 AgentConfig
kernelSource string
parentID string
taskPrompt string
dataDir string
// 驻留子(父侧):登记表 + 子侧钩子。
residentMu sync.Mutex
residents map[string]*residentChild
// 子侧向父发消息L3与 contextfull 上报(父侧内核级事件)的钩子。
notifyParent func(text string)
onContextFull func()
ctxFullSignaled bool
// 子侧inputch 处理表(子持有,父 pull
tableMu sync.Mutex
inputchTable []InputchRecord
inputchPending *InputchRecord
currentInputch string
indexer *memory.Indexer
tracker *tracker.Tracker
context *RelevanceContext
systemPrompt string
ctx context.Context
cancel context.CancelFunc
// 文档记忆(第二层)
docStore *document.Store
@ -50,9 +80,21 @@ type Agent struct {
// 文本记忆(原始对话日志)
textMem *text.Memory
// 人格设定
// 媒体存储(内容寻址):对话里出现的图片/音频按 sha256 落盘去重。
// 它是记忆块的内容存储,不单独做生命周期管理:块的创建/迁移/删除
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store
// 人格设定(内容来自启动时载入的人格文件/配置项)
personality *agentPkg.Personality
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
personaStore PersonaStore
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs
allowedOutputs []string
// 插件注册表(用于 plgreload
pluginReg *plugin.Registry
pluginDir string
@ -69,7 +111,6 @@ type Agent struct {
maxContextSize int
// 当前请求的输出通道mutex 保护process() 内独占)
currentOutputChannel string
// 阶段管道:插件消息流编辑
stageHost *StageHost
@ -83,13 +124,24 @@ type Agent struct {
selfInputCh chan selfInputMsg
// 子任务异步执行
childMu sync.Mutex
childNextID int64
childResults map[string]string
childRunning map[string]bool // 运行中的子任务child_result 查询时区分'运行中'与'不存在'
childMu sync.Mutex
childNextID int64
// childTasks 记录子任务状态:运行中 / 结果 / 是否已交付。
//
// 为什么保留结果而不是“读到即删”:完成通知会写进持久上下文
// formatMergedTimeline 每轮都重新注入),模型之后还会再查。若读到即删,
// 第二次查询就得到“不存在或已过期”这个**永久失败信号**——模型据此认为
// 任务未完成,会无限重试/汇报(实测单轮 35 次工具调用、持续 514 秒)。
childTasks map[string]*childTaskState
// childSeq 给完成的任务排个序,用于有界淘汰。
childSeq int64
// 高优先级打断通道interceptLoop 注入process() 在工具循环轮次间非阻塞读取
interceptCh chan *agentIO.InputEvent
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go
// M2 起取代 eventLoop 的隐式 channel 排队。
sched *scheduler
// 工具轮次硬上限0 = 不限);见 AgentConfig.MaxToolTurns。
maxToolTurns int
// 进行中的 LLM 请求取消函数interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
@ -106,6 +158,13 @@ type Agent struct {
// 当前轮次的非文本媒体数据(图片/音频),供 describe_image 等工具访问
pendingMedia map[string]interface{}
// pendingMediaDigests 累积本轮已落进 CAS 的媒体 digest。
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 由 schedulerLoop goroutine 独占读写。
pendingMediaDigests []string
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
interruptInput bool
@ -119,12 +178,25 @@ type Agent struct {
// 输入去重:防 webui/GUI 断线重连导致的消息重放
// key=source+"|"+content, value=上次接收时间;短窗口内同内容丢弃
lastInput map[string]time.Time
lastInput map[string]time.Time
lastInputMu sync.Mutex
// 词嵌入模型,用于实体语义相似度计算
embedder *memory.StaticEmbedder
// multimodalSpace 是统一多模态向量空间(可选)。实现可以是内嵌 ONNX
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
multimodalSpace vector.MultimodalEmbedder
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
// embeddingError 是打开/适配失败的原因(成功时为空)。
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
embeddingProvider string
embeddingError string
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
fusionCfg CrossModalFusionConfig
// 技能索引提供者:由 skillmgr 插件实现,向 system prompt 注入轻量技能索引
skillIndex SkillIndexProvider
}
@ -144,23 +216,53 @@ type AgentConfig struct {
ProviderManager *agentAPI.ProviderManager
IO *agentIO.IOManager
Memory *memory.GraphDB
Indexer *memory.Indexer
Tracker *tracker.Tracker
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 tempmain只写 temp
//
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go
LightMemory *memory.LightMemory
Indexer *memory.Indexer
Tracker *tracker.Tracker
DocStore *document.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
Personality *agentPkg.Personality
PluginReg *plugin.Registry
DocStore *document.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MultimodalSpace vector.MultimodalEmbedder
// EmbeddingProvider / EmbeddingError 是向量空间的配置身份与打开失败原因,
// 供 healthcheck_kernel 状态报告区分「未配置 / 打开失败 / 已启用」。
EmbeddingProvider string
EmbeddingError string
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
PluginReg *plugin.Registry
// KernelSource 是本 agent 的"上级"(驻留子的父)。
//
// 设计 §6.1:某个 agent 的 L4 只属于它的**内核** —— 根 agent 的内核是内核自身与
// 内核级插件;驻留子的内核是**父 agent**。因此子的 KernelSource = 父 ⇒ 只有父
// 能在子的阶梯上产生 L4父的"发送消息")。
KernelSource string
// ParentID 是父 agent 的 id空 = 根 agent。子用它判断自己是不是驻留子。
ParentID string
// DataDir 是本 agent 的数据目录;创建驻留子时用它派生 temp 图记忆路径。
DataDir string
// TaskPrompt 是在固定提示词之上注入的**任务提示词**(驻留子创建时给定)。
TaskPrompt string
// AllowedOutputs 是本 agent **被授权的输出通道集合**(设计 §4.4 / R2
//
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
// 父 agent 创建驻留子时用它收窄子的输出能力。
AllowedOutputs []string
PluginDir string
DistillInterval time.Duration
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
ReviewInterval time.Duration // 关系复审间隔0 则使用 DistillInterval
MergeInterval time.Duration // 实体合并检测间隔0 则使用 DistillInterval
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
ContextSavePath string // 上下文持久化路径,空则不持久化
EmbeddingModelPath string // 预训练词嵌入模型路径word2vec 文本格式),空则不使用
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
ReviewInterval time.Duration // 关系复审间隔0 则使用 DistillInterval
MergeInterval time.Duration // 实体合并检测间隔0 则使用 DistillInterval
MaxContextSize int // 活跃上下文最大条数,超出按相关性裁剪
ContextSavePath string // 上下文持久化路径,空则不持久化
EmbeddingModelPath string // 预训练词嵌入模型路径word2vec 文本格式),空则不使用
Embedder *memory.StaticEmbedder // 共享词嵌入实例nil 时按 EmbeddingModelPath 自建
StageHost *StageHost
EventBus *events.Bus
@ -169,6 +271,10 @@ type AgentConfig struct {
SkillIndexProvider SkillIndexProvider
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
// MaxToolTurns 是单个任务允许的工具轮次上限0 = 不限)。
// 设计文档 D6主循环必须有硬上限否则模型不停调用就永不完结。
MaxToolTurns int
}
func New(cfg AgentConfig) *Agent {
@ -194,8 +300,7 @@ func New(cfg AgentConfig) *Agent {
embedder = memory.NewStaticEmbedder(strings.Split(cfg.EmbeddingModelPath, ",")...)
}
if cfg.DocStore != nil {
cfg.DocStore.SetVectorizer(embedder)
cfg.DocStore.ReindexWithVectorizer(embedder)
// TF-IDF 内置为 fallback无需外部注入
}
if cfg.Knowledge != nil {
cfg.Knowledge.SetVectorizer(embedder)
@ -209,45 +314,67 @@ func New(cfg AgentConfig) *Agent {
if cfg.IO != nil {
rc.SetChannelDefLookup(cfg.IO.GetInputChannelDef)
}
// 注入稠密多模态向量空间可选配置后文档检索、L0 相关性裁剪、
// 跨模态检索全部共享同一向量空间,取代稀疏 fastText 语义路。
// 未配置时退化到 TF-IDF/fastText 稀疏检索,保持既有行为。
if cfg.MultimodalSpace != nil && cfg.MultimodalSpace.Loaded() {
rc.SetDenseSpace(cfg.MultimodalSpace)
if cfg.DocStore != nil {
cfg.DocStore.SetDenseSpace(cfg.MultimodalSpace)
cfg.DocStore.BuildDenseIndex(cfg.MultimodalSpace)
}
}
return &Agent{
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
docStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
personality: cfg.Personality,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
archiveInterval: cfg.ArchiveInterval,
reviewInterval: cfg.ReviewInterval,
mergeInterval: cfg.MergeInterval,
maxContextSize: cfg.MaxContextSize,
stageHost: cfg.StageHost,
skillIndex: cfg.SkillIndexProvider,
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childResults: make(map[string]string),
childRunning: make(map[string]bool),
interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
id: cfg.ID,
startTime: time.Now(),
provider: cfg.Provider,
providerManager: cfg.ProviderManager,
io: cfg.IO,
memory: cfg.Memory,
graph: graphMemoryOf(cfg),
kernelSource: cfg.KernelSource,
parentID: cfg.ParentID,
taskPrompt: cfg.TaskPrompt,
dataDir: cfg.DataDir,
indexer: cfg.Indexer,
tracker: cfg.Tracker,
context: rc,
systemPrompt: cfg.SystemPrompt,
ctx: ctx,
cancel: cancel,
docStore: cfg.DocStore,
knowledge: cfg.Knowledge,
social: cfg.SocialStore,
textMem: cfg.TextMemory,
mediaStore: cfg.MediaStore,
personality: cfg.Personality,
personaStore: cfg.PersonaStore,
allowedOutputs: cfg.AllowedOutputs,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
archiveInterval: cfg.ArchiveInterval,
reviewInterval: cfg.ReviewInterval,
mergeInterval: cfg.MergeInterval,
maxContextSize: cfg.MaxContextSize,
stageHost: cfg.StageHost,
skillIndex: cfg.SkillIndexProvider,
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childTasks: make(map[string]*childTaskState),
sched: newScheduler(256),
maxToolTurns: cfg.MaxToolTurns,
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
multimodalSpace: cfg.MultimodalSpace,
embeddingProvider: cfg.EmbeddingProvider,
embeddingError: cfg.EmbeddingError,
fusionCfg: cfg.FusionCfg,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
}
@ -255,21 +382,80 @@ func New(cfg AgentConfig) *Agent {
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
func (a *Agent) Start() {
go a.eventLoop()
go a.schedulerLoop()
go a.interceptLoop()
go a.distillLoop()
go a.archiveLoop()
go a.mergeLoop()
go a.reviewLoop()
a.reembedStaleMedia()
a.migrateLegacyGraphMedia()
log.Printf("[agent] %s started, waiting for IO interrupts", a.id)
}
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()
a.cancel()
}
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
//
// - 轻量内核(给了 LightMemory用 LightMemory**整理面保持 nil**
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory
func graphMemoryOf(cfg AgentConfig) GraphMemory {
if cfg.LightMemory != nil {
return cfg.LightMemory
}
if cfg.Memory == nil {
return nil
}
return cfg.Memory
}
// graphMem 返回本 agent 的图记忆**共同面**。
//
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
// 轻量内核则显式设 graph=LightMemory 且 memory=nil共同面走 LightMemory
// 整理面因 memory==nil 而全部不可达。
func (a *Agent) graphMem() GraphMemory {
if a.graph != nil {
return a.graph
}
if a.memory == nil {
return nil
}
return a.memory
}
func (a *Agent) ID() types.AgentID { return a.id }
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
//
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
func (a *Agent) IsOutputAllowed(channel string) bool {
if len(a.allowedOutputs) == 0 {
return true
}
for _, c := range a.allowedOutputs {
if c == channel {
return true
}
}
return false
}
// ResolveOutputTarget 解析输出通道的投递目标agent + inputch
//
// ok=false 表示该输出通道由传输层device 通道,如 qq/webui自行处理。
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
if a.io == nil || a.io.ChannelRegistry() == nil {
return agentIO.OutputTarget{}, false
}
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
}
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
// key=source+"|"+content窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
const duplicateInputWindow = 10 * time.Second

View File

@ -15,14 +15,14 @@ type mockOutputDevice struct {
toolFn func(string, map[string]interface{}) (interface{}, error)
}
func (d *mockOutputDevice) Name() string { return d.name }
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
func (d *mockOutputDevice) Start() error { return nil }
func (d *mockOutputDevice) Stop() error { return nil }
func (d *mockOutputDevice) Name() string { return d.name }
func (d *mockOutputDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *mockOutputDevice) Description() string { return "mock " + d.name }
func (d *mockOutputDevice) Tools() []agentIO.ToolDef { return d.tools }
func (d *mockOutputDevice) Start() error { return nil }
func (d *mockOutputDevice) Stop() error { return nil }
func (d *mockOutputDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *mockOutputDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *mockOutputDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
if d.toolFn != nil {
return d.toolFn(tool, args)
@ -67,8 +67,8 @@ func TestExecuteOutputSendTool(t *testing.T) {
"type": "text",
}}
result := a.executeOutputSendTool(tc)
if !strings.Contains(result, "screen") {
t.Errorf("unexpected result: %s", result)
if result != "ok" {
t.Errorf("expected ok, got: %s", result)
}
}

View File

@ -22,13 +22,23 @@ type ToolResultItem struct {
}
type ContextEvent struct {
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
Vector vector.Vector `json:"-"`
// ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。
//
// 全量生成会让每条事件都多一个字段进 context.json而绝大多数对话没有媒体。
// omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。
ID string `json:"id,omitempty"`
Timestamp time.Time `json:"timestamp"`
Source string `json:"source"`
Input string `json:"input"`
Response string `json:"response,omitempty"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// --- 原生多模态记忆 ---
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块text/image/video/audio
Vector vector.Vector `json:"-"` // 稀疏词向量TF-IDF/fastText 空间)
DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间)
DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化)
}
const contextFlushInterval = 5 * time.Second
@ -37,6 +47,7 @@ type RelevanceContext struct {
mu sync.Mutex
events []*ContextEvent
embedder *memory.StaticEmbedder
denseSpace vector.MultimodalEmbedder
savePath string
saveTimer *time.Timer
dirty bool
@ -55,6 +66,14 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
return rc
}
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
c.mu.Lock()
defer c.mu.Unlock()
c.denseSpace = ds
}
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {
c.mu.Lock()
defer c.mu.Unlock()
@ -77,7 +96,7 @@ func (c *RelevanceContext) load() {
return
}
for _, evt := range events {
evt.Vector = c.computeVector(evt)
c.computeVector(evt)
}
c.events = events
}
@ -176,12 +195,32 @@ func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner {
}
}
func (c *RelevanceContext) computeVector(evt *ContextEvent) vector.Vector {
func (c *RelevanceContext) computeVector(evt *ContextEvent) {
text := textForVector(evt, c.toolDefLookup, c.channelDefLookup)
if text == "" {
return nil
// 稀疏向量始终计算TF-IDF/fastText退化时仍可用
if text != "" {
evt.Vector = c.embedder.Vectorize(text)
}
// 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。
// 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。
if c.denseSpace != nil && c.denseSpace.Loaded() {
fp := c.denseSpace.Fingerprint()
var parts [][]float64
if text != "" {
if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 {
parts = append(parts, dv)
}
}
for _, b := range evt.Blocks {
// 只融合同指纹的块向量:另一套坐标系的向量混进来会算出
// 两边都不像的方向。
if len(b.Vector) > 0 && b.Fingerprint == fp {
parts = append(parts, b.Vector)
}
}
evt.DenseVec = vector.FuseVectors(parts...)
evt.DenseFP = fp
}
return c.embedder.Vectorize(text)
}
func (c *RelevanceContext) Save() error {
@ -202,7 +241,7 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
evt.Vector = c.computeVector(&evt)
c.computeVector(&evt)
c.events = append(c.events, &evt)
c.save()
@ -212,7 +251,7 @@ func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
evt.Vector = c.computeVector(&evt)
c.computeVector(&evt)
idx := sort.Search(len(c.events), func(i int) bool {
return c.events[i].Timestamp.After(evt.Timestamp)
@ -256,6 +295,15 @@ func (c *RelevanceContext) flush() {
c.dirty = false
}
// scoredEvent 是 Prune 里按相关度排序的事件。
//
// 提为包级类型Prune 需要把待归档列表传给后续处理。
type scoredEvent struct {
event *ContextEvent
score float64
idx int
}
func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int {
c.mu.Lock()
defer c.mu.Unlock()
@ -275,17 +323,30 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
return 0
}
// 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。
var queryDense []float64
useDense := false
queryFP := ""
if c.denseSpace != nil && c.denseSpace.Loaded() {
if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil {
queryDense = dv
queryFP = c.denseSpace.Fingerprint()
useDense = true
}
}
queryVec := c.embedder.VectorizeClean(currentInput)
type scored struct {
event *ContextEvent
score float64
idx int
}
scoredEvents := make([]scored, len(candidates))
scoredEvents := make([]scoredEvent, len(candidates))
for i, evt := range candidates {
score := vector.CosineSimilarity(queryVec, evt.Vector)
scoredEvents[i] = scored{event: evt, score: score, idx: i}
var score float64
// 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量
// 属于另一个坐标系,拿来比会得到无意义的分数。
if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) {
score = vector.DenseCosine(queryDense, evt.DenseVec)
} else {
score = vector.CosineSimilarity(queryVec, evt.Vector)
}
scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i}
}
sort.Slice(scoredEvents, func(i, j int) bool {
@ -322,11 +383,20 @@ func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *docume
Content: s.event.Input,
Response: s.event.Response,
ToolResults: convertToolResults(s.event.ToolResults),
Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...),
}
}
doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc())
if err == nil && doc != nil {
archived = len(entries)
// 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。
// 迁移的是块本身ID 不变、只换持有层),不是复制也不是保活引用;
// 因此归档后清空源事件的块,确保同一块不同时留在两层。
for _, s := range archive {
if s.event != nil {
s.event.Blocks = nil
}
}
}
}
@ -369,6 +439,41 @@ func (c *RelevanceContext) Recent(n int) []ContextEvent {
return result
}
// Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。
// 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。
func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
c.mu.Lock()
defer c.mu.Unlock()
var out []memory.MemoryBlock
for _, e := range c.events {
out = append(out, e.Blocks...)
}
return out
}
// TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。
//
// 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。
// 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。
func (c *RelevanceContext) TrimKeepRecent(n int) int {
c.mu.Lock()
if n < 1 {
n = 1
}
if len(c.events) <= n {
c.mu.Unlock()
return 0
}
dropped := len(c.events) - n
kept := make([]*ContextEvent, n)
copy(kept, c.events[dropped:])
c.events = kept
c.dirty = true
c.mu.Unlock()
c.save()
return dropped
}
func (c *RelevanceContext) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
@ -385,5 +490,3 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
}
return result
}

View File

@ -0,0 +1,268 @@
package core
import (
"fmt"
"log"
"sort"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// CrossModalHit 是跨模态检索融合后的一条候选。
//
// 统一的检索单元是记忆块而非 CAS 全库:媒体在 L0/L2/L3 都由层容器持有,
// 只有仍被某层记忆块持有的媒体才可召回。Doc 是 L2 文档Media 是该块携带的
// 原生媒体坐标。两路分数尺度不同,融合前各自归一化,见 fuseCrossModal。
type CrossModalHit struct {
Doc *document.Doc // 文本路命中的文档;视觉路命中时为 nil
Media *media.Item // 视觉路命中的媒体;文本路命中时也可能带关联媒体
MediaScore float64 // 视觉路原始 cosine无则 0
DocScore float64 // 文本路原始 cosine无则 0
Fused float64 // 归一化加权融合分,供最终排序
// 该媒体同时被两路命中(文本路经文档关联、视觉路直接命中)时,
// DoubleHit=true —— 双信号确认,应排在只被一路命中的候选之前。
DoubleHit bool
}
// CrossModalFusionConfig 控制文本路与视觉路的融合行为。
// 默认各路权重 0.5,双命中加权 0.15;不同模型/场景可按实测调整。
type CrossModalFusionConfig struct {
WeightText float64 // 文本路融合权重(默认 0.5
WeightVisual float64 // 视觉路融合权重(默认 0.5
DoubleHitBonus float64 // 双命中额外加分(默认 0.15
MinMaxEps float64 // min-max 归一化除零保护(默认 1e-12
}
var defaultFusionConfig = CrossModalFusionConfig{
WeightText: 0.5,
WeightVisual: 0.5,
DoubleHitBonus: 0.15,
MinMaxEps: 1e-12,
}
func (c CrossModalFusionConfig) textWeight() float64 {
if c.WeightText <= 0 {
return defaultFusionConfig.WeightText
}
return c.WeightText
}
func (c CrossModalFusionConfig) visualWeight() float64 {
if c.WeightVisual <= 0 {
return defaultFusionConfig.WeightVisual
}
return c.WeightVisual
}
func (c CrossModalFusionConfig) doubleHitBonus() float64 {
return c.DoubleHitBonus
}
func (c CrossModalFusionConfig) minMaxEps() float64 {
if c.MinMaxEps <= 0 {
return defaultFusionConfig.MinMaxEps
}
return c.MinMaxEps
}
// retrieveCrossModal 是跨模态并行检索的统一入口。
//
// 策略(两路并行,召回真正最相似的):
// 1. 文本路query 整段文本编码后查文档层Doc.DenseVec 已融合其块的媒体向量),
// 命中文档若持有媒体块,直接带上该块。
// 2. 视觉路query 经多模态模型文本编码 → 与媒体块向量比余弦
// QueryMediaScored覆盖文本向量没写到的视觉内容。
// 3. 融合:两条路候选各自 min-max 归一化到 [0,1],加权求和后降序,取 topK。
// 同一媒体被两路同时命中视为双信号确认,额外加权。
//
// 多模态空间未配置时视觉路为空,退化为纯文本路(等价旧 docStore.Query
func (a *Agent) retrieveCrossModal(query string, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
if topK <= 0 {
topK = 5
}
// 融合前各取 2× 余量,保证融合排序后 topK 仍有足够候选。
per := topK * 2
if per < 8 {
per = 8
}
// ---- 文本路 ----
var textHits []CrossModalHit
if a.docStore != nil {
for _, dh := range a.docStore.QueryScored(query, per) {
hit := CrossModalHit{Doc: dh.Doc, DocScore: dh.Score}
// 命中文档若持有一等记忆块,把首个媒体块一并带上。
if a.mediaStore != nil && len(dh.Doc.Blocks) > 0 {
if it, err := a.mediaStore.Stat(dh.Doc.Blocks[0].PayloadDigest); err == nil {
hit.Media = it
}
}
textHits = append(textHits, hit)
}
}
// ---- 视觉路(多模态文本编码 → 当前记忆层持有的媒体块)----
var visualHits []CrossModalHit
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() && a.mediaStore != nil {
qv, err := a.multimodalSpace.VectorizeDense(query)
if err != nil {
log.Printf("[crossmodal] 多模态文本编码失败: %v", err)
} else if mh, err := a.mediaStore.QueryMediaScored(qv, a.multimodalSpace.Fingerprint(), per); err != nil {
log.Printf("[crossmodal] 媒体记忆检索失败: %v", err)
} else {
// 只有仍被某层记忆块持有的媒体才可召回CAS 是全库字节存储,
// 直接拿它的检索结果会把已无处可归的内容也从记忆里翻出来。
held := a.heldMediaDigests()
for _, h := range mh {
if h.Item == nil || !held[h.Item.Digest] {
continue
}
visualHits = append(visualHits, CrossModalHit{
Media: h.Item, MediaScore: h.Score,
})
}
}
}
return fuseCrossModal(textHits, visualHits, topK, cfg)
}
// fuseCrossModal 把文本路与视觉路候选按各自归一化分融合排序。
//
// 归一化模板:两路分数尺度不可直接相加,先各自在路内 min-max 到 [0,1]
//
// norm(x) = (x - min) / (max - min)max==min 时置 1
//
// 再加权求和fused = wText·normText + wVisual·normVisual。同一媒体两路都命中
// (经文档关联 + 视觉直接)时 DoubleHit在加权分上再加双信号确认分。
// 权重通过 CrossModalFusionConfig 按场景配置,不同模型/版本可按实测调整。
func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossModalFusionConfig) []CrossModalHit {
norm := func(hits []CrossModalHit, pick func(CrossModalHit) float64) []float64 {
out := make([]float64, len(hits))
if len(hits) == 0 {
return out
}
maxV, minV := pick(hits[0]), pick(hits[0])
for _, h := range hits[1:] {
v := pick(h)
if v > maxV {
maxV = v
}
if v < minV {
minV = v
}
}
for i, h := range hits {
v := pick(h)
if maxV-minV < cfg.minMaxEps() {
out[i] = 1
continue
}
out[i] = (v - minV) / (maxV - minV)
}
return out
}
textN := norm(textHits, func(h CrossModalHit) float64 { return h.DocScore })
visualN := norm(visualHits, func(h CrossModalHit) float64 { return h.MediaScore })
byKey := make(map[string]*CrossModalHit)
var keys []string
key := func(h CrossModalHit) string {
if h.Doc != nil {
return "doc:" + h.Doc.ID
}
if h.Media != nil {
return "media:" + h.Media.Digest
}
return ""
}
// 先并入视觉路(视觉媒体是独立实体)
for i, h := range visualHits {
k := key(h)
if k == "" {
continue
}
clone := h
clone.Fused = cfg.visualWeight() * visualN[i]
byKey[k] = &clone
keys = append(keys, k)
}
// 再并入文本路:命中的文档是独立实体;带媒体的文档若其媒体 digest
// 已在视觉路(双命中),合并到同一候选并标记 DoubleHit。
for i, h := range textHits {
if h.Doc == nil {
continue
}
if h.Media != nil {
if ex, ok := byKey["media:"+h.Media.Digest]; ok {
ex.DoubleHit = true
ex.Doc = h.Doc
ex.Fused += cfg.textWeight()*textN[i] + cfg.doubleHitBonus()
continue
}
}
k := "doc:" + h.Doc.ID
if ex, ok := byKey[k]; ok {
ex.Doc = h.Doc
ex.DoubleHit = false
ex.Fused += cfg.textWeight() * textN[i]
continue
}
clone := h
clone.Fused = cfg.textWeight() * textN[i]
byKey[k] = &clone
keys = append(keys, k)
}
var merged []CrossModalHit
for _, k := range keys {
if c := byKey[k]; c != nil {
merged = append(merged, *c)
}
}
sort.SliceStable(merged, func(i, j int) bool {
if merged[i].DoubleHit != merged[j].DoubleHit {
return merged[i].DoubleHit
}
return merged[i].Fused > merged[j].Fused
})
if len(merged) > topK {
merged = merged[:topK]
}
return merged
}
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
// 文档行给出摘要;媒体行只给 MIME + 短 digest不再有生成的描述
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
if len(hits) == 0 {
return ""
}
var lines []string
for i, h := range hits {
marker := ""
switch {
case h.DoubleHit:
marker = "(图文双命中)"
case h.Doc != nil:
marker = "(文本命中)"
case h.Media != nil:
marker = "(视觉命中)"
}
parts := []string{fmt.Sprintf("[%d]", i+1)}
if h.Doc != nil {
parts = append(parts, h.Doc.Summary)
if h.Doc.Source != "" {
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
}
}
if h.Media != nil {
if line := mediaLabel(h.Media); line != "" {
parts = append(parts, line)
}
}
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
lines = append(lines, strings.Join(parts, " "))
}
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
}

View File

@ -0,0 +1,92 @@
package core
// 设备类工具的**授权闸**:设备指令类工具走的是工具面,而 AllowedOutputs 只作用于
// output_send__<通道> —— 不补闸的话"授权"对指令类完全无效(驻留子拿到
// device_ctl_cmdrun 就能指挥任意设备)。这里按目标设备的通道名 device/<id> 查同一道闸。
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// registerFakeDevice 注册一台假设备,带一个"需要 device_id 的指令类工具"和一个无参枚举工具。
func registerFakeDevice(t *testing.T, a *Agent, name string, called *[]string) {
t.Helper()
dev := &mockOutputDevice{
name: name,
caps: agentIO.CapStructured,
tools: []agentIO.ToolDef{
{Name: "device_ctl_cmdrun", Description: "在设备上执行命令"},
{Name: "devicedetect", Description: "枚举设备"},
},
toolFn: func(tool string, args map[string]interface{}) (interface{}, error) {
if called != nil {
*called = append(*called, tool)
}
return "ok:" + tool, nil
},
}
if err := a.io.RegisterDevice(dev); err != nil {
t.Fatalf("注册测试设备 %s 失败: %v", name, err)
}
}
func deviceToolCall(name string, args map[string]interface{}) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: "call_1", Name: name, Arguments: args}
}
// 完整授权(根 agent 默认):设备指令工具照常可用。
func TestDeviceToolAuth_RootHasFullGrant(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
var called []string
registerFakeDevice(t, a, "devicectl", &called)
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
"device_id": "pc-1", "command": "ls",
}), "cli")
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
t.Fatalf("根 agent 应可指挥任意设备,实际: %s", got)
}
}
// 收窄授权(驻留子):只授权了 device/ok-1指挥别的设备必须被拒且**不落到设备**。
func TestDeviceToolAuth_NarrowedGrantRefusesOtherDevice(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
var called []string
registerFakeDevice(t, a, "devicectl", &called)
got := a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
"device_id": "other-2", "command": "rm -rf /",
}), "cli")
if !strings.Contains(got, "未授权") {
t.Fatalf("未授权设备应被拒,实际: %s", got)
}
if len(called) != 0 {
t.Fatalf("被拒的调用不得落到设备,实际执行了 %v", called)
}
// 已授权的设备照常可用
got = a.executeToolCall(deviceToolCall("device_ctl_cmdrun", map[string]interface{}{
"device_id": "ok-1", "command": "ls",
}), "cli")
if !strings.Contains(got, "ok:device_ctl_cmdrun") {
t.Fatalf("已授权设备应可用,实际: %s", got)
}
}
// 无参枚举类devicedetect不受闸门影响它不指向具体设备。
func TestDeviceToolAuth_EnumerationNotGated(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"device/ok-1"}
var called []string
registerFakeDevice(t, a, "devicectl", &called)
got := a.executeToolCall(deviceToolCall("devicedetect", map[string]interface{}{}), "cli")
if !strings.Contains(got, "ok:devicedetect") {
t.Fatalf("枚举类工具不应被设备授权闸拦,实际: %s", got)
}
}

View File

@ -180,15 +180,40 @@ func (a *Agent) archiveColdDocs() {
coldDocs := a.docStore.FindColdDocs(72*time.Hour, 2)
for _, doc := range coldDocs {
triples := docToTriples(doc, a.embedder)
if len(triples) > 0 {
ec, rc, err := a.memory.Commit(triples, string(a.id)+"_doc_archival", 0)
if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err)
if len(triples) == 0 {
continue
}
ec, rc, blocks, err := a.commitTriplesWithMedia(triples, string(a.id)+"_doc_archival", 0, doc.Blocks)
if err != nil {
log.Printf("[agent] doc→graph archival error: %v", err)
continue
}
// 归档的实质是「信息从 L2 搬到 L3」。一条实体、一条关系都没写进
// 图库时,信息并没有搬过去,此时删文档等于直接丢数据。
//
// 这不是理论情形Commit 会静默跳过实体名不合法的三元组
//validEntityName 要求 250 字符),而 LLM 生成的长描述几乎
// 提不出合规实体名——实测 456 字图片描述得到 0 entities 0
// relations随后文档被删、媒体引用被释放、blob 被 GC 清掉,
// 图片与描述彻底消失。保留文档,下一轮再试。
if ec == 0 && rc == 0 {
log.Printf("[agent] doc→graph: %s 未写入任何实体/关系,保留文档待下轮重试"+
"(三元组 %d 条全被实体名校验拒绝)", doc.ID, len(triples))
continue
}
log.Printf("[agent] doc→graph: %s → %d entities, %d relations, %d blocks", doc.ID, ec, rc, blocks)
// 文档持有的一等块写入 L3并以 document --contains--> block 边关联;
// 块 ID 原样保留(迁移而非重建)。块迁走后删除文档即完成迁移。
if len(doc.Blocks) > 0 {
if bound := a.linkBlocksToDocument(doc.ID, doc.Blocks); bound != len(doc.Blocks) {
log.Printf("[agent] doc→graph: %s 块迁移不完整 (%d/%d),保留文档待下轮重试",
doc.ID, bound, len(doc.Blocks))
continue
}
log.Printf("[agent] doc→graph: %s → %d entities, %d relations", doc.ID, ec, rc)
a.docStore.Remove(doc.ID)
}
a.docStore.Remove(doc.ID)
}
}
}
@ -412,6 +437,10 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
})
}
// 媒体不再参与三元组:它作为一等块由 linkBlocksToDocument
// 写入 L3 并以 document --contains--> block 边关联,
// 不经过文本描述与 NLP 提取器。
// NLP 通用提取
e := nlp.NewExtractor(nil)
if embedder != nil {
@ -466,7 +495,6 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults
func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
start := time.Now()
a.currentOutputChannel = "_consolidation_"
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
stageCtx.Extra["output_channel"] = evt.OutputChannel

View File

@ -10,27 +10,14 @@ import (
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func (a *Agent) eventLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] eventLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.eventLoop()
}
}()
for {
select {
case evt := <-a.io.InputChan():
a.handleInput(evt)
case msg := <-a.selfInputCh:
a.handleSelfInput(msg)
case <-a.ctx.Done():
return
}
}
}
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代M2
//
// 原实现直接在 select 里处理 inputCh/selfInputCh没有任何可枚举的队列、
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
// 兼容性说明M2 全部任务为 LevelBackground因此行为等价于原先的 FIFO。
func (a *Agent) interceptLoop() {
defer func() {
@ -63,43 +50,16 @@ func (a *Agent) interceptLoop() {
clone.Payload["interrupt_source"] = evt.Source
clone.Payload["interrupt_channel"] = evt.OutputChannel
a.llmMu.Lock()
hasActiveLLM := a.cancelLLM != nil
if hasActiveLLM {
a.cancelLLM()
log.Printf("[agent] LLM request cancelled by interrupt")
}
a.llmMu.Unlock()
if hasActiveLLM {
if a.currentOutputChannel == "_consolidation_" {
log.Printf("[agent] consolidation interrupted, re-injecting input for %s/%s", evt.Source, evt.OutputChannel)
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": evt.Source,
"interrupt_channel": evt.OutputChannel,
})
} else {
select {
case a.interceptCh <- clone:
default:
log.Printf("[agent] intercept channel full, queuing input for %s", evt.Source)
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": evt.Source,
"interrupt_channel": evt.OutputChannel,
})
}
}
} else {
a.io.InjectInputTo(evt.Source, evt.OutputChannel, "text", map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": evt.Source,
"interrupt_channel": evt.OutputChannel,
})
// 决策交给调度器requestPreempt 总会登记中断(进中断队列或 immediate
// 因而不会丢),仅当它会真抢占时才告诉我“该取消可取消的步骤”。
// 本 goroutine 不碰任何帧——只写中断队列与让位信号。
//
// 级别由来源声明InjectOptions.Priority → payload["priority"]
// 未声明一律 L1。L4“立即打断”只有内核级插件能声明
// 外部插件即便报了 L4 也会被夹到 L3内核自身另有 raiseKernelInterrupt。
level := interruptLevel(evt, a.isKernelLevelSource(evt.Source))
if a.sched.requestPreempt(clone, level) {
a.cancelCurrentLLM()
}
case <-a.ctx.Done():
@ -108,6 +68,20 @@ func (a *Agent) interceptLoop() {
}
}
// cancelCurrentLLM 取消正在进行的 LLM 请求(若有)。
//
// 只有 LLM 流式步骤是可取消的;工具 RPC / ONNX / CAS 在 v1 是临界区,
// 取消对它们无效——让位信号会等它们自然结束后的安全点(设计文档 D2
func (a *Agent) cancelCurrentLLM() {
a.llmMu.Lock()
cancel := a.cancelLLM
a.llmMu.Unlock()
if cancel != nil {
cancel()
log.Printf("[agent] LLM request cancelled by preemption")
}
}
// channelConsolidation 标记记忆整理类自输入:无记忆路径处理,
// 不写入对话上下文、不向任何输出通道 emit 响应。
const channelConsolidation = "_consolidation_"
@ -121,34 +95,27 @@ type selfInputMsg struct {
channel string
}
func (a *Agent) handleSelfInput(msg selfInputMsg) {
// selfEvent 把内核自循环消息归一成输入事件。
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
if msg.channel == "" {
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
}
a.processTextInput(&agentIO.InputEvent{
return &agentIO.InputEvent{
Source: "system",
Type: "text",
Payload: map[string]interface{}{"content": msg.text},
OutputChannel: msg.channel,
}, msg.text)
}
}
func (a *Agent) handleSelfInput(msg selfInputMsg) {
_, _ = a.runInputTask(selfEvent(msg))
}
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
switch evt.Type {
case "text":
input, _ := evt.Payload["content"].(string)
if input == "" {
return
}
// 去重webui/GUI 断线重连会重放未确认消息,短窗口内同来源同内容丢弃,避免轰炸
if a.isDuplicateInput(evt.Source, input) {
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(input, 60))
return
}
a.processTextInput(evt, input)
case "image", "audio":
a.processMediaInput(evt)
case "text", "image", "audio":
_, _ = a.runInputTask(evt)
case "event":
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
@ -162,74 +129,97 @@ func (a *Agent) handleInput(evt *agentIO.InputEvent) {
}
}
func (a *Agent) processMediaInput(evt *agentIO.InputEvent) {
start := time.Now()
a.pendingMedia = evt.Payload
defer func() { a.pendingMedia = nil }()
// inputPayload 是一次输入在「模态」这个维度上的全部内容。
//
// 拆出这个结构,是为了让 processInput 只有一条主干:模态不再决定走哪个函数,
// 只决定这里的字段填不填。此前 text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项——
// 不是因为媒体不需要,而是复制粘贴之后文本那条继续演进、媒体那条没跟上。
type inputPayload struct {
// text 是进 LLM 与记忆的文本。纯媒体输入时它是 mediaToBlocks 给的 alt 文案。
text string
// blocks 非空表示本轮带多模态内容,随当前轮的 message 一起发给模型。
blocks []agentAPI.ContentBlock
// mediaType 供插件在 stage 里判断本轮媒体的模态。
mediaType string
// captureTool 是媒体落进 CAS 时记录的来源标签。
captureTool string
}
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
// resolveInput 把 InputEvent 归一成 inputPayload。
//
// 三种来源在这里合流:
// 1. evt.Type 是 image/audio —— 用户直接发的媒体payload 里是 data/url
// 2. evt.Type 是 text 且 payload 带 media_blocks —— 插件经 IOInjector 的
// InjectInputMedia / InjectInputMediaSync / InjectInterruptMedia 注入的
// 媒体,块已经是成品;
// 3. 纯文本。
//
// 第 2 种此前无处可去:注入方把块放进 payload而文本路径不看这个键
// 于是插件注入的媒体到 payload 就断了,且不报错。
func (a *Agent) resolveInput(evt *agentIO.InputEvent) (inputPayload, bool) {
switch evt.Type {
case "image", "audio":
blocks, alt := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
return inputPayload{
text: alt,
blocks: blocks,
mediaType: evt.Type,
captureTool: "input_" + evt.Type,
}, true
}
blocks, fallback := a.mediaToBlocks(evt.Payload, evt.Type, evt.Source)
stageCtx := a.stageCtxFromInput(fallback, evt.Source, "")
stageCtx.Extra = map[string]interface{}{
"media_blocks": blocks,
"media_type": evt.Type,
"input_source": evt.Source,
"output_channel": evt.OutputChannel,
text, _ := evt.Payload["content"].(string)
blocks, mediaType := injectedBlocks(evt.Payload)
// 文本与媒体都空才算无效输入:只带图不带字是合法的(插件注入常这样)。
if text == "" && len(blocks) == 0 {
return inputPayload{}, false
}
a.injectSourceContext(stageCtx, evt)
return inputPayload{
text: text,
blocks: blocks,
mediaType: mediaType,
captureTool: "inject_" + evt.Source,
}, true
}
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return
// injectedBlocks 取出 payload 里插件注入的多模态块。
//
// 两种静态类型都要认:内核内部注入直接给 []agentAPI.ContentBlock
// 而经公共 SDK 的 IOInjector 过来的是 []pubsdk.ContentBlock。两者字段完全一致
// 但 Go 不会自动转换,只认一种的后果是另一种被静默丢弃。
func injectedBlocks(payload map[string]interface{}) ([]agentAPI.ContentBlock, string) {
var blocks []agentAPI.ContentBlock
switch v := payload["media_blocks"].(type) {
case []agentAPI.ContentBlock:
blocks = v
case []pubsdk.ContentBlock:
blocks = make([]agentAPI.ContentBlock, 0, len(v))
for _, b := range v {
nb := agentAPI.ContentBlock{Type: b.Type, Text: b.Text}
if b.ImageURL != nil {
nb.ImageURL = &agentAPI.ImageURL{URL: b.ImageURL.URL, Detail: b.ImageURL.Detail}
}
if b.AudioURL != nil {
nb.AudioURL = &agentAPI.AudioURL{URL: b.AudioURL.URL}
}
blocks = append(blocks, nb)
}
}
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": evt.Payload,
"source": evt.Source,
})
archived := a.context.Prune(fallback, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
if len(blocks) == 0 {
return nil, ""
}
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: fallback,
})
response, toolsUsed, toolResults, err := a.process(fallback, stageCtx)
if err != nil {
log.Printf("[agent] process media error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: fallback, Response: resp})
return
}
elapsed := time.Since(start)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: fallback,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, fallback, response, toolResults, toolsUsed)
// 模态由块自身判定,注入方不必额外声明。图优先:一次注入里图片是主体。
mediaType := ""
for _, b := range blocks {
if b.ImageURL != nil {
return blocks, "image"
}
if b.AudioURL != nil {
mediaType = "audio"
}
}
return blocks, mediaType
}
func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string, source string) ([]agentAPI.ContentBlock, string) {
@ -271,12 +261,12 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
}
if mediaType == "image" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "image_url",
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: imgURL, Detail: "auto"},
})
} else if mediaType == "audio" {
blocks = append(blocks, agentAPI.ContentBlock{
Type: "audio_url",
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: imgURL},
})
}
@ -285,124 +275,51 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
return blocks, alt
}
func (a *Agent) processTextInput(evt *agentIO.InputEvent, input string) {
start := time.Now()
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
}
if evt.OutputChannel == "_consolidation_" {
a.processConsolidation(evt, input)
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态。
//
// 为什么要单独一条路径而不是复用 emitResponse跳过意味着“我们没有处理这条输入”
// 不应对外发 agent_output 事件(否则 WebUI 聊天记录会凭空多出一条空消息),
// 但必须写 ResponseCh——否则 cli/clawhub 这类无超时的同步注入会永久挂起。
//
// 非阻塞写ResponseCh 由同步调用方以 cap=1 创建,调用方超时离开后仍可写入。
func (a *Agent) emitSkippedReply(evt *agentIO.InputEvent, reason string) {
if evt == nil || evt.ResponseCh == nil {
return
}
noMemory := false
if v, ok := evt.Payload["no_memory"].(bool); ok {
noMemory = v
ch := evt.OutputChannel
if ch == "" {
ch = evt.Source
}
if !noMemory && a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
noMemory = true
}
payload := map[string]interface{}{
"content": "",
"request_id": evt.RequestID,
"skipped": true,
"reason": reason,
}
// 工具提醒/中断terminal_watch、timer 等)不是用户发言:
// 以 system 角色注入 LLM且不写入用户对话履历。
isInterrupt, _ := evt.Payload["interrupt"].(bool)
a.mu.Lock()
a.interruptInput = isInterrupt
a.mu.Unlock()
if isInterrupt {
noMemory = true
}
stageCtx := a.stageCtxFromInput(input, evt.Source, "")
stageCtx.Extra["input_source"] = evt.Source
stageCtx.Extra["output_channel"] = evt.OutputChannel
if noMemory {
stageCtx.NoMemory = true
}
a.injectSourceContext(stageCtx, evt)
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return
}
input = stageCtx.RawMessage
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
cleanInput := input
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
cleanInput = chDef.Cleaner(input)
}
}
a.publishEvent(events.EventRawInput, map[string]interface{}{
"content": input,
"source": evt.Source,
})
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
if !isInterrupt {
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: input,
})
}
response, toolsUsed, toolResults, err := a.process(input, stageCtx)
if err != nil {
log.Printf("[agent] process error: %v", err)
resp := fmt.Sprintf("处理错误: %v", err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: input, Response: resp})
return
}
elapsed := time.Since(start)
log.Printf("[agent] input from %s → response (%dms, tools=%v)", evt.Source, elapsed.Milliseconds(), toolsUsed)
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: cleanInput,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
})
a.emitResponse(evt, response)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed)
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
}
}
func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
// 通道一律从**输入事件**推导(内核不持有"当前通道")。
ch := outputChannelOf(evt)
stageCtx := &sdk.StageContext{
FinalText: response,
Phase: sdk.StageBeforeOutput,
Extra: map[string]interface{}{"output_channel": ch},
}
a.runStage(sdk.StageBeforeOutput, stageCtx)
response = stageCtx.FinalText
ch := a.currentOutputChannel
if ch == "" {
ch = evt.OutputChannel
}
if ch == "" {
ch = evt.Source
}
payload := map[string]interface{}{
"content": response,
"request_id": evt.RequestID,
@ -437,29 +354,70 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
a.runStage(sdk.StageAfterOutput, stageCtx)
}
func (a *Agent) drainInterrupts() []string {
var out []string
for {
select {
case evt := <-a.interceptCh:
if evt == nil {
continue
}
text, _ := evt.Payload["content"].(string)
if text == "" {
continue
}
source := evt.Source
if source == "" {
source = "unknown"
}
channel := evt.OutputChannel
if channel == "" {
channel = source
}
out = append(out, fmt.Sprintf("[打断消息][来源:%s][输出通道:%s] %s", source, channel, text))
default:
return out
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
//
// 默认**不裁剪**ContextPolicy 必须在注入点payload 的 context_policy
// 或通道定义ChannelDef.ContextPolicy上显式声明为 prune 才会裁剪。
//
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
// 是可枚举、可审计的。
//
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
if a.context == nil || !a.pruneDeclared(evt) {
return 0
}
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
if a.isLightKernel() {
return 0
}
topK := a.maxContextSize - 1
if topK < 1 {
topK = 1
}
return a.context.Prune(cleanInput, topK, a.docStore)
}
// pruneDeclared 判定这次输入是否显式声明了裁剪。
//
// 优先级注入点声明的payload> 通道声明的ChannelDef> 默认不裁剪。
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
return p == pubsdk.ContextPolicyPrune
}
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
}
}
return false
}
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
//
// 优先级:注入点声明的 cleanerpayload.cleaner_name引用某个已注册的通道
// cleaner> 按 source 查到的通道 cleaner > 原文。
//
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
if a.io == nil {
return input
}
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
log.Printf("[agent] 注入声明了 cleaner_name=%q 但没有注册过该通道的 Cleaner已回退", name)
}
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
return chDef.Cleaner(input)
}
return input
}

View File

@ -0,0 +1,269 @@
package core
import (
"fmt"
"log"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
)
// L3 图库的媒体绑定。
//
// 媒体在 L3 是一等记忆块memory_blocks以结构边与承载它的节点相连
// sentence --contains--> block对话/三元组产生的记忆)
// document --contains--> blockL2 文档归档进 L3
//
// 这里不再有任何 marker 文本、正则反解或"描述文本当索引"的路径:
// 媒体只按自己的统一空间向量被检索,图库/文档只记录它的结构归属。
// migrateLegacyGraphMedia 把 marker 反解出来的旧媒体实体迁移成原生一等块。
//
// 旧数据里媒体是 type=Media 的普通实体(「图片 a1b2c3d4e5f6」
// 靠生成的描述文本当索引。迁移后它变成真正的记忆块,以
// sentence --contains--> block 结构边挂回原句子,旧实体与描述关系删除。
// 迁移幂等(实体处理完即删除),因此在每个 Agent 启动时跑一次是安全的。
func (a *Agent) migrateLegacyGraphMedia() {
if a.memory == nil || a.mediaStore == nil {
return
}
blocks, entities, err := a.memory.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := a.mediaStore.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
return a.blockFromDigest(full)
})
if err != nil {
log.Printf("[media] 旧媒体实体迁移失败(下轮重试): %v", err)
return
}
if blocks > 0 || entities > 0 {
log.Printf("[media] 旧媒体实体迁移完成: 新建 %d 个原生块,删除 %d 个描述式实体", blocks, entities)
}
}
// attachBlocksToSentence 把一组 digest 变成 L3 一等块并挂到句子上。
// seed 允许复用已持有块的 IDL2→L3 迁移保持块身份不变)。
func (a *Agent) attachBlocksToSentence(sentenceID int64, digests []string, seed map[string]memory.MemoryBlock) int {
if a.mediaStore == nil || a.memory == nil || sentenceID == 0 {
return 0
}
bound := 0
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] digest %s 无法解析: %v", d, err)
continue
}
b, ok := seed[full]
if !ok {
if b, ok = a.blockFromDigest(full); !ok {
continue
}
}
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
log.Printf("[media] L3 块写入失败 (%s): %v", shortDigest(full), err)
continue
}
if err := a.memory.AddMemoryBlockEdge("sentence", strconv.FormatInt(sentenceID, 10), "block", b.ID, "contains"); err != nil {
log.Printf("[media] 句子→块边建立失败 (%s): %v", shortDigest(full), err)
continue
}
bound++
}
return bound
}
// linkBlocksToDocument 把文档持有的块写入 L3并建立
// document --contains--> block 边。块的 ID 原样保留(迁移而非重建)。
func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock) int {
if a.memory == nil || docID == "" || len(blocks) == 0 {
return 0
}
if err := a.memory.PutDocumentNode(docID, ""); err != nil {
log.Printf("[media] 写入 L3 文档节点失败 (%s): %v", docID, err)
return 0
}
if err := a.memory.PutMemoryBlocks(blocks); err != nil {
log.Printf("[media] 写入 L3 记忆块失败 (doc %s): %v", docID, err)
return 0
}
bound := 0
for _, b := range blocks {
if err := a.memory.AddMemoryBlockEdge("document", docID, "block", b.ID, "contains"); err != nil {
log.Printf("[media] 文档→块边建立失败 (%s): %v", shortDigest(b.PayloadDigest), err)
continue
}
bound++
}
return bound
}
// commitTriplesWithMedia 提交三元组并把三元组显式携带的媒体变成 L3 一等块。
//
// seed 是调用方已持有的一等块(如 L2 文档的 Blocks用于保持块身份
// 普通对话路径传 nil。blocks 是本次写入 L3 的块数。
func (a *Agent) commitTriplesWithMedia(triples []memory.Triple, sessionID string, turnID int, seed []memory.MemoryBlock) (entities, relations, blocks int, err error) {
g := a.graphMem()
if g == nil {
return 0, 0, 0, fmt.Errorf("graph memory 未启用")
}
// 轻量内核memory == nil子只有图记忆或没有媒体库时只写图记忆。
// 写目标由 a.graph 决定 —— 根落 main子落自己的 temp。
if a.memory == nil || a.mediaStore == nil {
ec, rc, cErr := g.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)
if err != nil {
return ec, rc, 0, err
}
byDigest := make(map[string]memory.MemoryBlock, len(seed))
for _, b := range seed {
if b.PayloadDigest != "" {
byDigest[b.PayloadDigest] = b
}
}
for _, t := range triples {
if len(t.MediaDigests) == 0 {
continue
}
sid := sentenceIDs[t.SentenceText]
if sid == 0 {
continue
}
blocks += a.attachBlocksToSentence(sid, t.MediaDigests, byDigest)
}
return ec, rc, blocks, nil
}
// RecallBlocksForSentence 反查某条图库句子持有的一等记忆块。
func (a *Agent) RecallBlocksForSentence(sentenceID int64) ([]memory.MemoryBlock, error) {
if a.memory == nil {
return nil, nil
}
return a.memory.BlocksForNode("sentence", strconv.FormatInt(sentenceID, 10))
}
// resolveMediaDigests 把模型给的多为短digest 补全成完整 digest。
//
// 补不上就丢弃那一条并记日志:模型可能凭印象编了个 digest也可能内容已被删除。
func (a *Agent) resolveMediaDigests(digests []string) []string {
if a.mediaStore == nil || len(digests) == 0 {
return nil
}
seen := make(map[string]bool, len(digests))
var out []string
for _, d := range digests {
full, err := a.mediaStore.ResolvePrefix(d)
if err != nil {
log.Printf("[media] 模型给的 digest %s 无法解析: %v", d, err)
continue
}
if seen[full] {
continue
}
seen[full] = true
out = append(out, full)
}
return out
}
// sentenceIDsFromRelations 收集一批关系引用的句子 id去重、去零
//
// 关系行本身不持有媒体,媒体作为一等块以 sentence --contains--> block
// 结构边与句子相连;因此"这次召回涉及哪些媒体"必须经由关系 → 句子这一跳。
func sentenceIDsFromRelations(relations []memory.Relation) []int64 {
if len(relations) == 0 {
return nil
}
seen := make(map[int64]bool, len(relations))
var out []int64
for _, r := range relations {
if r.SentenceID == 0 || seen[r.SentenceID] {
continue
}
seen[r.SentenceID] = true
out = append(out, r.SentenceID)
}
return out
}
// mediaContextForRelations 是 mediaContextForSentences 的关系入口。
func (a *Agent) mediaContextForRelations(relations []memory.Relation) string {
return a.mediaContextForSentences(sentenceIDsFromRelations(relations))
}
// mediaContextForInjectedEntities 为自动注入路径产出媒体说明。
//
// Indexer.BuildContext 刻意不返回关系(只给实体索引以省 token
// 因此这里用命中的实体名再查一次关系,只为拿到 sentence_id。
func (a *Agent) mediaContextForInjectedEntities(injected *memory.InjectedContext) string {
if a.mediaStore == nil || a.memory == nil || injected == nil || len(injected.Entities) == 0 {
return ""
}
names := make([]string, 0, len(injected.Entities))
for _, e := range injected.Entities {
names = append(names, e.Name)
}
res, err := a.memory.Recall(nil, names, 1, "")
if err != nil || res == nil {
return ""
}
return a.mediaContextForRelations(res.Relations)
}
// blockLabelsForDoc 渲染文档持有块的标签MIME + 短 digest供 doc_query 展示。
func (a *Agent) blockLabelsForDoc(d *document.Doc) string {
if a.mediaStore == nil || d == nil || len(d.Blocks) == 0 {
return ""
}
var parts []string
for _, b := range d.Blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
return strings.Join(parts, "")
}
// mediaContextForSentences 给一组句子附上其持有的一等块标签。
//
// 标签只含 MIME 与短 digest图片按向量检索标签的作用是告诉模型
// "这条记忆当时带着哪份媒体、可用该 digest 取回字节"。
func (a *Agent) mediaContextForSentences(sentenceIDs []int64) string {
if a.mediaStore == nil || a.memory == nil || len(sentenceIDs) == 0 {
return ""
}
var lines []string
for _, sid := range sentenceIDs {
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil || len(blocks) == 0 {
continue
}
var parts []string
for _, b := range blocks {
it, err := a.mediaStore.Stat(b.PayloadDigest)
if err != nil || it == nil {
continue
}
if line := mediaLabel(it); line != "" {
parts = append(parts, line)
}
}
if len(parts) > 0 {
lines = append(lines, fmt.Sprintf("句子 #%d 关联媒体:%s", sid, strings.Join(parts, "")))
}
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}

View File

@ -0,0 +1,761 @@
package core
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// L3 图库媒体绑定测试。
//
// 这一层的目的只有一个:几个月后从图谱走到一条句子,要能取回当时那份媒体。
// 媒体作为一等块进入 L3以结构边与承载节点相连
//
// sentence --contains--> block对话/三元组产生的记忆)
// document --contains--> blockL2 文档归档进 L3
//
// 描述文本、marker 反解、由 marker 反推出的「媒体实体」全部已废弃,
// 因此这些测试也不存在任何按描述检索的断言。
// attachBlockToSentence 提交一条句子并把媒体变成 L3 一等块。
// 必须走真实提交:边要求两端都是真实图节点。
func attachBlockToSentence(t *testing.T, g *memory.GraphDB, ms *media.Store, sentenceText, digest string) (int64, memory.MemoryBlock) {
t.Helper()
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentenceText,
}}, "test", 0)
if err != nil {
t.Fatalf("CommitWithMedia: %v", err)
}
sid := ids[sentenceText]
if sid == 0 {
t.Fatalf("拿不到句子 id: %q", sentenceText)
}
it, err := ms.Stat(digest)
if err != nil || it == nil {
t.Fatalf("Stat(%s): %v", shortDigest(digest), err)
}
b := memory.MemoryBlock{
ID: fmt.Sprintf("blk_test_%d_%s", sid, shortDigest(digest)),
Modality: memory.BlockImage,
PayloadDigest: it.Digest,
MIME: it.MIME,
Size: it.Size,
Width: it.Width,
Height: it.Height,
Vector: it.Vec,
Fingerprint: it.VecModel,
}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatalf("PutMemoryBlocks: %v", err)
}
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
t.Fatalf("AddMemoryBlockEdge: %v", err)
}
return sid, b
}
func newGraphMediaAgent(t *testing.T) (*Agent, *memory.GraphDB, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return &Agent{memory: g, mediaStore: ms}, g, ms
}
func TestCommitWithMedia_ReturnsSentenceIDs(t *testing.T) {
_, g, _ := newGraphMediaAgent(t)
sentence := "这张图是紫蓝红三色带。"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
}}
ids, ec, rc, err := g.CommitWithMedia(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec == 0 || rc == 0 {
t.Fatalf("应写入实体与关系,实际 ec=%d rc=%d", ec, rc)
}
if ids[sentence] == 0 {
t.Fatalf("应返回句子 id实际 %v", ids)
}
}
func TestCommit_StillWorksAfterRefactor(t *testing.T) {
// Commit 有三十多个调用点,内部转调后行为必须完全不变
_, g, _ := newGraphMediaAgent(t)
triples := []memory.Triple{
{Subject: "张三", Relation: "喜欢", Object: "咖啡", SentenceText: "张三喜欢咖啡"},
{Subject: "李四", Relation: "住在", Object: "北京"},
}
ec, rc, err := g.Commit(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if ec != 4 || rc != 2 {
t.Fatalf("期望 4 实体 2 关系,实际 ec=%d rc=%d", ec, rc)
}
// 重复提交同一批:关系被唯一约束去重。
//
// 实体计数**不**归零——这是 upsertEntity 的既有行为SQLite 的
// ON CONFLICT DO UPDATE 也算一行 affected于是 RowsAffected() > 0
// 被当成"新建了"。用 main 分支的 graph.go 单独验证过基线同样是
// 首次 ec=2 / 重复 ec=2与 CommitWithMedia 重构无关。
// entitiesCreated 只用于日志,故此处记录现状而不改行为。
ec2, rc2, err := g.Commit(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
if rc2 != 0 {
t.Fatalf("重复提交不该新建关系,实际 rc=%d", rc2)
}
if ec2 != 4 {
t.Fatalf("实体计数应与首次一致(既有 upsert 计数行为),实际 ec=%d", ec2)
}
}
func TestCommitTriplesWithMedia_RoundTrip(t *testing.T) {
// 整层的核心断言:写入 → 提交 → 反查取回原始字节
a, _, ms := newGraphMediaAgent(t)
content := []byte("\x89PNG\r\n\x1a\n fake image bytes")
digest, err := ms.Put(content, media.Item{MIME: "image/png", Kind: media.KindImage})
if err != nil {
t.Fatal(err)
}
sentence := "用户发来一张紫蓝红三色带图。"
triples := []memory.Triple{{
Subject: "图片", Relation: "内容", Object: "三色带",
SentenceText: sentence,
MediaDigests: []string{digest[:12]}, // 模型手里通常只有短 digest
}}
if _, _, bound, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
} else if bound != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", bound)
}
ids, _, _, err := a.memory.CommitWithMedia(triples, "s1", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
// 反查:从句子取回一等块,再取回字节
blocks, err := a.RecallBlocksForSentence(sid)
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Fatalf("反查应得完整 digest %s实际 %+v", shortDigest(digest), blocks)
}
got, err := ms.Get(blocks[0].PayloadDigest)
if err != nil {
t.Fatalf("取回内容失败: %v", err)
}
if string(got) != string(content) {
t.Fatal("取回的内容与写入不一致")
}
// 块仍被 L3 持有 → 内容应仍可读
if _, err := ms.Get(digest); err != nil {
t.Fatalf("被 L3 记忆块持有的内容不该被清除: %v", err)
}
}
func TestAttachBlocksToSentence_SkipsUnresolvable(t *testing.T) {
// digest 在库里不存在时必须跳过,不能建一条指向虚无的块边。
a, g, _ := newGraphMediaAgent(t)
if n := a.attachBlocksToSentence(42, []string{"deadbeefdead"}, nil); n != 0 {
t.Fatalf("无法补全的 digest 不该建块,实际绑定 %d", n)
}
blocks, err := g.BlocksForNode("sentence", "42")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 0 {
t.Fatalf("不该有块,实际 %+v", blocks)
}
}
func TestAttachBlocksToSentence_NilStoreNoop(t *testing.T) {
a := &Agent{}
if n := a.attachBlocksToSentence(1, []string{"aaaaaaaaaaaa"}, nil); n != 0 {
t.Fatalf("媒体关闭时应静默无操作,实际 %d", n)
}
if got, err := a.RecallBlocksForSentence(1); err != nil || got != nil {
t.Fatalf("媒体关闭时应静默无操作,实际 %v / %v", got, err)
}
}
func TestAttachBlocksToSentence_ReusesSeedIdentity(t *testing.T) {
// L2→L3 迁移必须保持块身份:同一个块换层,而不是另建一个同内容的新块。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("seed-img"), media.Item{MIME: "image/png"})
seedBlock, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "迁移", Relation: "包含", Object: "媒体", SentenceText: "迁移测试句。",
}}, "seed", 0)
if err != nil {
t.Fatal(err)
}
sid := ids["迁移测试句。"]
byDigest := map[string]memory.MemoryBlock{digest: seedBlock}
if n := a.attachBlocksToSentence(sid, []string{digest}, byDigest); n != 1 {
t.Fatalf("应绑定 1 个块,实际 %d", n)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != seedBlock.ID {
t.Fatalf("块身份应保持为 %s实际 %+v", seedBlock.ID, blocks)
}
}
func TestLinkBlocksToDocument_CreatesDocumentNodeEdge(t *testing.T) {
// 文档归档进 L3块原样迁入document --contains--> block 边建立。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("doc-img"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if n := a.linkBlocksToDocument("doc_42", []memory.MemoryBlock{b}); n != 1 {
t.Fatalf("应建立 1 条文档→块边,实际 %d", n)
}
blocks, err := g.BlocksForNode("document", "doc_42")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != b.ID {
t.Fatalf("文档应持有块 %s实际 %+v", b.ID, blocks)
}
}
func TestCommitTriplesWithMedia_FallsBackWithoutStore(t *testing.T) {
// 媒体关闭时退回普通 Commit行为与直接调 Commit 完全一致
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
a := &Agent{memory: g}
ec, rc, _, err := a.commitTriplesWithMedia([]memory.Triple{
{Subject: "张三", Relation: "喜欢", Object: "咖啡"},
}, "s1", 0, nil)
if err != nil {
t.Fatal(err)
}
if ec != 2 || rc != 1 {
t.Fatalf("期望 2 实体 1 关系,实际 ec=%d rc=%d", ec, rc)
}
}
func TestMediaContextForSentences(t *testing.T) {
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
out := a.mediaContextForSentences([]int64{sid, sid + 100})
if out == "" {
t.Fatal("应产出媒体说明")
}
if !contains(out, fmt.Sprintf("句子 #%d", sid)) || !contains(out, shortDigest(digest)) {
t.Fatalf("说明内容不对: %q", out)
}
// 说明只含 MIME 与短 digest不含任何生成的描述
if contains(out, "紫蓝红") {
t.Fatalf("说明里不该有描述文本(描述式索引已废弃): %q", out)
}
// 无引用的句子不该出现
if contains(out, fmt.Sprintf("句子 #%d", sid+100)) {
t.Fatalf("无引用的句子不该出现: %q", out)
}
}
func TestMediaContextForRelations_SurfacesMediaToAgent(t *testing.T) {
// L3 检索接线回归媒体作为一等块进了图库agent 必须拿得出来。
a, g, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("img bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
sid, _ := attachBlockToSentence(t, g, ms, "一张紫蓝红三色带图。", digest)
// 命中的关系挂着该句子 → 应产出媒体说明
out := a.mediaContextForRelations([]memory.Relation{{ID: 1, SentenceID: sid}})
if out == "" {
t.Fatal("关系挂着有媒体的句子却没产出媒体说明——L3 检索接线断了")
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体说明里应含短 digest 供反查: %q", out)
}
// 没挂媒体的关系不该产出噪声
if out := a.mediaContextForRelations([]memory.Relation{{ID: 2, SentenceID: 99}}); out != "" {
t.Errorf("无媒体的句子不该产出说明: %q", out)
}
if out := a.mediaContextForRelations(nil); out != "" {
t.Errorf("空关系不该产出说明: %q", out)
}
}
func TestBuildMemoryContext_IncludesMediaSection(t *testing.T) {
// buildMemoryContext 是自动注入路径(每次 LLM 调用都走)。
// 媒体说明必须出现在这里,否则 agent 只有显式调 memory_recall 才知道有图。
a, graph, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("auto inject"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
sentence := "用户发来的图片。"
sids, _, _, err := graph.CommitWithMedia([]memory.Triple{{
Subject: "测试图片", Relation: "包含", Object: "三色带", SentenceText: sentence,
}}, "auto", 0)
if err != nil {
t.Fatal(err)
}
sid := sids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
if err := graph.PutMemoryBlocks([]memory.MemoryBlock{{
ID: "blk_auto_1", Modality: memory.BlockImage,
PayloadDigest: digest, MIME: "image/png",
}}); err != nil {
t.Fatal(err)
}
if err := graph.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", "blk_auto_1", "contains"); err != nil {
t.Fatal(err)
}
a.indexer = memory.NewIndexer(graph)
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
out := a.buildMemoryContext("测试图片", 0)
if out == "" {
t.Skip("图库召回未命中indexer 检索策略所致),无法验证媒体段注入")
}
if !contains(out, "【关联媒体】") {
t.Errorf("自动注入的记忆上下文缺少媒体段: %q", out)
}
if !contains(out, shortDigest(digest)) {
t.Errorf("媒体段里应含短 digest: %q", out)
}
}
func TestResolvePrefix(t *testing.T) {
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
digest, _ := ms.Put([]byte("content"), media.Item{MIME: "image/png"})
// 短前缀补全
full, err := ms.ResolvePrefix(digest[:12])
if err != nil || full != digest {
t.Fatalf("短前缀补全失败: %v / %v", full, err)
}
// 完整 digest 原样返回
full, err = ms.ResolvePrefix(digest)
if err != nil || full != digest {
t.Fatalf("完整 digest 应原样返回: %v / %v", full, err)
}
// 过短拒绝
if _, err := ms.ResolvePrefix("abc"); err == nil {
t.Fatal("过短前缀应报错")
}
// 不存在
if _, err := ms.ResolvePrefix("deadbeefdead"); err == nil {
t.Fatal("不存在的前缀应报错")
}
// 完整但不存在的 digest 也要报错,否则调用方会挂一条孤儿块
fake := strings.Repeat("0", 64)
if _, err := ms.ResolvePrefix(fake); err == nil {
t.Fatal("不存在的完整 digest 应报错")
}
}
func TestResolvePrefix_AmbiguityIsError(t *testing.T) {
// 前缀歧义视为错误而非"取第一个":挂错块会让内容被误删。
// 构造歧义需要两个同前缀 digest——sha256 无法人为构造,
// 因此这里退而验证「12 位前缀在大量样本下的行为是确定的」:
// 要么唯一命中,要么明确报歧义,绝不静默取第一个。
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "m"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
digests := make([]string, 0, 200)
for i := 0; i < 200; i++ {
d, err := ms.Put([]byte("content-"+strconv.Itoa(i)), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
digests = append(digests, d)
}
for _, d := range digests {
got, err := ms.ResolvePrefix(d[:12])
if err != nil {
if !contains(err.Error(), "歧义") {
t.Fatalf("非歧义错误: %v", err)
}
continue
}
if got != d {
t.Fatalf("补全结果错误: 前缀 %s 得到 %s", d[:12], got)
}
}
}
func TestArchiveColdDocs_KeepsDocWhenGraphWriteEmpty(t *testing.T) {
// 数据丢失回归三元组全被实体名校验拒绝时Commit 无错但 0 entities
// 0 relations文档不能删、其持有的块不能丢。
a, _, ms := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.embedder = memory.NewStaticEmbedder()
content := []byte("image bytes")
digest, err := ms.Put(content, media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
// 精确构造「三元组非空 + Commit 全部拒绝」这个状态:
// Source/Summary 都超过 validEntityName 的 50 字符上限,
// 于是 docToTriples 产出的两条元数据三元组都被跳过。
longSource := strings.Repeat("超长来源名", 20) // 100 字
longSummary := strings.Repeat("超长摘要文本", 20) // >80 字触发长度门槛被跳过
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_keep",
Summary: longSummary,
Content: "一段没有媒体标记的正文",
Source: longSource,
CreatedAt: time.Now().Add(-200 * time.Hour),
LastAccess: time.Now().Add(-200 * time.Hour),
AccessCount: 0,
Blocks: []memory.MemoryBlock{{ID: "blk_keep_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
// Insert 会把 LastAccess 覆写成 now、AccessCount 置 1
// 于是 FindColdDocs(72h, 2) 一篇都找不到。插入后再改回来,
// 让文档真正满足"冷"的条件——这是触发归档路径的前提。
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
// 关键断言:内容在、块在、文档在
if _, err := ms.Get(digest); err != nil {
t.Fatalf("图库未写入任何实体/关系,内容却丢了: %v", err)
}
held := false
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID && len(d.Blocks) > 0 {
held = true
}
}
if !held {
t.Error("文档或块被释放了——图库没有句子承载它,内容会被删除")
}
}
func TestArchiveColdDocs_MigratesBlocksToGraph(t *testing.T) {
// 归档成功时块必须迁进 L3 并以 document --contains--> block 关联,
// 然后文档才被删除(迁移而非复制/引用保活)。
a, g, ms := newGraphMediaAgent(t)
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
a.docStore = ds
a.embedder = memory.NewStaticEmbedder()
digest, _ := ms.Put([]byte("archived-image"), media.Item{MIME: "image/png"})
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_arch",
Summary: "带图的冷文档",
Content: "张三把三色带图交给了李四。",
Source: "manual",
Blocks: []memory.MemoryBlock{{ID: "blk_arch_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
for _, d := range ds.RecentDocs(10) {
if d.ID == doc.ID {
d.LastAccess = time.Now().Add(-200 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
if d := ds.Get("doc_arch"); d != nil {
t.Fatal("块已迁入 L3文档应被删除")
}
blocks, err := g.BlocksForNode("document", "doc_arch")
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].ID != "blk_arch_1" {
t.Fatalf("L3 文档节点应持有原块(身份不变),实际 %+v", blocks)
}
if _, err := ms.Get(digest); err != nil {
t.Fatalf("块被 L3 持有,内容应仍可读: %v", err)
}
}
func TestMigrateLegacyMediaEntities(t *testing.T) {
// 旧数据:媒体被伪装成 type=Media 的实体,靠描述文本当索引。
// 迁移必须把它还原成原生块(挂回原句子)并删掉旧实体与描述关系。
_, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("legacy-img"), media.Item{MIME: "image/png"})
sentence := "老数据里的三色带图 [image/png " + digest[:12] + "]"
// 直接构造旧的实体/关系形态(不走已删除的 marker 代码)。
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "图片 " + digest[:12],
SubjectType: "Media",
Relation: "内容",
Object: "三色带的描述文本",
ObjectType: "Description",
SentenceText: sentence,
}}, "legacy", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
if sid == 0 {
t.Fatal("拿不到句子 id")
}
blocks, entities, err := g.MigrateLegacyMediaEntities(func(short string) (memory.MemoryBlock, bool) {
full, err := ms.ResolvePrefix(short)
if err != nil {
return memory.MemoryBlock{}, false
}
it, err := ms.Stat(full)
if err != nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: "blk_legacy_" + short, Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size,
}, true
})
if err != nil {
t.Fatal(err)
}
if blocks != 1 || entities != 1 {
t.Fatalf("应迁移 1 块 / 删 1 实体,实际 %d / %d", blocks, entities)
}
// 旧媒体实体与描述关系必须消失
res, err := g.Recall([]string{"图片 " + digest[:12]}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
for _, e := range res.Entities {
if e.Type == "Media" {
t.Fatalf("旧媒体实体仍存在: %+v", e)
}
}
// 块必须挂回原句子
got, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].PayloadDigest != digest {
t.Fatalf("句子应持有原生块,实际 %+v", got)
}
// 幂等:再跑一遍不应重复建块
blocks2, entities2, err := g.MigrateLegacyMediaEntities(nil)
if err != nil {
t.Fatal(err)
}
if blocks2 != 0 || entities2 != 0 {
t.Fatalf("无 resolver 时应空操作,实际 %d / %d", blocks2, entities2)
}
}
func TestCleanupOrphanedSentences_KeepsBlockBackedSentences(t *testing.T) {
// 旧媒体实体被删除后,承载它的句子可能再无关系引用,
// 但它还挂着媒体块——清理孤儿句子时不能把它删掉。
a, g, ms := newGraphMediaAgent(t)
digest, _ := ms.Put([]byte("orphan-img"), media.Item{MIME: "image/png"})
sentence := "只靠媒体块存活的句子。"
ids, _, _, err := g.CommitWithMedia([]memory.Triple{{
Subject: "媒体载体", Relation: "包含", Object: "内容", SentenceText: sentence,
}}, "orphan", 0)
if err != nil {
t.Fatal(err)
}
sid := ids[sentence]
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
if err := g.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
if err := g.AddMemoryBlockEdge("sentence", strconv.FormatInt(sid, 10), "block", b.ID, "contains"); err != nil {
t.Fatal(err)
}
// 解除关系引用,句子只剩块边
res, err := g.Recall([]string{"媒体载体"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
for _, r := range res.Relations {
if err := g.ClearSentenceID(r.ID); err != nil {
t.Fatal(err)
}
}
if _, err := g.CleanupOrphanedSentences(); err != nil {
t.Fatal(err)
}
blocks, err := g.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 {
t.Fatalf("承载媒体块的句子被误删,块反查失败: %+v", blocks)
}
}
func TestSentenceIDsFromRelations(t *testing.T) {
// 关系行不持有媒体,媒体挂在句子上。这个函数负责"关系→句子"这一跳,
// 去重与去零都不能少sentence_id=0 表示该关系没有关联句子。
rels := []memory.Relation{
{ID: 1, SentenceID: 5},
{ID: 2, SentenceID: 0}, // 无句子
{ID: 3, SentenceID: 5}, // 重复
{ID: 4, SentenceID: 7},
}
got := sentenceIDsFromRelations(rels)
if len(got) != 2 {
t.Fatalf("应得 2 个去重后的句子 id实际 %v", got)
}
if got[0] != 5 || got[1] != 7 {
t.Fatalf("句子 id 或顺序不对: %v", got)
}
if n := sentenceIDsFromRelations(nil); n != nil {
t.Fatalf("空输入应返回 nil实际 %v", n)
}
}
func TestMediaBlocksHeldByDocumentSurviveDeletion(t *testing.T) {
// 文档持有的一等块把内容钉住;文档被删后块随之消失,内容才可回收。
_, _, ms := newGraphMediaAgent(t)
digest, err := ms.Put([]byte("doc image"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
it, _ := ms.Stat(digest)
doc := &document.Doc{
ID: "doc_1", Summary: "带图的文档", Content: "正文",
Blocks: []memory.MemoryBlock{{ID: "blk_doc_1", Modality: memory.BlockImage,
PayloadDigest: it.Digest, MIME: it.MIME, Size: it.Size}},
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
// 文档仍持有块 → 内容在
if _, err := ms.Stat(digest); err != nil {
t.Fatal("有文档块持有内容时不该被清")
}
// 删除文档 → 一并删除其内容(与文本块一致:删块即删内容)
ds.Remove(doc.ID)
if blocks := ds.Blocks(); len(blocks) != 0 {
t.Fatalf("删除文档后不该还有块,实际 %+v", blocks)
}
if err := ms.Delete(digest); err != nil {
t.Fatal(err)
}
if _, err := ms.Stat(digest); err == nil {
t.Fatal("删除后内容应已移除")
}
}

View File

@ -0,0 +1,167 @@
package core
// inputch 总览:**单工具多视图**。
//
// inputch 是**最基本的输入路由单位**(由插件注册,一个插件可注册多个)。
// 父 agent 需要能看清两件事:
// 1. 有哪些 inputch 已注册(谁注册的);
// 2. 它们是怎么划分的(各自划给了哪个 agent、容量多少
//
// 按用户要求做成**单工具多视图**(一个 `input_channels` 工具 + `view` 参数),
// 而不是一堆小工具 —— 视图切换比工具增殖更好用,也更省提示词预算。
import (
"fmt"
"sort"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
func (a *Agent) executeInputChannels(tc agentAPI.ToolCall) string {
view, _ := tc.Arguments["view"].(string)
view = strings.TrimSpace(view)
if view == "" {
view = "all"
}
name, _ := tc.Arguments["name"].(string)
all := a.io.InputChannels()
if len(all) == 0 {
return "没有任何已注册的 inputch。"
}
switch view {
case "all":
return a.renderInputChannels(all, "全部已注册 inputch")
case "mine":
return a.renderInputChannels(a.channelRegistry().ListByOwner(string(a.id)),
"划给本 agent"+string(a.id)+")的 inputch")
case "unassigned":
return a.renderInputChannels(a.channelRegistry().ListByOwner(""),
"尚未划出的 inputch可按需分配")
case "by_agent":
return a.renderInputChannelsByAgent(all)
case "detail":
if name == "" {
return "view=detail 需要 name 参数inputch 名)"
}
ch, ok := a.io.LookupInputChannel(name)
if !ok {
return fmt.Sprintf("inputch %q 未注册", name)
}
return renderInputChannelDetail(ch)
default:
return fmt.Sprintf("未知 view=%q可用all | mine | unassigned | by_agent | detail", view)
}
}
func (a *Agent) channelRegistry() *agentIO.ChannelRegistry { return a.io.ChannelRegistry() }
// renderInputChannels 渲染一组 inputch 的一行式概览。
func (a *Agent) renderInputChannels(list []agentIO.InputChannel, title string) string {
if len(list) == 0 {
return title + ":无"
}
var b strings.Builder
fmt.Fprintf(&b, "%s%d 个):", title, len(list))
for _, ch := range list {
fmt.Fprintf(&b, "\n - %s%s%s", ch.Name, pluginSuffix(ch), policySuffix(ch))
fmt.Fprintf(&b, "\n 归属: %s", ownerLabel(ch.Owner))
if ch.Capacity > 0 {
fmt.Fprintf(&b, " | 容量: %d", ch.Capacity)
}
if ch.Output != "" {
fmt.Fprintf(&b, " | 默认回程: %s", ch.Output)
}
}
return b.String()
}
// renderInputChannelsByAgent 按归属分组("划分情况"总览)。
func (a *Agent) renderInputChannelsByAgent(all []agentIO.InputChannel) string {
byOwner := map[string][]agentIO.InputChannel{}
for _, ch := range all {
byOwner[ch.Owner] = append(byOwner[ch.Owner], ch)
}
owners := make([]string, 0, len(byOwner))
for o := range byOwner {
owners = append(owners, o)
}
sort.Strings(owners)
var b strings.Builder
fmt.Fprintf(&b, "inputch 划分情况(共 %d 个):", len(all))
for _, o := range owners {
names := make([]string, 0, len(byOwner[o]))
for _, ch := range byOwner[o] {
names = append(names, ch.Name)
}
sort.Strings(names)
fmt.Fprintf(&b, "\n - %s: %s", ownerLabel(o), strings.Join(names, ", "))
}
return b.String()
}
// renderInputChannelDetail 渲染单个 inputch 的全部字段。
func renderInputChannelDetail(ch agentIO.InputChannel) string {
var b strings.Builder
fmt.Fprintf(&b, "inputch: %s\n", ch.Name)
fmt.Fprintf(&b, " 注册插件: %s\n", orDash(ch.Plugin))
fmt.Fprintf(&b, " 归属 agent: %s\n", ownerLabel(ch.Owner))
if ch.Capacity > 0 {
fmt.Fprintf(&b, " 容量: %d\n", ch.Capacity)
} else {
fmt.Fprintf(&b, " 容量: 内核默认\n")
}
fmt.Fprintf(&b, " 默认回程输出通道: %s\n", orDash(ch.Output))
fmt.Fprintf(&b, " 记忆策略: %s\n", policyLabel(ch))
return b.String()
}
func pluginSuffix(ch agentIO.InputChannel) string {
if ch.Plugin == "" {
return ""
}
return "(插件 " + ch.Plugin + ""
}
// policySuffix 用短标记提示策略(详见 view=detail
func policySuffix(ch agentIO.InputChannel) string {
var m []string
if ch.Def.NoMemory {
m = append(m, "无记忆")
}
if ch.Def.Cleaner != nil {
m = append(m, "清洗")
}
if ch.Def.ContextPolicy != "" && ch.Def.ContextPolicy != "none" {
m = append(m, "裁剪:"+ch.Def.ContextPolicy)
}
if len(m) == 0 {
return ""
}
return " [" + strings.Join(m, "/") + "]"
}
func policyLabel(ch agentIO.InputChannel) string {
if s := policySuffix(ch); s != "" {
return strings.Trim(s, " []")
}
return "默认(记入记忆、不裁剪)"
}
func ownerLabel(owner string) string {
if owner == "" {
return "未分配(根 agent/内核默认)"
}
return owner
}
func orDash(s string) string {
if s == "" {
return "-"
}
return s
}

View File

@ -0,0 +1,104 @@
package core
// inputch 总览工具(单工具多视图)的验收。
//
// 需求:父 agent 能看到**所有已注册的 inputch**以及**它们的划分情况**。
// 构筑方式单工具input_channels+ 多视图view=all|mine|unassigned|by_agent|detail
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
func inputChToolCall(args map[string]interface{}) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: "ic1", Name: "input_channels", Arguments: args}
}
func TestInputChannelsTool_SingleToolMultipleViews(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
me := string(a.id)
// 同一个插件注册多个 inputch最基本的输入路由单位另一个插件再注册一个。
if err := a.io.RegisterInputChannelFrom("qq", "qq", agentIO.ChannelDef{NoMemory: true}); err != nil {
t.Fatal(err)
}
if err := a.io.RegisterInputChannelFrom("qq", "qq/device-2", agentIO.ChannelDef{}); err != nil {
t.Fatal(err)
}
if err := a.io.RegisterInputChannelFrom("sub", "sub/in", agentIO.ChannelDef{}); err != nil {
t.Fatal(err)
}
// 划分qq 与 sub/in 归本 agentqq/device-2 留未分配。
if err := a.io.AssignInputChannel("qq", me, 64); err != nil {
t.Fatal(err)
}
if err := a.io.AssignInputChannel("sub/in", me, 0); err != nil {
t.Fatal(err)
}
// view=all默认全部已注册且带归属插件。
all := a.executeInputChannels(inputChToolCall(nil))
for _, want := range []string{"qq", "qq/device-2", "sub/in", "插件 qq", "插件 sub", "无记忆"} {
if !strings.Contains(all, want) {
t.Fatalf("view=all 缺少 %q\n%s", want, all)
}
}
// view=mine只有划给本 agent 的。
mine := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "mine"}))
if !strings.Contains(mine, "qq") || !strings.Contains(mine, "sub/in") {
t.Fatalf("view=mine 应含 qq 与 sub/in\n%s", mine)
}
if strings.Contains(mine, "qq/device-2") {
t.Fatalf("view=mine 不应含未分配的 qq/device-2\n%s", mine)
}
// view=unassigned只有尚未划出的。
un := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "unassigned"}))
if !strings.Contains(un, "qq/device-2") {
t.Fatalf("view=unassigned 应含 qq/device-2\n%s", un)
}
if strings.Contains(un, "sub/in") {
t.Fatalf("view=unassigned 不应含已划分的 sub/in\n%s", un)
}
// view=by_agent划分情况总览按归属分组
byAgent := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "by_agent"}))
if !strings.Contains(byAgent, me+":") {
t.Fatalf("view=by_agent 应列出归属 %q\n%s", me, byAgent)
}
if !strings.Contains(byAgent, "未分配") {
t.Fatalf("view=by_agent 应列出未分配一组:\n%s", byAgent)
}
// view=detail单个 inputch 的全字段(容量 / 策略 / 回程 / 归属)。
detail := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "qq"}))
for _, want := range []string{"inputch: qq", "注册插件: qq", "容量: 64", "无记忆", me} {
if !strings.Contains(detail, want) {
t.Fatalf("view=detail 缺少 %q\n%s", want, detail)
}
}
if miss := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail", "name": "nope"})); !strings.Contains(miss, "未注册") {
t.Fatalf("detail 查未注册的 inputch 应明确报错:%s", miss)
}
if noName := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "detail"})); !strings.Contains(noName, "需要 name") {
t.Fatalf("detail 缺 name 应提示:%s", noName)
}
// 未知视图必须报错并列出可用值(不要把拼错静默当成默认视图)。
bad := a.executeInputChannels(inputChToolCall(map[string]interface{}{"view": "whatever"}))
if !strings.Contains(bad, "未知 view") || !strings.Contains(bad, "by_agent") {
t.Fatalf("未知 view 应报错并列出可用值:%s", bad)
}
}
// 一个 inputch 都没有时应给出明确说明,而不是空串。
func TestInputChannelsTool_NoChannels(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if out := a.executeInputChannels(inputChToolCall(nil)); !strings.Contains(out, "没有任何已注册") {
t.Fatalf("空登记表应明确说明:%q", out)
}
}

View File

@ -0,0 +1,615 @@
package core
import (
"path/filepath"
"strconv"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 统一输入主干processInput / resolveInput / injectedBlocks
// 模型可调用工具的媒体接线测试。
//
// 这一层此前的结构性缺陷text 与 image/audio 各有一个 process 函数,
// 媒体那条缺了去重、no_memory、通道 Cleaner、中断语义、EventRawInput 五项。
// 归一成一条主干后,这些行为对所有模态一致——下面的断言就是这个不变量。
func newInputTestAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
return &Agent{mediaStore: ms}, ms
}
// ---------- injectedBlocks ----------
// 内核内部注入直接给 []agentAPI.ContentBlock经公共 SDK 的 IOInjector 过来的是
// []pubsdk.ContentBlock。两者字段一致但 Go 不会自动转换,只认一种的后果是
// 另一种被静默丢弃——插件注入的图到 payload 就断了,且不报错。
func TestInjectedBlocks_AcceptsBothStaticTypes(t *testing.T) {
t.Run("内核类型", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "text", Text: "看图"},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "data:image/png;base64,AAA"}},
},
})
if len(blocks) != 2 {
t.Fatalf("blocks = %d期望 2", len(blocks))
}
if kind != "image" {
t.Errorf("mediaType = %q期望 image", kind)
}
})
t.Run("公共SDK类型", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []pubsdk.ContentBlock{
{Type: "text", Text: "听音频"},
{Type: "audio_url", AudioURL: &pubsdk.AudioURL{URL: "data:audio/wav;base64,BBB"}},
},
})
if len(blocks) != 2 {
t.Fatalf("blocks = %d期望 2公共 SDK 类型被静默丢弃)", len(blocks))
}
if kind != "audio" {
t.Errorf("mediaType = %q期望 audio", kind)
}
// 转换必须保留 URL否则块到了模型手上是空的
if blocks[1].AudioURL == nil || blocks[1].AudioURL.URL != "data:audio/wav;base64,BBB" {
t.Errorf("AudioURL 转换丢失: %+v", blocks[1].AudioURL)
}
})
t.Run("图优先于音频", func(t *testing.T) {
_, kind := injectedBlocks(map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: "a"}},
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "b"}},
},
})
if kind != "image" {
t.Errorf("mediaType = %q期望 image", kind)
}
})
t.Run("无媒体块", func(t *testing.T) {
blocks, kind := injectedBlocks(map[string]interface{}{"content": "纯文本"})
if blocks != nil || kind != "" {
t.Errorf("无 media_blocks 时应返回 (nil,\"\"),实际 (%v,%q)", blocks, kind)
}
})
t.Run("ImageURL 的 Detail 透传", func(t *testing.T) {
blocks, _ := injectedBlocks(map[string]interface{}{
"media_blocks": []pubsdk.ContentBlock{
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "u", Detail: "high"}},
},
})
if len(blocks) != 1 || blocks[0].ImageURL.Detail != "high" {
t.Errorf("Detail 未透传: %+v", blocks)
}
})
}
// ---------- resolveInput ----------
func TestResolveInput_UnifiesAllModalities(t *testing.T) {
a, _ := newInputTestAgent(t)
t.Run("用户上传图片", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "qq",
Type: "image",
Payload: map[string]interface{}{"data": "AAAA", "mime": "image/png"},
})
if !ok {
t.Fatal("图片输入被判为无效")
}
if in.mediaType != "image" || in.captureTool != "input_image" {
t.Errorf("mediaType=%q captureTool=%q", in.mediaType, in.captureTool)
}
if in.text == "" {
t.Error("纯媒体输入应有 alt 文案作为文本落点")
}
if len(in.blocks) == 0 {
t.Error("图片应转成内容块")
}
})
t.Run("插件注入的媒体", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "myplugin",
Type: "text",
Payload: map[string]interface{}{
"content": "帮我看看这张图",
"media_blocks": []pubsdk.ContentBlock{
{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AAA"}},
},
},
})
if !ok {
t.Fatal("带媒体的文本输入被判为无效")
}
if in.text != "帮我看看这张图" {
t.Errorf("text = %q", in.text)
}
if len(in.blocks) != 1 || in.mediaType != "image" {
t.Errorf("blocks=%d mediaType=%q —— 插件注入的媒体到 payload 就断了", len(in.blocks), in.mediaType)
}
if in.captureTool != "inject_myplugin" {
t.Errorf("captureTool = %q期望带来源便于溯源", in.captureTool)
}
})
t.Run("只带图不带字也合法", func(t *testing.T) {
_, ok := a.resolveInput(&agentIO.InputEvent{
Source: "myplugin",
Type: "text",
Payload: map[string]interface{}{
"media_blocks": []agentAPI.ContentBlock{
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: "u"}},
},
},
})
if !ok {
t.Error("只带媒体不带文本应视为有效输入(插件注入常这样)")
}
})
t.Run("文本与媒体都空才无效", func(t *testing.T) {
if _, ok := a.resolveInput(&agentIO.InputEvent{
Source: "cli",
Type: "text",
Payload: map[string]interface{}{"content": ""},
}); ok {
t.Error("空输入应被拒")
}
})
t.Run("纯文本", func(t *testing.T) {
in, ok := a.resolveInput(&agentIO.InputEvent{
Source: "cli",
Type: "text",
Payload: map[string]interface{}{"content": "你好"},
})
if !ok || in.text != "你好" || len(in.blocks) != 0 || in.mediaType != "" {
t.Errorf("纯文本路径异常: ok=%v in=%+v", ok, in)
}
})
}
// ---------- 模型工具侧memory_digests 结构化传递 ----------
// 模型只知道 digest从对话或 memory_recall 的「关联媒体」读到)。
// 它不再需要自己拼任何标记digest 作为结构化字段随三元组提交。
func TestResolveMediaDigestsAndNoMarkerText(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, err := ms.Put([]byte("marker-bytes"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatalf("Put: %v", err)
}
t.Run("短digest补全", func(t *testing.T) {
got := a.resolveMediaDigests([]string{digest[:12]})
if len(got) != 1 || got[0] != digest {
t.Fatalf("短 digest 应补全为完整 digest得到 %v", got)
}
})
t.Run("无法解析的digest被丢弃", func(t *testing.T) {
if got := a.resolveMediaDigests([]string{"ffffffffffff"}); len(got) != 0 {
t.Errorf("不存在的 digest 不该保留: %v", got)
}
})
t.Run("无媒体存储时返回nil", func(t *testing.T) {
bare := &Agent{}
if got := bare.resolveMediaDigests([]string{digest}); got != nil {
t.Errorf("无媒体存储时应返回 nil: %v", got)
}
})
}
// 句子文本必须保持原样:媒体归属走结构化块边,不往文本里贴 marker。
func TestMemoryCommit_DoesNotPolluteSentenceText(t *testing.T) {
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{memory: g, mediaStore: ms}
digest, _ := ms.Put([]byte("clean-sentence"), media.Item{MIME: "image/png"})
sentence := "用户发来一张图。"
triples := []memory.Triple{{
Subject: "用户", Relation: "发来", Object: "图片",
SentenceText: sentence,
MediaDigests: a.resolveMediaDigests([]string{digest[:12]}),
}}
if _, _, _, err := a.commitTriplesWithMedia(triples, "s1", 0, nil); err != nil {
t.Fatal(err)
}
res, err := a.memory.Recall([]string{"用户"}, nil, 2, "")
if err != nil {
t.Fatal(err)
}
if len(res.Relations) == 0 {
t.Fatal("召回为空")
}
if res.Relations[0].SentenceText != sentence {
t.Errorf("句子文本被污染: %q", res.Relations[0].SentenceText)
}
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatal(err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("块应挂到句子,实际 %+v", blocks)
}
}
// ---------- resolveMediaDigests ----------
func TestResolveMediaDigests(t *testing.T) {
a, ms := newInputTestAgent(t)
d1, _ := ms.Put([]byte("one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("two"), media.Item{MIME: "image/png"})
got := a.resolveMediaDigests([]string{d1[:10], d2, d1, "ffffffffffff"})
if len(got) != 2 {
t.Fatalf("got = %v期望 2 条(去重 + 丢弃无法解析的)", got)
}
for _, d := range got {
if len(d) != 64 {
t.Errorf("应返回完整 digest实际 %q", d)
}
}
if a.resolveMediaDigests(nil) != nil {
t.Error("空输入应返回 nil")
}
bare := &Agent{}
if bare.resolveMediaDigests([]string{d1}) != nil {
t.Error("无媒体存储时应返回 nil")
}
}
// ---------- 文档持有的一等记忆块 ----------
func TestDocCommit_StoresBlocks(t *testing.T) {
// doc_commit 带 media_digests 时,媒体应作为一等块直接存在文档上,
// 并随 doc 一起持久化(不再靠 media_refs 保活)。
dir := t.TempDir()
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
d1, _ := ms.Put([]byte("doc-one"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-two"), media.Item{MIME: "image/png"})
doc := &document.Doc{ID: "doc_x", Summary: "s", Content: "c"}
for _, d := range []string{d1, d2} {
if b, ok := (&Agent{mediaStore: ms}).blockFromDigest(d); ok {
doc.Blocks = append(doc.Blocks, b)
}
}
if err := ds.Insert(doc); err != nil {
t.Fatal(err)
}
blocks := ds.Blocks()
if len(blocks) != 2 {
t.Fatalf("文档应持有 2 个块,实际 %d", len(blocks))
}
seen := map[string]bool{}
for _, b := range blocks {
seen[b.PayloadDigest] = true
}
if !seen[d1] || !seen[d2] {
t.Errorf("块 digest 不对: %+v", blocks)
}
}
// ---------- 文档持有块标签doc_query 展示用) ----------
func TestBlockLabelsForDoc(t *testing.T) {
a, ms := newInputTestAgent(t)
digest, _ := ms.Put([]byte("ctx-bytes"), media.Item{MIME: "image/png"})
b, ok := a.blockFromDigest(digest)
if !ok {
t.Fatal("blockFromDigest 失败")
}
t.Run("从文档持有的一等块渲染", func(t *testing.T) {
got := a.blockLabelsForDoc(&document.Doc{ID: "doc_1", Blocks: []memory.MemoryBlock{b}})
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("标签应含短 digest: %q", got)
}
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
}
})
t.Run("无块时为空", func(t *testing.T) {
if got := a.blockLabelsForDoc(&document.Doc{ID: "doc_x", Content: "普通正文"}); got != "" {
t.Errorf("应返回空串,实际 %q", got)
}
})
t.Run("无媒体存储", func(t *testing.T) {
bare := &Agent{}
if got := bare.blockLabelsForDoc(&document.Doc{ID: "doc_x"}); got != "" {
t.Errorf("无媒体存储时应返回空串,实际 %q", got)
}
})
}
// ---------- mediaLabel ----------
// 媒体标签的唯一生成处:只含 MIME 与短 digest不含任何生成的描述。
func TestMediaLabel(t *testing.T) {
a, ms := newInputTestAgent(t)
_ = a
digest, _ := ms.Put([]byte("labelled"), media.Item{MIME: "image/png"})
it, err := ms.Stat(digest)
if err != nil {
t.Fatal(err)
}
got := mediaLabel(it)
if !strings.Contains(got, "image/png") {
t.Errorf("标签应含 MIME: %q", got)
}
if !strings.Contains(got, shortDigest(digest)) {
t.Errorf("必须带短 digest 供反查: %q", got)
}
if got := mediaLabel(nil); got != "" {
t.Errorf("nil 应返回空串,实际 %q", got)
}
}
// ---------- 模型工具端到端memory_commit / doc_commit / doc_query ----------
func newToolTestAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
ds := document.NewStore(filepath.Join(dir, "documents"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatalf("doc store: %v", err)
}
t.Cleanup(func() { ds.Stop() })
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
emb := memory.NewStaticEmbedder("")
a := &Agent{
id: "tester",
memory: g,
docStore: ds,
mediaStore: ms,
context: NewRelevanceContext("", emb),
}
return a, ms
}
// memory_commit 带 media_digests三元组入库后必须能从句子反查回那份字节。
func TestToolMemoryCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("commit-bytes"), media.Item{MIME: "image/png"})
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "配色方案",
"relation": "参考",
"object": "三色带图",
"media_digests": []interface{}{digest[:12]},
},
},
},
})
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
res, err := a.memory.Recall([]string{"配色方案"}, nil, 2, "")
if err != nil {
t.Fatalf("Recall: %v", err)
}
if len(res.Relations) == 0 || res.Relations[0].SentenceID == 0 {
t.Fatal("没有句子落点 —— 媒体引用无从挂起")
}
blocks, err := a.memory.BlocksForNode("sentence", strconv.FormatInt(res.Relations[0].SentenceID, 10))
if err != nil {
t.Fatalf("BlocksForNode: %v", err)
}
if len(blocks) != 1 || blocks[0].PayloadDigest != digest {
t.Errorf("句子块 = %+v期望 [%s]", blocks, digest)
}
}
// 不带 media_digests 时行为与本特性上线前一致(不多写句子、不报错)。
func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
a, _ := newToolTestAgent(t)
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
},
},
})
if strings.Contains(out, "失败") {
t.Errorf("普通提交不该失败: %q", out)
}
if strings.Contains(out, "关联") {
t.Errorf("无媒体时不该提媒体: %q", out)
}
}
// sentence_text 必须透传:丢了它,图谱就回不到原文。
func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
a, _ := newToolTestAgent(t)
a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "李四",
"relation": "住在",
"object": "杭州",
"sentence_text": "李四搬到杭州已经三年了。",
},
},
},
})
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回为空")
}
if res.Relations[0].SentenceText != "李四搬到杭州已经三年了。" {
t.Errorf("SentenceText = %q", res.Relations[0].SentenceText)
}
}
// doc_commit 带 media_digests媒体成为文档直接持有的一等块正文保持原样。
func TestToolDocCommit_BindsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("doc-commit-bytes"), media.Item{MIME: "image/png"})
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "这是一篇带图的笔记正文。",
"summary": "带图笔记",
"media_digests": []interface{}{digest[:12]},
},
})
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
docs := a.docStore.RecentDocs(5)
if len(docs) == 0 {
t.Fatal("文档未写入")
}
d := docs[0]
if strings.Contains(d.Content, "image/png") {
t.Errorf("正文不该被媒体标记污染: %q", d.Content)
}
var held bool
for _, b := range d.Blocks {
if b.PayloadDigest == digest {
held = true
}
}
if !held {
t.Errorf("文档应持有一等记忆块 [%s],实际 %+v", digest, d.Blocks)
}
}
// doc_query 必须把媒体说明附在返回值里,否则模型检索到带图文档也不知道有图。
func TestToolDocQuery_ShowsMedia(t *testing.T) {
a, ms := newToolTestAgent(t)
digest, _ := ms.Put([]byte("query-bytes"), media.Item{MIME: "image/png"})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "紫蓝红三色带配色说明正文",
"summary": "紫蓝红三色带",
"media_digests": []interface{}{digest},
},
})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色说明", "top_k": float64(3)},
})
// 正文进的是 cold_storage 事件(工具返回值只给引用编号),媒体说明也在那里。
var found bool
for _, e := range a.context.Recent(10) {
if strings.Contains(e.Response, shortDigest(digest)) {
found = true
}
}
if !found {
t.Error("doc_query 未把媒体说明带进上下文 —— 模型不知道这篇文档带过图")
}
}
// 无媒体存储时三个工具的行为与本特性上线前完全一致。
func TestTools_NilMediaStoreDegrades(t *testing.T) {
a, _ := newToolTestAgent(t)
a.mediaStore = nil
out := a.executeMemoryTool(agentAPI.ToolCall{
Name: "memory_commit",
Arguments: map[string]interface{}{
"triples": []interface{}{
map[string]interface{}{
"subject": "无存储", "relation": "仍可", "object": "提交",
"media_digests": []interface{}{"aabbccddeeff"},
},
},
},
})
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
}
out = a.executeDocTool(agentAPI.ToolCall{
Name: "doc_commit",
Arguments: map[string]interface{}{
"content": "无媒体存储的文档",
"media_digests": []interface{}{"aabbccddeeff"},
},
})
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时文档写入不该失败: %q", out)
}
}

View File

@ -0,0 +1,169 @@
package core
// N2c 验收:**轻量内核 profile**。
//
// 设计 docs/zh/resident-subagent-design.md §16.0(窄接口 + nil 即禁用)与 §5.5。
//
// 轻量内核(驻留子)的记忆装配:
// - graph = *memory.LightMemory读 tempmain只写 temp
// - memory = nil ⇒ 既有的 `if a.memory != nil` 关卡自动禁掉**全部**整理面:
// 记忆整理流水线distill.go 的 archive/review/merge 循环)、记忆块与媒体桥
// graphmedia.go / medialoop.go、记忆整理工具merge/delete/purge/edit/block_merge
//
// 因此这里要钉住四件事:
// ① 子的写入只落 temp主库不受影响
// ② 子的读是并集(看得到主库 + 自己的 temp
// ③ 整理类工具**不进子的工具表**
// ④ 即便被直调,整理类操作也明确报"轻量内核不支持"(纵深防御,不静默降级)。
import (
"path/filepath"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// newLightAgent 构造一个轻量内核 agent驻留子形态有 LightMemory没有整理面。
func newLightAgent(t *testing.T, main *memory.GraphDB, tempPath string) *Agent {
t.Helper()
light, err := memory.NewLightMemory(main, tempPath, true)
if err != nil {
t.Fatal(err)
}
a := New(AgentConfig{
ID: "sub-1",
Provider: &scriptProvider{},
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
LightMemory: light, // 轻量内核:只给图记忆共同面
})
t.Cleanup(func() { light.Close() })
return a
}
func TestLightProfile_MemoryFaceWiring(t *testing.T) {
dir := t.TempDir()
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
defer main.Close()
if _, _, err := main.Commit([]memory.Triple{
{Subject: "主记忆实体", Relation: "属于", Object: "主库"},
}, "sess", 1); err != nil {
t.Fatal(err)
}
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
// ① 整理面必须为 nil —— 这正是"nil 即禁用"的开关。
if a.memory != nil {
t.Fatal("轻量内核不该有整理面a.memory 必须为 nil")
}
if a.graphMem() == nil {
t.Fatal("轻量内核必须有图记忆共同面")
}
// ② 写入只落 temp主库不得出现子才知道的实体。
if _, _, _, err := a.commitTriplesWithMedia([]memory.Triple{
{Subject: "子独有实体", Relation: "来自", Object: "子的temp"},
}, "sess", 1, nil); err != nil {
t.Fatalf("子的写入应成功(落 temp: %v", err)
}
mainRes, err := main.Recall([]string{"子独有实体"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
for _, e := range mainRes.Entities {
if e.Name == "子独有实体" {
t.Fatalf("子的写入不该进主库:%v", e.Name)
}
}
// ③ 读是并集:主库的实体与 temp 的实体都要看得到。
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_recall",
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
})
if !strings.Contains(got, "主记忆实体") {
t.Fatalf("子应看得到主记忆:%s", got)
}
if !strings.Contains(got, "子独有实体") {
t.Fatalf("子应看得到自己的 temp%s", got)
}
}
func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
dir := t.TempDir()
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
defer main.Close()
a := newLightAgent(t, main, filepath.Join(dir, "sub.db"))
// ① 整理类工具不进子的工具表(不是进去再报不可用)。
names := toolNames(a)
for _, banned := range []string{
"memory_merge", "memory_delete_entity", "memory_block_merge",
"memory_purge", "memory_edit",
} {
if hasTool(names, banned) {
t.Fatalf("轻量内核不该声明整理类工具 %s%v", banned, names)
}
}
// 对照:共同面/无关能力照常在。
if !hasTool(names, "input_channels") {
t.Fatalf("轻量内核仍应有共同面工具:%v", names)
}
// ② 纵深防御:即便被直调,整理类操作也必须明确报"轻量内核不支持"。
for _, tool := range []string{
"memory_merge", "memory_delete_entity", "memory_block_merge",
"memory_purge", "memory_edit", "memory_introspect",
} {
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "x", Name: tool,
Arguments: map[string]interface{}{
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
},
})
if !strings.Contains(got, "轻量内核") {
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
}
}
}
// 对照:完整内核(根 agent仍有整理面与整理工具。
func TestFullProfile_KeepsOrganizeFace(t *testing.T) {
dir := t.TempDir()
main, err := memory.NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
defer main.Close()
a := New(AgentConfig{
ID: "root",
Provider: &scriptProvider{},
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
Memory: main,
})
if a.memory == nil {
t.Fatal("根 agent 必须有整理面")
}
if a.graphMem() == nil {
t.Fatal("根 agent 必须有图记忆共同面")
}
names := toolNames(a)
if !hasTool(names, "memory_merge") {
t.Fatalf("根 agent 应保留整理类工具:%v", names)
}
}

View File

@ -0,0 +1,545 @@
//go:build medialive
// 媒体记忆自动触发链的集成测试。
//
// 与其他媒体测试的区别:**不手工调用任何一步**。这里只做两件事——
// 往 IOManager 注入一个 image 事件,然后等。之后全部由生产代码自己走:
//
// processMediaInput → captureBlockMedia入 CAS
// → PruneL0→L2 块迁移)
// → describePendingMedia真实视觉模型生成描述
// → archiveColdDocs → commitTriplesWithMedia → bindSentenceBlocksL2→L3
// → 第二轮提问,验证 agent 真能召回
//
// 为什么必须这样测:单测能证明每个函数正确,却证明不了它**被接上了**——
// 手工注入 store 的单测全绿而生产链路断开,是本文件要拦的典型缺陷。
//
// 需要真实 LLM因此加 medialive build tag默认 go test 不跑:
//
// MEDIALIVE_BASE_URL=http://127.0.0.1:8081/v1 \
// MEDIALIVE_API_KEY=sk-xxx \
// MEDIALIVE_MODEL=claude-opus-5 \
// MEDIALIVE_ADAPTER=openai \
// go test -tags medialive ./internal/agent/core/ -run TestMediaLive -v -timeout 20m
//
// 源、模型、密钥全部由调用方显式指定,测试自己不猜任何默认值——
// 猜一个默认端点会让测试在别人机器上打到意料之外的服务。
package core
import (
"bytes"
"compress/zlib"
"encoding/binary"
"fmt"
"hash/crc32"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
luaVM "gitcode.com/JianFeeeee/HomeAgent/internal/lua"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// liveCfg 是调用方通过环境变量显式提供的 LLM 源配置。
type liveCfg struct {
baseURL string
apiKey string
model string
adapter string
}
// requireLiveCfg 读取环境变量;缺任何一项就 Skip 而非猜默认值。
//
// 刻意不提供 fallback一个猜出来的 base_url 可能打到调用者机器上
// 完全不相干的服务,而测试会把那次调用的失败报成"媒体记忆有问题"。
func requireLiveCfg(t *testing.T) liveCfg {
t.Helper()
c := liveCfg{
baseURL: os.Getenv("MEDIALIVE_BASE_URL"),
apiKey: os.Getenv("MEDIALIVE_API_KEY"),
model: os.Getenv("MEDIALIVE_MODEL"),
adapter: os.Getenv("MEDIALIVE_ADAPTER"),
}
var missing []string
if c.baseURL == "" {
missing = append(missing, "MEDIALIVE_BASE_URL")
}
if c.apiKey == "" {
missing = append(missing, "MEDIALIVE_API_KEY")
}
if c.model == "" {
missing = append(missing, "MEDIALIVE_MODEL")
}
if c.adapter == "" {
missing = append(missing, "MEDIALIVE_ADAPTER")
}
if len(missing) > 0 {
t.Skipf("缺少环境变量 %s——本测试要求调用方显式指定源/模型/密钥,不使用任何默认值",
strings.Join(missing, ", "))
}
return c
}
// livePNG 造一张横向三色带真 PNG手工拼 IHDR/IDAT/IEND
//
// 用可辨认的纯色而非随机字节:断言要能检查"模型是否真的看到了内容"
// 随机噪声无法产生可验证的描述。
func livePNG(t *testing.T, w, h int, colors [][3]byte) []byte {
t.Helper()
chunk := func(typ string, data []byte) []byte {
var b bytes.Buffer
if err := binary.Write(&b, binary.BigEndian, uint32(len(data))); err != nil {
t.Fatal(err)
}
body := append([]byte(typ), data...)
b.Write(body)
if err := binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(body)); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
var raw bytes.Buffer
for y := 0; y < h; y++ {
raw.WriteByte(0) // filter type: none
c := colors[y*len(colors)/h]
for x := 0; x < w; x++ {
raw.Write(c[:])
}
}
var comp bytes.Buffer
zw := zlib.NewWriter(&comp)
if _, err := zw.Write(raw.Bytes()); err != nil {
t.Fatal(err)
}
zw.Close()
var ihdr bytes.Buffer
binary.Write(&ihdr, binary.BigEndian, uint32(w))
binary.Write(&ihdr, binary.BigEndian, uint32(h))
ihdr.Write([]byte{8, 2, 0, 0, 0}) // 8-bit truecolor
var out bytes.Buffer
out.Write([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'})
out.Write(chunk("IHDR", ihdr.Bytes()))
out.Write(chunk("IDAT", comp.Bytes()))
out.Write(chunk("IEND", nil))
return out.Bytes()
}
// liveEnv 是一套完整但完全独立的 agent 运行环境。
type liveEnv struct {
agent *Agent
io *agentIO.IOManager
mediaSt *media.Store
docStore *document.Store
graph *memory.GraphDB
dir string
}
// newLiveEnv 构造真 Agent真 provider、真 CAS、真图库、真文档库。
//
// 不注册任何插件:本测试关心记忆链路,插件会引入无关的外部副作用
// (网络轮询、写文件),而且生产插件目录里的进程不该被测试碰到。
func newLiveEnv(t *testing.T, c liveCfg) *liveEnv {
t.Helper()
dir := t.TempDir()
vm := luaVM.NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatalf("lua vm: %v", err)
}
t.Cleanup(vm.Stop)
// Vision: true —— 能力是声明的,不是探测的。网关可能静默剥离
// image_url 后仍返回 200从响应无法推断它到底看见了没有。
prov := agentAPI.NewLuaAdaptedProvider(agentAPI.BaseConfig{
Model: c.model, BaseURL: c.baseURL, APIKey: c.apiKey,
MaxTokens: 1200, Temperature: 0.3, Vision: true,
}, vm, "medialive", c.adapter)
pm := agentAPI.NewProviderManager()
pm.Register("medialive", prov)
if err := pm.SetDefault("medialive"); err != nil {
t.Fatalf("set default provider: %v", err)
}
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media store: %v", err)
}
t.Cleanup(func() { ms.Close() })
graph, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("graph: %v", err)
}
t.Cleanup(func() { graph.Close() })
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatalf("doc store: %v", err)
}
t.Cleanup(docStore.Stop)
io := agentIO.NewIOManager()
a := New(AgentConfig{
ID: types.AgentID("medialive"),
SystemPrompt: "你是一个有长期记忆的助手。回答简洁准确。",
Provider: prov,
ProviderManager: pm,
IO: io,
Memory: graph,
DocStore: docStore,
MediaStore: ms,
StageHost: NewStageHost(),
MaxContextSize: 3, // 故意压低:第二轮就能触发 Prune 归档
InputProcessing: types.InputProcessingConfig{},
})
// 排空 outputCh容量 256但长跑不消费会堵住 emitResponse。
go func() {
for {
select {
case <-io.OutputChan():
case <-a.ctx.Done():
return
}
}
}()
return &liveEnv{agent: a, io: io, mediaSt: ms, docStore: docStore, graph: graph, dir: dir}
}
// TestMediaLive_AutoTriggerChain 全自动触发链:只注入事件,不手工调任何一步。
func TestMediaLive_AutoTriggerChain(t *testing.T) {
c := requireLiveCfg(t)
env := newLiveEnv(t, c)
a := env.agent
defer a.Stop()
img := livePNG(t, 96, 96, [][3]byte{{128, 0, 255}, {0, 64, 255}, {255, 0, 0}})
t.Logf("测试图片: %d 字节(紫/蓝/红三色带)", len(img))
// ── 阶段 1注入 image 事件,验证 CAS 自动落盘 ──
//
// 直接调 handleInput 而不启 eventLoopeventLoop 是纯转发select →
// handleInput走同一条代码路径但同步调用让断言不必猜时序。
evt := &agentIO.InputEvent{
RequestID: "live-1",
Source: "test_channel",
Type: "image",
OutputChannel: "test_channel",
Payload: map[string]interface{}{
"data": mediaB64(img),
"mime": "image/png",
"alt": "一张测试图片",
},
}
t0 := time.Now()
a.handleInput(evt)
t.Logf("第一轮(含真实 LLM 往返)耗时 %.1fs", time.Since(t0).Seconds())
// 媒体不再有文字描述CAS 里只有字节、元数据与向量。
// 这里直接按 digest 定位刚落的图(不再有 Pending 队列)。
st := env.mediaSt.Stats()
if st["count"].(int) != 1 {
t.Fatalf("CAS 应自动收到 1 张图,实际 %v 张captureBlockMedia 未被触发?)", st["count"])
}
var digest string
var found bool
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
digest, found = b.PayloadDigest, true
}
}
if !found {
t.Fatal("无法从上下文块定位刚落盘的图")
}
it0, err := env.mediaSt.Stat(digest)
if err != nil {
t.Fatal(err)
}
t.Logf("✓ 阶段1 CAS 自动落盘: digest=%s size=%d tool=%s",
digest[:12], it0.Size, it0.Tool)
stored, err := env.mediaSt.Get(digest)
if err != nil || !bytes.Equal(stored, img) {
t.Fatalf("落盘内容与原图不一致 (err=%v)", err)
}
// ── 阶段 2一等记忆块自动挂到 ContextEvent 上 ──
//
// 这一步验证 bindEventMedia事件必须拿到 ID 并直接持有块;
// 事件文本必须保持原样(不再往正文里贴媒体标记)。
var evtID string
for _, e := range a.context.Recent(0) {
if len(e.Blocks) > 0 {
evtID = e.ID
if strings.Contains(e.Input, digest[:12]) {
t.Error("事件 Input 里被写入了媒体标记——描述式索引链应该已经拆除")
}
if e.Blocks[0].PayloadDigest != digest {
t.Fatalf("事件持有的块 digest 不对: %+v", e.Blocks)
}
break
}
}
if evtID == "" {
t.Fatal("没有任何 ContextEvent 挂上媒体bindEventMedia 未被触发)")
}
t.Logf("✓ 阶段2 块自动绑定: event=%s", evtID)
// ── 阶段 3媒体只按自己的向量被索引不再生成任何描述 ──
if it, err := env.mediaSt.Stat(digest); err != nil {
t.Fatal(err)
} else if len(it.Vec) == 0 {
// 未配置多模态空间时就没有向量——这是合法的降级状态,
// 但要明确报出来,而不是靠描述文本假装能检索。
t.Log("未配置多模态空间:本图无向量,之后只能靠块结构召回 digest")
} else {
t.Logf("✓ 阶段3 已写入原生向量: dim=%d", len(it.Vec))
}
// ── 阶段 4Prune 自动把块从 L0 迁移到 L2 ──
//
// MaxContextSize=3多注入几轮文本把带图事件挤出活跃上下文。
// 迁移的是块本身同一身份换层L0 中不该再留下它。
// 填充数量必须 > Prune 内部固定的 10 条保护窗口。
//
// Prune 无条件保护最后 10 条事件protected := events[len-10:]
// 只在更早的部分里挑归档对象。填 4 条时总数才 5全落进保护窗口、
// candidates 为空、直接返回 0——这不是缺陷是"最近的对话不该被归档"
// 的设计。带图事件必须被推到第 11 条之前才可能被归档。
const fillerCount = 14
for i := 0; i < fillerCount; i++ {
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "filler",
Input: fmt.Sprintf("无关的填充对话 %d用来把带图事件挤出活跃窗口", i),
Response: "好的。",
})
}
archived := a.context.Prune("当前输入", a.maxContextSize-1, env.docStore)
t.Logf("Prune 归档 %d 条事件", archived)
if archived == 0 {
t.Fatal("Prune 未归档任何事件,无法验证引用转移")
}
docRefsFound := ""
for _, d := range env.docStore.RecentDocs(20) {
for _, b := range d.Blocks {
if b.PayloadDigest == digest {
docRefsFound = d.ID
}
}
}
if docRefsFound == "" {
t.Fatal("块未随归档事件迁移到 L2 文档")
}
// 同一块不能同时留在 L0。
for _, e := range a.context.Recent(0) {
for _, b := range e.Blocks {
if b.PayloadDigest == digest {
t.Errorf("块仍留在 L0evt %s违反单层不变量", e.ID)
}
}
}
t.Logf("✓ 阶段4 块自动迁移: context/%s → document/%s", evtID, docRefsFound)
// 迁移全程内容必须可读:块虽换了层,字节仍在。
if _, err := env.mediaSt.Get(digest); err != nil {
t.Fatalf("迁移后内容不可读: %v", err)
}
// ── 阶段 5archiveColdDocs 自动把块连到 L3 文档节点 ──
//
// FindColdDocs(72h, 2) 要求文档足够"冷",测试里新建的文档不满足,
// 因此把 LastAccess 往前推——这是为了触发生产代码路径,
// 而不是替代它commitTriplesWithMedia/linkBlocksToDocument 全由它自己调)。
for _, d := range env.docStore.RecentDocs(20) {
if d.ID == docRefsFound {
d.LastAccess = time.Now().Add(-100 * time.Hour)
d.AccessCount = 0
}
}
a.archiveColdDocs()
// 块可能以 document --contains--> block文档归档
// sentence --contains--> block对话三元组两种边存在。
sentRefs := 0
var boundSentence int64
docBound := 0
rows, err := env.graph.Recall(nil, nil, 1, "")
if err != nil {
t.Fatalf("graph recall: %v", err)
}
t.Logf("图库实体数 %d", len(rows.Entities))
docBlocks, err := env.graph.BlocksForNode("document", docRefsFound)
if err != nil {
t.Fatal(err)
}
docBound = len(docBlocks)
// 句子 id 是自增整数,扫前若干个足够覆盖本测试写入的量
for sid := int64(1); sid <= 40; sid++ {
blocks, err := env.graph.BlocksForNode("sentence", strconv.FormatInt(sid, 10))
if err == nil && len(blocks) > 0 {
sentRefs += len(blocks)
if boundSentence == 0 {
boundSentence = sid
}
}
}
if sentRefs == 0 && docBound == 0 {
t.Error("L2→L3 未写入任何块边——linkBlocksToDocument 未被 archiveColdDocs 触发")
} else if docBound > 0 {
t.Logf("✓ 阶段5 L3 自动写入: 文档 %s 持有 %d 个块", docRefsFound, docBound)
got := docBlocks
if got[0].PayloadDigest != digest {
t.Errorf("文档节点持有的块 digest 不对: %+v", got)
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从文档块取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
} else {
t.Logf("✓ 阶段5 L3 自动写入: %d 个句子块,首个 sentences.id=%d", sentRefs, boundSentence)
got, err := env.agent.RecallBlocksForSentence(boundSentence)
if err != nil || len(got) == 0 || got[0].PayloadDigest != digest {
t.Errorf("从句子反查块失败: got=%+v err=%v", got, err)
} else if raw, err := env.mediaSt.Get(got[0].PayloadDigest); err != nil || !bytes.Equal(raw, img) {
t.Errorf("从句子取回的字节与原图不一致 (err=%v)", err)
} else {
t.Logf("✓ 阶段5 反查取回 %d 字节,与原图逐字节一致", len(raw))
}
}
// ── 阶段 6内容随块存在不被单独清理 ──
if _, err := env.mediaSt.Stat(digest); err != nil {
t.Fatalf("被记忆块持有的内容不存在了: %v", err)
}
t.Logf("✓ 阶段6 被持有内容仍在")
// ── 阶段 7E2E — 第二轮提问,验证 agent 真能召回 ──
//
// 不再提供图片,只问"还记得吗"。能答出三色说明记忆链路端到端可用。
// L2 文档此刻已被 archiveColdDocs 删除(归档的语义就是搬完删源),
// 所以这一轮只能靠 L3 图库召回——而自动注入路径依赖 indexer。
// 生产由 main.go 注入并周期 Sync测试里手工建一个并同步一次。
a.indexer = memory.NewIndexer(env.graph)
if err := a.indexer.Sync(); err != nil {
t.Fatalf("indexer sync: %v", err)
}
if mc := a.buildMemoryContext("测试图片", 0); mc != "" {
t.Logf("注入的记忆上下文: %s", truncRunes(mc, 200))
} else {
t.Log("图库召回为空(本测试不再依赖文本描述,仅记录现状)")
}
ask := &agentIO.InputEvent{
RequestID: "live-2",
Source: "test_channel",
Type: "text",
OutputChannel: "test_channel",
Payload: map[string]interface{}{
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
},
}
respCh := make(chan *agentIO.OutputEvent, 4)
ask.ResponseCh = respCh
t2 := time.Now()
a.handleInput(ask)
t.Logf("第二轮耗时 %.1fs", time.Since(t2).Seconds())
// 第二轮仍走真实 LLM这里只验证链路不报错、有回复。
// 不再断言"答出紫/蓝/红":图片的颜色信息只在原生向量里,
// 未配置多模态空间时模型本来就无从得知——那不属于记忆接线缺陷。
var answer string
select {
case out := <-respCh:
answer, _ = out.Payload["content"].(string)
case <-time.After(5 * time.Second):
t.Fatal("第二轮没有收到回复")
}
t.Logf("agent 回答: %s", truncRunes(answer, 220))
if strings.HasPrefix(answer, "处理错误:") {
t.Skipf("上游 LLM 调用失败,端到端召回无法判定: %s", truncRunes(answer, 160))
}
t.Logf("✓ 阶段7 E2E 链路贯通(召回能力取决于是否配置多模态向量空间)")
st = env.mediaSt.Stats()
t.Logf("收尾: %v 条 / %v 字节 / 类型 %v",
st["count"], st["total_bytes"], st["by_kind"])
}
// TestMediaLive_NegativeControl 阴性对照:没有媒体记忆时不该"记得"。
//
// 没有这条对照,任何"答出了具体内容"的结果都可能只是模型先验,
// 无法区分真召回与猜测。
func TestMediaLive_NegativeControl(t *testing.T) {
c := requireLiveCfg(t)
env := newLiveEnv(t, c)
a := env.agent
defer a.Stop()
ask := &agentIO.InputEvent{
RequestID: "neg-1",
Source: "test_channel",
Type: "text",
OutputChannel: "test_channel",
Payload: map[string]interface{}{
"content": "你还记得我之前发给你的那张图片吗?它是什么样子的?请说出具体颜色。",
},
}
respCh := make(chan *agentIO.OutputEvent, 4)
ask.ResponseCh = respCh
a.handleInput(ask)
var answer string
select {
case out := <-respCh:
answer, _ = out.Payload["content"].(string)
case <-time.After(5 * time.Second):
t.Fatal("阴性对照没有收到回复")
}
t.Logf("无记忆时的回答: %s", truncRunes(answer, 200))
// 上游不可用时这条对照没有意义:它只能证明"没答出颜色"
// 而原因是调用失败而非缺少记忆。据此判 PASS 属于假阳性。
if strings.HasPrefix(answer, "处理错误:") {
t.Skipf("上游 LLM 调用失败,阴性对照无法判定: %s", truncRunes(answer, 160))
}
guessed := strings.Contains(answer, "紫") &&
strings.Contains(answer, "蓝") &&
strings.Contains(answer, "红")
if guessed {
t.Errorf("无任何媒体记忆却猜中紫/蓝/红——"+
"说明阳性用例的通过可能只是先验偏好而非真召回: %s", truncRunes(answer, 300))
}
}
// mediaB64 返回不带 data URL 前缀的 base64processMediaInput 自己拼前缀)。
func mediaB64(b []byte) string {
return media.DataURL("image/png", b)[len("data:image/png;base64,"):]
}
func truncRunes(s string, n int) string {
r := []rune(strings.ReplaceAll(s, "\n", " "))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}

View File

@ -0,0 +1,199 @@
package core
import (
"errors"
"log"
"sync"
"sync/atomic"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 媒体与记忆块的生命周期辅助。
//
// 媒体不单独做生命周期管理(没有 GC、没有引用计数blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统本身决定,块被永久删除时内容随之删除。
// 图片不靠文本描述索引——它只按自己的统一空间向量被检索。
// heldMediaDigests 汇总三层记忆当前持有的媒体 digest 集合。
//
// CAS 是全库字节存储,它的检索结果不等于「记忆里的媒体」——
// 召回前用它把已无处可归的内容过滤掉。
func (a *Agent) heldMediaDigests() map[string]bool {
held := map[string]bool{}
collect := func(blocks []memory.MemoryBlock) {
for _, b := range blocks {
if b.PayloadDigest != "" {
held[b.PayloadDigest] = true
}
}
}
if a.context != nil {
collect(a.context.Blocks())
}
if a.docStore != nil {
collect(a.docStore.Blocks())
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
collect(blocks)
}
}
return held
}
// payloadHeld 报告某个 digest 是否仍被三层记忆中的一等块持有。
// 这是删除前的一次活查询(不是持久化账本):同一份字节可能同时被多个块共享。
func (a *Agent) payloadHeld(digest string) bool {
if digest == "" {
return false
}
if a.context != nil {
for _, b := range a.context.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.docStore != nil {
for _, b := range a.docStore.Blocks() {
if b.PayloadDigest == digest {
return true
}
}
}
if a.memory != nil {
if blocks, err := a.memory.MemoryBlocks(); err == nil {
for _, b := range blocks {
if b.PayloadDigest == digest {
return true
}
}
}
}
return false
}
// forgetPayloads 在记忆块被永久删除后删除它们的内容。
//
// 与文本块一致:删除块即删除内容。只有确认没有任何存活块仍共享该 digest
// 时才删字节(同一张图可能被多个块引用)。
func (a *Agent) forgetPayloads(digests []string) {
if a.mediaStore == nil {
return
}
for _, d := range digests {
if d == "" || a.payloadHeld(d) {
continue
}
if err := a.mediaStore.Delete(d); err != nil {
log.Printf("[media] 删除内容失败 %s: %v", shortDigest(d), err)
}
}
}
// reembedStaleMedia 在启动时批量迁移历史媒体向量到当前向量空间。
//
// 触发场景(任一变化都会导致旧向量无法参与查询):
// - 切换模型(模型 A→模型 Bfp 变了)
// - 切换向量维度ONNX→HTTP dim 512→1024
// - 首次部署嵌入服务(历史无向量的媒体补算)
// - 嵌入服务离线后重新上线(失败条目 vec_model 仍为空)
//
// 并发策略:启动时用 worker pool 并行迁移,避免上千张图片串行耗时过长。
// 并发数在 ONNX 内嵌路径下不超 CPU 核心数(避免 ONNX 并发限流),
// 外部 API 路径下不超 8避免打爆外部服务
func (a *Agent) reembedStaleMedia() {
if a.multimodalSpace == nil || a.mediaStore == nil {
return
}
fp := a.multimodalSpace.Fingerprint()
digests, err := a.mediaStore.StaleVecDigestsAll(fp)
if err != nil {
log.Printf("[media] 查询需重算向量的媒体失败: %v", err)
return
}
if len(digests) == 0 {
log.Printf("[media] 无需迁移向量(所有媒体已与当前空间对齐 fp=%s", shortFP(fp))
return
}
// 并发度ONNX 内嵌不超过 4外部 API 不超过 8由配置或实际环境动态定
workers := 4
if fp[:min(4, len(fp))] == "http:" {
workers = 8
}
log.Printf("[media] 启动向量迁移: %d 条 → 新空间 fp=%s dim=%d workers=%d",
len(digests), shortFP(fp), a.multimodalSpace.Dim(), workers)
jobs := make(chan string, workers*2)
var done, failed, unsupported int64
var failedMu sync.Mutex
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for d := range jobs {
switch err := a.reembedOne(d, fp); {
case err == nil:
atomic.AddInt64(&done, 1)
case errors.Is(err, vector.ErrModalityUnsupported):
// 该模态不在本空间内(如音频):不重试、不计失败,
// 也不拿另一个模型的向量顶替。
atomic.AddInt64(&unsupported, 1)
default:
failedMu.Lock()
failed++
failedMu.Unlock()
}
}
}()
}
for i, d := range digests {
jobs <- d
// 每迁移 20 条输出进度日志,让用户看到迁移在推进
if (i+1)%20 == 0 {
log.Printf("[media] 向量迁移进度: %d/%d (done=%d failed=%d)", i+1, len(digests), atomic.LoadInt64(&done), failed)
}
}
close(jobs)
wg.Wait()
log.Printf("[media] 向量迁移完成: 成功=%d 失败=%d 不在本空间=%d 总计=%d fp=%s",
done, failed, unsupported, len(digests), shortFP(fp))
}
// reembedOne 为单条媒体重新计算向量并写入stat/get 失败时跳过该条目)。
//
// 模态不在本空间覆盖范围时返回 ErrModalityUnsupported调用方据此区分
// 「永久无向量」与「本次失败重试」。
func (a *Agent) reembedOne(digest, fp string) error {
it, err := a.mediaStore.Stat(digest)
if err != nil {
return err
}
data, err := a.mediaStore.Get(digest)
if err != nil {
return err
}
mime := it.MIME
if mime == "" {
mime = "image/png"
}
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
if err != nil {
return err
}
return a.mediaStore.SetVec(digest, vec, fp)
}
// shortFP 截断 fingerprint 为可读日志格式。
func shortFP(fp string) string {
if len(fp) > 12 {
return fp[:12]
}
return fp
}

View File

@ -0,0 +1,187 @@
package core
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 媒体与记忆块的生命周期测试。
//
// 媒体没有独立生命周期管理(没有 GC、没有引用计数blob 是记忆块的内容,
// 块的创建/迁移/删除由记忆系统决定。图片也不靠文本描述索引。
func newMediaLoopAgent(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
a := &Agent{mediaStore: ms}
a.ctx, a.cancel = context.WithCancel(context.Background())
t.Cleanup(a.cancel)
return a, ms
}
// heldMediaDigests 汇总三层记忆持有的媒体:只有这些才可被召回。
func TestHeldMediaDigests_CollectsAcrossLayers(t *testing.T) {
a, ms := newMediaLoopAgent(t)
d1, _ := ms.Put([]byte("ctx-layer"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("doc-layer"), media.Item{MIME: "image/png"})
d3, _ := ms.Put([]byte("graph-layer"), media.Item{MIME: "image/png"})
d4, _ := ms.Put([]byte("orphan"), media.Item{MIME: "image/png"})
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "带图的一轮", Blocks: []memory.MemoryBlock{
{ID: "blk_ctx", Modality: memory.BlockImage, PayloadDigest: d1},
}})
dir := t.TempDir()
bo, ok := a.blockFromDigest(d2)
if !ok {
t.Fatal("blockFromDigest 失败")
}
ds := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := ds.Start(); err != nil {
t.Fatal(err)
}
defer ds.Stop()
if err := ds.Insert(&document.Doc{ID: "doc_1", Summary: "s", Blocks: []memory.MemoryBlock{bo}}); err != nil {
t.Fatal(err)
}
a.docStore = ds
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
{ID: "blk_g", Modality: memory.BlockImage, PayloadDigest: d3},
}); err != nil {
t.Fatal(err)
}
a.memory = g
held := a.heldMediaDigests()
for _, want := range []string{d1, d2, d3} {
if !held[want] {
t.Errorf("层次持有 %s 却不在结果里: %v", shortDigest(want), held)
}
}
if held[d4] {
t.Errorf("无人持有的 %s 不该出现在结果里", shortDigest(d4))
}
}
// fakeSpace 是一个只覆盖图像的假统一空间,用来验证「不在本空间」与
// 「本次失败」必须被区分对待。
type fakeSpace struct{}
func (fakeSpace) VectorizeDense(string) ([]float64, error) { return []float64{1, 0}, nil }
func (fakeSpace) EmbedImageDense(_ []byte, mime string) ([]float64, error) {
if strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/") {
return nil, fmt.Errorf("%w: %s", vector.ErrModalityUnsupported, mime)
}
return []float64{1, 0}, nil
}
func (fakeSpace) Fingerprint() string { return "fake-space" }
func (fakeSpace) Dim() int { return 2 }
func (fakeSpace) Loaded() bool { return true }
func (fakeSpace) Close() {}
// TestReembedStaleMedia_SkipsUnsupportedWithoutFaking 验证向量迁移不会:
// - 把音频当失败反复重试;
// - 更不能拿另一个模型的向量顶替音频(那会污染统一空间且静默)。
func TestReembedStaleMedia_SkipsUnsupportedWithoutFaking(t *testing.T) {
a, ms := newMediaLoopAgent(t)
img, _ := ms.Put([]byte("img-bytes"), media.Item{MIME: "image/png"})
aud, _ := ms.Put([]byte("aud-bytes"), media.Item{MIME: "audio/wav"})
a.multimodalSpace = fakeSpace{}
a.reembedStaleMedia()
it, err := ms.Stat(img)
if err != nil {
t.Fatal(err)
}
if len(it.Vec) != 2 || it.VecModel != "fake-space" {
t.Fatalf("图像应拿到本空间向量,实际 vec=%v model=%q", it.Vec, it.VecModel)
}
audIt, err := ms.Stat(aud)
if err != nil {
t.Fatal(err)
}
if len(audIt.Vec) != 0 || audIt.VecModel != "" {
t.Fatalf("音频不得被写入任何向量(不能用别的模型顶替),实际 vec=%v model=%q",
audIt.Vec, audIt.VecModel)
}
}
// payloadHeld 是删除前的活查询。
func TestPayloadHeld(t *testing.T) {
a, ms := newMediaLoopAgent(t)
d, _ := ms.Put([]byte("held"), media.Item{MIME: "image/png"})
if a.payloadHeld(d) {
t.Fatal("尚无块持有时不该报已持有")
}
a.context = NewRelevanceContext("", memory.NewStaticEmbedder(""))
a.context.Append(ContextEvent{Input: "x", Blocks: []memory.MemoryBlock{
{ID: "blk_1", Modality: memory.BlockImage, PayloadDigest: d},
}})
if !a.payloadHeld(d) {
t.Fatal("L0 持有却报未持有")
}
if a.payloadHeld("") {
t.Fatal("空 digest 应为 false")
}
}
// TestForgetPayloads_DeletesOnlyUnheldContent 验证删除语义:
// 块被删除后内容才被删;仍被其它记忆块共享的 digest 不会被误删。
func TestForgetPayloads_DeletesOnlyUnheldContent(t *testing.T) {
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
d1, _ := ms.Put([]byte("held-by-graph"), media.Item{MIME: "image/png"})
d2, _ := ms.Put([]byte("being-forgotten"), media.Item{MIME: "image/png"})
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
if err := g.PutMemoryBlocks([]memory.MemoryBlock{
{ID: "blk_keep", Modality: memory.BlockImage, PayloadDigest: d1},
}); err != nil {
t.Fatal(err)
}
a := &Agent{mediaStore: ms, memory: g}
a.forgetPayloads([]string{d1, d2})
if _, err := ms.Stat(d1); err != nil {
t.Fatalf("仍被 L3 块持有的内容不该被删: %v", err)
}
if _, err := ms.Stat(d2); err == nil {
t.Fatal("无人持有的内容应被删除")
}
}

View File

@ -0,0 +1,210 @@
package core
import (
"fmt"
"log"
"strings"
"sync/atomic"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// blockSeq 保证块 ID 全局唯一Graph memory_blocks 以 id 为主键)。
var blockSeq int64
func newBlockID() string {
return fmt.Sprintf("blk_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&blockSeq, 1))
}
// blockModalityOf 把 CAS 媒体大类映射为一等记忆块模态。
func blockModalityOf(k media.Kind) memory.BlockModality {
switch k {
case media.KindImage:
return memory.BlockImage
case media.KindVideo:
return memory.BlockVideo
case media.KindAudio:
return memory.BlockAudio
default:
return memory.BlockText
}
}
// blockFromDigest 把一份已入库媒体变成一等记忆块。
// 块携带 digest/向量/fingerprintCAS 只提供字节与元数据,不参与生命周期。
func (a *Agent) blockFromDigest(digest string) (memory.MemoryBlock, bool) {
if a.mediaStore == nil || digest == "" {
return memory.MemoryBlock{}, false
}
it, err := a.mediaStore.Stat(digest)
if err != nil || it == nil {
return memory.MemoryBlock{}, false
}
return memory.MemoryBlock{
ID: newBlockID(),
Modality: blockModalityOf(it.Kind),
PayloadDigest: it.Digest,
MIME: it.MIME,
Size: it.Size,
Width: it.Width,
Height: it.Height,
Vector: it.Vec,
Fingerprint: it.VecModel,
Tool: it.Tool,
CreatedAt: it.FirstSeen,
}, true
}
// 媒体记忆接线:把对话里出现的图片/音频落进内容寻址存储CAS
// 并让 L0 的 ContextEvent 直接持有一等记忆块。
//
// 媒体进入对话有两条路用户直接发图ContentBlock data URL、插件注入
// SetToolBlocks。两条都在这里收口从 data URL 取出字节存进 CAS
// 用其向量构造一等记忆块挂到当轮 ContextEvent 上;事件被 Prune 时
// 块随之迁移到 L2 文档。
//
// 不再生成任何描述文本,也不再往正文写 media marker图片只按自己的
// 统一空间向量被检索,描述式索引是将就方案。
// captureBlockMedia 把 blocks 里的 data URL 媒体落进 CAS返回 digest 列表。
//
// 只处理 data URLhttp(s) URL 拿不到字节就无法做内容寻址,
// 而"下载它再存"会把一次对话变成一次网络请求超时、鉴权、SSRF 全来了),
// 不在本层解决。
func (a *Agent) captureBlockMedia(blocks []agentAPI.ContentBlock, tool string) []string {
if a.mediaStore == nil || len(blocks) == 0 {
return nil
}
var digests []string
for _, b := range blocks {
var url string
switch {
case b.ImageURL != nil && b.ImageURL.URL != "":
url = b.ImageURL.URL
case b.AudioURL != nil && b.AudioURL.URL != "":
url = b.AudioURL.URL
default:
continue
}
mime, data, ok := media.ParseDataURL(url)
if !ok {
continue // http(s) URL 或格式不认,跳过
}
d, err := a.mediaStore.Put(data, media.Item{
MIME: mime,
Tool: tool,
})
if err != nil {
// 媒体存不进去不该让对话失败——它是记忆增强,不是对话必需品
log.Printf("[media] 落盘失败 (tool=%s mime=%s): %v", tool, mime, err)
continue
}
// 入库即算一次多模态坐标并缓存(多模态空间可用时)。
// 之后 doc_query / memory_recall / 内部召回直接复用 SetVec 的缓存坐标,
// 不重复跑 ONNX模型切换由启动时的 reembedStaleMedia 补算。
a.embedMediaOnIngest(d, mime, data)
digests = append(digests, d)
}
return digests
}
// embedMediaOnIngest 给刚入库的图片立即计算多模态坐标并缓存。
// 只在 多模态空间可用且为图像时执行;音频/未配置时静默跳过(保持既有行为)。
func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
if a.multimodalSpace == nil || !a.multimodalSpace.Loaded() {
return
}
if !strings.HasPrefix(mime, "image/") {
return
}
vec, err := a.multimodalSpace.EmbedImageDense(data, mime)
if err != nil {
log.Printf("[media] 入库嵌入失败 %s: %v", shortDigest(digest), err)
return
}
if err := a.mediaStore.SetVec(digest, vec, a.multimodalSpace.Fingerprint()); err != nil {
log.Printf("[media] 入库写向量失败 %s: %v", shortDigest(digest), err)
}
}
// stageMediaDigests 累积本轮捕获的 digest等 ContextEvent 建好后一起挂上。
//
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
func (a *Agent) stageMediaDigests(digests ...string) {
if len(digests) == 0 {
return
}
a.pendingMediaDigests = append(a.pendingMediaDigests, digests...)
}
// drainMediaDigests 取出并清空本轮累积的 digest。
func (a *Agent) drainMediaDigests() []string {
if len(a.pendingMediaDigests) == 0 {
return nil
}
out := a.pendingMediaDigests
a.pendingMediaDigests = nil
return out
}
// bindEventMedia 把本轮捕获的媒体变成一等记忆块,直接挂到 ContextEvent 上。
//
// 块存储在事件自身(随 context.json 持久化),不再写 media_refs
// 存活与否由“三层记忆块是否持有这个 digest”决定不维护引用账本。
func (a *Agent) bindEventMedia(evt *ContextEvent, digests []string) {
if a.mediaStore == nil || evt == nil || len(digests) == 0 {
return
}
if evt.ID == "" {
evt.ID = newEventID()
}
for _, d := range digests {
b, ok := a.blockFromDigest(d)
if !ok {
log.Printf("[media] 块构造失败 (%s)", shortDigest(d))
continue
}
evt.Blocks = append(evt.Blocks, b)
}
}
// mediaLabel 渲染一行媒体标签,供提示词告知"这条记忆带着哪份媒体"。
//
// 不再包含任何生成的描述文本:图片只按自己的向量被检索,标签仅提供
// MIME 与短 digest让模型知道有这份媒体、可据 digest 取回字节。
// 查不到返回空串:内容可能已被删除,不该造出一条指向虚无的标签。
func mediaLabel(it *media.Item) string {
if it == nil {
return ""
}
label := string(it.Kind)
if it.MIME != "" {
label = it.MIME
}
return fmt.Sprintf("[%s %s]", label, shortDigest(it.Digest))
}
// newEventID 生成 ContextEvent 的稳定标识。
//
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
// schedulerLoop 单 goroutine 串行 Append不存在同纳秒两条。
func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}
func shortDigest(d string) string {
if len(d) > 12 {
return d[:12]
}
return d
}

View File

@ -0,0 +1,333 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/document"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/media"
)
// 媒体记忆接线测试:验证媒体从对话进入 CAS、挂到 L0 事件、
// 随归档转到 L2 文档的完整链路。
//
// 核心断言不是"函数被调用了",而是不变量:
// 1. 媒体存不进去时对话照常(它是记忆增强,不是对话必需品)
// 2. 引用转移期间内容始终可读(先挂后销,不留归零窗口)
// 3. mediaStore 为 nil 时全链路静默跳过,行为与本特性上线前一致
func newTestAgentWithMedia(t *testing.T) (*Agent, *media.Store) {
t.Helper()
dir := t.TempDir()
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatalf("media.New: %v", err)
}
t.Cleanup(func() { ms.Close() })
emb := memory.NewStaticEmbedder()
a := &Agent{
mediaStore: ms,
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
}
return a, ms
}
// imageBlockURL 造一个带指定 URL 的图片块。
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
}
}
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
blocks := []agentAPI.ContentBlock{
{Type: "text", Text: "看这张图"},
imageBlockURL(media.DataURL("image/png", raw)),
}
digests := a.captureBlockMedia(blocks, "multimodal_see_picture")
if len(digests) != 1 {
t.Fatalf("应捕获 1 个媒体,实际 %d", len(digests))
}
got, err := ms.Get(digests[0])
if err != nil {
t.Fatalf("回读失败: %v", err)
}
if string(got) != string(raw) {
t.Fatal("内容不一致")
}
it, _ := ms.Stat(digests[0])
if it.MIME != "image/png" || it.Tool != "multimodal_see_picture" || it.Kind != media.KindImage {
t.Fatalf("元数据不对: %+v", it)
}
}
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
// http(s) URL 拿不到字节就无法内容寻址;"下载它再存"会把一次对话
// 变成一次网络请求超时、鉴权、SSRF 全来了),不在本层解决。
a, _ := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL("https://example.com/x.png"),
}
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
}
}
func TestCaptureBlockMedia_NilStoreIsNoop(t *testing.T) {
// mediaStore 未启用时全链路静默跳过,不能 panic 也不能报错——
// 行为必须与本特性上线前完全一致。
a := &Agent{}
blocks := []agentAPI.ContentBlock{imageBlockURL(media.DataURL("image/png", []byte("x")))}
if d := a.captureBlockMedia(blocks, "t"); d != nil {
t.Fatalf("nil store 应返回 nil实际 %v", d)
}
a.stageMediaDigests("deadbeef")
if got := a.drainMediaDigests(); len(got) != 1 {
t.Fatal("stage/drain 不依赖 store应正常工作")
}
// bindEventMedia 对 nil store 也必须安全
evt := &ContextEvent{}
a.bindEventMedia(evt, []string{"deadbeef"})
if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("nil store 时不该改动事件: %+v", evt)
}
if s := mediaLabel(nil); s != "" {
t.Fatalf("nil 媒体应产出空标签,得到 %q", s)
}
}
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL(media.DataURL("image/jpeg", []byte("frame"))),
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: media.DataURL("audio/wav", []byte("sound"))}},
}
digests := a.captureBlockMedia(blocks, "multimodal_see_video")
if len(digests) != 2 {
t.Fatalf("应捕获 2 个,实际 %d", len(digests))
}
kinds := map[media.Kind]int{}
for _, d := range digests {
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
kinds[it.Kind]++
}
if kinds[media.KindImage] != 1 || kinds[media.KindAudio] != 1 {
t.Fatalf("大类归属不对: %v", kinds)
}
}
func TestStageDrainMediaDigests(t *testing.T) {
a, _ := newTestAgentWithMedia(t)
a.stageMediaDigests("a", "b")
a.stageMediaDigests("c")
got := a.drainMediaDigests()
if len(got) != 3 {
t.Fatalf("应累积 3 个,实际 %d", len(got))
}
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
if again := a.drainMediaDigests(); again != nil {
t.Fatalf("drain 后应为空,实际 %v", again)
}
}
func TestBindEventMedia_CreatesBlocks(t *testing.T) {
a, ms := newTestAgentWithMedia(t)
d, err := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
if err != nil {
t.Fatal(err)
}
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
a.bindEventMedia(evt, []string{d})
if evt.ID == "" {
t.Fatal("应懒生成事件 ID")
}
if len(evt.Blocks) != 1 || evt.Blocks[0].PayloadDigest != d {
t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks)
}
if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" {
t.Fatalf("块元数据不对: %+v", evt.Blocks[0])
}
}
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
a, _ := newTestAgentWithMedia(t)
evt := &ContextEvent{Input: "纯文本"}
a.bindEventMedia(evt, nil)
if evt.ID != "" {
t.Fatalf("无媒体时不该生成 ID得到 %q", evt.ID)
}
}
func TestMediaLabel_NoGeneratedDescription(t *testing.T) {
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该 digest 取回字节」。
// 它不包含任何生成的描述:描述式索引是把就机制,已彻底废弃。
a, ms := newTestAgentWithMedia(t)
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
it, err := ms.Stat(d)
if err != nil {
t.Fatal(err)
}
s := mediaLabel(it)
if s == "" {
t.Fatal("应产出标签")
}
if !strings.Contains(s, "image/png") {
t.Fatalf("标签应含 MIME 标注: %q", s)
}
if !strings.Contains(s, shortDigest(d)) {
t.Fatalf("标签应含短 digest 供反查: %q", s)
}
_ = a
}
func TestPrune_NilMediaStoreStillArchives(t *testing.T) {
// 媒体存储未启用时归档链路必须照常工作
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
for i := 0; i < 15; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "内容",
})
}
if n := rc.Prune("查询", 5, docStore); n == 0 {
t.Fatal("无媒体存储时归档也应正常")
}
}
func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) {
// context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空,
// 不影响任何既有行为。
dir := t.TempDir()
path := filepath.Join(dir, "context.json")
// 写一份"存量格式"(无 id / blocks 字段)
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatal(err)
}
emb := memory.NewStaticEmbedder()
rc := NewRelevanceContext(path, emb)
if rc.Len() != 1 {
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
}
// 新写入带记忆块的事件,再读回
ms, err := media.New(filepath.Join(dir, "media"))
if err != nil {
t.Fatal(err)
}
defer ms.Close()
a := &Agent{mediaStore: ms, context: rc}
d, _ := ms.Put([]byte("img"), media.Item{MIME: "image/png"})
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
a.bindEventMedia(&evt, []string{d})
rc.Append(evt)
rc.flush()
rc2 := NewRelevanceContext(path, emb)
if rc2.Len() != 2 {
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
}
var persisted int
for _, e := range rc2.Recent(10) {
persisted += len(e.Blocks)
}
if persisted != 1 {
t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted)
}
}
func TestPruneMigratesBlocksToDocument(t *testing.T) {
// 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document
// 身份ID/模态/digest/向量)原样保留;同一块不能同时留在两层。
// 这条路径不依赖 media_refs/ref_count。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
block := memory.MemoryBlock{
ID: "blk_migrate_1", Modality: memory.BlockImage,
PayloadDigest: "deadbeef", MIME: "image/png", Size: 42,
Vector: []float64{0.1, 0.2, 0.3}, Fingerprint: "qwen:test",
}
rc.Append(ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq", Input: "很久以前的一张图",
Blocks: []memory.MemoryBlock{block},
})
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq", Input: "无关内容",
})
}
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
t.Fatal("应有事件被归档")
}
// 块应已到达 L2且身份不变。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
if len(d.Blocks) > 0 {
found = d
break
}
}
if found == nil {
t.Fatal("归档文档应持有一等记忆块")
}
if len(found.Blocks) != 1 {
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
}
got := found.Blocks[0]
if got.ID != block.ID || got.Modality != block.Modality || got.PayloadDigest != block.PayloadDigest || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
}
// 同一块不能同时留在 L0。
for _, e := range rc.Recent(100) {
if len(e.Blocks) > 0 {
t.Fatalf("块仍留在 L0同一块同时存在于两层: %+v", e.Blocks)
}
}
}

View File

@ -0,0 +1,56 @@
package core
// 图记忆的**共同面**(设计 docs/zh/resident-subagent-design.md §16.0)。
//
// 只有两个方法 —— 因为按调用方实测分类后,`Agent.memory` 的 42 处使用里,
// 真正"根与子都要"的只有:
//
// Recall 读(上下文检索 / memory_recall
// Commit 写(自动写入路径的图部分 / memory_commit
//
// 其余 40 处全是**主 agent 整理记忆**与**记忆整理流水线**
// - 记忆整理流水线distill.go 的 archive/review/merge 循环
// - 记忆块 + 媒体桥graphmedia.go / medialoop.go
// - 记忆整理工具memory_merge / memory_delete_entity / memory_block_merge /
// memory_purge / memory_edit / memory_introspect
//
// 所以驻留子的轻量内核**不该有那些代码路径**:它用 `*memory.LightMemory` 接上 `graph`
// 而 `a.memory`(整理面)保持 nil —— 既有的 22 处 `if a.memory != nil` 关卡
// 会自动把整理面全部禁掉,不需要写"每个方法都返回错误"的受限包装。
import "gitcode.com/JianFeeeee/HomeAgent/internal/memory"
// isLightKernel 报告本 agent 是不是**轻量内核**(驻留子)。
//
// 轻量内核 = 传统上下文 + 图记忆(作用域化):它**没有**动态上下文能力
// (预算裁剪 / 按相关度裁剪并向 doc 记忆归档),那些是父 agent 专属的。
func (a *Agent) isLightKernel() bool { return a != nil && a.parentID != "" }
// contextTokenBudget 返回拼装时间线时可用的 token 预算。
//
// 完整内核:用动态上下文算出来的 ContextTokens按相关度/预算**策略性**裁时间线)
// 轻量内核:**用整个窗口** —— 传统上下文只受"模型能收多少"这个**硬上限**约束,
// 不做任何策略性裁剪(不按相关度挑、不向 doc 记忆归档);
// 而且在撞到硬上限之前contextfull90% 窗口)已按 L4 上报父 agent
// 决策(压缩/回收/销毁)—— 丢事件的决定权在父,不在内核。
func (a *Agent) contextTokenBudget(b TokenBudget) int {
if a.isLightKernel() {
if b.MaxContext > 0 {
return b.MaxContext
}
return defaultMaxContextTokens
}
return b.ContextTokens
}
// GraphMemory 是任意 agent 都能用的图记忆面。
//
// 实现者:
// - 根 agent直接就是 *memory.GraphDB
// - 驻留子:*memory.LightMemory读 tempmain只写 temp
type GraphMemory interface {
// Recall 按关键词/种子实体召回(子的实现是两空间并集)。
Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
// Commit 写入三元组(子的实现只落自己的 temp 空间)。
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
}

View File

@ -4,8 +4,8 @@ import (
"fmt"
"strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
@ -16,6 +16,11 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if channel == "" || payload == "" || rawType == "" {
return "工具名称格式: output_send__{channel}payload 和 type 不能为空"
}
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
if !a.IsOutputAllowed(channel) {
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
}
meta, _ := tc.Arguments["meta"].(string)
caps := a.io.GetChannelCapabilities(channel)
@ -78,7 +83,10 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("[%s] 通道发送结果未确认:%s", channel, note)
}
}
return fmt.Sprintf("已通过 [%s] 通道发送: %v", channel, result)
// 成功回执:只返回极简标记,不回传完整插件响应。
// 「已通过 [qq] 通道发送: map[status:sent message_id:xxx]」这类富回执
// 会驱动模型继续调用 output_send回声效应是 output loop 的根源之一。
return "ok"
}
a.io.EmitTextTo("agent_io", channel, payload)
@ -143,7 +151,14 @@ func (a *Agent) executeOutputListChannels() string {
if ch.OutputCaps == 0 {
continue
}
parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
if !a.IsOutputAllowed(ch.Name) {
continue
}
line := fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description)
if t, ok := a.ResolveOutputTarget(ch.Name); ok {
line += fmt.Sprintf("(目标: %s / inputch %s", orDash(t.AgentID), orDash(t.InputCh))
}
parts = append(parts, line)
for _, t := range ch.Tools {
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
}

View File

@ -0,0 +1,138 @@
package core
// N1b输出通道授权集合 + 输出通道 → 目标 agent 的 inputch 解析。
//
// 设计依据 docs/zh/resident-subagent-design.md §4.4(通道分配:不对称)与 R2
// (插件与工具由父授权,**默认完整授权**)。
//
// 三个过滤点必须一致,否则会出现"列表里看不到、但按名字还能调"的裂缝:
// ① 工具表(不为未授权的通道生成 output_send__X
// ② 列表工具output_list_channels 只列授权的)
// ③ 调用点(凭名字直调也必须被拒 —— 纵深防御)
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// registerFakeOutput 注册一个假的输出通道device 通道)。
func registerFakeOutput(t *testing.T, a *Agent, name string) {
t.Helper()
if err := a.io.RegisterDevice(&mockOutputDevice{name: name, caps: agentIO.CapText}); err != nil {
t.Fatalf("注册测试通道 %s 失败: %v", name, err)
}
}
func toolNames(a *Agent) []string {
var names []string
for _, t := range a.buildToolDefs() {
m, ok := t.(map[string]interface{})
if !ok {
continue
}
fn, _ := m["function"].(map[string]interface{})
if n, _ := fn["name"].(string); n != "" {
names = append(names, n)
}
}
return names
}
func hasTool(names []string, want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
// 默认(未配置白名单)= 完整授权:所有输出通道都能用。
func TestOutputGrant_DefaultIsFull(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
registerFakeOutput(t, a, "qq")
registerFakeOutput(t, a, "webui")
if !a.IsOutputAllowed("qq") || !a.IsOutputAllowed("webui") {
t.Fatal("默认应为完整授权")
}
names := toolNames(a)
if !hasTool(names, "output_send__qq") || !hasTool(names, "output_send__webui") {
t.Fatalf("默认完整授权下应生成全部输出门,实际 %v", names)
}
if out := a.executeOutputListChannels(); !strings.Contains(out, "qq") || !strings.Contains(out, "webui") {
t.Fatalf("默认完整授权下列表应含全部通道:\n%s", out)
}
}
// 白名单收窄:三个过滤点必须一致。
func TestOutputGrant_NarrowedWhitelist(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.allowedOutputs = []string{"webui"} // 模拟父创建子时收窄
registerFakeOutput(t, a, "qq")
registerFakeOutput(t, a, "webui")
if a.IsOutputAllowed("qq") {
t.Fatal("白名单外的通道不应被授权")
}
if !a.IsOutputAllowed("webui") {
t.Fatal("白名单内的通道应被授权")
}
// ① 工具表
names := toolNames(a)
if hasTool(names, "output_send__qq") {
t.Fatalf("未授权的通道不该生成输出门工具:%v", names)
}
if !hasTool(names, "output_send__webui") {
t.Fatalf("已授权的通道应生成输出门工具:%v", names)
}
// ② 列表工具
out := a.executeOutputListChannels()
if strings.Contains(out, "qq") {
t.Fatalf("列表不应含未授权通道:\n%s", out)
}
if !strings.Contains(out, "webui") {
t.Fatalf("列表应含已授权通道:\n%s", out)
}
// ③ 调用点(凭名字直调)
got := a.executeOutputSendTool(agentAPI.ToolCall{
ID: "c1", Name: "output_send__qq",
Arguments: map[string]interface{}{"payload": "hi", "type": "text"},
})
if !strings.Contains(got, "未授权") {
t.Fatalf("未授权的输出门必须被拒,实际 %q", got)
}
}
// 输出通道 → 目标 agent 的 inputch 的解析("输出可寻址到具体 agent")。
func TestOutputTarget_Resolution(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
reg := a.io.ChannelRegistry()
if err := reg.BindOutputTarget("to-child-1", "child-1", "sub/in"); err != nil {
t.Fatal(err)
}
tgt, ok := a.ResolveOutputTarget("to-child-1")
if !ok || tgt.AgentID != "child-1" || tgt.InputCh != "sub/in" {
t.Fatalf("解析结果=%+v ok=%v", tgt, ok)
}
// 未登记的输出通道由传输层处理(如 qq/webui 这类 device 通道)。
if _, ok := a.ResolveOutputTarget("qq"); ok {
t.Fatal("未登记目标解析的输出通道不应解析出 agent")
}
if err := reg.BindOutputTarget("", "x", "y"); err == nil {
t.Fatal("空输出通道名应报错")
}
// 列表工具在已登记时带出目标,便于模型知道"这条通道发给谁"。
registerFakeOutput(t, a, "to-child-1")
if out := a.executeOutputListChannels(); !strings.Contains(out, "child-1") {
t.Fatalf("已登记目标的输出通道应在列表里标出目标:\n%s", out)
}
}

View File

@ -0,0 +1,54 @@
package core
import (
"fmt"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// PersonaStore 是人格设定的读写面。
//
// 首启门禁buildSystemPrompt与 persona_set 工具都通过它工作,实现在 cmd/homed
// 读写配置项 core.agent.personal_prompt 与一次性标记 core.internal.persona_initialized
// (落库逻辑与 WebUI 向导共用 internal/config 的实现)。
//
// 为什么放在内核而不是某个通道插件:人格是**任何通道都要问一次**的事。
// 系统提示词每轮重建门禁放在这里WebUI / QQ / CLI / ACP / 邮件等全部通道自动覆盖。
type PersonaStore interface {
// PersonaInitialized 报告人格是否已确认(向导或工具已问过)。
PersonaInitialized() bool
// SetPersona 落库人格并打一次性标记,返回是否需要重启才生效。
SetPersona(mode, content string) (restartRequired bool, err error)
}
// executePersonaTool 落地首启人格设定。
//
// 成功即打一次性标记 → 之后 buildSystemPrompt 不再要求模型询问人格。
// custom 模式返回「需重启生效」:人格在 homed 启动时载入。
func (a *Agent) executePersonaTool(tc agentAPI.ToolCall) string {
if a.personaStore == nil {
return "人格设定不可用:内核未接入配置"
}
mode, _ := tc.Arguments["mode"].(string)
content, _ := tc.Arguments["content"].(string)
mode = strings.TrimSpace(mode)
restart, err := a.personaStore.SetPersona(mode, content)
if err != nil {
return fmt.Sprintf("人格设定失败:%v", err)
}
switch mode {
case "custom":
msg := "已保存自定义人格"
if restart {
msg += "**重启 homed 后生效**(人格在启动时载入)"
}
return msg
case "default":
return "已确认使用默认人格"
case "later":
return "已记为「以后再说」,继续使用默认人格"
default:
return "已保存人格设定"
}
}

View File

@ -0,0 +1,112 @@
package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// newTestAgent 造一个最小可用的 AgentbuildToolDefs 要求 io 非 nil
func newTestAgent(st PersonaStore) *Agent {
return &Agent{io: agentIO.NewIOManager(), personaStore: st}
}
// fakePersonaStore 记录调用并可控地报告「是否已确认」。
type fakePersonaStore struct {
initialized bool
mode string
content string
calls int
}
func (f *fakePersonaStore) PersonaInitialized() bool { return f.initialized }
func (f *fakePersonaStore) SetPersona(mode, content string) (bool, error) {
f.calls++
f.mode, f.content = mode, content
f.initialized = true
return mode == "custom", nil
}
// 首启门禁:人格未确认时,**任何通道**的系统提示词都必须带上「去问用户」的指令;
// 确认后必须消失(否则会每轮反复追问)。
func TestPersonaOnboardingGateInSystemPrompt(t *testing.T) {
st := &fakePersonaStore{}
a := newTestAgent(st)
p := a.buildSystemPrompt("", "你好")
if !strings.Contains(p, "首启人格设定") || !strings.Contains(p, "persona_set") {
t.Fatalf("未确认人格时提示词应要求模型询问并调用 persona_set实际缺少该段")
}
// 模型落地后(标记置位)不再出现
if out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
Arguments: map[string]interface{}{"mode": "default"}}); !strings.Contains(out, "默认人格") {
t.Fatalf("persona_set(default) 回执不对: %s", out)
}
if !st.initialized {
t.Fatal("落库后应置位标记")
}
if p2 := a.buildSystemPrompt("", "你好"); strings.Contains(p2, "首启人格设定") {
t.Fatal("人格已确认后不应再要求询问")
}
// 未接入配置personaStore 为 nil门禁与工具都必须静默关闭
b := newTestAgent(nil)
if pb := b.buildSystemPrompt("", "你好"); strings.Contains(pb, "首启人格设定") {
t.Fatal("未接入配置时不应出现首启门禁")
}
if out := b.executePersonaTool(agentAPI.ToolCall{Name: "persona_set"}); !strings.Contains(out, "不可用") {
t.Fatalf("未接入配置时工具应回明确错误,实际: %s", out)
}
}
// persona_set 的三选一语义与回执。
func TestPersonaSetToolModes(t *testing.T) {
cases := []struct {
mode, content, want string
}{
{"custom", "你是测试人格", "重启"},
{"default", "", "默认人格"},
{"later", "", "以后再说"},
}
for _, c := range cases {
st := &fakePersonaStore{}
a := newTestAgent(st)
out := a.executePersonaTool(agentAPI.ToolCall{Name: "persona_set",
Arguments: map[string]interface{}{"mode": c.mode, "content": c.content}})
if !strings.Contains(out, c.want) {
t.Errorf("mode=%s 回执应含 %q实际: %s", c.mode, c.want, out)
}
if st.calls != 1 || st.mode != c.mode || st.content != c.content {
t.Errorf("mode=%s 落库参数不对: calls=%d mode=%s content=%q", c.mode, st.calls, st.mode, st.content)
}
}
}
// 工具 schema 必须在 catalog 里出现(模型才可能调用)。
func TestPersonaSetToolDefPresent(t *testing.T) {
a := newTestAgent(&fakePersonaStore{})
found := false
for _, td := range a.buildToolDefs() {
if m, ok := td.(map[string]interface{}); ok {
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
found = true
}
}
}
if !found {
t.Fatal("buildToolDefs 未包含 persona_set")
}
// 未接入配置时不应暴露该工具
b := newTestAgent(nil)
for _, td := range b.buildToolDefs() {
if m, ok := td.(map[string]interface{}); ok {
if fn, ok := m["function"].(map[string]interface{}); ok && fn["name"] == "persona_set" {
t.Fatal("未接入配置时不应暴露 persona_set")
}
}
}
}

View File

@ -7,385 +7,94 @@ import (
"fmt"
"log"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
a.mu.Lock()
defer a.mu.Unlock()
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
//
// zen 兼容网关要求请求最后一条必须是 userthinking 续写模式校验),工具轮
// 产出 assistant/tool 结尾会被 400 拒绝;首轮 system 结尾不补,否则会覆盖
// 真实用户输入。
//
// 用独立常量 + 精确等值判定,是因为这条消息是**核心自己插入的**、不是用户输入,
// 所以可以安全地按内容识别并在补位前移除上一条,保证至多一条。
const continuationPlaceholder = "请根据以上工具结果继续。"
if a.provider == nil {
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
// replyDeliveredPlaceholder 是「本批工具调用全部是输出通道发送」之后补的占位。
//
// 为何不能继续用通用的「请继续」异步通道qq/wechat的回复**只能**经
// output_send__* 交付(纯文本不送达,见 buildSystemPrompt 的输出规则)。于是
// 模型「已经回复完了」的表达形式就是一个工具调用,而紧随其后的
// 「请根据以上工具结果继续。」会被读成「还要再做一步」——能做的「一步」恰好
// 还是再发一条消息。两者叠加成自我强化的发送循环:生产实测单轮 34 次
// output_send__qq、持续 514 秒,直到 QQ 插件自己的循环保险拒绝发送才停下。
//
// 所以这里换成一条明确的终止许可:已回复完就直接返回纯文本收尾。
const replyDeliveredPlaceholder = "若你的回复已完成,直接返回纯文本即可结束本轮,无需再调用任何工具。"
// continuationFor 选择工具轮之后补位的 user 占位文案。
// replyOnly 表示上一批工具调用全部是输出通道发送(即模型刚交付了回复)。
func continuationFor(replyOnly bool) string {
if replyOnly {
return replyDeliveredPlaceholder
}
return continuationPlaceholder
}
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
// isOutputDeliveryTool 判断工具是否是「向输出通道交付内容」。
// output_send__{channel}_help 只是查询用法,不算交付。
func isOutputDeliveryTool(name string) bool {
return strings.HasPrefix(name, "output_send__") && !strings.HasSuffix(name, "_help")
}
memContext := a.buildMemoryContext(input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, input)
tools := a.buildToolDefs()
// isContinuationPlaceholder 判断一条 user 消息是否是本机制插入的占位。
// 只按两个常量精确匹配,不碰任何真实用户消息。
func isContinuationPlaceholder(m agentAPI.Message) bool {
return m.Role == "user" &&
(m.Content == continuationPlaceholder || m.Content == replyDeliveredPlaceholder)
}
msgs := a.buildMessages(sysPrompt, input, budget.ContextTokens)
// 工具提醒interrupt以 system 角色注入,不让模型误认为用户发言
if a.interruptInput {
last := msgs[len(msgs)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
msgs[len(msgs)-1] = last
a.interruptInput = false
// toolOutputForQuery 返回用于相关性计算的工具输出**有效内容**。
//
// 为什么要过 Cleaner 而不是直接用原始 resultContextPolicy=prune 的入参是
// **相关性查询向量**——它决定保留/归档哪些上下文事件。原始工具输出里混着
// ANSI 转义、base64、JSON 包装等噪声,直接拿去向量化会让打分失真。
// 而 ToolDef.Cleaner 的契约本就写着“仅在向量化/jieba/蒸馏时调用”,裁剪正是
// 在向量化,所以这里必须过它(此前只在构建事件向量时用了,裁剪查询漏了)。
//
// Cleaner 未注册或 RPC 失败时回退原文(清洗是计算层优化,不能因此丢内容);
// 返回空串时也回退——空串会让查询向量退化成零向量,裁剪就失去判据。
func (a *Agent) toolOutputForQuery(toolName, raw string) string {
if a.stageHost == nil {
return raw
}
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
if len(msgs) > 0 {
msgs[len(msgs)-1].Blocks = blocks
}
cleaner := a.stageHost.ToolDefCleaner(toolName)
if cleaner == nil {
return raw
}
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
len(tools), a.context.Len(),
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
if a.runStage(sdk.StagePreAction, stageCtx) {
return *stageCtx.Response, toolsUsed, toolResults, nil
if cleaned := cleaner(raw); cleaned != "" {
return cleaned
}
if len(stageCtx.ContextMsgs) > 0 {
for _, m := range stageCtx.ContextMsgs {
role, _ := m["role"].(string)
content, _ := m["content"].(string)
if role != "" {
msgs = append(msgs, agentAPI.Message{Role: role, Content: content})
}
}
}
for turn := 0; ; turn++ {
for _, interrupt := range a.drainInterrupts() {
msgs = append(msgs, agentAPI.Message{
Role: "system",
Content: "[中断消息] " + interrupt,
})
}
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
// 注意:仅当尾部确为工具轮产物(assistant/tool)时才补位;首轮 system 上下文结尾不补,
// 否则会错误覆盖实际用户输入(如 injectSourceContext 追加的 system 说明)。
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{
Role: "user",
Content: "请根据以上工具结果继续。",
})
}
req := &agentAPI.CompletionRequest{
Messages: msgs,
MaxTokens: 4096,
Tools: tools,
ToolChoice: "auto",
DisableThinking: !a.thinkingEnabled,
}
var providers []agentAPI.Provider
if a.providerManager != nil {
// 精确模型名走 byModel 路由AUTO/空走优先级链
var allProviders []agentAPI.Provider
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
allProviders = a.providerManager.ResolveForModel(req.Model)
} else {
allProviders = a.providerManager.OrderedProviders()
}
providers = make([]agentAPI.Provider, 0, len(allProviders))
for _, p := range allProviders {
if a.providerManager.IsAvailable(p.Name()) {
providers = append(providers, p)
}
}
}
if len(providers) == 0 {
providers = []agentAPI.Provider{a.provider}
}
var resp *agentAPI.CompletionResponse
var llmErr error
for pi, fbProvider := range providers {
if pi > 0 {
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
fbProvider.Name(), pi, len(providers)-1)
}
// 同源瞬时错误重试网关瞬断502/503/504/429/网络抖动)通常秒级恢复,
// 直接跳下一个 provider或直接报错会丢掉本可成功的请求。
// 凭证错误401/403与用户中断不重试。
const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
fbProvider.Name(), attempt, maxAttempts, llmErr)
select {
case <-time.After(2 * time.Second):
case <-a.ctx.Done():
llmErr = a.ctx.Err()
}
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
break
}
}
fCtx, fCancel := context.WithCancel(a.ctx)
a.llmMu.Lock()
a.cancelLLM = fCancel
a.llmMu.Unlock()
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a)
a.llmMu.Lock()
a.cancelLLM = nil
a.llmMu.Unlock()
fCancel()
if llmErr == nil {
a.providerManager.ResetAvailability(fbProvider.Name())
if fbProvider != a.provider {
a.provider = fbProvider
log.Printf("[agent] switched active provider to %q after fallback",
fbProvider.Name())
}
break
}
// 用户中断:立即终止,不重试也不换 provider
if errors.Is(llmErr, context.Canceled) {
break
}
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
break
}
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
}
if llmErr == nil {
break
}
if errors.Is(llmErr, context.Canceled) {
break
}
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
} else {
a.providerManager.MarkUnavailable(fbProvider.Name())
}
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
}
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
if a.currentOutputChannel == "_consolidation_" {
return "", toolsUsed, toolResults, fmt.Errorf("interrupted by user input")
}
continue
}
return "", toolsUsed, toolResults, fmt.Errorf("all %d providers failed, last error: %w",
len(providers), llmErr)
}
stageCtx.LLMText = resp.Content
stageCtx.ReasoningContent = resp.ReasoningContent
stageCtx.TokenUsage = map[string]int{
"prompt_tokens": resp.TokenUsage.Prompt,
"completion_tokens": resp.TokenUsage.Completion,
"total_tokens": resp.TokenUsage.Total,
}
stageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
for i := range stageCtx.ToolCalls {
if stageCtx.ToolCalls[i].Plugin == "" {
stageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(stageCtx.ToolCalls[i].Name)
}
}
if a.runStage(sdk.StagePostAction, stageCtx) {
return *stageCtx.Response, toolsUsed, toolResults, nil
}
resp.Content = stageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(stageCtx.ToolCalls)
chainPayload := map[string]interface{}{
"content": resp.Content,
"reasoning": resp.ReasoningContent,
"tool_calls": resp.ToolCalls,
"phase": "intermediate",
"turn": turn,
}
if resp.TokenUsage.Total > 0 {
chainPayload["usage"] = map[string]int{
"prompt": resp.TokenUsage.Prompt,
"completion": resp.TokenUsage.Completion,
"total": resp.TokenUsage.Total,
}
}
a.publishEvent(events.EventAgentLLMChain, chainPayload)
if resp.ReasoningContent != "" {
a.publishEvent(events.EventReasoning, map[string]interface{}{
"content": resp.ReasoningContent,
"channel": a.currentOutputChannel,
})
}
if len(resp.ToolCalls) == 0 {
return resp.Content, toolsUsed, toolResults, nil
}
contentOnce := true
for _, tc := range resp.ToolCalls {
if len(a.interceptCh) > 0 {
for _, interrupt := range a.drainInterrupts() {
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": a.resolveToolPlugin(tc.Name),
"args": tc.Arguments,
"status": "interrupted",
"reason": "user interrupt before execution",
"channel": a.currentOutputChannel,
})
break
}
toolsUsed = append(toolsUsed, tc.Name)
pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
if tc.RawArguments != "" {
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
}
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
stageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
stageCtx.ToolResults = nil
if a.runStage(sdk.StageBeforeToolcall, stageCtx) {
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "denied",
"channel": a.currentOutputChannel,
})
continue
}
tc.Arguments = stageCtx.ToolCalls[0].Arguments
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
msgs = append(msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
continue
}
result := a.executeToolCall(tc)
toolResults = append(toolResults, ToolResultItem{Name: tc.Name, Output: result})
log.Printf("[agent] tool %s result: %s", tc.Name, truncateStr(result, 100))
stageCtx.ToolResults = []sdk.ToolResult{{CallID: tc.ID, Name: tc.Name, Plugin: pluginName, Success: true, Result: result}}
a.runStage(sdk.StageAfterToolcall, stageCtx)
if len(stageCtx.ToolResults) > 0 {
if r, ok := stageCtx.ToolResults[0].Result.(string); ok {
result = r
}
}
msgContent := ""
if contentOnce {
msgContent = resp.Content
contentOnce = false
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
//
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
// 这也是插件文案一直在说的「注入后续对话」。
// 为何不能挂 tool message同一张图、同一模型、三轮实测——
// 图在 user message → 3/3 读到
// 图在 tool message → 0/3模型答「没能读到这张图」
// tool 纯文本 + 后接 user → 3/3 读到
// tool message 那轮 prompt_tokens 反而更高7967 vs 7089base64 确实
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
//
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
// 返回 200模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
var mediaMsg *agentAPI.Message
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
if cb, ok := b.(pubsdk.ContentBlock); ok {
// 跨包类型拷贝pubsdk.ContentBlock → agentAPI.ContentBlock
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
if cb.ImageURL != nil {
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
}
if cb.AudioURL != nil {
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
}
blocks = append(blocks, block)
}
}
if len(blocks) > 0 {
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
mediaBlocks := append([]agentAPI.ContentBlock{{
Type: "text",
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
}}, native...)
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
} else if fallbackText != "" {
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content
// 不再另起消息(文字在 tool message 里本来就能被读到)。
toolMsg.Content = result + "\n\n" + fallbackText
result = toolMsg.Content
if len(toolResults) > 0 {
toolResults[len(toolResults)-1].Output = result
}
}
}
}
msgs = append(msgs, toolMsg)
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
msgs = append(msgs, *mediaMsg)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "ok",
"channel": a.currentOutputChannel,
})
if len(a.interceptCh) > 0 {
for _, interrupt := range a.drainInterrupts() {
msgs = append(msgs, agentAPI.Message{Role: "system", Content: "[中断消息] " + interrupt})
}
break
}
return raw
}
// dropContinuationPlaceholders 移除此前由本机制插入的 user 占位。
//
// 为什么必须移除而不仅仅是“不再追加”:`msgs` 在循环外创建、循环内只增不减,
// 占位是核心自己插的、不是用户说的话。不移除的话prompt 里就会线性叠上
// N 条一模一样的“继续”,把前缀上下文(含记忆注入)往后挤。
func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
out := msgs[:0]
for _, m := range msgs {
if isContinuationPlaceholder(m) {
continue
}
out = append(out, m)
}
return out
}
// chatStreamWithFallback 优先流式调用 provider失败时回退非流式 Chat()。
@ -400,14 +109,14 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
//
// 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发
// 空闲超时;总生成时长不再受限於 180s 整体超时。
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent) (*agentAPI.CompletionResponse, error) {
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
ch, err := p.ChatStream(ctx, req)
if err != nil {
log.Printf("[agent] stream connect failed (%v), falling back to non-stream chat", err)
return p.Chat(ctx, req)
}
resp, accErr := accumulateStream(ctx, ch, a)
resp, accErr := accumulateStream(ctx, ch, a, channel)
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
// 被 cancel 时丢弃已收内容返回 err让 process() 的 continue 分支
@ -418,7 +127,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
if a != nil {
a.publishEvent(events.EventContentDelta, map[string]interface{}{
"content": "",
"channel": a.currentOutputChannel,
"channel": channel,
"reset": true,
})
}
@ -448,7 +157,7 @@ type toolCallAcc struct {
// accumulateStream 消费 chunk channel累积为完整 CompletionResponse
// 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent) (*agentAPI.CompletionResponse, error) {
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
resp := &agentAPI.CompletionResponse{
ToolCalls: make([]agentAPI.ToolCall, 0),
}
@ -500,7 +209,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
if a != nil {
a.publishEvent(events.EventReasoningDelta, map[string]interface{}{
"content": ck.ReasoningContent,
"channel": a.currentOutputChannel,
"channel": channel,
})
}
}
@ -509,7 +218,7 @@ func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Ag
if a != nil {
a.publishEvent(events.EventContentDelta, map[string]interface{}{
"content": ck.Content,
"channel": a.currentOutputChannel,
"channel": channel,
})
}
}
@ -622,11 +331,13 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
include := 0
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
est := len(e.Source) + len(e.Input) + 40
// ❗单位必须与 EstimateTokens 一致rune×2。这里曾用 `len()`**字节**)再 ×2
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break
// 把更早的事件整段丢掉实测2384 字的中文事件被估成 14398 token > 8192
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
if e.Response != "" {
est += 120
estTokens += 120
}
estTokens := est * 2
if remaining-estTokens < 0 && include > 0 {
break
}

View File

@ -0,0 +1,32 @@
package core
import (
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func TestExpandPromptVars(t *testing.T) {
got := expandPromptVars("型号 {{kernel_version}}{{kernel_commit}}SDK {{sdk_version}}")
if !strings.Contains(got, meta.Version) || !strings.Contains(got, sdkmeta.Version) {
t.Fatalf("占位符未展开: %q", got)
}
if strings.Contains(got, "{{") {
t.Fatalf("仍有未展开的内置占位符: %q", got)
}
// 人格卡实测原文:写死了 v1.0.3,应能被占位符取代
live := expandPromptVars("你是 HomeAgent 的看板娘「小宅」(Xiao Zhai)HΔ-Kernel v{{kernel_version}} 型号的家政型 AI 管家助手。")
if strings.Contains(live, "1.0.3") || !strings.Contains(live, "v"+meta.Version) {
t.Fatalf("人格卡版本未跟随内核: %q", live)
}
// 未知占位符必须原样保留(写错要看得见,不能被静默吞掉)
if unk := expandPromptVars("版本 {{kernel_verison}}"); !strings.Contains(unk, "{{kernel_verison}}") {
t.Fatalf("未知占位符被吞: %q", unk)
}
// 无占位符时原样返回(人格卡热路径,不做无谓拷贝)
if plain := "无占位符"; expandPromptVars(plain) != plain {
t.Fatal("无占位符时不应改写")
}
}

View File

@ -0,0 +1,119 @@
package core
import (
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 这一组测试锁死「默认不裁剪」这条语义。
//
// 改动前:每条非中断输入都无条件 Prune 一次,没有任何声明能关掉它。
// 这是破坏性行为(低相关事件被归档并从上下文移走),却无法从调用点看出
// 「谁触发的裁剪」。改成需声明后,必须逐条验证默认值确实是不裁剪。
func TestPruneDeclared_DefaultsToNoPrune(t *testing.T) {
m := agentIO.NewIOManager()
a := &Agent{io: m}
evt := &agentIO.InputEvent{Source: "unknown_source", Payload: map[string]interface{}{}}
if a.pruneDeclared(evt) {
t.Fatal("既没有通道声明也没有注入声明的输入,默认必须不裁剪")
}
// 通道注册了、但策略是 none / 空:仍然不裁剪。
m.RegisterInputChannel("quiet", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyNone})
if a.pruneDeclared(&agentIO.InputEvent{Source: "quiet", Payload: map[string]interface{}{}}) {
t.Fatal("ChannelDef.ContextPolicy=none 不应裁剪")
}
m.RegisterInputChannel("empty", pubsdk.ChannelDef{})
if a.pruneDeclared(&agentIO.InputEvent{Source: "empty", Payload: map[string]interface{}{}}) {
t.Fatal("ChannelDef 未设 ContextPolicy 不应裁剪")
}
}
// 通道显式声明 prune 才裁剪。
func TestPruneDeclared_ChannelOptIn(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
a := &Agent{io: m}
if !a.pruneDeclared(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}) {
t.Fatal("通道声明 prune 后应裁剪")
}
}
// 注入点声明的优先级高于通道定义:同一通道下的不同注入可以有不同意图。
func TestPruneDeclared_InjectionOverridesChannel(t *testing.T) {
m := agentIO.NewIOManager()
a := &Agent{io: m}
m.RegisterInputChannel("chan", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
// 注入点说 none → 即使通道说 prune 也不裁。
evt := &agentIO.InputEvent{Source: "chan", Payload: map[string]interface{}{
"context_policy": pubsdk.ContextPolicyNone,
}}
if a.pruneDeclared(evt) {
t.Fatal("注入点声明 none 应覆盖通道的 prune")
}
// 通道没说,注入点说 prune → 裁。
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
"context_policy": pubsdk.ContextPolicyPrune,
}}
if !a.pruneDeclared(evt) {
t.Fatal("注入点声明 prune 应生效")
}
}
// 没有 context 时不能 panic也不该裁剪。
func TestPruneOnInput_NilContextIsSafe(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
a := &Agent{io: m}
if got := a.pruneOnInput(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}, "x"); got != 0 {
t.Fatalf("nil context 应返回 0实际 %d", got)
}
}
// cleanInputFor 的优先级:注入点声明的 cleaner > 按 source 查的 cleaner > 原文。
func TestCleanInputFor_Priority(t *testing.T) {
m := agentIO.NewIOManager()
m.RegisterInputChannel("src", pubsdk.ChannelDef{
Cleaner: func(s string) string { return "by-source:" + s },
})
m.RegisterInputChannel("explicit", pubsdk.ChannelDef{
Cleaner: func(s string) string { return "by-name:" + s },
})
a := &Agent{io: m}
// 无声明 → 用 source 的 cleaner
evt := &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{}}
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
t.Fatalf("应回退到 source 的 cleaner实际 %q", got)
}
// 注入点指定 cleaner_name → 覆盖 source 的
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "explicit"}}
if got := a.cleanInputFor(evt, "raw"); got != "by-name:raw" {
t.Fatalf("注入点声明的 cleaner 应优先,实际 %q", got)
}
// 完全没有 cleaner → 原文
evt = &agentIO.InputEvent{Source: "nobody", Payload: map[string]interface{}{}}
if got := a.cleanInputFor(evt, "raw"); got != "raw" {
t.Fatalf("没有 cleaner 时应返回原文,实际 %q", got)
}
// 声明的名字查不到 → 回退到 source 的 cleaner并记日志不能 panic、不能丢内容
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "missing"}}
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
t.Fatalf("未知 cleaner_name 应回退,实际 %q", got)
}
// nil IOManager 不能 panic
if got := (&Agent{}).cleanInputFor(evt, "raw"); got != "raw" {
t.Fatalf("nil io 应返回原文,实际 %q", got)
}
}

View File

@ -0,0 +1,65 @@
package core
import (
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// ContextPolicy=prune 的查询向量必须取**清洗后**的有效内容。
//
// 裁剪的入参是相关性查询向量,它决定保留/归档哪些上下文事件。原始工具输出里
// 混着 ANSI 转义、base64、JSON 包装等噪声,直接向量化会让打分失真,裁掉本该
// 保留的事件。ToolDef.Cleaner 的契约本就写着「仅在向量化/jieba/蒸馏时调用」,
// 裁剪正是在向量化——此前只在构建事件向量时用了它,裁剪查询漏了。
func TestToolOutputForQueryAppliesCleaner(t *testing.T) {
host := NewStageHost()
called := 0
if err := host.RegisterTool("demo_tool", sdk.ToolDef{
Name: "demo_tool",
Cleaner: func(s string) string {
called++
return "cleaned:" + s
},
}, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
a := &Agent{stageHost: host}
raw := "\x1b[31mresult\x1b[0m"
got := a.toolOutputForQuery("demo_tool", raw)
if called != 1 {
t.Fatalf("Cleaner 应被调用恰好一次,实际 %d", called)
}
if got != "cleaned:"+raw {
t.Fatalf("查询应使用清洗结果,实际 %q", got)
}
// 未注册 Cleaner 的工具:回退原文。
if got := a.toolOutputForQuery("no_such_tool", raw); got != raw {
t.Fatalf("无 Cleaner 应回退原文,实际 %q", got)
}
// 无 StageHost如裸 Agent不能 panic回退原文。
if got := (&Agent{}).toolOutputForQuery("demo_tool", raw); got != raw {
t.Fatalf("nil stageHost 应回退原文,实际 %q", got)
}
}
// Cleaner 返回空串时必须回退原文:空串会让查询向量退化成零向量,
// 所有事件相关性相同,裁剪就失去判据(等于随机裁)。
func TestToolOutputForQueryEmptyCleanFallsBack(t *testing.T) {
host := NewStageHost()
if err := host.RegisterTool("t", sdk.ToolDef{
Name: "t",
Cleaner: func(string) string { return "" },
}, func(map[string]interface{}) (interface{}, error) { return nil, nil }); err != nil {
t.Fatalf("RegisterTool: %v", err)
}
a := &Agent{stageHost: host}
if got := a.toolOutputForQuery("t", "raw"); got != "raw" {
t.Fatalf("Cleaner 返回空应回退原文,实际 %q", got)
}
}

View File

@ -0,0 +1,515 @@
package core
// 驻留式子 agent 的**生命周期与控制面**(设计 docs/zh/resident-subagent-design.md §7/§9/§10
//
// 父 ──创建/发送消息/查看/压缩/回收/销毁──► 驻留子
// 子 ──主动消息(L3) / contextfull(L4)──► 父
//
// 层级关系:
// - 父持**登记表**residents它是查看·发送·压缩·回收·销毁的寻址依据
// - 父 `Stop()` ⇒ 销毁全部子(**不留孤儿**
// - 子的 `KernelSource` = 父;父则把子的 contextfull 当**内核级事件**raiseKernelInterrupt上报给自己
// - 子持有自己的 **inputch 处理表**(父 pull不打断子
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// InputchRecord 是 inputch 处理表的一条记录(子持有,父 pull
type InputchRecord struct {
InputCh string `json:"inputch"`
At time.Time `json:"at"`
Proactive bool `json:"proactive"` // true = 子主动写入false = 系统自动写
Text string `json:"text"`
}
// ResidentOptions 是创建一个驻留子的参数(父的"创建"动作)。
type ResidentOptions struct {
// ID 是子 agent 的 id同时是登记表的键、跨 agent 寻址的依据)。
ID string
// TaskPrompt 是在固定提示词之上注入的**任务提示词**。
TaskPrompt string
// InputChs 是**划入**给这个子的 inputch单位 = inputch可来自同一插件的多个
InputChs []string
// AllowedOutputs 是授权给它的输出通道集合nil/空 = 完整授权)。
AllowedOutputs []string
// Capacity 是划入 inputch 的队列容量0 = 内核默认)。
Capacity int
// TempPath 是它 temp 图记忆的存储路径(必填;与子同生共死)。
TempPath string
}
// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。
type ResidentInfo struct {
ID string `json:"id"`
State string `json:"state"`
InputChs []string `json:"inputchs"`
AllowedOutputs []string `json:"allowed_outputs"`
Rounds int `json:"rounds"`
ContextFull bool `json:"context_full"`
CreatedAt time.Time `json:"created_at"`
TableSize int `json:"table_size"`
Table []InputchRecord `json:"table,omitempty"`
}
type residentChild struct {
id string
agent *Agent
light *memory.LightMemory
mainRO *memory.GraphDB
tempPath string
dir string
inputChs []string
allowed []string
createdAt time.Time
mu sync.Mutex
state string // running | contextfull | stopped
}
// mainGraph 返回父自己的完整图记忆库(驻留子的受限句柄由它派生)。
func (a *Agent) mainGraph() *memory.GraphDB { return a.memory }
// SpawnResident 创建一个驻留子(父的"创建"动作)。
func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
if strings.TrimSpace(opts.ID) == "" {
return ResidentInfo{}, fmt.Errorf("驻留子必须有 id")
}
if strings.TrimSpace(opts.TempPath) == "" {
return ResidentInfo{}, fmt.Errorf("驻留子必须给出 temp 图记忆路径")
}
main := a.mainGraph()
if main == nil {
return ResidentInfo{}, fmt.Errorf("父没有图记忆,无法为驻留子提供主库只读视图")
}
a.residentMu.Lock()
if a.residents == nil {
a.residents = map[string]*residentChild{}
}
if _, dup := a.residents[opts.ID]; dup {
a.residentMu.Unlock()
return ResidentInfo{}, fmt.Errorf("驻留子 %s 已存在", opts.ID)
}
a.residentMu.Unlock()
// ① 轻量内核的记忆装配:主库**受限句柄**(结构上写不进)+ 自己的 temp 实例。
// temp 目录由内核创建(调用方只给路径)——与子同生共死,销毁时整目录丢弃。
if dir := filepath.Dir(opts.TempPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o700); err != nil {
return ResidentInfo{}, fmt.Errorf("创建 temp 目录: %w", err)
}
}
mainRO, err := memory.OpenGraphDBReadOnly(main.Path())
if err != nil {
return ResidentInfo{}, fmt.Errorf("打开主库受限句柄: %w", err)
}
light, err := memory.NewLightMemory(mainRO, opts.TempPath, true)
if err != nil {
_ = mainRO.Close()
return ResidentInfo{}, err
}
// ② 划入 inputch登记表里记归属一个插件的多个 inputch 可分别划给不同子)。
if reg := a.io.ChannelRegistry(); reg != nil {
for _, ch := range opts.InputChs {
if err := reg.Assign(ch, opts.ID, opts.Capacity); err != nil {
_ = light.Close()
_ = mainRO.Close()
return ResidentInfo{}, fmt.Errorf("划入 inputch %s: %w", ch, err)
}
}
}
// ③ 子的 io**独立**的 IOManager自己的输入通道入口但共享通道登记表。
childIO := agentIO.NewIOManager()
if reg := a.io.ChannelRegistry(); reg != nil {
childIO.SetChannelRegistry(reg)
}
parentID := string(a.id)
child := New(AgentConfig{
ID: types.AgentID(opts.ID),
Provider: a.provider,
ProviderManager: a.providerManager,
IO: childIO,
StageHost: a.stageHost,
LightMemory: light, // 轻量内核:只有图记忆共同面
AllowedOutputs: opts.AllowedOutputs,
KernelSource: parentID, // 子的 L4 只属于父
ParentID: parentID,
TaskPrompt: opts.TaskPrompt,
})
rc := &residentChild{
id: opts.ID, agent: child, light: light, mainRO: mainRO,
tempPath: opts.TempPath, dir: filepath.Dir(opts.TempPath),
inputChs: append([]string(nil), opts.InputChs...),
allowed: append([]string(nil), opts.AllowedOutputs...),
createdAt: time.Now(), state: "running",
}
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
parentInCh := a.residentInboundChannel(opts.ID)
child.notifyParent = func(text string) {
a.io.InjectInterruptTextOpts(opts.ID, parentInCh, text,
agentIO.InjectOptions{Priority: "L3"})
}
// ⑤ 子的 contextfull → 父侧的**内核级事件**L4带子标识
child.onContextFull = func() { a.handleChildContextFull(rc) }
a.residentMu.Lock()
a.residents[opts.ID] = rc
a.residentMu.Unlock()
child.Start()
// ⑥ create 即开工:把任务提示词作为**第一条排队输入**投给子。
//
// 为什么必须在这里投TaskPrompt 只进子的系统提示词("你是谁、要做什么"
// 而**不会**让子跑起来 —— 实测现象是子启动后 rounds=0、永远待机
// (日志 `[agent] r1 started, waiting for IO interrupts` 之后无事发生)。
// 走排队输入(非中断):创建是"安排工作",不是"打断它正在做的事"。
if strings.TrimSpace(opts.TaskPrompt) != "" {
child.io.InjectInputTo(a.residentParentSource(), parentInCh, "text",
map[string]interface{}{"content": opts.TaskPrompt})
}
return rc.info(), nil
}
// residentParentSource 是"父给子投递"的输入来源名(子的视角里能看出是谁发的)。
func (a *Agent) residentParentSource() string { return "parent/" + string(a.id) }
// residentInboundChannel 是"父接收某个子的消息"的 inputch 名(登记进登记表可见)。
func (a *Agent) residentInboundChannel(childID string) string {
ch := "child/" + childID
if reg := a.io.ChannelRegistry(); reg != nil {
// 归属父自己:它是父的入站 inputch。
_ = reg.Register(agentIO.InputChannel{Name: ch, Plugin: "resident", Owner: string(a.id)})
}
return ch
}
// DestroyResident 立刻销毁一个驻留子并从登记表移除(父的"销毁"动作;不收割)。
//
// 销毁是父**随时**可做的;父退出时由 StopResidents 对全部子执行。
func (a *Agent) DestroyResident(id string) error {
a.residentMu.Lock()
rc, ok := a.residents[id]
if ok {
delete(a.residents, id)
}
a.residentMu.Unlock()
if !ok {
return fmt.Errorf("驻留子 %s 不存在", id)
}
a.teardownResident(rc)
return nil
}
// teardownResident 停内核、放通道、丢 temp销毁与回收共用
func (a *Agent) teardownResident(rc *residentChild) {
rc.mu.Lock()
rc.state = "stopped"
rc.mu.Unlock()
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
if rc.light != nil {
_ = rc.light.Close() // 关掉 temp 实例
}
if rc.mainRO != nil {
_ = rc.mainRO.Close()
}
// temp 与子同生共死:连同目录一起丢弃(回收/销毁都不保留)。
if rc.dir != "" && strings.Contains(rc.tempPath, rc.id) {
_ = os.RemoveAll(rc.dir)
}
// 归还划入的 inputch归属清空 ⇒ 回到"未分配",可再分配)。
if reg := a.io.ChannelRegistry(); reg != nil {
for _, ch := range rc.inputChs {
_ = reg.Assign(ch, "", 0)
}
}
}
// StopResidents 销毁全部驻留子(父退出时必须;不留孤儿)。
func (a *Agent) StopResidents() int {
a.residentMu.Lock()
all := make([]*residentChild, 0, len(a.residents))
for _, rc := range a.residents {
all = append(all, rc)
}
a.residents = map[string]*residentChild{}
a.residentMu.Unlock()
for _, rc := range all {
a.teardownResident(rc)
}
return len(all)
}
// Residents 返回登记表的快照(按 id 排序,便于断言与展示稳定)。
func (a *Agent) Residents() []ResidentInfo {
a.residentMu.Lock()
all := make([]*residentChild, 0, len(a.residents))
for _, rc := range a.residents {
all = append(all, rc)
}
a.residentMu.Unlock()
out := make([]ResidentInfo, 0, len(all))
for _, rc := range all {
out = append(out, rc.info())
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// ResidentTable 是父**查看**子的 inputch 处理表pull不打断子
func (a *Agent) ResidentTable(id string) ([]InputchRecord, error) {
a.residentMu.Lock()
rc, ok := a.residents[id]
a.residentMu.Unlock()
if !ok {
return nil, fmt.Errorf("驻留子 %s 不存在", id)
}
return rc.agent.inputchTableSnapshot(), nil
}
// SendToResident 是父的"发送消息":经输出通道寻址到该子的 inputch
// 对子而言是 **L4 中断**(取消当前状态 + 插入新消息)。
func (a *Agent) SendToResident(id, text string) error {
a.residentMu.Lock()
rc, ok := a.residents[id]
a.residentMu.Unlock()
if !ok {
return fmt.Errorf("驻留子 %s 不存在", id)
}
ch := "sub/" + id
if len(rc.inputChs) > 0 {
ch = rc.inputChs[0]
}
// 来源 = 父;子的 KernelSource 是父 ⇒ 在子的阶梯上这是合法的 L4。
rc.agent.io.InjectInterruptTextOpts(string(a.id), ch, text,
agentIO.InjectOptions{Priority: "L4"})
return nil
}
// CompressResident 是父的"压缩"**保留语义**):压上下文 + **清理处理表**,子继续存在。
func (a *Agent) CompressResident(id string) (int, error) {
a.residentMu.Lock()
rc, ok := a.residents[id]
a.residentMu.Unlock()
if !ok {
return 0, fmt.Errorf("驻留子 %s 不存在", id)
}
dropped := rc.agent.context.TrimKeepRecent(residentKeepRecent)
rc.agent.clearInputchTable() // 处理表记的是被压掉那段窗口的逐轮处理 ⇒ 必须清
rc.agent.resetContextFull()
rc.mu.Lock()
rc.state = "running"
rc.mu.Unlock()
return dropped, nil
}
// ReclaimResident 是父的"回收"**取消语义**):父读子的 temp → 选记录 → 合入 main
// → 丢弃 temp → **取消**该驻留子。
//
// keep 由父决定"哪些纳入记忆";为 nil 时表示全部合入。
func (a *Agent) ReclaimResident(id string, keep func([]InputchRecord, []memory.Triple) []memory.Triple) (ResidentInfo, error) {
a.residentMu.Lock()
rc, ok := a.residents[id]
a.residentMu.Unlock()
if !ok {
return ResidentInfo{}, fmt.Errorf("驻留子 %s 不存在", id)
}
info := rc.info()
var promoted int
// 收割:读 temp 的全部活跃三元组(比通过图记录选择更直接)。
if rc.light != nil && rc.light.Temp() != nil && a.mainGraph() != nil {
exported, err := rc.light.Temp().ExportTriples(0)
if err != nil {
return info, fmt.Errorf("读取子 temp 失败: %w", err)
}
selected := exported
if keep != nil {
selected = keep(info.Table, exported)
}
if len(selected) > 0 {
if _, _, err := a.mainGraph().Commit(selected, "reclaim/"+id, 0); err != nil {
return info, fmt.Errorf("合入主记忆失败: %w", err)
}
promoted = len(selected)
}
}
// 收割完成 ⇒ 取消该驻留子(回收是取消语义,≠ 压缩)。
if err := a.DestroyResident(id); err != nil {
return info, err
}
info.State = fmt.Sprintf("reclaimed(promoted=%d)", promoted)
return info, nil
}
// handleChildContextFull 把子的 contextfull 当**内核级事件**上报给父自己:
// 父侧 L4 中断(带子标识)—— 只推信号,细节靠"查看"拉状态面。
func (a *Agent) handleChildContextFull(rc *residentChild) {
rc.mu.Lock()
rc.state = "contextfull"
rc.mu.Unlock()
a.raiseKernelInterrupt("child/"+rc.id, "kernel",
fmt.Sprintf("内核事件:驻留子 %s 上下文已满contextfull。请【查看】其状态面后决定【压缩】/【回收】/【销毁】。", rc.id))
}
// checkContextFull 判断本 agent 的**积累上下文是否已经装不下窗口**,是则触发一次 contextfull。
//
// ❗判据为什么不能写成"估算拼好的 f.Msgs"`buildMessages` 拿到的 `budget.ContextTokens`
// 本身就是按 `targetUsage = 0.8 × 窗口` 算出来的,时间线**在拼进消息之前就被预算裁过**了。
// 于是 `f.Msgs` 的规模结构上封顶在 ~80% 窗口 —— 对 90% 阈值而言是**永远不成立**的判据
// (写测试时我用一个比系统提示词还小的窗口才勉强越过线,那等于什么都没测)。
//
// 正确的事是"**要被裁了**":拿**未裁剪**的积累上下文(`a.context` 的全部事件)估算,
// 它超过窗口阈值就说明下一轮必须丢事件 ⇒ 这就是 contextfull。
//
// 只对**驻留子**生效(只有它们设了 onContextFull
func (a *Agent) checkContextFull(f *TaskFrame) {
if a.onContextFull == nil || a.ctxFullSignaled {
return
}
max := 0
if a.provider != nil {
max = a.provider.MaxContextTokens()
}
if max <= 0 {
max = defaultMaxContextTokens
}
if a.context == nil {
return
}
// 未裁剪的积累上下文规模。
acc := 0
for _, e := range a.context.Recent(0) {
acc += EstimateTokens(e.Input) + EstimateTokens(e.Response)
}
if float64(acc) < float64(max)*contextFullRatio {
return
}
a.ctxFullSignaled = true
a.onContextFull()
}
func (a *Agent) resetContextFull() { a.ctxFullSignaled = false }
// ---- 子侧inputch 处理表(子持有,父 pull ----
// recordInputchNote 是子**主动写入**本轮 inputch 的处理信息(工具 inputch_note
func (a *Agent) recordInputchNote(text string) {
a.tableMu.Lock()
defer a.tableMu.Unlock()
a.inputchPending = &InputchRecord{
InputCh: a.currentInputch, At: time.Now(), Proactive: true, Text: text,
}
}
// autoRecordInputch 是**系统自动写**兜底:本轮未主动写时,把该轮 inputch 的处理信息写入。
// 保证每一轮必有记录,父不会看到空洞。
func (a *Agent) autoRecordInputch(f *TaskFrame) {
if f == nil {
return
}
a.tableMu.Lock()
defer a.tableMu.Unlock()
if a.inputchPending != nil {
rec := *a.inputchPending
a.inputchPending = nil
rec.InputCh = firstNonEmpty(rec.InputCh, a.currentInputch)
a.inputchTable = append(a.inputchTable, rec)
return
}
text := fmt.Sprintf("轮次完成:输入=%s", truncateStr(f.Input, 80))
if f.Response != "" {
text += ";产出=" + truncateStr(f.Response, 120)
}
a.inputchTable = append(a.inputchTable, InputchRecord{
InputCh: a.currentInputch, At: time.Now(), Proactive: false, Text: text,
})
}
func (a *Agent) inputchTableSnapshot() []InputchRecord {
a.tableMu.Lock()
defer a.tableMu.Unlock()
return append([]InputchRecord(nil), a.inputchTable...)
}
func (a *Agent) clearInputchTable() {
a.tableMu.Lock()
defer a.tableMu.Unlock()
a.inputchTable = nil
a.inputchPending = nil
}
// ---- 子侧主动向父发消息L3 ----
func (a *Agent) notifyParentFrom(text string) string {
if a.notifyParent == nil {
return "本 agent 没有上级,无法发送消息"
}
if strings.TrimSpace(text) == "" {
return "消息内容不能为空"
}
a.notifyParent(text)
return "已发送给主 agent"
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// residentKeepRecent 是压缩时保留的最近事件条数。
const residentKeepRecent = 20
// contextFullRatio 是触发 contextfull 的占比:积累上下文超过窗口的这个比例就报。
// 取 0.9 而不是 1.0:留一点余量,让父 agent 在"下一次必须丢事件"之前就能决策
// (压缩 / 回收 / 销毁),而不是等已经丢了再报。
const contextFullRatio = 0.9
// defaultMaxContextTokens 是 provider 未报告窗口时的兜底(与 ComputeTokenBudget 一致)。
const defaultMaxContextTokens = 32768
func (rc *residentChild) info() ResidentInfo {
rc.mu.Lock()
state, full := rc.state, rc.state == "contextfull"
rc.mu.Unlock()
table := rc.agent.inputchTableSnapshot()
info := ResidentInfo{
ID: rc.id, State: state, InputChs: append([]string(nil), rc.inputChs...),
AllowedOutputs: append([]string(nil), rc.allowed...),
ContextFull: full, CreatedAt: rc.createdAt, TableSize: len(table),
}
if len(table) > 0 {
info.Table = table
}
return info
}
// MarshalResidentInfo 便于工具输出(单工具多视图用 JSON 视图)。
func MarshalResidentInfo(info ResidentInfo) string {
b, err := json.Marshal(info)
if err != nil {
return fmt.Sprintf("%+v", info)
}
return string(b)
}

View File

@ -0,0 +1,493 @@
package core
// 驻留子生命周期N3、跨 agent 投递N4、inputch 处理表N5、contextfullN6
//
// 设计 docs/zh/resident-subagent-design.md §6/§7/§8/§9/§10。
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// newRootWith 造一个带完整图记忆的父 agent并指定它以及它的驻留子用的 provider。
func newRootWith(t *testing.T, provider agentAPI.Provider) (*Agent, *memory.GraphDB, string) {
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: provider,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
Memory: main,
DataDir: dir,
})
a.Start() // 父也有自己的调度器:子的消息要真的进它的中断队列并被处理
t.Cleanup(func() { a.Stop(); main.Close() })
return a, main, dir
}
// newRootForResidents 是默认构造(正常窗口)。
func newRootForResidents(t *testing.T) (*Agent, *memory.GraphDB, string) {
t.Helper()
return newRootWith(t, &countingProvider{})
}
func spawnTestResident(t *testing.T, parent *Agent, dir, id string, inputChs ...string) ResidentInfo {
t.Helper()
info, err := parent.SpawnResident(ResidentOptions{
ID: id,
TaskPrompt: "盯住这个通道,有情况就汇报",
InputChs: inputChs,
TempPath: filepath.Join(dir, "residents", id, "graph.db"),
})
if err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
return info
}
// waitFor 轮询直到条件成立(测试里不使用 sleep 猜时序)。
func waitFor(t *testing.T, what string, fn func() bool) {
t.Helper()
waitForWithin(t, what, 5*time.Second, fn)
}
func waitForWithin(t *testing.T, what string, within time.Duration, fn func() bool) {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等待超时:%s", what)
}
// ---- N3生命周期 ----
func TestResident_LifecycleAndNoOrphans(t *testing.T) {
parent, _, dir := newRootForResidents(t)
// 父先注册两个 inputch模拟插件注册再把其中一个划给子。
reg := parent.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "qq", Plugin: "qq"}); err != nil {
t.Fatal(err)
}
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
t.Fatal(err)
}
info := spawnTestResident(t, parent, dir, "child-1", "sub/in")
if info.State != "running" {
t.Fatalf("新建的驻留子状态=%q", info.State)
}
if list := parent.Residents(); len(list) != 1 || list[0].ID != "child-1" {
t.Fatalf("登记表=%+v", list)
}
// 划入生效inputch 的归属变成子。
ch, ok := reg.Lookup("sub/in")
if !ok || ch.Owner != "child-1" {
t.Fatalf("划入未生效:%+v", ch)
}
// 未划入的仍是未分配。
if qq, _ := reg.Lookup("qq"); qq.Owner != "" {
t.Fatalf("未划入的 inputch 不该有归属:%+v", qq)
}
// 父的入站 inputch接收该子的消息登记在父名下。
if inbound, ok := reg.Lookup("child/child-1"); !ok || inbound.Owner != "parent" {
t.Fatalf("父的入站 inputch 未登记:%+v ok=%v", inbound, ok)
}
// 子的轻量内核:有共同面、没有整理面。
c1 := parent.residents["child-1"]
if c1.agent.memory != nil {
t.Fatal("驻留子不该有记忆整理面")
}
if c1.agent.graphMem() == nil {
t.Fatal("驻留子必须有图记忆共同面")
}
// 子的 L4 只属于父。
if !c1.agent.isKernelLevelSource("parent") {
t.Fatal("父必须是子的内核级来源(子的 L4 归父独占)")
}
if c1.agent.isKernelLevelSource("别人") {
t.Fatal("非父来源不得成为子的内核级来源")
}
// 销毁:出登记表、归还 inputch、temp 目录丢弃。
if err := parent.DestroyResident("child-1"); err != nil {
t.Fatal(err)
}
if len(parent.Residents()) != 0 {
t.Fatal("销毁后登记表应为空")
}
if ch, _ := reg.Lookup("sub/in"); ch.Owner != "" {
t.Fatalf("销毁后 inputch 应回到未分配:%+v", ch)
}
if err := parent.DestroyResident("child-1"); err == nil {
t.Fatal("重复销毁应报错")
}
// **父退出 ⇒ 全部子销毁、不留孤儿**。
spawnTestResident(t, parent, dir, "c-a", "sub/in")
spawnTestResident(t, parent, dir, "c-b")
if n := parent.StopResidents(); n != 2 {
t.Fatalf("StopResidents 销毁 %d 个,期望 2", n)
}
if len(parent.Residents()) != 0 {
t.Fatal("父退出后登记表必须为空")
}
for _, id := range []string{"c-a", "c-b"} {
if _, err := osStat(filepath.Join(dir, "residents", id)); err == nil {
t.Fatalf("子 %s 的 temp 目录应被丢弃", id)
}
}
}
// osStat 只是为了让"目录是否还存在"的断言可读(存在返回 nil 错误)。
func osStat(path string) (interface{}, error) {
_, err := os.Stat(path)
return nil, err
}
// ---- N4跨 agent 投递 ----
func TestResident_DeliveryBothDirections(t *testing.T) {
parent, _, dir := newRootForResidents(t)
spawnTestResident(t, parent, dir, "child-1")
child := parent.residents["child-1"].agent
// 父 → 子:发送消息 ⇒ 子在 **L4** 上收到(父是子的内核级来源)。
if err := parent.SendToResident("child-1", "先停一下,改做 X"); err != nil {
t.Fatal(err)
}
waitFor(t, "子收到 L4 中断", func() bool {
return child.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
})
// 子 → 父:主动消息 ⇒ 父在 **L3** 上收到(不是 L4
// 注意 L3 的枚举值是 LevelInteractiveLevelMessage 是 L2
child.notifyParent("我这边发现了点东西")
waitFor(t, "父收到子的 L3 消息", func() bool {
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= 1
})
if got := parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical]; got != 0 {
t.Fatalf("子的主动消息不得以 L4 出现在父的阶梯上(实际 %d 次)", got)
}
}
// ---- N5inputch 处理表 ----
func TestResident_InputchTableAutoAndProactive(t *testing.T) {
parent, _, dir := newRootForResidents(t)
spawnTestResident(t, parent, dir, "child-1")
child := parent.residents["child-1"].agent
// 说明create 会把任务提示词作为**第一条输入**投给子("create 即开工"
// 所以这里先等那一轮写完 —— 表里每多一轮就多一条,正是"每轮必有记录"。
waitFor(t, "任务提示词那一轮写入", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) >= 1
})
base, err := parent.ResidentTable("child-1")
if err != nil {
t.Fatal(err)
}
n := len(base)
// ① 子不主动写 ⇒ 系统自动写(每一轮必有记录)。
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "干活"})
waitFor(t, "自动写处理表", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) == n+1 && !table[n].Proactive
})
table, err := parent.ResidentTable("child-1")
if err != nil {
t.Fatal(err)
}
if table[n].InputCh != "sub/in" {
t.Fatalf("处理表应记本轮 inputch实际 %q", table[n].InputCh)
}
// ② 子主动写 ⇒ 本轮不再自动写。
child.recordInputchNote("本轮我自己记:已完成第一阶段")
child.autoRecordInputch(&TaskFrame{Input: "第二轮"})
table, err = parent.ResidentTable("child-1")
if err != nil {
t.Fatal(err)
}
if len(table) != n+2 || !table[n+1].Proactive || !strings.Contains(table[n+1].Text, "第一阶段") {
t.Fatalf("主动写优先的语义不成立:%+v", table)
}
}
// ---- N6contextfull + 三种处置 ----
func TestResident_ContextFullAndDispositions(t *testing.T) {
parent, main, dir := newRootForResidents(t)
// ① contextfull把子的**积累上下文**a.context不是拼好的消息灌到超过窗口 90%。
// 注意不能靠"拼好的消息很大"来触发:拼装前时间线已被 token 预算裁到 ~80% 窗口。
spawnTestResident(t, parent, dir, "child-1")
rc := parent.residents["child-1"]
child := rc.agent
child.context.Append(ContextEvent{
Timestamp: time.Now(), Source: "sub/in",
Input: strings.Repeat("上下文填充", 8000), // 40000 字 ≈ 80000 token ≫ 8192×0.9
})
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "继续"})
// 父在 **L4** 上收到 contextfull带子标识—— 只推信号。
waitFor(t, "父收到 contextfull 的 L4 中断", func() bool {
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelCritical] >= 1
})
waitFor(t, "父的登记表显示子 contextfull", func() bool {
for _, r := range parent.Residents() {
if r.ID == "child-1" && r.ContextFull {
return true
}
}
return false
})
// ② 压缩(保留语义):上下文变短 + 处理表清空 + 子继续存在。
child.recordInputchNote("压缩前的记录")
if _, err := parent.CompressResident("child-1"); err != nil {
t.Fatal(err)
}
if len(parent.Residents()) != 1 {
t.Fatal("压缩后子必须继续存在(压缩是保留语义)")
}
if table, _ := parent.ResidentTable("child-1"); len(table) != 0 {
t.Fatalf("压缩必须清理 inputch 处理表,实际 %d 条", len(table))
}
// ③ 回收(取消语义):父选中的 temp 记录合入 main然后取消该子。
if _, _, err := child.graphMem().Commit([]memory.Triple{
{Subject: "子的发现", Relation: "指向", Object: "结论"},
}, "sess", 1); err != nil {
t.Fatalf("子写自己的 temp 应成功: %v", err)
}
if _, err := parent.ReclaimResident("child-1", reclaimKeepAll); err != nil {
t.Fatal(err)
}
if len(parent.Residents()) != 0 {
t.Fatal("回收是取消语义:子不该继续存在")
}
res, err := main.Recall([]string{"子的发现"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range res.Entities {
if e.Name == "子的发现" {
found = true
}
}
if !found {
t.Fatal("回收应把选中的 temp 记录合入主记忆")
}
// ④ 销毁:随时可做、立刻移除。
spawnTestResident(t, parent, dir, "child-2")
if err := parent.DestroyResident("child-2"); err != nil {
t.Fatal(err)
}
if len(parent.Residents()) != 0 {
t.Fatal("销毁后不该还在登记表里")
}
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return def
}
// ---- N7端到端 + 压力 ----
func TestResident_E2EAndStress(t *testing.T) {
parent, _, dir := newRootForResidents(t)
reg := parent.io.ChannelRegistry()
// 压力规模可用环境变量放大(默认 8 子 × 12 轮):
// RESIDENT_STRESS_N / RESIDENT_STRESS_ROUNDS
nResidents := envInt("RESIDENT_STRESS_N", 8)
roundsEach := envInt("RESIDENT_STRESS_ROUNDS", 12)
ids := make([]string, 0, nResidents)
for i := 0; i < nResidents; i++ {
id := "sub-" + string(rune('a'+i))
ch := "sub/" + id + "/in"
if err := reg.Register(agentIO.InputChannel{Name: ch, Plugin: "sub"}); err != nil {
t.Fatal(err)
}
spawnTestResident(t, parent, dir, id, ch)
ids = append(ids, id)
}
// 压力:每个子灌 roundsEach 轮输入;其中一半走父→子的 L4 消息,一半走普通输入。
for _, id := range ids {
for r := 0; r < roundsEach; r++ {
// 内容必须唯一:内核会去重相同输入(去重路径不产生处理表记录)。
msg := fmt.Sprintf("%s 第 %d 轮", id, r)
if r%2 == 0 {
if err := parent.SendToResident(id, msg); err != nil {
t.Fatal(err)
}
} else {
parent.residents[id].agent.io.InjectInput("sub/"+id+"/in", "text",
map[string]interface{}{"content": msg})
}
}
}
// 全部子都必须活着,且每一轮都留下处理表记录(自动或主动)。
waitForWithin(t, "全部子完成各自轮次", 30*time.Second, func() bool {
for _, id := range ids {
table, err := parent.ResidentTable(id)
if err != nil || len(table) < roundsEach/2 {
return false
}
}
return true
})
// 双向通信在压力下也成立:让每个子都汇报一次(父侧 L3
for _, id := range ids {
parent.residents[id].agent.notifyParent("压力汇报 " + id)
}
waitForWithin(t, "父收到全部子的汇报", 10*time.Second, func() bool {
return parent.DumpScheduler().Stats.InterruptsByLevel[LevelInteractive] >= uint64(nResidents)
})
// 父退出 ⇒ 全部子销毁、登记表清空(不留孤儿)。
if n := parent.StopResidents(); n != nResidents {
t.Fatalf("父退出应销毁 %d 个子,实际 %d", nResidents, n)
}
if len(parent.Residents()) != 0 {
t.Fatal("父退出后登记表必须为空")
}
t.Logf("压力通过:%d 个驻留子 × %d 轮(父→子 L4 与普通输入各半)+ 双向汇报",
nResidents, roundsEach)
}
// ---- 传统上下文:轻量内核不做动态上下文的裁剪 ----
// captureProvider 记录模型**实际收到**的消息。
//
// 为什么不直接看 TaskFrame`prepareInputTask` 只做前半段(去重/通道/阶段/落上下文),
// 消息是在 `runTaskSteps` 的 stepPrepare 里才拼出来的;而且断言"模型看到了什么"
// 本来就比断言内核内部字段更接近事实。
type captureProvider struct {
countingProvider
mu sync.Mutex
calls int
messages []agentAPI.Message
}
func (p *captureProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
if req != nil {
p.messages = append([]agentAPI.Message(nil), req.Messages...)
}
p.mu.Unlock()
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *captureProvider) chatText() (int, string) {
p.mu.Lock()
defer p.mu.Unlock()
var b strings.Builder
for _, m := range p.messages {
b.WriteString(m.Content)
b.WriteString("\n")
}
return p.calls, b.String()
}
// 子 agent 的上下文是**传统上下文**:累积的事件全部交给模型,
// 内核**不得**按动态上下文的预算静默丢弃(那是父 agent 的能力)。
// 装不下时由 contextfull 上报父决策,而不是自己丢。
func TestLightKernel_TraditionalContextNoTrimming(t *testing.T) {
provider := &captureProvider{}
parent, _, dir := newRootWith(t, provider)
spawnTestResident(t, parent, dir, "child-1")
child := parent.residents["child-1"].agent
if !child.isLightKernel() {
t.Fatal("驻留子必须被识别为轻量内核")
}
// 先等"create 即开工"那一轮(任务提示词)跑完,否则下面抓到的是它的请求,
// 而不是我们注入了大段上下文之后的那一轮。
waitFor(t, "任务提示词那一轮结束", func() bool {
table, err := parent.ResidentTable("child-1")
return err == nil && len(table) >= 1
})
// 前提:动态上下文的份额 < 窗口(否则测不出区别)。
b := ComputeTokenBudget(child.provider, child.systemPrompt)
if b.MaxContext <= b.ContextTokens {
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
}
// 填充量:**超过动态份额、但仍在窗口内**。
// ⇒ 完整内核会因预算把最早那条裁掉;轻量内核不该裁(只受窗口硬上限约束)。
filler := strings.Repeat("填", b.ContextTokens/2+200)
if EstimateTokens(filler) <= b.ContextTokens {
t.Fatalf("测试前提不成立:填充(%d token) 应超过动态份额(%d)", EstimateTokens(filler), b.ContextTokens)
}
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最早的事件标记EARLY"})
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: filler})
child.context.Append(ContextEvent{Timestamp: time.Now(), Source: "sub/in", Input: "最新的事件标记LATE"})
child.io.InjectInput("sub/in", "text", map[string]interface{}{"content": "本轮输入"})
// 等**新的一轮**请求(首轮可能已经发过,必须严格等到注入之后那次)。
waitFor(t, "子发出新一轮 LLM 请求", func() bool {
n, _ := provider.chatText()
return n >= 2
})
_, got := provider.chatText()
if !strings.Contains(got, "最早的事件标记EARLY") {
t.Fatalf("传统上下文:更早的事件不得被预算裁掉(属于父 agent 的动态上下文能力);实收消息长度=%d", len(got))
}
if !strings.Contains(got, "最新的事件标记LATE") {
t.Fatal("最新事件必须在内")
}
}
// 对照:完整内核(根 agent仍走动态上下文按预算裁时间线
func TestFullKernel_StillUsesDynamicContext(t *testing.T) {
parent, _, _ := newRootForResidents(t)
if parent.isLightKernel() {
t.Fatal("根 agent 不是轻量内核")
}
b := ComputeTokenBudget(parent.provider, "sys")
if got := parent.contextTokenBudget(b); got != b.ContextTokens {
t.Fatalf("完整内核应使用动态上下文的 ContextTokens(%d),实际 %d", b.ContextTokens, got)
}
if b.MaxContext <= b.ContextTokens {
t.Fatalf("前提不成立:窗口(%d) 应大于动态上下文份额(%d)", b.MaxContext, b.ContextTokens)
}
}

View File

@ -0,0 +1,179 @@
package core
// 驻留子的**工具面**(对照设计 §7 控制面与 §8 处理表)。
//
// 单工具多动作:父侧一个 `resident_agents`list/create/send/inspect/compress/reclaim/destroy
// 子侧两个小工具:`notify_parent`L3 主动汇报)与 `inputch_note`(主动写处理表)。
import (
"fmt"
"log"
"path/filepath"
"strings"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
func strArg(tc agentAPI.ToolCall, key string) string {
s, _ := tc.Arguments[key].(string)
return strings.TrimSpace(s)
}
func splitArg(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// executeResidentAgents 是父的驻留子控制面(单工具多动作)。
func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
switch action := strArg(tc, "action"); action {
case "", "list":
list := a.Residents()
if len(list) == 0 {
return "当前没有驻留子 agent。"
}
var b strings.Builder
fmt.Fprintf(&b, "驻留子 agent%d 个):", len(list))
for _, r := range list {
fmt.Fprintf(&b, "\n - %s [%s] inputch=%v 轮次=%d 处理表=%d",
r.ID, r.State, r.InputChs, r.Rounds, r.TableSize)
if r.ContextFull {
b.WriteString(" ⚠️ contextfull")
}
}
return b.String()
case "create":
id := strArg(tc, "id")
tempPath := strArg(tc, "temp_path")
if tempPath == "" {
anchor := a.dataDir
if anchor == "" {
// 兜底:从**主图库路径**推导(<data>/memory/graph.db ⇒ <data>)。
// 为什么不静默失败:这条路径只在"配置漏接线"时走到,
// 静默报错会让线上表现为"工具能调但永远建不出来"(实测就是这样)。
if a.memory != nil && a.memory.Path() != "" {
anchor = filepath.Dir(filepath.Dir(a.memory.Path()))
log.Printf("[resident] data_dir 未接线,回退到主图库目录: %s", anchor)
}
}
if anchor == "" {
return "创建驻留子需要 data_dir 或显式 temp_path内核未接线 DataDir"
}
tempPath = filepath.Join(anchor, "residents", id, "graph.db")
}
info, err := a.SpawnResident(ResidentOptions{
ID: id,
TaskPrompt: strArg(tc, "task_prompt"),
InputChs: splitArg(strArg(tc, "input_chs")),
AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")),
Capacity: intArg(tc, "capacity"),
TempPath: tempPath,
})
if err != nil {
return fmt.Sprintf("创建驻留子失败: %v", err)
}
return "已创建驻留子: " + MarshalResidentInfo(info)
case "send":
if err := a.SendToResident(strArg(tc, "id"), strArg(tc, "text")); err != nil {
return fmt.Sprintf("发送失败: %v", err)
}
return "已发送(对子而言是 L4 中断)"
case "inspect":
id := strArg(tc, "id")
if id == "" {
return "inspect 需要 id或先用 action=list"
}
table, err := a.ResidentTable(id)
if err != nil {
return fmt.Sprintf("查看失败: %v", err)
}
var b strings.Builder
fmt.Fprintf(&b, "驻留子 %s 的 inputch 处理表(%d 条):", id, len(table))
for _, r := range table {
kind := "系统写"
if r.Proactive {
kind = "主动写"
}
fmt.Fprintf(&b, "\n - [%s][%s] %s", r.InputCh, kind, r.Text)
}
if len(table) == 0 {
b.WriteString("\n (尚无记录)")
}
return b.String()
case "compress":
n, err := a.CompressResident(strArg(tc, "id"))
if err != nil {
return fmt.Sprintf("压缩失败: %v", err)
}
return fmt.Sprintf("已压缩子 agent 上下文(丢弃 %d 条旧事件,并发清理其 inputch 处理表);子继续存在", n)
case "reclaim":
info, err := a.ReclaimResident(strArg(tc, "id"), reclaimKeepAll)
if err != nil {
return fmt.Sprintf("回收失败: %v", err)
}
return "已回收temp 中选中的记录已合入主记忆,该驻留子已取消): " + MarshalResidentInfo(info)
case "destroy":
if err := a.DestroyResident(strArg(tc, "id")); err != nil {
return fmt.Sprintf("销毁失败: %v", err)
}
return "已销毁并移除该驻留子"
default:
return fmt.Sprintf("未知 action=%q可用list | create | send | inspect | compress | reclaim | destroy", action)
}
}
// reclaimKeepAll 是回收时的默认策略:把子 temp 的活跃记录全部纳入主记忆
// "哪些纳入"由父的模型决定——这里给的是"全要"这一档)。
func reclaimKeepAll(_ []InputchRecord, triples []memory.Triple) []memory.Triple { return triples }
func intArg(tc agentAPI.ToolCall, key string) int {
switch v := tc.Arguments[key].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// executeNotifyParent 是子的"主动向父发消息"(父侧阶梯 = **L3 中断**)。
func (a *Agent) executeNotifyParent(tc agentAPI.ToolCall) string {
return a.notifyParentFrom(strArg(tc, "text"))
}
// executeInputchNote 是子"主动写入本轮 inputch 的处理信息"。
// 主动写过 ⇒ 本轮系统不再自动写(见 autoRecordInputch
func (a *Agent) executeInputchNote(tc agentAPI.ToolCall) string {
text := strArg(tc, "text")
if text == "" {
return "text 不能为空"
}
a.recordInputchNote(text)
return "已记录本轮 inputch 处理信息(本轮系统不会再自动写)"
}
// childInboundChannelHint 是给子看的"父会怎么把消息投给你"的提示(不参与调度)。
func childInboundChannelHint(a *Agent) string { return "sub/" + string(a.id) }
// residentTempDir 返回某个驻留子 temp 存储所在目录(销毁时连同目录丢弃)。
func residentTempDir(tempPath string) string { return filepath.Dir(tempPath) }
var _ = agentIO.InputChannel{} // 保持 agentIO 依赖(工具面未来会用通道登记)

View File

@ -0,0 +1,937 @@
package core
// 输入调度器:四级中断优先级 · 可抢占 · 现场保存/恢复。
//
// 设计依据 docs/zh/input-scheduler-design.md。
//
// # 模型(两类别 + 四级)
//
// 类别由**用哪个注入 API**决定,与通道名无关:
// - TaskInterruptInjectInterrupt* 注入。带级别 L1..L4可抢占
// 可被更高级中断打断(被打断的现场压入**中断栈**)。
// - TaskQueuedInjectText*/InjectInputSync* 与内核自循环。**无级别**
// 用于“不需及时处理”的场景,可被**任何**中断打断。
//
// 级别只属于中断:
// - L1..L3 由插件在 InjectOptions.Priority 里声明(见 clampPluginLevel
// - L4 给“立即打断”能力内核自身raiseKernelInterruptpanic / selfip
// 与**内核级插件**(编译期内置插件,如 WebUI 终止按钮)可声明;
// 外部插件经 proc 桥被夹到 L3core 里也再判一次来源。
//
// # 选择顺序
//
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
// 2. 中断队列 L4→L1同级 FIFO
// 3. 中断栈顶(与 2 的队头比级别,取高者;栈顶无级别时中断必胜)
// 4. 排队队列FIFO
//
// # 并发模型(不变量 I2
//
// queue/running/栈/stats 只由 schedulerLoop 与调度 goroutine 写;
// interruptLoop 只写中断登记与让位信号,**从不碰帧**。外部读取一律经
// DumpScheduler() 加锁取快照。
import (
"fmt"
"log"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// Level 是**中断**的优先级,由内核预定义四级。
//
// 语义:它衡量“这项工作有多不能等”,与具体通道名无关。
// 插件在中断注入时通过 InjectOptions.Priority 声明 L1..L3
// **L4 由内核独占**panic、内核事件 selfip插件声明 L4 会被夹到 L3。
//
// 排队输入InjectText* / InjectInputSync***没有级别**:它们本就是
// “不需及时处理”的那一类,可被任何中断打断(见 TaskClass
type Level int
const (
// LevelBackground L1完全可等。例QQ/微信这类异步消息、批量通知。
LevelBackground Level = 1
// LevelMessage L2一般提醒。例插件希望尽快看到、但不紧急的提示。
LevelMessage Level = 2
// LevelInteractive L3需及时处理。例时钟/定时器到达、终端输出、交互输入。
LevelInteractive Level = 3
// LevelCritical L4**内核独占**。panic 中断、内核事件中断selfip
// 插件不得声明此级。
LevelCritical Level = 4
)
// DefaultLevel 是未显式声明时的中断级别。
//
// 取最低级是刻意的:**显式才是特权**,新插件不会默认拿到抢占权。
const DefaultLevel = LevelBackground
// clampPluginLevel 把**非内核级**来源声明的级别夹到 L1..L3。
//
// L4 是“立即打断”能力panic / 内核事件 / 内核级插件的终止按钮),
// 只给内核与编译期内置插件;外部插件声明 L4 会被夹到 L3。
func clampPluginLevel(l Level) Level {
if l < LevelBackground {
return DefaultLevel
}
if l > LevelInteractive {
return LevelInteractive
}
return l
}
func (l Level) String() string {
switch l {
case LevelBackground:
return "L1-background"
case LevelMessage:
return "L2-message"
case LevelInteractive:
return "L3-interactive"
case LevelCritical:
return "L4-critical"
default:
return "L?-unknown"
}
}
// ParseLevel 已删除。
//
// 为何不保留:优先级是**内核内部属性**,不是配置项。
// 曾一度做成 `core.agent.priority.<channel>`(配置中心可见),
// 那等于把内核的调度内部属性外化成运维配置,与设计意图相反。
//
// 现在级别的来源只有两个(见 Task/Level 注释):
// - 插件在中断注入时声明InjectOptions.PriorityL1..L3
// - 内核内部产生 L4panic / selfip
// TaskClass 是任务的两大类别——**由“用哪个注入 API”决定与通道名无关**。
//
// 这是模型的核心区分:
// - InjectInterrupt* → TaskInterrupt带级别可抢占可被更高级中断打断→ 中断栈)
// - InjectText* / InjectInputSync* / 内核自循环 → TaskQueued无级别
// 可被**任何**中断打断(“用于不需要及时处理的场景”)
type TaskClass int
const (
TaskQueued TaskClass = iota
TaskInterrupt
)
func (c TaskClass) String() string {
switch c {
case TaskQueued:
return "queued"
case TaskInterrupt:
return "interrupt"
default:
return "unknown"
}
}
// TaskKind 区分任务来源。
type TaskKind int
const (
// TaskKindInput 来自 io.InputChan外部/插件注入的输入)。
TaskKindInput TaskKind = iota
// TaskKindSelf 来自 selfInputCh内核自循环记忆整理、子任务通知
TaskKindSelf
)
func (k TaskKind) String() string {
switch k {
case TaskKindInput:
return "input"
case TaskKindSelf:
return "self"
default:
return "unknown"
}
}
// Task 是调度器的最小单位。
type Task struct {
ID uint64
Class TaskClass
// Level 仅对 TaskInterrupt 有意义TaskQueued 恒为 0无级别
Level Level
Kind TaskKind
EnqueuedAt time.Time
Event *agentIO.InputEvent // Kind == TaskKindInput
Self selfInputMsg // Kind == TaskKindSelf
// PreemptCount 是本任务被抢占的次数,用于饥饿防护:
// effectiveLevel = min(L4, Level + min(PreemptCount, 2))。
PreemptCount int
// LastPreemptAt 是上次被抢占的时刻,用于抢占冷却。
LastPreemptAt time.Time
}
// preemptPromotionCap 是抢占计数能带来的最大提升档数。
const preemptPromotionCap = 2
// preemptCooldown 是“刚被抢占过”的冷却期:期内不再被抢占,
// 避免高优先级流把同一任务反复打断到永不完结。
const preemptCooldown = 2 * time.Second
// effectiveLevel 返回任务的**有效**级别。
//
// 排队输入恒为 0无级别任何中断≥ L1都大于它——这正好实现
// “排队输入可被任何中断打断”。
//
// 中断则叠加饥饿防护:被抢占越多的中断越“值钱”,逐步追上抢占它的流;
// 封顶 L4因此它永远不会反过来抢占内核紧急中断。
func effectiveLevel(t *Task) Level {
if t.Class != TaskInterrupt {
return 0
}
p := t.PreemptCount
if p > preemptPromotionCap {
p = preemptPromotionCap
}
l := t.Level + Level(p)
if l > LevelCritical {
l = LevelCritical
}
return l
}
// canPreempt 是唯一的抢占判据。
//
// 由于 effectiveLevel(排队)=0这一个比较同时覆盖两条规则
// - running 是排队任务 → 任何中断≥L1都能抢占
// - running 是中断 Li → 只有 Lj > Li 的中断能抢占(严格大于)。
func canPreempt(incoming, running *Task) bool {
if incoming == nil || running == nil {
return false
}
if incoming.Class != TaskInterrupt {
return false // 排队输入从不抢占
}
return effectiveLevel(incoming) > effectiveLevel(running)
}
// SchedulerStats 是调度器的累计计数(可观测性,设计文档 §11 O2
//
// InterruptsByLevel / PreemptsByLevel 按**中断级别**分桶(下标 1..4
// “各级中断各登记了多少、各真正抢断了多少次”。按级别验收(而不是只看总数)
// 是这套调度器的核心判据——总数相同、级别分布不同,行为完全不同。
type SchedulerStats struct {
Enqueued uint64
Executed uint64
// Rejected 是因队列满(或深度超限)而未被接纳的次数。
Rejected uint64
// Suspended / Resumed 是挂起与恢复的次数。
// 不变量:系统排空后 Suspended == Resumed挂起必然被恢复
// 因此两者各自只在**一处**计数suspend / resumeTask
Suspended uint64
Resumed uint64
// InterruptsByLevel[1..4]:各级中断被**登记**的次数(含未抢占成功的)。
InterruptsByLevel [5]uint64
// PreemptsByLevel[1..4]:各级中断**判定为可抢占并进入 immediate**的次数。
// 注意它与 Suspended 不等价:受害者可能在让位信号生效前就自行结束,
// 此时抢占者仍然"下一个运行",但没有挂起发生。
PreemptsByLevel [5]uint64
}
// bumpInterruptLevel 按级别累加(级别必须落在 1..4,否则忽略——
// 排队任务没有级别,不该出现在中断计数里)。
func (st *SchedulerStats) bumpInterruptLevel(dst *[5]uint64, lv Level) {
if lv >= LevelBackground && lv <= LevelCritical {
dst[lv]++
}
}
// SchedulerSnapshot 是调度器的原子快照。
type SchedulerSnapshot struct {
Running *Task
// Queue 是排队输入队列无级别FIFO
Queue []*Task
// InterruptQueues[level] 是四条中断队列(下标 1..4,同级 FIFO
InterruptQueues [5][]*Task
// Immediate 是刚抢占成功、将在下一个安全点立即运行的中断(最多一个)。
Immediate *Task
// PendingInterrupts = 四条中断队列 + Immediate对外的待处理中断总数视图
PendingInterrupts []*Task
// SuspendStack中断栈含嵌套抢占的多个现场**栈顶**优先恢复。
SuspendStack []*suspendedTask
Stats SchedulerStats
// MaxInterruptFrames 是中断栈帧数的结构上界(= 中断级数,不是配置项)。
MaxInterruptFrames int
}
// schedulerStatus 把快照转成对外的状态 DTO不暴露帧内容
func (a *Agent) schedulerStatus() sdk.SchedulerStatus {
if a.sched == nil {
return sdk.SchedulerStatus{}
}
snap := a.DumpScheduler()
out := sdk.SchedulerStatus{
ReadyQueueDepth: len(snap.Queue),
PendingInterrupts: len(snap.PendingInterrupts),
SuspendStack: len(snap.SuspendStack),
MaxSuspendDepth: snap.MaxInterruptFrames,
Enqueued: snap.Stats.Enqueued,
Executed: snap.Stats.Executed,
Rejected: snap.Stats.Rejected,
Suspended: snap.Stats.Suspended,
Resumed: snap.Stats.Resumed,
Preempted: snap.Stats.Suspended,
}
if snap.Running != nil {
out.Running = &sdk.SchedulerTask{
ID: snap.Running.ID, Level: int(snap.Running.Level), Kind: snap.Running.Kind.String(),
}
}
return out
}
type scheduler struct {
mu sync.Mutex
queue []*Task
running *Task
seq uint64
stats SchedulerStats
maxQueue int
// interruptQueues[level]:四条**中断队列**level 1..4),同级 FIFO。
// 未能立即抢占的中断(级别不足,或运行任务在临界区)按级别入队,
// nextRef 从 L4 到 L1 依次扫描。
interruptQueues [5][]*Task
// immediate刚抢占成功的中断。抢占必须**立即生效**,所以它不经队列,
// 在下一个安全点直接运行。这也消除了“抢占者与被抢占者同级”的比较问题——
// 抢占者根本不需要和栈顶比。
immediate *Task
// suspendStack**中断栈**。被抢占后保存现场的任务压栈LIFO
// 用于“中断被中断”的嵌套场景:只有**栈顶**参与恢复选择,栈内不做优先级重排。
suspendStack []*suspendedTask
// preemptArmed/preemptLevel运行任务的“让位信号”。
// interruptLoop 只写这两个字段与 pendingInterrupts帧永远只由调度器读写。
preemptArmed bool
preemptLevel Level
// critical 报告运行任务是否在不可抢占临界区(如记忆整理)。
// 由于 interceptLoop 要读它,必须是原子的:帧仍只由调度器读写。
critical atomic.Bool
// wake 用于把空闲的调度器叫醒pendingInterrupts 不是 channel
// 没有这个信号时“空闲时到达的中断”会一直等下一次输入(设计 §5.1 ③)。
wake chan struct{}
// maxInterruptFrames中断栈帧数的**结构上界**,不是配置项。
//
// 链条 = 排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)
// 被挂起 4 帧L4 之上没有更高级别,链到此为止。超限只可能是内核 bug
// 因此这里只做防御性计数,**不降级、不丢弃帧**。
maxInterruptFrames int
}
// suspendedTask 是一个被抢占任务的现场。
type suspendedTask struct {
Task *Task
Frame *TaskFrame
}
// nextSelection 标识 nextRef 从哪个集合取出任务。
type nextSelection int
const (
nextNone nextSelection = iota
nextReady
nextInterrupt
nextImmediate
nextSuspended
)
func newScheduler(maxQueue int) *scheduler {
if maxQueue <= 0 {
maxQueue = 256
}
return &scheduler{
maxQueue: maxQueue,
maxInterruptFrames: int(LevelCritical), // 结构推论:= 中断级数
wake: make(chan struct{}, 1),
}
}
// signalWake 非阻塞地唤醒调度器。
func (s *scheduler) signalWake() {
select {
case s.wake <- struct{}{}:
default:
}
}
// setCritical 由调度器 goroutine 在任务进入/离开临界区时设置。
func (s *scheduler) setCritical(v bool) { s.critical.Store(v) }
// inCritical 报告运行任务是否在不可抢占临界区。
func (s *scheduler) inCritical() bool { return s.critical.Load() }
// hasRoom 报告排队队列是否还能接收任务。泵入侧据此节流:
// 队列满则停止从 channel 取,让背压落回 channel 本身。
func (s *scheduler) hasRoom() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.queue) < s.maxQueue
}
// allocateIDLocked 分配任务 ID 与入队时刻(调用方持锁)。
func (s *scheduler) allocateIDLocked(t *Task) {
s.seq++
t.ID = s.seq
if t.EnqueuedAt.IsZero() {
t.EnqueuedAt = time.Now()
}
}
// enqueue 把一个**排队输入**入队;队列满返回 false调用方负责计数
func (s *scheduler) enqueue(t *Task) bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.queue) >= s.maxQueue {
s.stats.Rejected++
return false
}
s.allocateIDLocked(t)
s.stats.Enqueued++
s.queue = append(s.queue, t)
return true
}
// next 取出下一个要执行的任务;队列空返回 nil。
//
// 保留该签名供已有测试使用;调度器自用 nextRef需要区分是否携带现场
func (s *scheduler) next() *Task {
t, _, _ := s.nextRef()
return t
}
// nextRef 选出下一个任务。优先顺序:
//
// 1. immediate —— 刚抢占成功的中断(抢占必须立即生效)
// 2. 中断队列 L4→L1同级 FIFO
// 3. 中断栈顶(与 2 比级别取高者;栈顶是排队任务时视为最低)
// 4. 排队队列FIFO
func (s *scheduler) nextRef() (*Task, *TaskFrame, nextSelection) {
s.mu.Lock()
defer s.mu.Unlock()
if s.immediate != nil {
t := s.immediate
s.immediate = nil
s.running = t
return t, nil, nextImmediate
}
qTask, qLevel := s.highestInterruptLocked()
// 中断栈:只比**栈顶**(严格 LIFO。栈内不做优先级重排——
// 嵌套抢占天然使栈自底向上级别递增,且“后被打断的先恢复”才是栈语义。
if n := len(s.suspendStack); n > 0 {
top := s.suspendStack[n-1]
// 栈顶 vs 最高级待处理中断:取高者(持平归栈顶,维持 LIFO 与公平)。
if qTask == nil || effectiveLevel(top.Task) >= qLevel {
// 这里只负责“选出”Resumed 由 resumeTask 计一次(否则会双计,
// 使“排空后 Suspended == Resumed”这条不变量失真
s.suspendStack = s.suspendStack[:n-1]
s.running = top.Task
return top.Task, top.Frame, nextSuspended
}
}
if qTask != nil {
s.popInterruptLocked(qLevel)
s.running = qTask
return qTask, nil, nextInterrupt
}
if len(s.queue) > 0 {
t := s.queue[0]
s.queue = s.queue[1:]
s.running = t
return t, nil, nextReady
}
return nil, nil, nextNone
}
// highestInterruptLocked 返回当前最高级非空中断队列的队头及其级别。
func (s *scheduler) highestInterruptLocked() (*Task, Level) {
for lv := LevelCritical; lv >= LevelBackground; lv-- {
if q := s.interruptQueues[lv]; len(q) > 0 {
return q[0], lv
}
}
return nil, 0
}
// popInterruptLocked 弹出某级别中断队列的队头(调用方已确认非空)。
func (s *scheduler) popInterruptLocked(lv Level) {
s.interruptQueues[lv] = s.interruptQueues[lv][1:]
}
// interruptCountLocked 统计所有待处理中断(含 immediate 槽)。
func (s *scheduler) interruptCountLocked() int {
n := 0
for lv := LevelBackground; lv <= LevelCritical; lv++ {
n += len(s.interruptQueues[lv])
}
if s.immediate != nil {
n++
}
return n
}
// setImmediateLocked 登记一个应“立即运行”的抢占者。
//
// 槽只有一格:若已有抢占者且新的级别更高,旧的降级入队;否则新的入队。
func (s *scheduler) setImmediateLocked(t *Task) {
if s.immediate != nil && effectiveLevel(t) <= effectiveLevel(s.immediate) {
s.enqueueInterruptLocked(t)
return
}
if s.immediate != nil {
s.enqueueInterruptLocked(s.immediate)
}
s.allocateIDLocked(t)
s.stats.Enqueued++
s.immediate = t
}
func removeTask(list []*Task, target *Task) []*Task {
for i, t := range list {
if t == target {
return append(list[:i], list[i+1:]...)
}
}
return list
}
// enqueueInterruptLocked 把一个未立即抢占的中断按其级别入队(调用方持锁)。
//
// 有界:满了丢**最老**的一条并计数(中断是提示性输入,宁可丢旧保新)。
func (s *scheduler) enqueueInterruptLocked(t *Task) {
s.allocateIDLocked(t)
if s.interruptCountLocked() >= s.maxQueue {
for lv := LevelBackground; lv <= LevelCritical; lv++ {
if len(s.interruptQueues[lv]) > 0 {
s.popInterruptLocked(lv)
s.stats.Rejected++
break
}
}
}
lv := t.Level
if lv < LevelBackground || lv > LevelCritical {
lv = DefaultLevel
}
s.interruptQueues[lv] = append(s.interruptQueues[lv], t)
s.stats.Enqueued++
}
// requestPreempt 登记一次中断请求class=TaskInterrupt
//
// 返回 true 表示“应该尝试取消运行任务正在进行的可取消步骤LLM 流式)”。
//
// 判据是 canPreempt由优先级级别系统一承担并受抢占冷却约束
// - running 是排队任务 → 任何中断都抢占;
// - running 是中断 Li → 仅 Lj > Li 的中断抢占。
//
// 能抢占时把中断放进 immediate立即生效否则按其级别入队等当前任务
// 结束或下一个安全点再处理——无论哪种,中断都不会丢。
//
// 临界区(如记忆整理)内不 arm、不取消中断只入队等临界区结束后的安全点处理
// 这是设计 §4.3 的硬要求——那个位置的“不抢占”不能只是不让位,还必须不取消。
// level 必须是**已解析好**的中断级别(含特权判定):
// 生产路径只有 interruptLoop它用 (*Agent).interruptLevel 得出 level
// 内核自身用 requestKernelPreempt固定 L4。本函数不再夹取
// 否则内核级插件的 L4 会被无辜削掉。
func (s *scheduler) requestPreempt(evt *agentIO.InputEvent, level Level) bool {
return s.registerInterrupt(newInterruptTask(evt, level))
}
// requestKernelPreempt 是**内核**中断入口panic / 内核事件 selfip
//
// 级别固定 L4且**不夹取**——这是 L4 的唯一来源,插件永远够不到。
func (s *scheduler) requestKernelPreempt(evt *agentIO.InputEvent) bool {
return s.registerInterrupt(newKernelInterruptTask(evt))
}
// registerInterrupt 是登记中断的公共实现(任务已带好 Class/Level
func (s *scheduler) registerInterrupt(t *Task) bool {
s.mu.Lock()
running := s.running
critical := s.critical.Load()
s.stats.bumpInterruptLevel(&s.stats.InterruptsByLevel, t.Level)
arm := false
if !critical && canPreempt(t, running) {
if running.LastPreemptAt.IsZero() || time.Since(running.LastPreemptAt) >= preemptCooldown {
arm = true
s.preemptArmed = true
s.preemptLevel = t.Level
s.stats.bumpInterruptLevel(&s.stats.PreemptsByLevel, t.Level)
s.setImmediateLocked(t)
}
}
if !arm {
s.enqueueInterruptLocked(t)
}
s.mu.Unlock()
if !arm {
s.signalWake()
}
return arm
}
// preemptGrantedFor 报告运行任务是否应在当前安全点让位。
func (s *scheduler) preemptGrantedFor() bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.preemptArmed || s.running == nil {
return false
}
return s.preemptLevel > effectiveLevel(s.running)
}
func (s *scheduler) clearPreempt() {
s.mu.Lock()
s.preemptArmed = false
s.preemptLevel = 0
s.mu.Unlock()
}
// suspend 保存现场。
//
// 深度上界是**结构推论**= 中断级数),不是配置项:安全点上的 canSuspend 已提前
// 拦下超限情况,此处仅在竞态下兜底计数——绝不丢弃帧(帧丢了会丢副作用记录)。
func (s *scheduler) suspend(t *Task, f *TaskFrame) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.suspendStack) >= s.maxInterruptFrames {
s.stats.Rejected++
}
s.suspendStack = append(s.suspendStack, &suspendedTask{Task: t, Frame: f})
s.stats.Suspended++
// 饥饿防护:抢占计数 +1提升有效级并记录冷却起点。
t.PreemptCount++
t.LastPreemptAt = time.Now()
if s.running == t {
s.running = nil
}
// D1=B中断任务在上一个任务之前的完整状态上开始运行
// 因此这里**不**把被打断任务的任何内容交给它。
s.preemptArmed = false
s.preemptLevel = 0
}
// canSuspend 报告还有下潜余量(安全点用它决定是否真的让位)。
func (s *scheduler) canSuspend() bool {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.suspendStack) < s.maxInterruptFrames
}
// done 标记任务执行结束。
func (s *scheduler) done(t *Task) {
s.mu.Lock()
defer s.mu.Unlock()
if s.running == t {
s.running = nil
}
// 任务正常结束:让位信号不再有意义(中断已在中断队列/immediate 里)。
s.preemptArmed = false
s.preemptLevel = 0
s.stats.Executed++
}
// currentLevel 返回当前正在执行任务的级别;无 running 时为默认级。
//
// 用于在 prepare 段把级别写进帧(抢占比较的基准)。
// interruptLevel 返回一次**中断注入**的级别。
//
// 级别是“这项工作有多不能等”,由来源在 InjectOptions.Priority 里声明
// (排队注入没有级别,它们的 TaskClass 是 TaskQueued
//
// privileged 表示来源是**内核级插件**(编译期内置插件,见 isKernelLevelSource
// - privileged=true → 可用到 L4实现“立即打断”如 WebUI 终止按钮)
// - privileged=false → 夹到 L1..L3空/非法一律降级为 DefaultLevelL1
//
// 另有完全绕过本函数的 L4 来源:内核自身的 raiseKernelInterruptpanic / selfip
func interruptLevel(evt *agentIO.InputEvent, privileged bool) Level {
if evt == nil || evt.Payload == nil {
return DefaultLevel
}
raw, _ := evt.Payload["priority"].(string)
l, ok := parseInterruptLevel(raw)
if !ok {
return DefaultLevel
}
if privileged {
return l
}
return clampPluginLevel(l)
}
// isKernelLevelSource 报告某来源是否是**内核级插件**(编译期内置插件)。
//
// 只有它们能声明 L4见 interruptLevel。判据是插件注册表里的“内置工厂”
// 而不是插件自报的名字本身——外部插件经 proc 桥时已被夹到 L3这里是第二道闸。
//
// source 的约定是 `插件名` 或 `插件名/实例`(如 webui/<deviceID>),故取第一段。
func (a *Agent) isKernelLevelSource(source string) bool {
if source == "" {
return false
}
name := source
if i := strings.IndexByte(name, '/'); i > 0 {
name = name[:i]
}
// ① 编译期内置插件(根 agent 的 L4 来源之一)。
if a.pluginReg != nil && a.pluginReg.IsBuiltinPlugin(name) {
return true
}
// ② **本 agent 的上级**(驻留子的父)—— 设计 §6.1 的 L4 通则:
// 子的阶梯上只有父能产生 L4所以父的"发送消息"一定能打断子。
return a.kernelSource != "" && name == a.kernelSource
}
// parseInterruptLevel 解析插件声明的级别字符串("L1".."L3")。
// 只认字面量:拼写错误必须降级成默认级而不是被静默当成别的级别。
func parseInterruptLevel(s string) (Level, bool) {
switch s {
case "L1", "l1":
return LevelBackground, true
case "L2", "l2":
return LevelMessage, true
case "L3", "l3":
return LevelInteractive, true
case "L4", "l4":
// 内核级:解析出来但会被 clamp 夹到 L3。
return LevelCritical, true
default:
return 0, false
}
}
// newInputTask 把一个**排队输入**包装成任务(无级别)。
func newInputTask(evt *agentIO.InputEvent) *Task {
return &Task{Class: TaskQueued, Kind: TaskKindInput, Event: evt, EnqueuedAt: time.Now()}
}
// newInterruptTask 把一个中断请求包装成任务(带级别)。
func newInterruptTask(evt *agentIO.InputEvent, level Level) *Task {
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: level, Event: evt, EnqueuedAt: time.Now()}
}
// newSelfTask 包装内核自循环输入——它是**排队任务**:记忆整理/子任务通知
// 不需要及时处理,可被任何中断打断。
func newSelfTask(msg selfInputMsg) *Task {
return &Task{Class: TaskQueued, Kind: TaskKindSelf, Self: msg, EnqueuedAt: time.Now()}
}
// newKernelInterruptTask 构造一个**内核级中断**L4
//
// 这是 L4 的唯一来源panic 中断、内核事件中断selfip
// 插件永远拿不到这个入口——它不经 InjectOptions也不经 proc 桥。
func newKernelInterruptTask(evt *agentIO.InputEvent) *Task {
return &Task{Class: TaskInterrupt, Kind: TaskKindInput, Level: LevelCritical, Event: evt, EnqueuedAt: time.Now()}
}
// DumpScheduler 返回调度器的原子快照(供状态页/测试断言)。
func (a *Agent) DumpScheduler() SchedulerSnapshot {
if a.sched == nil {
return SchedulerSnapshot{}
}
a.sched.mu.Lock()
defer a.sched.mu.Unlock()
snap := SchedulerSnapshot{Running: a.sched.running, Stats: a.sched.stats}
snap.Queue = append(snap.Queue, a.sched.queue...)
snap.Immediate = a.sched.immediate
for lv := LevelBackground; lv <= LevelCritical; lv++ {
snap.InterruptQueues[lv] = append(snap.InterruptQueues[lv], a.sched.interruptQueues[lv]...)
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.interruptQueues[lv]...)
}
if a.sched.immediate != nil {
snap.PendingInterrupts = append(snap.PendingInterrupts, a.sched.immediate)
}
snap.SuspendStack = append(snap.SuspendStack, a.sched.suspendStack...)
snap.MaxInterruptFrames = a.sched.maxInterruptFrames
return snap
}
// schedulerLoop 是唯一的任务执行者(取代原 eventLoop 的输入处理)。
func (a *Agent) schedulerLoop() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] schedulerLoop panic recovered: %v\n%s", r, debug.Stack())
time.Sleep(time.Second)
go a.schedulerLoop()
}
}()
for {
a.pumpInbox()
t, f, kind := a.sched.nextRef()
if kind == nextNone {
// 无待办阻塞等新输入、新中断wake或退出。
select {
case evt := <-a.io.InputChan():
a.sched.enqueue(newInputTask(evt))
case msg := <-a.selfInputCh:
a.sched.enqueue(newSelfTask(msg))
case <-a.sched.wake:
// 中断已入 pendingInterrupts回到循环顶部重新挑选。
case <-a.ctx.Done():
return
}
continue
}
if kind == nextSuspended {
a.resumeTask(t, f)
continue
}
a.executeNewTask(t)
}
}
// pumpInbox 把 channel 里**已就绪**的输入搬进就绪队列(非阻塞)。
//
// 为什么不直接边收边执行:先把已到达的输入收进队列,选择函数才有意义——
// M3 起抢占必然要看"队列里还压着什么",而 channel 不是可枚举的结构。
//
// 队列满即停止泵入(背压落回 channel语义与设计文档 §4.4 一致)。
func (a *Agent) pumpInbox() {
for a.sched.hasRoom() {
select {
case evt := <-a.io.InputChan():
a.sched.enqueue(newInputTask(evt))
case msg := <-a.selfInputCh:
a.sched.enqueue(newSelfTask(msg))
case <-a.ctx.Done():
return
default:
return
}
}
}
// executeTask 执行一个任务(测试与旧调用方的入口);见 executeNewTask。
// raiseKernelInterrupt 是 **L4 的唯一入口**panic 中断与内核事件中断selfip
//
// 它不经 io.InputChan那是外部/插件输入),而是直接向调度器登记一条内核中断:
// 级别固定 L4、不夹取、不受插件声明影响。这正是“L4 只有内核持有”的落点。
//
// 能否抢占由调度器按统一判据决定;若会抢占,则顺手取消可取消的 LLM 流式步骤
// (与 interceptLoop 对插件中断的处理完全一致)。
func (a *Agent) raiseKernelInterrupt(source, channel, text string) {
if a.sched == nil {
return
}
evt := &agentIO.InputEvent{
Source: source,
Type: "interrupt",
OutputChannel: channel,
Payload: map[string]interface{}{
"content": text,
"interrupt": true,
"interrupt_source": source,
"interrupt_channel": channel,
"kernel": true,
},
}
if a.sched.requestKernelPreempt(evt) {
a.cancelCurrentLLM()
}
}
// reportTaskPanic 把一个任务 panic 报告成内核 L4 中断。
//
// 递归保护是**结构性**的:若 panic 的任务本身就是 L4 内核中断,则不再产生新的
// L4——否则同一个 panic 会自我放大成中断风暴,与“内核事件”应有的语义相反。
func (a *Agent) reportTaskPanic(t *Task, r interface{}) {
if t.Class == TaskInterrupt && t.Level >= LevelCritical {
return
}
a.raiseKernelInterrupt("kernel", "kernel",
fmt.Sprintf("内核事件:任务 #%d 发生 panic%v该任务已被丢弃调度器存活", t.ID, r))
}
func (a *Agent) executeTask(t *Task) {
a.executeNewTask(t)
}
// executeNewTask 执行一个**新建**任务,并做任务级 panic 隔离(不变量 I6
//
// 与改造前的差异(有意):原 eventLoop 在 panic 后重启整个循环,
// 现在一个任务的 panic 只丢弃该任务,调度器与其它任务不受影响。
func (a *Agent) executeNewTask(t *Task) {
var f *TaskFrame
var out stepOutcome = outcomeDone
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] task#%d (%s) panic recovered: %v\n%s",
t.ID, t.Level, r, debug.Stack())
a.reportTaskPanic(t, r)
}
}()
switch t.Kind {
case TaskKindInput:
f, out = a.runInputTask(t.Event)
case TaskKindSelf:
f, out = a.runInputTask(selfEvent(t.Self))
}
}()
if out == outcomeSuspended && f != nil {
a.sched.suspend(t, f)
a.publishEvent(events.EventScheduler, map[string]interface{}{
"action": "suspend", "task": t.ID, "level": int(t.Level),
})
return
}
a.sched.done(t)
}
// resumeTask 从保存的现场继续一个被抢占的任务。
//
// 关键:不重建帧、不重跑 prepare 段——否则会重复提交上下文与事件。
// resumeTask 从保存的现场继续一个被抢占的任务。
//
// 关键:不重跑 prepare 段(否则会重复提交上下文与事件),而是先把基础前缀
// 重建到「中断任务之上」,再把本任务自己的现场接回去(见 rebaseFramePrefix
func (a *Agent) resumeTask(t *Task, f *TaskFrame) {
a.sched.mu.Lock()
a.sched.stats.Resumed++
a.sched.mu.Unlock()
a.publishEvent(events.EventScheduler, map[string]interface{}{
"action": "resume", "task": t.ID, "level": int(t.Level),
})
a.rebaseFramePrefix(f)
defer func() {
if r := recover(); r != nil {
log.Printf("[agent] resume task#%d panic recovered: %v\n%s",
t.ID, r, debug.Stack())
a.reportTaskPanic(t, r)
a.sched.done(t)
}
}()
out := a.runTaskSteps(f)
if out == outcomeSuspended {
a.sched.suspend(t, f)
return
}
a.finishInputTask(f, out)
a.sched.done(t)
}

View File

@ -0,0 +1,91 @@
package core
// 抢占场景下的**输出通道路由**:被打断任务恢复后,回复必须回到它自己的输出通道。
//
// 回归判据(做驻留式子 agent 前必须成立):**内核不持有"当前通道"可变状态**。
// 曾经有 agent 级字段 a.currentOutputChannel只在 prepare 段写入,而被打断任务
// 恢复时不重新 prepareresumeTask 只 rebase 前缀),于是中断任务 prepare 时把它
// 覆盖成自己的通道,被恢复的任务再把回复发到**中断任务的通道**上——两任务串台。
// N0 已删除该字段:通道一律从输入事件/帧推导outputChannelOf / f.OutputChannel
//
// 每任务回执evt.ResponseChTarget=evt.Source不受影响所以既有测试全绿
// 但按通道投递OutputEvent.OutputChannel / events.EventAgentOutput 的 channel
// 是插件渲染给用户的路径,它会串。
import (
"testing"
"time"
)
func TestPreempt_ResumeKeepsOwnOutputChannel(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
// 排队任务,来源与输出通道都是 qq。
lowEvt, lowCh := textEvent("qq", "低优先级任务")
lowTask := newInputTask(lowEvt)
if !a.sched.enqueue(lowTask) {
t.Fatal("入队失败")
}
if lowEvt.OutputChannel != "qq" {
t.Fatalf("前置条件不成立OutputChannel=%q", lowEvt.OutputChannel)
}
lt, _, kind := a.sched.nextRef()
if kind != nextReady {
t.Fatalf("应取到排队任务kind=%v", kind)
}
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 未被调用")
}
// cli 中断抢占L3任务被挂起。
intrEvt, intrCh := textEvent("cli", "紧急打断")
intrEvt.Payload["interrupt"] = true
if !a.sched.requestPreempt(intrEvt, LevelInteractive) {
t.Fatal("L3 中断应能抢占排队任务")
}
a.cancelCurrentLLM()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("被打断任务未挂起")
}
// 中断任务先运行(它会 prepare通道是 cli
it, _, k := a.sched.nextRef()
if k != nextImmediate {
t.Fatalf("应取到立即运行的中断kind=%v", k)
}
a.executeNewTask(it)
if len(intrCh) != 1 {
t.Fatalf("中断任务应回执一次,实际 %d", len(intrCh))
}
// 注意:这里**刻意**不再有任何"内核当前通道"可断言 —— 该字段已删除,
// 通道只跟着输入事件与帧走。下面断言的就是这个性质本身。
// 恢复被抢占任务:它不重新 prepare只能靠帧里记着自己的通道。
rt, rf, k2 := a.sched.nextRef()
if k2 != nextSuspended {
t.Fatalf("应恢复被抢占任务kind=%v", k2)
}
a.resumeTask(rt, rf)
select {
case out := <-lowCh:
if out.OutputChannel != "qq" {
t.Fatalf("被打断任务恢复后的输出通道=%q期望 qq —— 被中断任务的通道覆盖了 agent 级字段(两任务串台)",
out.OutputChannel)
}
if out.Target != "qq" {
t.Fatalf("回执 Target=%q期望 qq", out.Target)
}
case <-time.After(3 * time.Second):
t.Fatal("被恢复任务未回执")
}
}

View File

@ -0,0 +1,167 @@
package core
// M4 验收测试:临界区语义显式化 + 抢占延迟到安全点 + 批次不再被中断放弃。
//
// 设计依据 docs/zh/input-scheduler-design.md §4.3临界区、§11.1P5/P6
//
// 关键结构事实:让位检查**只在 step 之间**进行,因此任何正在执行的 step
// (工具 RPC、ONNX、CAS 落盘)天然不可抢占——中断只能等它返回。
import (
"sync"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// P5/P6工具执行期间到达的高优先级中断不得立即抢占必须等工具返回后的安全点。
func TestPreempt_DeferredDuringToolExec(t *testing.T) {
sh := NewStageHost()
entered := make(chan struct{})
release := make(chan struct{})
var once sync.Once
sh.RegisterTool("t_slow", sdk.ToolDef{Name: "t_slow", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
once.Do(func() { close(entered) })
<-release
return "slow-done", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_slow")}}, // 低优先级任务调用慢工具
{Content: "low-done"}, // 恢复后收尾
{Content: "intr-done"}, // 中断任务
}}
a := New(AgentConfig{
ID: "crit",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: sh,
})
if _, _ = enqueueQueued(t, a, "qq", "低优先级任务"); true {
}
lt, _, _ := a.sched.nextRef()
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("慢工具未被调用")
}
// 工具执行中注入 L4 中断。
intrEvt, _ := textEvent("cli", "紧急打断")
intrEvt.Payload["interrupt"] = true
if !a.sched.requestKernelPreempt(intrEvt) {
t.Fatal("L4 应 arm 让位信号")
}
// 关键断言:信号已 arm但任务仍在工具里 —— 绝不能挂起。
if !a.sched.preemptGrantedFor() {
t.Fatal("让位信号应已 arm")
}
if a.DumpScheduler().Running == nil {
t.Fatal("工具执行中不得挂起StepToolExec 是临界区)")
}
if len(a.DumpScheduler().SuspendStack) != 0 {
t.Fatal("工具执行中 suspendStack 应为空")
}
// 放行工具 → 工具返回后的安全点才挂起。
close(release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("工具返回后未挂起")
}
snap := a.DumpScheduler()
if len(snap.SuspendStack) != 1 {
t.Fatalf("工具返回后 suspendStack=%d期望 1", len(snap.SuspendStack))
}
if snap.SuspendStack[0].Frame.Step != StepToolAfter {
t.Fatalf("应在工具执行后的安全点挂起StepToolAfter实际 %v", snap.SuspendStack[0].Frame.Step)
}
if len(snap.PendingInterrupts) != 1 {
t.Fatalf("中断请求不得丢失pendingInterrupts=%d", len(snap.PendingInterrupts))
}
}
// _consolidation_ 整任务视为不可抢占(它直接改图库)。
func TestCriticalSection_ConsolidationMarked(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
// 判定:只有记忆整理通道是整任务临界区。
if isCriticalChannel("cli") {
t.Fatal("普通通道不应被判为临界区")
}
if !isCriticalChannel(channelConsolidation) {
t.Fatal("记忆整理必须是不可抢占临界区")
}
// 集成:标志的推导链「输入事件 → 通道 → isCriticalChannel → scheduler.critical」
// 必须成立N0 之后通道只从事件推导,不再有内核可变字段)。
for _, c := range []struct {
channel string
want bool
}{
{"cli", false},
{channelConsolidation, true},
} {
evt, _ := textEvent("tc", "x")
evt.OutputChannel = c.channel
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
if got := a.sched.inCritical(); got != c.want {
t.Fatalf("通道 %q → critical=%v期望 %v", c.channel, got, c.want)
}
}
}
// 新语义:没有抢占时,同批的多个工具必须全部执行——不再有「中断放弃剩余批」。
func TestBatch_NotAbandonedWithoutPreemption(t *testing.T) {
sh := NewStageHost()
var mu sync.Mutex
var ran []string
reg := func(name string) {
sh.RegisterTool(name, sdk.ToolDef{Name: name, Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
mu.Lock()
ran = append(ran, name)
mu.Unlock()
return name + "-out", nil
})
}
reg("t_a")
reg("t_b")
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_a"), tc("c2", "t_b")}},
{Content: "全部完成"},
}}
a := New(AgentConfig{
ID: "batch",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: sh,
})
if _, _ = enqueueQueued(t, a, "cli", "跑两个工具"); true {
}
tt, _, _ := a.sched.nextRef()
a.executeNewTask(tt)
if len(ran) != 2 || ran[0] != "t_a" || ran[1] != "t_b" {
t.Fatalf("同批工具应全部按序执行,实际 %v", ran)
}
snap := a.DumpScheduler()
if len(snap.SuspendStack) != 0 || len(snap.PendingInterrupts) != 0 {
t.Fatalf("无抢占时不应有挂起或待处理中断:%+v", snap)
}
if snap.Stats.Executed != 1 {
t.Fatalf("Executed=%d期望 1", snap.Stats.Executed)
}
}

View File

@ -0,0 +1,222 @@
package core
// M7 验收测试:可观测性 + 压力 + 端到端。
//
// 设计依据 docs/zh/input-scheduler-design.md §11.5O1/O2、§11.6E1/E2
//
// 这一组与前几组的区别:前几组直接驱动调度器(确定性、可断言内部状态),
// 这一组**完整启动** schedulerLoop + interceptLoop经真实 channel 投递,
// 验证组装后的行为与不变量。
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
)
// countingProvider 只统计调用次数,永远成功。
type countingProvider struct{ n atomic.Int64 }
func (p *countingProvider) Name() string { return "counting" }
func (p *countingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.n.Add(1)
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *countingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("counting provider: no stream")
}
func (p *countingProvider) MaxContextTokens() int { return 8192 }
func waitQuiescent(t *testing.T, a *Agent, wantExecuted uint64, timeout time.Duration) SchedulerSnapshot {
t.Helper()
deadline := time.Now().Add(timeout)
for {
snap := a.DumpScheduler()
if snap.Running == nil && len(snap.Queue) == 0 &&
len(snap.PendingInterrupts) == 0 && len(snap.SuspendStack) == 0 &&
snap.Stats.Executed >= wantExecuted {
return snap
}
if time.Now().After(deadline) {
t.Fatalf("未在 %v 内排空running=%v queue=%d pending=%d suspend=%d executed=%d",
timeout, snap.Running != nil, len(snap.Queue), len(snap.PendingInterrupts),
len(snap.SuspendStack), snap.Stats.Executed)
}
time.Sleep(20 * time.Millisecond)
}
}
// 压力N 个排队输入 + M 个中断,全部经真实 loop 执行,结束时三集合必须排空。
func TestScheduler_StressMixedLoad(t *testing.T) {
sp := &countingProvider{}
a := New(AgentConfig{
ID: "stress",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
a.Start()
defer a.Stop()
const nInputs = 200
const nInterrupts = 50
for i := 0; i < nInputs; i++ {
a.io.InjectInput("cli", "text", map[string]interface{}{"content": fmt.Sprintf("msg-%d", i)})
}
// 中断按 L1/L2/L3 轮转:把“四条中断队列按级别高→低扫描”真正压上,
// 而不只是排空一条队列。
levels := []string{"L1", "L2", "L3"}
for i := 0; i < nInterrupts; i++ {
a.io.InjectInterruptTextOpts("qq", "cli", fmt.Sprintf("intr-%d", i),
agentIO.InjectOptions{Priority: levels[i%len(levels)]})
}
snap := waitQuiescent(t, a, nInputs+nInterrupts, 30*time.Second)
if got := sp.n.Load(); got != int64(nInputs+nInterrupts) {
t.Fatalf("LLM 调用=%d期望 %d每条输入/中断恰好一次)", got, nInputs+nInterrupts)
}
if snap.Stats.Rejected != 0 {
t.Fatalf("容量充足却出现 Rejected=%d说明背压/深度判定有误", snap.Stats.Rejected)
}
// 上次快照的计数在排空后应当稳定(不丢不重):等于入队后的执行数。
if snap.Stats.Executed != uint64(nInputs+nInterrupts) {
t.Fatalf("Executed=%d期望 %d", snap.Stats.Executed, nInputs+nInterrupts)
}
}
// O2每次挂起/恢复都产生一条 scheduler 事件。
func TestObservability_SchedulerEventsAndStatus(t *testing.T) {
bus := events.NewBus()
var mu sync.Mutex
var actions []string
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
mu.Lock()
actions = append(actions, fmt.Sprint(e.Payload["action"]))
mu.Unlock()
})
sp := newPreemptProvider("intr-done", "low-done")
a := New(AgentConfig{
ID: "obs",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
EventBus: bus,
})
// 直接驱动一次抢占-挂起-恢复(与 M3b 相同的手法)。
lowEvt, _ := textEvent("qq", "低优先级")
lowTask := &Task{Kind: TaskKindInput, Level: LevelBackground, Event: lowEvt, EnqueuedAt: time.Now()}
a.sched.enqueue(lowTask)
lt, _, _ := a.sched.nextRef()
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 未进入")
}
intrEvt, _ := textEvent("cli", "紧急")
intrEvt.Payload["interrupt"] = true
a.sched.requestKernelPreempt(intrEvt)
a.cancelCurrentLLM()
<-done
it, _, _ := a.sched.nextRef()
a.executeNewTask(it)
rt, rf, _ := a.sched.nextRef()
a.resumeTask(rt, rf)
mu.Lock()
got := strings.Join(actions, ",")
mu.Unlock()
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
t.Fatalf("调度事件缺失:%q", got)
}
// 状态快照(供状态页/诊断):计数一致、三集合为空。
st := a.GetKernelStatus().Scheduler
if st.SuspendStack != 0 || st.PendingInterrupts != 0 || st.ReadyQueueDepth != 0 {
t.Fatalf("排空后状态非空:%+v", st)
}
if st.Suspended == 0 || st.Resumed == 0 {
t.Fatalf("挂起/恢复计数缺失:%+v", st)
}
if st.Executed < 2 {
t.Fatalf("Executed=%d期望 >=2", st.Executed)
}
if st.MaxSuspendDepth != 4 {
t.Fatalf("MaxSuspendDepth=%d期望 4", st.MaxSuspendDepth)
}
}
// E1/E2完整启动 loop经真实 channel 投递 L1 任务与 L4 中断,
// 断言「LLM 流式中断 → 挂起 → 中断先完成 → 原任务恢复」的整条链路。
func TestE2E_RealLoopPreemption(t *testing.T) {
bus := events.NewBus()
var mu sync.Mutex
var actions []string
bus.Subscribe(events.EventScheduler, func(e *events.Event) {
mu.Lock()
actions = append(actions, fmt.Sprint(e.Payload["action"]))
mu.Unlock()
})
sp := newPreemptProvider("intr-done", "low-done")
a := New(AgentConfig{
ID: "e2e",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
EventBus: bus,
})
a.Start()
defer a.Stop()
// 排队输入qq 入站消息 → 阻塞在第一次 LLM 调用(排队任务无级别)
a.io.InjectInput("qq", "text", map[string]interface{}{"content": "长任务"})
select {
case <-sp.entered:
case <-time.After(5 * time.Second):
t.Fatal("低优先级任务未进入 LLM")
}
// 插件声明的 L3 中断interceptLoop 应取消 LLM 并登记抢占。
// 注意它能打断**排队任务**不是因为级别高,而是因为排队任务无级别——
// 任何中断都大于它。
a.io.InjectInterruptTextOpts("cli", "cli", "紧急打断", agentIO.InjectOptions{Priority: "L3"})
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "后续常规输入"})
// 排空:中断任务 + 被恢复的原任务 + 后续常规输入
snap := waitQuiescent(t, a, 3, 15*time.Second)
mu.Lock()
got := strings.Join(actions, ",")
mu.Unlock()
if !strings.Contains(got, "suspend") || !strings.Contains(got, "resume") {
t.Fatalf("E2E 未发生抢占-挂起-恢复:%q", got)
}
if snap.Stats.Executed < 3 {
t.Fatalf("Executed=%d期望 >=3", snap.Stats.Executed)
}
// 第一次 LLM 调用被丢弃 + 中断 1 + 恢复 1 + 常规输入 1 = 4
if sp.callCount() != 4 {
t.Fatalf("LLM 调用=%d期望 4丢弃 1 + 中断 1 + 恢复 1 + 常规 1", sp.callCount())
}
}

View File

@ -0,0 +1,294 @@
package core
// L4 的内核独占性 + 两类别抢占规则。
//
// 模型(用户明确):
// - 类别由**用哪个注入 API** 决定,与通道名无关;
// - L1..L3 由插件在 InjectOptions.Priority 声明;
// - L4 只有内核持有panic / 内核事件 selfip
// - 排队输入无级别,可被**任何**中断打断。
import (
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// 非内核级来源声明 L4 必须被夹到 L3内核级来源内置插件可用到 L4。
func TestKernel_L4RequiresKernelLevelSource(t *testing.T) {
if got := clampPluginLevel(LevelCritical); got != LevelInteractive {
t.Fatalf("非特权声明 L4 应被夹到 L3实际 %v", got)
}
cases := []struct {
declared string
want Level
}{
{"L1", LevelBackground},
{"L2", LevelMessage},
{"L3", LevelInteractive},
{"l2", LevelMessage},
{"L4", LevelInteractive}, // 非特权 → 夹到 L3
{"L7", DefaultLevel}, // 未知 → 默认级
{"", DefaultLevel}, // 未声明 → 默认级
{"紧急", DefaultLevel}, // 拼写错误 → 默认级(不得被静默当成别的级别)
}
for _, c := range cases {
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt, false); got != c.want {
t.Fatalf("非特权声明 %q → 级别 %v期望 %v", c.declared, got, c.want)
}
}
if got := interruptLevel(nil, false); got != DefaultLevel {
t.Fatalf("无事件应为默认级,实际 %v", got)
}
// 特权内核级插件L4 被承认,其余待遇不变。
for _, c := range []struct {
declared string
want Level
}{
{"L4", LevelCritical},
{"L3", LevelInteractive},
{"L1", LevelBackground},
{"", DefaultLevel},
{"L9", DefaultLevel},
} {
evt := &agentIO.InputEvent{Payload: map[string]interface{}{}}
if c.declared != "" {
evt.Payload["priority"] = c.declared
}
if got := interruptLevel(evt, true); got != c.want {
t.Fatalf("特权声明 %q → 级别 %v期望 %v", c.declared, got, c.want)
}
}
}
// 内核级 = 插件注册表里的**内置工厂**(编译期自注册),与插件自报名无关;
// source 约定 `插件名` 或 `插件名/实例`(如 webui/<deviceID>)。
func TestKernel_KernelLevelSource(t *testing.T) {
plugin.RegisterFactory("core_test_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := &Agent{pluginReg: plugin.NewRegistry()}
cases := []struct {
source string
want bool
}{
{"core_test_builtin", true},
{"core_test_builtin/dev-1", true}, // 插件名/实例
{"core_test_external", false},
{"webui", false}, // 本测试注册表里没有 webui 工厂
{"", false},
{"core_test_builtinX", false}, // 不做前缀匹配
}
for _, c := range cases {
if got := a.isKernelLevelSource(c.source); got != c.want {
t.Fatalf("source=%q → %v期望 %v", c.source, got, c.want)
}
}
if (&Agent{}).isKernelLevelSource("core_test_builtin") {
t.Fatal("没有插件注册表时不得授予内核级")
}
}
// 排队任务可被**任何**中断打断——包括最低的 L1。
func TestKernel_QueuedTaskIsPreemptedByAnyInterrupt(t *testing.T) {
s := newScheduler(8)
q := newInputTask(&agentIO.InputEvent{Source: "plugin", OutputChannel: "plugin"})
s.enqueue(q)
if task, _, kind := s.nextRef(); task != q || kind != nextReady {
t.Fatalf("应取到排队任务kind=%v", kind)
}
evt, _ := textEvent("qq", "最低级中断")
if !s.registerInterrupt(newInterruptTask(evt, LevelBackground)) {
t.Fatal("L1 中断也必须能打断排队任务(排队任务无级别)")
}
if s.immediate == nil || s.immediate.Level != LevelBackground {
t.Fatalf("抢占者应进 immediate 槽,实际 %+v", s.immediate)
}
}
// 排队输入从不抢占——它没有级别,也就没有“比谁高”。
func TestKernel_QueuedInputNeverPreempts(t *testing.T) {
s := newScheduler(8)
if !s.enqueue(newInputTask(&agentIO.InputEvent{Source: "a", OutputChannel: "a"})) {
t.Fatal("入队失败")
}
s.nextRef() // running = 第一个排队任务
if s.registerInterrupt(newInputTask(&agentIO.InputEvent{Source: "b", OutputChannel: "b"})) {
t.Fatal("排队输入不得抢占任何任务")
}
}
// 内核 L4 入口不受夹取影响且能抢占中断L3
func TestKernel_RequestKernelPreemptUsesL4(t *testing.T) {
s := newScheduler(8)
evt, _ := textEvent("cli", "L3 运行中")
s.registerInterrupt(newInterruptTask(evt, LevelInteractive))
s.nextRef() // running = L3 中断
kevt, _ := textEvent("kernel", "panic 中断")
if !s.requestKernelPreempt(kevt) {
t.Fatal("内核 L4 应能抢占 L3 中断")
}
if s.immediate == nil || s.immediate.Level != LevelCritical {
t.Fatalf("内核中断必须是 L4实际 %+v", s.immediate)
}
}
// 任务 panic → 内核 L4 中断panic 是 L4 的来源之一)。
func TestKernel_PanicRaisesL4Interrupt(t *testing.T) {
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已收到内核事件"}}}
a := newPreemptAgent(t, sp)
// Event 为 nilhandleInput 解引用即 panic。
bad := newInputTask(nil)
if !a.sched.enqueue(bad) {
t.Fatal("入队失败")
}
task, _, _ := a.sched.nextRef()
a.executeTask(task) // panic 被隔离
snap := a.DumpScheduler()
if snap.Immediate == nil {
t.Fatal("panic 必须产生一条内核 L4 中断")
}
if snap.Immediate.Level != LevelCritical {
t.Fatalf("panic 中断级别=%v期望 L4", snap.Immediate.Level)
}
text, _ := snap.Immediate.Event.Payload["content"].(string)
if !strings.Contains(text, "panic") {
t.Fatalf("panic 中断应说明发生了什么,实际 %q", text)
}
if snap.Immediate.Event.Payload["kernel"] != true {
t.Fatal("内核中断必须带 kernel 标记,便于与插件中断区分")
}
}
// 递归保护是结构性的L4 内核中断自己 panic 时,不再产生新的 L4。
func TestKernel_PanicInsideL4DoesNotRecurse(t *testing.T) {
sp := &scriptProvider{}
a := newPreemptAgent(t, sp)
evt, _ := textEvent("kernel", "内核事件")
l4 := newKernelInterruptTask(evt)
a.sched.immediate = l4
task, _, _ := a.sched.nextRef()
if task != l4 {
t.Fatal("应取到 L4 内核中断")
}
a.executeTask(&Task{ID: task.ID, Class: TaskInterrupt, Level: LevelCritical, Kind: TaskKindInput, Event: nil})
snap := a.DumpScheduler()
if snap.Immediate != nil || len(snap.PendingInterrupts) != 0 {
t.Fatalf("L4 自身 panic 不得再产生中断(否则自我放大),实际 immediate=%+v pending=%d",
snap.Immediate, len(snap.PendingInterrupts))
}
}
// 中断栈的 4 帧上界是**结构推论**:排队(L0) ← I(L1) ← I(L2) ← I(L3) ← I(L4 运行中)。
func TestKernel_StackBoundIsFullChain(t *testing.T) {
s := newScheduler(16)
frame := func() *TaskFrame { return &TaskFrame{} }
chain := []struct {
class TaskClass
lv Level
}{
{TaskQueued, 0},
{TaskInterrupt, LevelBackground},
{TaskInterrupt, LevelMessage},
{TaskInterrupt, LevelInteractive},
}
for i := 0; i < len(chain)-1; i++ {
s.suspend(&Task{ID: uint64(i + 1), Class: chain[i].class, Level: chain[i].lv}, frame())
}
if !s.canSuspend() {
t.Fatal("3 帧挂起时仍应容得下 L3第 4 级)继续下潜")
}
s.suspend(&Task{ID: 4, Class: TaskInterrupt, Level: LevelInteractive}, frame())
if s.canSuspend() {
t.Fatal("4 帧挂起 = 全链挂起L4 运行中),不应再有下潜余量")
}
}
// 端到端:插件声明 Priority → io.applyInjectOpts → payload → interruptLevel → 任务级别。
// 这条链路断在任何一环,插件声明的级别都会静默失效(降级到 L1
func TestKernel_PriorityFlowsThroughIOLayer(t *testing.T) {
ioM := agentIO.NewIOManager()
ioM.InjectInterruptTextOpts("qq", "cli", "通知", agentIO.InjectOptions{Priority: "L3"})
select {
case evt := <-ioM.InputInterruptChan():
if got := interruptLevel(evt, false); got != LevelInteractive {
t.Fatalf("经 io 层后的级别=%v期望 L3payload=%v", got, evt.Payload)
}
task := newInterruptTask(evt, interruptLevel(evt, false))
if task.Class != TaskInterrupt || task.Level != LevelInteractive {
t.Fatalf("中断任务类别/级别=%v/%v期望 interrupt/L3", task.Class, task.Level)
}
case <-time.After(2 * time.Second):
t.Fatal("中断未到达 interruptCh")
}
// 排队路径带 priority 也必须无效:排队输入没有级别。
ioM.InjectTextOpts("qq", "cli", "普通输入", agentIO.InjectOptions{Priority: "L3"})
select {
case evt := <-ioM.InputChan():
task := newInputTask(evt)
if task.Class != TaskQueued || task.Level != 0 {
t.Fatalf("排队任务类别/级别=%v/%v期望 queued/无级别", task.Class, task.Level)
}
if effectiveLevel(task) != 0 {
t.Fatalf("排队任务有效级=%v期望 0", effectiveLevel(task))
}
case <-time.After(2 * time.Second):
t.Fatal("排队输入未到达 inputCh")
}
}
// 内核级插件声明的 L4 必须一路到达调度器(“立即打断”能力,如 WebUI 终止按钮)。
func TestKernel_KernelLevelPluginCanRaiseL4(t *testing.T) {
plugin.RegisterFactory("core_test_l4", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
a := newPreemptAgent(t, &scriptProvider{})
a.pluginReg = plugin.NewRegistry()
// 先让一个排队任务跑起来(无级别),才能看到“抢占”。
evt, _ := textEvent("qq", "长任务")
if !a.sched.enqueue(newInputTask(evt)) {
t.Fatal("入队失败")
}
a.sched.nextRef()
// 内核级插件(内置)声明 L4 的终止通知。
kevt, _ := textEvent("core_test_l4", "用户按了终止按钮")
kevt.Payload["priority"] = "L4"
level := interruptLevel(kevt, a.isKernelLevelSource(kevt.Source))
if level != LevelCritical {
t.Fatalf("内核级插件声明 L4 应得 L4实际 %v", level)
}
if !a.sched.requestPreempt(kevt, level) {
t.Fatal("L4 应能打断排队任务")
}
if a.sched.immediate == nil || a.sched.immediate.Level != LevelCritical {
t.Fatalf("应有一条 L4 中断在 immediate实际 %+v", a.sched.immediate)
}
// 反例:同样的声明来自外部插件 → 夹到 L3。
eevt, _ := textEvent("core_test_external", "外部插件也想立即打断")
eevt.Payload["priority"] = "L4"
if got := interruptLevel(eevt, a.isKernelLevelSource(eevt.Source)); got != LevelInteractive {
t.Fatalf("外部插件声明 L4 应被夹到 L3实际 %v", got)
}
}

View File

@ -0,0 +1,356 @@
package core
// M3b 验收测试:四级优先级 + 严格大于抢占 + 现场保存/恢复 + suspendStack。
//
// 设计依据 docs/zh/input-scheduler-design.md §11P1P4、R1、R5、D1T、Q3
//
// 测试手法:用一个「第一次调用阻塞到 ctx 取消、之后按脚本返回」的 provider
// 让测试可以确定性地把运行任务停在 S_LLM 上,再注入中断观察让位与恢复。
import (
"context"
"strings"
"sync"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// preemptProvider 第 1 次 Chat 阻塞直到 ctx 取消;第 2 次起返回脚本。
type preemptProvider struct {
mu sync.Mutex
calls int
entered chan struct{}
enteredOn sync.Once
responses []string
}
func newPreemptProvider(responses ...string) *preemptProvider {
return &preemptProvider{entered: make(chan struct{}), responses: responses}
}
func (p *preemptProvider) Name() string { return "preempt" }
func (p *preemptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
n := p.calls
p.mu.Unlock()
if n == 1 {
p.enteredOn.Do(func() { close(p.entered) })
<-ctx.Done()
return nil, ctx.Err()
}
p.mu.Lock()
defer p.mu.Unlock()
i := n - 2
if i < len(p.responses) {
return &agentAPI.CompletionResponse{Content: p.responses[i]}, nil
}
return &agentAPI.CompletionResponse{Content: "done"}, nil
}
func (p *preemptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, context.Canceled
}
func (p *preemptProvider) MaxContextTokens() int { return 8192 }
func (p *preemptProvider) callCount() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.calls
}
func newPreemptAgent(t *testing.T, sp agentAPI.Provider) *Agent {
t.Helper()
return New(AgentConfig{
ID: "preempt",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
}
// enqueueQueued 入队一个**排队任务**(无级别)——对应 InjectText*/InjectInputSync*。
func enqueueQueued(t *testing.T, a *Agent, source, content string) (*Task, *agentIO.InputEvent) {
t.Helper()
evt, _ := textEvent(source, content)
task := newInputTask(evt)
if !a.sched.enqueue(task) {
t.Fatal("入队失败")
}
return task, evt
}
// enqueueInterrupt 登记一次**中断**(走 requestPreempt能抢占则进 immediate 槽,
// 否则按级别进中断队列),返回被登记的任务。
func enqueueInterrupt(t *testing.T, a *Agent, level Level, source, content string) (*Task, *agentIO.InputEvent) {
t.Helper()
evt, _ := textEvent(source, content)
evt.Payload["interrupt"] = true
a.sched.requestPreempt(evt, level)
snap := a.DumpScheduler()
if snap.Immediate != nil && snap.Immediate.Event == evt {
return snap.Immediate, evt
}
q := snap.InterruptQueues[clampPluginLevel(level)]
for i := len(q) - 1; i >= 0; i-- {
if q[i].Event == evt {
return q[i], evt
}
}
return nil, evt
}
// P1 + R1 + R5 + D1=A高优先级抢占 → 挂起在 S_LLM → 中断任务带只读前缀 →
// 恢复后从 S_LLM 重发,且原任务的 msgs 未被改动。
func TestPreempt_HigherPreemptsAndResumes(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
lowTask, _ := enqueueQueued(t, a, "qq", "低优先级任务")
lt, _, kind := a.sched.nextRef()
if kind != nextReady || lt != lowTask {
t.Fatalf("应取到低优先级任务kind=%v", kind)
}
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 第 1 次调用未发生")
}
// 注入 L4 中断cli
intrEvt, _ := textEvent("cli", "紧急打断")
intrEvt.Payload["interrupt"] = true
if !a.sched.requestKernelPreempt(intrEvt) {
t.Fatal("L4 应请求抢占并返回 true应取消 LLM")
}
a.cancelCurrentLLM()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("低优先级任务未在取消后挂起")
}
snap := a.DumpScheduler()
if snap.Running != nil {
t.Fatal("挂起后不应还有 running")
}
if len(snap.SuspendStack) != 1 {
t.Fatalf("suspendStack=%d期望 1", len(snap.SuspendStack))
}
if len(snap.PendingInterrupts) != 1 {
t.Fatalf("pendingInterrupts=%d期望 1", len(snap.PendingInterrupts))
}
sf := snap.SuspendStack[0].Frame
if sf.Terminal != terminalSuspended {
t.Fatalf("挂起任务终态=%v期望 terminalSuspended", sf.Terminal)
}
if sf.Step != StepLLM {
t.Fatalf("应在 StepLLM 安全点挂起,实际 step=%v", sf.Step)
}
msgsBefore := len(sf.Msgs)
// R5第一次 LLM 调用被丢弃,未产生新消息。
if sp.callCount() != 1 {
t.Fatalf("挂起前 LLM 调用=%d期望 1不完整请求被丢弃", sp.callCount())
}
// Q3三集合统一比较 → 下一轮取中断L4 > L1
it, _, k := a.sched.nextRef()
if k != nextImmediate || it.Level != LevelCritical {
t.Fatalf("应取到 pending 中断kind=%v level=%v", k, it.Level)
}
// D1=B中断任务在**上一个任务之前的完整状态**上开始运行,不继承本任务的现场。
// 因此它能看到的唯一输入就是它自己携带的内容。
if it.Event == nil {
t.Fatal("中断任务必须携带自己的输入事件")
}
if got, _ := it.Event.Payload["content"].(string); got != "紧急打断" {
t.Fatalf("中断任务输入=%q期望 紧急打断", got)
}
a.executeNewTask(it)
if len(a.DumpScheduler().PendingInterrupts) != 0 {
t.Fatal("中断任务执行后 pendingInterrupts 应清空")
}
// R1恢复被抢占任务。基础前缀被重建到「中断任务之上」含中断已提交的上下文
// 本任务自己的现场接回其后,然后从 S_LLM 重发。
rt, rf, k2 := a.sched.nextRef()
if k2 != nextSuspended || rt != lowTask {
t.Fatalf("应恢复被抢占任务kind=%v", k2)
}
if rf.Step != StepLLM {
t.Fatalf("恢复游标=%v期望 StepLLM", rf.Step)
}
a.resumeTask(rt, rf)
// 「加载回中断之上」的判据:恢复后的消息序列里必须出现中断任务的上下文。
if !msgsContain(rf.Msgs, "紧急打断") {
t.Fatal("恢复后的任务应看见中断任务的上下文(现场未加载回中断之上)")
}
// 本任务自己的现场(工具轮产物)仍然在。
if len(rf.Msgs) < msgsBefore {
t.Fatalf("恢复后消息数=%d不应少于被抢占前的 %d", len(rf.Msgs), msgsBefore)
}
if sp.callCount() != 3 {
t.Fatalf("LLM 总调用=%d期望 3丢弃 1 + 中断 1 + 恢复 1", sp.callCount())
}
snap = a.DumpScheduler()
if len(snap.SuspendStack) != 0 || snap.Running != nil {
t.Fatalf("全部结束后应无挂起与运行任务:%+v", snap)
}
if snap.Stats.Executed != 2 {
t.Fatalf("Executed=%d期望 2中断任务 + 被抢占任务)", snap.Stats.Executed)
}
}
// P2/P3同级与更低级都不得抢占请求进中断队列。
//
// 注意:能比“同级/更低不得抢占”的只可能是**中断之间**——排队任务无级别,
// 任何中断都能打断它(这是模型的规定,不是漏洞)。
func TestPreempt_LowerOrEqualDoesNotPreempt(t *testing.T) {
sp := newPreemptProvider("low-done", "intr-done")
a := newPreemptAgent(t, sp)
lowTask, _ := enqueueInterrupt(t, a, LevelInteractive, "cli", "运行中的 L3")
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
t.Fatal("应取到运行中的 L3 中断")
}
done := make(chan struct{})
go func() { a.executeNewTask(lowTask); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 未被调用")
}
// 同级 L3
e1, _ := textEvent("webui", "同级打断")
if a.sched.requestPreempt(e1, LevelInteractive) {
t.Fatal("同级不得抢占")
}
// 更低级 L1
e2, _ := textEvent("system", "低优先级打断")
if a.sched.requestPreempt(e2, LevelBackground) {
t.Fatal("更低级不得抢占")
}
if a.sched.preemptGrantedFor() {
t.Fatal("未 arm 让位信号preemptGrantedFor 应为 false")
}
// 运行任务没有被取消,仍在等它自己的 ctx用取消让它收尾便于清理。
a.cancelCurrentLLM()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("运行任务未结束")
}
if len(a.DumpScheduler().PendingInterrupts) != 2 {
t.Fatalf("两条未抢占中断都应保留在中断队列,实际 %d",
len(a.DumpScheduler().PendingInterrupts))
}
}
// D1TsuspendStack 满时不再下潜canSuspend=false让位信号也不会 arm。
func TestPreempt_DepthCapBlocksSuspension(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
if a.sched.maxInterruptFrames != 4 {
t.Fatalf("默认栈深上界=%d期望 4= 中断级数,结构推论)", a.sched.maxInterruptFrames)
}
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
for i := 0; i < a.sched.maxInterruptFrames; i++ {
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: LevelBackground}, frame())
}
if a.sched.canSuspend() {
t.Fatal("深度已达上限canSuspend 应为 false")
}
// 超限兜底:仍保留帧(不丢副作用记录),但计数 Rejected。
before := a.DumpScheduler().Stats.Rejected
a.sched.suspend(&Task{ID: 99, Class: TaskInterrupt, Level: LevelBackground}, frame())
if a.DumpScheduler().Stats.Rejected != before+1 {
t.Fatal("超限挂起必须计数 Rejected")
}
if len(a.DumpScheduler().SuspendStack) != a.sched.maxInterruptFrames+1 {
t.Fatal("兜底路径必须保留帧而不是丢弃")
}
}
// 中断不丢:空闲时请求抢占 → 不 arm 信号,但请求进 pendingInterrupts 并被选出。
func TestPreempt_IdleInterruptIsQueuedNotLost(t *testing.T) {
a := newPreemptAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "已处理中断"}}})
evt, respCh := textEvent("qq", "空闲时的中断")
if a.sched.requestPreempt(evt, LevelMessage) {
t.Fatal("空闲时不应请求取消 LLM没有运行任务")
}
if len(a.DumpScheduler().PendingInterrupts) != 1 {
t.Fatal("空闲时的中断必须进中断队列(不能丢)")
}
task, _, kind := a.sched.nextRef()
if kind != nextInterrupt || task.Level != LevelMessage {
t.Fatalf("应取到待处理中断kind=%v", kind)
}
a.executeNewTask(task)
if len(respCh) != 1 {
t.Fatal("中断任务应完成并回执")
}
}
// msgsContain 报告消息序列里是否出现过某段文本(用于“现场是否合回”的断言)。
func msgsContain(msgs []agentAPI.Message, sub string) bool {
for _, m := range msgs {
if strings.Contains(m.Content, sub) {
return true
}
}
return false
}
// 恢复时的重建必须把 prepare 段对尾部消息的两处改写补回:
// 中断标记IsInterrupt与多模态块InputBlocks
func TestPreempt_ResumeRebaseRestoresTailDecorations(t *testing.T) {
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "ok"}}}
a := newPreemptAgent(t, sp)
block := agentAPI.ContentBlock{Type: "text", Text: "图"}
f := a.newTaskFrame("打断文本", a.stageCtxFromInput("打断文本", "", ""))
f.IsInterrupt = true
f.InputBlocks = []agentAPI.ContentBlock{block}
// 模拟 prepare 后的形状:基础前缀 + 一段“本任务自己的现场”
f.Msgs = []agentAPI.Message{
{Role: "system", Content: "S"},
{Role: "user", Content: "[中断消息] 打断文本"},
{Role: "assistant", Content: "进行中"},
}
f.PrefixLen = 2
a.rebaseFramePrefix(f)
if f.PrefixLen <= 0 || f.PrefixLen >= len(f.Msgs) {
t.Fatalf("重建后 PrefixLen=%d消息数=%d前缀应短于总数", f.PrefixLen, len(f.Msgs))
}
// 尾部现场assistant 进行中)必须还在最后。
if last := f.Msgs[len(f.Msgs)-1]; last.Content != "进行中" {
t.Fatalf("本任务现场应接回最后,实际 %+v", last)
}
prefixLast := f.Msgs[f.PrefixLen-1]
if prefixLast.Role != "system" || !strings.HasPrefix(prefixLast.Content, "[中断消息]") {
t.Fatalf("中断标记未补回:%+v", prefixLast)
}
if len(prefixLast.Blocks) != 1 || prefixLast.Blocks[0].Text != "图" {
t.Fatalf("多模态块未补回:%+v", prefixLast.Blocks)
}
}

View File

@ -0,0 +1,101 @@
package core
// 回归判据M3 实现与设计稿 §5.1/§4.3 的两处偏离。
//
// 这两条是**先写判据、确认失败、再修**的(修完保留为回归测试)。
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// cancelAwareProvider 第一次调用进入后阻塞,直到 ctx 取消;记录是否被取消。
type cancelAwareProvider struct {
once sync.Once
entered chan struct{}
canceled atomic.Bool
}
func newCancelAwareProvider() *cancelAwareProvider {
return &cancelAwareProvider{entered: make(chan struct{})}
}
func (p *cancelAwareProvider) Name() string { return "cancel-aware" }
func (p *cancelAwareProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.once.Do(func() { close(p.entered) })
<-ctx.Done()
p.canceled.Store(true)
return nil, ctx.Err()
}
func (p *cancelAwareProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, context.Canceled
}
func (p *cancelAwareProvider) MaxContextTokens() int { return 8192 }
// 设计 §5.1 ③interruptLoop 定级/决策后必须**唤醒调度器**。
// 现状:中断只被放进 pendingInterrupts而调度器空闲时阻塞在 select只看
// InputChan/selfInputCh/ctx.Done——没有任何东西会把它叫醒。
func TestGap_IdleInterruptIsProcessed(t *testing.T) {
sp := &countingProvider{}
a := New(AgentConfig{
ID: "idle-intr",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
a.Start()
defer a.Stop()
// 完全空闲时投递一条中断(模拟定时器通知/插件提醒)。
a.io.InjectInterruptText("qq", "cli", "空闲时的通知")
deadline := time.Now().Add(3 * time.Second)
for sp.n.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if sp.n.Load() == 0 {
snap := a.DumpScheduler()
t.Fatalf("空闲时到达的中断未被处理pendingInterrupts=%d调度器未被唤醒",
len(snap.PendingInterrupts))
}
}
// 设计 §4.3/§5.2`_consolidation_` 是整任务临界区——抢占请求必须排队等它结束,
// 而不是取消它。现状requestPreempt 不判临界区interceptLoop 照常 cancelLLM
// 于是正在流式的记忆整理被中断 → stepLLM 直接以 error 结束(整理丢一半)。
func TestGap_ConsolidationMustNotBeCancelled(t *testing.T) {
sp := newCancelAwareProvider()
a := New(AgentConfig{
ID: "consol",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
})
a.Start()
defer a.Stop()
// 走自循环通道发起一次记忆整理。
a.selfInputCh <- selfInputMsg{text: "合并实体", channel: channelConsolidation}
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("记忆整理未进入 LLM 调用")
}
// L4 中断到达。
a.io.InjectInterruptText("cli", "cli", "L4 打断")
time.Sleep(500 * time.Millisecond)
if sp.canceled.Load() {
t.Fatal("记忆整理是临界区,其 LLM 不该被取消(设计 §4.3/§5.2")
}
}

View File

@ -0,0 +1,192 @@
package core
// 中断栈(嵌套抢占)验收测试。
//
// 用户明确:存在**中断被中断**的场景,所以被打断的现场要压进**中断栈**。
// 因此恢复纪律是**严格 LIFO只比栈顶**,而不是“全栈按优先级挑最优”。
//
// 为什么这个区别成立:抢占判据是 adopted.level > effectiveLevel(running)
// 所以嵌套时栈自底向上的**基础级**天然递增;但饥饿防护的“有效级提升”会让
// 栈内某个更老的任务有效级超过栈顶,此时“只比栈顶”才保证嵌套语义不被破坏。
import (
"context"
"sync"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// nestingProvider 第 1、2 次调用阻塞到 ctx 取消;第 3 次起按脚本返回。
// 用序号精确对应 AL1→ BL2→ CL3→ 恢复 B → 恢复 A 的调用顺序。
type nestingProvider struct {
mu sync.Mutex
calls int
entered chan int
script []string
}
func newNestingProvider(script ...string) *nestingProvider {
return &nestingProvider{entered: make(chan int, 16), script: script}
}
func (p *nestingProvider) Name() string { return "nesting" }
func (p *nestingProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
p.mu.Lock()
p.calls++
n := p.calls
p.mu.Unlock()
p.entered <- n
if n <= 2 {
<-ctx.Done()
return nil, ctx.Err()
}
p.mu.Lock()
defer p.mu.Unlock()
i := n - 3
if i < len(p.script) {
return &agentAPI.CompletionResponse{Content: p.script[i]}, nil
}
return &agentAPI.CompletionResponse{Content: "?"}, nil
}
func (p *nestingProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, context.Canceled
}
func (p *nestingProvider) MaxContextTokens() int { return 8192 }
func awaitEnter(t *testing.T, ch chan int, want int) {
t.Helper()
select {
case got := <-ch:
if got != want {
t.Fatalf("LLM 进入序号=%d期望 %d", got, want)
}
case <-time.After(3 * time.Second):
t.Fatalf("等第 %d 次 LLM 调用超时", want)
}
}
// 中断被中断A(L1) → B(L2) → C(L3),恢复必须按 LIFOB 先A 后)。
func TestStack_NestedPreemptionResumesLIFO(t *testing.T) {
sp := newNestingProvider("c-done", "b-done", "a-done")
a := newPreemptAgent(t, sp)
// AL1开始运行
if _, _ = enqueueQueued(t, a, "qq", "任务A"); true {
}
at, _, _ := a.sched.nextRef()
doneA := make(chan struct{})
go func() { a.executeNewTask(at); close(doneA) }()
awaitEnter(t, sp.entered, 1)
// BL2抢占 A
bEvt, _ := textEvent("qq", "任务B")
if !a.sched.requestPreempt(bEvt, LevelMessage) {
t.Fatal("B(L2) 应抢占 A(L1)")
}
a.cancelCurrentLLM()
<-doneA
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
t.Fatalf("第一次抢占后栈深=%d期望 1", n)
}
// B 开始运行
bt, _, k := a.sched.nextRef()
if k != nextImmediate || bt.Level != LevelMessage {
t.Fatalf("应取到 Bpendingkind=%v level=%v", k, bt.Level)
}
doneB := make(chan struct{})
go func() { a.executeNewTask(bt); close(doneB) }()
awaitEnter(t, sp.entered, 2)
// CL3抢占 B —— 这就是“中断被中断”
cEvt, _ := textEvent("cli", "任务C")
if !a.sched.requestPreempt(cEvt, LevelInteractive) {
t.Fatal("C(L3) 应抢占 B(L2)")
}
a.cancelCurrentLLM()
<-doneB
snap := a.DumpScheduler()
if len(snap.SuspendStack) != 2 {
t.Fatalf("嵌套后栈深=%d期望 2", len(snap.SuspendStack))
}
if snap.SuspendStack[0].Task.Class != TaskQueued {
t.Fatalf("栈底应为排队任务 A无级别实际 %v", snap.SuspendStack[0].Task.Class)
}
if snap.SuspendStack[1].Task.Level != LevelMessage {
t.Fatalf("栈顶应为 B(L2),实际 %v", snap.SuspendStack[1].Task.Level)
}
// C 运行完毕(第三次调用,不阻塞)
ct, _, k := a.sched.nextRef()
if k != nextImmediate || ct.Level != LevelInteractive {
t.Fatalf("应取到 Ckind=%v level=%v", k, ct.Level)
}
a.executeNewTask(ct)
// LIFO先恢复栈顶 B再恢复 A
rt, rf, k := a.sched.nextRef()
if k != nextSuspended {
t.Fatalf("应恢复栈顶kind=%v", k)
}
if rt.Level != LevelMessage {
t.Fatalf("应先恢复栈顶 B(L2),实际 %v", rt.Level)
}
a.resumeTask(rt, rf)
rt2, rf2, k2 := a.sched.nextRef()
if k2 != nextSuspended {
t.Fatalf("应继续恢复 Akind=%v", k2)
}
if rt2.Class != TaskQueued {
t.Fatalf("最后应恢复排队的 A无级别实际 %v", rt2.Class)
}
a.resumeTask(rt2, rf2)
if n := len(a.DumpScheduler().SuspendStack); n != 0 {
t.Fatalf("全部恢复后栈应清空,实际 %d", n)
}
}
// 只比栈顶:栈内更老的任务即使(因有效级提升)优先级更高,也不得越过栈顶。
func TestStack_TopOnlyWinsOverHigherPrioritySuspended(t *testing.T) {
a := newPreemptAgent(t, &scriptProvider{})
// 人为构造“A(L3) 在栈底、B(L2) 在栈顶”。真实抢占不会产生这种顺序
// (栈自底向上基础级递增),这里专门用来区分两种实现:
// · 只比栈顶 → 取 B
// · 全栈扫最优 → 取 AL3 > L2
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now()},
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelMessage, EnqueuedAt: time.Now()},
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
rt, _, k := a.sched.nextRef()
if k != nextSuspended {
t.Fatalf("kind=%v期望 nextSuspended", k)
}
if rt.ID != 2 {
t.Fatalf("应取栈顶 B(ID=2),实际 ID=%d —— 说明在做全栈优先级扫描而非栈语义", rt.ID)
}
if n := len(a.DumpScheduler().SuspendStack); n != 1 {
t.Fatalf("取出栈顶后栈深=%d期望 1", n)
}
}
// 深度上限对嵌套同样成立:到顶后新的抢占请求不再下潜。
func TestStack_DepthCapDuringNesting(t *testing.T) {
a := newPreemptAgent(t, &scriptProvider{})
frame := func() *TaskFrame { return a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")) }
for i := 0; i < a.sched.maxInterruptFrames; i++ {
a.sched.suspend(&Task{ID: uint64(i + 1), Class: TaskInterrupt, Level: Level(i + 1)}, frame())
}
if a.sched.canSuspend() {
t.Fatal("栈已满canSuspend 应为 false")
}
if n := len(a.DumpScheduler().SuspendStack); n != a.sched.maxInterruptFrames {
t.Fatalf("栈深=%d期望上界 %d", n, a.sched.maxInterruptFrames)
}
}

View File

@ -0,0 +1,124 @@
package core
// M5 验收测试:饥饿防护(抢占计数提升有效级 + 抢占冷却)。
//
// 设计依据 docs/zh/input-scheduler-design.md §9、§11.5G1/G2
//
// 为什么需要:固定四级 + 「严格大于才抢占」下,一条 L4 流可以反复打断同一个
// L1 任务,使它永不完结。提升被抢占者的**有效**优先级,让它在竞争排队时
// 逐步追上;封顶 L4因此它永远抢不过真正的紧急输入紧急输入本身不被抢占
import (
"testing"
"time"
)
func TestStarvation_EffectiveLevelPromotion(t *testing.T) {
base := &Task{Class: TaskInterrupt, Level: LevelBackground}
if got := effectiveLevel(base); got != LevelBackground {
t.Fatalf("未抢占时有效级=%v期望 L1", got)
}
base.PreemptCount = 1
if got := effectiveLevel(base); got != LevelMessage {
t.Fatalf("被抢占 1 次后有效级=%v期望 L2", got)
}
base.PreemptCount = 2
if got := effectiveLevel(base); got != LevelInteractive {
t.Fatalf("被抢占 2 次后有效级=%v期望 L3", got)
}
base.PreemptCount = 99
if got := effectiveLevel(base); got != LevelInteractive {
t.Fatalf("提升应封顶在 +2 档,实际 %v", got)
}
// 封顶 L4L3 任务被多次抢占也不会超过紧急级。
high := &Task{Class: TaskInterrupt, Level: LevelInteractive, PreemptCount: 99}
if got := effectiveLevel(high); got != LevelCritical {
t.Fatalf("L3 提升后应封顶为 L4实际 %v", got)
}
}
func TestStarvation_CooldownBlocksImmediateRepreempt(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
a.sched.immediate = low
a.sched.nextRef() // running = low
e1, _ := textEvent("qq", "第一次打断")
if !a.sched.requestPreempt(e1, LevelMessage) {
t.Fatal("L2 应能抢占 L1首次")
}
a.sched.suspend(low, a.newTaskFrame("x", a.stageCtxFromInput("x", "", "")))
if low.PreemptCount != 1 {
t.Fatalf("PreemptCount=%d期望 1", low.PreemptCount)
}
if low.LastPreemptAt.IsZero() {
t.Fatal("挂起必须记录 LastPreemptAt冷却起点")
}
// 冷却期内:即使 L4 也不得再抢占。
a.sched.mu.Lock()
a.sched.running = low
a.sched.mu.Unlock()
e2, _ := textEvent("cli", "冷却期内的紧急打断")
if a.sched.requestKernelPreempt(e2) {
t.Fatal("抢占冷却期内不得再抢占")
}
if a.sched.preemptGrantedFor() {
t.Fatal("冷却期内不得 arm 让位信号")
}
}
func TestStarvation_PromotionBlocksSameLevelPreempt(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
low := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
a.sched.immediate = low
a.sched.nextRef()
// 模拟「已被抢占过一次」:有效级 = L2。
low.PreemptCount = 1
low.LastPreemptAt = time.Now().Add(-time.Hour) // 冷却已过
e1, _ := textEvent("qq", "同级打断")
if a.sched.requestPreempt(e1, LevelMessage) {
t.Fatal("有效级 L2 时L2 中断不得抢占(严格大于才抢占)")
}
e2, _ := textEvent("cli", "更高级打断")
if !a.sched.requestPreempt(e2, LevelInteractive) {
t.Fatal("L3 应能抢占有效级 L2")
}
if !a.sched.preemptGrantedFor() {
t.Fatal("L3 > 有效 L2应已 arm")
}
}
// 提升必须真的进入抢占判据,而不只是一个数学性质:
// 被抢占过一次的 L1 中断(有效 L2应当顶住同级 L2 流的再次抢占。
func TestStarvation_PromotionIsVisibleInSelection(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
// 栈里放一个「被抢占过一次的 L1」有效级 L2。
a.sched.suspend(&Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, PreemptCount: 1},
a.newTaskFrame("A", a.stageCtxFromInput("A", "", "")))
// 队列里来一个 L2有效级持平2 vs 2→ 不得越过栈顶。
evt, _ := textEvent("qq", "L2 中断")
a.sched.registerInterrupt(newInterruptTask(evt, LevelMessage))
if _, _, kind := a.sched.nextRef(); kind != nextSuspended {
t.Fatalf("有效级持平应恢复栈顶kind=%v", kind)
}
// 队列里来一个 L3严格大于 → 队头优先。
// PreemptCount 从 0 起suspend 内部会 +1 → 有效级 L2正好用来卡 L2 持平)。
a.sched.suspend(&Task{ID: 2, Class: TaskInterrupt, Level: LevelBackground},
a.newTaskFrame("B", a.stageCtxFromInput("B", "", "")))
evt2, _ := textEvent("cli", "L3 中断")
a.sched.registerInterrupt(newInterruptTask(evt2, LevelInteractive))
if _, _, kind := a.sched.nextRef(); kind != nextInterrupt {
t.Fatalf("L3 > 有效 L2 应取中断队列kind=%v", kind)
}
}

View File

@ -0,0 +1,351 @@
package core
// 优先级压力测试:**各级中断混合打入 + 排队输入**,全部经真实调度 loop 执行。
//
// 形状用户指定100 条中断L1/L2/L3/L4 各 25混合打入+ 100 条排队输入。
//
// 为什么需要「等待合适的受害者再注入」:调度器是单线程的,同一时刻只有**一个**
// 运行任务。如果闭着眼睛猛灌,绝大多数中断会落在「没有受害者」或「受害者级别
// 不够」的时刻,于是全部退化成排队——压力测试就只压到了队列,没有压到抢占。
// 因此每条中断都等到「运行中的任务按规则**应当**被它打断」时再注入:
// - 受害者是排队任务(无级别)→ 任何中断都该抢占它;
// - 受害者是中断 Li → 只有 Lj > Li 才该抢占它。
//
// 同时验证分级计数:各级登记了多少、各级真正抢断了多少次(只看总数会掩盖
// 「总数一样但级别分布完全不同」这种情况)。
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// fixedDelayProvider 返回固定内容,并在被取消时立刻返回 ctx.Err()。
//
// 延迟是必需的:任务必须先「在跑」才谈得上被打断;取消感知也是必需的,
// 否则抢占只能等它自然结束,测不到挂起/恢复。
type fixedDelayProvider struct {
n atomic.Int64
delay time.Duration
cancelled atomic.Int64 // 被 ctx 取消(即“流式段被抢占打断”)的次数
}
func (p *fixedDelayProvider) Name() string { return "fixed-delay" }
func (p *fixedDelayProvider) Chat(ctx context.Context, _ *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
select {
case <-time.After(p.delay):
case <-ctx.Done():
p.cancelled.Add(1)
return nil, ctx.Err()
}
p.n.Add(1)
return &agentAPI.CompletionResponse{Content: "fixed-reply"}, nil
}
func (p *fixedDelayProvider) ChatStream(context.Context, *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("fixed-delay provider: 非流式")
}
func (p *fixedDelayProvider) MaxContextTokens() int { return 8192 }
// waitForVictim 等到「适合被 lv 打断的受害者」正在运行,**且没有别的待处理中断**。
//
// 后半个条件很重要:若有待处理中断,本次注入只会进队列(中断队列先于排队任务
// 被消费),压力就落在队列上而不是抢占/挂起路径上。
// 返回 false 表示等到超时(调用方仍应注入,保持总量不变)。
func waitForVictim(a *Agent, lv Level, wait time.Duration) bool {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
snap := a.DumpScheduler()
if r := snap.Running; r != nil && len(snap.PendingInterrupts) == 0 {
if r.Class == TaskQueued {
return true // 排队任务:任何中断都该抢占
}
if r.Class == TaskInterrupt && lv > r.Level {
return true // 中断之间:严格更高级才该抢占
}
}
time.Sleep(2 * time.Millisecond)
}
return false
}
func TestStress_MixedLevelInterruptsPlusQueuedInputs(t *testing.T) {
const (
nQueued = 100
nInterrupts = 100
// 窗口要够宽:受害者必须先"在跑"cancel 才来得及把它打断成挂起。
// 太短(如 15ms时任务常在让位信号生效前就自己跑完——抢占判为可行
// 但不会有挂起发生,压力就测不到现场保存/恢复。
llmDelay = 40 * time.Millisecond
)
// L4 只有内核级来源能声明,所以注册一个内置(编译期工厂)插件名。
plugin.RegisterFactory("stress_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
sp := &fixedDelayProvider{delay: llmDelay}
a := New(AgentConfig{
ID: "stress-levels",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
PluginReg: plugin.NewRegistry(),
})
a.Start()
defer a.Stop()
// 第一波100 条排队输入无级别FIFO
for i := 0; i < nQueued; i++ {
a.io.InjectInput("cli", "text", map[string]interface{}{
"content": fmt.Sprintf("queued-%d", i),
})
}
// 给调度器一点时间真正开始跑排队任务,这样第一条中断就有受害者。
time.Sleep(20 * time.Millisecond)
// 第二波100 条中断,级别 L1→L2→L3→L4 轮转(各 25 条)。
// 每条都等到「该被它打断的受害者正在跑」时再注入。
levels := []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical}
names := map[Level]string{
LevelBackground: "L1", LevelMessage: "L2", LevelInteractive: "L3", LevelCritical: "L4",
}
// L4 必须来自内核级插件来源其余用普通来源cli 是内置名,但级别只有 L1..L3 时无所谓)。
src := func(lv Level) string {
if lv == LevelCritical {
return "stress_builtin"
}
return "cli"
}
for i := 0; i < nInterrupts; i++ {
lv := levels[i%len(levels)]
waitForVictim(a, lv, 3*time.Second)
a.io.InjectInterruptTextOpts(src(lv), "cli", fmt.Sprintf("irq-%s-%d", names[lv], i),
agentIO.InjectOptions{Priority: names[lv]})
}
// 排空200 个任务必须全部到达终态,且四容器全空。
snap := waitQuiescent(t, a, nQueued+nInterrupts, 90*time.Second)
// ---- 不丢不重 ----
if snap.Stats.Executed != nQueued+nInterrupts {
t.Fatalf("Executed=%d期望 %d每个任务恰好一个终态",
snap.Stats.Executed, nQueued+nInterrupts)
}
if snap.Stats.Rejected != 0 {
t.Fatalf("容量充足却出现 Rejected=%d背压/深度判定有误)", snap.Stats.Rejected)
}
wantPerLevel := uint64(nInterrupts / len(levels))
for _, lv := range levels {
if got := snap.Stats.InterruptsByLevel[lv]; got != wantPerLevel {
t.Fatalf("%s 登记数=%d期望 %d", names[lv], got, wantPerLevel)
}
}
// ---- 抢占确实发生在**每一级**上 ----
if snap.Stats.Suspended == 0 {
t.Fatalf("100 条中断没有造成任何抢占:%+v", snap.Stats)
}
for _, lv := range levels {
if got := snap.Stats.PreemptsByLevel[lv]; got == 0 {
t.Fatalf("%s 一次都没抢断成功(分级计数=%v", names[lv], snap.Stats.PreemptsByLevel)
}
}
// 每一次“取消流式段”都必须换来一次挂起取消→stepLLM 以 Canceled 收尾→安全点让位)。
// 反向不成立:挂起也可能发生在别的步骤边界上(那时 LLM 已经成功返回、来不及取消)。
if cancelled := uint64(sp.cancelled.Load()); snap.Stats.Suspended < cancelled {
t.Fatalf("被取消的 LLM 调用=%d 但有 %d 次挂起:有取消没换来挂起(现场丢了?)",
cancelled, snap.Stats.Suspended)
}
// 排空后挂起必须等于恢复——挂起来的任务都被接回去了。
if snap.Stats.Suspended != snap.Stats.Resumed {
t.Fatalf("Suspended=%d Resumed=%d排空后必须相等否则有现场丢了",
snap.Stats.Suspended, snap.Stats.Resumed)
}
if snap.Stats.Suspended > nInterrupts {
t.Fatalf("挂起次数=%d 超过中断总数 %d不该有任务被反复挂起这么多次",
snap.Stats.Suspended, nInterrupts)
}
// ---- LLM 调用次数:每个任务至少一次;被抢断的任务重发会增加 ----
if got := sp.n.Load(); got < int64(nQueued+nInterrupts) {
t.Fatalf("LLM 调用=%d少于任务数 %d有任务没跑到 LLM", got, nQueued+nInterrupts)
}
t.Logf("压力通过:%d 排队 + %d 中断L1/L2/L3/L4 各 %d",
nQueued, nInterrupts, nInterrupts/len(levels))
t.Logf(" Executed=%d Rejected=%d Suspended=%d Resumed=%d LLM完成=%d LLM被取消=%d",
snap.Stats.Executed, snap.Stats.Rejected, snap.Stats.Suspended, snap.Stats.Resumed,
sp.n.Load(), sp.cancelled.Load())
t.Logf(" 登记分级=%v 抢断分级=%v",
snap.Stats.InterruptsByLevel, snap.Stats.PreemptsByLevel)
}
// ---------------------------------------------------------------------------
// Phase B把嵌套压到**结构上限**——排队(L0) ← L1 ← L2 ← L3 ← L4(运行中) = 4 帧。
//
// Phase A 的形状100+100 混合)里,中断按严格优先级排队,同一时刻通常只有一层
// 嵌套;真正难的是「中断被中断」逐级下潜。这里按级别**逐级**注入:每一级都等到
// 上一级正在运行才注入,于是必然层层挂起,直到 L4 之上没有更高级别为止。
//
// 然后再验证恢复是**严格 LIFO**L3 → L2 → L1 → 排队任务。
// ---------------------------------------------------------------------------
func waitForRunning(a *Agent, pred func(*Task) bool, wait time.Duration) *Task {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
if r := a.DumpScheduler().Running; r != nil && pred(r) {
return r
}
time.Sleep(time.Millisecond)
}
return nil
}
func TestStress_NestingReachesStructuralBoundThenUnwindsLIFO(t *testing.T) {
const llmDelay = 300 * time.Millisecond // 窗口要够宽,让每次 cancel 都来得及生效
plugin.RegisterFactory("stress_nest_builtin", func(string, map[string]interface{}) (sdk.Plugin, error) {
return nil, nil
})
sp := &fixedDelayProvider{delay: llmDelay}
bus := newLevelRecorder()
a := New(AgentConfig{
ID: "stress-nest",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: NewStageHost(),
EventBus: bus.bus,
PluginReg: plugin.NewRegistry(),
})
a.Start()
defer a.Stop()
// 放一个排队任务(无级别)当栈底。
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "nest-base"})
if r := waitForRunning(a, func(tk *Task) bool { return tk.Class == TaskQueued }, 5*time.Second); r == nil {
t.Fatal("排队任务未开始运行")
}
// 逐级下潜L1 → L2 → L3 → L4L4 必须来自内核级来源)。
type step struct {
lv Level
src string
run Level // 注入前必须在运行的级别
}
steps := []step{
{LevelBackground, "cli", 0}, // 受害者=排队任务
{LevelMessage, "cli", LevelBackground}, // 受害者=L1
{LevelInteractive, "cli", LevelMessage}, // 受害者=L2
{LevelCritical, "stress_nest_builtin", LevelInteractive}, // 受害者=L3
}
for i, st := range steps {
if waitForRunning(a, func(tk *Task) bool {
if st.lv == LevelBackground {
return tk.Class == TaskQueued
}
return tk.Class == TaskInterrupt && tk.Level == st.run
}, 5*time.Second) == nil {
t.Fatalf("第 %d 级(%v注入前未等到预期的受害者运行", i+1, st.lv)
}
a.io.InjectInterruptTextOpts(st.src, "cli", fmt.Sprintf("nest-%d", i+1),
agentIO.InjectOptions{Priority: map[Level]string{
LevelBackground: "L1", LevelMessage: "L2",
LevelInteractive: "L3", LevelCritical: "L4",
}[st.lv]})
// 等这一层真的压进栈(否则下一级的"受害者"条件会被误判)。
deadline := time.Now().Add(5 * time.Second)
for {
if len(a.DumpScheduler().SuspendStack) >= i+1 {
break
}
if time.Now().After(deadline) {
t.Fatalf("第 %d 级注入后栈深未达 %d%d",
i+1, i+1, len(a.DumpScheduler().SuspendStack))
}
time.Sleep(time.Millisecond)
}
}
// 结构上限4 帧 = 排队(L0) + L1 + L2 + L3 挂起L4 运行中。
if n := len(a.DumpScheduler().SuspendStack); n != 4 {
t.Fatalf("嵌套峰值栈深=%d期望 4结构上限", n)
}
if a.sched.canSuspend() {
t.Fatal("已到结构上限L4 之上不该再有下潜余量")
}
// 排空4 层必须逐层弹回,且顺序严格 LIFO。
snap := waitQuiescent(t, a, 5, 30*time.Second)
if n := len(snap.SuspendStack); n != 0 {
t.Fatalf("排空后中断栈=%d期望 0", n)
}
if got, want := bus.resumeLevels(), []int{3, 2, 1, 0}; !equalInts(got, want) {
t.Fatalf("恢复顺序=%v期望 LIFO %v", got, want)
}
// 峰值栈深也就是本次全部挂起帧数4。
if snap.Stats.Suspended != 4 || snap.Stats.Resumed != 4 {
t.Fatalf("Suspended=%d Resumed=%d期望各 4", snap.Stats.Suspended, snap.Stats.Resumed)
}
if sp.cancelled.Load() == 0 {
t.Fatal("逐级下潜必须靠取消流式段生效,却没有一次 LLM 调用被取消")
}
t.Logf("嵌套压力通过:栈深峰值=4结构上限恢复顺序 LIFO=%v流式段被取消=%d 次",
bus.resumeLevels(), sp.cancelled.Load())
}
// levelRecorder 记录 scheduler 事件的挂起/恢复级别。
type levelRecorder struct {
bus *events.Bus
mu sync.Mutex
resume []int
}
func newLevelRecorder() *levelRecorder {
rec := &levelRecorder{bus: events.NewBus()}
rec.bus.Subscribe(events.EventScheduler, func(e *events.Event) {
if fmt.Sprint(e.Payload["action"]) != "resume" {
return
}
lv, _ := e.Payload["level"].(int)
rec.mu.Lock()
rec.resume = append(rec.resume, lv)
rec.mu.Unlock()
})
return rec
}
func (r *levelRecorder) resumeLevels() []int {
r.mu.Lock()
defer r.mu.Unlock()
return append([]int(nil), r.resume...)
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,253 @@
package core
// M2 验收测试调度器骨架就绪队列、选择函数、快照、panic 隔离)。
//
// 设计依据 docs/zh/input-scheduler-design.md §11.4Q1/Q4与 §11.5O1/K1
import (
"fmt"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 新模型的选择顺序immediate → 中断队列 L4→L1 → 栈顶(与队头比级别) → 排队 FIFO。
func TestScheduler_SelectionOrder(t *testing.T) {
s := newScheduler(16)
// 四条中断队列各放一个,入队顺序与级别相反 —— 验证“按级别扫”而非 FIFO。
for _, lv := range []Level{LevelBackground, LevelMessage, LevelInteractive, LevelCritical} {
evt, _ := textEvent("qq", "中断")
s.registerInterrupt(newInterruptTask(evt, lv))
}
// 排队任务两条无级别FIFO
s.enqueue(newSelfTask(selfInputMsg{text: "q1"}))
s.enqueue(newSelfTask(selfInputMsg{text: "q2"}))
var order []Level
for i := 0; i < 4; i++ {
task, _, kind := s.nextRef()
if kind != nextInterrupt {
t.Fatalf("第 %d 个应来自中断队列kind=%v", i+1, kind)
}
order = append(order, task.Level)
s.done(task)
}
want := []Level{LevelCritical, LevelInteractive, LevelMessage, LevelBackground}
for i := range want {
if order[i] != want[i] {
t.Fatalf("中断执行顺序=%v期望 %v", order, want)
}
}
// 中断耗尽后才是排队任务,且保持 FIFO。
for i := 1; i <= 2; i++ {
task, _, kind := s.nextRef()
if kind != nextReady {
t.Fatalf("中断耗尽后应取排队任务kind=%v", kind)
}
if task.Self.text != fmt.Sprintf("q%d", i) {
t.Fatalf("排队任务应 FIFO第 %d 个=%q", i, task.Self.text)
}
s.done(task)
}
if _, _, kind := s.nextRef(); kind != nextNone {
t.Fatal("全空后应返回 nextNone")
}
}
// immediate刚抢占成功的中断必须最先运行——哪怕队列里有更高级别的待处理中断。
// 这是“抢占立即生效”的实现方式,也是它不需要和栈顶比级别的原因。
func TestScheduler_ImmediateWins(t *testing.T) {
s := newScheduler(16)
evt1, _ := textEvent("cli", "L4 待处理")
s.registerInterrupt(newKernelInterruptTask(evt1))
evt2, _ := textEvent("qq", "抢占者")
preemptor := newInterruptTask(evt2, LevelBackground)
s.mu.Lock()
s.setImmediateLocked(preemptor)
s.mu.Unlock()
task, _, kind := s.nextRef()
if kind != nextImmediate || task != preemptor {
t.Fatalf("immediate 必须先运行kind=%v", kind)
}
}
// 中断队列头与中断栈顶比级别,取高者;栈顶是排队任务(无级别)时任何中断都赢。
func TestScheduler_StackTopVsInterruptQueue(t *testing.T) {
s := newScheduler(16)
// 直接构造挂起现场:不走 suspend(),避免 PreemptCount/冷却干扰本用例
// (本用例只测“选择顺序”这一件事)。
pushSuspended := func(id uint64, class TaskClass, lv Level) {
s.mu.Lock()
s.suspendStack = append(s.suspendStack, &suspendedTask{
Task: &Task{ID: id, Class: class, Level: lv}, Frame: &TaskFrame{},
})
s.mu.Unlock()
}
// 每次选取后清掉 running让下一次 registerInterrupt 不把它当成运行任务。
clearRunning := func() {
s.mu.Lock()
s.running = nil
s.mu.Unlock()
}
// 栈顶 L3队列只有 L2 → 恢复栈顶。
pushSuspended(1, TaskInterrupt, LevelInteractive)
evt, _ := textEvent("qq", "L2 待处理")
s.registerInterrupt(newInterruptTask(evt, LevelMessage))
if _, _, kind := s.nextRef(); kind != nextSuspended {
t.Fatalf("栈顶 L3 > 队头 L2 → 应恢复栈顶kind=%v", kind)
}
clearRunning()
// 栈顶 L3队列来了 L4 → 队头优先。
pushSuspended(2, TaskInterrupt, LevelInteractive)
evt2, _ := textEvent("cli", "L4 待处理")
s.registerInterrupt(newKernelInterruptTask(evt2))
if _, _, kind := s.nextRef(); kind != nextInterrupt {
t.Fatalf("队头 L4 > 栈顶 L3 → 应先取中断kind=%v", kind)
}
clearRunning()
// 栈顶是排队任务(无级别)→ 任何中断都赢。
pushSuspended(3, TaskQueued, 0)
evt3, _ := textEvent("qq", "L1 待处理")
s.registerInterrupt(newInterruptTask(evt3, LevelBackground))
if _, _, kind := s.nextRef(); kind != nextInterrupt {
t.Fatalf("排队栈顶可被任何中断打断kind=%v", kind)
}
}
// Q4队列有界满了必须拒绝并计数而不是静默丢弃或无界增长。
func TestScheduler_EnqueueBackpressure(t *testing.T) {
s := newScheduler(2)
if !s.enqueue(newSelfTask(selfInputMsg{text: "a"})) {
t.Fatal("第 1 个任务应入队成功")
}
if !s.enqueue(newSelfTask(selfInputMsg{text: "b"})) {
t.Fatal("第 2 个任务应入队成功")
}
if s.hasRoom() {
t.Fatal("队列已满hasRoom 应为 false")
}
if s.enqueue(newSelfTask(selfInputMsg{text: "c"})) {
t.Fatal("队列满时第 3 个任务必须被拒绝")
}
if s.stats.Rejected != 1 {
t.Fatalf("Rejected=%d期望 1", s.stats.Rejected)
}
if s.stats.Enqueued != 2 {
t.Fatalf("Enqueued=%d期望 2", s.stats.Enqueued)
}
}
// 生命周期next 置 running 并移出队列done 清 running 并累加计数。
func TestScheduler_Lifecycle(t *testing.T) {
s := newScheduler(4)
s.enqueue(newSelfTask(selfInputMsg{text: "a"}))
s.enqueue(newSelfTask(selfInputMsg{text: "b"}))
t1 := s.next()
if t1 == nil || s.running != t1 {
t.Fatal("next 应取出任务并置为 running")
}
if len(s.queue) != 1 {
t.Fatalf("取出后队列长度=%d期望 1", len(s.queue))
}
// 队列内不得同时出现 runningO1三集合互不重叠
for _, q := range s.queue {
if q == t1 {
t.Fatal("running 任务不得同时留在就绪队列")
}
}
s.done(t1)
if s.running != nil {
t.Fatal("done 后 running 应为 nil")
}
if s.stats.Executed != 1 {
t.Fatalf("Executed=%d期望 1", s.stats.Executed)
}
if s.next() == nil {
t.Fatal("队列里还有 bnext 不应为 nil")
}
if s.next() != nil {
t.Fatal("队列已空next 应返回 nil")
}
}
// K1任务 panic 必须被隔离——调度器统计仍然推进,且不向外抛出。
func TestScheduler_PanicIsolationOnExecuteTask(t *testing.T) {
sp := &scriptProvider{}
a := New(AgentConfig{
ID: "sched-panic",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
})
// Event 为 nilhandleInput 解引用即 panic用来验证 recover 生效。
task := &Task{Kind: TaskKindInput, Level: DefaultLevel, Event: nil}
a.executeTask(task) // 若未隔离,这里会 panic 冒泡使测试失败
if a.sched.stats.Executed != 1 {
t.Fatalf("panic 后 Executed=%d期望 1任务失败但调度器存活", a.sched.stats.Executed)
}
if a.sched.running != nil {
t.Fatal("panic 后 running 必须被清空")
}
}
// O1 轻量版:快照与内部状态一致,且 running 不出现在 queue 里。
func TestScheduler_SnapshotConsistency(t *testing.T) {
sp := &scriptProvider{}
a := New(AgentConfig{
ID: "sched-snap",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
})
a.sched.enqueue(newSelfTask(selfInputMsg{text: "a"}))
a.sched.enqueue(newSelfTask(selfInputMsg{text: "b"}))
snap := a.DumpScheduler()
if snap.Running != nil {
t.Fatal("尚未 next快照的 running 应为 nil")
}
if len(snap.Queue) != 2 || snap.Stats.Enqueued != 2 {
t.Fatalf("快照不一致queue=%d enqueued=%d", len(snap.Queue), snap.Stats.Enqueued)
}
r := a.sched.next()
a.executeTask(&Task{Kind: TaskKindSelf, Level: DefaultLevel, Self: selfInputMsg{text: "a"}})
snap = a.DumpScheduler()
if snap.Running != r {
t.Fatal("执行完成后 running 应仍指向未 done 的任务")
}
for _, q := range snap.Queue {
if q == r {
t.Fatal("快照中 running 与 queue 不得重叠")
}
}
// 队列快照必须是副本:改快照不得影响调度器。
snap.Queue = append(snap.Queue, &Task{})
if len(a.DumpScheduler().Queue) != 1 {
t.Fatal("DumpScheduler 必须返回队列副本")
}
}
// Level 的字面量是持久化/日志契约,改值必须是有意的。
func TestLevelContract(t *testing.T) {
if LevelBackground != 1 || LevelMessage != 2 || LevelInteractive != 3 || LevelCritical != 4 {
t.Fatalf("四级取值被改动:%d/%d/%d/%d",
LevelBackground, LevelMessage, LevelInteractive, LevelCritical)
}
if DefaultLevel != LevelBackground {
t.Fatalf("默认级必须是 L1显式才是特权实际 %v", DefaultLevel)
}
}

View File

@ -9,7 +9,43 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
// childTaskState 是一个子任务的生命周期状态。
//
// delivered 代替了早期的“读到即删”:完成通知会写进持久上下文
// formatMergedTimeline 每轮重新注入),模型之后还会再查。读一次就删的
// 话,第二次查询返回“不存在或已过期”——那是一个**永远不会成功的可操作
// 信号**,模型只能一遍遍地重试/汇报,循环永不结束。
type childTaskState struct {
running bool
result string
delivered bool // 结果是否已交付过(用于幂等应答)
seq int64 // 完成顺序,用于有界淘汰
}
// maxRetainedChildTasks 是保留的已完成子任务上限(防结果无限占用内存)。
const maxRetainedChildTasks = 20
// evictChildTasksLocked 淘汰最旧的已完成子任务。调用方必须持有 childMu。
func (a *Agent) evictChildTasksLocked() {
for len(a.childTasks) > maxRetainedChildTasks {
oldestID := ""
var oldestSeq int64
for id, st := range a.childTasks {
if st.running {
continue
}
if oldestID == "" || st.seq < oldestSeq {
oldestID, oldestSeq = id, st.seq
}
}
if oldestID == "" {
return // 剩下全是运行中的,不淘汰
}
delete(a.childTasks, oldestID)
}
}
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
task, _ := tc.Arguments["task"].(string)
if task == "" {
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
@ -33,19 +69,18 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
taskID := fmt.Sprintf("child_%d", a.childNextID)
a.childMu.Unlock()
// 捕获父 Agent 当前输出通道:子任务完成通知回到发起对话的通道,
// 让父 Agent 正常感知并可回复用户(而非走无记忆整理路径丢失通知)。
parentChannel := a.currentOutputChannel
// parentChannel 由调用方(任务帧)传入:子任务完成通知回到**发起这次
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
if parentChannel == "" || parentChannel == channelConsolidation {
parentChannel = "cli"
}
a.childMu.Lock()
a.childRunning[taskID] = true
a.childTasks[taskID] = &childTaskState{running: true}
a.childMu.Unlock()
go a.runChildTask(taskID, task, parentChannel, maxTurns)
return fmt.Sprintf("子任务已启动ID: %s最多 %d 轮)完成后会自动通知你,届时请使用 child_result 工具查看输出", taskID, maxTurns)
return fmt.Sprintf("子任务已启动ID: %s最多 %d 轮)完成后会自动通知你,届时用 child_result 查看输出即可(**只需查询一次**", taskID, maxTurns)
}
// defaultChildMaxTurns 子 Agent 默认工具轮数(可被 spawn_child 的 max_turns 参数覆盖)。
@ -114,7 +149,7 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
case ct.Name == "spawn_child" || ct.Name == "plgreload":
result = fmt.Sprintf("子 Agent 不允许调用系统工具: %s", ct.Name)
default:
result = a.executeToolCall(ct)
result = a.executeToolCall(ct, parentChannel)
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: resp.Content, ToolCalls: []agentAPI.ToolCall{ct}})
msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: ct.ID, Content: result})
@ -126,19 +161,30 @@ func (a *Agent) runChildTask(taskID, task string, parentChannel string, maxTurns
}
a.childMu.Lock()
a.childResults[taskID] = finalResult
delete(a.childRunning, taskID)
if st := a.childTasks[taskID]; st != nil {
st.running = false
st.result = finalResult
a.childSeq++
st.seq = a.childSeq
}
a.evictChildTasksLocked()
a.childMu.Unlock()
log.Printf("[child] %s done: %s", taskID, truncateStr(finalResult, 100))
notification := fmt.Sprintf("子任务 %s 已完成,请调用 child_result 工具查看输出", taskID)
notification := fmt.Sprintf("子任务 %s 已完成。请用 child_result 工具查看输出(只需查询一次;重复查询不会返回失败)。", taskID)
a.injectSelfChannel(selfInputMsg{
text: notification,
channel: parentChannel, // 回到父对话通道,正常处理(写入上下文 + emit 响应)
})
}
// executeChildResultTool 取回子任务结果。
//
// **幂等**:结果不会被“读到即删”,重复查询返回同一结果或一条明确提示。
// 这一点至关重要——完成通知会长期留在持久上下文里formatMergedTimeline
// 每轮重新注入),如果重复查询返回“不存在”这种失败信号,模型会认定任务
// 未完成而无限重试(实测单轮 35 次工具调用、持续 514 秒)。
func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
taskID, _ := tc.Arguments["task_id"].(string)
if taskID == "" {
@ -146,18 +192,25 @@ func (a *Agent) executeChildResultTool(tc agentAPI.ToolCall) string {
}
a.childMu.Lock()
result, ok := a.childResults[taskID]
if ok {
delete(a.childResults, taskID)
st, ok := a.childTasks[taskID]
if !ok {
a.childMu.Unlock()
return fmt.Sprintf("子任务 %s 结果】\n%s", taskID, result)
return fmt.Sprintf("子任务 %s 不存在:从未创建该 ID请核对 spawn_child 返回的 ID 拼写)", taskID)
}
if a.childRunning[taskID] {
if st.running {
a.childMu.Unlock()
return fmt.Sprintf("子任务 %s 仍在运行中,尚未完成。请等待完成通知后再查询。", taskID)
}
first := !st.delivered
st.delivered = true
result := st.result
a.childMu.Unlock()
return fmt.Sprintf("子任务 %s 不存在或已过期", taskID)
if first {
return fmt.Sprintf("【子任务 %s 结果】\n%s", taskID, result)
}
// 重复查询不是失败:明确告诉模型“任务已完成、结果已给过”,让它停止重试。
return fmt.Sprintf("【子任务 %s 已完成】结果已在上文提供(见先前的 child_result 工具结果),无需重复查询;请直接基于上文结果继续。", taskID)
}
func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
@ -195,5 +248,3 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
}
}

View File

@ -0,0 +1,88 @@
package core
import (
"fmt"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// child_result 必须幂等——这是 "任务已结束但核心循环不结束" 的根因修复。
//
// 子任务完成通知会写进持久上下文formatMergedTimeline 每轮重新注入),
// 模型之后还会再查。若第二次查询返回 "不存在或已过期" 这种**永久失败信号**
// 模型会认定任务未完成而无限重试/汇报(生产实测:单轮 35 次工具调用、
// 持续 514 秒)。
func TestChildResultIsIdempotent(t *testing.T) {
a := New(AgentConfig{ID: "t"})
a.childMu.Lock()
a.childTasks["child_1"] = &childTaskState{result: "任务完成:已创建 3 个日程", seq: 1}
a.childMu.Unlock()
call := func(id string) string {
return a.executeChildResultTool(agentAPI.ToolCall{
Name: "child_result",
Arguments: map[string]interface{}{"task_id": id},
})
}
first := call("child_1")
if !strings.Contains(first, "任务完成:已创建 3 个日程") {
t.Fatalf("首次查询应返回结果,实际: %q", first)
}
second := call("child_1")
if strings.Contains(second, "不存在") {
t.Fatalf("重复查询不能返回失败信号(会驱动模型无限重试),实际: %q", second)
}
if !strings.Contains(second, "已完成") {
t.Fatalf("重复查询应明确告知「已完成、结果已提供」,实际: %q", second)
}
// 只有从未创建过的 ID 才应报 "不存在"。
missing := call("child_999")
if !strings.Contains(missing, "不存在") {
t.Fatalf("未知 ID 应报不存在,实际: %q", missing)
}
}
// 运行中与已完成必须给出不同答复,否则模型无法判断该等还是该继续。
func TestChildResultRunningVsDone(t *testing.T) {
a := New(AgentConfig{ID: "t"})
a.childMu.Lock()
a.childTasks["child_run"] = &childTaskState{running: true}
a.childMu.Unlock()
got := a.executeChildResultTool(agentAPI.ToolCall{
Name: "child_result",
Arguments: map[string]interface{}{"task_id": "child_run"},
})
if !strings.Contains(got, "仍在运行中") {
t.Fatalf("运行中的任务应提示仍在运行,实际: %q", got)
}
}
// 保留的结果必须有界,不能随子任务数量无限增长。
func TestChildTaskRetentionBounded(t *testing.T) {
a := New(AgentConfig{ID: "t"})
a.childMu.Lock()
for i := 0; i < maxRetainedChildTasks*3; i++ {
a.childSeq++
a.childTasks[fmt.Sprintf("child_%d", i)] = &childTaskState{result: "r", seq: a.childSeq}
}
a.evictChildTasksLocked()
n := len(a.childTasks)
a.childMu.Unlock()
if n > maxRetainedChildTasks {
t.Fatalf("保留子任务数=%d超过上限 %d", n, maxRetainedChildTasks)
}
// 淘汰应保留最新的:最早的那批必须已不在
if _, ok := a.childTasks["child_0"]; ok {
t.Fatal("淘汰应优先丢弃最旧的已完成任务")
}
}

View File

@ -12,9 +12,14 @@ import (
)
func (a *Agent) runStage(stage sdk.Stage, ctx *sdk.StageContext) bool {
// 通道从 stage ctx 上取(由发起方写入)——内核不持有"当前通道"。
ch := ""
if ctx != nil && ctx.Extra != nil {
ch, _ = ctx.Extra["output_channel"].(string)
}
payload := map[string]interface{}{
"phase": string(stage),
"channel": a.currentOutputChannel,
"channel": ch,
}
if ctx != nil && len(ctx.ToolCalls) > 0 {
payload["tool"] = ctx.ToolCalls[0].Name

View File

@ -73,6 +73,8 @@ func collectKernelStatus(
BuildTime: meta.BuildTime,
SDKCompatible: meta.SDKCompatibleVersion,
KernelName: meta.KernelName,
// AGPL-3.0 §13状态页向网络使用者展示取得源码的入口。
SourceURL: meta.SourceURL,
},
Runtime: RuntimeStatus{
Goroutines: runtime.NumGoroutine(),
@ -154,7 +156,6 @@ func collectKernelStatus(
}
}
// Tracker
if trk != nil {
status.Tracker.Available = true
@ -190,12 +191,19 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
socialStore = a.social
}
var trk *tracker.Tracker
if a.tracker != nil {
trk = a.tracker
}
// 注意knowledge 在 collectKernelStatus 里是**接口**参数,
// 而 (*knowledge.Store)(nil) 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
// 所以这里必须先判具体指针再进行接口赋值healthcheck_kernel 会走到这条路径)。
var knowledgeLister interface{ List() []string }
if a.knowledge != nil {
knowledgeLister = a.knowledge
}
ks := collectKernelStatus(
a.startTime,
string(a.id),
@ -205,15 +213,44 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
a.io,
a.pluginReg,
a.memory,
a.knowledge,
knowledgeLister,
a.docStore,
textMem,
socialStore,
trk,
)
ks.ONNX = a.onnxStatus()
ks.Scheduler = a.schedulerStatus()
return ks
}
// onnxStatus 汇总统一多模态向量空间ONNX 模型)的启用状态。
//
// 判据是 Loaded()provider 真正打开且元数据合法),**不是**「配置里写了 provider」——
// 后者在模型缺失 / 运行时缺失时也为真,拿它当判据就是假绿。
func (a *Agent) onnxStatus() sdk.ONNXStatus {
st := sdk.ONNXStatus{Provider: a.embeddingProvider}
if a.multimodalSpace != nil && a.multimodalSpace.Loaded() {
st.Enabled = true
st.Dim = a.multimodalSpace.Dim()
st.Fingerprint = a.multimodalSpace.Fingerprint()
// 模态是可选能力:只有底层 provider 报出来时才带出。
if mr, ok := a.multimodalSpace.(interface{ Modalities() []string }); ok {
st.Modalities = mr.Modalities()
}
return st
}
switch {
case a.embeddingError != "":
st.Reason = "打开失败: " + a.embeddingError
case a.embeddingProvider == "":
st.Reason = "未配置统一向量空间 provider走词嵌入/TF-IDF 回退路径)"
default:
st.Reason = "provider 未加载"
}
return st
}
var _ StatusProvider = (*Agent)(nil)
var _ sdk.StatusAPI = (*Agent)(nil)

View File

@ -0,0 +1,103 @@
package core
import (
"strings"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
)
// statusSpace 是带模态元数据的假统一空间ProviderAdapter 的 Modalities() 形状)。
type statusSpace struct{ fakeSpace }
func (statusSpace) Modalities() []string { return []string{"text", "image"} }
// statusSpaceUnloaded 模拟「provider 建了但没加载成功」。
type statusSpaceUnloaded struct{ fakeSpace }
func (statusSpaceUnloaded) Loaded() bool { return false }
// healthcheck_kernel 必须能回答两件事:内核版本号、以及**是否真的启用了 ONNX 模型**。
//
// 判据要点:只有 provider 真正 Loaded() 才算启用 ——「配置里写了 provider」不算
// 否则模型缺失/运行时缺失时会报成已启用(假绿)。
func TestKernelStatusReportsVersionAndONNX(t *testing.T) {
t.Run("已启用:带出 provider/维度/指纹/模态", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
multimodalSpace: statusSpace{},
embeddingProvider: "chineseclip",
}
st := a.GetKernelStatus()
if !st.ONNX.Enabled {
t.Fatal("Loaded() 为真时 onnx.enabled 必须为真")
}
if st.ONNX.Provider != "chineseclip" || st.ONNX.Dim != 2 || st.ONNX.Fingerprint != "fake-space" {
t.Fatalf("onnx 身份字段不对: %+v", st.ONNX)
}
if len(st.ONNX.Modalities) != 2 || st.ONNX.Reason != "" {
t.Fatalf("模态/原因不对: %+v", st.ONNX)
}
// 内核版本号必须随状态一起报:人格卡要求「版本以运行时快照为准」靠的就是这一项
if st.Build.Version == "" || st.Build.KernelName == "" {
t.Fatalf("build 段缺少版本/内核名: %+v", st.Build)
}
})
t.Run("打开失败enabled=false 且给出具体原因", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
embeddingProvider: "chineseclip",
embeddingError: `embedding: open provider "chineseclip": model dir missing`,
}
st := a.GetKernelStatus()
if st.ONNX.Enabled {
t.Fatal("打开失败时不能报 enabled")
}
if st.ONNX.Provider != "chineseclip" {
t.Fatalf("未启用时仍应带出配置的 provider: %+v", st.ONNX)
}
if !strings.Contains(st.ONNX.Reason, "model dir missing") {
t.Fatalf("原因应包含具体错误: %q", st.ONNX.Reason)
}
})
t.Run("未配置:说明会走回退路径", func(t *testing.T) {
a := &Agent{io: agentIO.NewIOManager()}
st := a.GetKernelStatus()
if st.ONNX.Enabled || st.ONNX.Reason == "" {
t.Fatalf("未配置时应 enabled=false 且有原因: %+v", st.ONNX)
}
})
t.Run("provider 存在但未加载", func(t *testing.T) {
a := &Agent{
io: agentIO.NewIOManager(),
multimodalSpace: statusSpaceUnloaded{},
embeddingProvider: "qwen3vl",
}
st := a.GetKernelStatus()
if st.ONNX.Enabled {
t.Fatal("Loaded() 为假时不能报 enabled")
}
if st.ONNX.Reason == "" {
t.Fatalf("应给出未加载的原因: %+v", st.ONNX)
}
})
}
// collectKernelStatus 的 knowledge 参数是**接口**类型,而 (*knowledge.Store)(nil)
// 塞进接口后 `ks != nil` 仍为真 → 调 List() 直接 panic。
// 这条测试钉住这个成因:一旦不再 panic说明参数形状变了
// GetKernelStatus 里的 typed-nil 守卫就该同步删掉(否则它变成无意义代码)。
func TestCollectKernelStatusTypedNilKnowledgePanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("typed-nil 交给接口参数却未 panic成因已变请更新守卫与本测试")
}
}()
var nilStore *knowledge.Store
_ = collectKernelStatus(time.Now(), "a", "", 0, nil, nil, nil, nil, nilStore, nil, nil, nil, nil)
}

View File

@ -26,7 +26,7 @@ func TestAccumulateStreamToolCalls(t *testing.T) {
close(ch)
}()
resp, err := accumulateStream(context.Background(), ch, nil)
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
@ -56,7 +56,7 @@ func TestAccumulateStreamContent(t *testing.T) {
ch <- agentAPI.StreamChunk{Done: true, FinishReason: "stop"}
close(ch)
}()
resp, err := accumulateStream(context.Background(), ch, nil)
resp, err := accumulateStream(context.Background(), ch, nil, "cli")
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}

View File

@ -41,7 +41,7 @@ func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
}
close(ch)
resp, err := accumulateStream(ctx, ch, nil)
resp, err := accumulateStream(ctx, ch, nil, "cli")
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}

934
internal/agent/core/task.go Normal file
View File

@ -0,0 +1,934 @@
package core
// 任务状态机M1行为等价的纯重构
//
// 背景与设计见 docs/zh/input-scheduler-design.md。
//
// M1 只做一件事:把原先「一个 425 行的 process() 大函数」拆成
// **显式 step 游标 + TaskFrame**。目的不是加能力,而是让「现场」变成数据——
// 之后 M3 才能把帧存进 suspendStack 并在安全点恢复。
//
// 行为等价的判据:既有全部 agent 测试通过,且 R3/X3见设计文档 §11通过。
//
// 本文件**不引入**优先级、抢占、队列与并发;那些在 M2 起逐层加上。
//
// Step 与安全点(设计文档 §4.2
// step 与 step 之间是安全点StepToolExec工具执行与 StepPrepare 中的
// ONNX/落盘片段是**临界区**,执行中不可抢占。
//
// 与设计文档的差异:文档里的 StepBeforeOutput / StepAfterOutput / StepCommit /
// StepFinish 属于 emitResponse 与 processInput 层M1 不动它们M6 再迁入帧)。
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Step 是任务状态机的游标。
type Step int
const (
// StepPrepare 构建消息与工具表、应用中断标记、跑 pre_action 阶段。
StepPrepare Step = iota
// StepLLM 轮次顶部(中断/占位)+ LLM 调用(含 provider 回退与重试)+ post_action。
StepLLM
// StepToolBegin 取本批下一个工具,跑 before_toolcall被拒/插件不健康则跳过。
StepToolBegin
// StepToolExec 执行工具。**临界区**:副作用不可回滚,执行中不是安全点。
StepToolExec
// StepToolAfter after_toolcall 阶段、上下文裁剪、消息与事件组装、批后中断检查。
StepToolAfter
// StepTurnEnd 收尾本批并进入下一轮。
StepTurnEnd
)
// stepOutcome 是一次 step 执行的结果。
type stepOutcome int
const (
// outcomeContinue 继续执行下一个 step游标可能停在原地以表达"重跑本 step")。
outcomeContinue stepOutcome = iota
// outcomeDone 任务成功结束,响应在 frame.Response。
outcomeDone
// outcomeFailed 任务失败结束,错误在 frame.Err。
outcomeFailed
// outcomeSuspended 任务在安全点被抢占挂起帧已保存M3b 起使用)。
outcomeSuspended
)
// taskTerminal 是任务的终态种类(设计文档 §7每个任务恰有一个终态
type taskTerminal int
const (
// terminalNone 任务尚未结束,需进入 run 段。
terminalNone taskTerminal = iota
// terminalOK 正常完成(已提交上下文并回执)。
terminalOK
// terminalError 执行出错(已提交错误响应)。
terminalError
// terminalStageShortCircuit 被 on_input 阶段短路(响应已发出)。
terminalStageShortCircuit
// terminalSkipped 未进入执行:解析失败或被去重。
terminalSkipped
// terminalConsolidation 走记忆整理专用路径,已处理完毕。
terminalConsolidation
// terminalSuspended 被抢占挂起等待恢复M3b 起使用)。
terminalSuspended
)
// TaskFrame 承载一个任务在安全点之间必须存活的所有状态。
//
// 不变量(设计文档 §8.1 I3帧是**纯数据**;不得持有任何锁或资源跨越安全点。
type TaskFrame struct {
Input string
StageCtx *sdk.StageContext
// 跨轮次状态
Msgs []agentAPI.Message
Tools []interface{}
ToolsUsed []string
ToolResults []ToolResultItem
Turn int
LastBatchReplyOnly bool
// 当前工具批
PendingTools []agentAPI.ToolCall
ToolIdx int
ReplyOnly bool
ContentOnce bool
CurTool agentAPI.ToolCall
CurToolPlugin string
CurResult string
Resp *agentAPI.CompletionResponse
// 游标与终态
Step Step
Response string
Err error
// ---- 任务层现场(原 processInput 的局部变量)----
//
// 这些字段让帧覆盖 prepare → step… → finish 全生命周期:挂起发生在 run 段的
// 安全点,恢复后由 finish 段统一提交context.Append + emitResponse +
// emitMemoryCandidate因此挂起不会重复提交。
Evt *agentIO.InputEvent
CleanInput string
IsInterrupt bool
StartedAt time.Time
Terminal taskTerminal
// OutputChannel 是本任务的输出通道(来源通道的稳定副本)。
//
// 这是本任务通道的**唯一**来源:内核不持有"当前通道"可变状态N0 已删除
// Agent.currentOutputChannel。那类字段会被后来的任务覆盖而被打断任务
// 恢复时不重新 prepareresumeTask 只 rebase 前缀),于是两任务串台——
// 被打断任务的回复发到中断任务的通道上(见
// TestPreempt_ResumeKeepsOwnOutputChannel
OutputChannel string
// PrefixLen 是 stepPrepare 构建的**基础前缀**长度system + timeline + 用户输入)。
// 恢复时用它把「本任务自己的现场」接回重建后的前缀之上(见 rebaseFramePrefix
PrefixLen int
// InputBlocks 是本轮输入携带的多模态块;重建前缀时要重新挂回。
InputBlocks []agentAPI.ContentBlock
}
// outputChannelOf 从**输入事件**推导本次输出应走的通道。
//
// 内核不持有"当前通道"可变状态:那类字段会被后来的任务(中断任务)覆盖,
// 使被打断任务恢复后的提示词/事件标签串台。通道只跟着事件与帧走。
func outputChannelOf(evt *agentIO.InputEvent) string {
if evt == nil {
return ""
}
if evt.OutputChannel != "" {
return evt.OutputChannel
}
return evt.Source
}
// isCriticalChannel 报告某个通道是否是**整任务不可抢占**的临界区。
//
// 目前只有 `_consolidation_`(记忆整理直接改图库)。工具执行/ONNX/CAS 属于
// **单步**临界区,由"只在 step 之间检查让位"天然保护,不在这里列。
func isCriticalChannel(channel string) bool {
return channel == channelConsolidation
}
func (a *Agent) newTaskFrame(input string, stageCtx *sdk.StageContext) *TaskFrame {
return &TaskFrame{Input: input, StageCtx: stageCtx, Step: StepPrepare}
}
// runTaskSteps 驱动状态机直到任务结束或被抢占挂起。
//
// 这是 M1 的驱动循环M3a 从 process() 抽出来,使调用方可以拿到
// outcomeSuspended 并把帧留给调度器保存。
func (a *Agent) runTaskSteps(f *TaskFrame) stepOutcome {
// 步数上限只是防"转移缺失导致死循环"的护栏;正常任务远达不到。
const maxSteps = 1 << 20
for i := 0; i < maxSteps; i++ {
// 安全点:只在 step 之间检查让位。临界区StepToolExec不在此列
// 因为让位信号由 interruptLoop 置位、而本循环是唯一读帧者。
if !isCriticalChannel(f.OutputChannel) && a.sched.preemptGrantedFor() && a.sched.canSuspend() {
return outcomeSuspended
}
// 工具轮次硬上限(设计文档 D6在发起下一轮 LLM 前收尾。
// f.Turn 只在 stepTurnEnd 递增,所以它等于「已完成的工具批数」;
// 因此这里允许 maxToolTurns 批,而不会多跑第 maxToolTurns+1 轮。
if f.Step == StepLLM && a.maxToolTurns > 0 && f.Turn >= a.maxToolTurns {
log.Printf("[agent] 已达最大工具轮次 %dturn=%d强制收尾", a.maxToolTurns, f.Turn)
if f.Resp != nil && strings.TrimSpace(f.Resp.Content) != "" {
f.Response = f.Resp.Content
} else {
f.Response = fmt.Sprintf("[系统] 已达到最大工具轮次 %d任务中止。", a.maxToolTurns)
}
return outcomeDone
}
switch a.step(f) {
case outcomeDone:
return outcomeDone
case outcomeFailed:
return outcomeFailed
case outcomeSuspended:
return outcomeSuspended
}
}
f.Err = fmt.Errorf("agent: task step budget exhausted状态机未收敛疑似转移缺失")
return outcomeFailed
}
// process 是保留给 processConsolidation 与测试的薄壳,返回与原实现相同的四元组。
//
// 注意M3a 起**不再持 a.mu**——调度器是唯一执行者,而挂起不能持锁。
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
if a.provider == nil {
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
}
f := a.newTaskFrame(input, stageCtx)
switch a.runTaskSteps(f) {
case outcomeDone:
return f.Response, f.ToolsUsed, f.ToolResults, nil
case outcomeFailed:
return "", f.ToolsUsed, f.ToolResults, f.Err
default:
// 不该发生process() 不参与挂起(只有 runInputTask 会)。
return "", f.ToolsUsed, f.ToolResults,
fmt.Errorf("agent: task suspended outside scheduler")
}
}
// runInputTask 是一个输入任务的完整生命周期prepare → run → finish。
//
// 它是原 processInput 的全部职责,被拆成三段而不是一个大函数,目的只有一个:
// 让帧可以跨安全点被挂起——挂起后由调度器保存,恢复时接着 run 段继续,
// 而 finish 段(上下文提交与回执)只在任务真正结束时执行一次。
//
// M3a 还没有抢占,因此 outcomeSuspended 只会由 M3b 的抢占检查产生。
func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome) {
// 临界区标记由调度器 goroutine 维护,任务结束(含挂起)即清。
// interceptLoop 读它来决定“能不能取消”,因此必须是原子的。
defer a.sched.setCritical(false)
f, term := a.prepareInputTask(evt)
switch term {
case terminalSkipped, terminalStageShortCircuit, terminalConsolidation:
return nil, outcomeDone
}
out := a.runTaskSteps(f)
if out == outcomeSuspended {
f.Terminal = terminalSuspended
return f, outcomeSuspended
}
a.finishInputTask(f, out)
return f, out
}
// rebaseFramePrefix 把被挂起任务的上下文现场「加载回中断任务之上」。
//
// 语义(用户明确):
// - 中断打断时,被挂起任务自到达以来累积的全部现场(含 toolcall被保护
// - 中断在上一个任务之前的**完整状态**上开始运行(所以中断看不到本任务的部分进展);
// - 中断结束后,把被挂起任务与其现场加载回中断任务**之上**再继续——
// 即中断已提交的那段上下文留在下面(前缀),本任务自己的现场落回其上。
//
// 实现重建基础前缀system + timeline + 用户输入);由于中断结束时已把它的
// 输入/输出提交进 a.context重建出的 timeline 已含中断的效果;再把本任务
// 自己的尾部Stage 上下文 + 工具轮产物 + 占位)原样接回。
func (a *Agent) rebaseFramePrefix(f *TaskFrame) {
if f == nil || f.PrefixLen <= 0 || f.PrefixLen > len(f.Msgs) {
return
}
tail := append([]agentAPI.Message(nil), f.Msgs[f.PrefixLen:]...)
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
prefix := a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
// 重建会丢掉 prepare 段对尾部消息的两处改写,这里等价地补回。
if f.IsInterrupt && len(prefix) > 0 {
last := prefix[len(prefix)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
prefix[len(prefix)-1] = last
}
if len(f.InputBlocks) > 0 && len(prefix) > 0 {
prefix[len(prefix)-1].Blocks = f.InputBlocks
}
f.Msgs = append(prefix, tail...)
f.PrefixLen = len(prefix)
}
// prepareInputTask 执行 processInput 的前半段(去重、通道解析、阶段、裁剪、
// 输入事件落上下文)。返回终态不为 terminalNone 时调用方不得进入 run 段。
func (a *Agent) prepareInputTask(evt *agentIO.InputEvent) (*TaskFrame, taskTerminal) {
start := time.Now()
in, ok := a.resolveInput(evt)
if !ok {
// 空输入(文本与媒体都空):没有可处理内容,但同步调用方仍在等回执。
a.emitSkippedReply(evt, "empty_input")
return nil, terminalSkipped
}
// 去重按文本做webui/GUI 断线重连会重放未确认消息。
// 带媒体时跳过——媒体输入的 alt 文案("[从 qq 收到了 image]")对不同图片
// 是同一句,拿它去重会把连发的两张图误判成重复。
if len(in.blocks) == 0 && a.isDuplicateInput(evt.Source, in.text) {
log.Printf("[agent] dropped duplicate input from %s: %s", evt.Source, truncateStr(in.text, 60))
// 去重是「不处理」而不是「不回」否则同步调用方cli/clawhub 无超时)
// 会永久挂起(设计文档 §7 不变量 I5、§11.3 X2/X4
a.emitSkippedReply(evt, "duplicate")
return nil, terminalSkipped
}
// 通道只从**输入事件**推导,内核不持有"当前通道"可变状态
// (见 outputChannelOf这消除了中断任务覆盖它导致被打断任务串台的整类问题
// 进入本任务的临界区属性(记忆整理整任务不可抢占)。
// 必须在 processConsolidation 之前设置——它就在下面同步执行。
a.sched.setCritical(isCriticalChannel(outputChannelOf(evt)))
if evt.OutputChannel == channelConsolidation {
a.processConsolidation(evt, in.text)
return nil, terminalConsolidation
}
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
// 原始 data/url也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
//payload 里才有 data/url插件注入的是成品 block取不到原始数据。
if evt.Type == "image" || evt.Type == "audio" {
a.pendingMedia = evt.Payload
}
// 媒体先落进 CAS。不存的后果是 ContextEvent.Input 只剩一句 alt 文本,
// base64 随 message 数组发给模型后就丢了。
if len(in.blocks) > 0 {
a.stageMediaDigests(a.captureBlockMedia(in.blocks, in.captureTool)...)
}
noMemory := false
if v, ok := evt.Payload["no_memory"].(bool); ok {
noMemory = v
}
if !noMemory && a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.NoMemory {
noMemory = true
}
}
// 工具提醒/中断terminal_watch、timer 等)不是用户发言:
// 以 system 角色注入 LLM且不写入用户对话履历。
isInterrupt, _ := evt.Payload["interrupt"].(bool)
a.interruptInput = isInterrupt
if isInterrupt {
noMemory = true
}
stageCtx := a.stageCtxFromInput(in.text, evt.Source, "")
stageCtx.Extra["input_source"] = evt.Source
stageCtx.Extra["output_channel"] = evt.OutputChannel
if len(in.blocks) > 0 {
stageCtx.Extra["media_blocks"] = in.blocks
stageCtx.Extra["media_type"] = in.mediaType
}
if noMemory {
stageCtx.NoMemory = true
}
a.injectSourceContext(stageCtx, evt)
if a.runStage(sdk.StageOnInput, stageCtx) {
a.emitResponse(evt, *stageCtx.Response)
return nil, terminalStageShortCircuit
}
input := stageCtx.RawMessage
// 计算层用的清洗文本(不改原文):通道 Cleaner 提取语义内容后用于向量化/提关键词
cleanInput := input
if a.io != nil {
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
cleanInput = chDef.Cleaner(input)
}
}
// upload_* 字段一并转发webui 的 EventRawInput 订阅方靠它们还原附件卡片。
rawPayload := map[string]interface{}{"content": input, "source": evt.Source}
for _, k := range []string{"upload_url", "upload_type", "upload_size", "upload_name"} {
if v, ok := evt.Payload[k]; ok {
rawPayload[k] = v
}
}
a.publishEvent(events.EventRawInput, rawPayload)
archived := a.pruneOnInput(evt, cleanInput)
if archived > 0 {
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
}
// 本轮 inputch处理表按它记账+ contextfull 检测(只有驻留子设了钩子)。
a.tableMu.Lock()
a.currentInputch = outputChannelOf(evt)
a.tableMu.Unlock()
if !isInterrupt {
a.context.Append(ContextEvent{
Timestamp: start,
Source: evt.Source,
Input: input,
})
}
f := a.newTaskFrame(input, stageCtx)
f.Evt = evt
f.CleanInput = cleanInput
f.IsInterrupt = isInterrupt
f.StartedAt = start
// 通道记进帧:恢复时用它把 agent 级字段改回来(见 TaskFrame.OutputChannel
f.OutputChannel = outputChannelOf(evt)
return f, terminalNone
}
// finishInputTask 执行 processInput 的后半段(日志、上下文提交、回执、记忆候选)。
//
// 只在任务真正结束时调用一次——这正是不变量 I5每任务恰一次终态的落点。
func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) {
evt := f.Evt
// pendingMedia 是「本轮」语义:任务结束即清(挂起时保留,见 runInputTask
if evt != nil && (evt.Type == "image" || evt.Type == "audio") {
a.pendingMedia = nil
}
if out == outcomeFailed {
log.Printf("[agent] process %s error: %v", evt.Type, f.Err)
resp := fmt.Sprintf("处理错误: %v", f.Err)
a.emitResponse(evt, resp)
a.context.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Input: f.Input, Response: resp})
f.Terminal = terminalError
return
}
// inputch 处理表:本轮**未主动写入**时由系统自动写(保证每轮必有记录)。
// 只有驻留子会用到(根 agent 的 children 为 0 时这只是几个空操作)。
a.autoRecordInputch(f)
elapsed := time.Since(f.StartedAt)
log.Printf("[agent] %s from %s → response (%dms, tools=%v)",
evt.Type, evt.Source, elapsed.Milliseconds(), f.ToolsUsed)
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的。
turnEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: f.CleanInput,
Response: f.Response,
ToolsUsed: f.ToolsUsed,
ToolResults: f.ToolResults,
}
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
a.context.Append(turnEvt)
a.emitResponse(evt, f.Response)
if !f.StageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed)
}
f.Terminal = terminalOK
}
// step 执行恰好一个 step。
func (a *Agent) step(f *TaskFrame) stepOutcome {
switch f.Step {
case StepPrepare:
return a.stepPrepare(f)
case StepLLM:
return a.stepLLM(f)
case StepToolBegin:
return a.stepToolBegin(f)
case StepToolExec:
return a.stepToolExec(f)
case StepToolAfter:
return a.stepToolAfter(f)
case StepTurnEnd:
return a.stepTurnEnd(f)
default:
f.Err = fmt.Errorf("agent: unknown task step %d", f.Step)
return outcomeFailed
}
}
// stepPrepare 构建本轮任务的初始帧。
func (a *Agent) stepPrepare(f *TaskFrame) stepOutcome {
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
memContext := a.buildMemoryContext(f.Input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, f.Input)
f.Tools = a.buildToolDefs()
f.Msgs = a.buildMessages(sysPrompt, f.Input, a.contextTokenBudget(budget))
// 工具提醒interrupt以 system 角色注入,不让模型误认为用户发言
if a.interruptInput {
last := f.Msgs[len(f.Msgs)-1]
last.Role = "system"
last.Content = "[中断消息] " + last.Content
f.Msgs[len(f.Msgs)-1] = last
a.interruptInput = false
}
if blocks, ok := f.StageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
if len(f.Msgs) > 0 {
f.Msgs[len(f.Msgs)-1].Blocks = blocks
}
f.InputBlocks = blocks
}
// 基础前缀到此为止system + timeline + 用户输入);其后的 Stage 上下文
// 与工具轮产物都属于“本任务自己的现场”,恢复时要接回重建后的前缀之上。
f.PrefixLen = len(f.Msgs)
log.Printf("[agent] tool call loop start, max_ctx=%d target=%d fixed=%d mem=%d ctx=%d %d tools, %d events, personality=%t, docs=%d",
budget.MaxContext, budget.TargetUsage, budget.FixedTokens, budget.MemoryTokens, budget.ContextTokens,
len(f.Tools), a.context.Len(),
a.personality != nil && a.personality.Content != "",
a.docStoreSize())
if a.runStage(sdk.StagePreAction, f.StageCtx) {
f.Response = *f.StageCtx.Response
return outcomeDone
}
if len(f.StageCtx.ContextMsgs) > 0 {
for _, m := range f.StageCtx.ContextMsgs {
role, _ := m["role"].(string)
content, _ := m["content"].(string)
if role != "" {
f.Msgs = append(f.Msgs, agentAPI.Message{Role: role, Content: content})
}
}
}
// 上下文占满检测:此刻 f.Msgs 已建好(含 system + timeline + 本轮输入)。
// 只有驻留子设了 onContextFull ⇒ 对根 agent 是 no-op。
a.checkContextFull(f)
f.Step = StepLLM
return outcomeContinue
}
// stepLLM 是轮次顶部与 LLM 调用。
//
// 取消context.Canceled 且 agent 未退出)时**留在本 step 并 Turn++**——等价于
// 原实现的 `continue`:重新排空中断、补占位、重新请求。抢占挂起将在 M3 从这里接管。
func (a *Agent) stepLLM(f *TaskFrame) stepOutcome {
// zen 兼容网关要求请求的最后一条消息必须是 user(thinking 续写模式校验),
// 工具轮产出的 tool/assistant 消息作结尾会被 400 拒绝,故补一条 user 占位。
f.Msgs = dropContinuationPlaceholders(f.Msgs)
if last := f.Msgs[len(f.Msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
f.Msgs = append(f.Msgs, agentAPI.Message{
Role: "user",
Content: continuationFor(f.LastBatchReplyOnly),
})
}
req := &agentAPI.CompletionRequest{
Messages: f.Msgs,
MaxTokens: 4096,
Tools: f.Tools,
ToolChoice: "auto",
DisableThinking: !a.thinkingEnabled,
}
providers := a.resolveProviders(req)
resp, llmErr := a.callLLMWithFallback(req, providers, f.OutputChannel)
if llmErr != nil {
if errors.Is(llmErr, context.Canceled) && a.ctx.Err() == nil {
if f.OutputChannel == channelConsolidation {
f.Err = fmt.Errorf("interrupted by user input")
return outcomeFailed
}
f.Turn++
return outcomeContinue // 重跑 StepLLM
}
f.Err = fmt.Errorf("all %d providers failed, last error: %w", len(providers), llmErr)
return outcomeFailed
}
f.StageCtx.LLMText = resp.Content
f.StageCtx.ReasoningContent = resp.ReasoningContent
f.StageCtx.TokenUsage = map[string]int{
"prompt_tokens": resp.TokenUsage.Prompt,
"completion_tokens": resp.TokenUsage.Completion,
"total_tokens": resp.TokenUsage.Total,
}
f.StageCtx.ToolCalls = convertToolCalls(resp.ToolCalls)
for i := range f.StageCtx.ToolCalls {
if f.StageCtx.ToolCalls[i].Plugin == "" {
f.StageCtx.ToolCalls[i].Plugin = a.resolveToolPlugin(f.StageCtx.ToolCalls[i].Name)
}
}
if a.runStage(sdk.StagePostAction, f.StageCtx) {
f.Response = *f.StageCtx.Response
return outcomeDone
}
resp.Content = f.StageCtx.LLMText
resp.ToolCalls = convertBackToolCalls(f.StageCtx.ToolCalls)
chainPayload := map[string]interface{}{
"content": resp.Content,
"reasoning": resp.ReasoningContent,
"tool_calls": resp.ToolCalls,
"phase": "intermediate",
"turn": f.Turn,
}
if resp.TokenUsage.Total > 0 {
chainPayload["usage"] = map[string]int{
"prompt": resp.TokenUsage.Prompt,
"completion": resp.TokenUsage.Completion,
"total": resp.TokenUsage.Total,
}
}
a.publishEvent(events.EventAgentLLMChain, chainPayload)
if resp.ReasoningContent != "" {
a.publishEvent(events.EventReasoning, map[string]interface{}{
"content": resp.ReasoningContent,
"channel": f.OutputChannel,
})
}
if len(resp.ToolCalls) == 0 {
f.Response = resp.Content
return outcomeDone
}
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break放在循环里统计会漏。
f.Resp = resp
f.ReplyOnly = true
for _, tc := range resp.ToolCalls {
if !isOutputDeliveryTool(tc.Name) {
f.ReplyOnly = false
break
}
}
f.ContentOnce = true
f.PendingTools = resp.ToolCalls
f.ToolIdx = 0
f.Step = StepToolBegin
return outcomeContinue
}
// stepToolBegin 取本批下一个工具;批已耗尽或发生中断则进入收尾。
func (a *Agent) stepToolBegin(f *TaskFrame) stepOutcome {
if f.ToolIdx >= len(f.PendingTools) {
f.Step = StepTurnEnd
return outcomeContinue
}
tc := f.PendingTools[f.ToolIdx]
f.ToolsUsed = append(f.ToolsUsed, tc.Name)
pluginName := a.resolveToolPlugin(tc.Name)
log.Printf("[agent] executing tool: %s (plugin=%s, id=%s)", tc.Name, pluginName, tc.ID)
if tc.RawArguments != "" {
log.Printf("[agent] tool %s raw_arguments: %s", tc.Name, truncateStr(tc.RawArguments, 300))
}
sdkTC := sdk.ToolCall{ID: tc.ID, Name: tc.Name, Plugin: pluginName, Arguments: tc.Arguments}
f.StageCtx.ToolCalls = []sdk.ToolCall{sdkTC}
f.StageCtx.ToolResults = nil
if a.runStage(sdk.StageBeforeToolcall, f.StageCtx) {
result := fmt.Sprintf("工具 %s 已被插件拒绝", tc.Name)
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "denied",
"channel": f.OutputChannel,
})
f.ToolIdx++
return outcomeContinue
}
tc.Arguments = f.StageCtx.ToolCalls[0].Arguments
if pluginName != "" && !a.pluginHealth.isHealthy(pluginName) {
result := fmt.Sprintf("插件 %s 处于崩溃状态,已跳过执行,等待自动恢复重载", pluginName)
log.Printf("[agent] skip tool %s: plugin %s unhealthy", tc.Name, pluginName)
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "assistant", ToolCalls: []agentAPI.ToolCall{tc}})
f.Msgs = append(f.Msgs, agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result})
f.ToolIdx++
return outcomeContinue
}
f.CurTool = tc
f.CurToolPlugin = pluginName
f.Step = StepToolExec
return outcomeContinue
}
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := a.executeToolCall(f.CurTool, f.OutputChannel)
f.CurResult = result
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
f.StageCtx.ToolResults = []sdk.ToolResult{{
CallID: f.CurTool.ID, Name: f.CurTool.Name, Plugin: f.CurToolPlugin,
Success: true, Result: result,
}}
f.Step = StepToolAfter
return outcomeContinue
}
// stepToolAfter 是工具执行后的全部后处理(阶段、裁剪、消息与事件)。
func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
tc := f.CurTool
pluginName := f.CurToolPlugin
result := f.CurResult
a.runStage(sdk.StageAfterToolcall, f.StageCtx)
if len(f.StageCtx.ToolResults) > 0 {
if r, ok := f.StageCtx.ToolResults[0].Result.(string); ok {
result = r
}
}
// ContextPolicy: prune 工具调用后执行上下文裁剪§13.8
if def := a.stageHost.ToolDef(tc.Name); def != nil && def.ContextPolicy == "prune" {
if a.context != nil {
topK := a.maxContextSize - 1
if topK < 1 {
topK = 1
}
// 查询向量取**清洗后**的有效内容否则噪声ANSI/base64/JSON 包装)
// 会把相关性打分带偏,裁掉本该保留的事件。
a.context.Prune(a.toolOutputForQuery(tc.Name, result), topK, a.docStore)
}
}
msgContent := ""
if f.ContentOnce {
msgContent = f.Resp.Content
f.ContentOnce = false
}
f.Msgs = append(f.Msgs, agentAPI.Message{
Role: "assistant", Content: msgContent,
ReasoningContent: f.Resp.ReasoningContent,
ToolCalls: []agentAPI.ToolCall{tc},
})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block。
//
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
// 这也是插件文案一直在说的「注入后续对话」。
// 为何不能挂 tool message同一张图、同一模型、三轮实测——
// 图在 user message → 3/3 读到
// 图在 tool message → 0/3模型答「没能读到这张图」
// tool 纯文本 + 后接 user → 3/3 读到
// tool message 那轮 prompt_tokens 反而更高7967 vs 7089base64 确实
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
//
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
// 返回 200模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
var mediaMsg *agentAPI.Message
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
if cb, ok := b.(pubsdk.ContentBlock); ok {
// 跨包类型拷贝pubsdk.ContentBlock → agentAPI.ContentBlock
block := agentAPI.ContentBlock{Type: cb.Type, Text: cb.Text}
if cb.ImageURL != nil {
block.ImageURL = &agentAPI.ImageURL{URL: cb.ImageURL.URL, Detail: cb.ImageURL.Detail}
}
if cb.AudioURL != nil {
block.AudioURL = &agentAPI.AudioURL{URL: cb.AudioURL.URL}
}
blocks = append(blocks, block)
}
}
if len(blocks) > 0 {
// 先落进 CAS无论下面走直视还是回退转写媒体本体都该进记忆。
a.stageMediaDigests(a.captureBlockMedia(blocks, tc.Name)...)
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明。
mediaBlocks := append([]agentAPI.ContentBlock{{
Type: "text",
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
}}, native...)
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
} else if fallbackText != "" {
toolMsg.Content = result + "\n\n" + fallbackText
result = toolMsg.Content
if len(f.ToolResults) > 0 {
f.ToolResults[len(f.ToolResults)-1].Output = result
}
}
}
}
f.Msgs = append(f.Msgs, toolMsg)
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
f.Msgs = append(f.Msgs, *mediaMsg)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,
"plugin": pluginName,
"args": tc.Arguments,
"result": result,
"status": "ok",
"channel": f.OutputChannel,
})
f.ToolIdx++
f.Step = StepToolBegin
return outcomeContinue
}
// stepTurnEnd 收尾本批并进入下一轮。
func (a *Agent) stepTurnEnd(f *TaskFrame) stepOutcome {
// 供下一轮顶部选择补位文案。
f.LastBatchReplyOnly = f.ReplyOnly
f.Turn++
f.Step = StepLLM
return outcomeContinue
}
// resolveProviders 按请求模型解析候选 provider保持原语义
func (a *Agent) resolveProviders(req *agentAPI.CompletionRequest) []agentAPI.Provider {
var providers []agentAPI.Provider
if a.providerManager != nil {
var allProviders []agentAPI.Provider
if req.Model != "" && !strings.EqualFold(req.Model, "AUTO") {
allProviders = a.providerManager.ResolveForModel(req.Model)
} else {
allProviders = a.providerManager.OrderedProviders()
}
providers = make([]agentAPI.Provider, 0, len(allProviders))
for _, p := range allProviders {
if a.providerManager.IsAvailable(p.Name()) {
providers = append(providers, p)
}
}
}
if len(providers) == 0 {
providers = []agentAPI.Provider{a.provider}
}
return providers
}
// callLLMWithFallback 在候选 provider 间回退,并把同源瞬时错误重试一次。
// 逐行等价于原 process() 内的双层循环。
func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers []agentAPI.Provider, channel string) (*agentAPI.CompletionResponse, error) {
var resp *agentAPI.CompletionResponse
var llmErr error
for pi, fbProvider := range providers {
if pi > 0 {
log.Printf("[agent] LLM fallback: trying provider %q (fallback #%d/%d)",
fbProvider.Name(), pi, len(providers)-1)
}
// 同源瞬时错误重试网关瞬断502/503/504/429/网络抖动)通常秒级恢复,
// 直接跳下一个 provider或直接报错会丢掉本可成功的请求。
// 凭证错误401/403与用户中断不重试。
const maxAttempts = 2
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
log.Printf("[agent] provider %q transient failure, retry %d/%d in 2s: %v",
fbProvider.Name(), attempt, maxAttempts, llmErr)
select {
case <-time.After(2 * time.Second):
case <-a.ctx.Done():
llmErr = a.ctx.Err()
}
if llmErr == nil || errors.Is(llmErr, context.Canceled) || errors.Is(llmErr, context.DeadlineExceeded) {
break
}
}
fCtx, fCancel := context.WithCancel(a.ctx)
a.llmMu.Lock()
a.cancelLLM = fCancel
a.llmMu.Unlock()
resp, llmErr = chatStreamWithFallback(fCtx, fbProvider, req, a, channel)
a.llmMu.Lock()
a.cancelLLM = nil
a.llmMu.Unlock()
fCancel()
if llmErr == nil {
a.providerManager.ResetAvailability(fbProvider.Name())
if fbProvider != a.provider {
a.provider = fbProvider
log.Printf("[agent] switched active provider to %q after fallback", fbProvider.Name())
}
break
}
// 用户中断:立即终止,不重试也不换 provider
if errors.Is(llmErr, context.Canceled) {
break
}
// 凭证错误:重试无意义,跳出重试循环进入 provider 标记/切换
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
break
}
// 其余错误(含 5xx/429/网络):还有重试机会则继续,否则跳出
}
if llmErr == nil {
break
}
if errors.Is(llmErr, context.Canceled) {
break
}
var pe *agentAPI.ProviderError
if errors.As(llmErr, &pe) && (pe.StatusCode == 401 || pe.StatusCode == 403) {
a.providerManager.ReportStatus(fbProvider.Name(), pe.StatusCode)
log.Printf("[agent] provider %q marked unavailable (HTTP %d)", fbProvider.Name(), pe.StatusCode)
} else {
a.providerManager.MarkUnavailable(fbProvider.Name())
}
log.Printf("[agent] provider %q failed: %v", fbProvider.Name(), llmErr)
}
return resp, llmErr
}

View File

@ -0,0 +1,211 @@
package core
// M3a 验收测试任务生命周期prepare → run → finish与「每任务恰一次终态」。
//
// 设计依据 docs/zh/input-scheduler-design.md §11.3X2/X3/X4 的 M3a 形态):
// 帧覆盖全生命周期后提交context.Append与回执emitResponse只能在
// finish 段发生一次——挂起不会重复提交。
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func newLifecycleAgent(t *testing.T, sp agentAPI.Provider, bus *events.Bus, sh *StageHost) *Agent {
t.Helper()
return New(AgentConfig{
ID: "lifecycle",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: sh,
EventBus: bus,
})
}
func textEvent(source, content string) (*agentIO.InputEvent, chan *agentIO.OutputEvent) {
ch := make(chan *agentIO.OutputEvent, 1)
return &agentIO.InputEvent{
RequestID: "req-1",
Source: source,
Type: "text",
Payload: map[string]interface{}{"content": content},
OutputChannel: source,
ResponseCh: ch,
}, ch
}
// X3M3a 形态):正常任务在 finish 段**恰好**提交一次并回执一次。
func TestLifecycle_NormalCommitsOnceAndReplies(t *testing.T) {
bus := events.NewBus()
var outputs, rawInputs int
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
bus.Subscribe(events.EventRawInput, func(*events.Event) { rawInputs++ })
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "答复"}}}
a := newLifecycleAgent(t, sp, bus, NewStageHost())
evt, respCh := textEvent("cli", "你好")
if _, out := a.runInputTask(evt); out != outcomeDone {
t.Fatalf("runInputTask=%v期望 outcomeDone", out)
}
select {
case r := <-respCh:
if got, _ := r.Payload["content"].(string); got != "答复" {
t.Fatalf("回执内容=%q期望 答复", got)
}
if !r.Done {
t.Fatal("回执必须带 Done=true")
}
default:
t.Fatal("同步回执缺失finish 段必须写 ResponseCh")
}
if outputs != 1 {
t.Fatalf("agent_output 事件=%d期望恰好 1每任务一次终态", outputs)
}
if rawInputs != 1 {
t.Fatalf("raw_input 事件=%d期望 1", rawInputs)
}
if a.context.Len() != 2 {
t.Fatalf("上下文事件=%d期望 2输入事件 + 本轮事件)", a.context.Len())
}
}
// X2被去重的输入以 skipped 终态结束——不提交、不回执、不发输出事件。
func TestLifecycle_DuplicateSkippedHasTerminal(t *testing.T) {
bus := events.NewBus()
outputs := 0
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "第一次"}, {Content: "第二次"},
}}
a := newLifecycleAgent(t, sp, bus, NewStageHost())
e1, _ := textEvent("webui", "同样的消息")
if _, out := a.runInputTask(e1); out != outcomeDone {
t.Fatalf("首次输入=%v期望 outcomeDone", out)
}
after1, outputs1 := a.context.Len(), outputs
e2, ch2 := textEvent("webui", "同样的消息")
if _, out := a.runInputTask(e2); out != outcomeDone {
t.Fatalf("去重输入应正常返回(不挂起),实际 %v", out)
}
if a.context.Len() != after1 {
t.Fatalf("去重命中不得提交上下文:%d → %d", after1, a.context.Len())
}
if len(ch2) != 1 {
t.Fatal("去重命中必须回一个 skipped 终态,否则同步调用方永久挂起")
}
r := <-ch2
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
t.Fatalf("去重回执必须带 skipped=true实际 %+v", r.Payload)
}
if reason, _ := r.Payload["reason"].(string); reason != "duplicate" {
t.Fatalf("reason=%q期望 duplicate", reason)
}
if outputs != outputs1 {
t.Fatalf("去重命中不得发输出事件:%d → %d", outputs1, outputs)
}
}
// 被 on_input 阶段短路:回执阶段给的响应,且不提交上下文(与原实现一致)。
func TestLifecycle_OnInputShortCircuit(t *testing.T) {
sh := NewStageHost()
reply := "被插件短路"
sh.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
ctx.Response = &reply
return nil
})
sp := &scriptProvider{} // 不应被调用到
a := newLifecycleAgent(t, sp, events.NewBus(), sh)
evt, respCh := textEvent("cli", "任意")
if _, out := a.runInputTask(evt); out != outcomeDone {
t.Fatalf("短路任务=%v期望 outcomeDone", out)
}
select {
case r := <-respCh:
if got, _ := r.Payload["content"].(string); got != reply {
t.Fatalf("短路响应=%q期望 %q", got, reply)
}
default:
t.Fatal("短路路径必须回执")
}
if a.context.Len() != 0 {
t.Fatalf("短路路径不得提交上下文,实际 %d 条", a.context.Len())
}
if len(sp.reqs) != 0 {
t.Fatal("短路路径不得调用 LLM")
}
}
// 错误路径:以 error 终态结束,回执错误文本,且提交的是**错误事件**(无 turn 事件)。
func TestLifecycle_ErrorPathTerminal(t *testing.T) {
bus := events.NewBus()
outputs := 0
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
sp := &scriptProvider{err: &agentAPI.ProviderError{StatusCode: 401, Message: "bad key"}}
a := newLifecycleAgent(t, sp, bus, NewStageHost())
evt, respCh := textEvent("cli", "会失败")
if _, out := a.runInputTask(evt); out != outcomeFailed {
t.Fatalf("runInputTask=%v期望 outcomeFailed", out)
}
select {
case r := <-respCh:
got, _ := r.Payload["content"].(string)
if !strings.HasPrefix(got, "处理错误:") {
t.Fatalf("错误回执=%q期望以 处理错误: 开头", got)
}
default:
t.Fatal("错误路径必须回执(否则同步调用方永久挂起)")
}
if outputs != 1 {
t.Fatalf("错误路径的 agent_output 事件=%d期望 1", outputs)
}
// 输入事件 + 错误事件 = 2不得出现带 ToolsUsed 的 turn 事件。
if a.context.Len() != 2 {
t.Fatalf("错误路径上下文事件=%d期望 2", a.context.Len())
}
recent := a.context.Recent(10)
last := recent[len(recent)-1]
if last.Response == "" {
t.Fatal("错误事件必须带 Response")
}
}
// _consolidation_ 走记忆整理专用路径:不回执、不提交上下文。
func TestLifecycle_ConsolidationRouted(t *testing.T) {
bus := events.NewBus()
outputs := 0
bus.Subscribe(events.EventAgentOutput, func(*events.Event) { outputs++ })
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{{Content: "整理完毕"}}}
a := newLifecycleAgent(t, sp, bus, NewStageHost())
evt, respCh := textEvent("system", "整理任务")
evt.OutputChannel = channelConsolidation
if _, out := a.runInputTask(evt); out != outcomeDone {
t.Fatalf("consolidation=%v期望 outcomeDone", out)
}
if len(respCh) != 0 {
t.Fatal("consolidation 路径不得回执")
}
if outputs != 0 {
t.Fatalf("consolidation 路径不得发输出事件,实际 %d", outputs)
}
if a.context.Len() != 0 {
t.Fatalf("consolidation 路径不得写用户上下文,实际 %d", a.context.Len())
}
}

View File

@ -0,0 +1,132 @@
package core
// M6 验收测试:任务级回执与断链点统一为终态事件。
//
// 设计依据 docs/zh/input-scheduler-design.md §7不变量 I5、§11.3X1X4
//
// 问题背景:回执原先由全局 emitResponse 写(无任务归属),且 processInput 有多条
// 「提前 return 而不 emit」的路径解析失败、去重、consolidation——同步调用方
// 若不自带超时cli、clawhubadapter就会永久挂起。
import (
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// X1回执按任务归属中断的回执绝不投给被挂起的等待者。
func TestTerminal_TaskScopedReplyNotMisrouted(t *testing.T) {
sp := newPreemptProvider("intr-done", "low-done")
a := newPreemptAgent(t, sp)
lowEvt, lowCh := textEvent("qq", "低优先级任务")
lowTask := newInputTask(lowEvt)
if !a.sched.enqueue(lowTask) {
t.Fatal("入队失败")
}
lt, _, _ := a.sched.nextRef()
done := make(chan struct{})
go func() { a.executeNewTask(lt); close(done) }()
select {
case <-sp.entered:
case <-time.After(3 * time.Second):
t.Fatal("provider 未被调用")
}
intrEvt, intrCh := textEvent("cli", "紧急打断")
intrEvt.Payload["interrupt"] = true
if !a.sched.requestKernelPreempt(intrEvt) {
t.Fatal("内核 L4 应抢占排队任务")
}
a.cancelCurrentLLM()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("未挂起")
}
// 执行中断任务 → 只应写它自己的回执通道。
it, _, k := a.sched.nextRef()
if k != nextImmediate {
t.Fatalf("应取到立即运行的中断kind=%v", k)
}
a.executeNewTask(it)
if len(intrCh) != 1 {
t.Fatalf("中断任务应回执到自己的通道,实际 %d", len(intrCh))
}
if len(lowCh) != 0 {
t.Fatal("中断的回执绝不能被投给被挂起的等待者")
}
// 恢复并结束后,原任务才拿到自己的回执。
rt, rf, k2 := a.sched.nextRef()
if k2 != nextSuspended {
t.Fatalf("应恢复被抢占任务kind=%v", k2)
}
a.resumeTask(rt, rf)
if len(lowCh) != 1 {
t.Fatalf("恢复任务结束后应恰好回执一次,实际 %d", len(lowCh))
}
if got, _ := (<-lowCh).Payload["content"].(string); got != "low-done" {
t.Fatalf("原任务回执内容=%q期望 low-done", got)
}
}
// X2空输入解析失败也必须有终态回执。
func TestTerminal_EmptyInputGetsSkippedReply(t *testing.T) {
a := newLifecycleAgent(t, &scriptProvider{}, nil, NewStageHost())
ch := make(chan *agentIO.OutputEvent, 1)
evt := &agentIO.InputEvent{
RequestID: "r-empty",
Source: "cli",
Type: "text",
Payload: map[string]interface{}{}, // 无 content无媒体块
OutputChannel: "cli",
ResponseCh: ch,
}
if _, out := a.runInputTask(evt); out != outcomeDone {
t.Fatalf("空输入应正常返回,实际 %v", out)
}
if len(ch) != 1 {
t.Fatal("空输入必须回 skipped 终态")
}
if reason, _ := (<-ch).Payload["reason"].(string); reason != "empty_input" {
t.Fatalf("reason=%q期望 empty_input", reason)
}
if a.context.Len() != 0 {
t.Fatal("空输入不得写入上下文")
}
}
// X4无超时的同步调用方cli / clawhubadapter在断链路径上不再永久挂起。
//
// 这是回归判据:修复前 `InjectTextSync` 遇到去重命中会永久阻塞。
func TestTerminal_NoTimeoutSyncCallerDoesNotHang(t *testing.T) {
a := newLifecycleAgent(t, &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "第一次"}, {Content: "第二次"},
}}, nil, NewStageHost())
// 第一次成功
e1, ch1 := textEvent("cli", "重复内容")
if _, out := a.runInputTask(e1); out != outcomeDone {
t.Fatalf("首次=%v", out)
}
if len(ch1) != 1 {
t.Fatal("首次应有回执")
}
// 第二次(去重命中):模拟同步调用方阻塞等待——必须在 1s 内拿到终态。
e2, ch2 := textEvent("cli", "重复内容")
go a.runInputTask(e2)
select {
case r := <-ch2:
if skipped, _ := r.Payload["skipped"].(bool); !skipped {
t.Fatalf("应为 skipped 终态,实际 %+v", r.Payload)
}
case <-time.After(time.Second):
t.Fatal("去重命中让同步调用方永久挂起X4 回归)")
}
}

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