21 Commits

Author SHA1 Message Date
4737aa10e2 chore(version): release/v1.2.x 路牌推到 1.2.1(本版内容:人格门禁跨通道化 + healthcheck 状态报告)
v1.2.0 已从 f550bb2 发出且 tag 不可改写;本分支今起产出 1.2.1。
主干 main 的路牌仍是下一个未发布中版本 1.3.0,不随本 patch 变动。
2026-09-12 14:55:53 +08:00
d759e74cca feat(healthcheck): 内核状态快照报出内核版本号与 ONNX 模型启用状态
healthcheck_kernel 此前没有任何「ONNX 模型是否在用」的信息,只报「向量可用/不可用」,
分不清「统一多模态空间已加载」与「退回到词嵌入/TF-IDF 路径」;人格卡要求
「版本以运行时快照为准」,也缺一个可查字段(build.version 早就在,但没人知道)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

验证:go test ./internal/plugin/ ./internal/plugin/proc/ ok,
含 4 条真实模板 E2E(模板路径切换后仍通过)。
2026-09-12 12:42:09 +08:00
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
119 changed files with 1408 additions and 13259 deletions

View File

@ -528,17 +528,13 @@ func main() {
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,
PersonaStore: internalConfig.RegistryPersonaStore{Reg: cfgReg},
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,

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

@ -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

@ -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
@ -92,9 +64,6 @@ type Agent struct {
// 为 nil 时门禁与工具都静默关闭(例如单测里不接配置的场景)。
personaStore PersonaStore
// 被授权的输出通道集合(空 = 完整授权,见 AgentConfig.AllowedOutputs
allowedOutputs []string
// 插件注册表(用于 plgreload
pluginReg *plugin.Registry
pluginDir string
@ -111,6 +80,7 @@ type Agent struct {
maxContextSize int
// 当前请求的输出通道mutex 保护process() 内独占)
currentOutputChannel string
// 阶段管道:插件消息流编辑
stageHost *StageHost
@ -136,12 +106,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 +128,7 @@ type Agent struct {
//
// 需要缓存而不是当场挂到事件上:媒体在 process() 执行期间被捕获,
// 而承载它的 ContextEvent 要等 process() 返回后才 Append——此刻还没有 owner_id。
// 由 schedulerLoop goroutine 独占读写
// 与 pendingMedia 同受 a.mu 保护
pendingMediaDigests []string
// 当前输入是否为工具提醒/中断(以 system 角色注入,避免被当成用户消息)
@ -216,13 +182,8 @@ 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
@ -232,29 +193,12 @@ type AgentConfig struct {
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
EmbeddingProvider string
EmbeddingError string
FusionCfg CrossModalFusionConfig // 跨模态融合权重;零值用默认
Personality *agentPkg.Personality
PersonaStore PersonaStore // 人格设定的读写面(首启门禁 + persona_set 工具)
PluginReg *plugin.Registry
PluginDir string
DistillInterval time.Duration
ArchiveInterval time.Duration // 冷文档归档间隔L2→L30 则使用 DistillInterval
@ -271,10 +215,6 @@ type AgentConfig struct {
SkillIndexProvider SkillIndexProvider
InputProcessing types.InputProcessingConfig // 非文本输入处理配置
// MaxToolTurns 是单个任务允许的工具轮次上限0 = 不限)。
// 设计文档 D6主循环必须有硬上限否则模型不停调用就永不完结。
MaxToolTurns int
}
func New(cfg AgentConfig) *Agent {
@ -332,11 +272,6 @@ func New(cfg AgentConfig) *Agent {
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,
@ -350,7 +285,6 @@ func New(cfg AgentConfig) *Agent {
mediaStore: cfg.MediaStore,
personality: cfg.Personality,
personaStore: cfg.PersonaStore,
allowedOutputs: cfg.AllowedOutputs,
pluginReg: cfg.PluginReg,
pluginDir: cfg.PluginDir,
distillInterval: cfg.DistillInterval,
@ -363,8 +297,7 @@ func New(cfg AgentConfig) *Agent {
eventBus: cfg.EventBus,
selfInputCh: make(chan selfInputMsg, 64),
childTasks: make(map[string]*childTaskState),
sched: newScheduler(256),
maxToolTurns: cfg.MaxToolTurns,
interceptCh: make(chan *agentIO.InputEvent, 64),
pluginHealth: newPluginHealthTracker(),
thinkingEnabled: cfg.ThinkingEnabled,
inputCfg: cfg.InputProcessing,
@ -382,7 +315,7 @@ func New(cfg AgentConfig) *Agent {
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()
@ -394,68 +327,11 @@ func (a *Agent) Start() {
}
func (a *Agent) Stop() {
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
a.StopResidents()
a.cancel()
}
// graphMemoryOf 决定本 agent 的图记忆共同面实现。
//
// - 轻量内核(给了 LightMemory用 LightMemory**整理面保持 nil**
// - 完整内核:直接用主图库(*memory.GraphDB 天然满足 GraphMemory
func graphMemoryOf(cfg AgentConfig) GraphMemory {
if cfg.LightMemory != nil {
return cfg.LightMemory
}
if cfg.Memory == nil {
return nil
}
return cfg.Memory
}
// graphMem 返回本 agent 的图记忆**共同面**。
//
// `graph` 显式为 nil 时回落到 `memory` —— 这样"只设 memory 的构造"
// (大量既有测试直接用 Agent 字面量)照常工作,不需要同时维护两个字段。
// 轻量内核则显式设 graph=LightMemory 且 memory=nil共同面走 LightMemory
// 整理面因 memory==nil 而全部不可达。
func (a *Agent) graphMem() GraphMemory {
if a.graph != nil {
return a.graph
}
if a.memory == nil {
return nil
}
return a.memory
}
func (a *Agent) ID() types.AgentID { return a.id }
// IsOutputAllowed 报告某个输出通道是否被授权给本 agent。
//
// 默认(未配置白名单)= **完整授权**;这是"默认完整授权、父可收窄"的落点。
func (a *Agent) IsOutputAllowed(channel string) bool {
if len(a.allowedOutputs) == 0 {
return true
}
for _, c := range a.allowedOutputs {
if c == channel {
return true
}
}
return false
}
// ResolveOutputTarget 解析输出通道的投递目标agent + inputch
//
// ok=false 表示该输出通道由传输层device 通道,如 qq/webui自行处理。
func (a *Agent) ResolveOutputTarget(channel string) (agentIO.OutputTarget, bool) {
if a.io == nil || a.io.ChannelRegistry() == nil {
return agentIO.OutputTarget{}, false
}
return a.io.ChannelRegistry().ResolveOutputTarget(channel)
}
// isDuplicateInput 判断是否为短窗口内的重复输入(防 webui/GUI 断线重连消息重放)。
// key=source+"|"+content窗口内重复返回 true 并刷新时间戳(持续轰炸时保持拦截)。
const duplicateInputWindow = 10 * time.Second

View File

@ -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,
@ -354,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
@ -370,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,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

@ -16,11 +16,6 @@ func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string {
if channel == "" || payload == "" || rawType == "" {
return "工具名称格式: output_send__{channel}payload 和 type 不能为空"
}
// 授权闸(纵深防御):模型可能凭名字直接调未授权的输出门。
if !a.IsOutputAllowed(channel) {
return fmt.Sprintf("通道 [%s] 未授权给本 agent。可用通道见 output_list_channels", channel)
}
meta, _ := tc.Arguments["meta"].(string)
caps := a.io.GetChannelCapabilities(channel)
@ -151,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

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

View File

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

View File

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

View File

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

View File

@ -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,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,6 +192,7 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
socialStore = a.social
}
var trk *tracker.Tracker
if a.tracker != nil {
trk = a.tracker
@ -220,7 +222,6 @@ func (a *Agent) GetKernelStatus() *KernelStatus {
trk,
)
ks.ONNX = a.onnxStatus()
ks.Scheduler = a.schedulerStatus()
return ks
}

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,934 +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 置位、而本循环是唯一读帧者。
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,7 +43,7 @@ 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)
@ -61,21 +61,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 +88,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 +104,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 +125,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 +163,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 +223,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 +233,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 +245,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 +255,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 +289,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,8 +68,8 @@ 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"
@ -609,11 +580,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 == "" {
@ -671,90 +637,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,13 +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
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 导致的循环依赖。
@ -112,11 +112,11 @@ 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),
}
}
@ -266,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) {
@ -444,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 {
@ -507,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 {

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

@ -742,7 +742,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()
@ -455,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":
@ -495,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

@ -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
}

View File

@ -1,208 +0,0 @@
package memory
// N2a 第二块砖轻量内核的图记忆装配temp 可写 + 主库只读 + 并集查询)。
//
// 设计docs/zh/resident-subagent-design.md §5.6。
// 关键性质:
// ① 子写入只落 temp写不进展主库—— main 是受限句柄,结构性拒绝;
// ② 子查询 = temp main
// ③ 两个子之间 temp 互不可见;
// ④ allowTempGraphWrite=false 时子对图记忆完全只读(没有 temp 实例)。
import (
"path/filepath"
"testing"
)
// newMainWith 建一个主库并写入若干三元组。
func newMainWith(t *testing.T, dir string, triples ...Triple) *GraphDB {
t.Helper()
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
if len(triples) > 0 {
if _, _, err := main.Commit(triples, "sess", 1); err != nil {
t.Fatalf("主库写入失败: %v", err)
}
}
return main
}
func names(entities []Entity) []string {
out := make([]string, 0, len(entities))
for _, e := range entities {
out = append(out, e.Name)
}
return out
}
func hasName(entities []Entity, name string) bool {
for _, e := range entities {
if e.Name == name {
return true
}
}
return false
}
// 子写入只落 temp查询是 temp main。
func TestLightMemory_WriteGoesToTempReadIsUnion(t *testing.T) {
dir := t.TempDir()
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
defer main.Close()
light, err := NewLightMemory(main, filepath.Join(dir, "sub-1.db"), true)
if err != nil {
t.Fatal(err)
}
defer light.Close()
// 子写自己的发现。
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "任职于", Object: "乙公司"}}, "sess", 1); err != nil {
t.Fatalf("子写入 temp 应成功: %v", err)
}
// 查询能同时看到 main张三与 temp李四
res, err := light.Recall([]string{"张三", "李四"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if !hasName(res.Entities, "张三") {
t.Fatalf("子应看得到主库内容,实际 %v", names(res.Entities))
}
if !hasName(res.Entities, "李四") {
t.Fatalf("子应看得到自己 temp 的内容,实际 %v", names(res.Entities))
}
// 主库**没有**被写入(子改不了 main
mainOnly, err := main.Recall([]string{"李四"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if hasName(mainOnly.Entities, "李四") {
t.Fatalf("主的图记忆里不该出现子才知道的实体:%v", names(mainOnly.Entities))
}
}
// 两个子之间 temp 互不可见(只有 main 共享)。
func TestLightMemory_ChildrenTempsAreIsolated(t *testing.T) {
dir := t.TempDir()
main := newMainWith(t, dir, Triple{Subject: "共享", Relation: "属于", Object: "主库"})
defer main.Close()
a, err := NewLightMemory(main, filepath.Join(dir, "sub-a.db"), true)
if err != nil {
t.Fatal(err)
}
defer a.Close()
b, err := NewLightMemory(main, filepath.Join(dir, "sub-b.db"), true)
if err != nil {
t.Fatal(err)
}
defer b.Close()
if _, _, err := a.Commit([]Triple{{Subject: "A的秘密", Relation: "仅属于", Object: "A"}}, "s", 1); err != nil {
t.Fatal(err)
}
resB, err := b.Recall([]string{"A的秘密", "共享"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if hasName(resB.Entities, "A的秘密") {
t.Fatalf("B 不该看到 A 的 temp%v", names(resB.Entities))
}
if !hasName(resB.Entities, "共享") {
t.Fatalf("B 应看得到共享的 main%v", names(resB.Entities))
}
}
// allowTempGraphWrite=false子对图记忆完全只读没有 temp 实例,写入被拒)。
func TestLightMemory_WriteDisabledIsFullyReadOnly(t *testing.T) {
dir := t.TempDir()
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
defer main.Close()
light, err := NewLightMemory(main, "", false)
if err != nil {
t.Fatalf("不允许写时不应要求 temp 路径: %v", err)
}
defer light.Close()
if light.AllowWrite() {
t.Fatal("AllowWrite 应为 false")
}
if light.Temp() != nil {
t.Fatal("不允许写时不该有 temp 实例")
}
if _, _, err := light.Commit([]Triple{{Subject: "李四", Relation: "x", Object: "y"}}, "s", 1); err == nil {
t.Fatal("不允许写时的写入必须被拒")
}
// 读仍然可用(只读主库)。
res, err := light.Recall([]string{"张三"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if !hasName(res.Entities, "张三") {
t.Fatalf("只读模式仍应读到主库:%v", names(res.Entities))
}
}
// 并集必须去重:同一个实体同时存在于 main 与 temp 时只出现一次。
func TestLightMemory_UnionDedupesSameEntity(t *testing.T) {
dir := t.TempDir()
main := newMainWith(t, dir, Triple{Subject: "张三", Relation: "任职于", Object: "甲公司"})
defer main.Close()
light, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
if err != nil {
t.Fatal(err)
}
defer light.Close()
if _, _, err := light.Commit([]Triple{{Subject: "张三", Relation: "擅长", Object: "Go"}}, "s", 1); err != nil {
t.Fatal(err)
}
res, err := light.Recall([]string{"张三"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range res.Entities {
if e.Name == "张三" {
n++
}
}
if n != 1 {
t.Fatalf("同名实体在并集中应只出现一次,实际 %d 次:%v", n, names(res.Entities))
}
}
// 并集查询的确定性(同样的输入必得同样的顺序),便于断言与展示稳定。
func TestMergeRecall_Deterministic(t *testing.T) {
a := &RecallResult{
Entities: []Entity{{Name: "b", MentionCount: 1}, {Name: "a", MentionCount: 1}},
Relations: []Relation{
{SourceName: "z", RelationType: "r", TargetName: "y"},
{SourceName: "a", RelationType: "r", TargetName: "b"},
},
}
b := &RecallResult{
Entities: []Entity{{Name: "a", MentionCount: 5}},
Relations: []Relation{{SourceName: "a", RelationType: "r", TargetName: "b"}},
}
got := mergeRecall(a, b)
if len(got.Entities) != 2 || got.Entities[0].Name != "a" || got.Entities[1].Name != "b" {
t.Fatalf("实体合并结果=%v", names(got.Entities))
}
if got.Entities[0].MentionCount != 5 {
t.Fatalf("同名实体应保留 mention_count 较大者,实际 %d", got.Entities[0].MentionCount)
}
if len(got.Relations) != 2 {
t.Fatalf("关系应去重后剩 2 条,实际 %d", len(got.Relations))
}
if got.Relations[0].SourceName != "a" {
t.Fatalf("关系未按源名排序:%+v", got.Relations)
}
}

View File

@ -1,117 +0,0 @@
package memory
// N2d 数据面:**回收**=父读子的 temp → 选记录 → 合入 main。
//
// 设计 §9回收是"取消语义"(收割后取消该驻留子),且**由父决定纳入哪些**。
// 这里只做数据面(读得到、合得进、幂等),控制面(谁来决定、何时取消)在 N3/N6。
import (
"path/filepath"
"testing"
)
func TestExportTriples_OnlyActiveWithNames(t *testing.T) {
dir := t.TempDir()
g, err := NewGraphDB(filepath.Join(dir, "g.db"))
if err != nil {
t.Fatal(err)
}
defer g.Close()
if _, _, err := g.Commit([]Triple{
{Subject: "张三", Relation: "任职于", Object: "甲公司"},
{Subject: "李四", Relation: "合作", Object: "王五"},
}, "sess", 1); err != nil {
t.Fatal(err)
}
got, err := g.ExportTriples(0)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("导出 %d 条,期望 2%+v", len(got), got)
}
// 实体名必须带出来(合入侧要靠名字 upsert而不是内部 id
seen := map[string]bool{}
for _, tr := range got {
if tr.Subject == "" || tr.Object == "" || tr.Relation == "" {
t.Fatalf("导出的三元组字段不全:%+v", tr)
}
seen[tr.Subject+"→"+tr.Object] = true
}
if !seen["张三→甲公司"] || !seen["李四→王五"] {
t.Fatalf("导出内容不对:%+v", got)
}
// limit 生效。
one, err := g.ExportTriples(1)
if err != nil {
t.Fatal(err)
}
if len(one) != 1 {
t.Fatalf("limit=1 应导出 1 条,实际 %d", len(one))
}
}
// 回收主流程:子写 temp → 父导出 → 选中的合入 main主库拿到、temp 不变。
func TestReclaim_HarvestChildTempIntoMain(t *testing.T) {
dir := t.TempDir()
main, err := NewGraphDB(filepath.Join(dir, "main.db"))
if err != nil {
t.Fatal(err)
}
defer main.Close()
child, err := NewLightMemory(main, filepath.Join(dir, "sub.db"), true)
if err != nil {
t.Fatal(err)
}
defer child.Close()
// 子在 temp 里积累了两条发现。
if _, _, err := child.Commit([]Triple{
{Subject: "子发现A", Relation: "指向", Object: "结论1"},
{Subject: "子发现B", Relation: "指向", Object: "结论2"},
}, "sess", 1); err != nil {
t.Fatal(err)
}
// 父:导出 → **选一条**"哪些纳入记忆"由父决定)→ 写进 main。
all, err := child.Temp().ExportTriples(0)
if err != nil {
t.Fatal(err)
}
if len(all) != 2 {
t.Fatalf("导出 %d 条,期望 2", len(all))
}
selected := []Triple{all[0]}
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
t.Fatalf("合入 main 失败: %v", err)
}
// 主库只拿到选中的那条。
mainRes, err := main.Recall([]string{"子发现A", "子发现B"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if !hasName(mainRes.Entities, "子发现A") {
t.Fatalf("选中的记录应进主库:%v", names(mainRes.Entities))
}
if hasName(mainRes.Entities, "子发现B") {
t.Fatalf("未选中的记录不该进主库:%v", names(mainRes.Entities))
}
// 重复收割是幂等的Commit 按实体名 upsert + 关系唯一约束)。
before := len(mainRes.Entities)
if _, _, err := main.Commit(selected, "reclaim", 0); err != nil {
t.Fatal(err)
}
after, err := main.Recall([]string{"子发现A"}, nil, 1, "")
if err != nil {
t.Fatal(err)
}
if len(after.Entities) != before {
t.Fatalf("重复收割不应产生重复实体before=%d after=%d", before, len(after.Entities))
}
}

View File

@ -34,15 +34,11 @@ type StaticEmbedder struct {
jieba *gojieba.Jieba
stopWords map[string]bool
// words 是词向量表。**用 float32 存**源文件fastText 文本格式)本身就是 float32
// 用 float64 存等于把 578 万……不,是 57.8 万词 × 300 维的常驻内存凭空翻倍
// 实测生产float64 → 1.29GBfloat32 → 0.65GB)。相似度计算仍在 float64 里累加,
// 精度不受影响。改回 float64 会被 TestStaticEmbedder_VectorMemIsFloat32 拦住。
words map[string][]float32
words map[string][]float64
dim int
loaded bool
unkVec []float32
unkVec []float64
unkNorm float64
}
@ -154,7 +150,7 @@ func NewStaticEmbedder(modelPaths ...string) *StaticEmbedder {
e := &StaticEmbedder{
jieba: GetJieba(),
stopWords: sw,
words: make(map[string][]float32),
words: make(map[string][]float64),
}
if len(modelPaths) == 0 {
@ -258,16 +254,15 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
continue
}
vec := make([]float32, dim)
vec := make([]float64, dim)
for i := 0; i < dim; i++ {
// 源文件是 float32 精度的文本向量:用 32 位解析,与源数据一致。
v, _ := strconv.ParseFloat(fields[i+1], 32)
vec[i] = float32(v)
v, _ := strconv.ParseFloat(fields[i+1], 64)
vec[i] = v
}
e.words[word] = vec
if primary {
for i := range vecSum {
vecSum[i] += float64(vec[i])
vecSum[i] += vec[i]
}
count++
}
@ -281,13 +276,11 @@ func (e *StaticEmbedder) load(spec string, primary bool) error {
for i := range vecSum {
vecSum[i] /= float64(count)
}
e.unkVec = make([]float32, dim)
for i, v := range vecSum {
e.unkVec[i] = float32(v)
}
e.unkVec = make([]float64, dim)
copy(e.unkVec, vecSum)
var normSq float64
for _, v := range e.unkVec {
normSq += float64(v) * float64(v)
normSq += v * v
}
e.unkNorm = float64(math.Sqrt(normSq))
e.loaded = true
@ -373,11 +366,11 @@ func (e *StaticEmbedder) Vectorize(text string) vector.Vector {
if !ok {
for i, v := range unkVec {
sum[i] += w * float64(v)
sum[i] += w * v
}
} else {
for i, v := range vec {
sum[i] += w * float64(v)
sum[i] += w * v
}
}
weightSum += w

View File

@ -1,37 +0,0 @@
package memory
import (
"testing"
"unsafe"
)
// 词向量必须用 float32 存。
//
// 这条判据是拿生产内存换来的:向量本体 = 词数 × 维数 × 每元素字节数。
// 生产配置加载了 200000(zh) + 378151(en) = 57.8 万词 × 300 维 ⇒
// float64 = 1.29GB、float32 = 0.65GB(差 0.65GB 常驻)。
// 源数据fastText 文本格式)本身就是 float32 精度,用 float64 存没有任何收益。
//
// 若有人把类型改回 float64本测试**编译失败**`var vec []float32` 的类型断言),
// 这正是想要的效果。
func TestStaticEmbedder_VectorMemIsFloat32(t *testing.T) {
e := newSynthEmbedder(t, 300)
words := 0
bytes := 0
for _, vec := range e.words {
var typed []float32 = vec // 编译期断言:存储必须是 []float32
if len(typed) != e.dim {
t.Fatalf("维度不符: %d != %d", len(typed), e.dim)
}
words++
bytes += len(typed) * int(unsafe.Sizeof(typed[0]))
}
if words == 0 {
t.Fatal("合成模型应至少加载一个词")
}
// float32每词 300×4 = 1200 字节float64 会是 2400
if want := words * e.dim * 4; bytes != want {
t.Fatalf("向量本体字节数应 %dfloat32实际 %d", want, bytes)
}
}

View File

@ -77,12 +77,6 @@ type Store struct {
docs []DocVector
dim int
index *InvertedIndex
// minScore 是候选分数下限。**必须按向量空间标定**
// 词向量/多模态余弦通常在 0.3~0.9,而 TF-IDF 余弦只有 0.0~0.2 ——
// 用同一个阈值会把词法路的大量有效候选静默砍掉
// (实测:知识库自检索 MRR 0.307 → 0.193 就是这么掉的)。
minScore float64
}
type DocVector struct {
@ -92,27 +86,9 @@ type DocVector struct {
Meta map[string]string
}
// DefaultMinScore 是默认候选中选阈值(沿用历史行为)。
const DefaultMinScore = 0.05
// MinScore 返回当前候选中选阈值(供接线处自证用的是哪个阈值)。
func (s *Store) MinScore() float64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.minScore
}
// SetMinScore 调整候选中选阈值(按向量空间标定,见 minScore 字段注释)。
func (s *Store) SetMinScore(v float64) {
s.mu.Lock()
defer s.mu.Unlock()
s.minScore = v
}
func NewStore() *Store {
return &Store{
index: NewInvertedIndex(),
minScore: DefaultMinScore,
index: NewInvertedIndex(),
}
}
@ -184,7 +160,7 @@ func (s *Store) SearchScored(query Vector, topK int) []DocVectorHit {
for _, d := range s.docs {
if d.ID == id {
score := CosineSimilarity(query, d.Vector)
if score > s.minScore {
if score > 0.05 {
results = append(results, scored{d, score})
}
break

View File

@ -210,29 +210,3 @@ func BenchmarkExtractNGrams(b *testing.B) {
extractNGrams(text, 2)
}
}
// 候选中选阈值必须**按向量空间标定**:词向量/多模态余弦通常在 0.3~0.9
// 而 TF-IDF 余弦只有 0.0~0.2。用同一个阈值会把词法路的有效候选静默砍掉
// (知识库自检索 MRR 0.307→0.193 就是这么掉的,且当时看不出任何报错)。
func TestSearchScoredRespectsMinScore(t *testing.T) {
// 构造一个低余弦候选:共享特征 "a",但两个向量几乎正交 → cosine ≈ 0.02
st := NewStore()
st.Insert("doc", "", Vector{"a": 1, "b": 1}, nil) // |doc| = √2
query := Vector{"a": 0.02, "c": 100} // 与 doc 的点积 0.02
hits := st.SearchScored(query, 10)
for _, h := range hits {
if h.Score < DefaultMinScore {
t.Fatalf("默认阈值 %.2f 不该返回 %.5f 的候选", DefaultMinScore, h.Score)
}
}
if len(hits) != 0 {
t.Fatalf("该查询在默认阈值下应被过滤,实际返回 %d 条", len(hits))
}
st.SetMinScore(0)
hits = st.SearchScored(query, 10)
if len(hits) != 1 || hits[0].Doc.ID != "doc" {
t.Fatalf("阈值设为 0 后应召回低余弦候选,实际 %+v", hits)
}
}

View File

@ -21,17 +21,10 @@ var (
// 新增的注入行为标志位InjectOptionsno_memory / context_policy
// 与 ChannelDef.ContextPolicy使输入/排队注入/中断注入/同步注入都能
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
// 1.3.0注入优先级贯通SDK 1.3.0 的 InjectOptions.Priority 与
// PriorityL1-L4——插件可声明自己中断的级别内核按四级中断阶梯调度
// 并实现 UnregisterOutputChannel使动态通道远程设备 device/<id>
// 能随资源生灭,而不是留下死通道骗模型。
//
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
// 1.3.5:系统提示词(人格卡)支持版本占位符 —— 人格卡是配置项,写死版本号
// 会随发版说谎(线上写 v1.0.3、内核 1.3.xagent 就自报 1.0.3)。
// 支持 {{kernel_version}} / {{kernel_commit}} / {{sdk_version}}。
Version = "1.3.5"
Version = "1.2.1"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -49,10 +42,7 @@ var (
// 注入变体、InjectOptions、ChannelDef.ContextPolicy因此声明为
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
// **插件调用、内核实现**,不调就不受影响,无需重编。
// 1.3.0:内核实现了 SDK 1.3.0 的全部新增面 —— InjectOptions.Priority 的
// 分级中断调度、PriorityL1-L4 常量的语义,以及 UnregisterOutputChannel
// 在通道登记表 + io 设备表上的注销。同样:不调新面就不受影响。
SDKCompatibleVersion = "1.3.0"
SDKCompatibleVersion = "1.2.0"
// SourceURL 是本内核构建所对应的源码地址。
//

View File

@ -1,43 +0,0 @@
package plugin
import (
"bytes"
"log"
"strings"
"testing"
)
// 延迟判定的语义:看的是"插件 Start 结束后最终声明了什么"
// 而不是"注册出站通道的那一刻有没有入站声明"。
//
// 为什么必须这样判:声明顺序自由 —— qq/weather 都是**先** RegisterOutputChannel
// **后** RegisterInputChannel按注册时刻判会把它们误报成"只声明了输出通道"
// (实测发生过:用户据此以为 qq 插件没更新)。
func TestWarnOutputOnlyChannels(t *testing.T) {
r := NewRegistry()
var buf bytes.Buffer
oldOut := log.Writer()
log.SetOutput(&buf)
defer log.SetOutput(oldOut)
// ① 出站+入站都声明了(先出站后入站)⇒ 不该告警
r.noteChannel("qq", "qq", true)
r.noteChannel("qq", "qq", false)
r.warnOutputOnlyChannels("qq")
if s := buf.String(); s != "" {
t.Fatalf("qq 声明了入站通道,不应告警,实际: %s", s)
}
// ② 只声明出站 ⇒ 应告警,且只报这一个通道
buf.Reset()
r.noteChannel("weather", "weather_weather_out", true)
r.noteChannel("weather", "weather_weather_in", false)
r.warnOutputOnlyChannels("weather")
out := buf.String()
if !strings.Contains(out, "weather_weather_out") {
t.Fatalf("只声明出站的通道应被告警,实际: %q", out)
}
if strings.Contains(out, "weather_weather_in") {
t.Fatalf("已声明入站的通道不该被牵连,实际: %q", out)
}
}

View File

@ -181,7 +181,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
return nil, err
}
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectInterrupt:
var p injectParams
@ -191,7 +191,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
return nil, err
}
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectTextNoMem:
var p injectParams
@ -202,7 +202,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
return nil, err
}
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName, p.Priority))
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectSync:
var p injectParams
@ -212,7 +212,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
return nil, err
}
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return map[string]interface{}{"reply": reply}, nil
case MethodIOInjectMedia:
@ -227,7 +227,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err != nil {
return nil, err
}
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
case MethodIOInjectMediaSync:
@ -242,7 +242,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err != nil {
return nil, err
}
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return map[string]interface{}{"reply": reply}, nil
case MethodIOInjectInterruptMedia:
@ -257,7 +257,7 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
if err != nil {
return nil, err
}
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName, p.Priority))
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
return nil, nil
// ---- 生命周期(原 case 8----
@ -695,8 +695,6 @@ type injectParams struct {
NoMemory bool `json:"no_memory,omitempty"`
ContextPolicy string `json:"context_policy,omitempty"`
CleanerName string `json:"cleaner_name,omitempty"`
// Priority 声明中断注入的优先级L1..L3L4 内核独占,见 InjectOptions。
Priority string `json:"priority,omitempty"`
}
// injectMediaParams 是带媒体注入/工具块注入的参数。
@ -717,35 +715,14 @@ type injectMediaParams struct {
NoMemory bool `json:"no_memory,omitempty"`
ContextPolicy string `json:"context_policy,omitempty"`
CleanerName string `json:"cleaner_name,omitempty"`
Priority string `json:"priority,omitempty"`
}
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
//
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
// 与旧三参数注入等价。
func pubSdkInjectOpts(noMemory bool, policy, cleanerName, priority string) pubsdk.InjectOptions {
return pubsdk.InjectOptions{
NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName,
Priority: clampExternalPriority(priority),
}
}
// clampExternalPriority 把外部插件声明的优先级夹到 L1..L3。
//
// 走本桥的必然是外部插件(独立进程/动态库),它们**不是内核级插件**
// 因此不能声明 L4——“立即打断”那类能力只属于编译期内置插件如 WebUI 终止按钮)。
//
// 为什么在这里夹而不是只在内核里按 source 判source 是插件自报的字段,
// 外部插件可以冒用 "webui" 之名;而本函数所在的位置能确知“这来自外部进程”。
// 内核侧的 isKernelLevelSource 是第二道闸(纵深防御)。
func clampExternalPriority(priority string) string {
switch priority {
case "L4", "l4":
return pubsdk.PriorityL3
default:
return priority
}
func pubSdkInjectOpts(noMemory bool, policy, cleanerName string) pubsdk.InjectOptions {
return pubsdk.InjectOptions{NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName}
}
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。

View File

@ -1,48 +0,0 @@
package proc
// 外部插件(走 proc 桥的独立进程/动态库)**不是内核级插件**
// 因此不能声明 L4 —— “立即打断”能力只属于编译期内置插件(如 WebUI 终止按钮)。
//
// 在这里夹取而不是只在内核里按 source 判,是因为 source 是插件自报字段、可以冒名;
// 本函数所在位置能确知“这来自外部进程”。内核侧的 isKernelLevelSource 是第二道闸。
import (
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func TestClampExternalPriority_RejectsL4(t *testing.T) {
cases := []struct {
in string
want string
}{
{"L4", pubsdk.PriorityL3}, // 越权 → 夹到 L3
{"l4", pubsdk.PriorityL3}, // 大小写都要夹
{"L3", pubsdk.PriorityL3},
{"L2", pubsdk.PriorityL2},
{"L1", pubsdk.PriorityL1},
{"", ""}, // 未声明保持空(内核按默认级处理)
{"紧急", "紧急"}, // 未知值原样传给内核,由内核降级为 L1 并留痕
{"L9", "L9"}, // 同上
}
for _, c := range cases {
if got := clampExternalPriority(c.in); got != c.want {
t.Fatalf("clampExternalPriority(%q)=%q期望 %q", c.in, got, c.want)
}
}
}
// 贯穿 pubSdkInjectOptsRPC 报文里的 priority 必须经过夹取才落到 InjectOptions。
func TestPubSdkInjectOpts_ClampsPriority(t *testing.T) {
got := pubSdkInjectOpts(true, "prune", "cleaner", "L4")
if got.Priority != pubsdk.PriorityL3 {
t.Fatalf("经桥后的优先级=%q期望 L3", got.Priority)
}
if !got.NoMemory || got.ContextPolicy != "prune" || got.CleanerName != "cleaner" {
t.Fatalf("其它字段被改动:%+v", got)
}
if l2 := pubSdkInjectOpts(false, "", "", "L2"); l2.Priority != pubsdk.PriorityL2 {
t.Fatalf("L2 应原样通过,实际 %q", l2.Priority)
}
}

View File

@ -303,40 +303,15 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
}); err != nil {
return err
}
// **兼容网**:插件应当显式 RegisterInputChannel 声明自己的入站通道;
// 但历史插件常常只用 RegisterOutputChannel 声明(却用同一个名字注入输入,
// 例cli 只声明输出 "cli" 就用 InjectTextSync("cli", ...) 注入)。
// 不兜底的话 inputch 登记表里没有它,"把 inputch 划给驻留子"直接失败
// (实测报 `划入 inputch cli: inputch 未注册`)。
//
// ❗这里**不能**判"是否声明过入站通道"并告警:声明顺序是自由的,
// 先 RegisterOutputChannel 再 RegisterInputChannel 是常见写法qq 就是),
// 按此刻的状态判会对它误报(实测:把 qq 报成"只声明了输出通道")。
// 真正该问的问题是"插件 Start 结束后,这个出站通道有没有对应的入站声明" ——
// 那在 load 完成后统一判(见 warnOutputOnlyChannels
if _, ok := r.iom.LookupInputChannel(chName); !ok {
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
}
r.noteChannel(name, chName, true)
return nil
}
regOutputUnreg := func(chName string) error {
if r.iom == nil {
return nil
}
r.iom.UnregisterDevice(chName)
r.forgetChannel(name, chName)
return nil
}
regInput := func(chName string, def sdk.ChannelDef) error {
if r.iom == nil {
return nil
}
// inputch 是最基本的输入路由单位:登记**归属插件**,便于父 agent 看清
// "哪个插件的哪个 inputch 划给了谁"(一个插件可注册多个 inputch
_ = r.iom.RegisterInputChannelFrom(name, chName, agentIO.ChannelDef(def))
r.iom.RegisterInputChannel(chName, agentIO.ChannelDef(def))
r.noteChannel(name, chName, false)
return nil
}
@ -346,19 +321,18 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
EventBus: r.evBus,
// 带 media 的包装:插件提交的三元组/文档/文本事件里的媒体会落进 CAS
// 并挂上引用。传入插件名仅用于日志溯源(哪个插件写的媒体)。
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
Knowledge: sdk.NewKnowledge(r.ks),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Settings: sett,
RegTool: regTool,
RegStage: regStage,
RegAPI: regAPI,
RegOutput: regOutput,
RegOutputUnreg: regOutputUnreg,
RegInput: regInput,
PluginMgr: r,
Memory: sdk.NewGraphMemoryWithMedia(name, r.memDB, r.mediaStore),
TextMemory: sdk.NewTextMemoryWithMedia(name, r.textMem, r.mediaStore),
DocMemory: sdk.NewDocMemoryWithMedia(name, r.docStore, r.mediaStore),
Knowledge: sdk.NewKnowledge(r.ks),
LLM: sdk.NewLLM(r.mgr, r.cfgReg, r.lua, r.baseKey),
Settings: sett,
RegTool: regTool,
RegStage: regStage,
RegAPI: regAPI,
RegOutput: regOutput,
RegInput: regInput,
PluginMgr: r,
Status: r.status,
Supervisor: r.sup,
@ -455,7 +429,6 @@ func (r *Registry) Load(dir string) error {
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.instances = append(r.instances, p)
r.mu.Unlock()
r.warnOutputOnlyChannels(name)
log.Printf("[plugin] loaded: %s", name)
}
@ -549,7 +522,6 @@ func (r *Registry) loadOne(plgDir, name string) bool {
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
r.sdkRefs[name] = plgSDK
r.instances = append(r.instances, plg)
r.warnOutputOnlyChannels(name)
if h := pluginEntryHash(plgDir); h != "" {
r.pluginHashes[name] = h
} else {
@ -584,50 +556,7 @@ func (r *Registry) stageRegistrarFor() (func(plugin string, stage sdk.Stage, han
return nil, false
}
// warnOutputOnlyChannels 在插件 Start 结束后,报告"只声明了出站、没有入站声明"的通道。
//
// 为什么放在 Start 之后:声明顺序自由(先出站后入站很常见),注册时刻的状态
// 判不出意图。这里看的是**插件最终声明了什么**,因此不会误报 qq 这种写法。
//
// 注:这类通道内核已兜底登记 inputch功能可用告警只是提醒插件作者把意图写明。
func (r *Registry) warnOutputOnlyChannels(plugin string) {
r.channelsMu.Lock()
set := r.pluginChannels[plugin]
var only []string
if set != nil {
for ch := range set.outputs {
if !set.inputs[ch] {
only = append(only, ch)
}
}
}
r.channelsMu.Unlock()
sort.Strings(only)
for _, ch := range only {
log.Printf("[plugin] %s 只声明了出站通道 %q未 RegisterInputChannel"+
"内核已兜底登记 inputch若这是有意为之可忽略", plugin, ch)
}
}
// noteChannel 记住插件注册了哪个通道,供卸载/崩溃时摘除。
// forgetChannel 把某个通道从"本插件注册过哪些通道"的记账里摘掉(注销通道时用)。
//
// 不摘的话 status 的 channels 列表与 input_channels 视图会一直列着已死通道,
// 模型会以为它还在(远程设备掉线后尤其明显)。
func (r *Registry) forgetChannel(plugin, channel string) {
if plugin == "" || channel == "" {
return
}
r.channelsMu.Lock()
defer r.channelsMu.Unlock()
set := r.pluginChannels[plugin]
if set == nil {
return
}
delete(set.outputs, channel)
delete(set.inputs, channel)
}
func (r *Registry) noteChannel(plugin, channel string, output bool) {
if plugin == "" || channel == "" {
return

View File

@ -221,8 +221,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.notifyInterval = DefaultNotifyInterval
}
// agentcli 通道:终端生命周期/输出事件经它注入 agent见本文件 InjectText* 调用)。
_ = s.RegisterInputChannel("agentcli", sdk.ChannelDef{})
s.RegisterTool("terminal_create", sdk.ToolDef{
Name: "terminal_create",
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +

View File

@ -192,9 +192,6 @@ func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp *
caps = 1
}
desc := fmt.Sprintf("OC channel %s (from %s)", chName, pn)
// 通道名来自外部 OC 配置(动态):声明处**同时**登记 inputch ——
// 该通道既收InjectInputSync 见 plugin.go又发output_send__<通道>)。
_ = s.RegisterInputChannel(chName, sdk.ChannelDef{})
s.RegisterOutputChannel(chName, caps, desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
return sp.CallTool(chName, args)
})

View File

@ -59,9 +59,6 @@ func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
// inputch 先登记:本插件既用 "cli" 作输出目标,也用它注入输入(终端行)。
// 输入侧必须显式登记,否则"把 inputch 划给驻留子"会找不到它。
_ = s.RegisterInputChannel("cli", sdk.ChannelDef{})
s.RegisterOutputChannel("cli", 1, "CLI 终端", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
if payload != "" {

View File

@ -1,196 +0,0 @@
//go:build linux || darwin
package plugins
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
)
// forbidStoppingSharedBackend 给测试实例关上「退出时停后端」这道闸。
//
// 本机 127.0.0.1:8888 的 SearXNG 是**线上服务在用的**。插件侧已保证「只关自己拉起的实例」,
// 这里再加一道:即便这个测试实例真的把它拉起来了,退出时也不关 —— 否则跑一次测试就断一次
// 线上搜索实测被这条坑过E2E 临时目录里的插件实例在 teardown 时 `docker compose stop`
// 表现为「搜索后端起不来」)。
func forbidStoppingSharedBackend(t *testing.T, env *testPluginEnv) {
t.Helper()
if env.cfgReg == nil {
t.Fatal("测试环境没有配置注册表,无法关闭后端托管")
}
ps := env.cfgReg.PluginConfig("deepsearch")
// 配置表只在 RegisterDef 时创建(内核有意为之:读写不隐式建表)。
// 插件启动时才会注册自己的 def而我们要在那之前写入所以这里先注册这个键。
ps.RegisterDef(internalConfig.ConfigDef{Key: "stop_searxng_on_exit", Type: "bool", Default: "true"})
if err := ps.Set("stop_searxng_on_exit", "false"); err != nil {
t.Fatalf("写 stop_searxng_on_exit=false 失败: %v", err)
}
}
// deepsearch 插件的真实调用往返:内核 ExecuteTool → RPC → 插件子进程 → 本地 SearXNG → 结果回传。
//
// 与 TestRealPlugin_ToolInvokeRoundTrip 的区别:那条只断言「链路通(拿到结果或拿到插件侧的错误)」,
// 这条断言**内容形状**——返回里必须有「摘要」与「引擎覆盖度」。这正是旧实现(抓 Bing HTML
// 拿不到的东西,也是「搜索能力不行」的根因,所以它必须成为回归判据。
//
// 前置:本机 127.0.0.1:8888 上跑着 SearXNG部署见 /root/searxng-agent
// 未启动时 fail 并给出可操作提示,而不是 skip —— 否则这条判据会在环境退化时静默失效。
func TestRealPlugin_DeepSearchInvoke(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
plgDir := filepath.Join(env.tmpDir, "plugins")
installRealPlugin(t, plgDir, "deepsearch")
forbidStoppingSharedBackend(t, env)
if err := env.pluginReg.Load(plgDir); err != nil {
t.Fatalf("加载插件: %v", err)
}
if env.pluginReg.Get("deepsearch") == nil {
t.Fatal("deepsearch 未经 proc 通道加载")
}
var toolName string
for _, def := range env.stageHost.GetToolDefs() {
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_search") {
toolName = def.Name
break
}
}
if toolName == "" {
t.Fatal("deepsearch 未注册检索工具")
}
t.Logf("调用工具 %s", toolName)
// 用当初失败的那条查询做判据
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{
"query": "深度科技 deepin 开发者 被开除",
"count": float64(3),
})
if err != nil {
if strings.Contains(err.Error(), "not found in any plugin") {
t.Fatalf("工具未注册到 stageHost: %v", err)
}
t.Fatalf("工具调用失败(检查本机 SearXNG 是否在 127.0.0.1:8888 运行): %v", err)
}
if res == nil {
t.Fatal("工具返回 nil 且无错误")
}
text := fmt.Sprintf("%v", res)
t.Logf("工具返回前 500 字:\n%s", truncRunes(text, 500))
if !strings.Contains(text, "摘要:") {
t.Errorf("返回内容缺少摘要——这正是旧实现拿不到的部分:\n%s", truncRunes(text, 800))
}
if !strings.Contains(text, "覆盖:") {
t.Errorf("返回内容缺少引擎覆盖度(模型据此判断可信度):\n%s", truncRunes(text, 800))
}
if !strings.Contains(text, "http") {
t.Errorf("返回内容缺少结果链接:\n%s", truncRunes(text, 800))
}
}
// deepsearch_status 也走一遍真实调用:它把「后端是否可用、哪些引擎在出结果」暴露成工具,
// 出问题时 agent 可以先自检,而不是盲目换词重搜。
func TestRealPlugin_DeepSearchStatusInvoke(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
plgDir := filepath.Join(env.tmpDir, "plugins")
installRealPlugin(t, plgDir, "deepsearch")
forbidStoppingSharedBackend(t, env)
if err := env.pluginReg.Load(plgDir); err != nil {
t.Fatalf("加载插件: %v", err)
}
var toolName string
for _, def := range env.stageHost.GetToolDefs() {
if strings.HasPrefix(def.Name, "deepsearch") && strings.HasSuffix(def.Name, "_status") {
toolName = def.Name
break
}
}
if toolName == "" {
t.Fatal("deepsearch 未注册自检工具")
}
res, err := env.stageHost.ExecuteTool(toolName, map[string]interface{}{"probe": "test"})
if err != nil {
t.Fatalf("自检调用失败(检查本机 SearXNG 是否运行): %v", err)
}
text := fmt.Sprintf("%v", res)
t.Logf("自检返回:%s", truncRunes(text, 400))
for _, want := range []string{"healthz", "search_ok", "engines_returning_results"} {
if !strings.Contains(text, want) {
t.Errorf("自检结果缺少字段 %q%s", want, truncRunes(text, 500))
}
}
}
func truncRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// 插件的停止**不得带走共享后端**:这是「搜索后端起不来」的直接回归判据。
// 判据落在网络层healthz 仍然 200而不是「有没有执行 docker 命令」。
func TestRealPlugin_DeepSearchKeepsSharedBackendOnStop(t *testing.T) {
env := setupIntegration(t)
defer env.cleanup()
requireSearxngUp(t)
plgDir := filepath.Join(env.tmpDir, "plugins")
installRealPlugin(t, plgDir, "deepsearch")
forbidStoppingSharedBackend(t, env)
if err := env.pluginReg.Load(plgDir); err != nil {
t.Fatalf("加载插件: %v", err)
}
if env.pluginReg.Get("deepsearch") == nil {
t.Fatal("deepsearch 未加载")
}
// 停掉这个插件实例(相当于测试 teardown / 另一个 daemon 退出)
if err := env.pluginReg.StopAndUnload("deepsearch"); err != nil {
t.Fatalf("停止插件失败: %v", err)
}
// 给「如果它真去关了」留出执行窗口stop -t 2 最多 2 秒
deadline := time.Now().Add(6 * time.Second)
for time.Now().Before(deadline) {
if !searxngHealthy() {
t.Fatalf("插件停止把共享后端带走了healthz 不再是 200—— 线上搜索会因此不可用")
}
time.Sleep(500 * time.Millisecond)
}
t.Log("插件已停止,共享后端仍在服务")
}
// requireSearxngUp 前置检查:后端不在时 fail 并给出可操作提示(不 skip避免环境退化时静默失效
func requireSearxngUp(t *testing.T) {
t.Helper()
if !searxngHealthy() {
t.Fatal("本机 127.0.0.1:8888 的 SearXNG 不可用;先 `cd /root/searxng-agent && docker compose up -d`")
}
}
func searxngHealthy() bool {
cl := &http.Client{Timeout: 3 * time.Second}
resp, err := cl.Get("http://127.0.0.1:8888/healthz")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}

View File

@ -28,7 +28,6 @@ type testPluginEnv struct {
memDB *memory.GraphDB
ks *knowledge.Store
docStore *doc.Store
cfgReg *internalConfig.ConfigRegistry
}
func setupIntegration(t *testing.T) *testPluginEnv {
@ -102,7 +101,6 @@ func setupIntegrationWithProvider(t *testing.T, pm *agentAPI.ProviderManager) *t
memDB: memDB,
ks: ks,
docStore: docStore,
cfgReg: cfgReg,
}
}

View File

@ -138,39 +138,28 @@ func (p *Plugin) Stop() error {
func (p *Plugin) registerTools(s *sdk.PluginSDK) {
s.RegisterTool("plugin_install", sdk.ToolDef{
Name: "plugin_install",
Description: "安装 HomeAgent 插件包(.hmap。两种来源urlhttp/https 下载)或 path本机路径配合 plugindev_build 的产物用这个)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Description: "从 URL 安装 HomeAgent 插件包(.hmap 文件)。插件已存在时传 overwrite=true 原地更新(升级/降级/重装,保留配置表,无需卸载重装)。更新后需调用 plgreload 或重启生效。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "插件包的下载 URLhttp/https",
},
"path": map[string]interface{}{
"type": "string",
"description": "插件包在**本机**的路径(.hmap。与 url 二选一;同时给出时以 path 为准",
"description": "插件包的下载 URL",
},
"overwrite": map[string]interface{}{
"type": "boolean",
"description": "已存在时原地更新(保留配置)。默认 false",
},
},
"required": []string{"url"},
},
}, func(args map[string]interface{}) (interface{}, error) {
overwrite, _ := args["overwrite"].(bool)
// path 优先:它对应"agent 自己构建出产物再装"的场景plugindev_build → plugin_install
if path, _ := args["path"].(string); strings.TrimSpace(path) != "" {
pth := strings.TrimSpace(path)
if st, err := os.Stat(pth); err != nil || st.IsDir() {
return map[string]interface{}{"error": fmt.Sprintf("path 无效(必须是存在的 .hmap 文件): %s", pth)}, nil
}
return p.installFromPath(pth, overwrite)
}
url, _ := args["url"].(string)
if strings.TrimSpace(url) == "" {
return map[string]interface{}{"error": "需要 url 或 path二选一"}, nil
if url == "" {
return map[string]interface{}{"error": "url is required"}, nil
}
return p.installFromURL(strings.TrimSpace(url), overwrite)
overwrite, _ := args["overwrite"].(bool)
return p.installFromURL(url, overwrite)
})
s.RegisterTool("plugin_list", sdk.ToolDef{
@ -466,12 +455,12 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
if existing && !overwrite {
return map[string]interface{}{
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"current": oldVersion,
"action": "remove_first",
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
"error": "plugin already exists",
"name": pkg.Name,
"version": pkg.Version,
"current": oldVersion,
"action": "remove_first",
"hint": `传 "overwrite": true 可原地更新(保留配置)`,
}, nil
}
@ -486,7 +475,7 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
os.RemoveAll(backup)
if err := os.Rename(target, backup); err != nil {
return map[string]interface{}{
"error": "backup old plugin dir failed",
"error": "backup old plugin dir failed",
"details": err.Error(),
}, nil
}
@ -495,8 +484,8 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
os.RemoveAll(target)
if rbErr := os.Rename(backup, target); rbErr != nil {
return map[string]interface{}{
"error": "extract failed AND rollback failed",
"details": err.Error(),
"error": "extract failed AND rollback failed",
"details": err.Error(),
"rollback": rbErr.Error(),
}, nil
}
@ -518,15 +507,15 @@ func (p *Plugin) installFromData(data []byte, overwrite bool) (interface{}, erro
action = "reinstalled"
}
return map[string]interface{}{
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"status": "installed",
"name": pkg.Name,
"version": pkg.Version,
"previous_version": oldVersion,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
"entry": pkg.Entry,
"checksum": checksum,
"action": action,
"reload_required": true,
"config_kept": true,
}, nil
}

View File

@ -3,7 +3,6 @@
package plugins
import (
"bytes"
"fmt"
"os"
"os/exec"
@ -27,47 +26,6 @@ import (
// 前置:插件需已用新版 hmapdev 重编scripts/rebuild-plugins.sh
// 未重编时测试 skip 而非 fail——CI 上不强制要求先跑重编脚本。
// hostExecutableKind 按魔数判断产物是不是**本机**可执行文件,返回可读格式名。
//
// 为何需要以前只按文件名找候选plugin.bin_<os>_<arch> → plugin.bin拿到
// darwin/windows 产物就直接 exec报的是 "exec format error"——看起来像插件坏了,
// 实际上只是**开发环境里的产物平台不对**。本轮就踩了:跨平台示例构建把
// build/plugin.bin 覆盖成 Mach-O arm64internal/plugins 6 个测试全红,排查半小时。
func hostExecutableKind(path string) (string, bool) {
f, err := os.Open(path)
if err != nil {
return "无法读取", false
}
defer f.Close()
head := make([]byte, 4)
n, _ := f.Read(head)
if n < 2 {
return "文件过短", false
}
switch runtime.GOOS {
case "linux":
if n >= 4 && head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' {
return "ELF本机", true
}
if n >= 4 && head[0] == 0xcf && head[1] == 0xfa && head[2] == 0xed && head[3] == 0xfe {
return "Mach-O 64 little-endianmacOS不是本机", false
}
if n >= 2 && head[0] == 'M' && head[1] == 'Z' {
return "PEWindows不是本机", false
}
return "未知格式(不是本机可执行文件)", false
case "darwin":
for _, m := range [][]byte{{0xcf, 0xfa, 0xed, 0xfe}, {0xca, 0xfe, 0xba, 0xbe}} {
if n >= 4 && bytes.Equal(head, m) {
return "Mach-O本机", true
}
}
return "不是本机可执行文件", false
default:
return "未知平台(默认放行)", true
}
}
// realPluginDir 返回某个 example 插件的 linux 产物路径。
func realPluginBinary(t *testing.T, name string) string {
t.Helper()
@ -81,14 +39,9 @@ func realPluginBinary(t *testing.T, name string) string {
filepath.Join(root, "build", "plugin.bin"),
}
for _, c := range candidates {
if st, err := os.Stat(c); err != nil || st.IsDir() {
continue
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
if kind, ok := hostExecutableKind(c); !ok {
t.Skipf("插件 %s 的产物 %s 不是本机可执行格式(%s重建cd %s && hmapdev build --target %s/%s --no-bundle",
name, c, kind, root, runtime.GOOS, runtime.GOARCH)
}
return c
}
t.Skipf("插件 %s 未重编(先跑 scripts/rebuild-plugins.sh", name)
return ""

View File

@ -182,9 +182,6 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
switch tool {
case "output":
// 出站:见 outputch.go。声明了 CapStructured 就必须真的能收输出。
return d.output(args)
case "devicedetect":
return d.detect(args)
case "device_ctl_status":

View File

@ -1,318 +0,0 @@
package remotedevice
// 设备输出通道:把"agent 主动发给设备"做成**每设备一个输出通道** `device-<id>`。
//
// 为什么是输出通道而不是再加一批工具:
// - **寻址**`output_send__device-<id>` 直接指名道姓;模型看 `output_list_channels`
// 就知道当前有哪些设备在线,不必先 `devicedetect` 再往参数里塞 device_id。
// - **能力**caps 由设备声明的 caps 映射,**内核**在发送前就按 caps 拦
// (把图片发给只支持文本的音箱会被拒,而不是等设备侧报错)。
// - **授权**`AllowedOutputs` 是内核级的授权闸(`executeOutputSendTool` 里先查
// `IsOutputAllowed`)。父 agent 因此可以"只授权某一台设备"给驻留子 ——
// 这在工具模型下做不到(拿到 `device_ctl_cmdrun` 就能对任意设备下指令)。
//
// 而 `screensee`/`computeruse`/`device_ctl_*` 这类**请求-响应**仍留作工具:
// 它们的返回值(图像/命令输出/状态)必须进模型上下文,做成通道会丢掉这个语义。
import (
"crypto/sha1"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// deviceOutputCaps 把设备声明的 caps 映射成输出通道能力位。
//
// 映射依据(与 registry.go 的 capabilityTools/compatFullCaps 同一套词表):
// - 文本:任何设备都收(消息/指令都是文本)→ 总是 CapText
// - 结构化能跑命令cmd/cmdrun/cmdresult 视为历史全能力)→ 能渲染结构化结果
// - 音频speaker/speakeruse或 kind=speaker
// - 图片/文件有屏screen/screensue/screensee/gui、剪切板、摄像头或 kind 是带屏设备
// - **未声明任何已知能力** → 视为全能力(与 deviceSupportsTool 的旧设备兼容规则一致)
func deviceOutputCaps(caps []string, kind string) agentIO.OutputCapability {
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
out := agentIO.CapText
known := false
for _, c := range caps {
switch c {
case "cmd", "cmdrun", "cmdresult":
// 历史"全能力"标记:这类设备能跑命令、能收结构化结果。
return full
case "screen", "screensue", "screensee", "gui", "display":
known = true
out |= agentIO.CapImage | agentIO.CapFile
case "clipboard", "clipboardsee", "clipboardsue":
known = true
out |= agentIO.CapFile
case "camera", "camerasue":
known = true
out |= agentIO.CapImage | agentIO.CapFile
case "speaker", "speakeruse", "audio":
known = true
out |= agentIO.CapAudio
case "computeruse":
known = true
out |= agentIO.CapStructured
}
}
// kind 兜底:带屏设备即便没声明 caps也能收图和文件。
switch kind {
case "computer", "phone", "tablet", "tv":
known = true
out |= agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured
case "speaker":
known = true
out |= agentIO.CapAudio
}
if !known {
return full // 旧设备兼容:未声明已知能力 ⇒ 全能力
}
return out
}
// deviceChannelName 由**设备自报的 id** 派生一个合规且唯一的通道名:`device-<派生值>`。
//
// 入站与出站**同名**:两者指的是同一台设备,分成两个名字只会让模型与授权表更难对。
//
// 为什么不能直接用 id通道名会被内核拼进 LLM 的**函数名**`output_send__<通道名>`
// 上游规范是 `^[a-zA-Z0-9_-]{1,64}$`;而设备 id 是**外部输入**(设备自己声明),
// 可能含空格/非 ASCII/超长。违规的后果不是"这个工具不能用",而是**整条请求被 400 拒绝** ——
// 实测把生产打挂:`Invalid 'tools[299].function.name'`,网关 auto tier 全链条失败,
// 内核只能报"所有 provider 都失败",表现成"整个 agent 不说话了"。
//
// 派生规则(确定性,同一 id 永远同名):
// 1. 保留 [A-Za-z0-9_-],其它字符折成 '-';折叠后为空则用 "dev"
// 2. 截断到 maxDeviceChannelSuffix 字符(给 "device-" 与短哈希留余量)
// 3. 若发生截断,或该名字已被**另一个** id 占用,则追加 id 的 6 位短哈希
//
// 设备 id 本身仍用于路由与日志(真名不丢),通道名只是它派生的标识符。
func (p *Plugin) deviceChannelName(id string) string {
p.devChansMu.Lock()
defer p.devChansMu.Unlock()
if p.devChans == nil {
p.devChans = make(map[string]string)
}
if name, ok := p.devChans[id]; ok {
return name
}
var b strings.Builder
for _, r := range id {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
b.WriteRune(r)
default:
b.WriteByte('-')
}
}
base := b.String()
if base == "" {
base = "dev"
}
truncated := false
if len(base) > maxDeviceChannelSuffix {
base = base[:maxDeviceChannelSuffix]
truncated = true
}
name := "device-" + base
// 撞名检查:不同 id 折出同一个名字时必须可区分
for otherID, otherName := range p.devChans {
if otherName == name && otherID != id {
truncated = true
break
}
}
if truncated {
sum := sha1.Sum([]byte(id))
name += "-" + hex.EncodeToString(sum[:3])
}
p.devChans[id] = name
return name
}
const (
// maxDeviceChannelSuffix 是通道名主体的长度上限。
// 预算:上游函数名上限 64 = "output_send__"(13) + "device-"(7) + 主体 + "-"+短哈希(7)
// ⇒ 主体最多 37取 32 留余量(改名/前缀变动不会立刻越界)。
maxDeviceChannelSuffix = 32
)
// wireDeviceChannels 把"设备上下线"接到通道的登记/注销上。
//
// 一台设备 = 一对**同名**通道 `device-<id>`:入站(设备上报 → agent与出站
// agent → 设备)。用**同步回调**而不是 ChangeChan后者是 select+default
// 缓冲满会丢事件;丢一次就留下死通道或漏注册)。
//
// 抽成方法而不是内联在 Start 里:测试要能走**同一条**接线,
// 否则测试自己塞 handlerStart 忘了接线也照样绿。
func (p *Plugin) wireDeviceChannels() {
p.registry.SetPresenceHandler(
func(meta DeviceMeta) {
_ = p.sdk.RegisterInputChannel(p.deviceChannelName(meta.DeviceID), sdk.ChannelDef{})
p.ensureDeviceOutputChannel(meta.DeviceID)
},
func(id string) { p.dropDeviceOutputChannel(id) },
)
}
// ensureDeviceOutputChannel 给在线设备注册输出通道 device-<id>(幂等)。
func (p *Plugin) ensureDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" {
return
}
meta, ok := p.registry.Get(id)
if !ok || !meta.Online {
return
}
ch := p.deviceChannelName(id)
caps := deviceOutputCaps(meta.Caps, meta.Kind)
desc := fmt.Sprintf("远程设备 %s%sagent 主动向该设备发送内容;能力位 %s",
id, fallback(meta.Name, meta.Kind), agentIO.OutputCapability(caps).String())
// 重复注册是安全的:芯片侧 Register 会合并owner/capacity 取旧值)。
if err := p.sdk.RegisterOutputChannel(ch, int(caps), desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
return pushToDevice(p.registry, id, args)
}); err != nil {
p.logf("register output channel %s: %v", ch, err)
return
}
p.logf("device %s online → 输出通道 %scaps=%s", id, ch, agentIO.OutputCapability(caps).String())
}
// dropDeviceOutputChannel 设备下线时注销它的输出通道。
//
// 不注销的后果:`output_list_channels` 一直列着它,模型会往死通道发消息,
// 拿到的却只是"发送已提交"之类的假回执。
func (p *Plugin) dropDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" {
return
}
ch := p.deviceChannelName(id)
if err := p.sdk.UnregisterOutputChannel(ch); err != nil {
p.logf("unregister output channel %s: %v", ch, err)
return
}
p.logf("device %s offline → 注销输出通道 %s", id, ch)
}
// pushToDevice 把一次 output_send 的 {payload,type,meta} 转成下行帧发给设备。
//
// 线上格式(新增 op=push与既有 op=cmd/cmd_speech_* 并列):
//
// {"op":"push","req_id":"...","type":"text|image|file|audio|structured","payload":"...","meta":"..."}
//
// 大负载data URL 形式的图片/音频/文件)走既有分块通道 PushData
// 避免把 base64 塞进一个超大文本帧。
func pushToDevice(reg *Registry, id string, args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
typ, _ := args["type"].(string)
metaStr, _ := args["meta"].(string)
if payload == "" {
return nil, fmt.Errorf("payload 不能为空")
}
if typ == "" {
typ = "text"
}
reqID := fmt.Sprintf("push_%d", time.Now().UnixNano())
if data, mime, ok := decodeDataURL(payload); ok && typ != "text" && typ != "structured" {
if err := reg.PushData(id, reqID, typ, mime, data); err != nil {
return nil, err
}
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
}
frame := map[string]interface{}{
"op": "push",
"req_id": reqID,
"type": typ,
"payload": payload,
}
if metaStr != "" {
frame["meta"] = metaStr
}
if err := reg.PushJSON(id, frame); err != nil {
return nil, err
}
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
}
// decodeDataURL 解析 data:<mime>;base64,<data> 形式的内联负载。
func decodeDataURL(s string) (data []byte, mime string, ok bool) {
if !strings.HasPrefix(s, "data:") {
return nil, "", false
}
rest := strings.TrimPrefix(s, "data:")
comma := strings.Index(rest, ",")
if comma < 0 {
return nil, "", false
}
head, body := rest[:comma], rest[comma+1:]
if !strings.HasSuffix(head, ";base64") {
return nil, "", false
}
mime = strings.TrimSuffix(head, ";base64")
b, err := base64.StdEncoding.DecodeString(body)
if err != nil {
return nil, "", false
}
return b, mime, true
}
func fallback(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func (p *Plugin) logf(format string, a ...interface{}) {
log.Printf("[remotedevice] "+format, a...)
}
// output 是**聚合通道** `devicectl` 的出站实现(每设备通道之外的另一条路)。
//
// 历史状态devicectlDevice 一直声明 OutputCapabilities=CapStructured
// 但 Execute 里根本没有 "output" 分支 ⇒ `output_send__devicectl` 必然报
// "unknown device tool output"。这里把它补实:按 meta/device_id 指到具体设备。
//
// 寻址方式(两者都收,模型的写法越少歧义越好):
// - args.meta 是 JSON 且含 device_id{"device_id":"phone-1"}
// - args.meta 直接就是设备 idphone-1
// - args.device_id
//
// 留空则返回**可执行**的提示(列出在线设备),而不是含糊报错 —— 模型据此重试。
func (d *devicectlDevice) output(args map[string]interface{}) (interface{}, error) {
deviceID, _ := args["device_id"].(string)
if deviceID == "" {
if metaStr, _ := args["meta"].(string); metaStr != "" {
var m map[string]interface{}
if json.Unmarshal([]byte(metaStr), &m) == nil {
deviceID, _ = m["device_id"].(string)
if deviceID == "" {
deviceID, _ = m["device"].(string)
}
}
if deviceID == "" {
deviceID = strings.TrimSpace(metaStr)
}
}
}
if deviceID == "" {
ids := []string{}
for _, m := range d.reg.OnlineList() {
ids = append(ids, m.DeviceID)
}
if len(ids) == 0 {
return nil, fmt.Errorf("devicectl 需要 meta.device_id 才能投递当前没有在线设备device_list_channels 可看每台设备的 device-<id> 通道)")
}
return nil, fmt.Errorf("devicectl 需要 meta.device_id或直接用通道 device-<id>);当前在线设备: %s", strings.Join(ids, ", "))
}
return pushToDevice(d.reg, deviceID, args)
}

View File

@ -1,287 +0,0 @@
package remotedevice
// 设备输出通道outputch.go的测试
// - caps 映射词表
// - 上下线 → 通道登记/注销 + push 真能落到设备(走真 WS 帧)
// - 聚合通道 devicectl 的出站寻址
import (
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"sync"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestDeviceOutputCapsMapping(t *testing.T) {
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
cases := []struct {
name string
caps []string
kind string
want agentIO.OutputCapability
}{
{"未声明已知能力 ⇒ 全能力(旧设备兼容)", nil, "", full},
{"未知 caps ⇒ 全能力", []string{"whatever"}, "", full},
{"cmd ⇒ 历史全能力", []string{"cmd"}, "", full},
{"speaker ⇒ 文本+音频", []string{"speaker"}, "", agentIO.CapText | agentIO.CapAudio},
{"screen ⇒ 文本+图+文件", []string{"screen"}, "", agentIO.CapText | agentIO.CapImage | agentIO.CapFile},
{"clipboard ⇒ 文本+文件", []string{"clipboard"}, "", agentIO.CapText | agentIO.CapFile},
{"kind=computer 兜底(未声明 caps", nil, "computer", agentIO.CapText | agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured},
{"kind=speaker 兜底", nil, "speaker", agentIO.CapText | agentIO.CapAudio},
}
for _, c := range cases {
if got := deviceOutputCaps(c.caps, c.kind); got != c.want {
t.Errorf("%s: deviceOutputCaps(%v,%q)=%s期望 %s", c.name, c.caps, c.kind, got, c.want)
}
}
}
// channelRecorder 记录通道注册/注销。
//
// 必须加锁:注册/注销发生在设备 WS 的处理 goroutine上下线回调
// 而测试在主线读 —— 裸 map/slice 会被 -race 抓住(第一版就是这么被抓住的)。
type channelRecorder struct {
mu sync.Mutex
registered map[string]int
unregistered []string
}
func (r *channelRecorder) caps(name string) (int, bool) {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.registered[name]
return c, ok
}
func (r *channelRecorder) unregList() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.unregistered...)
}
// recordingSDK 造一个能记账的 PluginSDK通道注册/注销都落到 recorder 里。
func recordingSDK(t *testing.T, iom *agentIO.IOManager, rec *channelRecorder) *sdk.PluginSDK {
t.Helper()
return sdk.New("remotedevice", sdk.SDKConfig{
IOManager: iom,
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, handler sdk.ToolHandler) error {
rec.mu.Lock()
rec.registered[name] = caps
rec.mu.Unlock()
// 通道 handler 也要真的可调用 —— 记进 iom 才能从外面触发。
return iom.RegisterDevice(&recordingDevice{name: name, handler: handler})
},
RegOutputUnreg: func(name string) error {
rec.mu.Lock()
rec.unregistered = append(rec.unregistered, name)
rec.mu.Unlock()
iom.UnregisterDevice(name)
return nil
},
RegInput: func(name string, def sdk.ChannelDef) error {
iom.RegisterInputChannel(name, agentIO.ChannelDef(def))
return nil
},
})
}
// recordingDevice 把插件注册的输出通道在 io 层落地,便于用 Execute("output") 触发。
type recordingDevice struct {
name string
handler sdk.ToolHandler
}
func (d *recordingDevice) Name() string { return d.name }
func (d *recordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *recordingDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
func (d *recordingDevice) Description() string { return "recording device" }
func (d *recordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
func (d *recordingDevice) Start() error { return nil }
func (d *recordingDevice) Stop() error { return nil }
func (d *recordingDevice) Tools() []agentIO.ToolDef { return nil }
func (d *recordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return d.handler(args)
}
func TestDeviceChannelLifecycleAndPush(t *testing.T) {
reg := NewRegistry()
token := "tk"
reg.SetAcceptToken(func(p string) bool { return p == token })
iom := agentIO.NewIOManager()
rec := &channelRecorder{registered: map[string]int{}}
p := &Plugin{registry: reg}
p.sdk = recordingSDK(t, iom, rec)
p.wireDeviceChannels() // 走 Start 的同一条接线
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
// 设备上线caps=speaker ⇒ 通道能力应为 文本+音频)
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"spk-1","name":"音箱","kind":"speaker","caps":["speaker"]}}`))
cli.readHelloAckAndBind(t, token)
ch := p.deviceChannelName("spk-1")
deadline := time.Now().Add(3 * time.Second)
caps, ok := rec.caps(ch)
for !ok && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
caps, ok = rec.caps(ch)
}
if !ok {
t.Fatalf("设备上线后应注册输出通道 %s实际: %v", ch, rec.unregList())
}
if want := int(agentIO.CapText | agentIO.CapAudio); caps != want {
t.Fatalf("通道能力位应为 文本+音频(%d),实际 %d", want, caps)
}
// 入站 inputch 同名登记(父 agent 才能把"这台设备"划给驻留子)
if _, ok := iom.LookupInputChannel(ch); !ok {
t.Fatalf("设备上线后应同时登记同名 inputch %s", ch)
}
// 触发一次出站:走 io 层的 output 分发(与 output_send__<通道> 同一条路)
dev := iom.GetDevice(ch)
if dev == nil {
t.Fatalf("输出通道 %s 未在 io 层注册", ch)
}
if _, err := dev.Execute("output", map[string]interface{}{"payload": "你好,设备", "type": "text"}); err != nil {
t.Fatalf("向设备发送失败: %v", err)
}
// 设备侧应收到 op=push 的帧
got := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var m map[string]interface{}
if json.Unmarshal(payload, &m) == nil {
got <- m
}
}()
select {
case m := <-got:
if m["op"] != "push" {
t.Fatalf("设备应收到 op=push实际 %v", m)
}
if m["payload"] != "你好,设备" || m["type"] != "text" {
t.Fatalf("push 帧内容不符: %v", m)
}
case <-time.After(3 * time.Second):
t.Fatal("设备未收到 push 帧")
}
// 设备下线 ⇒ 注销通道(不留死通道)
cli.close()
deadline = time.Now().Add(3 * time.Second)
for len(rec.unregList()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if got := rec.unregList(); len(got) == 0 || got[0] != ch {
t.Fatalf("设备下线后应注销 %s实际 %v", ch, got)
}
if iom.GetDevice(ch) != nil {
t.Fatalf("注销后 io 层不应还有 %s", ch)
}
}
func TestDevicectlAggregateOutputAddressing(t *testing.T) {
reg := NewRegistry()
token := "tk2"
reg.SetAcceptToken(func(p string) bool { return p == token })
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
defer srv.Close()
dev := &devicectlDevice{reg: reg}
// ① 没指定设备 ⇒ 报错要**可执行**(列出在线设备),而不是含糊失败
if _, err := dev.Execute("output", map[string]interface{}{"payload": "x", "type": "text"}); err == nil {
t.Fatal("无 device_id 时应报错")
}
cli := dialTestWS(t, srv.URL, token)
defer cli.close()
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"pc-1","name":"PC","kind":"computer","caps":["cmd"]}}`))
cli.readHelloAckAndBind(t, token)
deadline := time.Now().Add(3 * time.Second)
for !reg.Online("pc-1") && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if !reg.Online("pc-1") {
t.Fatal("设备未上线")
}
// ② meta 是 JSON 且含 device_id ⇒ 投递到该设备
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "meta": `{"device_id":"pc-1"}`,
}); err != nil {
t.Fatalf("按 meta.device_id 投递失败: %v", err)
}
m := make(chan map[string]interface{}, 1)
go func() {
_, payload, err := cli.readMsg()
if err != nil {
return
}
var got map[string]interface{}
if json.Unmarshal(payload, &got) == nil {
m <- got
}
}()
select {
case got := <-m:
if got["op"] != "push" {
t.Fatalf("应为 push 帧,实际 %v", got)
}
case <-time.After(3 * time.Second):
t.Fatal("聚合通道未投递到设备")
}
// ③ 设备在线但指定了不存在的设备 ⇒ 报错online 列表里有 pc-1
if _, err := dev.Execute("output", map[string]interface{}{
"payload": "hi", "type": "text", "device_id": "ghost",
}); err == nil {
t.Fatal("不存在的设备应报错")
}
}
// 通道名合规性:设备通道名会被内核拼进 LLM **函数名**output_send__<通道名>
// 而上游函数名规范是 ^[a-zA-Z0-9_-]{1,64}$ —— 违规会让**整条请求**被 400 拒绝
// 实测把生产打挂device/<id> 里的 `/` 触发 Invalid 'tools[299].function.name'
// 网关 auto tier 全链条失败,整个 agent 不说话了)。
//
// 通道名是**插件自己的声明**,所以这条判据钉在插件侧。
func TestDeviceChannelNameIsLLMFunctionNameSafe(t *testing.T) {
re := regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// 含**恶意/异常** id空格、符号、非 ASCII、超长、以及会折成同一个名字的两个 id
ids := []string{"waiter-fnnas", "1", "a b!c", "中文设备", strings.Repeat("x", 120), "a b", "a-b"}
p := &Plugin{}
seen := map[string]string{}
for _, id := range ids {
ch := p.deviceChannelName(id)
if prev, dup := seen[ch]; dup {
t.Errorf("不同设备 id%q 与 %q派生出同一个通道名 %q", prev, id, ch)
}
seen[ch] = id
if !re.MatchString(ch) {
t.Errorf("设备通道名 %q 违反上游函数名规范 %s", ch, re)
}
toolName := "output_send__" + ch
if !re.MatchString(toolName) {
t.Errorf("派生出的工具名 %q 违反上游函数名规范 %s", toolName, re)
}
}
}

View File

@ -37,11 +37,6 @@ type Plugin struct {
token string
sdk *sdk.PluginSDK
dev *devicectlDevice
// devChansMu/devChans 维护"设备自报 id → 派生的通道名"。
// 设备 id 是外部输入,不能直接进通道名(见 outputch.go 的 deviceChannelName
devChansMu sync.Mutex
devChans map[string]string
}
func New(name string) *Plugin {
@ -119,17 +114,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
}
// ---- 设备通道随在线状态生灭(见 outputch.go 的 wireDeviceChannels----
p.wireDeviceChannels()
// ---- 设备主动上报事件 → agent 注入 ----------------
// 摄像头发现异常/传感器报警等场景:设备经 WS op=event 上报,
// 插件将其格式化为文本经 SDK InjectText 异步注入 agentsource=device/{id}
// 同时发 EventBus 供 WebUI 展示。
//
// **注意**agent 的输出**不会**被自动转回设备 —— 主动转发只有 webui 与 cli 两个
// 交互界面(它们把最终回复渲染成对话气泡是本职)。设备要走
// `output_send__device-<id>`agent 主动调用),这才与"输出是 agent 的主动调用"一致。
// 插件将其格式化为文本经 SDK InjectText 异步注入 agentsource=device/{id}
// 回复路由回 device/{id} 通道),同时发 EventBus 供 WebUI 展示。
// 节流:同设备同类型事件 10s 内去重,防传感器风暴。
lastEventAt := map[string]time.Time{}
var eventMu sync.Mutex
@ -164,13 +152,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
log.Printf("[remotedevice] event from %s: %s", deviceID, evtType)
if p.sdk != nil {
// 设备通道 device-<id> 是动态的(分隔符用 - 而非 /,见 deviceChannelName 的说明:
// 通道名会进 LLM 函数名,必须满足 ^[a-zA-Z0-9_-]{1,64}$)。
// 首次上报时**懒登记** inputchRegister 幂等),父 agent 才能把它划给驻留子。
devCh := p.deviceChannelName(deviceID)
_ = p.sdk.RegisterInputChannel(devCh, sdk.ChannelDef{})
// 异步注入:不阻塞 WS 读循环;回复路由回 device/{id} 输出通道
p.sdk.InjectInput(devCh, devCh, "text", map[string]interface{}{"content": text})
p.sdk.InjectInput("device/"+deviceID, "device/"+deviceID, "text", map[string]interface{}{"content": text})
}
})
@ -312,13 +295,6 @@ func (p *Plugin) describeScreen(dataURL string, provider string) string {
}
func (p *Plugin) Stop() error {
// 注销全部设备通道:插件卸载/重载后这些通道不再有实现,
// 留着会让 output_list_channels 骗模型。
if p.sdk != nil {
for _, m := range p.registry.List() {
p.dropDeviceOutputChannel(m.DeviceID)
}
}
if p.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

View File

@ -60,14 +60,10 @@ func (c *wconn) unlockWrite() {
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
// 鉴权在设备端执行,服务端不存储授权状态。
type Registry struct {
mu sync.RWMutex
devices map[string]*DeviceMeta // deviceID -> meta在线/历史)
conns map[string]*wconn // deviceID -> 活跃连接(支持 push
onlineCh chan string
// onOnline/onOffline设备上下线的同步回调见 SetPresenceHandler
onOnline func(DeviceMeta)
onOffline func(string)
mu sync.RWMutex
devices map[string]*DeviceMeta // deviceID -> meta在线/历史)
conns map[string]*wconn // deviceID -> 活跃连接(支持 push
onlineCh chan string
onStatus func(msg map[string]interface{})
onEvent func(deviceID string, msg map[string]interface{})
acceptFn func(token string) bool
@ -285,12 +281,7 @@ func (r *Registry) register(meta DeviceMeta) {
meta.LastSeen = time.Now().Unix()
// 保留设备自报的授权状态(客户端鉴权,服务端不覆盖)
r.devices[meta.DeviceID] = &meta
onOnline := r.onOnline
r.mu.Unlock()
// 先回调(可能注册 device-<id> 输出通道),再发变更通知。
if onOnline != nil {
onOnline(meta)
}
r.notifyChange(meta.DeviceID)
}
@ -300,27 +291,10 @@ func (r *Registry) markOffline(id string) {
m.Online = false
}
delete(r.conns, id)
onOffline := r.onOffline
r.mu.Unlock()
if onOffline != nil {
onOffline(id)
}
r.notifyChange(id)
}
// SetPresenceHandler 注册设备上线/下线回调。
//
// 为什么不用 ChangeChan那是 `select { case ch <- id: default: }`,缓冲满了会**丢事件**
// (设备上下线是要跟"注册/注销输出通道"绑定的,丢一次就会留下一个死通道或漏注册)。
// 这里同步调用,且在**释放锁之后**调 —— 回调内部会回查 registryGet/List
// 持锁调用会自己锁死自己。
func (r *Registry) SetPresenceHandler(onOnline func(DeviceMeta), onOffline func(string)) {
r.mu.Lock()
r.onOnline = onOnline
r.onOffline = onOffline
r.mu.Unlock()
}
func (r *Registry) notifyChange(id string) {
select {
case r.onlineCh <- id:

View File

@ -63,8 +63,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
}
// timer 通道:定时器到点经它注入 agent见本文件 InjectInterruptTextOpts 调用)。
_ = s.RegisterInputChannel("timer", sdk.ChannelDef{})
s.RegisterTool("timer_set", sdk.ToolDef{
Name: "timer_set",
Description: "设置一个定时提醒。倒计时结束后通过中断通道通知 agent。",
@ -110,10 +108,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
case <-time.After(dur):
log.Printf("[timer] firing: %s (%s later)", message, dur)
// NoMemory定时提醒是系统通知不是记忆内容。
// PriorityL3定时器是“时钟那种实时工作”——到点就该处理
// 比 QQ 那类可无限等待的异步消息高L3 vs L1
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
sdk.InjectOptions{NoMemory: true, Priority: sdk.PriorityL3})
sdk.InjectOptions{NoMemory: true})
case <-p.stopCh:
log.Printf("[timer] cancelled: %s", message)
}

View File

@ -1812,12 +1812,8 @@ func (h *Handler) handleChatInterrupt(w http.ResponseWriter, r *http.Request) {
if body.DeviceID != "" {
source = "webui/" + body.DeviceID
}
// PriorityL4终止按钮必须能立即打断当前任务内核级插件才有的能力
// agent 正卡在工具执行里时按不下手——那是临界区,由内核在安全点生效;
// 但 LLM 流式段会被立刻取消。
h.sdk.InjectInterrupt(source, "webui", "text", map[string]interface{}{
"content": body.Message,
"priority": sdk.PriorityL4,
"content": body.Message,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "interrupted"})
}

View File

@ -148,10 +148,6 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
// 能力位 7 = CapText|CapFile|CapImage旧值 1 仅文本agent 无法向 webui 发文件/图片
// 入站通道webui控制台对话与 http外部 HTTP 注入),都由本插件注入输入。
// http 通道还声明 NoMemory外部抓来的内容不进记忆计算见 handler 里的 NoMemory 注入)。
_ = s.RegisterInputChannel("webui", sdk.ChannelDef{})
_ = s.RegisterInputChannel("http", sdk.ChannelDef{NoMemory: true})
s.RegisterOutputChannel("webui", 7, "Web 控制台(支持文字/图片/文件,图片内联展示、文件可下载)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
rawType, _ := args["type"].(string)

View File

@ -50,19 +50,9 @@ const (
type APIRegistrar = pubsdk.APIRegistrar
type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
type OutputChannelUnregistrar = pubsdk.OutputChannelUnregistrar
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
type ChannelDef = pubsdk.ChannelDef
// 中断优先级的取值再导出:内置插件用 sdk.PriorityL4 声明“立即打断”,
// 外部插件同名常量会被内核夹到 L3见 core.interruptLevel / proc 桥)。
const (
PriorityL1 = pubsdk.PriorityL1
PriorityL2 = pubsdk.PriorityL2
PriorityL3 = pubsdk.PriorityL3
PriorityL4 = pubsdk.PriorityL4
)
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
// 否则内核要认两份,而漏认会静默丢失标志位。
type InjectOptions = pubsdk.InjectOptions
@ -285,21 +275,20 @@ type AudioURL = pubsdk.AudioURL
// SDKConfig holds all dependencies for creating a PluginSDK.
type SDKConfig struct {
IOManager *agentIO.IOManager
EventBus *events.Bus
Memory MemoryAPI
TextMemory TextMemoryAPI
DocMemory DocMemoryAPI
Knowledge KnowledgeAPI
LLM LLMAPI
Settings SettingsAPI
RegTool ToolRegistrar
RegStage StageRegistrar
RegAPI APIRegistrar
RegOutput OutputChannelRegistrar
RegOutputUnreg OutputChannelUnregistrar
RegInput InputChannelRegistrar
PluginMgr PluginManager
IOManager *agentIO.IOManager
EventBus *events.Bus
Memory MemoryAPI
TextMemory TextMemoryAPI
DocMemory DocMemoryAPI
Knowledge KnowledgeAPI
LLM LLMAPI
Settings SettingsAPI
RegTool ToolRegistrar
RegStage StageRegistrar
RegAPI APIRegistrar
RegOutput OutputChannelRegistrar
RegInput InputChannelRegistrar
PluginMgr PluginManager
Status StatusAPI
Supervisor SupervisorAPI
@ -318,9 +307,6 @@ func New(name string, cfg SDKConfig) *PluginSDK {
if cfg.RegInput != nil {
base.SetInputChannelRegistrar(cfg.RegInput)
}
if cfg.RegOutputUnreg != nil {
base.SetOutputChannelUnregistrar(cfg.RegOutputUnreg)
}
base.SetMemoryAPI(cfg.Memory)
base.SetTextMemoryAPI(cfg.TextMemory)
base.SetDocMemoryAPI(cfg.DocMemory)

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