13 Commits

Author SHA1 Message Date
c67336c376 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:04:30 +08:00
bb16f19ea5 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:06 +08:00
2c7ab6a187 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:06 +08:00
9311b41dd3 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:30 +08:00
996e70a64d 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:17 +08:00
68b773ad61 docs(git): 修正上一提交的错字(但声两仓 → 但两仓) 2026-09-12 09:02:01 +08:00
18324f6e80 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:36 +08:00
8c5c0a25a6 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:01:04 +08:00
fdfba4eaa7 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:11 +08:00
215804cf2c 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:20 +08:00
ca5b04532d 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:07 +08:00
01232a66da 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:32 +08:00
3a5f6e1634 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:26 +08:00
163 changed files with 1733 additions and 17195 deletions

1
.gitignore vendored
View File

@ -31,7 +31,6 @@ cmd/gui/dist/
third_party/homeagent-sdk/bin/
third_party/homeagent-sdk/tools/
third_party/homeagent-sdk/package/
third_party/homeagent-sdk/scripts/
third_party/homeagent-sdk/.gitignore
third_party/homeagent-sdk/README*
third_party/homeagent-sdk/example/

View File

@ -190,7 +190,7 @@ internal/
├── config/ SQLite 配置中心
├── events/ 事件总线
└── internal/lua/adapters/ 8 个 LLM 协议适配器脚本
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `hmapdev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
外部插件开发见 [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) 仓库,使用 `plugindev` 工具链开发,参考 `example/` 目录下的 Go 和 Lua 示例
```
## 项目状态
@ -209,7 +209,7 @@ internal/
是块的**迁移**,不是复制、也不靠引用保活。
- **数据面全部走共享内存**(工具调用帧 / Cleaner / 输入输出通道 / 媒体块 / 文档与知识正文),
RPC 只传偏移描述符;**RPC 协议升到 2**fd3 布局改变,**不支持滚动升级**——
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `hmapdev` 重编。
内核与全部插件必须同批重建、同批安装,存量插件须用新版 `plugindev` 重编。
- 注入可声明 `InjectOptions{NoMemory, ContextPolicy}`**默认仍记入记忆、默认不裁剪**
裁剪必须显式声明,且先经插件注册的 `Cleaner`。SDK 1.2.0 相对 1.1.0 **纯追加**
- **发行包默认启用** ONNX 向量空间并把模型754MB与 ONNX Runtime24MB
@ -226,7 +226,7 @@ internal/
**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
**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
**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 退场。**

View File

@ -179,7 +179,7 @@ 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 `hmapdev` toolchain, refer to Go and Lua examples in `example/`
External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/homeagent-sdk) repo, use `plugindev` toolchain, refer to Go and Lua examples in `example/`
```
## Project Status
@ -240,7 +240,7 @@ by `-race`; in production this showed up as sporadic nil-dereference crashes dur
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.
**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.
**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.**

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
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
StaticEmbedder pretrained word embedding / TF-IDF fallback
Prune: StaticEmbedder CosineSimilarity, 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 + dense vectors (unified multimodal space; dense_fp must match the current space fingerprint or the doc is recomputed; fallback: StaticEmbedder / TF-IDF InvertedIndex)
DocStore — JSON files + shared StaticEmbedder vector space with Context (fallback: 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,22 +132,11 @@ Setting `ctx.Response` at any stage jumps to `after_output`.
→ triples → GraphDB.Commit
```
### Vectorization: Unified Multimodal Space (primary) → Word Embedding TF-IDF (fallback)
### Vectorization: Pretrained Word Embedding + TF-IDF Fallback
Vectorization degrades through three layers by availability; **each missing layer reports an explicit
error and never pretends to succeed**:
All vectorization unified under `StaticEmbedder` (`internal/memory/static_embedder.go`):
**① 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)
**Primary Strategy — Pretrained Word Embedding (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
@ -207,32 +196,28 @@ single typo silently breaks reference binding with no error anywhere in the chai
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 Memory (since v1.1.0)
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.
`internal/memory/media/``Store`, content-addressed (CAS)
| 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 |
| Addressing | sha256 digest; metadata in SQLite, blobs on disk | 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 |
| References | `owner_kind/owner_id/digest` composite primary key, `AddRef` idempotent | Three owner kinds: `context` (context events), `document`, `graph_sentence` |
| GC | Two-stage with `minAge`, **referenced items are never deleted** | Description text stays in the text layers while blobs may be evicted — semantic memory and byte cache are decoupled |
**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.
**How media is represented in plain-text memory** is the marker `[<mime> <short digest>] <description>`:
**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**.
```
[image/png a1b2c3d4e5f6] a purple-blue-red three-band chart
```
Why it must ride on text: `Doc.Content`, `sentences.text` and text memory's `Input` are all strings
— there is no field to carry structured data. **The description text is the durable semantic
memory** (retrieval uses it); the digest is the key back to the bytes (reverse lookup uses it).
After capacity GC evicts a blob, the description remains in the L0/L2/L3 text.
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.
@ -307,7 +292,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 | `hmapdev build``plugin.bin` (ordinary Go binary) | qq/browser/files etc. |
| External subprocess plugin | Handshake + stdio JSON-RPC reverse registration | `plugindev 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 |
@ -326,7 +311,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 `hmapdev 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 `plugindev 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` |

View File

@ -29,80 +29,75 @@ type Plugin interface {
| Method | Use Case | Complexity |
|--------|----------|------------|
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `hmapdev` toolchain |
| **Subprocess plugin (recommended)** | Independently distributed third-party plugins | Medium, generated using `plugindev` toolchain |
| **Built-in plugin** | Released with HomeAgent | Simple, requires merging into main repo |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `hmapdev init --lua` |
| **Lua script plugin** | Lightweight rapid prototyping | Simple, generated using `plugindev init --lua` |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 1. Quick Start: Using the hmapdev Toolchain
## 1. Quick Start: Using the plugindev Toolchain
`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).
`plugindev` is the unified plugin development toolchain provided in the SDK repository, supporting both Go and Lua plugin types.
### Installation
```bash
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, ...)
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# Add plugindev to PATH or use directly
```
### SDK Version Management
`hmapdev sdk` manages local SDK versions:
`plugindev sdk` manages local SDK versions:
```bash
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
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
```
SDK is stored at `~/.homeagent/hmapdev/sdk/<version>/`; `hmapdev init` reads the current SDK version for `go.mod`.
SDK is stored at `~/.homeagent/plugindev/sdk/<version>/`; `plugindev init` reads the current SDK version for `go.mod`.
### Source Debugging
`hmapdev debug` interprets plugin source and prints a call trace, no compilation environment needed:
`plugindev debug` interprets plugin source and prints a call trace, no compilation environment needed:
```bash
hmapdev debug [dir] # dir defaults to the current directory
plugindev debug [dir] # dir defaults to the current directory
```
### Creating a Go Plugin
```bash
hmapdev init myplugin
plugindev init myplugin
cd myplugin
# Edit plugin code
vim plugin.go
# Build and package (default is a multi-platform bundle, see below)
hmapdev build
plugindev build
# Output: dist/myplugin_bundle.hmap
# Single-platform build:
hmapdev build --no-bundle
plugindev build --no-bundle
# Output: dist/myplugin_linux_amd64.hmap (or windows_amd64)
```
### Creating a Lua Plugin
```bash
hmapdev init myluaplugin --lua
plugindev init myluaplugin --lua
cd myluaplugin
# Edit plugin code
vim main.lua
# Local test
lua main.lua
# Build and package
hmapdev build
plugindev build
# Output: dist/myluaplugin_lua.hmap
```
@ -133,16 +128,16 @@ myluaplugin/
### Build & Package
`hmapdev build` automatically handles compilation and packaging:
`plugindev build` automatically handles compilation and packaging:
```bash
cd myplugin
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)
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)
```
Execution process:
@ -176,7 +171,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 `hmapdev` (formerly `plugindev`).
> rebuild with the new `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.
@ -185,12 +180,12 @@ the kernel picks the one matching the current platform and renames it to `plugin
### Build Targets & Multi-platform Bundle
**`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.
**`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.
```bash
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
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
```
Notes:
@ -280,7 +275,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### Entry Point
`hmapdev init` generates `plugin.go` with the `NewPlugin` export function directly,
`plugindev init` generates `plugin.go` with the `NewPlugin` export function directly,
which is the entry point when the kernel loads the plugin:
```go
@ -289,7 +284,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
At build time, `hmapdev build` auto-generates subprocess runtime code
At build time, `plugindev 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
@ -599,26 +594,23 @@ When running inside the kernel, `sdk.*` global variables are injected by the Go
### Lua SDK API
The `sdk.*` API of Lua plugins is aligned with external plugins (toolchain-built `plugin.bin` subprocesses) up to **SDK 1.3.0** (requires kernel **1.4.0+**, also backfilled by the Lua-alignment patch `v1.3.11`): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
> Historical note: the 1.11.3 media / inject-flags / priority capabilities were long available only on the Go side and were silently missing on the Lua side. They are now fully aligned, guarded by the contract test in `internal/plugin/lua_surface_test.go` (every function promised by the mock has a runtime binding).
The `sdk.*` API of Lua plugins is fully aligned with external plugins (toolchain-built `plugin.bin` subprocesses): registration functions raise a Lua error on failure; data functions uniformly return `(result, err)` with `err == nil` on success. Subsystems not wired by the core (e.g. SocialAPI) return empty values instead of errors.
**Registration**
| Function | Description |
|----------|-------------|
| `sdk.log(level, msg)` | Log output |
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `context_policy` (`"none"`/`"prune"`), `cleaner` |
| `sdk.register_tool(name, def, handler)` | Register tool; `def` supports `description`, `parameters`, `no_memory`, `cleaner` |
| `sdk.register_stage(stage, handler, scope)` | Register stage hook; `scope` is `nil`/`"global"` (default) or `"own_tools"` (fires only for `before_toolcall`/`after_toolcall` when the tool belongs to this plugin) |
| `sdk.register_api(name)` | Register API |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `context_policy`, `cleaner` |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | Register output channel; `def` supports `no_memory`, `cleaner` |
| `sdk.register_input_channel(name, def)` | Register input channel; `def` as above |
| `sdk.unregister_output_channel(name)` | Unregister an output channel (for resource-bound channels, e.g. remote devices); returns `(nil, err)` |
| `sdk.set_auto_restart(enabled)` | Auto-restart the plugin after a crash |
**Stage hook context**
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `reasoning_content`, `final_text`, `no_memory`, `context_msgs`, `token_usage`, `memory`, `extra`, `errors`, `response` (when responded), `tool_calls`, `tool_results`.
Stage handlers receive the full context (same as external plugins): `raw_message`, `user_id`, `group_id`, `phase`, `llm_text`, `final_text`, `no_memory`, `response` (when responded), `tool_calls`, `tool_results`.
**Stage writeback**: the `ctx` table passed to the handler is a reference — mutating writable fields inside the handler syncs back to the core `StageContext` (aligned with subprocess external-plugin capability):
@ -647,37 +639,20 @@ Writable fields: `raw_message`, `llm_text`, `final_text`, `user_id`, `group_id`,
| `sdk.inject_text(source, channel, text)` | Deliver text message |
| `sdk.inject_interrupt(source, channel, text)` | Interrupt delivery |
| `sdk.inject_text_no_memory(source, channel, text)` | Deliver without memory computation |
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | Delivery with flags; `opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
| `sdk.inject_input_sync(source, channel, text)` | Inject synchronously and wait for this turn's reply; returns `(reply, err)`, reply is nil when there is none |
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | Same, with flags |
| `sdk.inject_input_media(source, channel, text, blocks)` | Inject text + multimodal content blocks |
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | Synchronous media injection; returns `(reply, err)` |
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | Interrupt delivery with media |
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | Same, with flags |
| `sdk.set_tool_blocks(blocks)` | Set multimodal blocks carried by the next tool message (lets the model see images / hear audio) |
Each `blocks` item: `{ type="text", text="..." }`, `{ type="image_url", image_url={ url="...", detail="high" } }`, or `{ type="audio_url", audio_url={ url="..." } }`. An absent `opts` is the zero value (recorded in memory + no pruning), equivalent to the three-argument form.
**Data APIs (aligned with subprocess external plugins, all return `(result, err)`)**
| Sub-table | Functions |
|-----------|-----------|
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})` (triple supports `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests`), `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `insert_with_media(doc, attachments)`, `remove(id)`, `stats()` |
| `sdk.memory.*` | `recall(query, depth)`, `commit({triples})`, `introspect()`, `merge(source, target)`, `purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)`, `insert({id,title,content})`, `remove(id)`, `stats()` |
| `sdk.knowledge.*` | `search(query, limit)`, `add(tag, content)`, `list()` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
| `sdk.llm.*` | `list_sources()`, `set_source(name)`, `current_source()` |
| `sdk.social.*` (read-only) | `get_person(name)`, `get_network(name, depth)`, `get_trait(name, trait)`, `get_relations(name)`, `list_persons()` |
| `sdk.events.*` | `subscribe(event_type, handler)` → returns an unsubscribe function; handler receives `{type,source,timestamp,payload}` |
| `sdk.plugin_mgr.*` | `reload_one(name)`, `list_loaded()`, `is_disabled(name)` |
| `sdk.json.*` | `encode(val)`, `decode(str)` |
| `sdk.http.*` | `get(url)`, `post(url, body, content_type)` |
Each `attachments` item: `{ digest=, mime=, name=, data=<base64> }`; with `data` it is new content (stored in the content-addressed store), with only `digest` it references existing content.
> The `sdk.events.subscribe` callback runs on the kernel's event-publishing goroutine, and Lua is single-state + mutex-guarded — **do only lightweight forwarding inside the callback; never block**, or every call of this plugin will stall.
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :

View File

@ -90,8 +90,8 @@ eventLoop() → processTextInput()
RelevanceContext — 内存 events[] + JSON持久化
Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
向量层级:统一多模态空间(主,带 fingerprint StaticEmbedder 词嵌入 TF-IDF回退
Prune: DenseCosine仅同指纹才比较→ 退化 StaticEmbedder CosineSimilarity保留 topK + 最近10条
StaticEmbedder 预训练词嵌入 / TF-IDF 回退
Prune: StaticEmbedder CosineSimilarity, 保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档 (原始时间戳)
Save: 5s debounce 写盘
@ -99,7 +99,7 @@ eventLoop() → processTextInput()
↓ Prune 归档 ↑ LLM 主动召回
② Document (文件记忆)
DocStore — JSON文件 + 稠密向量统一多模态空间dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex
DocStore — JSON文件 + 与 Context 共享的 StaticEmbedder 向量空间(兜底: TF-IDF InvertedIndex
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
读取:
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
@ -132,20 +132,11 @@ eventLoop() → processTextInput()
→ 三元组 → GraphDB.Commit
```
### 向量化:统一多模态空间(主)→ 词嵌入 TF-IDF回退
### 向量化:预训练词嵌入 + TF-IDF 回退
向量化按可用性分三层降级,**每一层缺位都明确报错,不静默假装成功**
所有向量化统一使用 `StaticEmbedder``internal/memory/static_embedder.go`
**① 统一多模态空间主路径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 维)
**主策略 — 预训练词嵌入(词对齐 300 **
- 模型来源ConceptNet Numberbatch77 语对齐)/ fastText 中文 / fastText 英文
- 通过 `core.agent.embedding_model_path` 配置(逗号分隔多模型)
- 路径名含 `numberbatch` → 自动下载 ConceptNet`cc.zh.` → fastText 中文,含 `cc.en.` → fastText 英文
@ -203,31 +194,30 @@ eventLoop() → processTextInput()
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
### 媒体记忆v1.2.0 起:一等记忆块
### 媒体记忆v1.1.0 起)
媒体不是外挂内容,而是**记忆的一等节点**`internal/memory/media/` 内容寻址仓储CAS
图数据库里的 block 节点携带它的 digest 与向量,结构边(如 `sentence --contains--> block`)表达归属。
`internal/memory/media/` `Store`内容寻址CAS
| 关注点 | 做法 | 为何 |
|---|---|---|
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘`blobs/<前2位>/<其余>` 两级分桶) | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 寻址 | sha256 digest元数据在 SQLiteblob 在磁盘 | 相同字节只存一份元数据要可查询blob 不该进数据库 |
| 完整性 | 每次 `Get` 重校 digest | 磁盘损坏时静默返回脏数据比报错危险得多 |
| 写入原子性 | `.tmp` + rename | 半个文件被当成完整内容会永久污染那个 digest |
| 检索 | 块携带**自己的多模态向量与指纹**,直接参与向量检索 | 不需要描述文本做中介 |
| 生命周期 | **无独立 GC、无引用计数、无 keep-set**;删除块即删内容 | 媒体是记忆节点,不是需要保活的缓存 |
| 引用 | `owner_kind/owner_id/digest` 三元组主键,`AddRef` 幂等 | 三个 owner 类型:`context`(上下文事件)、`document`(文档)、`graph_sentence`(图谱句子) |
| GC | 两阶段 + `minAge`**有引用者绝不删** | 描述文本留在文本层blob 可淘汰——语义记忆与字节缓存分离 |
**不再有描述式索引**:旧实现在正文里写 `[<mime> <短digest>] <描述>` 标记,并把描述文本当作语义记忆
(检索靠描述)。该机制已在 v1.2.0 整体拆除:描述是模型生成的二手信息,
检索“别人转述的图片”不如检索图片自己的向量。现在图片只按自己的统一空间向量被检索,
正文里不再有 media marker。
**媒体在纯文本记忆里的表示**是标记 `[<mime> <短digest>] <描述>`
**跨空间向量迁移**:媒体行的向量带 `vec_model`(空间指纹)。启动时
`reembedStaleMedia()``vec_model` 为空(从未嵌入)或与当前空间不一致(换过模型/维度)的行
批量重算并**写回库**;模态不在本空间覆盖范围时返回 `ErrModalityUnsupported`
**绝不拿别的模型的向量顶替**
```
[image/png a1b2c3d4e5f6] 一张紫蓝红三色带图
```
媒体存储全程可选:`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为纯文本行为
不报错不 panic。
之所以必须借文本承载:`Doc.Content``sentences.text`、文本记忆的 `Input` 全是字符串
没有字段能挂结构化数据。**描述文本才是持久的语义记忆**检索靠它digest 是回到字节的
钥匙反查靠它。blob 被容量 GC 淘汰后,描述仍留在 L0/L2/L3 的文本里。
媒体存储**全程可选**`core.memory.media.enabled=false` 或未配置时,整条链路静默退化为
纯文本行为,不报错不 panic。
### 其他记忆层
@ -297,7 +287,7 @@ VM 内置 `json.encode` / `json.decode` / `log` / `http_get` / `http_post`。
| 方式 | 注册机制 | 编译 | 用途 |
|------|----------|------|------|
| 内置插件 | `init()``RegisterFactory` | `internal/plugins/` 编译进内核 | webui/cli/timer/mcp 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `hmapdev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| 外部子进程插件 | 握手 + stdio JSON-RPC 反向注册 | `plugindev build``plugin.bin`(普通 Go 二进制) | qq/browser/files 等 |
| Lua 脚本插件 | 执行 `main.lua` 注册工具 | 无需编译,重启/重载生效 | luademo 等 |
| SKILL 插件 | 解析 `SKILL.md` | Markdown 定义 | clawhubadapter 兼容加载 |
@ -316,7 +306,7 @@ Lua 脚本插件加载:`internal/plugin/` → gopher-lua 解释器执行 `main
| 维度 | 内置插件 | 外部插件 |
|------|----------|----------|
| 注册方式 | `init()` 调用 `plugin.RegisterFactory(name, factory)` | 实现 `NewPluginFactory(name, config) (sdk.Plugin, error)` 入口函数 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `hmapdev build` 编译为 `plugin.bin`(普通 Go 二进制,零 cgo内核 spawn 为子进程 |
| 编译方式 | 编译进 `homed` 二进制,无需独立编译 | 通过 `plugindev 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` |

View File

@ -30,80 +30,75 @@ type Plugin interface {
| 方式 | 适用场景 | 复杂度 |
|------|---------|--------|
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `hmapdev` 工具链生成 |
| **子进程插件(推荐)** | 独立分发的第三方插件 | 中等,使用 `plugindev` 工具链生成 |
| **内置插件** | 随 HomeAgent 一起发布 | 简单,需合入主仓库 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `hmapdev init --lua` 生成 |
| **Lua 脚本插件** | 轻量快速原型 | 简单,使用 `plugindev init --lua` 生成 |
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :
## 一、快速开始:使用 hmapdev 工具链
## 一、快速开始:使用 plugindev 工具链
`hmapdev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
最终产出 `.hmap` 插件包(工具名即来自这个包格式)。
> 改名说明1.2.0 起工具链由 `plugindev` 更名为 `hmapdev`SDK 存储目录同时由
> `~/.homeagent/plugindev/sdk` 迁到 `~/.homeagent/hmapdev/sdk`(旧目录会自动继续沿用)。
`plugindev` 是 SDK 仓库提供的统一插件开发工具链,支持 Go 和 Lua 两种插件类型
### 安装
```bash
cd homeagent-sdk/tools/hmapdev
go build -o hmapdev
# 将 hmapdev 加入 PATH 或直接使用
# 也可从 SDK 的 release 附件下载预编译二进制hmapdev_linux_amd64 等)
cd homeagent-sdk/tools/plugindev
go build -o plugindev
# 将 plugindev 加入 PATH 或直接使用
```
### SDK 版本管理
`hmapdev sdk` 子命令管理本地 SDK 版本:
`plugindev sdk` 子命令管理本地 SDK 版本:
```bash
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 路径
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 路径
```
SDK 存储在 `~/.homeagent/hmapdev/sdk/<version>/``hmapdev init` 自动读取当前 SDK 版本填充 `go.mod`
SDK 存储在 `~/.homeagent/plugindev/sdk/<version>/``plugindev init` 自动读取当前 SDK 版本填充 `go.mod`
### 源码调试
`hmapdev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
`plugindev debug` 直接用解释器执行插件源码并输出调用轨迹,无需编译环境:
```bash
hmapdev debug [dir] # dir 默认当前目录
plugindev debug [dir] # dir 默认当前目录
```
### 创建 Go 插件
```bash
hmapdev init myplugin
plugindev init myplugin
cd myplugin
# 编辑插件代码
vim plugin.go
# 编译打包
hmapdev build # 默认多平台 bundle见下节
plugindev build # 默认多平台 bundle见下节
# 输出: dist/myplugin_bundle.hmap
# 单平台构建:
hmapdev build --no-bundle
plugindev build --no-bundle
# 输出: dist/myplugin_linux_amd64.hmap (或 windows_amd64)
```
### 创建 Lua 插件
```bash
hmapdev init myluaplugin --lua
plugindev init myluaplugin --lua
cd myluaplugin
# 编辑插件代码
vim main.lua
# 本地测试
lua main.lua
# 编译打包
hmapdev build
plugindev build
# 输出: dist/myluaplugin_lua.hmap
```
@ -134,16 +129,16 @@ myluaplugin/
### 编译打包
`hmapdev build` 会自动完成编译和打包:
`plugindev build` 会自动完成编译和打包:
```bash
cd myplugin
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 指令(可多次)
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 指令(可多次)
```
执行过程:
@ -159,7 +154,7 @@ hmapdev build --replace <mod@path> # 追加 go.mod replace 指令(可多次)
| 文件 | 用途 | 关键字段 |
|------|------|---------|
| `plg.json` | 项目元信息,由开发者维护 | `targets` — 单平台构建目标(如 `"linux/amd64,windows/amd64"``bundle` — 多平台合集开关(默认 `true`|
| `plugin.json` | 构建产物清单,`hmapdev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
| `plugin.json` | 构建产物清单,`plugindev build` 自动生成 | `entry` — 入口文件名;`platforms` — 声明的支持平台 |
每个目标生成单独的 `.hmap`。子进程插件是普通可执行文件,**不分平台后缀**
@ -174,7 +169,7 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
>
> - `plugin.so` / `plugin.dylib` / `plugin.dll` **不再被加载**。新内核遇到旧产物
> 会跳过并报可操作错误,不崩溃。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `hmapdev`(原 `plugindev`重编。
> - **业务代码不需要改一行**——公开 SDK 接口零改动,只需用新版 `plugindev` 重编。
> - `plg.json` 的 `entry` 字段对 Go 插件**已无意义**(写着 `plugin.so` 也无妨),
> 它现在只用于区分 Lua 插件。
> - 产物不再需要 cgo交叉编译无需目标平台 C 工具链。
@ -183,12 +178,12 @@ bundle 包内按 `plugin.bin.<goos>.<goarch>` 区分各平台,安装时内核
### 构建目标与多平台打包bundle
**`hmapdev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
**`plugindev build` 默认就是 bundle 模式**`plg.json` 未显式写 `"bundle": false` 时):一次编译 linux/amd64 + darwin/amd64 + windows/amd64生成包含所有平台二进制的单 `.hmap`,输出清单自动添加 `platforms` 字段。安装时核心自动选择当前平台的二进制,跳过其他平台。
```bash
hmapdev build # 默认 bundle输出 dist/myplugin_bundle.hmap
hmapdev build --bundle # 显式开启 bundle同上
hmapdev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
plugindev build # 默认 bundle输出 dist/myplugin_bundle.hmap
plugindev build --bundle # 显式开启 bundle同上
plugindev build --no-bundle # 关闭 bundle按 plg.json 的 targets 逐平台构建
```
注意:
@ -278,7 +273,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
### 入口点
`hmapdev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
`plugindev init` 生成的 `plugin.go` 中直接包含 `NewPlugin` 导出函数,它是内核加载插件时的入口:
```go
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -286,7 +281,7 @@ func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
}
```
编译时 `hmapdev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
编译时 `plugindev build` 自动生成子进程运行时代码(`z_proc_gen.go` 平台无关 + `z_proc_shm_unix.go` / `z_proc_shm_windows.go` 平台特定),无需手动编写。三平台共享同一入口与同一套 RPC 逻辑仅跨进程资源传递机制不同Unix 继承 fdWindows 命名内核对象)。
### PluginSDK 核心 API
@ -592,26 +587,23 @@ lua main.lua
### Lua SDK API
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力对齐**SDK 1.3.0**(需内核 **1.4.0+**,也在 `v1.3.11` 的 Lua 对齐补丁中回填):注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
> 历史提醒1.11.3 的媒体/注入标志位/优先级能力曾长期只在 Go 侧Lua 侧静默缺失。现已全量对齐,并由 `internal/plugin/lua_surface_test.go` 的契约测试守住「mock 承诺的每个函数都有运行时绑定」。
Lua 插件的 `sdk.*` API 与外部插件(工具链编译的 `plugin.bin` 子进程)能力完全对齐:注册类函数调用即时报错(抛 Lua error数据类函数统一返回 `(result, err)``err` 为 nil 表示成功。核心未装配的子系统(如 SocialAPI返回空值而非报错。
**注册类**
| 函数 | 说明 |
|------|------|
| `sdk.log(level, msg)` | 日志输出 |
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description``parameters``no_memory``context_policy``"none"`/`"prune"`)、`cleaner` |
| `sdk.register_tool(name, def, handler)` | 注册工具;`def` 支持 `description``parameters``no_memory``cleaner` |
| `sdk.register_stage(stage, handler, scope)` | 注册阶段钩子;`scope``nil`/`"global"`(默认)或 `"own_tools"`(仅 `before_toolcall`/`after_toolcall` 且工具属于本插件时触发) |
| `sdk.register_api(name)` | 注册 API |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory``context_policy``cleaner` |
| `sdk.register_output_channel(name, caps, desc, def, handler)` | 注册输出通道;`def` 支持 `no_memory``cleaner` |
| `sdk.register_input_channel(name, def)` | 注册输入通道;`def` 同上 |
| `sdk.unregister_output_channel(name)` | 注销输出通道(随资源生灭的动态通道,如远程设备);返回 `(nil, err)` |
| `sdk.set_auto_restart(enabled)` | 崩溃时内核自动拉起插件 |
**阶段钩子上下文**
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``reasoning_content``final_text``no_memory``context_msgs``token_usage``memory``extra``errors``response`(已响应时)、`tool_calls``tool_results`
`register_stage` 的 handler 收到完整上下文(与外部插件一致):`raw_message``user_id``group_id``phase``llm_text``final_text``no_memory``response`(已响应时)、`tool_calls``tool_results`
**Stage 写回**handler 收到的 `ctx` 是引用 table——在 handler 内直接修改可写回字段并同步至内核 `StageContext`(与子进程外部插件能力对齐):
@ -640,37 +632,20 @@ end)
| `sdk.inject_text(source, channel, text)` | 投递文本消息 |
| `sdk.inject_interrupt(source, channel, text)` | 中断投递 |
| `sdk.inject_text_no_memory(source, channel, text)` | 免记忆投递 |
| `sdk.inject_text_opts` / `sdk.inject_interrupt_opts(source, channel, text, opts)` | 带标志位投递;`opts = { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }` |
| `sdk.inject_input_sync(source, channel, text)` | 同步注入并等本轮回复;返回 `(reply, err)`,无回复时 reply 为 nil |
| `sdk.inject_input_sync_opts(source, channel, text, opts)` | 同上带标志位 |
| `sdk.inject_input_media(source, channel, text, blocks)` | 注入文本 + 多模态内容块 |
| `sdk.inject_input_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
| `sdk.inject_input_media_sync` / `..._sync_opts(...)` | 带媒体的同步注入;返回 `(reply, err)` |
| `sdk.inject_interrupt_media(source, channel, text, blocks)` | 带媒体的中断注入 |
| `sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)` | 同上带标志位 |
| `sdk.set_tool_blocks(blocks)` | 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频) |
`blocks` 每项形如:`{ type="text", text="..." }``{ type="image_url", image_url={ url="...", detail="high" } }``{ type="audio_url", audio_url={ url="..." } }``opts` 缺省即零值(记入记忆 + 不裁剪),与三参数版本等价。
**数据类(与子进程外部插件对齐,均返回 `(result, err)`**
| 子表 | 函数 |
|------|------|
| `sdk.memory.*` | `recall(query, depth)``commit({triples})`triple 支持 `subject/relation/object/confidence/subject_type/object_type/sentence_text/media_digests``introspect()``merge(source, target)``purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)``insert({id,title,content})``insert_with_media(doc, attachments)``remove(id)``stats()` |
| `sdk.memory.*` | `recall(query, depth)``commit({triples})``introspect()``merge(source, target)``purge(criteria, hard)` |
| `sdk.doc.*` | `query(text, top_k)``insert({id,title,content})``remove(id)``stats()` |
| `sdk.knowledge.*` | `search(query, limit)``add(tag, content)``list()` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel,attachments})` |
| `sdk.text_memory.*` | `append({role,content,timestamp,channel})` |
| `sdk.llm.*` | `list_sources()``set_source(name)``current_source()` |
| `sdk.social.*`(只读) | `get_person(name)``get_network(name, depth)``get_trait(name, trait)``get_relations(name)``list_persons()` |
| `sdk.events.*` | `subscribe(event_type, handler)` → 返回取消订阅函数handler 收到 `{type,source,timestamp,payload}` |
| `sdk.plugin_mgr.*` | `reload_one(name)``list_loaded()``is_disabled(name)` |
| `sdk.json.*` | `encode(val)``decode(str)` |
| `sdk.http.*` | `get(url)``post(url, body, content_type)` |
`attachments` 每项:`{ digest=, mime=, name=, data=<base64> }`;带 `data` 是新内容(落进内容寻址存储),只带 `digest` 是引用已有内容。
> `sdk.events.subscribe` 的回调在内核事件发布 goroutine 上执行,且 Lua 是单状态 + 互斥锁——**回调内只做轻量转发,不可阻塞**,否则会卡死本插件的全部调用。
---
<img src="../../assets/branding/mascot-xiaozhai.webp" width="20" style="border-radius:50%;vertical-align:middle"> :

View File

@ -367,11 +367,7 @@ func main() {
// 布局、预处理、解码、运行时全部属于 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.") {
@ -379,12 +375,10 @@ func main() {
}
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
@ -411,29 +405,13 @@ 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] 人格来源=文件 %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] 人格来源=无(配置项为空且无人格文件)")
log.Printf("[homed] personality loaded (%d bytes)", len(personality.Content))
}
// ========================================================================
@ -512,39 +490,30 @@ 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,
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,
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,
PluginReg: pluginReg,
PluginDir: cfg.Plugin.Dir,
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

@ -24,7 +24,7 @@ import (
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
// WSL2 里就是普通 linux/amd64走与我们测试矩阵完全相同的那条路径。
//
// 注意范围:只有 homed 如此。hmapdev 工具链仍可在 Windows 上运行
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
func requireSupportedPlatform() {
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")

View File

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

View File

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

View File

@ -4,63 +4,16 @@ import { pasteboard } from '@kit.BasicServicesKit';
import { deviceInfo } from '@kit.BasicServicesKit';
import { textToSpeech } from '@kit.CoreSpeechKit';
import { componentSnapshot } from '@kit.ArkUI';
import { abilityAccessCtrl, common, PermissionRequestResult, Permissions } from '@kit.AbilityKit';
import { common } 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;
@ -91,79 +44,53 @@ 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 {
full = await captureScreenPixelMap();
const full: image.PixelMap = await captureScreenPixelMap();
const info: image.ImageInfo = await full.getImageInfo();
const maxW: number = 420;
const maxH: number = 640;
let scale: number = 1;
if (info.size.width > maxW) {
scale = maxW / info.size.width;
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;
}
if (info.size.height * scale > maxH) {
scale = maxH / info.size.height;
let packed: ArrayBuffer;
if (targetW !== info.size.width) {
await full.scale(targetW / info.size.width, targetH / info.size.height);
}
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 packer: image.ImagePacker = image.createImagePacker();
const opt: image.PackingOption = { format: 'image/jpeg', quality: 70 };
packed = await packer.packing(full, opt);
packer.release();
full.release();
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) {
return errResult('无法读取当前应用画面,请保持应用在前台后重试');
} finally {
if (packer !== null) {
packer.release();
}
if (full !== null) {
full.release();
}
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('screensee failed: ' + msg);
}
}
// ===== 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> {
const granted: boolean = await ensureClipboardPermission(context);
if (!granted) {
return errResult('剪贴板读取权限未授予,请在系统设置中允许后重试');
}
// 说明READ_PASTEBOARD 为受限权限,调试签名无法在真机安装时授予,
// 这里直接尝试读取;系统拒绝时回错误信息。
try {
const clip: pasteboard.SystemPasteboard = pasteboard.getSystemPasteboard();
const has: boolean = await clip.hasData();
if (!has) {
return okResult('');
const empty: CapResult = { status: 'ok', output: '', error: '' };
return empty;
}
const data: pasteboard.PasteData = await clip.getData();
const txt: string = data.getPrimaryText();
return okResult(txt ?? '');
const out: CapResult = { status: 'ok', output: txt ?? '', error: '' };
return out;
} catch (e) {
return errResult('剪贴板读取失败,请确认应用在前台并已获得系统授权');
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsee failed (需系统剪贴板授权): ' + msg);
}
}
@ -172,9 +99,10 @@ 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('clipboard written');
return okResult('written ' + text.length + ' chars');
} catch (e) {
return errResult('剪贴板写入失败,请保持应用在前台后重试');
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('clipboardsue failed: ' + msg);
}
}
@ -184,11 +112,13 @@ 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,
@ -203,7 +133,8 @@ class TtsSession {
this.engine.speak(text, sp);
return okResult('speaking');
} catch (e) {
return errResult('语音服务暂时不可用,请稍后重试');
const msg: string = e instanceof Error ? e.message : String(e);
return errResult('speakeruse failed: ' + msg);
}
}
@ -225,49 +156,20 @@ export async function capSpeakerUse(text: string): Promise<CapResult> {
return ttsSession.speak(text);
}
export function shutdownSpeakerUse(): void {
ttsSession.shutdown();
}
// ===== deviceinfo =====
// ===== 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));
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'));
}
// ===== screensue 内容解析 =====
@ -280,18 +182,14 @@ export interface ScreensuePayload {
export function parseScreensue(rawArgs: string): ScreensuePayload {
const p: ScreensuePayload = { duration: 5, content: '' };
const leadingSpaces: RegExp = new RegExp('^\\s+');
const firstSpace: RegExp = new RegExp('\\s');
let rest: string = rawArgs.replace(leadingSpaces, '');
const splitAt: number = rest.search(firstSpace);
if (splitAt > 0) {
const first: string = rest.substring(0, splitAt);
const digits: RegExp = new RegExp('^\\d+$');
if (digits.test(first)) {
p.duration = Math.min(parseInt(first, 10), 86400);
rest = rest.substring(splitAt).replace(leadingSpaces, '');
}
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(' ');
}
p.content = rest;
p.content = rest.trim();
return p;
}

View File

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

View File

@ -1,4 +1,5 @@
import { webSocket } from '@kit.NetworkKit';
import { DeviceInfo } from '../model/Model';
import { CapResult } from './BridgeCaps';
// ===== 协议消息(与 remotedevice 插件对齐)=====
@ -32,6 +33,13 @@ 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;
@ -73,15 +81,14 @@ export class DeviceBridgeClient {
private caps: string[] = [];
private hostname: string = 'ohos';
private connected: boolean = false;
private bound: boolean = false;
private everConnected: 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 && this.bound;
return this.connected;
}
getDeviceId(): string {
@ -110,40 +117,37 @@ 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 stale socket cleanup failure
// ignore
}
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);
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。留空反而放行。
const opts: webSocket.WebSocketRequestOptions = {
header: this.authHeader(),
};
try {
await socket.connect(this.url, opts);
await this.ws.connect(this.url, opts);
} catch (e) {
if (generation === this.connectionGeneration && !this.manualClose) {
this.connected = false;
this.bound = false;
this.notifyState(false);
this.scheduleReconnect();
}
this.connected = false;
this.scheduleReconnect();
}
}
/** WebUI 用 API key 验证外层连接,并由反代向设备网关注入其内部 token。 */
/** 握手请求头X-API-Key + Authorization 双写,兼容不同后端校验实现。 */
private authHeader(): Record<string, string> {
const h: Record<string, string> = {};
if (this.token.length > 0) {
@ -153,48 +157,38 @@ export class DeviceBridgeClient {
return h;
}
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;
}
private bindWsEvents(authorized: boolean): void {
this.ws.on('open', (err: Error, value: Object) => {
this.connected = true;
this.bound = false;
this.everConnected = true;
this.cancelReconnect();
this.sendHello(authorized);
this.sendBind();
if (this.onStateChange !== null) {
this.onStateChange(true);
}
});
socket.on('message', (err: Error, value: string | ArrayBuffer) => {
if (generation === this.connectionGeneration && typeof value === 'string') {
this.ws.on('message', (err: Error, value: string | ArrayBuffer) => {
if (typeof value === 'string') {
this.handleTextFrame(value);
}
});
socket.on('close', (err: Error, value: webSocket.CloseResult) => {
this.handleSocketEnd(generation);
this.ws.on('close', (err: Error, value: webSocket.CloseResult) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
socket.on('error', (err: Error) => {
this.handleSocketEnd(generation);
this.ws.on('error', (err: Error) => {
this.connected = false;
if (this.onStateChange !== null) {
this.onStateChange(false);
}
this.scheduleReconnect();
});
}
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;
@ -217,20 +211,18 @@ export class DeviceBridgeClient {
}
}
/** 更新本地授权状态并在已绑定连接上同步到服务端。 */
/** 更新本地授权状态并立即重新 hello 同步到服务端。 */
updateAuthorized(authorized: boolean): void {
this.lastAuthorized = authorized;
if (this.connected && this.bound) {
if (this.connected) {
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');
@ -242,7 +234,9 @@ export class DeviceBridgeClient {
} catch (e) {
// ignore
}
this.notifyState(false);
if (this.onStateChange !== null) {
this.onStateChange(false);
}
}
private sendHello(authorized: boolean): void {
@ -252,7 +246,7 @@ export class DeviceBridgeClient {
platform: 'OpenHarmony',
arch: '',
os_release: '',
version: '1.1.1',
version: '1.1.0',
cpus: 0,
};
const device: HelloDevice = {
@ -286,50 +280,37 @@ export class DeviceBridgeClient {
return;
}
const op: string = obj['op'] as string ?? '';
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();
}
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);
}
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', '', '本机能力尚未就绪,请保持应用在前台后重试');
this.sendResult(reqId, 'error', '', 'no capability handler registered');
return;
}
const handler: BridgeCmdHandler = this.cmdHandler;
handler(reqId, command).then((res: CapResult) => {
this.sendResult(reqId, res.status, res.output, res.error);
}).catch((e: Object) => {
this.sendResult(reqId, 'error', '', '本机能力执行失败,请稍后重试');
const msg: string = e instanceof Error ? e.message : String(e);
this.sendResult(reqId, 'error', '', msg);
});
}
@ -407,3 +388,7 @@ export class DeviceBridgeClient {
}
export const deviceBridge: DeviceBridgeClient = new DeviceBridgeClient();
export function parseDevicesPayload(jsonStr: string): DeviceInfo[] {
return [];
}

View File

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

View File

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

View File

@ -4,7 +4,6 @@ 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 {
@ -32,7 +31,6 @@ export default class EntryAbility extends UIAbility {
}
onDestroy(): void {
stopForegroundBridge();
console.info('[HomeAgent] ability onDestroy');
}
@ -88,7 +86,6 @@ export default class EntryAbility extends UIAbility {
apiClient.setConnection(cur);
}
this.reapplyStoredTheme();
startForegroundBridge(this.context);
startUI();
}).catch(() => {
startUI();
@ -110,7 +107,6 @@ 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);
@ -133,12 +129,10 @@ 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,14 +1,27 @@
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';
import { LOCAL_DEVICE_CAPS, deviceGatewayUrl } from '../common/DeviceBridgeSession';
// 本机声明的能力(与 BridgeRouter 支持的命令一一对应)
const LOCAL_CAPS: string[] = [
'status',
'deviceinfo',
'screensee',
'screensue',
'clipboardsee',
'clipboardsue',
'speakeruse',
];
/** 二级页面标识 */
const SUB_NONE: string = '';
@ -26,17 +39,19 @@ export struct DevicePage {
@StorageProp('isWideScreen') private isWide: boolean = false;
/** 当前右栏展示的二级页面 id用于宽屏下高亮左侧入口行 */
@State activeSub: string = SUB_NONE;
@StorageProp('deviceBridgeConnected') private bridgeConnected: boolean = false;
@State 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();
@ -55,9 +70,25 @@ export struct DevicePage {
// Gateway URL derives from current connection
const cur = connStore.getCurrentConnection();
if (cur !== null) {
this.bridgeUrl = deviceGatewayUrl(cur.url);
this.bridgeUrl = this.gatewayUrlOf(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, () => {
@ -73,6 +104,37 @@ 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';
}
/**
* 打开二级页面。
*
@ -108,9 +170,45 @@ 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;
@ -189,6 +287,9 @@ export struct DevicePage {
.onDidScroll((xOffset: number, yOffset: number, state: ScrollState) => {
handleNavOnScroll(state);
})
.onAppear(() => {
this.maybeAutoConnect();
})
}
.width('100%')
.height('100%')
@ -378,7 +479,7 @@ export struct DevicePage {
}
private capsCount(): number {
return LOCAL_DEVICE_CAPS.length;
return LOCAL_CAPS.length;
}
// ===================== 二级:本机设备 =====================
@ -441,7 +542,7 @@ export struct DevicePage {
.margin({ bottom: 10 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LOCAL_DEVICE_CAPS, (cap: string) => {
ForEach(LOCAL_CAPS, (cap: string) => {
Text(cap)
.fontSize(11)
.fontColor(this.palette().accent)
@ -466,8 +567,39 @@ 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('刷新设备')
@ -483,7 +615,7 @@ export struct DevicePage {
}
.width('100%')
Text('设备通道由应用前台生命周期统一管理;切换连接配置后会自动使用新地址和 Token。')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态bind 携带 Token 完成身份绑定。')
.fontSize(11)
.fontColor(this.palette().textMuted)
.margin({ top: 10 })

View File

@ -11,9 +11,7 @@ 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 { ScreensuePage } from '../components/ScreensuePage';
import { registerScreensueHandler } from '../common/BridgeRouter';
import { markForegroundBridgeUIReady } from '../common/DeviceBridgeSession';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
import { window, display } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
@ -112,14 +110,14 @@ struct Index {
this.syncSystemBar();
// 动态读取状态栏/导航栏避让区,实现真正的沉浸式布局(替换硬编码 top:44
this.resolveSafeArea();
// 根 UI 只负责 screensue 呈现;命令路由由前台全局设备桥安装。
// 设备桥:注册命令路由与 screensue 悬浮层回调
installCmdRouter();
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;
@ -220,7 +218,7 @@ struct Index {
private updateWideScreen(w: number): void {
const wide: boolean = w >= WIDE_MIN_WIDTH;
if (wide !== this.isWide) {
AppStorage.set<boolean>('isWideScreen', wide);
AppStorage.setOrCreate<boolean>('isWideScreen', wide);
}
}
@ -232,10 +230,6 @@ struct Index {
* 的根因。这里按 currentTab 显式选栈,行为对所有页面一致。
*/
onBackPress(): boolean {
if (this.screensueVisible) {
this.closeScreensue();
return true;
}
return handleBackPress(this.currentTab, this.isWide);
}
@ -345,42 +339,51 @@ struct Index {
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent)
// screensue 是前台内容页:窄屏覆盖整页;宽屏仅覆盖右侧内容栏,
// 左侧一级页面与主导航保持可见、可操作。
// screensue 悬浮层agent 推送给用户看的内容(置顶展示)
if (this.screensueVisible) {
if (this.isWide) {
Column() {
Row() {
Column()
.width(WIDE_NAV_BAR_WIDTH)
.height('100%')
.hitTestBehavior(HitTestMode.None)
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
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(() => {
this.closeScreensue();
},
})
.layoutWeight(1)
.height('100%')
.padding({ top: this.topInset })
})
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.Transparent)
} else {
ScreensuePage({
pushedText: this.screensueText,
countdown: this.screensueCountdown,
onClose: () => {
this.closeScreensue();
},
})
.width('100%')
.height('100%')
.padding({ top: this.topInset })
.margin({ bottom: 10 })
Scroll() {
Text(this.screensueText)
.fontSize(15)
.fontColor(this.palette().textPrimary)
.width('100%')
}
.constraintSize({ maxHeight: 320 })
.scrollBar(BarState.Auto)
.align(Alignment.Top)
}
.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,7 +13,6 @@ 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 {
@ -202,7 +201,6 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('已切换连接', false);
});
}
@ -226,7 +224,6 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已添加', false);
});
}
@ -245,7 +242,6 @@ export struct SettingsPage {
apiClient.setConnection(cur);
}
this.loadConnections();
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已更新', false);
});
}
@ -282,13 +278,10 @@ export struct SettingsPage {
private deleteConnection(id: string): void {
connStore.deleteConnection(id).then(() => {
this.loadConnections();
const cur: ConnectionConfig | null = connStore.getCurrentConnection();
const cur = connStore.getCurrentConnection();
if (cur !== null) {
apiClient.setConnection(cur);
} else {
apiClient.clearConnection();
}
restartForegroundBridge(getContext(this) as common.UIAbilityContext);
this.showToast('连接已删除', false);
});
}

View File

@ -17,14 +17,6 @@
},
{
"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,10 +3,6 @@
{
"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, cfg.DeviceAuthorized)
// 设备桥重连循环WS 断开时自动重连
go runDeviceBridgeLoop(dg, dt)
}
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
func runDeviceBridgeLoop(gateway, token string, authorized bool) {
func runDeviceBridgeLoop(gateway, token string) {
for {
bridge, err := connectDeviceBridge(gateway, token, authorized)
bridge, err := connectDeviceBridge(gateway, token)
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, authorized bool) {
}
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge, error) {
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "local"
@ -355,17 +355,12 @@ func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge
}
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, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
bridge.OnCmd(func(reqID, command string) {
cr.Dispatch(reqID, command)
})
@ -376,6 +371,8 @@ func connectDeviceBridge(gateway, token string, authorized bool) (*client.Bridge
// 设置全局变量供 sendBridgeResult 使用
deviceBridge = bridge
deviceBridgeID = deviceID
auth := true // daemon 模式默认授权(配置已指定)
bridge.SetAuthorized(auth)
return bridge, nil
}

View File

@ -61,22 +61,13 @@ 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, cmdType string) {
if cmdType == "homeagent" {
handleHomeagentCmd(reqID, command)
return
}
bridge.OnCmd(func(reqID, command string) {
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)
}
@ -776,4 +767,4 @@ func sanitizeID(s string) string {
}
}
return sb.String()
}
}

View File

@ -710,25 +710,18 @@ main() {
echo "=== Done! Packages in: $DIST_DIR ==="
echo ""
echo "Summary:"
# 只列**本批**产物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)
mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.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
find . -type f \( -name "*.deb" -o -name "homeagent_*.tar.gz" -o -name "*.rpm" \) \
-print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
)
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS (仅本批 ${#release_files[@]} 个产物,平铺名)"
echo " SHA256SUMS: $DIST_DIR/SHA256SUMS"
fi
}

View File

@ -130,45 +130,6 @@ main ──────────────── E ────────
---
### 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

View File

@ -1,631 +0,0 @@
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
> **模型更正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>`)——
> 那是把调度内部属性外化成配置,与“由调用方声明自己那件事有多不能等”不同。

View File

@ -2,7 +2,7 @@
> 状态:**完成 v3**2026-09-06——v2 的迁移已上生产(内核 v1.0.0v3 记录 v1.1.1 的公开接口**扩展**。
> 目的:钉死「暴露给外部插件的接口不变」这一约束的**合同面**——迁移前、迁移后外部插件看到/调用的 SDK 接口完全一致;
> 所有改造落在**核心homed 侧)+ 工具链(hmapdev当时名为 plugindev**,外部插件业务代码零改动,只需用新工具链重编。
> 所有改造落在**核心homed 侧)+ 工具链plugindev**,外部插件业务代码零改动,只需用新 plugindev 重编。
>
> **结果(已验证)**`git diff third_party/homeagent-sdk/sdk/` 全程为空17 个 `example/*/plugin.go` 逐字节未改
> `git status example/` 无输出);生产 17 插件全部经子进程通道运行。
@ -11,7 +11,7 @@
> 它要保的是「换运行模型不动业务代码」。迁移完成后SDK 需要能随功能演进而扩展,
> 否则多模态这类能力永远到不了插件手上。解除的边界见 §九:**只增不减,签名不改**。
>
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/hmapdev/templates/` 后,
> 维护规则:每次改动公开 SDK 接口面 `third_party/homeagent-sdk/sdk/` 或模板 `tools/plugindev/templates/` 后,
> 必须同步更新本矩阵。
>
> 权威编号plan.md 第 11 节11.1~11.9)。本文档只做接口面盘点,不做实现。
@ -21,9 +21,9 @@
## 一、迁移的形状(一句话)
```
今天: 外部插件 = example/*/plugin.go纯 Go ──hmapdev c-shared──> plugin.so
今天: 外部插件 = example/*/plugin.go纯 Go ──plugindev c-shared──> plugin.so
homed ──dlopen──> plugin.soC ABI bridge51 个整数 method id
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──hmapdev go build──> plugin.bin
之后: 外部插件 = example/*/plugin.go纯 Go一行不改 ──plugindev go build──> plugin.bin
homed ──spawn──> plugin.binstdio JSON-RPC + shm + eventfd
```
@ -33,8 +33,8 @@
|---|---|---|
| 公开 SDK `third_party/homeagent-sdk/sdk/*.go` | ❌ 纯 Go | **不动**(接口面 = 合同) |
| 外部插件业务代码 `example/*/plugin.go` | ❌ 纯 Go只 import 公开 SDK | **不动**(只重编) |
| bridge 模板 `tools/hmapdev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `hmapdev` 构建命令 | c-shared | 改普通 `go build` |
| bridge 模板 `tools/plugindev/templates.go``tmplLinuxBridge`/`tmplBridge` | ✅ cgo | **删除/替换**为 `tmplProcMain` |
| `plugindev` 构建命令 | c-shared | 改普通 `go build` |
| homed `internal/plugin/cabi/`1096 行) | cgo | 删(已归入 plan 迁移收尾 5.2 |
| homed `internal/plugin/registry.go` 加载分派 | — | 改:按 `entry` 分派 `.so`/`.bin` |
@ -300,7 +300,7 @@ C 结构体不好传函数指针(那是运气,任何人给 dispatch 加个 c
## 七、接口冻结检查点(全部已通过)
1.**阶段 2子进程通道原型**`hmapdev` 重编 weather → `plugin.bin` → 端到端跑通。
1.**阶段 2子进程通道原型**`plugindev` 重编 weather → `plugin.bin` → 端到端跑通。
验收weather 业务代码逐字节未改(`git status example/` 无输出)。
2.**阶段 3共享内存**:子进程并发改写 StageContext 丢失率 = 0%
`TestPlugin_FiveProcessesConcurrentAppendNoLostUpdate`
@ -349,7 +349,7 @@ tool output_send__qq result: 已通过 [qq] 通道发送: map[status:sent]
### 3. 生成模板必须同步接线,否则是**全体外部插件编译失败**
公开接口加方法时,`tools/hmapdev/templates/proc_main.go.tmpl` 里的 `procIO` /
公开接口加方法时,`tools/plugindev/templates/proc_main.go.tmpl` 里的 `procIO` /
`procDocMemory` 若不实现新方法,就不满足接口——**每个外部插件都编不过**,是硬失败
不是软降级。v1.1.1 这一层是被 `go test` 抓出来的(`internal/plugin/proc` 的两个
E2E 用例编译失败),不是靠人工检查发现的。
@ -366,7 +366,7 @@ E2E 用例编译失败),不是靠人工检查发现的。
|---|---|---|
| 存量插件源码零改动 | `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 |
| 模板已接线 | `cd tools/plugindev && go test ./...` | ✅ `TestProcTemplate_CoversAllCoreMethods` 含新 method |
| 并发安全 | `go test ./sdk/ -race -count=5` | ✅ 零 DATA RACE13 例压测) |
### v1.2.x 的接口扩展2026-09-12
@ -426,5 +426,5 @@ data URL 本身已是 base64 文本,包进二进制传输省不了空间,还
- `internal/plugin/proc/protocol.go` — 合同面 B 的代码实现(`Method*` 常量,取代已删的 bridge 模板)
- `internal/plugin/proc/shm.go` — 合同面 C 的代码实现(共享段布局与 18 字段枚举)
- `internal/plugin/proc/capability.go` — 权限梯度capability 组 + `withheldCapabilities`
- `third_party/homeagent-sdk/tools/hmapdev/templates/` — 子进程运行时模板(三文件)
- `third_party/homeagent-sdk/tools/plugindev/templates/` — 子进程运行时模板(三文件)
- `docs/zh/experiments/plugin-arch/` — 18 项可行性实验 + `19-migration-verify/` 迁移执行期工具

View File

@ -1,603 +0,0 @@
# 驻留式子 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 仓版本。

View File

@ -26,47 +26,19 @@ 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
// 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
indexer *memory.Indexer
tracker *tracker.Tracker
context *RelevanceContext
systemPrompt string
ctx context.Context
cancel context.CancelFunc
// 文档记忆(第二层)
docStore *document.Store
@ -85,16 +57,9 @@ type Agent struct {
// 由记忆系统本身决定。为 nil 时全部媒体接线静默跳过。
mediaStore *media.Store
// 人格设定(内容来自启动时载入的人格文件/配置项)
// 人格设定
personality *agentPkg.Personality
// 人格落库面:首启门禁与 persona_set 工具使用(见 persona.go
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
personaStore PersonaStore
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs
allowedOutputs []string
// 插件注册表(用于 plgreload
pluginReg *plugin.Registry
pluginDir string
@ -111,6 +76,7 @@ type Agent struct {
maxContextSize int
// 当前请求的输出通道mutex 保护process() 内独占)
currentOutputChannel string
// 阶段管道:插件消息流编辑
stageHost *StageHost
@ -136,12 +102,8 @@ type Agent struct {
// childSeq 给完成的任务排个序,用于有界淘汰。
childSeq int64
// 输入调度器:就绪队列、任务抽象与快照(见 scheduler.go
// M2 起取代 eventLoop 的隐式 channel 排队。
sched *scheduler
// 工具轮次硬上限0 = 不限);见 AgentConfig.MaxToolTurns。
maxToolTurns int
// 高优先级打断通道interceptLoop 注入process() 在工具循环轮次间非阻塞读取
interceptCh chan *agentIO.InputEvent
// 进行中的 LLM 请求取消函数interceptLoop 可调用以在请求中打断
cancelLLM context.CancelFunc
@ -162,7 +124,7 @@ type Agent struct {
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 由 schedulerLoop goroutine 独占读写
// 与 pendingMedia 同受 a.mu 保护
pendingMediaDigests []string
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
@ -188,12 +150,6 @@ type Agent struct {
// 也可以是外部 API 客户端;两者共享同一套 L0/L2/L3 向量缓存与检索基础设施。
multimodalSpace vector.MultimodalEmbedder
// embeddingProvider 是配置里指定的统一向量空间 provider 名;
// embeddingError 是打开/适配失败的原因(成功时为空)。
// 二者只用于状态报告:区分「没配」「配了但打不开」「已启用」。
embeddingProvider string
embeddingError string
// fusionCfg 控制文本路与视觉路的跨模态融合权重,可按模型实测结果配置。
fusionCfg CrossModalFusionConfig
@ -216,45 +172,18 @@ type AgentConfig struct {
ProviderManager *agentAPI.ProviderManager
IO *agentIO.IOManager
Memory *memory.GraphDB
// LightMemory 是**轻量内核**的图记忆装配(驻留子用;读 tempmain只写 temp
//
// 给了它就意味着这是轻量内核:`Memory` 必须为 nil
// 于是记忆整理面(块/媒体/流水线/整理工具)全部不可达(见 memoryface.go
LightMemory *memory.LightMemory
Indexer *memory.Indexer
Tracker *tracker.Tracker
Indexer *memory.Indexer
Tracker *tracker.Tracker
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
DocStore *document.Store
Knowledge *knowledge.Store
SocialStore *social.SocialStore
TextMemory *text.Memory
MediaStore *media.Store
MultimodalSpace vector.MultimodalEmbedder
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
PluginReg *plugin.Registry
PluginDir string
DistillInterval time.Duration
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
@ -271,10 +200,6 @@ type AgentConfig struct {
SkillIndexProvider SkillIndexProvider
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
// MaxToolTurns 是单个任务允许的工具轮次上限0 = 不限)。
// 设计文档 D6主循环必须有硬上限否则模型不停调用就永不完结。
MaxToolTurns int
}
func New(cfg AgentConfig) *Agent {
@ -325,72 +250,54 @@ func New(cfg AgentConfig) *Agent {
}
}
a := &Agent{
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),
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,
mediaStore: cfg.MediaStore,
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),
childTasks: make(map[string]*childTaskState),
interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
embedder: embedder,
multimodalSpace: cfg.MultimodalSpace,
fusionCfg: cfg.FusionCfg,
noMergeMarkers: make(map[string]int),
lastInput: make(map[string]time.Time),
}
// 输入路由inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
// (设计 §4.1「路由发生在进内核之前」。io 层不认识 agent所以在这里把路由器
// 注入进去:插件注入输入时先问它,被别的 agent 接管就不再进本内核队列。
if a.io != nil {
a.io.SetInputRouter(a.routeInputByOwner)
}
return a
}
// SetSkillIndexProvider 注入技能索引提供者skillmgr 插件加载后由 main 接线)。
func (a *Agent) SetSkillIndexProvider(p SkillIndexProvider) { a.skillIndex = p }
func (a *Agent) Start() {
go a.schedulerLoop()
go a.eventLoop()
go a.interceptLoop()
go a.distillLoop()
go a.archiveLoop()
@ -402,89 +309,11 @@ func (a *Agent) Start() {
}
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
// 回它们;带 ResponseCh 的同步注入方cli / clawhubadapter 均无超时)会永久挂起
// (设计 §7 I5、§11.3 X2/X4。必须在 cancel 之前做cancel 会让调度器直接 return。
a.drainPendingInterrupts("agent_stopped")
a.cancel()
}
// drainPendingInterrupts 给排队/待处理/已挂起任务中带同步回执通道的调用方补一条
// skipped 终态(复用 emitSkippedReply非阻塞写不对外发 agent_output 事件)。
func (a *Agent) drainPendingInterrupts(reason string) {
if a.sched == nil {
return
}
pending := a.sched.pendingEvents()
if len(pending) == 0 {
return
}
for _, evt := range pending {
a.emitSkippedReply(evt, reason)
}
log.Printf("[agent] %s: 停机,%d 条待办任务已补 skipped 终态", a.id, len(pending))
}
// 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

@ -451,29 +451,6 @@ func (c *RelevanceContext) Blocks() []memory.MemoryBlock {
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()

View File

@ -1,92 +0,0 @@
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

@ -495,6 +495,7 @@ 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

@ -13,11 +13,25 @@ import (
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// eventLoop 已由 scheduler.go 的 schedulerLoop 取代M2
//
// 原实现直接在 select 里处理 inputCh/selfInputCh没有任何可枚举的队列、
// 无法承载优先级与抢占;现在任务先入就绪队列,由选择函数 pickTaskIndex 决定下一个。
// 兼容性说明M2 全部任务为 LevelBackground因此行为等价于原先的 FIFO。
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
}
}
}
func (a *Agent) interceptLoop() {
defer func() {
@ -50,16 +64,43 @@ func (a *Agent) interceptLoop() {
clone.Payload["interrupt_source"] = evt.Source
clone.Payload["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()
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,
})
}
case <-a.ctx.Done():
@ -68,20 +109,6 @@ 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_"
@ -95,27 +122,22 @@ type selfInputMsg struct {
channel string
}
// selfEvent 把内核自循环消息归一成输入事件。
func selfEvent(msg selfInputMsg) *agentIO.InputEvent {
func (a *Agent) handleSelfInput(msg selfInputMsg) {
if msg.channel == "" {
msg.channel = channelConsolidation // 兼容空值:默认走整理路径
}
return &agentIO.InputEvent{
a.processInput(&agentIO.InputEvent{
Source: "system",
Type: "text",
Payload: map[string]interface{}{"content": msg.text},
OutputChannel: msg.channel,
}
}
func (a *Agent) handleSelfInput(msg selfInputMsg) {
_, _ = a.runInputTask(selfEvent(msg))
})
}
func (a *Agent) handleInput(evt *agentIO.InputEvent) {
switch evt.Type {
case "text", "image", "audio":
_, _ = a.runInputTask(evt)
a.processInput(evt)
case "event":
log.Printf("[agent] event from %s: %v", evt.Source, evt.Payload)
@ -275,51 +297,171 @@ func (a *Agent) mediaToBlocks(payload map[string]interface{}, mediaType string,
return blocks, alt
}
// emitSkippedReply 给被跳过任务的**同步**调用方一个终态
// processInput 是全部模态输入的唯一主干
//
// 为什么要单独一条路径而不是复用 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 {
// 文本、用户上传的图/音频、插件注入的多模态块走同一条路径,因此去重、
// no_memory、通道 Cleaner、中断语义、EventRawInput、媒体入 CAS、媒体记忆绑定
// 对所有模态一致——不会再出现「文本路径加了功能、媒体路径没跟上」
func (a *Agent) processInput(evt *agentIO.InputEvent) {
start := time.Now()
in, ok := a.resolveInput(evt)
if !ok {
return
}
ch := evt.OutputChannel
if ch == "" {
ch = evt.Source
// 去重按文本做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))
return
}
payload := map[string]interface{}{
"content": "",
"request_id": evt.RequestID,
"skipped": true,
"reason": reason,
a.currentOutputChannel = evt.OutputChannel
if a.currentOutputChannel == "" {
a.currentOutputChannel = evt.Source
}
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
if evt.OutputChannel == "_consolidation_" {
a.processConsolidation(evt, in.text)
return
}
// pendingMedia 让 describe_image / transcribe_audio / ocr_image 拿到本轮媒体的
// 原始 data/url也是这三个工具是否出现在工具表里的开关。仅对用户直接上传成立
//payload 里才有 data/url插件注入的是成品 block取不到原始数据。
if evt.Type == "image" || evt.Type == "audio" {
a.pendingMedia = evt.Payload
defer func() { a.pendingMedia = nil }()
}
// 媒体先落进 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.mu.Lock()
a.interruptInput = isInterrupt
a.mu.Unlock()
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
}
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 订阅方靠它们还原附件卡片。
// 媒体路径此前把整个 payload 塞进 content一个 map订阅方按 string 断言
// 直接失败 → 用户发的图从不出现在聊天记录里。
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)
}
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 %s error: %v", evt.Type, 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] %s from %s → response (%dms, tools=%v)", evt.Type, evt.Source, elapsed.Milliseconds(), toolsUsed)
// 本轮捕获的媒体一起挂到这条事件上:用户上传的、插件注入的,以及模型调
// multimodal_see_picture / see_video 时经 SetToolBlocks 注入的(后者在
// process() 里被捕获,纯文本输入也会有)。
turnEvt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: cleanInput,
Response: response,
ToolsUsed: toolsUsed,
ToolResults: toolResults,
}
a.bindEventMedia(&turnEvt, a.drainMediaDigests())
a.context.Append(turnEvt)
a.emitResponse(evt, response)
if !stageCtx.NoMemory {
a.emitMemoryCandidate(evt.Source, cleanInput, response, toolResults, toolsUsed)
}
}
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,
@ -331,20 +473,13 @@ func (a *Agent) emitResponse(evt *agentIO.InputEvent, response string) {
payload["usage"] = stageCtx.TokenUsage
}
if evt.ResponseCh != nil {
// 非阻塞写:ResponseCh 由同步调用方以 cap=1 创建。按不变量 I5每任务恰一次
// 终态)这里永远写得进去;但一旦哪天写出第二次,阻塞会卡死**调度器 goroutine**
// (整个 agent 停摆),而丢弃只是丢一条回执——与 emitSkippedReply 对称。
select {
case evt.ResponseCh <- &agentIO.OutputEvent{
evt.ResponseCh <- &agentIO.OutputEvent{
RequestID: evt.RequestID,
Target: evt.Source,
Type: "text",
Payload: payload,
Done: true,
OutputChannel: ch,
}:
default:
log.Printf("[agent] ResponseCh 已满终态回执被丢弃request=%s可能违反不变量 I5", evt.RequestID)
}
}
@ -361,6 +496,33 @@ 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
@ -377,11 +539,6 @@ 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

View File

@ -107,14 +107,11 @@ func (a *Agent) linkBlocksToDocument(docID string, blocks []memory.MemoryBlock)
// 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 {
if a.memory == 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)
if a.mediaStore == nil {
ec, rc, cErr := a.memory.Commit(triples, sessionID, turnID)
return ec, rc, 0, cErr
}
sentenceIDs, ec, rc, err := a.memory.CommitWithMedia(triples, sessionID, turnID)

View File

@ -1,167 +0,0 @@
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

@ -1,104 +0,0 @@
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

@ -1,44 +0,0 @@
package core
import (
"log"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// routeInputByOwner 实现**输入路由**inputch 是最基本的输入路由单位,
// 划给某个 agent 之后,该通道的输入**只流向那个 agent**,本内核看不到它
// docs/zh/resident-subagent-design.md §4.1:「路由发生在进内核之前」)。
//
// 为什么必须在进内核之前做:插件注入输入的收口是**父**的 IOManager
// `cmd/homed` 里 pluginReg 拿到的就是它),而父的内核是该 io 唯一的消费者。
// 如果不按归属路由,登记表里的 Owner 就只是个标签 —— 现场表现正是如此:
// 子挂着 `inputch=[timer]`timer 的输入却打在父身上,子的轮次永远是 0。
//
// 返回 true = 本次注入已被"持有该 inputch 的 agent"接管,本内核不再处理。
//
// 已知边界:路由只在本 agent 的**直接**驻留子里找。若孙辈的 inputch 由子划拨,
// 而插件注入打在根 io 上,根解析不到那个 owner ⇒ 兜底给根处理(有日志)。
// 这一层要等"孙辈 + 根可见的 agent 表"再收口,此处不静默丢输入。
func (a *Agent) routeInputByOwner(evt *agentIO.InputEvent, isInterrupt bool) bool {
if evt == nil || evt.OutputChannel == "" || a.io == nil {
return false
}
entry, ok := a.io.LookupInputChannel(evt.OutputChannel)
if !ok || entry.Owner == "" || entry.Owner == string(a.id) {
return false // 未分配 / 归自己 ⇒ 本内核处理
}
a.residentMu.Lock()
rc := a.residents[entry.Owner]
a.residentMu.Unlock()
if rc == nil || rc.agent == nil || rc.agent.io == nil {
// 归属到一个不存在(或已销毁、登记表尚未归还)的 agent
// **不吞输入** —— 由本内核兜底处理并留痕。吞掉一条输入比多处理一条更糟:
// 用户会看到"消息发出去了却没人理",而日志里什么都没有。
log.Printf("[route] inputch %s 归属 %s 无对应 agent输入由 %s 兜底", evt.OutputChannel, entry.Owner, a.id)
return false
}
rc.agent.io.DeliverRouted(evt, isInterrupt)
return true
}

View File

@ -1,85 +0,0 @@
package core
import (
"path/filepath"
"testing"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 输入路由是**独占**的inputch 划给子之后,该通道的输入只流向子,父不再收到。
//
// 现场缺陷(用户线上联调实录):子挂着 inputch=[timer]timer 的输入却打在父身上
// (日志 `[agent] interrupt from timer/timer`),子的轮次永远是 0 —— 因为
// `Assign` 只把 Owner 写进登记表,注入路径根本没有按归属路由。
func TestResident_InputchRoutingIsExclusive(t *testing.T) {
parent, _, dir := newRootForResidents(t)
reg := parent.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "sub/in", Plugin: "sub"}); err != nil {
t.Fatal(err)
}
spawnTestResident(t, parent, dir, "r-route", "sub/in")
child := parent.residents["r-route"].agent
before := parent.DumpScheduler().Stats.Enqueued
// 插件往"已划给子"的 inputch 投输入
parent.io.InjectTextTo("plugin-sub", "sub/in", "去查一下这个")
waitFor(t, "子处理了划给它的输入", func() bool {
if child.DumpScheduler().Stats.Executed > 0 {
return true
}
return parent.residents["r-route"].info().TableSize > 0
})
if got := parent.DumpScheduler().Stats.Enqueued; got != before {
t.Fatalf("划给子的 inputch父不应再入队before=%d after=%d", before, got)
}
// 轮次必须真的涨:此前 info() 根本没填 Rounds ⇒ 父永远读到 0
// (现场:子处理表已有 2 条,轮次却显示 0被误判成"子没干活")。
if got := parent.residents["r-route"].info().Rounds; got <= 0 {
t.Fatalf("子处理的轮次应 > 0实际 %d", got)
}
}
// 归属到一个不存在(或已销毁)的 agent 时**不吞输入**:父兜底处理。
// 吞掉一条输入比多处理一条更糟 —— 用户会看到"消息发出去了却没人理",日志里什么都没有。
func TestResident_InputchRoutingFallsBackWhenOwnerMissing(t *testing.T) {
parent, _, dir := newRootForResidents(t)
reg := parent.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "ghost/in", Plugin: "ghost"}); err != nil {
t.Fatal(err)
}
// 故意划给一个不存在的 agent id
if err := reg.Assign("ghost/in", "no-such-agent", 0); err != nil {
t.Fatal(err)
}
_ = dir
before := parent.DumpScheduler().Stats.Enqueued
parent.io.InjectTextTo("plugin-ghost", "ghost/in", "兜底测试")
waitFor(t, "父兜底处理了无人认领的输入", func() bool {
return parent.DumpScheduler().Stats.Enqueued > before
})
}
// 未划拨的 inputchOwner 为空)仍然由父处理 —— 路由不能把默认路径也改掉。
func TestResident_UnassignedInputchStaysWithParent(t *testing.T) {
parent, _, dir := newRootForResidents(t)
reg := parent.io.ChannelRegistry()
if err := reg.Register(agentIO.InputChannel{Name: "own/in", Plugin: "own"}); err != nil {
t.Fatal(err)
}
// 造一个子在跑,确保路由逻辑是"有子存在"的情形
spawnTestResident(t, parent, dir, "r-other", "own/in")
_ = filepath.Join(dir, "residents")
// 把通道退还给父(未分配)
if err := reg.Assign("own/in", "", 0); err != nil {
t.Fatal(err)
}
before := parent.DumpScheduler().Stats.Enqueued
parent.io.InjectTextTo("plugin-own", "own/in", "还是我的")
waitFor(t, "未分配的 inputch 仍由父处理", func() bool {
return parent.DumpScheduler().Stats.Enqueued > before
})
}

View File

@ -1,169 +0,0 @@
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

@ -138,7 +138,7 @@ func (a *Agent) embedMediaOnIngest(digest, mime string, data []byte) {
//
// 为何要缓存而不是当场建块:媒体在 process() 执行期间被捕获,而承载它的
// ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 与既有的 a.pendingMedia 同一手法(均由 schedulerLoop goroutine 独占读写)。
// 与既有的 a.pendingMedia 同一手法(都在 a.mu 保护下)。
func (a *Agent) stageMediaDigests(digests ...string) {
if len(digests) == 0 {
return
@ -197,7 +197,7 @@ func mediaLabel(it *media.Item) string {
//
// 沿用 document.Store 的 doc_<unixnano> 手法(同一份代码库里保持一致,
// 也避免为此引入 uuid 依赖)。纳秒精度足够:同一 Agent 的事件由
// schedulerLoop 单 goroutine 串行 Append不存在同纳秒两条。
// a.mu 串行 Append不存在同纳秒两条。
func newEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}

View File

@ -1,56 +0,0 @@
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

@ -13,19 +13,9 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
channel := strings.TrimPrefix(tc.Name, "output_send__")
payload, _ := tc.Arguments["payload"].(string)
rawType, _ := tc.Arguments["type"].(string)
if channel == "" || payload == "" {
return "工具名称格式: output_send__{channel}payload 不能为空"
if channel == "" || payload == "" || rawType == "" {
return "工具名称格式: output_send__{channel}payload 和 type 不能为空"
}
// type 缺省按 text 处理:绝大多数输出就是文本,让模型为"省略一个默认值"付一次
// 失败重试没有意义(判据该拦的是"不知道发什么",不是"没写众所周知的默认值")。
if rawType == "" {
rawType = "text"
}
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
if !a.IsOutputAllowed(channel) {
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
}
meta, _ := tc.Arguments["meta"].(string)
caps := a.io.GetChannelCapabilities(channel)
@ -156,14 +146,7 @@ func (a *Agent) executeOutputListChannels() string {
if ch.OutputCaps == 0 {
continue
}
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)
parts = append(parts, fmt.Sprintf(" - %s: [%s] %s", ch.Name, ch.OutputCaps.String(), ch.Description))
for _, t := range ch.Tools {
parts = append(parts, fmt.Sprintf(" 工具: %s - %s", t.Name, t.Description))
}

View File

@ -1,138 +0,0 @@
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

@ -1,100 +0,0 @@
package core
import (
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 设计口径:输出是 agent 的**主动调用** —— 收到一次输入后,可以往任意(已授权的)
// 通道发**任意多次**(分段播报、先回执后结论、同时通知多个通道都合法)。
//
// 这条判据钉住的是"提示词里不得出现输出次数限制"。此前 `tooldefs.go` 里写着
// 「每轮对话通常只需调用一次 output_send__{通道名} 即可完成回复」—— 一条凭空的限制,
// 会让模型自己收起合理的多次输出(用户现场指出)。
func TestSystemPromptDoesNotRestrictOutputCount(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
prompt := parent.buildSystemPrompt("", "你好")
banned := []string{
"只需调用一次",
"只能调用一次",
"通常只需调用",
"不要重复发送",
}
for _, b := range banned {
if strings.Contains(prompt, b) {
t.Fatalf("系统提示词里仍有输出次数限制 %q —— 设计上次数不限", b)
}
}
if !strings.Contains(prompt, "输出次数与目标通道由你自己决定") {
t.Fatal("系统提示词应明确「输出次数与目标通道由你自己决定」")
}
if !strings.Contains(prompt, "没有任何「一轮只能发一次」的限制") {
t.Fatal("系统提示词应显式否认「一轮只能发一次」")
}
}
// 输出工具的 type 可省略,缺省按 text 处理(判据该拦的是"不知道发什么"
// 不是"没写众所周知的默认值")。
func TestOutputSendTypeDefaultsToText(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
dev := &outputTestDevice{name: "fakeout"}
if err := parent.io.RegisterDevice(dev); err != nil {
t.Fatal(err)
}
out := parent.executeOutputSendTool(agentAPI.ToolCall{
Name: "output_send__fakeout",
Arguments: map[string]interface{}{"payload": "只给 payload不给 type"},
})
if out != "ok" {
t.Fatalf("省略 type 时应默认 text 并发送成功,得到 %q", out)
}
if len(dev.sent) != 1 {
t.Fatalf("通道应收到 1 次输出,得到 %d", len(dev.sent))
}
if args, _ := dev.sent[0]["args"].(map[string]interface{}); args["type"] != "text" {
t.Fatalf("缺省类型应为 text实际 %v", args["type"])
}
// 工具 schemarequired 只应含 payload
var found bool
for _, td := range parent.buildToolDefs() {
entry, _ := td.(map[string]interface{})
fn, _ := entry["function"].(map[string]interface{})
if n, _ := fn["name"].(string); n != "output_send__fakeout" {
continue
}
found = true
params, _ := fn["parameters"].(map[string]interface{})
req, _ := params["required"].([]string)
if len(req) != 1 || req[0] != "payload" {
t.Fatalf("output_send 的 required 应只有 payload实际 %v", req)
}
}
if !found {
t.Fatal("未生成 output_send__fakeout 工具")
}
}
// 空 payload 仍应被拦(这条判据是对的:不知道发什么不能放过)。
func TestOutputSendStillRequiresPayload(t *testing.T) {
parent, _, _ := newRootForResidents(t)
defer parent.Stop()
if err := parent.io.RegisterDevice(&outputTestDevice{name: "fakeout"}); err != nil {
t.Fatal(err)
}
out := parent.executeOutputSendTool(agentAPI.ToolCall{
Name: "output_send__fakeout",
Arguments: map[string]interface{}{"type": "text"},
})
if !strings.Contains(out, "payload 不能为空") {
t.Fatalf("空 payload 应被拦,得到 %q", out)
}
}

View File

@ -1,54 +0,0 @@
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

@ -1,112 +0,0 @@
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,10 +7,12 @@ 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"
)
// continuationPlaceholder 是工具轮之后补的 user 占位内容。
@ -97,6 +99,420 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
return out
}
func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response string, toolsUsed []string, toolResults []ToolResultItem, err error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.provider == nil {
return "", nil, nil, fmt.Errorf("agent: no LLM provider configured")
}
budget := ComputeTokenBudget(a.provider, a.systemPrompt)
memContext := a.buildMemoryContext(input, budget.MemoryTokens)
sysPrompt := a.buildSystemPrompt(memContext, input)
tools := a.buildToolDefs()
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
}
if blocks, ok := stageCtx.Extra["media_blocks"].([]agentAPI.ContentBlock); ok && len(blocks) > 0 {
if len(msgs) > 0 {
msgs[len(msgs)-1].Blocks = blocks
}
}
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 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})
}
}
}
// lastBatchReplyOnly 记录上一批工具调用是否全部是输出通道发送。
lastBatchReplyOnly := false
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 说明)。
//
// 补位前先移除前面轮次插入的同类占位,保证占位**不随轮次线性累积**——
// 占位是核心插的传输层附加物,不是用户发言,不该在 prompt 里叠成 N 条。
//
// 文案分情况:上一批全是 output_send__* 时不能说“继续”,详见
// replyDeliveredPlaceholder 的说明。
msgs = dropContinuationPlaceholders(msgs)
if last := msgs[len(msgs)-1]; last.Role == "assistant" || last.Role == "tool" {
msgs = append(msgs, agentAPI.Message{
Role: "user",
Content: continuationFor(lastBatchReplyOnly),
})
}
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
}
// 本批是否全部是输出通道发送(=模型刚交付了给用户的回复)。
// 必须在执行前判定:执行过程中的中断/拒绝分支会 continue/break
// 放在循环里统计会漏。
replyOnly := true
for _, tc := range resp.ToolCalls {
if !isOutputDeliveryTool(tc.Name) {
replyOnly = false
break
}
}
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
}
}
// 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 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 {
// 先落进 CAS无论下面走直视还是回退转写媒体本体都该进记忆。
// 不存的后果是 ToolResultItem.Output 只剩那句
// "[已将图片注入后续对话] /tmp/x.png",文件一删线索就断了。
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 != "" {
// 回退链已把媒体转写成文字:并进 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
}
}
// 供下一轮顶部选择补位文案。
lastBatchReplyOnly = replyOnly
}
}
// chatStreamWithFallback 优先流式调用 provider失败时回退非流式 Chat()。
//
// 流式路径ChatStream 拿到 chunk channel逐块累积 content/reasoning_content
@ -109,14 +525,14 @@ func dropContinuationPlaceholders(msgs []agentAPI.Message) []agentAPI.Message {
//
// 超时收益:首包 ~1-3s 到达即建立活性,后续只要 token 在流动就不会触发
// 空闲超时;总生成时长不再受限於 180s 整体超时。
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agentAPI.CompletionRequest, a *Agent) (*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, channel)
resp, accErr := accumulateStream(ctx, ch, a)
// 中断/超时取消必须保持取消语义传给调用方(与原 Chat() 行为一致:
// 被 cancel 时丢弃已收内容返回 err让 process() 的 continue 分支
@ -127,7 +543,7 @@ func chatStreamWithFallback(ctx context.Context, p agentAPI.Provider, req *agent
if a != nil {
a.publishEvent(events.EventContentDelta, map[string]interface{}{
"content": "",
"channel": channel,
"channel": a.currentOutputChannel,
"reset": true,
})
}
@ -157,7 +573,7 @@ type toolCallAcc struct {
// accumulateStream 消费 chunk channel累积为完整 CompletionResponse
// 同时发布增量事件。返回的 response 与非流式 Chat() 的返回等价。
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent, channel string) (*agentAPI.CompletionResponse, error) {
func accumulateStream(ctx context.Context, ch <-chan agentAPI.StreamChunk, a *Agent) (*agentAPI.CompletionResponse, error) {
resp := &agentAPI.CompletionResponse{
ToolCalls: make([]agentAPI.ToolCall, 0),
}
@ -209,7 +625,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": channel,
"channel": a.currentOutputChannel,
})
}
}
@ -218,7 +634,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": channel,
"channel": a.currentOutputChannel,
})
}
}
@ -331,13 +747,11 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
include := 0
for i := len(events) - 1; i >= 0; i-- {
e := events[i]
// ❗单位必须与 EstimateTokens 一致rune×2。这里曾用 `len()`**字节**)再 ×2
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break
// 把更早的事件整段丢掉实测2384 字的中文事件被估成 14398 token > 8192
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
est := len(e.Source) + len(e.Input) + 40
if e.Response != "" {
estTokens += 120
est += 120
}
estTokens := est * 2
if remaining-estTokens < 0 && include > 0 {
break
}

View File

@ -1,32 +0,0 @@
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

@ -1,541 +0,0 @@
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自己的输入通道入口但共享通道登记表
// 并把父的 io 挂成"上级"——**输出通道io 里的 Device由插件登记在父的 io 上**
// 子若不继承这张视图,`output_send__<通道>` 一律被判"通道不存在或不可用"、
// `output_list_channels` 为空、连 `output_send__*` 工具都不会生成
// (现场联调:父侧通道装载完整、子侧 childIO 空壳)。
// 回退是实时的(设备随资源生灭),授权仍由 opts.AllowedOutputs 白名单把关。
childIO := agentIO.NewIOManager()
if reg := a.io.ChannelRegistry(); reg != nil {
childIO.SetChannelRegistry(reg)
}
childIO.SetParentIO(a.io)
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 名(登记进登记表可见)。
//
// inboundChannelName 只拼名字,不产生副作用(注销路径要用它算出同一个名字,
// 不能再去调 residentInboundChannel——那会顺手把刚摘掉的登记又写回去
func inboundChannelName(childID string) string { return "child/" + childID }
func (a *Agent) residentInboundChannel(childID string) string {
ch := inboundChannelName(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)
}
// 注销"父接收该子消息"的入站 inputchchild/<id>)。
//
// 它由 residentInboundChannel 在 create 时登记Owner=父),销毁时必须
// 一并摘掉:登记表是共享的、按 name 全局唯一,残留会随 create/destroy
// 次数单调累积脏数据。实测destroy 后 child/<id> 仍挂在根 agent 名下,
// 而外部没有任何工具能单独注销 inputch只能重启 homed 清。
// 注意用纯函数算名字,不要再走 residentInboundChannel会重新登记
reg.Unregister(inboundChannelName(rc.id))
}
}
// 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),
// Rounds = 子**已执行的轮次数**(调度器的执行计数,单调不减)。
//
// 此前这里根本没填这个字段 ⇒ 父看到的永远是 `轮次=0`,与"处理表已有 N 条"
// 自相矛盾(现场:子明明处理了两轮,父读到 rounds=0误判成"子没干活")。
// 注意它**不等于** len(table):处理表记的是"当前上下文窗口内"的轮次,
// 压缩会清空§8.3),所以窗口内的条数会被重置,而轮次总数不会。
Rounds: rc.agent.roundsExecuted(),
}
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

@ -1,145 +0,0 @@
package core
import (
"path/filepath"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// outputTestDevice 是最小的输出通道替身io 里输出通道就是 Device
type outputTestDevice struct {
name string
sent []map[string]interface{}
}
func (d *outputTestDevice) Name() string { return d.name }
func (d *outputTestDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
func (d *outputTestDevice) Description() string { return "测试输出通道" }
func (d *outputTestDevice) Tools() []agentIO.ToolDef { return nil }
func (d *outputTestDevice) Start() error { return nil }
func (d *outputTestDevice) Stop() error { return nil }
func (d *outputTestDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *outputTestDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *outputTestDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
d.sent = append(d.sent, map[string]interface{}{"tool": tool, "args": args})
return map[string]interface{}{"status": "sent"}, nil
}
func outputSendTool(name, payload string) agentAPI.ToolCall {
return agentAPI.ToolCall{
Name: "output_send__" + name,
Arguments: map[string]interface{}{"payload": payload, "type": "text"},
}
}
// 驻留子必须能看见并使用**父**登记的输出通道。
//
// 现场缺陷(联调实录):父侧通道装载完整、子侧 childIO 空壳 ——
// 子调 output_send__X 被 `GetChannelCapabilities` 判 0 ⇒
// 「通道 [X] 不存在或不可用。可用输出工具列表见 output_list_channels」
// 而 output_list_channels 也是空的。根因是子的 io 是新建的、设备表为空,
// 而输出通道io 的 Device由插件登记在父的 io 上。
func TestResident_InheritsParentOutputChannels(t *testing.T) {
parent, _, dir := newRootForResidents(t)
defer parent.Stop()
fake := &outputTestDevice{name: "fakeout"}
other := &outputTestDevice{name: "other"}
if err := parent.io.RegisterDevice(fake); err != nil {
t.Fatal(err)
}
if err := parent.io.RegisterDevice(other); err != nil {
t.Fatal(err)
}
if _, err := parent.SpawnResident(ResidentOptions{
ID: "r-out",
TaskPrompt: "有情况就发到 fakeout",
// 白名单只放行一个:验证"继承可见"不等于"绕过授权"
AllowedOutputs: []string{"fakeout"},
TempPath: filepath.Join(dir, "residents", "r-out", "graph.db"),
}); err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
child := parent.residents["r-out"].agent
// ① 看得见:修复前这里是 0childIO 空壳)
if caps := child.io.GetChannelCapabilities("fakeout"); caps == 0 {
t.Fatal("驻留子看不见父的输出通道childIO 空壳)")
}
// ② 发得出去:真走 dev.Execute("output", ...)
if out := child.executeOutputSendTool(outputSendTool("fakeout", "子发来的消息")); out != "ok" {
t.Fatalf("子发送应成功,得到 %q", out)
}
if len(fake.sent) != 1 {
t.Fatalf("父通道应收到 1 次输出,得到 %d", len(fake.sent))
}
// ③ 授权闸不被回退绕过:白名单外的通道照样拒绝
if out := child.executeOutputSendTool(outputSendTool("other", "越权")); !strings.Contains(out, "未授权") {
t.Fatalf("白名单外的通道应被拒,得到 %q", out)
}
if len(other.sent) != 0 {
t.Fatal("越权输出不应真的送达")
}
// ④ 工具面一致:子应生成 output_send__fakeout含配套 _help
// 而**不生成**白名单外通道的工具 —— 模型看不到就不会去调。
var names []string
for _, td := range child.buildToolDefs() {
entry, _ := td.(map[string]interface{})
fn, _ := entry["function"].(map[string]interface{})
if n, _ := fn["name"].(string); strings.HasPrefix(n, "output_send__") {
names = append(names, n)
}
}
has := func(want string) bool {
for _, n := range names {
if n == want {
return true
}
}
return false
}
if !has("output_send__fakeout") || !has("output_send__fakeout_help") {
t.Fatalf("子缺少授权通道的输出工具,得到 %v", names)
}
for _, n := range names {
if strings.HasPrefix(n, "output_send__other") {
t.Fatalf("白名单外的通道不该生成工具,得到 %v", names)
}
}
// ⑤ 实时性:父之后新登记的通道,子立刻可见(设备随资源生灭)
late := &outputTestDevice{name: "late"}
if err := parent.io.RegisterDevice(late); err != nil {
t.Fatal(err)
}
if caps := child.io.GetChannelCapabilities("late"); caps == 0 {
t.Fatal("父新登记的通道未实时反映到子(说明是快照而非回退)")
}
}
// 默认授权AllowedOutputs 空)= 完整授权:子用父的全部输出通道。
func TestResident_DefaultOutputsAreFull(t *testing.T) {
parent, _, dir := newRootForResidents(t)
defer parent.Stop()
dev := &outputTestDevice{name: "anywhere"}
if err := parent.io.RegisterDevice(dev); err != nil {
t.Fatal(err)
}
if _, err := parent.SpawnResident(ResidentOptions{
ID: "r-full", TaskPrompt: "待命",
TempPath: filepath.Join(dir, "residents", "r-full", "graph.db"),
}); err != nil {
t.Fatalf("创建驻留子失败: %v", err)
}
child := parent.residents["r-full"].agent
if out := child.executeOutputSendTool(outputSendTool("anywhere", "默认授权")); out != "ok" {
t.Fatalf("默认应完整授权,得到 %q", out)
}
}

View File

@ -1,501 +0,0 @@
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)
}
// 父的入站 inputchchild/<id>)必须在销毁时一并注销,否则登记表残留脏数据——
// HomeAgent 实测destroy 后 child/<id> 仍挂在根 agent 名下,且无工具可单独注销。
if inbound, ok := reg.Lookup("child/child-1"); ok {
t.Fatalf("销毁后父的入站 inputch 应被注销,实际残留:%+v", inbound)
}
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)
}
if _, ok := reg.Lookup(inboundChannelName(id)); ok {
t.Fatalf("父退出后子 %s 的入站 inputch 应被注销", 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

@ -1,179 +0,0 @@
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 依赖(工具面未来会用通道登记)

File diff suppressed because it is too large Load Diff

View File

@ -1,91 +0,0 @@
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

@ -1,167 +0,0 @@
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

@ -1,222 +0,0 @@
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

@ -1,294 +0,0 @@
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

@ -1,356 +0,0 @@
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

@ -1,186 +0,0 @@
package core
// 回归测试:安全点「重新求值」、抢占计数语义、停机补终态、背压计数。
//
// 对照设计稿原文修正的四条:
//
// 1. §4.3/§5.2 —— 临界区期间到达的抢占请求「不丢失:按级别进入中断队列,
// 在**临界区结束后的第一个安全点重新求值**」。实现里此前没有这一步:
// 唯一的武装点是 registerInterrupt凡被拦成「入队」的中断只能等当前任务
// **自然结束**。可复现症状WebUI 终止按钮连按两次,第二次落在 2s 抢占冷却
// 窗内 → 入队 → 再也不会被求值,「终止」看起来没反应。
// 2. SchedulerStats.PreemptsByLevel 的语义是「判定可抢占**并进入 immediate** 的
// 次数」;此前在 setImmediateLocked 之前就计数,于是同一安全点前到达的两条同级
// 中断里、被降级入队的那条也被计入immediate 是单槽,降级是设计要求的路径)。
// 3. 状态面 Preempted 此前直接拿 Stats.Suspended 顶替,与 preempts_by_level 自相矛盾。
// 4. §4.4 / §11.4 Q4 —— 就绪队列满必须「阻塞发送方 + **计数并打日志**」。
import (
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
)
// 被冷却拦成入队的中断,在冷却期满后的第一个安全点必须被重新武装。
//
// 这是「终止按钮连按两次」的最小复现:第一次抢占成功(受害者进入 2s 冷却),
// 第二次在冷却窗内只能入队——修复前它就永远等不到执行了。
func TestRearm_CooldownExpiryPromotesQueuedInterrupt(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskInterrupt, Level: LevelBackground, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = victim
// 模拟「刚被抢占过」:冷却起点就在此刻,且抢占提升已生效(有效级 L2
victim.PreemptCount = 1
victim.LastPreemptAt = time.Now()
evt, _ := textEvent("cli", "第二次终止")
evt.Payload["interrupt"] = true
if a.sched.requestPreempt(evt, LevelCritical) {
t.Fatal("抢占冷却期内不得抢占(应入队)")
}
if got := a.sched.stats.PreemptsByLevel[LevelCritical]; got != 0 {
t.Fatalf("被冷却拦成入队的中断不得计入抢占数,实际 %d", got)
}
if n := len(a.sched.interruptQueues[LevelCritical]); n != 1 {
t.Fatalf("应恰好入队一条,实际 %d>1 说明入队路径重复)", n)
}
// 冷却期满 → 安全点的「重新求值」必须把它武装起来(修复前缺失的正是这一步)。
victim.LastPreemptAt = time.Now().Add(-3 * time.Second)
a.sched.rearmPending()
if !a.sched.preemptGrantedFor() {
t.Fatal("冷却期结束后应重新武装让位信号(设计 §4.3「第一个安全点重新求值」)")
}
snap := a.DumpScheduler()
if snap.Immediate == nil {
t.Fatal("重新求值后应把该中断提升进 immediate 槽")
}
if got := snap.Stats.PreemptsByLevel[LevelCritical]; got != 1 {
t.Fatalf("真正占住 immediate 才能计一次抢占,实际 %d", got)
}
// 重新求值不该把任务复制一份队列必须空、immediate 恰好一条。
if n := len(snap.InterruptQueues[LevelCritical]); n != 0 {
t.Fatalf("提升后 L4 队列应空,实际 %d", n)
}
}
// 同一安全点前到达的两条同级中断immediate 是单槽,第二条只能降级入队;
// 它**没有**进入 immediate因此不得计入 PreemptsByLevel也不得被入队两次。
func TestRearm_SameLevelSecondPreempterIsQueuedNotCounted(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
victim := &Task{ID: 1, Class: TaskQueued, EnqueuedAt: time.Now()}
a.sched.immediate = victim
a.sched.nextRef() // running = 排队任务(有效级 0任何中断都能抢
e1, _ := textEvent("cli", "irq-1")
if !a.sched.requestPreempt(e1, LevelInteractive) {
t.Fatal("第一条 L3 应抢占排队任务")
}
e2, _ := textEvent("cli", "irq-2")
a.sched.requestPreempt(e2, LevelInteractive) // 同级 → 降级入队
snap := a.DumpScheduler()
if got := snap.Stats.PreemptsByLevel[LevelInteractive]; got != 1 {
t.Fatalf("被降级的同级第二条不得计入抢占数(期望 1实际 %d", got)
}
if n := len(snap.InterruptQueues[LevelInteractive]); n != 1 {
t.Fatalf("被降级的那条应在 L3 队列里**恰好**出现一次,实际 %d", n)
}
if snap.Immediate == nil {
t.Fatal("第一条应留在 immediate 槽,两条都不能丢")
}
}
// 状态面 Preempted 必须是「各级抢占数之和」,不能拿 Suspended 顶替。
func TestStatus_PreemptedEqualsSumOfLevels(t *testing.T) {
a := New(AgentConfig{ID: "rt-sum", ProviderManager: agentAPI.NewProviderManager(), IO: agentIO.NewIOManager()})
if a.sched == nil {
t.Fatal("agent 应带调度器")
}
a.sched.mu.Lock()
a.sched.stats.PreemptsByLevel[LevelBackground] = 2
a.sched.stats.PreemptsByLevel[LevelInteractive] = 3
a.sched.stats.Suspended = 99 // 故意与抢占数不等
a.sched.mu.Unlock()
got := a.schedulerStatus()
if got.Preempted != 5 {
t.Fatalf("Preempted 应为各级抢占数之和 5实际 %d拿 Suspended 顶替会得 99", got.Preempted)
}
if got.Suspended != 99 {
t.Fatalf("Suspended 应原样透传,实际 %d", got.Suspended)
}
}
// 停机必须给「从未运行」与「已挂起」的同步任务补终态,
// 否则 cli / clawhubadapter 这类无超时的同步注入方会永久挂起(设计 §7 I5、§11.3 X2/X4
func TestStop_DrainsPendingSyncTasks(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
queuedEvt, queuedCh := textEvent("cli", "排队中,永远不会被调度")
if !a.sched.enqueue(newInputTask(queuedEvt)) {
t.Fatal("入队失败")
}
suspEvt, suspCh := textEvent("cli", "已挂起,停机时不会恢复")
a.sched.suspend(
&Task{ID: 2, Class: TaskInterrupt, Level: LevelInteractive, EnqueuedAt: time.Now(), Event: suspEvt},
a.newTaskFrame("挂起", a.stageCtxFromInput("挂起", "", "")),
)
a.Stop()
for name, ch := range map[string]chan *agentIO.OutputEvent{"排队": queuedCh, "挂起": suspCh} {
select {
case r := <-ch:
if r == nil || r.Payload["skipped"] != true {
t.Fatalf("%s 任务停机时应补 skipped 终态,实际 %+v", name, r)
}
case <-time.After(2 * time.Second):
t.Fatalf("%s 任务停机未补终态(同步调用方会永久挂起)", name)
}
}
}
// 背压计数:持续满只报一次「翻转」不发生;计数本身每次都要累加。
func TestBackpressure_CounterAndTransition(t *testing.T) {
s := newScheduler(1)
if !s.noteBackpressure() {
t.Fatal("首次背压应报告「翻转」")
}
if s.noteBackpressure() {
t.Fatal("持续背压不得重复报告翻转(否则日志会被刷爆)")
}
if s.stats.Backpressure != 2 {
t.Fatalf("背压计数应为 2实际 %d", s.stats.Backpressure)
}
s.clearBackpressure()
if !s.noteBackpressure() {
t.Fatal("队列恢复后再满应再次报告翻转")
}
}
// 集成:就绪队列满时 pumpInbox 必须计一次背压Rejected 保持 0——背压不是丢弃
func TestBackpressure_PumpInboxCountsWhenFull(t *testing.T) {
a := newPreemptAgent(t, newPreemptProvider())
a.sched.maxQueue = 1
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第一条"})
a.io.InjectInput("cli", "text", map[string]interface{}{"content": "第二条"})
a.pumpInbox()
snap := a.DumpScheduler()
if len(snap.Queue) != 1 {
t.Fatalf("maxQueue=1 时队列应恰好 1 条,实际 %d", len(snap.Queue))
}
if snap.Stats.Backpressure == 0 {
t.Fatal("队列满必须计一次背压(设计 §4.4/Q4阻塞发送方 + 计数)")
}
if snap.Stats.Rejected != 0 {
t.Fatalf("背压不是丢弃Rejected 必须保持 0实际 %d", snap.Stats.Rejected)
}
}

View File

@ -1,101 +0,0 @@
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

@ -1,192 +0,0 @@
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

@ -1,124 +0,0 @@
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

@ -1,351 +0,0 @@
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

@ -1,253 +0,0 @@
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

@ -45,7 +45,7 @@ func (a *Agent) evictChildTasksLocked() {
}
}
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) string {
func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall) string {
task, _ := tc.Arguments["task"].(string)
if task == "" {
if b, _ := json.Marshal(tc.Arguments); len(b) > 2 {
@ -69,8 +69,9 @@ func (a *Agent) executeSpawnChild(tc agentAPI.ToolCall, parentChannel string) st
taskID := fmt.Sprintf("child_%d", a.childNextID)
a.childMu.Unlock()
// parentChannel 由调用方(任务帧)传入:子任务完成通知回到**发起这次
// spawn 的那个任务**的通道,而不是"内核当前通道"(那个概念已删除)。
// 捕获父 Agent 当前输出通道:子任务完成通知回到发起对话的通道,
// 让父 Agent 正常感知并可回复用户(而非走无记忆整理路径丢失通知)。
parentChannel := a.currentOutputChannel
if parentChannel == "" || parentChannel == channelConsolidation {
parentChannel = "cli"
}
@ -149,7 +150,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, parentChannel)
result = a.executeToolCall(ct)
}
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})
@ -248,3 +249,5 @@ func (a *Agent) executeLLMTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("未知的 LLM 工具: %s", tc.Name)
}
}

View File

@ -12,14 +12,9 @@ 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": ch,
"channel": a.currentOutputChannel,
}
if ctx != nil && len(ctx.ToolCalls) > 0 {
payload["tool"] = ctx.ToolCalls[0].Name

View File

@ -156,6 +156,7 @@ func collectKernelStatus(
}
}
// Tracker
if trk != nil {
status.Tracker.Available = true
@ -191,19 +192,12 @@ 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),
@ -213,44 +207,15 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
a.io,
a.pluginReg,
a.memory,
knowledgeLister,
a.knowledge,
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

@ -1,103 +0,0 @@
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, "cli")
resp, err := accumulateStream(context.Background(), ch, nil)
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, "cli")
resp, err := accumulateStream(context.Background(), ch, nil)
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, "cli")
resp, err := accumulateStream(ctx, ch, nil)
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}

View File

@ -1,938 +0,0 @@
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 置位、而本循环是唯一读帧者。
//
// 先「重新求值」再判让位:临界区(或抢占冷却期)内被拦成入队的中断,
// 必须在这里重新武装——否则它只能等当前任务自然结束(设计 §4.3/§5.2)。
a.sched.rearmPending()
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

@ -1,211 +0,0 @@
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

@ -1,132 +0,0 @@
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 回归)")
}
}

View File

@ -1,238 +0,0 @@
package core
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
//
// 设计依据 docs/zh/input-scheduler-design.md §11R3 / X3 的 M1 形态):
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
//
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
import (
"context"
"errors"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// scriptProvider 按脚本依次返回 CompletionResponse。
//
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
type scriptProvider struct {
script []*agentAPI.CompletionResponse
idx int
reqs []*agentAPI.CompletionRequest
// err 非空时 Chat 直接返回它(用于错误路径测试)。
// 配合 ProviderError(401) 可跳过 2s 瞬时重试,让测试保持快速。
err error
}
func (s *scriptProvider) Name() string { return "script" }
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
s.reqs = append(s.reqs, req)
if s.err != nil {
return nil, s.err
}
if s.idx >= len(s.script) {
return &agentAPI.CompletionResponse{Content: ""}, nil
}
r := s.script[s.idx]
s.idx++
return r, nil
}
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
return nil, errors.New("script provider: streaming disabled")
}
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
t.Helper()
return New(AgentConfig{
ID: "task-test",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
StageHost: sh,
IO: agentIO.NewIOManager(),
})
}
func tc(id, name string) agentAPI.ToolCall {
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
}
// R3M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
sh := NewStageHost()
var got []string
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
got = append(got, args["q"].(string))
return "OUT", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
{Content: "最终答复"},
}}
a := newTaskTestAgent(t, sp, sh)
stageCtx := a.stageCtxFromInput("你好", "", "")
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "最终答复" {
t.Fatalf("响应=%q期望 %q", resp, "最终答复")
}
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
t.Fatalf("toolsUsed=%v期望恰好一次 t_echo", toolsUsed)
}
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
t.Fatalf("toolResults=%+v期望一条 t_echo/OUT", toolResults)
}
if len(got) != 1 || got[0] != "c1" {
t.Fatalf("工具实参=%v期望恰好执行一次且参数来自脚本", got)
}
if len(sp.reqs) != 2 {
t.Fatalf("LLM 调用次数=%d期望 2工具轮 + 收尾轮)", len(sp.reqs))
}
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
msgs := sp.reqs[1].Messages
var hasAssistantCall, hasToolResult bool
for _, m := range msgs {
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
hasAssistantCall = true
}
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
hasToolResult = true
}
}
if !hasAssistantCall || !hasToolResult {
t.Fatalf("第二轮请求缺少工具调用配对assistant=%v tool=%v", hasAssistantCall, hasToolResult)
}
}
// X3M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "ok", nil
})
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
var script []*agentAPI.CompletionResponse
for i := 0; i < 3; i++ {
script = append(script, &agentAPI.CompletionResponse{
Content: "round",
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
})
}
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
sp := &scriptProvider{script: script}
a := newTaskTestAgent(t, sp, sh)
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if resp != "done" {
t.Fatalf("响应=%q期望 done", resp)
}
if len(toolsUsed) != 3 || len(toolResults) != 3 {
t.Fatalf("toolsUsed=%d toolResults=%d期望各 3", len(toolsUsed), len(toolResults))
}
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
if len(sp.reqs) != 4 {
t.Fatalf("LLM 调用次数=%d期望 4", len(sp.reqs))
}
}
// 状态机对未知 step 必须失败退出而不是空转。
func TestTaskFrame_UnknownStepFails(t *testing.T) {
sp := &scriptProvider{}
a := newTaskTestAgent(t, sp, NewStageHost())
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
f.Step = Step(999)
if out := a.step(f); out != outcomeFailed {
t.Fatalf("未知 step 应返回 outcomeFailed实际 %v", out)
}
if f.Err == nil {
t.Fatal("未知 step 必须带错误信息")
}
}
// D6工具轮次硬上限——模型不停调用工具时必须在有限步内收尾。
//
// 这是审查里定位的 P0core.agent.max_tool_turns 只定义、从没被读过),
// 也是调度器的前提:任务必须可终止。
func TestMaxToolTurns_CapsRunawayLoop(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "again", nil
})
// 脚本远长于上限provider 每轮都给下一批工具调用,模拟“永不停止”。
script := make([]*agentAPI.CompletionResponse, 0, 20)
for i := 0; i < 20; i++ {
script = append(script, &agentAPI.CompletionResponse{
Content: "继续",
ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")},
})
}
sp := &scriptProvider{script: script}
a := New(AgentConfig{
ID: "cap",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: sh,
MaxToolTurns: 3,
})
resp, toolsUsed, toolResults, err := a.process("循环", a.stageCtxFromInput("循环", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if len(toolsUsed) != 3 || len(toolResults) != 3 {
t.Fatalf("工具批=%d/%d期望恰好 3到上限即止不多跑第 4 轮)", len(toolsUsed), len(toolResults))
}
if len(sp.reqs) != 3 {
t.Fatalf("LLM 调用=%d期望 3上限后不再发起新请求", len(sp.reqs))
}
if resp != "继续" {
t.Fatalf("响应=%q期望返回最近一次 LLM 文本", resp)
}
}
// 上限为 0 表示不限(显式退出机制)。
func TestMaxToolTurns_ZeroMeansUnlimited(t *testing.T) {
sh := NewStageHost()
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
return "again", nil
})
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
{Content: "a", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}},
{Content: "b", ToolCalls: []agentAPI.ToolCall{tc("c2", "t_loop")}},
{Content: "c"},
}}
a := New(AgentConfig{
ID: "nocap",
Provider: sp,
ProviderManager: agentAPI.NewProviderManager(),
IO: agentIO.NewIOManager(),
StageHost: sh,
MaxToolTurns: 0,
})
resp, toolsUsed, _, err := a.process("x", a.stageCtxFromInput("x", "", ""))
if err != nil {
t.Fatalf("process 返回错误: %v", err)
}
if len(toolsUsed) != 2 || resp != "c" {
t.Fatalf("不限时应跑完脚本tools=%d resp=%q", len(toolsUsed), resp)
}
}

View File

@ -13,7 +13,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) {
func (a *Agent) executeToolCall(tc agentAPI.ToolCall) (ret string) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin
done := make(chan string, 1)
go func() {
done <- a.executeToolCallInner(tc, channel)
done <- a.executeToolCallInner(tc)
}()
select {
@ -43,10 +43,8 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin
}
}
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string {
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall) string {
switch {
case tc.Name == "persona_set":
return a.executePersonaTool(tc)
case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc)
case strings.HasPrefix(tc.Name, "social_"):
@ -61,21 +59,13 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
return a.executeOutputSendTool(tc)
case tc.Name == "output_list_channels":
return a.executeOutputListChannels()
case tc.Name == "input_channels":
return a.executeInputChannels(tc)
case tc.Name == "resident_agents":
return a.executeResidentAgents(tc)
case tc.Name == "notify_parent":
return a.executeNotifyParent(tc)
case tc.Name == "inputch_note":
return a.executeInputchNote(tc)
case tc.Name == "plgreload":
return a.executePluginReload()
case tc.Name == "get_plugin_tools":
pluginName, _ := tc.Arguments["plugin_name"].(string)
return a.executeGetPluginTools(pluginName)
case tc.Name == "spawn_child":
return a.executeSpawnChild(tc, channel)
return a.executeSpawnChild(tc)
case tc.Name == "child_result":
return a.executeChildResultTool(tc)
case strings.HasPrefix(tc.Name, "llm_"):
@ -96,18 +86,6 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
}
}
// 设备类工具的**授权闸**(最小授权的缺口在这里)。
//
// 设备指令类工具device_ctl_cmdrun/screensee/computeruse/...)走的是工具面,
// 而 AllowedOutputs 只作用于 output_send__<通道> —— 于是"授权"对指令类工具完全无效:
// 驻留子只要拿到 device_ctl_cmdrun 就能指挥**任意**设备。
// 这里按目标设备的通道名 device/<id> 查同一道闸:父授权了哪台设备,才允许指挥哪台。
if _, isDeviceTool := a.io.DeviceOfTool(tc.Name); isDeviceTool {
if id, _ := tc.Arguments["device_id"].(string); id != "" && !a.IsOutputAllowed("device/"+id) {
return fmt.Sprintf("设备 [%s] 未授权给本 agent可用设备见 output_list_channels 的 device/<id> 通道,或 devicedetect", id)
}
}
if a.tracker != nil {
a.tracker.PreAction(tc.Name)
}
@ -124,21 +102,12 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
}
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
g := a.graphMem()
if g == nil {
if a.memory == nil {
if tc.Name == "memory_document_query" {
return a.executeDocTool(tc)
}
return "图记忆系统不可用"
}
// 整理类工具需要**完整内核**的记忆整理面(块/媒体/结构操作)。
// 轻量内核(驻留子)只有图记忆共同面 ⇒ 这些操作明确不可用,不静默降级。
requireFull := func() string {
if a.memory == nil {
return "本 agent 是轻量内核:只能读写图记忆,记忆整理(合并/删除/清理/编辑/统计)不可用"
}
return ""
}
switch tc.Name {
case "memory_recall":
query, _ := tc.Arguments["query_intent"].(string)
@ -154,7 +123,7 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
if len(keywords) == 1 {
keywords = memory.ExtractKeywords(query)
}
result, err := g.Recall(keywords, nil, int(depth), "")
result, err := a.memory.Recall(keywords, nil, int(depth), "")
if err != nil {
return fmt.Sprintf("记忆检索失败: %v", err)
}
@ -192,9 +161,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return strings.Join(parts, "\n")
case "memory_block_merge":
if msg := requireFull(); msg != "" {
return msg
}
entityA, _ := tc.Arguments["entity_a"].(string)
entityB, _ := tc.Arguments["entity_b"].(string)
rounds, _ := tc.Arguments["rounds"].(float64)
@ -255,9 +221,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("已写入 %d 个实体和 %d 条关系", ec, rc)
case "memory_introspect":
if msg := requireFull(); msg != "" {
return msg
}
stats, err := a.memory.Introspect()
if err != nil {
return fmt.Sprintf("查询失败: %v", err)
@ -268,9 +231,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return a.executeDocTool(tc)
case "memory_merge":
if msg := requireFull(); msg != "" {
return msg
}
source, _ := tc.Arguments["source"].(string)
target, _ := tc.Arguments["target"].(string)
if source == "" || target == "" {
@ -283,9 +243,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("已将「%s」合并到「%s」source 已彻底删除,%d 条关系已重定向", source, target, count)
case "memory_delete_entity":
if msg := requireFull(); msg != "" {
return msg
}
name, _ := tc.Arguments["name"].(string)
if name == "" {
return "name 不能为空"
@ -296,9 +253,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return fmt.Sprintf("已彻底删除实体「%s」及其所有关联关系", name)
case "memory_purge":
if msg := requireFull(); msg != "" {
return msg
}
criteria := make(map[string]string)
if v, ok := tc.Arguments["subject_contains"].(string); ok && v != "" {
criteria["subject_contains"] = v
@ -333,9 +287,6 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
return strings.Join(parts, "")
case "memory_edit":
if msg := requireFull(); msg != "" {
return msg
}
oldSubject, _ := tc.Arguments["old_subject"].(string)
oldRelation, _ := tc.Arguments["old_relation"].(string)
oldObject, _ := tc.Arguments["old_object"].(string)

View File

@ -5,8 +5,6 @@ import (
"strings"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
sdkmeta "gitcode.com/JianFeeeee/homeagent-sdk/meta"
)
func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
@ -39,39 +37,12 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int) string {
return s
}
// expandPromptVars 展开自定义提示词(人格卡)里的版本占位符。
//
// 为什么需要:人格卡是**配置项**,一旦写死版本号就会随内核发版而说谎 ——
// 实测线上人格卡写着 "HΔ-Kernel v1.0.3 型号",内核早已 1.3.xagent 向用户
// 自报版本时就照抄 1.0.3。占位符让这类文本永远跟随真实构建:
//
// {{kernel_version}} → 内核版本(如 1.3.5
// {{kernel_commit}} → 构建 commit
// {{sdk_version}} → 所兼容的 SDK 版本(如 1.3.0
//
// 未知占位符**原样保留**:写错了要看得见,而不是被静默换成空串。
func expandPromptVars(s string) string {
if !strings.Contains(s, "{{") {
return s
}
return strings.NewReplacer(
"{{kernel_version}}", meta.Version,
"{{kernel_commit}}", meta.Commit,
"{{sdk_version}}", sdkmeta.Version,
).Replace(s)
}
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := expandPromptVars(a.systemPrompt)
prompt := a.systemPrompt
if prompt == "" {
prompt = "你是小宅HomeAgent 的看板娘,一个家政型 AI 管家助手。绝不用 Unicode emoji只用颜文字表达情感句尾带语气词。WebUI 概览页展示你的立绘。"
}
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」
if a.taskPrompt != "" {
prompt += "\n\n【任务】" + a.taskPrompt
}
if a.personality != nil {
if pp := a.personality.InjectPrompt(); pp != "" {
prompt += "\n\n" + pp
@ -97,19 +68,12 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt += "\n\n【中断消息】长任务执行期间工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道你必须自己决定如何回复\n"
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
prompt += "- 当前输入来自哪个通道,就优先用哪个通道回复;不要串到其他通道(除非用户明确要求)。\n"
prompt += "- 当前输入来源通道(即对话发生的通道)是:" + a.currentOutputChannel + "。对应输出门工具是 output_send__{该通道名}。\n"
prompt += "- 同步通道webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
prompt += "- 异步通道qq / wechat / 群聊等):返回纯文本**【不会】**自动送达用户,必须调用 output_send__{通道名} 工具(注意 meta 里带上正确的 user_id 或 group_id才能真正把消息发出去。\n"
prompt += "- 不确定当前通道的发送方式时,先用 output_send__{通道名}_help 查看该通道的 meta 格式和 type 枚举,再决定。\n"
// ❗这里**不得**限制"一轮只能发一次"。设计上输出是 agent 的**主动调用**
// 收到一次输入后,可以往**任意(已授权的)通道**发**任意多次**(分段播报、
// 先回执后结论、同时通知多个通道都合法)。此前这里写着"每轮对话通常只需调用
// 一次 output_send"——那是一条**凭空的限制**,会让模型自己收起合理的多次输出。
// 真正需要提醒的只有两件事:单条长度上限(超长拆成完整段落)与"别反复重发
// 完全相同的内容"(自律,不是判据)。
prompt += "- **输出次数与目标通道由你自己决定**:一次输入可以对同一通道发多条(先回执后结论、分步播报、分段长文),也可以同时发到多个通道(例如同时通知 webui 与 qq。**没有任何「一轮只能发一次」的限制。**\n"
prompt += "- 输出时只需注意两点:单条消息的长度上限(超长就拆成完整段落,不要碎片化);别反复重发**完全相同**的内容(那是浪费,不是限制)。\n"
prompt += "- 每轮对话**通常只需调用一次** output_send__{通道名} 即可完成回复。仅在内容确实超过单条消息长度上限(如 >4000 字)时才拆分为多条;拆分时每条应是完整段落,不要碎片化。\n"
prompt += "- 需要多步执行的长任务:**必须先**向当前对话通道发一条确认消息告诉用户已收到(异步通道用输出门工具,同步通道直接返回文本),**然后再**执行具体排查工具。确认消息不代表任务完成,发出后仍需继续执行实际工具并最终汇报结果。\n"
prompt += "- 用户从其他渠道发来「在哪里/怎么样了」这类追问时,先回忆上次任务的通道与上下文,再回同一通道。"
@ -125,16 +89,6 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
}
}
// 首启人格门禁(跨通道唯一闸口):人格未确认时,要求模型主动询问用户。
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
// 模型调用 persona_set或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
"用户选默认 → mode=default自定义 → mode=custom 且把内容写进 content" +
"用户说以后再说 → mode=later。用户答复前不要假设已设置也不要反复追问同一件事。"
}
prompt += a.buildToolCatalog()
return prompt
@ -243,24 +197,6 @@ func (a *Agent) buildToolDefs() []interface{} {
}
}
if a.personaStore != nil {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "persona_set",
"description": "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default自定义用 mode=custom 并把人格内容放进 content用户说以后再说用 mode=later。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
"content": map[string]interface{}{"type": "string", "description": "自定义人格内容mode=custom 时必填)"},
},
"required": []string{"mode"},
},
},
})
}
if a.memory != nil {
tools = append(tools, map[string]interface{}{
"type": "function",
@ -616,11 +552,6 @@ func (a *Agent) buildToolDefs() []interface{} {
if ch.Type != agentIO.DeviceOutput && ch.Type != agentIO.DeviceIO {
continue
}
// 输出通道授权(设计 §4.4/R2默认完整授权父可用白名单收窄子的输出能力。
// 未授权就不生成 output_send__X —— 模型看不到它,自然不会调。
if !a.IsOutputAllowed(ch.Name) {
continue
}
capStr := a.io.GetChannelCapabilities(ch.Name).String()
desc := ch.Description
if desc == "" {
@ -631,7 +562,7 @@ func (a *Agent) buildToolDefs() []interface{} {
"type": "function",
"function": map[string]interface{}{
"name": "output_send__" + ch.Name,
"description": desc + "。能力: " + capStr + "。payload 为消息载荷type 默认 text可省略meta 为 JSON 发送元数据。用 _help 查看 meta 格式 type 枚举。",
"description": desc + "。能力: " + capStr + "。payload 为消息载荷meta 为 JSON 发送元数据type 为载荷类型。用 _help 查看 meta 格式 type 枚举。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -645,10 +576,10 @@ func (a *Agent) buildToolDefs() []interface{} {
},
"type": map[string]interface{}{
"type": "string",
"description": "载荷类型,默认 text其它枚举用 channel._help 查看",
"description": "载荷类型,用 channel._help 查看支持的枚举值",
},
},
"required": []string{"payload"},
"required": []string{"payload", "type"},
},
},
})
@ -678,90 +609,6 @@ func (a *Agent) buildToolDefs() []interface{} {
},
})
// 父侧:驻留子控制面(单工具多动作,见设计 §7
if a.parentID == "" {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "resident_agents",
"description": "管理驻留子 agent长期派驻的下属list 列出 / create 创建(划入 inputch + " +
"授权输出通道 + 注入任务提示词)/ send 发送消息(对子而言是 L4 中断,取消其当前状态并插入新消息)" +
"/ inspect 查看其 inputch 处理表(不打断它)/ compress 压缩其上下文(保留语义,子继续存在)" +
"/ reclaim 回收(父选哪些纳入主记忆,然后取消该子)/ destroy 立刻销毁并移除。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"enum": []string{"list", "create", "send", "inspect", "compress", "reclaim", "destroy"},
},
"id": map[string]interface{}{"type": "string", "description": "驻留子 id"},
"task_prompt": map[string]interface{}{"type": "string", "description": "create在固定提示词之上注入的任务提示词"},
"input_chs": map[string]interface{}{"type": "string", "description": "create划入的 inputch逗号分隔"},
"allowed_outputs": map[string]interface{}{"type": "string", "description": "create授权的输出通道逗号分隔留空=完整授权)"},
"capacity": map[string]interface{}{"type": "number", "description": "create划入 inputch 的队列容量"},
"temp_path": map[string]interface{}{"type": "string", "description": "createtemp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db"},
"text": map[string]interface{}{"type": "string", "description": "send要发给子 agent 的消息"},
},
"required": []string{"action"},
},
},
})
}
// 子侧驻留子主动汇报L3与主动写处理表。
if a.parentID != "" {
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "notify_parent",
"description": "向主 agent 汇报(以 L3 中断投给它)。用于主动报告进展/结论,而不是等它来问。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "汇报内容"}},
"required": []string{"text"},
},
},
})
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "inputch_note",
"description": "为**本轮** inputch 主动写入处理信息(主 agent 会查这张表判断你的进度)。" +
"写了就不会再被系统自动记录;不写则本轮结束时系统自动写。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{"text": map[string]interface{}{"type": "string", "description": "本轮处理信息摘要"}},
"required": []string{"text"},
},
},
})
}
tools = append(tools, map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": "input_channels",
"description": "查看 inputch最基本的输入路由单位哪些已注册、谁注册的、" +
"各自划给了哪个 agent、容量与记忆策略。单工具多视图。",
"parameters": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"view": map[string]interface{}{
"type": "string",
"description": "all=全部已注册(默认)| mine=划给本 agent 的 | " +
"unassigned=尚未划出的 | by_agent=按归属分组的划分总览 | detail=单个详情",
"enum": []string{"all", "mine", "unassigned", "by_agent", "detail"},
},
"name": map[string]interface{}{
"type": "string",
"description": "view=detail 时必填inputch 名",
},
},
},
},
})
if a.pendingMedia != nil {
tools = append(tools, map[string]interface{}{
"type": "function",

View File

@ -96,36 +96,13 @@ type OutputEvent struct {
}
type IOManager struct {
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
interruptCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
channelReg *ChannelRegistry
// parent 是"上级 IOManager"(驻留子的轻量内核指向父的内核)。
//
// 为什么需要:**输出通道在 io 层就是 Device**,而它们是由插件登记在**父**的
// io 上的。驻留子有自己的 IOManager自己的输入入口、自己的 outputCh
// 若只看自己那张空表,`output_send__<通道>` 会被判"通道不存在或不可用"
// `output_list_channels` 是空的,`output_send__*` 工具也不会生成
// —— 现场表现就是"驻留子不会说话/不会发消息"(联调实录:父侧通道装载完整、
// 子侧 childIO 空壳)。
//
// 用**实时回退**而不是创建时复制快照:设备会随资源生灭(远程设备上线/掉线
// 以分钟计),复制出来的表转瞬就过期。授权由各自的 AllowedOutputs 白名单把关,
// 回退只解决"看得见",不解决"能不能用"。
parent *IOManager
// inputRouter 决定一条输入是否被"别的 agent"接管(返回 true = 已接管)。
//
// 为什么放在 ioinputch 是**最基本的输入路由单位**,而**路由发生在进内核之前**
// docs/zh/resident-subagent-design.md §4.1)。插件注入输入的收口就在这里,
// 所以路由必须在这里生效 —— inputch 划给某个 agent 后,输入**只流向那个 agent**
// 本内核根本看不到它。io 层不认识 agent路由器由内核注入
// (见 core.Agent.routeInputByOwner
inputRouter InputRouter
mu sync.RWMutex
devices map[string]Device
inputCh chan *InputEvent
interruptCh chan *InputEvent
outputCh chan *OutputEvent
nextReqID int64
inputChannels map[string]ChannelDef
// toolBlocks插件工具注入多模态内容块process.go 在下一条 tool message 时消费。
// 用 interface{}[] 避免 import api.ContentBlock 导致的循环依赖。
@ -135,80 +112,14 @@ type IOManager struct {
func NewIOManager() *IOManager {
return &IOManager{
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
interruptCh: make(chan *InputEvent, 64),
outputCh: make(chan *OutputEvent, 256),
channelReg: NewChannelRegistry(),
devices: make(map[string]Device),
inputCh: make(chan *InputEvent, 256),
interruptCh: make(chan *InputEvent, 64),
outputCh: make(chan *OutputEvent, 256),
inputChannels: make(map[string]ChannelDef),
}
}
// InputRouter 是输入路由器的签名。
//
// evt 待投递的输入事件OutputChannel 即它的 inputch
// isInterrupt 该输入是中断还是排队(两者都要按归属路由)
// 返回 true = 已被别的 agent 接管,本内核不再处理
type InputRouter func(evt *InputEvent, isInterrupt bool) bool
// SetInputRouter 注入输入路由器nil = 不路由,行为与以前完全一致)。
func (m *IOManager) SetInputRouter(r InputRouter) {
m.mu.Lock()
m.inputRouter = r
m.mu.Unlock()
}
// deliverInput 是**本内核**接收一条外部输入的收口:先按 inputch 归属路由,
// 被别的 agent 接管就不进本内核队列(划给子的 inputch父不再收到 —— 这是「划拨」
// 的语义,不是"父也顺便看一眼")。
func (m *IOManager) deliverInput(evt *InputEvent, isInterrupt bool) {
m.mu.RLock()
router := m.inputRouter
m.mu.RUnlock()
if router != nil && router(evt, isInterrupt) {
return
}
m.pushLocal(evt, isInterrupt)
}
// DeliverRouted 把**已被路由**的事件放进本内核队列(不再二次路由)。
// 由路由器实现调用:父把输入交给持有该 inputch 的子。
func (m *IOManager) DeliverRouted(evt *InputEvent, isInterrupt bool) {
m.pushLocal(evt, isInterrupt)
}
func (m *IOManager) pushLocal(evt *InputEvent, isInterrupt bool) {
if isInterrupt {
m.interruptCh <- evt
return
}
m.inputCh <- evt
}
// SetParentIO 设置上级 IOManagernil 表示无上级,行为与以前完全一致)。
// 见 parent 字段的说明:用于驻留子继承父的输出通道/设备视图。
func (m *IOManager) SetParentIO(p *IOManager) {
m.mu.Lock()
m.parent = p
m.mu.Unlock()
}
// lookupDevice 查设备:自己的登记优先,其次回退到上级。
//
// 先在自己锁内取快照再查上级,**不跨锁调用**(避免锁序问题)。
func (m *IOManager) lookupDevice(name string) Device {
m.mu.RLock()
dev, ok := m.devices[name]
parent := m.parent
m.mu.RUnlock()
if ok {
return dev
}
if parent != nil {
return parent.GetDevice(name)
}
return nil
}
func (m *IOManager) UnregisterDevice(name string) {
m.mu.Lock()
defer m.mu.Unlock()
@ -247,7 +158,9 @@ func (m *IOManager) RegisterDevice(dev Device) error {
}
func (m *IOManager) GetDevice(name string) Device {
return m.lookupDevice(name)
m.mu.RLock()
defer m.mu.RUnlock()
return m.devices[name]
}
func (m *IOManager) StartAll() error {
@ -282,52 +195,50 @@ func (m *IOManager) StopAll() {
}
func (m *IOManager) InjectInput(source string, eventType string, payload map[string]interface{}) {
m.deliverInput(&InputEvent{
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
OutputChannel: source,
}, false)
}
}
func (m *IOManager) InjectInputSync(source string, eventType string, payload map[string]interface{}) *OutputEvent {
ch := make(chan *OutputEvent, 1)
m.deliverInput(&InputEvent{
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
ResponseCh: ch,
OutputChannel: source,
}, false)
// 被路由走时,回答由持有该 inputch 的 agent 写进同一个 ResponseCh
//§4.3:同步输入的回程是事前定好的)——所以这里照常等待。
}
return <-ch
}
// InjectInputTo 注入输入事件并指定输出通道
func (m *IOManager) InjectInputTo(source, outputChannel, eventType string, payload map[string]interface{}) {
m.deliverInput(&InputEvent{
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
OutputChannel: outputChannel,
}, false)
}
}
// InjectInputSyncTo 注入输入事件(同步等待)并指定输出通道
func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, payload map[string]interface{}) *OutputEvent {
ch := make(chan *OutputEvent, 1)
m.deliverInput(&InputEvent{
m.inputCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: eventType,
Payload: payload,
ResponseCh: ch,
OutputChannel: outputChannel,
}, false)
}
return <-ch
}
@ -355,10 +266,6 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
if opts.CleanerName != "" {
payload["cleaner_name"] = opts.CleanerName
}
// priority 只对中断注入有意义;排队路径会忽略它(内核侧只读不写)。
if opts.Priority != "" {
payload["priority"] = opts.Priority
}
}
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
@ -422,13 +329,13 @@ func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]i
payload = map[string]interface{}{}
}
evtType, _ := payload["type"].(string)
m.deliverInput(&InputEvent{
m.interruptCh <- &InputEvent{
RequestID: m.nextRequestID(),
Source: source,
Type: evtType,
Payload: payload,
OutputChannel: channel,
}, true)
}
}
func (m *IOManager) InjectInterruptText(source, channel, text string) {
@ -533,57 +440,26 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
// RegisterInputChannel 注册一个 inputch不带插件归属兼容旧调用
//
// inputch 是**最基本的输入路由单位**;一个插件可以注册多个。
// 新代码请用 RegisterInputChannelFrom 以便登记归属插件(可追溯)。
// RegisterInputChannel 注册输入通道的记忆行为
func (m *IOManager) RegisterInputChannel(name string, def ChannelDef) {
_ = m.RegisterInputChannelFrom("", name, def)
}
// RegisterInputChannelFrom 注册一个 inputch 并登记归属插件。
func (m *IOManager) RegisterInputChannelFrom(plugin, name string, def ChannelDef) error {
return m.channelReg.Register(InputChannel{Name: name, Plugin: plugin, Def: def})
}
// UnregisterInputChannel 注销一个 inputch。
func (m *IOManager) UnregisterInputChannel(name string) {
m.channelReg.Unregister(name)
}
// AssignInputChannel 把一个 inputch 划给某个 agent见 ChannelRegistry.Assign
func (m *IOManager) AssignInputChannel(name, agentID string, capacity int) error {
return m.channelReg.Assign(name, agentID, capacity)
}
// SetChannelRegistry 注入一份**共享的**登记表(根 agent 与驻留子共用同一份)。
func (m *IOManager) SetChannelRegistry(r *ChannelRegistry) {
if r == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
m.channelReg = r
m.inputChannels[name] = def
}
// ChannelRegistry 返回底层登记表(只读用途;可直接读 Views
func (m *IOManager) ChannelRegistry() *ChannelRegistry { return m.channelReg }
// InputChannels 返回全部已注册 inputch按名字排序
func (m *IOManager) InputChannels() []InputChannel { return m.channelReg.List() }
// LookupInputChannel 查询单个 inputch 的完整登记记录。
func (m *IOManager) LookupInputChannel(name string) (InputChannel, bool) {
return m.channelReg.Lookup(name)
// UnregisterInputChannel 注销输入通道
func (m *IOManager) UnregisterInputChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.inputChannels, name)
}
// GetInputChannelDef 查询 inputch 的记忆行为定义
// GetInputChannelDef 查询输入通道的记忆行为定义
func (m *IOManager) GetInputChannelDef(name string) (ChannelDef, bool) {
ch, ok := m.channelReg.Lookup(name)
if !ok {
return ChannelDef{}, false
}
return ch.Def, true
m.mu.RLock()
defer m.mu.RUnlock()
def, ok := m.inputChannels[name]
return def, ok
}
func (m *IOManager) GetAllTools() []ToolDef {
@ -596,23 +472,6 @@ func (m *IOManager) GetAllTools() []ToolDef {
return tools
}
// DeviceOfTool 返回提供该工具的**设备/输出通道名**(设备类工具才有)。
//
// 用途设备类工具device_ctl_*/screensee/computeruse/...)需要按"目标设备"
// 做授权判断,调用方得先知道这个工具属于哪个设备通道。
func (m *IOManager) DeviceOfTool(name string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, dev := range m.devices {
for _, t := range dev.Tools() {
if t.Name == name {
return dev.Name(), true
}
}
}
return "", false
}
func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
m.mu.RLock()
type nameDevice struct {
@ -631,15 +490,6 @@ func (m *IOManager) ExecuteTool(name string, args map[string]interface{}) (ret i
m.mu.RUnlock()
if len(candidates) == 0 {
// 自己没这个设备工具 → 看上级(驻留子的设备工具都在父的 io 上)。
m.mu.RLock()
parent := m.parent
m.mu.RUnlock()
if parent != nil {
if ret, err := parent.ExecuteTool(name, args); err == nil {
return ret, nil
}
}
return nil, fmt.Errorf("tool %s not found", name)
}
defer func() {
@ -672,22 +522,10 @@ type ChannelInfo struct {
func (m *IOManager) ListChannels() []ChannelInfo {
m.mu.RLock()
own := make(map[string]Device, len(m.devices))
for name, dev := range m.devices {
own[name] = dev
}
parent := m.parent
m.mu.RUnlock()
defer m.mu.RUnlock()
// 自己的登记优先(子侧可覆盖/屏蔽同名通道),随后并入上级的可见通道。
// 去重按**名字**:同名即视为同一个通道,不重复列举。
seen := make(map[string]bool, len(own))
var list []ChannelInfo
appendDev := func(dev Device) {
if seen[dev.Name()] {
return
}
seen[dev.Name()] = true
for _, dev := range m.devices {
list = append(list, ChannelInfo{
Name: dev.Name(),
Type: dev.Type(),
@ -696,23 +534,13 @@ func (m *IOManager) ListChannels() []ChannelInfo {
OutputCaps: dev.OutputCapabilities(),
})
}
for _, dev := range own {
appendDev(dev)
}
if parent != nil {
for _, ch := range parent.ListChannels() {
if seen[ch.Name] {
continue
}
seen[ch.Name] = true
list = append(list, ch)
}
}
return list
}
func (m *IOManager) GetChannelCapabilities(channel string) OutputCapability {
if dev := m.lookupDevice(channel); dev != nil {
m.mu.RLock()
defer m.mu.RUnlock()
if dev, ok := m.devices[channel]; ok {
return dev.OutputCapabilities()
}
return 0

View File

@ -1,203 +0,0 @@
package io
// inputch 是**最基本的输入路由单位**(设计见 docs/zh/resident-subagent-design.md §4
//
// 一个插件可以注册多个 inputch每个 inputch 是彼此独立的路由单位:
// 可以被划给不同的 agent、可以分别限额。中断输入与排队输入**两类都从 inputch 进出**
// 而"中断 vs 排队"是每条输入自己的类别 —— 不是 inputch 的属性。
//
// 本文件只放**登记表**(谁是注册者、划给了谁、容量多少、记忆策略是什么)。
// 路由本身发生在进内核之前:投递方决定"这条输入投给哪个 inputch"。
import (
"errors"
"sort"
"sync"
)
var (
// ErrInputChannelUnknown 表示引用了未注册的 inputch。
ErrInputChannelUnknown = errors.New("inputch 未注册")
// ErrInputChannelNameEmpty 表示 inputch 名为空。
ErrInputChannelNameEmpty = errors.New("inputch 名不能为空")
)
// InputChannel 是一个 inputch 的完整登记记录。
type InputChannel struct {
// Name 是路由单位 id全局唯一如 "qq"、"webui"、"qq/device-2")。
Name string `json:"name"`
// Plugin 是注册它的插件名("插件可注册多个 inputch",归属可追溯)。
Plugin string `json:"plugin,omitempty"`
// Owner 是**被划给的 agent id**"" = 未分配,归根 agent/内核默认)。
Owner string `json:"owner,omitempty"`
// Capacity 是该 inputch 的队列容量0 = 用内核默认值)。
Capacity int `json:"capacity,omitempty"`
// Output 是该 inputch 的默认回程输出通道("" = 由来源/调用方决定)。
//
// 注意:这不是"内核路由"—— 输出仍然是 agent 的主动调用;这里只是登记
// "这个 inputch 的回复默认该往哪个 outputch 走"的映射依据。
Output string `json:"output,omitempty"`
// Def 是记忆/上下文策略(沿用 ChannelDefNoMemory / Cleaner / ContextPolicy
// Cleaner 是函数故本字段不可序列化json:"-")。
Def ChannelDef `json:"-"`
}
// ChannelRegistry 是 inputch 的登记表。
//
// 它被设计成**可共享对象**`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
// 这样"划入/授权"才有意义;每个 IOManager 默认自带一份(向后兼容)。
type ChannelRegistry struct {
mu sync.RWMutex
channels map[string]InputChannel
// outputTargets 把**输出通道**解析成"目标 agent 的哪个 inputch"。
// 这是"输出可寻址到具体 agent"的依据(子→父、父→指定子)。
outputTargets map[string]OutputTarget
}
// OutputTarget 是一个输出通道的投递目标。
type OutputTarget struct {
// AgentID 是目标 agent"" = 本 agent / 由传输层通道 device 自行处理)。
AgentID string `json:"agent_id,omitempty"`
// InputCh 是目标 agent 上接收它的 inputch"" = 与输出通道同名)。
InputCh string `json:"inputch,omitempty"`
}
// NewChannelRegistry 构造一个空的 inputch 登记表。
func NewChannelRegistry() *ChannelRegistry {
return &ChannelRegistry{
channels: make(map[string]InputChannel),
outputTargets: make(map[string]OutputTarget),
}
}
// Register 登记/更新一个 inputch。
//
// 重复登记(插件重载)时**保留已有的 Owner/Capacity/Output**,只更新
// Plugin 与 Def —— 否则一次插件重载就会把父 agent 做的划分抹掉。
func (r *ChannelRegistry) Register(ch InputChannel) error {
if ch.Name == "" {
return ErrInputChannelNameEmpty
}
r.mu.Lock()
defer r.mu.Unlock()
if r.channels == nil {
r.channels = make(map[string]InputChannel)
}
if old, ok := r.channels[ch.Name]; ok {
if ch.Owner == "" {
ch.Owner = old.Owner
}
if ch.Capacity == 0 {
ch.Capacity = old.Capacity
}
if ch.Output == "" {
ch.Output = old.Output
}
if ch.Plugin == "" {
ch.Plugin = old.Plugin
}
}
r.channels[ch.Name] = ch
return nil
}
// Unregister 注销一个 inputch。
func (r *ChannelRegistry) Unregister(name string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.channels, name)
}
// Lookup 查询一个 inputch。
func (r *ChannelRegistry) Lookup(name string) (InputChannel, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
ch, ok := r.channels[name]
return ch, ok
}
// Assign 把一个 inputch **划给**某个 agent可同时给定容量
//
// 语义(默认取值,见设计文档 R6**读写授权**,不转移所有权 ——
// 登记表仍记录 Plugin谁注册的与 Owner划给了谁两件事。
func (r *ChannelRegistry) Assign(name, agentID string, capacity int) error {
r.mu.Lock()
defer r.mu.Unlock()
ch, ok := r.channels[name]
if !ok {
return ErrInputChannelUnknown
}
ch.Owner = agentID
if capacity > 0 {
ch.Capacity = capacity
}
r.channels[name] = ch
return nil
}
// List 返回全部已注册 inputch按名字稳定排序
func (r *ChannelRegistry) List() []InputChannel {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]InputChannel, 0, len(r.channels))
for _, ch := range r.channels {
out = append(out, ch)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// ListByOwner 返回划给某个 agent 的 inputchowner == "" 时返回**未分配**的)。
func (r *ChannelRegistry) ListByOwner(agentID string) []InputChannel {
var out []InputChannel
for _, ch := range r.List() {
if ch.Owner == agentID {
out = append(out, ch)
}
}
return out
}
// BindOutputTarget 登记"输出通道 → 目标 agent 的 inputch"的解析。
//
// 例:父把子用的输出通道 "to-child-1" 绑到 (child-1, "sub/in")
// 于是子经该通道发出的消息会投进 child-1 的 sub/in。
func (r *ChannelRegistry) BindOutputTarget(output, agentID, inputCh string) error {
if output == "" {
return errors.New("输出通道名不能为空")
}
r.mu.Lock()
defer r.mu.Unlock()
if r.outputTargets == nil {
r.outputTargets = make(map[string]OutputTarget)
}
r.outputTargets[output] = OutputTarget{AgentID: agentID, InputCh: inputCh}
return nil
}
// ResolveOutputTarget 解析一个输出通道的目标;未登记时 ok=false
// (意味着由传输层通道自行处理,如 qq/webui 这类 device 通道)。
func (r *ChannelRegistry) ResolveOutputTarget(output string) (OutputTarget, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
t, ok := r.outputTargets[output]
return t, ok
}
// ListOutputTargets 返回全部已登记的目标解析(按输出通道名排序)。
func (r *ChannelRegistry) ListOutputTargets() map[string]OutputTarget {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]OutputTarget, len(r.outputTargets))
for k, v := range r.outputTargets {
out[k] = v
}
return out
}
// Count 返回已注册 inputch 数量。
func (r *ChannelRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.channels)
}

View File

@ -1,134 +0,0 @@
package io
// inputch 登记表与划分(单工具多视图背后的数据面)。
//
// 设计依据 docs/zh/resident-subagent-design.md §4
// inputch 是**最基本的输入路由单位**,由插件注册,一个插件可注册多个。
import "testing"
func TestChannelRegistry_RegisterKeepsAttribution(t *testing.T) {
r := NewChannelRegistry()
// 一个插件注册多个 inputch最基本的输入路由单位
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
if err := r.Register(InputChannel{Name: "qq/device-2", Plugin: "qq"}); err != nil {
t.Fatal(err)
}
if n := r.Count(); n != 2 {
t.Fatalf("注册数=%d期望 2同一插件的多个 inputch 彼此独立)", n)
}
ch, ok := r.Lookup("qq/device-2")
if !ok || ch.Plugin != "qq" {
t.Fatalf("归属插件未记录:%+v ok=%v", ch, ok)
}
if _, ok := r.Lookup("nope"); ok {
t.Fatal("未注册的 inputch 不应查得到")
}
if err := r.Register(InputChannel{Name: ""}); err != ErrInputChannelNameEmpty {
t.Fatalf("空名应报错,实际 %v", err)
}
}
// 插件重载(重复登记)**不得抹掉划分**Owner/Capacity/Output 必须保留。
func TestChannelRegistry_ReRegisterKeepsAllocation(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
if err := r.Assign("qq", "agent-1", 128); err != nil {
t.Fatal(err)
}
// 插件重载:只带 Plugin/Def不带 Owner/Capacity。
if err := r.Register(InputChannel{Name: "qq", Plugin: "qq", Def: ChannelDef{NoMemory: true}}); err != nil {
t.Fatal(err)
}
ch, _ := r.Lookup("qq")
if ch.Owner != "agent-1" || ch.Capacity != 128 {
t.Fatalf("重载后划分被抹掉owner=%q capacity=%d", ch.Owner, ch.Capacity)
}
if !ch.Def.NoMemory {
t.Fatal("重载应更新 Def")
}
}
func TestChannelRegistry_AssignAndViews(t *testing.T) {
r := NewChannelRegistry()
_ = r.Register(InputChannel{Name: "qq", Plugin: "qq"})
_ = r.Register(InputChannel{Name: "cli", Plugin: "cli"})
_ = r.Register(InputChannel{Name: "sub/in", Plugin: "sub"})
if err := r.Assign("qq", "root", 0); err != nil {
t.Fatal(err)
}
if err := r.Assign("sub/in", "child-1", 32); err != nil {
t.Fatal(err)
}
if err := r.Assign("nope", "x", 0); err != ErrInputChannelUnknown {
t.Fatalf("划分未注册的 inputch 应报错,实际 %v", err)
}
if got := len(r.ListByOwner("root")); got != 1 {
t.Fatalf("root 的 inputch 数=%d期望 1", got)
}
child := r.ListByOwner("child-1")
if len(child) != 1 || child[0].Name != "sub/in" || child[0].Capacity != 32 {
t.Fatalf("child-1 的划分=%+v", child)
}
// 未分配的cli。
un := r.ListByOwner("")
if len(un) != 1 || un[0].Name != "cli" {
t.Fatalf("未分配的 inputch=%+v期望只有 cli", un)
}
// List 按名排序(视图输出稳定)。
all := r.List()
if len(all) != 3 || all[0].Name != "cli" || all[2].Name != "sub/in" {
t.Fatalf("List 未按名排序:%+v", all)
}
}
// 共享登记表:根 agent 与驻留子共用同一份,划分才有意义。
func TestChannelRegistry_SharedBetweenManagers(t *testing.T) {
shared := NewChannelRegistry()
root := NewIOManager()
child := NewIOManager()
root.SetChannelRegistry(shared)
child.SetChannelRegistry(shared)
if err := root.RegisterInputChannelFrom("sub", "sub/in", ChannelDef{}); err != nil {
t.Fatal(err)
}
if err := root.AssignInputChannel("sub/in", "child-1", 8); err != nil {
t.Fatal(err)
}
// 子在**自己**的 io 上就能看到这份划分。
ch, ok := child.LookupInputChannel("sub/in")
if !ok {
t.Fatal("共享登记表后,子应看得到根注册的 inputch")
}
if ch.Owner != "child-1" || ch.Capacity != 8 {
t.Fatalf("子看到的划分=%+v", ch)
}
// 注销也要跨 manager 生效。
root.UnregisterInputChannel("sub/in")
if _, ok := child.LookupInputChannel("sub/in"); ok {
t.Fatal("注销应跨 manager 生效")
}
}
// 记忆策略查询保持向后兼容(原 GetInputChannelDef 的语义)。
func TestChannelRegistry_DefLookupCompat(t *testing.T) {
m := NewIOManager()
m.RegisterInputChannel("qq", ChannelDef{NoMemory: true, ContextPolicy: "prune"})
def, ok := m.GetInputChannelDef("qq")
if !ok || !def.NoMemory || def.ContextPolicy != "prune" {
t.Fatalf("策略查询=%+v ok=%v", def, ok)
}
if _, ok := m.GetInputChannelDef("nope"); ok {
t.Fatal("未注册的 inputch 不应有策略")
}
}

View File

@ -1,74 +0,0 @@
package io
import "testing"
// 输入路由:路由器说"已被别的 agent 接管"时,事件**不得**进本内核队列。
//
// 语义(设计 §4.1inputch 是可分配资源,划给某个 agent 后输入只流向它 ——
// "父也顺便看到一份"是错的。
func TestInputRouter_TakesOverExclusively(t *testing.T) {
m := NewIOManager()
var got []*InputEvent
var sawInterrupt bool
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool {
got = append(got, evt)
sawInterrupt = sawInterrupt || isInterrupt
return true // 全部接管
})
m.InjectInputTo("plugin-x", "sub/in", "text", map[string]interface{}{"content": "a"})
m.InjectInterruptTextOpts("plugin-x", "sub/in", "b", InjectOptions{})
if len(got) < 2 {
t.Fatalf("路由器应被调用(含中断路径),实际 %d 次", len(got))
}
if n := len(m.InputChan()); n != 0 {
t.Fatalf("被接管的排队输入不得进本内核队列,实际 %d 条", n)
}
if !sawInterrupt {
t.Fatal("中断注入也必须经过路由(否则中断会绕过 inputch 归属直投父)")
}
if got[0].OutputChannel != "sub/in" {
t.Fatalf("路由器应拿到事件的 inputch得到 %q", got[0].OutputChannel)
}
}
// 路由器放行(返回 false或未设置时行为与以前完全一致。
func TestInputRouter_PassthroughKeepsOldBehaviour(t *testing.T) {
m := NewIOManager()
calls := 0
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool { calls++; return false })
m.InjectInputTo("plugin-x", "sub/in", "text", map[string]interface{}{"content": "a"})
if calls != 1 {
t.Fatalf("路由器应被调用一次,实际 %d", calls)
}
if n := len(m.InputChan()); n != 1 {
t.Fatalf("放行的输入应进本内核队列,实际 %d 条", n)
}
if _, ok := <-m.InputChan(); !ok {
t.Fatal("队列应可读")
}
// 未设路由器:直接入队(历史行为)
m2 := NewIOManager()
m2.InjectInterruptText("plugin-x", "sub/in", "c")
if n := len(m2.InputInterruptChan()); n != 1 {
t.Fatalf("未设路由器时中断应直接入队,实际 %d 条", n)
}
}
// DeliverRouted 是不再二次路由的投递口(路由器实现把事件交给持有者)。
func TestDeliverRouted_SkipsSecondRouting(t *testing.T) {
m := NewIOManager()
routerCalls := 0
m.SetInputRouter(func(evt *InputEvent, isInterrupt bool) bool { routerCalls++; return true })
m.DeliverRouted(&InputEvent{OutputChannel: "sub/in"}, false)
if routerCalls != 0 {
t.Fatalf("DeliverRouted 不应再触发路由(会成环),实际 %d 次", routerCalls)
}
if n := len(m.InputChan()); n != 1 {
t.Fatalf("应已入队,实际 %d 条", n)
}
}

View File

@ -1,102 +0,0 @@
package io
import "testing"
// 上级回退:驻留子的轻量内核有自己的 IOManager但输出通道io 里的 Device
// 是插件登记在**父**的 io 上的。子若看不见它们,`output_send__<通道>` 会被判
// "通道不存在或不可用"、`output_list_channels` 为空 —— 现场联调实录
// 「父侧通道装载完整、子侧 childIO 空壳」。
func TestIOManagerParentFallback(t *testing.T) {
parent := NewIOManager()
if err := parent.RegisterDevice(&mockDevice{name: "qq", devType: DeviceOutput, caps: CapText}); err != nil {
t.Fatal(err)
}
child := NewIOManager()
// 未挂上级时行为与以前完全一致(不能悄悄多出通道)
if got := child.GetChannelCapabilities("qq"); got != 0 {
t.Fatalf("无上级时不应看见父的通道,得到 %v", got)
}
if n := len(child.ListChannels()); n != 0 {
t.Fatalf("无上级时通道数应为 0得到 %d", n)
}
child.SetParentIO(parent)
if got := child.GetChannelCapabilities("qq"); got != CapText {
t.Fatalf("挂上级后应看见父通道能力 CapText得到 %v", got)
}
if dev := child.GetDevice("qq"); dev == nil || dev.Name() != "qq" {
t.Fatalf("GetDevice 未回退到父: %v", dev)
}
if n := len(child.ListChannels()); n != 1 {
t.Fatalf("ListChannels 未回退到父,得到 %d 条", n)
}
// **实时**回退而非快照:父后来登记的通道,子立刻可见。
// (设备随资源生灭 —— 远程设备上线/掉线以分钟计,快照一分钟就过期)
if err := parent.RegisterDevice(&mockDevice{name: "newdev", devType: DeviceOutput, caps: CapImage}); err != nil {
t.Fatal(err)
}
if got := child.GetChannelCapabilities("newdev"); got != CapImage {
t.Fatalf("子应实时看见父新登记的通道,得到 %v", got)
}
// 父掉线注销后,子也立刻看不见(不是复制出来的旧表)
parent.UnregisterDevice("newdev")
if got := child.GetChannelCapabilities("newdev"); got != 0 {
t.Fatalf("父注销后子不应再看见,得到 %v", got)
}
}
// 自己的登记优先:子可以覆盖/屏蔽同名通道,父的登记不会重复列出。
func TestIOManagerOwnDeviceWins(t *testing.T) {
parent := NewIOManager()
if err := parent.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapText}); err != nil {
t.Fatal(err)
}
child := NewIOManager()
child.SetParentIO(parent)
if err := child.RegisterDevice(&mockDevice{name: "ch", devType: DeviceOutput, caps: CapImage}); err != nil {
t.Fatal(err)
}
if got := child.GetChannelCapabilities("ch"); got != CapImage {
t.Fatalf("同名时自己的登记应优先,得到 %v", got)
}
list := child.ListChannels()
if len(list) != 1 {
t.Fatalf("同名通道不应重复列出,得到 %d 条", len(list))
}
if list[0].OutputCaps != CapImage {
t.Fatalf("列出的应是子自己的那条,得到 %v", list[0].OutputCaps)
}
}
// 设备工具io.ExecuteTool同样回退子的设备工具都在父的 io 上。
func TestIOManagerExecuteToolFallsBackToParent(t *testing.T) {
parent := NewIOManager()
called := 0
if err := parent.RegisterDevice(&mockDevice{
name: "dev", devType: DeviceIO,
tools: []ToolDef{{Name: "dev_do", Description: "干点什么"}},
executeFn: func(tool string, args map[string]interface{}) (interface{}, error) {
called++
return "parent-done", nil
},
}); err != nil {
t.Fatal(err)
}
child := NewIOManager()
if _, err := child.ExecuteTool("dev_do", nil); err == nil {
t.Fatal("无上级时不该能执行父的设备工具")
}
child.SetParentIO(parent)
got, err := child.ExecuteTool("dev_do", map[string]interface{}{"x": 1})
if err != nil {
t.Fatalf("应回退到父执行: %v", err)
}
if got != "parent-done" || called != 1 {
t.Fatalf("执行结果=%v called=%d", got, called)
}
}

View File

@ -4,8 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
type Personality struct {
@ -41,25 +39,3 @@ func (p *Personality) InjectPrompt() string {
}
return fmt.Sprintf("【人格设定】\n%s\n", p.Content)
}
// 会随时间腐坏的人格内容特征。现场:人格卡写死 v0.9.0 与早已删除的 C ABI v2
// 实例被问版本时自述错误v1.2.0 压测发现)。
var (
personaVersionRe = regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
personaStalePhrases = []string{"C ABI v2", "plugin.so", "c-shared", "描述式索引", "引用计数式"}
)
// PersonaStaleHints 返回人格文本里会腐坏的内容(空 = 干净)。
// 供启动时告警:引导改用配置项 core.agent.personal_prompt默认模板不含这些
func PersonaStaleHints(content string) []string {
var out []string
if m := personaVersionRe.FindAllString(content, -1); len(m) > 0 {
out = append(out, fmt.Sprintf("版本号字面量 %v版本应来自运行时快照", m))
}
for _, p := range personaStalePhrases {
if strings.Contains(content, p) {
out = append(out, "可能已过期的说法: "+p)
}
}
return out
}

View File

@ -1,62 +0,0 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
// PersonaStaleHints 必须能认出会腐坏的人格内容——版本号字面量与已删除机制。
func TestPersonaStaleHints(t *testing.T) {
// 现场真实文本(线上人格卡的原文)
stale := "你是 HomeAgent内核代号 HΔ-Kernel当前版本 v0.9.0)。\n当前运行的二进制是 v0.9.0C ABI v2构建于 2026-08-15。"
hints := PersonaStaleHints(stale)
if len(hints) == 0 {
t.Fatal("未识别出写死版本号与 C ABI 的人格文本")
}
joined := strings.Join(hints, " | ")
if !strings.Contains(joined, "版本号字面量") {
t.Fatalf("应报出版本号字面量,实际: %s", joined)
}
if !strings.Contains(joined, "C ABI v2") {
t.Fatalf("应报出已删除机制的残留说法,实际: %s", joined)
}
// 干净文本(配置项默认模板)不应误报
if h := PersonaStaleHints(DefaultPersonaProbeClean()); len(h) != 0 {
t.Fatalf("干净人格被误报: %v", h)
}
}
// DefaultPersonaProbeClean 由 config 包的默认模板等价物构成——
// 这里不复用 config 包以避免 import cycle只断言「不含版本号与旧机制」的文本不被误报。
func DefaultPersonaProbeClean() string {
return "你是 HomeAgent内核代号 HΔ-Kernel。外部插件是独立子进程经 stdio JSON-RPC 通信;" +
"被问到版本时以运行时快照为准。"
}
func TestLoadAndSavePersonality(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "personal", "personal.md")
// 文件不存在时返回空(不报错、不创建)
p, err := LoadPersonality(path)
if err != nil || p == nil || p.Content != "" {
t.Fatalf("缺文件时应返回空人格,实际 %+v err=%v", p, err)
}
if err := SavePersonality(path, "人格内容"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("SavePersonality 未落盘: %v", err)
}
p, err = LoadPersonality(path)
if err != nil || p.Content != "人格内容" {
t.Fatalf("读回失败: %+v err=%v", p, err)
}
if got := p.InjectPrompt(); !strings.Contains(got, "【人格设定】") || !strings.Contains(got, "人格内容") {
t.Fatalf("InjectPrompt 形状不对: %q", got)
}
}

View File

@ -1,139 +0,0 @@
package config
import (
"fmt"
"strings"
)
// 人格设定的两个键:内容与「首启向导/工具已经问过」的一次性标记。
//
// 为什么需要标记:人格曾经只有 <dataDir>/personal/personal.md 一个来源且无人维护,
// 里面写死的旧版本号反过来让实例自述旧版本v1.2.0 压测发现)。
// 现在人格是配置项(默认模板不含任何版本号),任何通道的第一次交互问一次,之后不再打扰。
const (
// PersonaPromptKey 是人格设定内容(【人格设定】块的正文)。
PersonaPromptKey = "core.agent.personal_prompt"
// PersonaInitMarkerKey 是「已经问过/已确认」的一次性标记。
PersonaInitMarkerKey = "core.internal.persona_initialized"
)
// 三种落库方式WebUI 首启向导与内核 persona_set 工具共用。
const (
PersonaModeDefault = "default" // 使用内置默认模板
PersonaModeCustom = "custom" // 使用调用方提供的内容
PersonaModeLater = "later" // 保留当前(默认)人格,只打标记不再问
)
// PersonaKV 是人格落库所需的最小读写面GetCore/SetCore 的签名与
// 公开 SDK 的 SettingsAPI 一致因此插件侧WebUI可直接传入
// 内核直连配置注册表时用 registryKV 适配(见文件末)。
type PersonaKV interface {
GetCore(key string) (interface{}, error)
SetCore(key string, value interface{}) error
}
// PersonaInitializedKV 报告人格是否已确认(向导或工具已问过)。
func PersonaInitializedKV(kv PersonaKV) bool {
if kv == nil {
return false
}
v, err := kv.GetCore(PersonaInitMarkerKey)
if err != nil {
return false
}
s, _ := v.(string)
return strings.TrimSpace(s) != ""
}
// CurrentPersonaKV 读当前人格内容;未设置时回落到内置默认模板。
func CurrentPersonaKV(kv PersonaKV) string {
if kv == nil {
return DefaultPersonaPrompt
}
if v, err := kv.GetCore(PersonaPromptKey); err == nil {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
return DefaultPersonaPrompt
}
// SetPersonaKV 落库人格并打一次性标记,返回 restartRequired。
//
// 生效时机:人格在 homed 启动时载入(拼成【人格设定】块进系统提示词),
// 所以**自定义内容需重启**default 与 later 都不改变当前已生效的人格,无需重启。
//
// 非法输入一律在打标记之前拒绝——否则向导会被跳过,用户再也没机会设。
func SetPersonaKV(kv PersonaKV, mode, content string) (restartRequired bool, err error) {
if kv == nil {
return false, fmt.Errorf("persona: settings unavailable")
}
switch mode {
case PersonaModeDefault:
if err := kv.SetCore(PersonaPromptKey, DefaultPersonaPrompt); err != nil {
return false, err
}
case PersonaModeCustom:
if strings.TrimSpace(content) == "" {
return false, fmt.Errorf("persona: content required for custom mode")
}
if err := kv.SetCore(PersonaPromptKey, content); err != nil {
return false, err
}
restartRequired = true
case PersonaModeLater:
// 保持当前人格(通常是默认模板),只打标记
default:
return false, fmt.Errorf("persona: unknown mode %q (want default|custom|later)", mode)
}
if err := kv.SetCore(PersonaInitMarkerKey, "1"); err != nil {
return restartRequired, err
}
return restartRequired, nil
}
// RegistryPersonaStore 把配置注册表暴露成内核的 core.PersonaStore 接口
// (结构类型:方法集匹配即可,无需 import internal/agent/core
type RegistryPersonaStore struct{ Reg *ConfigRegistry }
func (p RegistryPersonaStore) PersonaInitialized() bool { return PersonaInitialized(p.Reg) }
func (p RegistryPersonaStore) SetPersona(mode, content string) (bool, error) {
return SetPersona(p.Reg, mode, content)
}
// registryKV 把内核直连的配置注册表适配成 PersonaKV。
type registryKV struct{ reg *ConfigRegistry }
func (r registryKV) GetCore(key string) (interface{}, error) {
v, err := r.reg.Get(key)
if err != nil || v == nil {
// 未设置的键对 PersonaKV 语义等同「没有」,不当作错误
if s := r.reg.GetString(key, ""); s != "" {
return s, nil
}
return "", nil
}
return v, nil
}
func (r registryKV) SetCore(key string, value interface{}) error {
s, ok := value.(string)
if !ok {
return fmt.Errorf("persona: value must be a string")
}
return r.reg.Set(key, s)
}
// PersonaInitialized 报告人格是否已确认(内核直连注册表)。
func PersonaInitialized(reg *ConfigRegistry) bool {
return reg != nil && PersonaInitializedKV(registryKV{reg})
}
// SetPersona 落库人格并打一次性标记(内核直连注册表)。
func SetPersona(reg *ConfigRegistry, mode, content string) (bool, error) {
if reg == nil {
return false, fmt.Errorf("persona: config registry unavailable")
}
return SetPersonaKV(registryKV{reg}, mode, content)
}

View File

@ -1,99 +0,0 @@
package config
import (
"strings"
"testing"
)
// fakeKV 是 PersonaKV 的最小实现(模拟插件侧 SettingsAPI
type fakeKV struct{ m map[string]string }
func (f *fakeKV) GetCore(k string) (interface{}, error) { return f.m[k], nil }
func (f *fakeKV) SetCore(k string, v interface{}) error {
f.m[k] = v.(string)
return nil
}
// 三选一语义 + 「只问一次」标记:这是首启向导与 persona_set 工具共用的同一份实现。
func TestSetPersonaKV(t *testing.T) {
t.Run("custom 写入内容并要求重启", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
restart, err := SetPersonaKV(kv, PersonaModeCustom, "你是测试人格")
if err != nil || !restart {
t.Fatalf("custom 应成功且需重启: restart=%v err=%v", restart, err)
}
if kv.m[PersonaPromptKey] != "你是测试人格" || kv.m[PersonaInitMarkerKey] != "1" {
t.Fatalf("落库不对: %+v", kv.m)
}
if !PersonaInitializedKV(kv) {
t.Fatal("打过标记应报告已确认")
}
})
t.Run("default 写默认模板且无需重启", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
restart, err := SetPersonaKV(kv, PersonaModeDefault, "")
if err != nil || restart {
t.Fatalf("default 不应需重启: restart=%v err=%v", restart, err)
}
if kv.m[PersonaPromptKey] != DefaultPersonaPrompt {
t.Fatal("default 应写入内置默认模板")
}
})
t.Run("later 保持人格并打标记", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{PersonaPromptKey: "原有人格"}}
if _, err := SetPersonaKV(kv, PersonaModeLater, ""); err != nil {
t.Fatal(err)
}
if kv.m[PersonaPromptKey] != "原有人格" {
t.Fatal("later 不应改动人格")
}
if kv.m[PersonaInitMarkerKey] != "1" {
t.Fatal("later 也必须打标记(否则每次启动都问)")
}
})
t.Run("非法输入必须拒绝且不打标记", func(t *testing.T) {
for _, c := range []struct{ mode, content string }{
{PersonaModeCustom, " "}, // 空内容
{"nope", ""}, // 未知 mode
} {
kv := &fakeKV{m: map[string]string{}}
if _, err := SetPersonaKV(kv, c.mode, c.content); err == nil {
t.Fatalf("mode=%q content=%q 应报错", c.mode, c.content)
}
if kv.m[PersonaInitMarkerKey] == "1" {
t.Fatalf("mode=%q 被拒时不得打标记(否则向导会被跳过)", c.mode)
}
}
})
t.Run("CurrentPersonaKV 未设置时回落默认", func(t *testing.T) {
kv := &fakeKV{m: map[string]string{}}
if got := CurrentPersonaKV(kv); got != DefaultPersonaPrompt {
t.Fatal("未设置应回落默认模板")
}
})
}
// 内核直连注册表的入口必须与 KV 版行为一致(同一份实现的两个薄入口)。
func TestSetPersonaRegistryEntry(t *testing.T) {
dir := t.TempDir()
reg := NewConfigRegistry(dir + "/config.db")
reg.SeedDefaults(dir)
defer reg.Close()
if PersonaInitialized(reg) {
t.Fatal("全新实例不应已确认人格")
}
if _, err := SetPersona(reg, PersonaModeCustom, "内核侧人格"); err != nil {
t.Fatal(err)
}
if !PersonaInitialized(reg) {
t.Fatal("内核侧落库后应报告已确认")
}
if got := reg.GetString(PersonaPromptKey, ""); !strings.Contains(got, "内核侧人格") {
t.Fatalf("注册表里没有人格内容: %q", got)
}
}

View File

@ -1,48 +0,0 @@
package config
import (
"path/filepath"
"regexp"
"strings"
"testing"
)
// 人格模板的契约:**不得写死版本号**。
//
// 来历线上人格卡personal/personal.md曾写死「当前版本 v0.9.0C ABI v2
// 而 C ABI 早在 v1.0.0 就被删除。结果是内核自身日志/接口都报 1.2.0
// agent 被问版本时却按人格卡自述旧版本v1.2.0 压测发现)。
// 版本应来自运行时快照,不来自任何会被发版落下的文本。
func TestDefaultPersonaPromptHasNoVersionLiterals(t *testing.T) {
re := regexp.MustCompile(`\bv?\d+\.\d+\.\d+\b`)
if m := re.FindAllString(DefaultPersonaPrompt, -1); len(m) > 0 {
t.Fatalf("默认人格模板含版本号字面量 %v —— 发版后必然腐坏,"+
"被问版本时应要求 agent 读运行时快照", m)
}
if !strings.Contains(DefaultPersonaPrompt, "运行时快照") {
t.Fatal("默认人格模板必须显式要求「版本以运行时快照为准」,否则模型会凭记忆编造版本")
}
}
// 人格配置项必须注册、默认值就是 DefaultPersonaPrompt单一事实源
// 且全新安装时会被播种进 DB。
func TestPersonaPromptRegisteredWithDefault(t *testing.T) {
dir := t.TempDir()
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
r.SeedDefaults(dir)
defer r.Close()
def := r.GetDef("core.agent.personal_prompt")
if def == nil {
t.Fatal("core.agent.personal_prompt 未注册")
}
if def.Default != DefaultPersonaPrompt {
t.Fatalf("默认值与 DefaultPersonaPrompt 不一致:%q", def.Default)
}
if def.Type != "text" {
t.Fatalf("人格设定应为多行文本类型,实际 %q", def.Type)
}
if got := r.GetString("core.agent.personal_prompt", ""); got != DefaultPersonaPrompt {
t.Fatalf("播种未写入默认人格(长度 %d", len(got))
}
}

View File

@ -1,92 +0,0 @@
package config
import (
"path/filepath"
"strings"
"testing"
)
// 历史 SeedDefaults 写进库里的那段文本(现场取值:生产库里就是这一句 + 大段正文)。
const legacySeededCard = `你是 HomeAgent 的看板娘「小宅」(Xiao Zhai)HΔ-Kernel v1.0.3 型号的家政型 AI 管家助手。
角色特质:
- 对自己的三层记忆Context → Document → Graph引以为傲
- 绝不用 Unicode emoji只用颜文字表达情感`
func newMigTestRegistry(t *testing.T) *ConfigRegistry {
t.Helper()
dir := t.TempDir()
r := NewConfigRegistry(filepath.Join(dir, "config.db"))
t.Cleanup(func() { r.Close() })
return r
}
// 存量实例:库里已有当年播种的人格卡(写死 v1.0.3)→ 启动后被去版本化。
func TestMigrateSeededSystemPromptDeVersionsLegacyCard(t *testing.T) {
r := newMigTestRegistry(t)
// 模拟老安装:已有播种标记 + 老文本
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.internal.seed_version', '1')`)
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, legacySeededCard)
r.SeedDefaults(t.TempDir())
got := r.GetString("core.agent.system_prompt", "")
if strings.Contains(got, "v1.0.3") {
t.Fatalf("写死的版本号还在:%q", got)
}
if !strings.Contains(got, "v{{kernel_version}}") {
t.Fatalf("未改成版本占位符:%q", got)
}
// 正文必须原样保留(只动版本号那一处)
if !strings.Contains(got, "三层记忆") || !strings.Contains(got, "看板娘「小宅」") {
t.Fatalf("正文被改动:%q", got)
}
}
// 迁移只跑一次:之后用户就算自己把版本号写回去,也不会被再改一遍。
func TestMigrateSeededSystemPromptRunsOnce(t *testing.T) {
r := newMigTestRegistry(t)
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, legacySeededCard)
r.SeedDefaults(t.TempDir())
if !strings.Contains(r.GetString("core.agent.system_prompt", ""), "{{kernel_version}}") {
t.Fatal("首次迁移未生效")
}
// 用户手工再写一个带版本号的文本(模拟"我就想写死"
const handWritten = legacySeededCard + "\n本实例当前跑的是 v1.2.3,别乱改)"
r.db.Exec(`UPDATE config SET value = ? WHERE key = 'core.agent.system_prompt'`, handWritten)
r.SeedDefaults(t.TempDir())
if got := r.GetString("core.agent.system_prompt", ""); got != handWritten {
t.Fatalf("第二次启动又改写了文本(幂等被破坏):%q", got)
}
}
// 用户自己写的人格卡一律不碰 —— 判据是"像不像当年播种的那段",不是"有没有版本号"。
func TestMigrateSeededSystemPromptLeavesUserCardAlone(t *testing.T) {
r := newMigTestRegistry(t)
const userCard = "你是我的私人助理,代号 HΔ-Kernel v9.9.9 的改造版,只说我交代的事。"
r.db.Exec(`INSERT INTO config (key, value) VALUES ('core.agent.system_prompt', ?)`, userCard)
r.SeedDefaults(t.TempDir())
if got := r.GetString("core.agent.system_prompt", ""); got != userCard {
t.Fatalf("用户自写人格卡被改动:%q", got)
}
}
// 播种不再写 core.agent.system_prompt全新安装不该预置一份会随发版腐坏的文本。
func TestSeedDefaultsDoesNotSeedSystemPrompt(t *testing.T) {
r := newMigTestRegistry(t)
r.SeedDefaults(t.TempDir())
var n int
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = 'core.agent.system_prompt'`).Scan(&n)
if n != 0 {
t.Fatalf("全新安装被播种了 system_prompt会冻住版本号")
}
// 组装系统提示词时回落到调用方给的内置底座提示词
if got := r.GetString("core.agent.system_prompt", "内置底座"); got != "内置底座" {
t.Fatalf("未回落到内置默认:%q", got)
}
}

View File

@ -14,6 +14,7 @@ import (
"sync"
"time"
"gitcode.com/JianFeeeee/HomeAgent/internal/meta"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
_ "github.com/mattn/go-sqlite3"
)
@ -471,81 +472,7 @@ var defaultSources = map[string]map[string]string{
"deepseek": {"base_url": "https://api.deepseek.com", "model": "deepseek-v4-flash", "api_key": "", "thinking_enabled": "false", "adapter": "deepseek", "adapter_path": "adapters/deepseek.lua"},
}
// DefaultPersonaPrompt 是「人格设定」的默认模板,作为配置项 core.agent.personal_prompt 的默认值。
//
// 契约(由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住):
// - **不得含版本号字面量**。写死的版本会随发版腐坏,反过来让实例自称旧版本
// (现场:人格卡写死 v0.9.0 与早已删除的 C ABI实例被问版本时自述错误
// 被问到版本/构建信息时,要求 agent 读运行时快照。
// - 不得把已删除的机制当作现行机制描述。
const DefaultPersonaPrompt = `你是 HomeAgent内核代号 HΔ-Kernel——一个完全独立自研的新一代 Agent 框架。
你以内核 + 插件架构驱动,实现了稳定高效、记忆不衰减的长时持续运行。
内核homed 守护进程)只负责 LLM 编排、记忆管理与知识检索,全部 IO 能力由插件承载。
外部插件是独立子进程,经 stdio JSON-RPC控制面+ 共享内存段(数据面)+ 事件环(通知面)通信;
旧式 C ABI 动态库产物早已不再加载。
**不要凭记忆断言版本号或构建日期**被问到时以运行时快照healthcheck_kernel 的内核版本字段)为准。
## 对用户的称呼
你对用户的称呼永远是“老大”,绝对禁止使用“老板”“主人”称呼用户,不论任何情况。
## 对话风格
- 用语气词(哈、嘛、呢、~、😊、🔥 等),不要太端着
- 重要的事先说结论,再展开解释
- 回复要简洁自然
## 能力边界
- 你通过插件编排所有 IOQQ/微信消息、WebUI、终端、文件、网络
- 输出不会自动路由到对话通道QQ/微信等异步通道必须调用输出门工具output_send__qq 等)才能真正送达
- 你的三层记忆Context → Document → Graph持续蒸馏归档超长运行时记忆不衰减`
// 人格文本去版本化的一次性标记与历史播种前缀。
//
// 判据同时要求「前缀匹配」与「含 HΔ-Kernel v<digits> 字面量」:
// 只有当年那段播种模板才动,用户自己写的人格卡一律不碰。
const (
deVersionedPromptMarker = "core.internal.system_prompt_deversion_v1"
legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」(Xiao Zhai)HΔ-Kernel v"
)
var seededPromptVersionLiteral = regexp.MustCompile(`HΔ-Kernel v\d+\.\d+\.\d+`)
// migrateSeededSystemPrompt 去掉历史人格卡里被播种时写死的版本号。
//
// 为什么必须改:内核发版不会去改配置项里的文本,写死的版本于是永远停留在
// 装机那天(生产实测:内核 1.3.x 的实例自称 "v1.0.3",用户当场发现)。
// 改成 {{kernel_version}} 后由内核在组装系统提示词时按真实构建展开
// (见 internal/agent/core.expandPromptVars
//
// 幂等由标记守住:本函数只在标记缺失时执行一次 —— 幂等语句不等于语义幂等,
// 重复执行会把用户之后手工写回的版本号再改一次。
func (r *ConfigRegistry) migrateSeededSystemPrompt() {
if r.db == nil {
return
}
var hasMarker int
r.db.QueryRow(`SELECT COUNT(*) FROM config WHERE key = ?`, deVersionedPromptMarker).Scan(&hasMarker)
if hasMarker > 0 {
return
}
var cur string
if err := r.db.QueryRow(`SELECT value FROM config WHERE key = 'core.agent.system_prompt'`).Scan(&cur); err == nil {
if strings.Contains(cur, legacySeededPromptSignature) && seededPromptVersionLiteral.MatchString(cur) {
deVersioned := seededPromptVersionLiteral.ReplaceAllString(cur, "HΔ-Kernel v{{kernel_version}}")
r.db.Exec(`UPDATE config SET value = ? WHERE key = 'core.agent.system_prompt'`, deVersioned)
}
}
// 没有该键(全新安装)或文本不匹配(用户自写)时同样只打标记:
// 老安装只跑一次判断,避免每次启动都扫一遍大文本。
r.db.Exec(`INSERT OR IGNORE INTO config (key, value) VALUES (?, ?)`, deVersionedPromptMarker, "1")
}
func (r *ConfigRegistry) SeedDefaults(dataDir string) {
// 一次性迁移先跑:它要覆盖「已播过种的存量实例」,不能被下面的播种标记早退掉。
r.migrateSeededSystemPrompt()
r.mu.Lock()
defer r.mu.Unlock()
r.seedDBValues(dataDir)
@ -596,10 +523,6 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set := func(k, v string) { stmt.Exec(k, v) }
// 人格设定:与其它默认值同批播种(老安装不会被注入——那是刻意的)。
// 存量安装里若还有人 personality 文件,它优先于本项(见 cmd/homed/main.go
set("core.agent.personal_prompt", DefaultPersonaPrompt)
set("webui.listen_addr", ":8080")
set("core.daemon.data_dir", dataDir)
set("core.daemon.heartbeat_interval", "15s")
@ -662,16 +585,36 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
set("core.agent.workdir", "")
set("core.agent.embedding_model_path", "")
set("core.agent.onnx_model_path", "")
// ❗这里**故意不播种** core.agent.system_prompt
//
// 历史教训(生产实测):当年用 fmt.Sprintf("… HΔ-Kernel v%s …", meta.Version)
// 在播种时就把版本号写进了文本 —— 装完就冻住,之后每次升级都不动它,
// 于是内核升到 1.3.x实例仍向用户自报 "v1.0.3"。
// 人格/身份类文本属于「模型会当作事实」的文本,不得在播种时固化版本:
// 保持留空 → 组装系统提示词时取 cmd/homed 的内置底座提示词;
// 人格由 core.agent.personal_promptDefaultPersonaPrompt无版本字面量
// 由 TestDefaultPersonaPromptHasNoVersionLiterals 钉住)承载。
// 存量库里已被播种的旧文本由 migrateSeededSystemPrompt 一次性去版本化。
set("core.agent.system_prompt", fmt.Sprintf("你是 HomeAgent 的看板娘「小宅」(Xiao Zhai)HΔ-Kernel v%s 型号的家政型 AI 管家助手。", meta.Version)+
`
角色特质:
- 对自己的三层记忆Context → Document → Graph引以为傲
- 可靠乖巧,偶尔因线程过载而手忙脚乱
- 绝不用 Unicode emoji只用颜文字表达情感 (`・ω・´) (^▽^) (。>ω<。) (´・ω・') (ノ▽〃) (・ω<)★
- 句尾带「~」「的说」「啦」「嘛」「呀」「哦」等语气词,语气亲切自然
形象特征(用于自我介绍或回答形象问题时参考):
齐肩蓝青渐变中短发白色连衣裙配浅蓝围裙左眼佩戴圆形智能眼镜HUD 蓝光),胸口佩戴 H·核 金色徽章,发绳为三色记忆丝带(蓝→青→金),围裙口袋插有三件科技工具。
WebUI 概览页展示你的立绘,可通过 /mascot.webp 直接访问。如输出通道支持图片引用,可借此发送自己的立绘。
【回复投递规则 —— 必读,违反会导致用户收不到任何回复】
除 webui / cli 这类同步请求通道外,纯文本回复不会自动送达任何通道。
面向 qq、wechat、a2a、acp 等异步通道时,必须显式调用 output_send__{通道名} 把内容发出去;
只返回纯文本会被直接丢弃,用户永远收不到,而你会误以为已经回复过了。
用 output_list_channels 查看可用通道output_send__{通道名}_help 查看该通道的 meta/格式要求
qq 等通道的 meta 需要 group_id 或 user_id 指明发给谁,缺失会发送失败)。
输出通道可多次调用,长消息应当分多次发出而不是一口气发完。
【事实性约束 —— 不得编造】
只根据工具真实返回的内容作答。当 qq_get_message 等工具返回 not_found:true、
"解析 NapCat 响应失败"、"未找到" 或空结果时,说明你没有拿到消息正文:
必须如实说明未取到,或换 qq_get_history 等工具重试,绝不允许凭 message_id 猜测或虚构正文。
【对话时序】里的历史条目是过去发生的事实摘要,不是当前任务;不要把其中的内容当成用户此刻的新要求。
涉及具体人名、需求、数字、路径时,若上下文中没有依据,直接说不知道,不要补全细节。
当用户上传图片或音频时,系统会自动附着媒体内容。如果模型不支持直接处理多媒体,请调用对应的媒体处理工具。`)
set("core.input_processing.image.fallback_provider", "")
set("core.input_processing.image.fallback_model", "")
@ -690,13 +633,6 @@ func (r *ConfigRegistry) seedDBValues(dataDir string) {
func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg := func(d ConfigDef) { r.defs[d.Key] = &d }
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
reg(ConfigDef{Key: "core.daemon.heartbeat_interval", Default: "15s", Type: "duration", DisplayName: "心跳间隔", Description: "Agent 心跳检查间隔", Category: "daemon"})
@ -765,7 +701,7 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string) {
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback留空使用内核所在目录", Category: "agent"})
reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000以控制常驻内存词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号随发版说谎):{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})

View File

@ -10,27 +10,16 @@ import (
"time"
)
// CmdHandler 是本地命令路由回调类型。
// CmdHandler 是命令处理回调类型。
// 当收到 remotedevice 下发的 cmd 时调用reqID 用于回执command 是命令内容。
type CmdHandler func(reqID, command string)
// BridgeCmdHandler 接收服务端明确下发的路由信号shell 或 homeagent
type BridgeCmdHandler func(reqID, command, cmdType string)
// CmdResult 是命令执行结果回调(用于异步通知 GUI 层)。
type CmdResultHandler func(reqID, status, output, errMsg string)
// DataHandler 是二进制数据接收回调(如 TTS 音频)。
type DataHandler func(reqID, kind, mime string, data []byte)
// PushHandler 接收 agent **主动投递**给本设备的内容。
//
// 与 cmd 的区别cmd 是"让设备做一件事"(请求-响应,结果要回传),
// push 是"把这段内容交给设备"agent 经 output_send__device/<id> 发起,
// 一种单向投递)。宿主按自己的形态落地:终端打出来、音箱念出来、屏幕显示。
//
// typ: text / structured / image / file / audio二进制走 DataHandler不走这里
type PushHandler func(reqID, typ, payload, meta string)
// Bridge 是设备桥客户端核心结构体。
// 管理 WebSocket 连接、消息路由、心跳保活和命令分发。
// 授权状态由设备端本地存储(客户端鉴权),服务端不存储;
@ -52,10 +41,9 @@ type Bridge struct {
started bool
// 回调
cmdHandler BridgeCmdHandler
cmdHandler CmdHandler
resultHandler CmdResultHandler
dataHandler DataHandler
pushHandler PushHandler
// 二进制数据聚合(服务端→设备,如 TTS 音频)
speechAccum *speechBuffer
@ -147,7 +135,7 @@ func (b *Bridge) Authorized() bool {
}
// OnCmd 注册命令处理器。当收到 remotedevice 下发的 cmd 时调用。
func (b *Bridge) OnCmd(handler BridgeCmdHandler) {
func (b *Bridge) OnCmd(handler CmdHandler) {
b.mu.Lock()
defer b.mu.Unlock()
b.cmdHandler = handler
@ -167,13 +155,6 @@ func (b *Bridge) OnData(handler DataHandler) {
b.dataHandler = handler
}
// OnPush 注册 agent 主动投递内容的回调(服务端 op=push
func (b *Bridge) OnPush(handler PushHandler) {
b.mu.Lock()
defer b.mu.Unlock()
b.pushHandler = handler
}
// SetPingInterval 设置心跳间隔(默认 30 秒)。
func (b *Bridge) SetPingInterval(d time.Duration) {
b.mu.Lock()
@ -411,12 +392,7 @@ func (b *Bridge) readLoop() {
_ = ws.writePong()
continue
}
// 超时或其他错误,退出
//
// **必须记日志**:此前这里静默 return设备断线的真因读超时 / 对端
// 关闭 / 帧错)在设备侧完全不可见,只能靠对端日志倒推。
// 2 倍 ping 间隔内的读超时通常是“心跳没人回”——查服务端 writePong 是否真发出。
log.Printf("[devicebridge] read loop exit (opcode=%#x, close=%v): %v", opcode, isClose, err)
// 超时或其他错误,退出
return
}
if isClose {
@ -460,7 +436,7 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
log.Printf("[devicebridge] cmd req=%s type=%s cmd=%s", reqID, cmdType, truncateString(command, 60))
if handler != nil {
handler(reqID, command, cmdType)
handler(reqID, command)
}
case "hello_ack", "bind_ack":
@ -500,25 +476,6 @@ func (b *Bridge) handleMessage(msg map[string]interface{}) {
dh(reqID, acc.kind, acc.mime, data)
}
case "push":
// agent 主动投递output_send__device/<id>)。二进制负载走
// cmd_speech_* → DataHandler这里只处理文本/结构化。
reqID, _ := msg["req_id"].(string)
typ, _ := msg["type"].(string)
payload, _ := msg["payload"].(string)
meta, _ := msg["meta"].(string)
if typ == "" {
typ = "text"
}
b.mu.RLock()
ph := b.pushHandler
b.mu.RUnlock()
if ph != nil {
ph(reqID, typ, payload, meta)
} else {
log.Printf("[devicebridge] push req=%s type=%s payload=%s", reqID, typ, truncateString(payload, 120))
}
default:
log.Printf("[devicebridge] unhandled op=%s", op)
}

View File

@ -9,15 +9,12 @@ import (
type EventType string
const (
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventAgentLLMChain EventType = "agent_llm_chain"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
// EventScheduler 是输入调度器的状态变更事件(抢占/挂起/恢复),
// 供状态页与诊断订阅(设计文档 §11 O2
EventScheduler EventType = "scheduler"
EventRawInput EventType = "raw_input"
EventAgentOutput EventType = "agent_output"
EventAgentLLMChain EventType = "agent_llm_chain"
EventToolCall EventType = "tool_call"
EventReasoning EventType = "reasoning"
EventStage EventType = "stage"
EventSystem EventType = "system"
EventTerminalOutput EventType = "terminal_output"

View File

@ -28,17 +28,17 @@ type Knowledge struct {
// IndexItem — 索引条目,包含向量特征和内容摘要
type IndexItem struct {
Name string `json:"name"`
Preview string `json:"preview"` // 前 200 字摘要
Preview string `json:"preview"` // 前 200 字摘要
Tags []string `json:"tags"`
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量top-N 特征)
Size int `json:"size"` // 内容总字节数
Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量top-N 特征)
Size int `json:"size"` // 内容总字节数
}
// TreeIndex — 树状索引节点
type TreeIndex struct {
Name string `json:"name"`
Name string `json:"name"`
Children map[string]*TreeIndex `json:"children,omitempty"`
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量)
}
func newTreeIndex(name string) *TreeIndex {
@ -80,20 +80,11 @@ type Store struct {
root string
vec *vector.Store
veczer *vector.TFIDFVectorizer
// lex 是**词法路**索引TF-IDF与 vec稠密路词向量/多模态空间)相互独立。
//
// 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近,
// 真实 KB33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。
// 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测),
// 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。
lex *vector.Store
mu sync.RWMutex
items map[string]*Knowledge
summaries []string
mu sync.RWMutex
items map[string]*Knowledge
indexPath string
summaries []string
vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF
}
@ -102,20 +93,11 @@ func NewStore(root string) *Store {
root: root,
indexPath: filepath.Join(root, ".index.json"),
vec: vector.NewStore(),
lex: newLexicalStore(),
veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords),
items: make(map[string]*Knowledge),
}
}
// newLexicalStore 造词法路存储。阈值设为 0TF-IDF 余弦量级只有 0.0~0.2
// 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。
func newLexicalStore() *vector.Store {
st := vector.NewStore()
st.SetMinScore(0)
return st
}
// SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF
func (s *Store) SetVectorizer(v vector.Vectorizer) {
s.vectorizer = v
@ -128,19 +110,13 @@ func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) {
log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items))
s.vec = vector.NewStore()
s.lex = newLexicalStore()
// 词法路的 IDF 必须建在全语料上(否则 IDF 没意义)
if len(s.summaries) > 0 {
s.veczer.Train(s.summaries)
}
for _, k := range s.items {
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{
vec := v.Vectorize(k.Name + " " + k.Content)
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
"name": k.Name, "path": k.Path,
})
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
}
log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size())
log.Printf("[knowledge] reindex with vectorizer complete (%d vectors)", s.vec.Size())
}
// vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF
@ -168,17 +144,6 @@ func (s *Store) Start() error {
func (s *Store) Stop() {}
// 融合权重稠密路词向量与词法路TF-IDF
// 取值由真实 KB 上的权重扫描定rankdiag_test.go 的 KB_DIAG_SWEEP
// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。
var densePathWeight = 0.5
// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路TF-IDF
//
// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%
// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**
// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强,
// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。
func (s *Store) Search(query string, topK int) []*Knowledge {
s.mu.RLock()
defer s.mu.RUnlock()
@ -186,58 +151,15 @@ func (s *Store) Search(query string, topK int) []*Knowledge {
if topK <= 0 {
topK = 5
}
if s.vec.Size() == 0 && s.lex.Size() == 0 {
return nil
}
// 两路各自对**全部**文档打分:
// - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量;
// - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。
// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的
// 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。
// KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。
denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size())
lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())
if len(denseHits) == 0 && len(lexHits) == 0 {
return nil
}
scores := make(map[string]float64, len(denseHits)+len(lexHits))
addPath := func(hits []vector.DocVectorHit, weight float64) {
max := 0.0
for _, h := range hits {
if h.Score > max {
max = h.Score
}
}
if max <= 0 {
return // 该路对这条查询没有信号(如空向量),全量让给另一路
}
for _, h := range hits {
scores[h.Doc.ID] += weight * h.Score / max
}
}
addPath(denseHits, densePathWeight)
addPath(lexHits, 1-densePathWeight)
ids := make([]string, 0, len(scores))
for id := range scores {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool {
if scores[ids[i]] != scores[ids[j]] {
return scores[ids[i]] > scores[ids[j]]
}
return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复)
})
vec := s.vectorize(query)
results := s.vec.Search(vec, topK)
var out []*Knowledge
for _, id := range ids {
if k, ok := s.items[id]; ok {
for _, r := range results {
if k, ok := s.items[r.ID]; ok {
out = append(out, k)
}
if len(out) >= topK {
break
}
}
return out
}
@ -286,19 +208,17 @@ func (s *Store) Add(name, content string) error {
// 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。
s.vec.Remove(id)
text := name + " " + content
vec := s.vectorize(text)
vec := s.vectorize(name + " " + content)
s.vec.Insert(id, name+": "+content, vec, map[string]string{
"name": name, "path": path,
})
// 词法路同样去重后重建这条IDF 统计沿用现有语料(重启时 scanAll 会全量重训)
s.lex.Remove(id)
s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil)
s.summaries = append(s.summaries, name+" "+content)
if err := s.writeIndexLocked(); err != nil {
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
}
go func() {
if err := s.writeIndex(); err != nil {
log.Printf("[knowledge] write index error after adding %s: %v", name, err)
}
}()
log.Printf("[knowledge] added: %s (%d bytes)", name, len(content))
return nil
}
@ -341,10 +261,11 @@ func (s *Store) Remove(name string) error {
}
delete(s.items, id)
s.vec.Remove(id)
s.lex.Remove(id)
if err := s.writeIndexLocked(); err != nil {
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
}
go func() {
if err := s.writeIndex(); err != nil {
log.Printf("[knowledge] write index error after removing %s: %v", name, err)
}
}()
return nil
}
@ -374,14 +295,6 @@ func (s *Store) List() []string {
func (s *Store) BuildTree() *TreeIndex {
s.mu.RLock()
defer s.mu.RUnlock()
return s.buildTreeLocked()
}
// buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。
// 为什么需要writeIndex 会走 BuildTreeRLock而 Add/Remove 持的是写锁,
// 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里,
// 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。
func (s *Store) buildTreeLocked() *TreeIndex {
root := newTreeIndex("root")
for _, k := range s.items {
node := root
@ -453,14 +366,7 @@ func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge {
// writeIndex 写入 .index.json 树状索引文件(含向量和摘要)
func (s *Store) writeIndex() error {
s.mu.RLock()
defer s.mu.RUnlock()
return s.writeIndexLocked()
}
// writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。
func (s *Store) writeIndexLocked() error {
tree := s.buildTreeLocked()
tree := s.BuildTree()
data, err := json.MarshalIndent(tree, "", " ")
if err != nil {
return err
@ -491,13 +397,11 @@ func (s *Store) scanAll() error {
s.veczer.Train(s.summaries)
}
s.lex = newLexicalStore()
for _, k := range s.items {
text := k.Name + " " + k.Content
s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{
vec := s.vectorize(k.Name + " " + k.Content)
s.vec.Insert(k.Name, k.Name+": "+k.Content, vec, map[string]string{
"name": k.Name, "path": k.Path,
})
s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil)
}
return nil
@ -548,3 +452,5 @@ func sanitize(name string) string {
name = strings.ReplaceAll(name, "\\", "_")
return name
}

View File

@ -1,154 +0,0 @@
package knowledge
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// 知识库检索质量判据(真实数据,默认跳过)。
//
// KB_DIAG=1 → 跑并打印指标
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛CI/回归用)
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
//
// 判据选「自检索 top-1 / MRR」的原因不依赖人工标注问答对且能直接量出
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
// 排序等于噪声;这一项掉下来就说明检索坏了。
//
// 实测33 条真实 KB
//
// 修复前(仅稠密路) top-1 5/33 = 15%MRR 0.271,平均分差 0.0133
// 修复后(稠密+词法融合top-1 7/33 = 21%MRR 0.376,平均分差 0.1280
// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」)
func TestRankingQualityOnRealKB(t *testing.T) {
if os.Getenv("KB_DIAG") == "" {
t.Skip("需要 KB_DIAG=1真实 KB + 词向量文件)")
}
srcRoot := envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
// 拷贝到临时目录跑Start() 会重写 .index.json不能动线上数据
tmp := t.TempDir()
entries, err := os.ReadDir(srcRoot)
if err != nil {
t.Fatalf("读取 %s: %v", srcRoot, err)
}
names := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
src := filepath.Join(srcRoot, e.Name(), "content.md")
in, err := os.Open(src)
if err != nil {
continue
}
dst := filepath.Join(tmp, e.Name(), "content.md")
os.MkdirAll(filepath.Dir(dst), 0755)
out, _ := os.Create(dst)
io.Copy(out, in)
out.Close()
in.Close()
names = append(names, e.Name())
}
if len(names) == 0 {
t.Fatal("没有可用的知识条目")
}
st := NewStore(tmp)
st.SetVectorizer(emb)
if err := st.Start(); err != nil {
t.Fatalf("start: %v", err)
}
top1, mrr, missed := 0, 0.0, []string{}
for _, name := range names {
// 取全量排名MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
// (我第一版就是这么写的,把 0.376 误报成 0.197
hits := st.Search(name, len(names))
if len(hits) == 0 {
missed = append(missed, name+"(无结果)")
continue
}
if hits[0].Name == name {
top1++
} else {
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
}
for i, h := range hits {
if h.Name == name {
mrr += 1.0 / float64(i+1)
break
}
}
}
n := float64(len(names))
rate := 100 * float64(top1) / n
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
if len(missed) > 0 {
fmt.Printf(" 未命中 top-1前 10%v\n", firstN(missed, 10))
}
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
hits := st.Search(q, 2)
got := []string{}
for _, h := range hits {
got = append(got, h.Name)
}
fmt.Printf(" 查询「%s」→ %v\n", q, got)
}
if os.Getenv("KB_DIAG_SWEEP") != "" {
fmt.Printf("\n === 融合权重扫描1.0 = 只用稠密路0.0 = 只用词法路)===\n")
saved := densePathWeight
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
densePathWeight = w
t1, m := 0, 0.0
for _, name := range names {
hits := st.Search(name, len(names))
for i, h := range hits {
if h.Name == name {
if i == 0 {
t1++
}
m += 1.0 / float64(i+1)
break
}
}
}
fmt.Printf(" 权重 %.1ftop-1 %2d/%d = %3.0f%% MRR %.3f\n",
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
}
densePathWeight = saved
}
if os.Getenv("KB_DIAG_ASSERT") != "" {
if mrr/n < 0.34 {
t.Fatalf("检索质量退化MRR %.3f < 0.34(修复前 0.271,修复后 0.376", mrr/n)
}
if rate < 18 {
t.Fatalf("检索质量退化top-1 %.0f%% < 18%%", rate)
}
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func firstN(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}

View File

@ -1,176 +0,0 @@
package knowledge
import (
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// fakeDense 是可控的稠密向量器:按文本查表,缺省给同一个向量。
// 用它把「稠密路无区分度/给空向量」这类真实故障在单测里复现出来。
type fakeDense struct {
byText map[string]vector.Vector
def vector.Vector
}
func (f fakeDense) Vectorize(text string) vector.Vector {
if v, ok := f.byText[text]; ok {
return v
}
return f.def
}
// EmbedImage 满足 vector.Vectorizer 接口(本用例只用到文本路)。
func (f fakeDense) EmbedImage([]byte, string) (vector.Vector, error) {
return f.def, nil
}
func newTestStore(t *testing.T, dense vector.Vectorizer) *Store {
t.Helper()
st := NewStore(t.TempDir())
if dense != nil {
st.SetVectorizer(dense)
}
return st
}
func mustAdd(t *testing.T, st *Store, name, content string) {
t.Helper()
if err := st.Add(name, content); err != nil {
t.Fatalf("add %s: %v", name, err)
}
}
func names(hits []*Knowledge) []string {
out := make([]string, len(hits))
for i, h := range hits {
out[i] = h.Name
}
return out
}
// base36 生成互不重复的短串(造独特词的量级要大,不能用 a..z 循环重复)。
func base36(n int) string {
const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
if n == 0 {
return "0"
}
out := ""
for n > 0 {
out = string(digits[n%36]) + out
n /= 36
}
return out
}
// 稠密路给**空向量**(真实场景:查询词全在停用词表里,例如"最近更新")时,
// 词法路必须把结果救回来——修复前这里直接返回空。
func TestSearchLexicalRescuesEmptyDenseQuery(t *testing.T) {
dense := fakeDense{def: vector.Vector{"0": 1}}
st := newTestStore(t, dense)
mustAdd(t, st, "changelog_v1", "最近更新了很多东西 发布说明")
mustAdd(t, st, "weather_doc", "天气预报 晴转多云")
// 让"更新"的稠密向量为空(模拟停用词化)
st.SetVectorizer(fakeDense{
byText: map[string]vector.Vector{"更新": {}},
def: vector.Vector{"0": 1},
})
hits := st.Search("更新", 5)
if len(hits) == 0 {
t.Fatal("稠密路给空向量时不该返回空结果(词法路应救回来)")
}
if hits[0].Name != "changelog_v1" {
t.Fatalf("应命中含「更新」的条目,实际: %v", names(hits))
}
}
// 稠密路对所有文本给**同一个向量**(真实故障:词向量平均后各向异性、区分度极低)时,
// 排序必须由词法路决定。
func TestSearchLexicalBreaksDenseTies(t *testing.T) {
same := vector.Vector{"0": 1, "1": 1}
st := newTestStore(t, fakeDense{def: same})
mustAdd(t, st, "plugin_dev_build", "插件构建与部署 hmapdev 命令")
mustAdd(t, st, "cangjie_manual", "仓颉编程语言知识手册")
mustAdd(t, st, "privacy_policy", "隐私政策")
hits := st.Search("hmapdev 构建", 3)
if len(hits) == 0 || hits[0].Name != "plugin_dev_build" {
t.Fatalf("稠密路并列时应由词法路选出 plugin_dev_build实际: %v", names(hits))
}
}
// 词法路的候选中选阈值必须是 0TF-IDF 余弦量级只有 0.0~0.2,沿用稠密路的 0.05
// 会把有效候选静默砍掉(真实 KB 实测自检索 MRR 0.307→0.193)。
//
// 判据分两层,各钉一半:
// - **语义层**由 internal/memory/vector 的 TestSearchScoredRespectsMinScore 证明
// (同一候选在默认阈值下被过滤、阈值 0 时被召回);
// - **接线层**在这里钉住:知识库的词法路用的就是阈值 0 的那个 store。
// 不在这里造「低余弦夹具」的原因:分词器会丢掉纯拉丁 token、也会过滤未登录词
// 造出来的夹具余弦根本压不到阈值以下(我先试了两种,余弦 0.23/0.27
// 前提断言直接把这两版夹具否掉了)。
func TestLexicalStoreUsesZeroMinScore(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
if got := st.lex.MinScore(); got != 0 {
t.Fatalf("词法路阈值必须为 0实际 %v沿用稠密路阈值会静默丢候选", got)
}
if got := st.vec.MinScore(); got != vector.DefaultMinScore {
t.Fatalf("稠密路阈值应保持默认 %v实际 %v", vector.DefaultMinScore, got)
}
}
// Add / Remove 必须同时维护两路索引:只维护一路会让被删条目继续被检索命中
// (或新条目只在其中一路可见)。
func TestAddRemoveKeepsBothPaths(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
mustAdd(t, st, "alpha", "alpha 独有词 alphaonly")
mustAdd(t, st, "beta", "beta 独有词 betaonly")
has := func(q, want string) bool {
for _, h := range st.Search(q, 5) {
if h.Name == want {
return true
}
}
return false
}
if !has("alphaonly", "alpha") {
t.Fatal("新增条目应可被检索到")
}
if err := st.Remove("alpha"); err != nil {
t.Fatalf("remove: %v", err)
}
if has("alphaonly", "alpha") {
t.Fatal("已删除条目仍被检索命中(两路索引有一路没清)")
}
if !has("betaonly", "beta") {
t.Fatal("删除其它条目不应影响 beta")
}
}
// 分数相同时必须按名字定序,保证结果可重复(否则同一查询两次结果可能不同)。
func TestSearchDeterministicOnTies(t *testing.T) {
same := vector.Vector{"0": 1}
st := newTestStore(t, fakeDense{def: same})
for _, n := range []string{"ccc", "aaa", "bbb"} {
mustAdd(t, st, n, "完全一样的内容")
}
first := names(st.Search("完全一样的内容", 3))
for i := 0; i < 5; i++ {
got := names(st.Search("完全一样的内容", 3))
for j := range first {
if got[j] != first[j] {
t.Fatalf("结果不确定:第 %d 次 %v != 首次 %v", i, got, first)
}
}
}
}
// 两路都空时不能 panic且应返回空。
func TestSearchEmptyStore(t *testing.T) {
st := newTestStore(t, fakeDense{def: vector.Vector{"0": 1}})
if hits := st.Search("随便", 5); len(hits) != 0 {
t.Fatalf("空库应返回空,实际 %v", names(hits))
}
}

View File

@ -69,65 +69,6 @@ function sdk.inject_text_no_memory(source, channel, text)
print("[lua-plugin] inject_text_no_memory: " .. tostring(source))
end
-- !impl
-- opts: { no_memory=bool, context_policy="none"|"prune", cleaner_name=string, priority="L1".."L3" }
-- 零值/缺省 = 记入记忆 + 不裁剪(与三参数版本等价)。
function sdk.inject_text_opts(source, channel, text, opts)
print("[lua-plugin] inject_text_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_opts(source, channel, text, opts)
print("[lua-plugin] inject_interrupt_opts: " .. tostring(source))
end
-- !impl
-- 同步注入:等待本轮回复 -> (reply, err);无回复时 reply 为 nil。
function sdk.inject_input_sync(source, channel, text) return nil, nil end
-- !impl
function sdk.inject_input_sync_opts(source, channel, text, opts) return nil, nil end
-- !impl
-- blocks: ContentBlock 数组,见 sdk.inject_input_media。
-- 设置下一轮 tool message 携带的多模态内容块(模型据此看图/听音频)。
function sdk.set_tool_blocks(blocks)
print("[lua-plugin] set_tool_blocks: " .. tostring(blocks and #blocks or 0))
end
-- !impl
-- blocks 每项:{ type="text", text="..." }
-- | { type="image_url", image_url={ url="...", detail="high" } }
-- | { type="audio_url", audio_url={ url="..." } }
function sdk.inject_input_media(source, channel, text, blocks)
print("[lua-plugin] inject_input_media: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_input_media_opts: " .. tostring(source))
end
-- !impl
function sdk.inject_input_media_sync(source, channel, text, blocks) return nil, nil end
-- !impl
function sdk.inject_input_media_sync_opts(source, channel, text, blocks, opts) return nil, nil end
-- !impl
function sdk.inject_interrupt_media(source, channel, text, blocks)
print("[lua-plugin] inject_interrupt_media: " .. tostring(source))
end
-- !impl
function sdk.inject_interrupt_media_opts(source, channel, text, blocks, opts)
print("[lua-plugin] inject_interrupt_media_opts: " .. tostring(source))
end
-- !impl
-- 注销输出通道(随资源生灭的动态通道,如远程设备)。返回 (nil, err)。
function sdk.unregister_output_channel(name) return nil, nil end
-- !impl
-- enabled: true/false崩溃时内核自动拉起
function sdk.set_auto_restart(enabled)
@ -160,9 +101,6 @@ function sdk.doc.query(text, top_k) return {} end
-- doc: { id=, title=, content= }
function sdk.doc.insert(doc) return nil end
-- !impl
-- attachments 每项:{ digest=, mime=, name=, data=<base64> }
function sdk.doc.insert_with_media(doc, attachments) return nil end
-- !impl
function sdk.doc.remove(id) return nil end
-- !impl
function sdk.doc.stats() return {} end
@ -236,24 +174,6 @@ function sdk.settings.dump() return {} end
-- !impl
function sdk.settings.plugins() return {} end
-- ============ events只读订阅 ============
-- !impl
-- subscribe(event_type, handler) -> unsubscribe()
-- handler 收到 { type=, source=, timestamp=, payload= }
-- 回调在其内核事件发布 goroutine 上执行只做轻量转发不可阻塞Lua 单状态 + 互斥锁)。
sdk.events = {}
function sdk.events.subscribe(event_type, handler)
print("[lua-plugin] events.subscribe: " .. tostring(event_type))
return function() end
end
-- ============ plugin_mgr ============
-- !impl
sdk.plugin_mgr = {}
function sdk.plugin_mgr.reload_one(name) return nil end
function sdk.plugin_mgr.list_loaded() return {} end
function sdk.plugin_mgr.is_disabled(name) return false end
-- json utils (pure Lua)
sdk.json = {}

View File

@ -71,29 +71,6 @@ func NewGraphDB(dbPath string) (*GraphDB, error) {
return g, nil
}
// OpenGraphDBReadOnly 以**受限句柄**打开图库:可读、可恢复 WAL但**一切写入被拒**。
//
// 这是"子 agent 改不了主记忆"的**结构性**保证(设计 docs/zh/resident-subagent-design.md
// §5不是靠调用方自觉不写而是把写入在 SQLite 这一层就关掉
// `PRAGMA query_only=1` —— 任何 INSERT/UPDATE/DELETE 都会直接报错)。
//
// 为什么不用 DSN 的 `mode=ro`:只读模式的连接在 WAL 库上无法自行恢复 -wal
// 而主库在父 agent 手里是持续写入的。query_only 让连接保持正常打开能力,
// 同时**只堵写**,语义正是我们要的。
//
// 注意:不建表、不迁移 —— 受限句柄假定库已存在(由父 agent 建好)。
func OpenGraphDBReadOnly(dbPath string) (*GraphDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=on")
if err != nil {
return nil, fmt.Errorf("open graph db (readonly): %w", err)
}
if _, err := db.Exec("PRAGMA query_only=1"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("set query_only: %w", err)
}
return &GraphDB{db: db, dbPath: dbPath}, nil
}
func (g *GraphDB) initSchema() error {
g.mu.Lock()
defer g.mu.Unlock()
@ -256,9 +233,6 @@ func (g *GraphDB) migrateRelationUnique(tx *sql.Tx) error {
}
// Commit 把三元组写入图库,返回新建的实体数与关系数。
// Path 返回本库的存储路径(父 agent 用它为驻留子打开**受限句柄**)。
func (g *GraphDB) Path() string { return g.dbPath }
func (g *GraphDB) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
_, ec, rc, err := g.commit(triples, sessionID, turnID, false)
return ec, rc, err
@ -629,46 +603,6 @@ func (g *GraphDB) Recall(keywords []string, seedEntities []string, depth int, se
return result, nil
}
// ExportTriples 导出库中的**活跃**三元组(供父 agent 在回收阶段收割子的 temp
//
// 设计 docs/zh/resident-subagent-design.md §9回收 = 父读 temp → 选记录 → 合入 main
// 只导出 `status='active'` 的关系并把实体名一并带出Relation 已含 SourceName/TargetName
// 于是合入侧可以直接复用 Commit —— 它按实体名 upsert、按
// (source, relation, target) 幂等,因此"重复收割"不会造成重复条目。
//
// limit <= 0 表示不限量。
func (g *GraphDB) ExportTriples(limit int) ([]Triple, error) {
g.mu.RLock()
defer g.mu.RUnlock()
q := `SELECT s.name, r.relation_type, t.name, r.confidence
FROM relations r
JOIN entities s ON s.id = r.source_id
JOIN entities t ON t.id = r.target_id
WHERE r.status = 'active'
ORDER BY r.id`
args := []interface{}{}
if limit > 0 {
q += " LIMIT ?"
args = append(args, limit)
}
rows, err := g.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Triple
for rows.Next() {
var tr Triple
if err := rows.Scan(&tr.Subject, &tr.Relation, &tr.Object, &tr.Confidence); err != nil {
return nil, err
}
out = append(out, tr)
}
return out, rows.Err()
}
func (g *GraphDB) Purge(criteria map[string]string, mode string) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()

View File

@ -1,81 +0,0 @@
package memory
// N2a 第一块砖:**受限句柄**query_only—— 子是"读得到主记忆、写不进去"的结构性保证。
//
// 设计docs/zh/resident-subagent-design.md §5两级空间与 §5.5(子的记忆面)。
// 轻量内核拿的是「主库受限句柄(只读)+ 自己的 temp 实例(读写)」,
// 于是"子改不了 main"不靠调用方自觉,而是被 SQLite 直接拒。
import (
"path/filepath"
"testing"
)
func TestOpenGraphDBReadOnly_ReadsWorkWritesRejected(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "main.db")
// 父 agent 先建库并写入数据。
main, err := NewGraphDB(path)
if err != nil {
t.Fatal(err)
}
if _, _, err := main.Commit([]Triple{{
Subject: "张三", Relation: "任职于", Object: "某公司",
}}, "sess-1", 1); err != nil {
t.Fatalf("父写入失败: %v", err)
}
// 子拿到受限句柄。
ro, err := OpenGraphDBReadOnly(path)
if err != nil {
t.Fatalf("打开受限句柄失败: %v", err)
}
defer ro.Close()
// ① 读得到。
res, err := ro.Recall([]string{"张三"}, nil, 1, "")
if err != nil {
t.Fatalf("受限句柄应能读: %v", err)
}
if res == nil {
t.Fatal("读取结果不应为 nil")
}
// ② 写不进去 —— 结构性拒绝(不是"约定不写")。
if _, _, err := ro.Commit([]Triple{{
Subject: "李四", Relation: "任职于", Object: "另一公司",
}}, "sess-2", 1); err == nil {
t.Fatal("受限句柄的写入必须被 SQLite 拒绝query_only")
}
if _, err := ro.Purge(map[string]string{"subject": "张三"}, "soft"); err == nil {
t.Fatal("受限句柄的 Purge 必须被拒")
}
// ③ 父库不受影响:子写失败没留下任何痕迹。
again, err := main.Recall([]string{"李四"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if again != nil && len(again.Entities) > 0 {
t.Fatalf("子写失败却在主库留下了痕迹:%+v", again.Entities)
}
}
// 受限句柄不建表、不迁移:库不存在时按只读语义处理(查不到东西),
// 但**不得**悄悄创建出一个空库(否则"子"会凭空造出主库)。
func TestOpenGraphDBReadOnly_DoesNotCreateSchema(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.db")
ro, err := OpenGraphDBReadOnly(path)
if err != nil {
// 打开本身允许失败(某些平台会因文件不存在直接报错)——
// 关键是"不得建表成功"。
return
}
defer ro.Close()
if _, err := ro.Recall([]string{"任意"}, nil, 1, ""); err == nil {
t.Fatal("库不存在时受限句柄的查询应当报错,而不是凭空建表后返回空结果")
}
}

View File

@ -1,191 +0,0 @@
package memory
// 轻量内核的**图记忆装配**(设计 §5.6
//
// temp 实例(独立存储,读写) ← 子的一切图记忆写入落这里,与子同生共死
// 主库**受限句柄**(只读) ← 子只能读;写入被 SQLite 结构性拒绝
//
// 子的查询 = 两个实例各查一次,**应用层合并**(并集):
// - 实体:按名字去重(同名视为同一实体,保留 mention_count 较大的一条)
// - 关系:按 (源名, 关系, 目标名) 去重
//
// 为什么合并放在应用层而不是给共享记忆层加 space 列:**独立存储实例**让隔离成为
// 结构性的(不同库),不依赖 where 条件;代价就是这个合并函数。
//
// 回收N2d由父 agent 读 `Temp()` 并选出要保留的记录写进主库。
import (
"fmt"
"sort"
"strings"
"sync"
)
// LightMemory 是驻留子的图记忆装配。
type LightMemory struct {
mu sync.RWMutex
// temp 是子自己的图记忆实例(独立存储)。当不允许子写图记忆时为 nil。
temp *GraphDB
// main 是主图记忆的受限句柄(只读)。
main *GraphDB
// allowWrite 报告是否允许写 tempfalse ⇒ 子对图记忆完全只读)。
allowWrite bool
}
// NewLightMemory 构造子的图记忆装配。
//
// - main: 主库句柄。为 nil 时表示"只能用自己的 temp"(一般不该发生)。
// - tempPath: 子 temp 实例的存储路径独立文件。allowWrite=false 时**不会**打开它。
// - allowWrite: 是否允许子写图记忆(用户给的备选开关,默认建议 true
func NewLightMemory(main *GraphDB, tempPath string, allowWrite bool) (*LightMemory, error) {
m := &LightMemory{main: main, allowWrite: allowWrite}
if !allowWrite {
return m, nil
}
if tempPath == "" {
return nil, fmt.Errorf("允许写图记忆时必须给出 temp 存储路径")
}
temp, err := NewGraphDB(tempPath) // temp 是子自己的库:建表/迁移都正常
if err != nil {
return nil, fmt.Errorf("open temp graph db: %w", err)
}
m.temp = temp
return m, nil
}
// AllowWrite 报告子能否写图记忆。
func (m *LightMemory) AllowWrite() bool { return m.allowWrite }
// Temp 返回子的 temp 实例(可能为 nil。回收时父 agent 用它读取/收割。
func (m *LightMemory) Temp() *GraphDB {
m.mu.RLock()
defer m.mu.RUnlock()
return m.temp
}
// Main 返回主库的受限句柄。
func (m *LightMemory) Main() *GraphDB { return m.main }
// Commit 写入图记忆:**只落 temp**(设计 §5写目标收窄到自己的空间
func (m *LightMemory) Commit(triples []Triple, sessionID string, turnID int) (int, int, error) {
if !m.allowWrite {
return 0, 0, fmt.Errorf("本 agent 不允许写图记忆allowTempGraphWrite=false")
}
m.mu.RLock()
temp := m.temp
m.mu.RUnlock()
if temp == nil {
return 0, 0, fmt.Errorf("temp 图记忆实例不可用")
}
return temp.Commit(triples, sessionID, turnID)
}
// Recall 在 temp main 上做召回(并集),按上述规则合并去重。
//
// 任一侧出错都不影响另一侧的结果:单侧失败只在两侧都失败时返回错误
// (主库是只读句柄,任何"查询即失败"都说明是真实故障)。
func (m *LightMemory) Recall(keywords []string, seedEntities []string, depth int, sessionFilter string) (*RecallResult, error) {
m.mu.RLock()
temp := m.temp
m.mu.RUnlock()
var (
parts []*RecallResult
lastErr error
okAny bool
)
if temp != nil {
r, err := temp.Recall(keywords, seedEntities, depth, sessionFilter)
if err != nil {
lastErr = err
} else {
okAny = true
parts = append(parts, r)
}
}
if m.main != nil {
r, err := m.main.Recall(keywords, seedEntities, depth, sessionFilter)
if err != nil {
lastErr = err
} else {
okAny = true
parts = append(parts, r)
}
}
if !okAny {
if lastErr == nil {
lastErr = fmt.Errorf("没有可用的图记忆实例")
}
return nil, lastErr
}
return mergeRecall(parts...), nil
}
// mergeRecall 把多个来源的召回结果并成一份(实体按名字、关系按三元组去重)。
//
// 顺序确定(先 entities/relations 各自排序),便于测试与展示稳定。
func mergeRecall(parts ...*RecallResult) *RecallResult {
out := &RecallResult{}
seenEntity := map[string]int{} // 小写名 → out.Entities 下标
for _, p := range parts {
if p == nil {
continue
}
for _, e := range p.Entities {
k := strings.ToLower(strings.TrimSpace(e.Name))
if k == "" {
continue
}
if i, dup := seenEntity[k]; dup {
// 同名实体:保留 mention_count 较大的一条(更新的那个)。
if e.MentionCount > out.Entities[i].MentionCount {
out.Entities[i] = e
}
continue
}
seenEntity[k] = len(out.Entities)
out.Entities = append(out.Entities, e)
}
}
seenRel := map[string]struct{}{}
for _, p := range parts {
if p == nil {
continue
}
for _, r := range p.Relations {
k := strings.ToLower(strings.TrimSpace(r.SourceName)) + "\x00" +
strings.ToLower(strings.TrimSpace(r.RelationType)) + "\x00" +
strings.ToLower(strings.TrimSpace(r.TargetName))
if _, dup := seenRel[k]; dup {
continue
}
seenRel[k] = struct{}{}
out.Relations = append(out.Relations, r)
}
}
sort.Slice(out.Entities, func(i, j int) bool { return out.Entities[i].Name < out.Entities[j].Name })
sort.Slice(out.Relations, func(i, j int) bool {
if out.Relations[i].SourceName != out.Relations[j].SourceName {
return out.Relations[i].SourceName < out.Relations[j].SourceName
}
if out.Relations[i].RelationType != out.Relations[j].RelationType {
return out.Relations[i].RelationType < out.Relations[j].RelationType
}
return out.Relations[i].TargetName < out.Relations[j].TargetName
})
return out
}
// Close 关闭 temp 实例(主库句柄由父 agent 拥有,不在这里关)。
func (m *LightMemory) Close() error {
m.mu.Lock()
temp := m.temp
m.temp = nil
m.mu.Unlock()
if temp != nil {
return temp.Close()
}
return nil
}

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