mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
merge: 安全策略收敛到 security 模块 + 文档全量校订
用户裁定驱动的两项架构修正,以及随之而来的文档体系重整。 ## 一、安全策略收敛(3 个提交) ###1d007e1工具策略闸真的接上了 生产日志实证 691 个文件里 `denied by policy` 命中 0 次。 根因不是「buildAgent 少传一行」,而是 `kernelmod/agent` 的 `Requires()` 没声明 `FacSecurity` —— 依赖图没理由把 security 的产物交给 agent(只有声明了的 device 拿到了)。 同时发现子 agent(resident/rtchild)各自 New 时都没传 Security, 从构造起就是 nil。 ###e78a38b父为子声明工具策略 修正上一版「子继承父权限」的错误模型。用户裁定:应当是父 agent 为子 agent 声明策略。用的是树里早就有的 `CheckToolFor` / `PrincipalPolicy` —— 机制完整但零生产调用方。 ###5194a96安全策略全面收敛到 security 模块 security 是外围内核到核心内核之间的防火墙,自己提供接口、 判定全部内聚在自己内部。调用方只提供事实(谁在跑、有没有父、 原始载荷),不提供结论(我是哪类主体、该走哪条判定链)。 ###8293282每 agent 一个策略实例 用户裁定「每个 agent 一个,分为 sessionProfile 和 longtimeProfile」。 切分原则是共享注册表 + per-agent 执行视图 —— 整份复制 Runtime 会让插件往 A 声明的档位对 B 不可见。优先级 session > longtime, 反过来就是提权。 ## 二、文档体系重整(4 个提交) ###b9b5ac0建立 future-plan 作为唯一未做登记处 此前「未做」散落各文档,时间一长就与代码脱节。 ###cf14b84/4963d70补齐状态分类 + 逐篇核实 32 篇文档此前混着三种时态。逐篇对代码核实,修正 6 处与代码不符 的自述(最危险的是 prod-migration-checklist 写着「已停机」, 那是一次性迁移当场的状态)。 ###a402bfd删除 7 份已完结的执行计划 执行计划在实施完结后变成误导性读物。删前提取三类内容: 教训 → 新建 engineering-lessons.md; 实测数据 → 并入设计文档附录 A; 未完成项 → future-plan.md。 保留 legacy-table-retirement.md 与 interrupt-subsystem-split-plan.md (被生产代码注释引用,是依据而非计划)。 ## 门禁 - go build ./... ✓ - go vet ./... ✓ - go test ./... -count=1:10 轮全绿 ✓ - -race(agent/core + security + kernelmod/all)✓ - SDK 接口冻结检查:third_party/homeagent-sdk/sdk/ diff 为空 ✓
This commit is contained in:
@ -175,7 +175,7 @@ flowchart TB
|
||||
subgraph G[③ Graph 图数据库]
|
||||
DB[(SQLite)]
|
||||
IDX[Indexer 向量+jieba→BFS depth=2] -->|【记忆索引】| SP
|
||||
MEM[memory_recall/commit/merge/purge/edit]
|
||||
MEM[stablemem_*/coldcontext_*]
|
||||
SOC[person_query/set_trait]
|
||||
subgraph SC[场面识别:声明 + 涌现]
|
||||
FE[① 指纹 chan/peer/tool/topic/part<br/>权重 1.0/1.0/0.8/0.4/0.2]
|
||||
@ -196,7 +196,7 @@ flowchart TB
|
||||
end
|
||||
SP[System Prompt] -->|顺序组装| LLM
|
||||
LLM[LLM] -->|coldcontext_query| Q2
|
||||
LLM -->|memory_recall| MEM
|
||||
LLM -->|stablemem_recall| MEM
|
||||
```
|
||||
|
||||
详细说明见 [`assets/docs/zh/ARCHITECTURE.md`](assets/docs/zh/ARCHITECTURE.md)。
|
||||
@ -329,7 +329,7 @@ internal/
|
||||
> inline/small **30.4µs**、frame/small 51.5µs、inline/large 767µs、frame/large 398µs。
|
||||
> 保留原文不修改,以免伪造历史。
|
||||
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://github.com/JianFeeeee/hologramagentsdk/tree/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
**v1.1.1** — 多模态贯通**插件边界**。v1.1.0 让记忆系统支持了二进制多媒体节点,但那条链路只对内核自己开放;本版打通到插件与模型。公开 SDK 新增媒体字段与三个媒体注入接口(配套 [SDK v1.1.0](https://github.com/JianFeeeee/hologramagentsdk/tree/v1.1.0),整条 1.1.x 线共用),内核实现对应四个 RPC。桥接层此前在**静默裁字段**:插件交进来的 `Confidence`/类型/`SentenceText` 全被丢弃、`Doc` 只留三个字段、`Remove` 不解引用(媒体永久算「被引用」,GC 收不掉)。`processTextInput`/`processMediaInput` 归一成一条 `processInput`,媒体路径由此获得它一直缺的去重、`no_memory`、通道 `Cleaner`、中断语义、`EventRawInput`。修掉三处真实缺陷:**用户发的图从来没出现在 WebUI 聊天记录里**(媒体路径发布 map 而订阅方断言 string)、**`memory_commit` 的 `sentence_text` 从未暴露给模型**(注:该工具现名 `stablemem_commit`,本条保留 1.1.1 发布时的原名)(而它是媒体绑定链的必经环节)、**`PluginSDK` 两处并发竞态**(`-race` 实测 11 处,插件重载瞬间偶发 nil 解引用崩溃)。
|
||||
|
||||
**v1.1.0** — 记忆系统支持**二进制多媒体节点**。内容寻址媒体存储(CAS + SQLite 元数据 + 磁盘 blob,`Get` always 重校 digest),贯通 L0(上下文事件)/L2(文档)/L3(图谱句子)三层,引用计数式 GC(有引用者绝不删)。视觉模型生成的描述文本是持久语义记忆,blob 只是可被容量 GC 淘汰的缓存。
|
||||
|
||||
@ -355,6 +355,8 @@ internal/
|
||||
- [插件开发指南](assets/docs/zh/PLUGIN_DEV.md) | [English](assets/docs/en/PLUGIN_DEV.md)
|
||||
- [Lua Adapter](assets/docs/zh/ADAPTER.md) | [English](assets/docs/en/ADAPTER.md)
|
||||
- [知识库演示](assets/knowledge/homeagent_architecture/content.md)
|
||||
- [设计文档索引](docs/zh/README.md) — 32 篇设计/计划文档的**状态分类与可信度规则**
|
||||
- [未来计划](docs/zh/future-plan.md) — 尚未实现 / 已知缺口的**唯一登记处**
|
||||
|
||||
## 下载
|
||||
|
||||
|
||||
@ -166,7 +166,7 @@ flowchart TB
|
||||
subgraph G[③ Graph Database]
|
||||
DB[(SQLite)]
|
||||
IDX[Indexer vector+jieba→BFS depth=2] -->|Memory Index| SP
|
||||
MEM[memory_recall/commit/merge/purge/edit]
|
||||
MEM[stablemem_*/coldcontext_*]
|
||||
SOC[person_query/set_trait]
|
||||
subgraph SC[Scene recognition: declared + emergent]
|
||||
FE[① fingerprint chan/peer/tool/topic/part<br/>weights 1.0/1.0/0.8/0.4/0.2]
|
||||
@ -187,7 +187,7 @@ flowchart TB
|
||||
end
|
||||
SP[System Prompt] -->|Sequential assembly| LLM
|
||||
LLM[LLM] -->|coldcontext_query| Q2
|
||||
LLM -->|memory_recall| MEM
|
||||
LLM -->|stablemem_recall| MEM
|
||||
```
|
||||
|
||||
See [`assets/docs/en/ARCHITECTURE.md`](assets/docs/en/ARCHITECTURE.md) for details.
|
||||
@ -336,7 +336,7 @@ plugin were discarded, `Doc` kept only three fields, and `Remove` never released
|
||||
path the dedup, `no_memory`, channel `Cleaner`, interrupt semantics and correct `EventRawInput`
|
||||
it had always lacked. Three real defects fixed: **user-sent images never appeared in the WebUI
|
||||
chat log** (the media path published a map while the subscriber asserted a string),
|
||||
**`memory_commit`'s `sentence_text` had never been exposed to the model** (though it is the
|
||||
**`memory_commit`'s `sentence_text` had never been exposed to the model** (note: this tool is now named `stablemem_commit`; the original name is kept for the v1.1.1 release note) (though it is the
|
||||
mandatory link in the media binding chain), and **two data races in `PluginSDK`** (11 reported
|
||||
by `-race`; in production this showed up as sporadic nil-dereference crashes during plugin reload).
|
||||
|
||||
|
||||
@ -228,12 +228,13 @@ L2 不是 L1 的摘要层,也不是另一种数据形态——它就是同样
|
||||
③ Graph (图数据库 — 顶层仍是三元组,节点即记忆块)
|
||||
SQLite — memory_blocks(节点) + memory_block_edges(边)
|
||||
三元组 = 【节点】【关系边】【节点】;putTripleBlocksTx 落为 src --edge_type--> dst
|
||||
写入: memory_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / memory_merge
|
||||
写入: stablemem_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / stablemem_merge
|
||||
读取:
|
||||
├── 自动召回: Indexer.BuildContext(input)
|
||||
│ → CleanText → 向量实体搜索 + jieba关键词 → SQLite LIKE + BFS depth=2
|
||||
│ → 【记忆索引】→ system prompt
|
||||
└── LLM主动: memory_recall / memory_merge / memory_purge / memory_edit / memory_delete_entity
|
||||
└── LLM主动: stablemem_recall / stablemem_merge / stablemem_purge /
|
||||
stablemem_edit / stablemem_delete_entity
|
||||
Social: person_query / set_trait / relate (包装 GraphDB)
|
||||
|
||||
④ 四个独立心跳循环(各自独立的 ticker 和配置间隔)
|
||||
@ -356,20 +357,26 @@ Core ◀─入向审查── 外部
|
||||
|
||||
| 工具 | 作用 |
|
||||
| ------ | ------ |
|
||||
| `memory_recall` | 从 Graph 召回 |
|
||||
| `memory_commit` | 写入 Graph 三元组 |
|
||||
| `memory_merge` | 合并两个实体节点 |
|
||||
| `memory_purge` | 删除指定实体 |
|
||||
| `memory_edit` | 编辑已有实体/关系 |
|
||||
| `memory_delete_entity` | 删除实体及其所有关系 |
|
||||
| `memory_introspect` | 查看记忆统计 |
|
||||
| `stablemem_recall` | 从 Graph 召回 |
|
||||
| `stablemem_commit` | 写入 Graph 三元组 |
|
||||
| `stablemem_merge` | 合并两个实体节点 |
|
||||
| `stablemem_block_merge` | 合并两个记忆块 |
|
||||
| `stablemem_purge` | 删除指定实体 |
|
||||
| `stablemem_edit` | 编辑已有实体/关系 |
|
||||
| `stablemem_delete_entity` | 删除实体及其所有关系 |
|
||||
| `stablemem_introspect` | 查看记忆统计 |
|
||||
| `coldcontext_query` | 从冷上下文(cold context)召回 |
|
||||
| `coldcontext_commit` | 写入冷上下文 |
|
||||
|
||||
`memory_commit` 与 `coldcontext_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
|
||||
> ★ 工具名以 `stablemem_` / `coldcontext_` 为前缀(v1.2 起,旧的
|
||||
> `memory_*` / `doc_*` 已废弃)。权威定义在
|
||||
> `internal/agent/core/tooldefs.go`;核对命令:
|
||||
> `grep -rhoE '"(stablemem|coldcontext)_[a-z_]+"' --include=*.go internal | sort -u`
|
||||
|
||||
`stablemem_commit` 与 `coldcontext_commit` 自 v1.1.1 起接受 `media_digests`,并由内核把
|
||||
`[<mime> <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。
|
||||
要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。
|
||||
`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
`stablemem_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。
|
||||
|
||||
### 媒体记忆(块 union:媒体即块)
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ CLI 需要终端面板时,**订阅事件总线**(`EventTerminalOutput` + `Ev
|
||||
- **无独立 GC、无引用计数、无 keep-set**:记忆块遵循单层不变量——Context → Document → Graph
|
||||
是块的**迁移**,不是复制、也不靠引用保活;**删除块即删内容**
|
||||
- **v1.1.1 起贯通插件边界**:插件可通过 `InsertWithMedia` / `InjectInputMedia` 读写媒体,
|
||||
模型可用 `memory_commit` / `coldcontext_commit` 的 `media_digests` 参数关联媒体
|
||||
模型可用 `stablemem_commit` / `coldcontext_commit` 的 `media_digests` 参数关联媒体
|
||||
- **旧机制已整体拆除**:此前把视觉模型生成的描述写进正文、以 `[<mime> <短digest>] <描述>` 标记
|
||||
参与检索的**描述式索引**,以及 `media_refs` 引用计数,均已在 v1.2.0 删除——描述是模型生成的
|
||||
二手信息,检索「别人转述的图片」不如检索图片自己的向量
|
||||
|
||||
156
docs/zh/README.md
Normal file
156
docs/zh/README.md
Normal file
@ -0,0 +1,156 @@
|
||||
# docs/zh 文档索引
|
||||
|
||||
> 建立于 2026-10-31。目的:**让读者知道该信哪一篇**。
|
||||
>
|
||||
> 此前本目录混着三种时态 —— 当时实测、历史推演、执行计划 —— 外观完全相同。
|
||||
> 曾有文档写着「`main.go` 仍走串行代码」而实际早已接完(滞后一整轮发布)。
|
||||
|
||||
---
|
||||
|
||||
## 一、本目录只放四类文档
|
||||
|
||||
| 类型 | 回答什么问题 | 有长期价值 | 命名 |
|
||||
| --- | --- | --- | --- |
|
||||
| **设计文档** | 为什么这样设计 | ✅ | `*-design.md` |
|
||||
| **操作手册** | 怎么做 | ✅ | `*-runbook.md` |
|
||||
| **教训** | 踩过什么坑 | ✅ | `engineering-lessons.md` |
|
||||
| **未做登记** | 还剩什么没做 | ✅ | `future-plan.md` |
|
||||
|
||||
### ❌ 不放:执行计划
|
||||
|
||||
执行计划(`*-plan.md` / `*-checklist.md`)**会随实施完结变成误导性读物** ——
|
||||
它记录"当时打算做什么",而读者会读成"现在是什么"。
|
||||
|
||||
2026-10-31 删除了 9 份已完结的计划,删前把两类内容提出来:
|
||||
|
||||
| 内容 | 去向 |
|
||||
| --- | --- |
|
||||
| 决策与教训 | `engineering-lessons.md` |
|
||||
| 未完成的项 | `future-plan.md` |
|
||||
| 实测数据 | 并入对应设计文档(如压测数据进 `toolcall-contract-and-sequence-design.md` 附录 A) |
|
||||
|
||||
**唯一例外**:`legacy-table-retirement.md` 与 `interrupt-subsystem-split-plan.md` 保留 ——
|
||||
前者被 4 处生产代码注释当作**退役依据**引用,后者记录的探测事实仍被判据引用。
|
||||
它们不是"执行计划",是**依据**。
|
||||
|
||||
---
|
||||
|
||||
## 二、怎么判断一篇文档的可信度
|
||||
|
||||
每篇首部都有**状态行**,取值只允许这四种:
|
||||
|
||||
| 状态 | 含义 | 能当事实用吗 |
|
||||
| --- | --- | --- |
|
||||
| **已实现并部署** | 描述与代码一致,关键处给了取数命令 | ✅ 直接信 |
|
||||
| **部分实现** | 先说清哪部分生效(附实测调用点) | ⚠️ 只信标了"生效"的 |
|
||||
| **设计 / 未实现** | 是方案,代码里可能没有 | ❌ 只能当设计意图 |
|
||||
| **归档说明** | 历史记录,**故意保留当时写法** | ❌ 不要改、也不要信 |
|
||||
|
||||
### 判据名的可信度规则
|
||||
|
||||
文档里的 `TestXxx` **不保证现在还存在**:
|
||||
|
||||
- **已实现并部署**类文档里的 → 应当存在。找不到就是缺陷
|
||||
- **设计 / 未实现**类文档里的 → 是**规划名**,可能从未实现
|
||||
- **归档说明**类文档里的 → 记录当时事实,可能已改名或删除
|
||||
|
||||
真实案例:`BootCtx` 改名 `KernelState` 后,
|
||||
`TestBootCtxCloseAllIsReverseOrder` → `TestKernelStateCloseAllIsReverseOrder`,
|
||||
文档没跟上。
|
||||
|
||||
**核对命令**:
|
||||
|
||||
```bash
|
||||
for n in $(grep -rhoE '`Test[A-Z][A-Za-z0-9_]+`' docs/zh/*.md | tr -d '`' | sort -u); do
|
||||
grep -rq "func $n(" --include="*_test.go" internal cmd 2>/dev/null || echo "缺失: $n"
|
||||
done
|
||||
```
|
||||
|
||||
有缺失**不一定**是缺陷,先看它出在哪类文档里。
|
||||
|
||||
**当前基线(2026-10-31)**:27 条缺失,全部已知:
|
||||
|
||||
| 篇数 | 文档 | 性质 |
|
||||
| --- | --- | --- |
|
||||
| 18 | `commondev-*.md` | 「设计 · 未实现」类,判据名是**规划名** |
|
||||
| 7 | `kernel-modularization-design.md` | §2.7.3 的**对照表左栏**(有意的历史记录) |
|
||||
| 2 | `multimodal-space.md` | 视频能力未纳入契约,判据为规划名 |
|
||||
|
||||
⇒ **在「已实现」类文档正文里找不到,才是缺陷。**
|
||||
|
||||
---
|
||||
|
||||
## 三、文档清单
|
||||
|
||||
### 3.1 设计文档 —— 可以直接当事实
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [`kernel-modularization-design.md`](./kernel-modularization-design.md) | **装配架构权威**:依赖声明、BFS 分层、Group 0、实例化粒度 |
|
||||
| [`kernel-secret-firewall-design.md`](./kernel-secret-firewall-design.md) | 安全防火墙(**部分实现**,见文首接线状态表) |
|
||||
| [`input-scheduler-design.md`](./input-scheduler-design.md) | 两类别输入 + L1–L4 中断 + 可抢占 |
|
||||
| [`resident-subagent-design.md`](./resident-subagent-design.md) | 驻留子:轻量内核、两级记忆、父子 L3/L4 |
|
||||
| [`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md) | 工具调用契约(**附录 A 是压测数据**) |
|
||||
| [`commondev-design.md`](./commondev-design.md) | 主管式多仓库开发流水线(**设计 · 未实现**) |
|
||||
| [`commondev-agent-runtime-design.md`](./commondev-agent-runtime-design.md) | commondev 运行时与内核接入 |
|
||||
| [`context-overflow-l4-design.md`](./context-overflow-l4-design.md) | 上下文超页 L4(归档,术语过时) |
|
||||
| [`triple-distill-llm-design.md`](./triple-distill-llm-design.md) | 三元组蒸馏(已实现并**部分否决**) |
|
||||
|
||||
### 3.2 操作手册
|
||||
|
||||
- [`ci-cd-runbook.md`](./ci-cd-runbook.md) — CI/CD
|
||||
- [`deploy-runbook.md`](./deploy-runbook.md) — 部署
|
||||
- [`site-infra-runbook.md`](./site-infra-runbook.md) — 站点基础设施
|
||||
|
||||
### 3.3 参考 / 实测报告
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [`engineering-lessons.md`](./engineering-lessons.md) | **踩过的坑与共同形态**(推荐先读) |
|
||||
| [`kernel-performance.md`](./kernel-performance.md) | 性能实测基线 |
|
||||
| [`production-recall-validation.md`](./production-recall-validation.md) | 生产召回验证(「探针 PASS 但结论是假的」) |
|
||||
| [`recall-as-association.md`](./recall-as-association.md) | 召回作为关联 |
|
||||
| [`recall-capability-tiers.md`](./recall-capability-tiers.md) | 召回能力分层(含 2 个未补缺口) |
|
||||
| [`multimodal-space.md`](./multimodal-space.md) | 多模态向量空间 |
|
||||
| [`vector-provider-decision.md`](./vector-provider-decision.md) | 向量 provider 选型(已定) |
|
||||
| [`abstention-criterion.md`](./abstention-criterion.md) | 弃答判据 |
|
||||
| [`triple-extract-attention.md`](./triple-extract-attention.md) | 三元组抽取与注意力 |
|
||||
|
||||
### 3.4 依据类(不是计划,是退役/拆分的判断依据)
|
||||
|
||||
| 文档 | 为什么保留 |
|
||||
| --- | --- |
|
||||
| [`legacy-table-retirement.md`](./legacy-table-retirement.md) | 被 4 处生产代码注释引用;剩余读方清单在 §八/§十二 |
|
||||
| [`interrupt-subsystem-split-plan.md`](./interrupt-subsystem-split-plan.md) | 探测事实被判据引用;拆分本身未实施 |
|
||||
|
||||
### 3.5 归档说明 —— 历史记录,不要改也不要信
|
||||
|
||||
- [`live-run-evidence-2026-10-04.md`](./live-run-evidence-2026-10-04.md) — 某次实跑证据快照
|
||||
- [`legacy-issues.md`](./legacy-issues.md) — 遗留问题清单(部分条目已过时)
|
||||
|
||||
> 归档文档里的旧工具名(`memory_recall` 等)**故意不改** —— 它们是证据快照,
|
||||
> 改了就无法判断"这条结论是在哪个版本上得出的"。
|
||||
|
||||
### 3.6 单一登记处
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [`future-plan.md`](./future-plan.md) | **全仓唯一的「尚未实现 / 已知缺口」登记处** |
|
||||
|
||||
---
|
||||
|
||||
## 四、写文档的三条纪律
|
||||
|
||||
1. **数字类断言必须给取数命令**(如模块数用
|
||||
`go run ./cmd/homed-depgraph -print`),**不要凭记忆**。
|
||||
本文档集写错过一次(17 模块 vs 实际 20)。
|
||||
|
||||
2. **区分「机制存在」与「接线生效」**。本项目头号缺陷形态是
|
||||
机制写对了、判据全绿、生产里零调用方。描述能力时写**实测的生产调用点**。
|
||||
|
||||
3. **判据要能红**。写判据时先问:把对应实现删掉,这条判据会红吗?
|
||||
本仓踩过两次:`security_toolgate_test.go` 手动调 `SetSecurity`
|
||||
把缺失的接线补上了;`retention_test.go` 手写合规文件名,
|
||||
从没跑过真实产物。两者都永远绿。
|
||||
|
||||
详见 [`engineering-lessons.md`](./engineering-lessons.md)。
|
||||
@ -1,5 +1,8 @@
|
||||
# 拒答判据:符号存在性
|
||||
|
||||
> **状态:参考 / 机制说明。** 描述拒答判据的设计口径与实现位置,非逐条审计记录。
|
||||
> 未实现项见 [`future-plan.md`](./future-plan.md)。
|
||||
|
||||
## 结论先行
|
||||
|
||||
**「查询符号在库中零出现」可以安全拒答。** 依据是确定性事实
|
||||
@ -85,11 +88,19 @@
|
||||
|
||||
## 未完成
|
||||
|
||||
- 拒答逻辑尚未接入生产路径(`recallByBlocks` 仍无条件返回内容)
|
||||
- 泛指词检测未实现
|
||||
- n=10 的判据仍偏小,但方向与上一轮 n=4 时的判断相反:
|
||||
> **2026-10-31 复核**:第一条已不成立,其余仍开放。
|
||||
|
||||
- ~~拒答逻辑尚未接入生产路径~~ ✅ **已接入**:
|
||||
`GraphDB.RecallBlocksGuarded` 把拒答下沉到**图库层**,
|
||||
生产路径 `toolcall.go:1312` 与端到端探针**走同一条路**。
|
||||
(下沉的理由值得记住:第一版把拒答放在 `core/toolcall.go` 里,
|
||||
探针绕过它 ⇒「判据测不到被测路径,判据就等于不存在」。)
|
||||
- ❌ 泛指词检测仍未实现
|
||||
- ⚠️ n=10 的判据仍偏小,但方向与上一轮 n=4 时相反:
|
||||
这次是**确定性事实 + 完全分离 + 新增样本不破**,而不是「差 0.047 的运气」
|
||||
|
||||
两者已登记到 [`future-plan.md`](./future-plan.md) §六。
|
||||
|
||||
## 迁移前后对照(2026-10-04,生产快照实测)
|
||||
|
||||
用同一份生产库快照,迁移前后各跑一次 `TestProbe_生产规模召回`:
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# CI/CD 流水线手册(GitHub Actions)
|
||||
|
||||
> **状态:操作手册(现行)。** 照着做即可;命令与配置如有变更需同步更新本文。
|
||||
|
||||
> 2026-09-29 随仓库迁移 gitcode → GitHub 而建。此前 AtomGit 停止对普通用户
|
||||
> 提供流水线,本仓无任何自动化验证。本文记录两条流水线的用法、机制与边界。
|
||||
> 分支模型见 `docs/git-branching.md`(本文与其 §七「发版产物清单」衔接)。
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# HologramAgent 生产部署手册(homed / waiter)
|
||||
|
||||
> **状态:操作手册(现行)。** 照着做即可;命令与配置如有变更需同步更新本文。
|
||||
|
||||
> 适用范围:本机(`.60`)的 `homeagent.service`,以及两台设备桥宿主上的
|
||||
> `waiter-remote.service`。
|
||||
>
|
||||
@ -354,7 +356,7 @@ journalctl -u homeagent.service --since "-10 min" | grep -E "finish_reason=lengt
|
||||
先看**是不是内核的兜底在工作**。例:`重复申请 stage 锁` 是 SDK 模板在每个
|
||||
stage handler 入口自动加锁 + 锁不可重入所致,**插件侧问题**;内核的强制释放
|
||||
是有意设计(避免后续插件死锁),不要当内核缺陷去修。详见
|
||||
[`toolcall-parallel-execution-plan.md`](./toolcall-parallel-execution-plan.md) 末节。
|
||||
[`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
179
docs/zh/engineering-lessons.md
Normal file
179
docs/zh/engineering-lessons.md
Normal file
@ -0,0 +1,179 @@
|
||||
# 工程教训
|
||||
|
||||
> 这个仓库踩过的坑,以及它们共同的形态。**写在这里是因为它们比实现细节更耐用** ——
|
||||
> 代码会改,缺陷形态会重复出现。
|
||||
>
|
||||
> 来源:这些条目原本散落在各份执行计划(`memory-*-plan.md`、
|
||||
> `toolcall-parallel-execution-plan.md` 等)里。计划本身已随实施完结而删除,
|
||||
> 但教训被提出来了 —— 那是它们唯一还有价值的部分。
|
||||
>
|
||||
> 建立于 2026-10-31。
|
||||
|
||||
---
|
||||
|
||||
## 一、判据类(最贵的教训,占了一半)
|
||||
|
||||
### 1.1 「结构判据」不能替代「质量判据」
|
||||
|
||||
**症状**:16 条判据、6 个变异**全部通过**,而功能完全不可用。
|
||||
|
||||
**当时的场景**:记忆检索改造。判据验的是「字段非空」「条数正确」「不 panic」,
|
||||
而实际检索结果是:
|
||||
|
||||
```text
|
||||
查询 "doc 层召回是怎么设计的" → score=0.7834
|
||||
查询 "hmap 插件安装验签" → score=0.7922
|
||||
查询 "QQ 头像" → score=0.7601
|
||||
```
|
||||
|
||||
**分数全挤在 0.75~0.79,区分度 ≈ 0,命中内容与查询毫无关系。**
|
||||
|
||||
⇒ 「全绿」证明的只是**我实现了我以为的东西**,不是它可用。
|
||||
|
||||
**判据要问**:这条判据在"功能完全失效"时会绿吗?会绿就不是质量判据。
|
||||
|
||||
### 1.2 判据测「接线之后」≠ 测「有没有接线」
|
||||
|
||||
这是本仓出现频率最高的缺陷形态(已攒够 5 例,见 `future-plan.md` §〇)。
|
||||
|
||||
两处实测:
|
||||
|
||||
| 判据 | 它做了什么 | 为什么永远绿 |
|
||||
| --- | --- | --- |
|
||||
| `security_toolgate_test.go` | 手动调 `a.SetSecurity(rt)` 再断言拦截 | **判据自己把缺失的接线补上了** |
|
||||
| `retention_test.go` | 手写 `month_2026-01.tar.gz` 再断言被删 | 从没跑过 `compressMonthly` 的**真实产物**(那产出的是 `month_2026-27`,解析必失败) |
|
||||
|
||||
两者都全绿,两个功能在生产里都完全没生效。
|
||||
|
||||
**判据要问**:**这条判据在生产装配下也会绿吗?**
|
||||
|
||||
### 1.3 「验证通过」≠「架构正确」
|
||||
|
||||
**当时的场景**:`630d3f4` 的 16 三元组端到端验证全部通过。
|
||||
但那次验证的是**拆分质量**(能不能把句子拆成主谓宾),
|
||||
不是**正确的落库形态**(拆出来的三元组该以什么结构存)。
|
||||
|
||||
⇒ 验证通过之后才发现,整个蒸馏路线方向错了,要整体否决。
|
||||
|
||||
**判据要问**:我验证的,是不是我真正想保证的那件事?
|
||||
|
||||
### 1.4 fixture 照臆测字段编
|
||||
|
||||
本仓 T23 教训:判据用的 fixture 是
|
||||
**手写臆测的字段名**,而真实结构体字段不同 ⇒ 判据测的是一个不存在的契约。
|
||||
|
||||
⇒ 判据的输入应当来自**真实产物**,不是人脑里的想象。
|
||||
|
||||
### 1.5 先改代码再写测试
|
||||
|
||||
原本记在记忆块流修复计划里(该计划已随实施完结删除):先改代码再补测试,
|
||||
测试就只会证明「我改的是我以为的」。
|
||||
|
||||
⇒ 这个顺序本身会让判据失去独立价值。
|
||||
|
||||
---
|
||||
|
||||
## 二、接线类
|
||||
|
||||
### 2.1 机制 ≠ 接线
|
||||
|
||||
见 `future-plan.md` §〇。一句话总结:
|
||||
|
||||
**策略表存在、判定函数正确、日志里一条拒绝记录都没有** —— 看起来像「没人违规」。
|
||||
|
||||
判断方法:`codegraph_callers(符号名)`。0 个生产调用方就是没接线。
|
||||
|
||||
### 2.2 依赖图是唯一保证顺序的地方
|
||||
|
||||
`agent` 模块读了 `ks.Security` 却没在 `Requires()` 里声明 `FacSecurity`。
|
||||
「恰好」能用(security 在第 1 层、agent 在第 4 层),但那是**偶然**:
|
||||
一旦 security 加了自己的依赖被推后,就会静默拿到 nil。
|
||||
|
||||
⇒ 读了谁的字段,就必须声明对谁的依赖。**不要靠层号碰巧对。**
|
||||
|
||||
### 2.3 单数路径改了,复数路径漏了
|
||||
|
||||
`StopRTChild`(单数)注销入站登记,`StopRTChildren`(复数)漏了。
|
||||
残留随 create/destroy 单调累积。
|
||||
|
||||
⇒ 写这类对称函数时,判据要**同时**钉两条路径。本仓已有判据专门做这件事。
|
||||
|
||||
---
|
||||
|
||||
## 三、权限与生命周期类
|
||||
|
||||
### 3.1 「接上了」有三种层次,容易停在第二层
|
||||
|
||||
| 层次 | 内容 |
|
||||
| --- | --- |
|
||||
| 1 | 机制存在(`CheckTool` / `ScanContent` 都写了) |
|
||||
| 2 | 接到了执行链(`securityDecisionFor` 真的调它) |
|
||||
| 3 | **模型正确** |
|
||||
|
||||
第三层的典型错误:把「子 agent 继承父的权限」当成「父为子声明策略」。
|
||||
两者都"接上了",但权限模型完全不同 —— 前者意味着子等于父,后者意味着子更窄。
|
||||
|
||||
**判断标准**:**这条策略是谁的意图?** 权限的意图只能来自委派者(父),
|
||||
不能来自被委派者(子),也不能来自内核默认(全局)。
|
||||
|
||||
### 3.2 以「调用方给的名字」为键的表,必须在生命周期结束时清理
|
||||
|
||||
两个实例:
|
||||
|
||||
- `rtClosedTranscripts[id]` —— 以模型给的名字为键,全仓 0 处删除。
|
||||
结果:同名重建的 rtchild 读到上一通电话的原文
|
||||
- `scopesByAgent[agentID]` 的 `longtimeProfile` —— 不释放则同名重建的 agent
|
||||
继承上一代的约束
|
||||
|
||||
⇒ 键来自外部(模型、调用方)就不能假设它唯一。
|
||||
|
||||
### 3.3 提权口子:让调用方声明自己是谁
|
||||
|
||||
`Kind: "resident"` 由 agent 自己写 —— 它同时意味着「我是内部主体」
|
||||
(豁免 HardPrivateTools)和「我不受收窄」。调用方说自己是内部就拿到豁免。
|
||||
|
||||
⇒ 类别必须由安全模块从**事实**(谁在调、有没有父)推出,不能由调用方声明。
|
||||
|
||||
---
|
||||
|
||||
## 四、流程类
|
||||
|
||||
### 4.1 长任务纪律
|
||||
|
||||
导出、回填等内存密集任务**绝不与测试并行**(同一会话里踩了三次 OOM)。
|
||||
|
||||
### 4.2 数字类断言要现场取,不要凭记忆
|
||||
|
||||
文档里写「17 模块 6 层」,实际是 **20 模块**。给读者的是错的坐标。
|
||||
|
||||
⇒ 数字必须附取数命令(如 `go run ./cmd/homed-depgraph -print`)。
|
||||
|
||||
### 4.3 计划文档不要进仓库
|
||||
|
||||
执行计划会在实施完结后变成**误导性读物**:它记录的是"当时打算做什么",
|
||||
而读者会读成"现在是什么"。
|
||||
|
||||
本仓删掉了 9 份已完结的计划(`memory-*-plan.md` 等),保留:
|
||||
|
||||
- **设计文档**(`*-design.md`):为什么这样设计,有长期价值
|
||||
- **教训**(本文):踩过什么坑
|
||||
- **未做登记**(`future-plan.md`):还剩什么没做
|
||||
- **操作手册**(`*-runbook.md`):怎么做
|
||||
|
||||
---
|
||||
|
||||
## 附:这些教训的共同点
|
||||
|
||||
回头看,几乎每一条都是同一个形态的变体:
|
||||
|
||||
> **某种"看起来对了"的信号,与"真的对了"之间存在缺口。**
|
||||
|
||||
- 判据全绿 ≠ 功能可用
|
||||
- 机制存在 ≠ 接线生效
|
||||
- 层号碰巧对 ≠ 依赖声明正确
|
||||
- 单数路径对 ≠ 复数路径也对
|
||||
- 名字相同 ≠ 是同一个东西
|
||||
- 计划写完 ≠ 事情做完
|
||||
|
||||
**所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号,
|
||||
在什么情况下会是假的?**
|
||||
@ -1,104 +0,0 @@
|
||||
# entities 退场迁移方案
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:已确认(方案 A:三表全退),执行中
|
||||
> 范围:含生产实例 ${HA_DATA}(用户 2026-10-02 确认迁移)
|
||||
> 前置:docs/zh/memory-restructure-plan.md(重构计划)
|
||||
> 原则:一次迁移,删除所有 entities 遗留,避免后续误导。
|
||||
|
||||
## 〇、执行前必须确认的一个决策
|
||||
|
||||
**relations / sentences 两张表跟不跟 entities 一起退?**
|
||||
|
||||
现状:三元组 = entities + relations + sentences 三表联写(graph.go Commit)。
|
||||
块形态:节点 = memory_blocks,边 = memory_block_edges。
|
||||
|
||||
- **方案 A:三表全退**(彻底)—— relations 是第二套边系统,留着必然误导;
|
||||
sentences 的"原句"职责由 text block 承担(memory_block_edges 已支持
|
||||
sentence 端点 kind,但句子本身该是块)。
|
||||
- **方案 B:只退 entities**(保守)—— relations/sentences 暂留,
|
||||
改成引用 block id。
|
||||
|
||||
**已确认方案 A**(用户 2026-10-02):留着 relations 就等于留着第二套边,
|
||||
两套边系统并存正是"浆糊"的来源。波及面:68 处 SQL(26+29+13)+ 10 个测试文件。
|
||||
生产实例纳入迁移范围,在第 3 步单独执行并快照回滚。
|
||||
|
||||
## 一、依赖面清单(已核实)
|
||||
|
||||
### 内核(internal/memory)
|
||||
|
||||
| 文件 | 依赖 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| graph.go | 26 处 SQL,Commit/Recall/merge/delete 全链 | **重写**:Commit→PutMemoryBlocks+edges;Recall→块向量召回 |
|
||||
| noise.go | IsNoiseEntity / NoiseEntities | 改为块判噪或删 |
|
||||
| scene.go | TagSceneByEntityGlob 等 6 处 | 改挂块节点 |
|
||||
| migrate.go | LegacyMediaEntityDigest(旧→块迁移) | 改名/扩为全文实体迁移 |
|
||||
| block.go | 1 处(端点校验引用 entities) | 改:端点只认 block |
|
||||
| indexer.go | vectorSearchEntities / buildIndexSummary | 改为块索引 |
|
||||
|
||||
### 接口面(对外契约,不能断)
|
||||
|
||||
| 调用方 | 现状 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| memoryface.go GraphMemory 接口 | Recall/RecallSorted/Commit | **签名保留**,实现换块 |
|
||||
| sdk/memory_impl.go (Recall/Commit) | 外部插件走这条 | 同上,返回类型适配 |
|
||||
| corehandler_memory.go | 插件 RPC | 同上 |
|
||||
| cli / webui / healthcheck 插件 | mem.Recall(...) | 签名不变则无感 |
|
||||
| toolcall.go memory_recall 工具 | g.RecallSorted | 输出格式改块(模型侧 description 同步) |
|
||||
|
||||
### 测试(10 个文件)
|
||||
|
||||
recall_order_test / noise_test / graph_test / light_memory_test /
|
||||
scene_test / scene_dedupe_test / reclaim_test / graph_readonly_test /
|
||||
indexer_test / medialive_test —— 随实现重写,判据保留(变异自证的成果不能丢)。
|
||||
|
||||
## 二、迁移步骤(每步可编译可测试)
|
||||
|
||||
### 第 1 步:块召回先行(不动 entities)
|
||||
|
||||
- GraphDB.RecallBlocks(queryVec, topK)——vector.SearchScored 走块
|
||||
- memoryface 加方法(不改旧 Recall 签名)
|
||||
- toolcall 的 memory_recall 输出切到块结果
|
||||
- **验收**:真库跨维度探针 ≥4/5(用已验证判据)
|
||||
|
||||
### 第 2 步:Commit 换块(写入侧切换)
|
||||
|
||||
- distill 产出 → 原句块+字段块+contains 边(重构计划第 2 步)
|
||||
- memory_commit 工具同样改块
|
||||
- **旧 Commit 保留但标记 Deprecated**,仅存量读取用
|
||||
- **验收**:新写入 0 行进 entities(用计数断言)
|
||||
|
||||
### 第 3 步:存量实体迁移为块
|
||||
|
||||
- 沿 MigrateLegacyMediaEntities 方向写 MigrateLegacyEntities:
|
||||
实体名→text block(带向量),关系→block edges
|
||||
- 可回滚:先快照 graph.db,迁移后探针验证,确认后清理
|
||||
- 孤儿(49 个无关系无原句)→ 原样转为孤立块,不编造连接
|
||||
- **验收**:迁移后跨维度 ≥4/5;快照可回滚
|
||||
|
||||
### 第 4 步:物理删除(最后一步,不可逆)
|
||||
|
||||
- 删 entities/relations/sentences 三表 + graph.go 全部相关 SQL
|
||||
(26+29+13 处)+ noise.go/scene.go/indexer.go 的实体路
|
||||
- 删 Entity/Relation/RecallResult 类型,接口面统一块类型
|
||||
- 删相关测试,保留判据重写为块版
|
||||
- **验收**:`grep -r entities internal/` 零命中;全量测试绿;
|
||||
知识库 content.md 的记忆系统描述同步更新
|
||||
|
||||
## 三、风险与回滚
|
||||
|
||||
- 每步一个 commit,第 4 步前任意一步可 revert
|
||||
- 第 3 步动真库前必须快照(cp graph.db graph.db.bak-<ts>)
|
||||
- 生产实例 ${HA_DATA} **不在本次范围**(需人工确认后单独做)
|
||||
|
||||
## 四、明确不变
|
||||
|
||||
- memory_recall 工具名与参数(调用方模型无感知)
|
||||
- pkg/embedding provider SPI(内核无感边界)
|
||||
- scene 机制(改挂块节点,机制本身保留)
|
||||
- vector.Store / qwen3vl 双模式权重(直接复用)
|
||||
391
docs/zh/future-plan.md
Normal file
391
docs/zh/future-plan.md
Normal file
@ -0,0 +1,391 @@
|
||||
# 未来计划(Future Work)
|
||||
|
||||
> **本文是唯一登记「尚未实现 / 已规划未做 / 已知缺口」的文档。**
|
||||
>
|
||||
> 建立于 2026-10-31。此前这些内容散落在各设计文档的「未做」「待补」「遗留」小节里,
|
||||
> 时间一长就与代码脱节(有的已完成却仍写着未做,有的未做却被当成已完成)。
|
||||
>
|
||||
> ## 收录规则
|
||||
>
|
||||
> **只收两类:**
|
||||
> 1. **代码里确实没有的能力**(已用 codegraph / 实测确认)
|
||||
> 2. **有机制但零生产调用方**(本仓最常见的缺陷形态,见 §0)
|
||||
>
|
||||
> **不收:**
|
||||
> - 已经在做的事(那不是"计划",是"进行中",写在对应设计文档里)
|
||||
> - 历史决策与被否决的方案(那是归档,写在 `legacy-issues.md` 等历史文档里)
|
||||
>
|
||||
> ## 状态标记
|
||||
>
|
||||
> | 标记 | 含义 |
|
||||
> |---|---|
|
||||
> | 🔴 | 有真实影响,应当优先做 |
|
||||
> | 🟡 | 该做,不紧急 |
|
||||
> | ⚪ | 有价值但优先级低 / 依赖前置条件 |
|
||||
> | ⏸ | 主动搁置(有明确理由,不是忘了) |
|
||||
|
||||
---
|
||||
|
||||
## 〇、先说清一个反复出现的缺陷形态:**机制 ≠ 接线**
|
||||
|
||||
本仓已有至少 **5 例**「代码写对了、判据全绿、生产里从不执行」的缺陷。
|
||||
它们比 bug 更难发现,因为:
|
||||
|
||||
- 策略表存在、判定函数正确、日志里一条拒绝记录都没有 ⇒ 看起来「没人违规」
|
||||
- 判据全绿,因为判据**直接调用**被测量的函数 ⇒ 测不到"有没有人调它"
|
||||
|
||||
| # | 能力 | 机制 | 曾有的缺陷 | 现状 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 工具黑白名单 `Policy.CheckTool` | 完整 | `Agent.security` 恒 nil,闸门恒放行 | ✅ 2026-10-30 修复(依赖图补 `FacSecurity`) |
|
||||
| 2 | 主体策略 `CheckToolFor` | 完整 | 0 生产调用方 | ✅ 2026-10-30 修复(父为子声明) |
|
||||
| 3 | 插件命名档位 `ActivateProfile` | 完整 | 曾 0 生产调用方 | ✅ 2026-10-31 修(见 §1.1,两次修才修对) |
|
||||
| 4 | 敏感词 / 注入预警 `ScanContent` | 完整(254 行) | **0 生产调用方** | 🔴 **未修**,见 §1.2 |
|
||||
| 5 | 沙箱 `CheckPath` / `CheckNetwork` | 完整 | **0 生产调用方** | 🔴 **未修**,见 §1.3 |
|
||||
|
||||
**做任何新能力之前,先问一句:这条链的每一跳,生产里真的有人调吗?**
|
||||
|
||||
---
|
||||
|
||||
## 一、安全(security)
|
||||
|
||||
### 1.1 ✅ 插件命名档位已接通(2026-10-31,两次提交)
|
||||
|
||||
> **本节已结案,保留是因为它的两次修法本身是教训。**
|
||||
> 最初的诊断是「`activateSecurityProfile` 零调用方,在 `prepareInputTask`
|
||||
> 里加一次调用」—— **那个诊断方向是错的**。
|
||||
|
||||
**真正的缺陷分三层:**
|
||||
|
||||
```
|
||||
第 3 层(表面):activateSecurityProfile 零调用方
|
||||
↑ 补一次调用「看起来」就好了
|
||||
|
||||
第 2 层:agent/core 在替 security 做**策略决策**
|
||||
· 自己从 payload 抠 security_profile(解析 security 的协议)
|
||||
· 自己判断 parentID != "" → 走 CheckToolFor,否则 CheckTool
|
||||
· 自己拼 PrincipalPolicy{...} 字面量
|
||||
· 自己写 Kind: "resident" —— **声明自己是哪类主体**(提权口子)
|
||||
|
||||
第 1 层(根因):security 缺少「输入接入点」
|
||||
判定原语是细粒度的,要求调用方自己知道"我是谁、该走哪条链"。
|
||||
于是每个消费方都得理解策略语义,三条输入路径各接一遍。
|
||||
```
|
||||
|
||||
**收敛后的形态**(`5194a96`):
|
||||
|
||||
```
|
||||
RoundInput{AgentID, IsChild, Payload, Source} ← 只有事实,无 Kind
|
||||
rt.BeginRound(in) → security 自己解析档位 + 自己判定主体类别
|
||||
round.CheckTool(t) → 本轮判定
|
||||
round.End() → 归位
|
||||
```
|
||||
|
||||
**再进一层:每 agent 一个实例**(`8293282`,用户裁定「每个 agent 一个,
|
||||
分为 sessionProfile 和 longtimeProfile」):
|
||||
|
||||
```
|
||||
共享(进程级 1 份) per-agent(N 份)
|
||||
───────────────────── ─────────────────────
|
||||
gate / policy / content sessionProfile ← 轮次级,End 即清
|
||||
profiles / principals longtimeProfile ← agent 生命期,跨轮保留
|
||||
scopes / eventPolicy
|
||||
```
|
||||
|
||||
优先级 **session > longtime**,这个顺序本身是安全语义:
|
||||
session 是"这一轮的事实",必须能盖住 long-time 默认值(否则访客在长期
|
||||
为 owner 的 agent 上无法收窄);反向就是提权。
|
||||
|
||||
**判据**:`TestAgentScopesDoNotShareSessionProfile` /
|
||||
`TestLongtimeProfileSurvivesRounds` / `TestReleaseAgentDropsLongtimeProfile` /
|
||||
`TestAgentCoreHoldsNoPolicySemantics`(源码扫描,禁 agent 再做策略决策)/
|
||||
`TestEveryInputPathDeclaresRound`(三条输入路径都要声明轮次边界)。
|
||||
|
||||
**顺带暴露的一个仍未解决的限制**:`Round` 是 per-round 句柄,但
|
||||
`Runtime.active`(旧路径)仍是进程级。per-agent 视图已经解决**并发 agent 串档**,
|
||||
但**同一 agent 内**若将来出现并行子任务,仍需按 task 隔离。
|
||||
当前所有路径都是串行的,故不构成缺陷。
|
||||
|
||||
---
|
||||
|
||||
### 1.2 🔴 敏感词 / 提示词注入预警从未运行
|
||||
|
||||
**现状**:`internal/security/content.go`(254 行)实现了完整的
|
||||
`ContentPolicy{Scan, BlockedBy}`,`Runtime.ScanContent` 也写好了,
|
||||
但**全仓零生产调用方**(只剩 2 条测试在调)。
|
||||
|
||||
**影响**:
|
||||
|
||||
- 配置里写的敏感词一条都不生效
|
||||
- 模型被提示词注入时内核不预警、不阻断
|
||||
- 插件通过 `SecurityProfile.SensitiveWords` 声明的档位敏感词同样不生效
|
||||
|
||||
**要做的**:在**入向边界**接线。合理位置是 `executeToolCallOutcome` 的 defer
|
||||
(那里已经有 `security.Global().Replace(...)` 做凭据脱敏,同一个位置、
|
||||
同一条 defer 分支),以及插件注入的输入在进 `prepareInputTask` 之前。
|
||||
|
||||
**注意**:不要只接一处。入向有两条路——工具结果、插件输入事件。只接一条会
|
||||
留下「工具的返回被扫了、插件消息没扫」的缺口,而后者才是注入的主要入口。
|
||||
|
||||
### 1.3 🔴 沙箱约束从未运行
|
||||
|
||||
**现状**:
|
||||
|
||||
| 能力 | 机制 | 生产调用方 |
|
||||
|---|---|---|
|
||||
| `Policy.CheckPath(方向, 路径)` | 完整,拦 `..` 逃逸 | **0** |
|
||||
| `Policy.CheckNetwork()` | 完整 | **0** |
|
||||
| `Runtime.CheckPath` / `Runtime.CheckNetwork` | 薄包装 | **0**(只被测试调) |
|
||||
|
||||
配置项 `core.security.sandbox.*` 全套已注册(`enabled` / `read_roots` /
|
||||
`write_roots` / `allow_network`),`ApplyPolicyFromConfig` 也确实把它们装进了
|
||||
`Policy` —— 但**没有任何模块拿它判路径**。
|
||||
|
||||
**当前唯一真实生效的路径约束**在 files 插件自己的 `pathWithinSandbox`
|
||||
(`internal/plugins/files/plugin.go`)—— 这正是设计文档批判的
|
||||
「每个插件各写一套」形态。
|
||||
|
||||
**要做的**:两个选择,需先定方向。
|
||||
|
||||
- **A(内核)**:在工具参数校验之后、分派之前,对带路径/URL 的参数做 `CheckPath` / `CheckNetwork`
|
||||
- **B(插件)**:把 `CheckPath` 经 SDK 暴露给插件,让每个 IO 插件自己调(**不推荐** ——
|
||||
会重新长回"各写一套")
|
||||
|
||||
倾向 A,但要先解决"哪些参数算路径"的判定(不能瞎猜 `command` 里的字符串)。
|
||||
|
||||
### 1.4 🟡 插件无法声明主体策略
|
||||
|
||||
**现状**:公开 SDK 的 `SecurityAPI` 只有 `DeclareProfile` / `DeclareProfiles`
|
||||
(命名档位)。`Runtime.DeclarePrincipalPolicy` 存在但**未暴露给插件**。
|
||||
|
||||
**区别**:档位是「按输入携带的参数选」,主体策略是「按主体 ID/标签授予」。
|
||||
QQ 需要前者(owner vs guest 由发送者身份选),但一个需要「按具体用户 id 授予
|
||||
工具」的插件需要后者。
|
||||
|
||||
**注意**:暴露前要想清 scope。`scope="tag"` 是**部署级**语义,让插件声明 tag 策略
|
||||
会让插件有能力影响全站 —— 那是个提权口子。倾向只暴露 `scope="id"`。
|
||||
|
||||
### 1.5 🟡 凭据库(变量库)的六个未决项
|
||||
|
||||
从 `kernel-secret-firewall-design.md` §5 迁来(原 T5–T10)。**T1–T4 已落地**,
|
||||
不再重列;下面六项代码里确实没有。
|
||||
|
||||
| # | 事项 | 为什么要做 |
|
||||
|---|---|---|
|
||||
| T9 | **不自动淘汰的规模上限** | 变量库落盘文件只增不减。需要**可观测**的降级(超 N 条时告警),而不是静默删某条 |
|
||||
| T10 | **提交工具的泄露面** | "主动提交"要把明文交给内核 ⇒ 明文会进会话/日志。应优先支持 `${ENV}` / `@file:` 两条**不明文入会话**的路径 |
|
||||
| T5 | **压缩对别名的影响** | 上下文压缩的摘要可能把 `@{sec_1}` 当普通文本;压缩后别名是否仍有效未验证 |
|
||||
| T6 | **明文在内存中的擦除** | Go 字符串不可变 ⇒ 只能擦可写副本。要先定"努力擦除"还是"不承诺" |
|
||||
| T7 | **引用外部文件、内核只持路径** | SSH 私钥这类"内核拿不到字节"的场景,可能需要只持路径的形态 |
|
||||
| T8 | **入向回填的安全边界** | 回填后 agent 可把 `@{别名}` 写进**任意**命令参数 ⇒ 等于"在任意命令里用明文"。这**是必需的**(否则没法用密钥),但"能做什么"因此回到**命令本身**。要定:要不要限制哪些工具允许触发回填 |
|
||||
|
||||
### 1.6 🟡 硬私有的判定目前靠插件自己
|
||||
|
||||
**现状**:QQ 的 `hard_private_tools` 由插件读配置后通过 `DenyTools` 声明
|
||||
(而不是内核的 `HardPrivateTools` 语义)。
|
||||
|
||||
差异:`DenyTools` 是「这个主体不能用」,`HardPrivateTools` 是「**任何**用户主体不能用」。
|
||||
后者防的是「用户偷看私人数据」,而内部执行体(父的委派)本就有完整权限 ——
|
||||
即使用错了也不会误伤内部,但语义是对的。
|
||||
|
||||
**要做的**:QQ 插件把 `hard_private_tools` 从 `DenyTools` 改成
|
||||
`HardPrivateTools`。纯插件改动,声明链路已贯通。
|
||||
|
||||
---
|
||||
|
||||
## 二、内核模块化
|
||||
|
||||
### 2.1 🔴 device 模块是空壳 —— 工具执行仍在 agent
|
||||
|
||||
**现状**:
|
||||
|
||||
| 项 | 设计意图 | 实际 |
|
||||
|---|---|---|
|
||||
| 工具枚举 | device 拥有 | ✅ `device.Runtime.Tools()` 有,但**唯一调用方是它自己的 ToolCount()** |
|
||||
| 工具路由 | device 拥有 | ❌ `toolcall.go` 实际调的是 `toolroute.Dispatch`,不是 `device.Dispatch` |
|
||||
| 工具执行 | device 拥有 | ❌ `device.Runtime.Execute` **0 生产调用方** |
|
||||
| inputch / outputch | device 拥有 | ❌ 仍在 `internal/agent/io` |
|
||||
| 安全过境 | device 是唯一落点 | ❌ device 拿到的 `ks.Security` 没有任何执行路径会用到 |
|
||||
|
||||
`ks.Device` 与 `Agent.MountDevice` 的产物没有任何读取方 ——
|
||||
`agent.Device()` 除了自己没人调。
|
||||
|
||||
**这不是"漏接线",是刻意的分阶段**(见 `internal/kernelmod/device/mod.go` 包注释):
|
||||
第一阶段先立边界与闸门,第二阶段再搬实现。**文档应当如实这么写,而不是写成已完成。**
|
||||
|
||||
**要做的(第二阶段)**:把 `tooldefs` / `toolcall` 的实现迁进 device。前置条件:
|
||||
|
||||
- device 对 Agent 内置处理器的窄接口(`api.BuiltinToolHandlers` 已有雏形)
|
||||
- 工具执行中的上下文/超时/取消/panic 恢复要一起搬
|
||||
- 记忆策略(`ContextPolicy` / `RecallPolicy`)的归属要定清
|
||||
- 独立工具(memgc 等)无 device 时的退化路径
|
||||
|
||||
### 2.2 🟡 `device` 设施无人消费
|
||||
|
||||
`device` 模块 `Provides FacDevice`,但**没有任何模块 `Requires` 它**(实测)。
|
||||
它是图的叶子。
|
||||
|
||||
**含义**:漏挂 device 模块时,所有判据仍绿(见 `bootgraph_test.go` 里
|
||||
`TestEveryRegisteredModuleIsCompiledIn` 的注释已经写明这类盲区)。
|
||||
在第二阶段完成前,这属于预期状态;完成后应有模块依赖它。
|
||||
|
||||
### 2.3 🟡 声明↔真实读取的漂移判据缺失
|
||||
|
||||
**历史**:设计文档 §2.7.3 点名了 `TestDeclarationsTraceToRealCallSites`
|
||||
(「声明与真实签名不漂移」),它随 `3864f36`(删死包 `kernelboot/modules`)
|
||||
迁包时**没有搬过来**,现在仓库里不存在。
|
||||
|
||||
**为什么它重要**:`kernelmod/agent` 的 `Requires()` 曾经少了 `FacSecurity` ——
|
||||
代码读 `ks.Security` 却不在图上声明。**这条判据正是用来抓这种漂移的**,
|
||||
它缺失期间,`agent→security` 这条边断了整整一轮发布。
|
||||
|
||||
**要做的**:写一条判据,扫描各模块 `Init` 及同目录非测试代码里对
|
||||
`ks.<Field>` 的读取,与 `Requires()` 的设施集合比对,报告「读了但没声明」。
|
||||
|
||||
**已知的例外**(判据要放行,不能误报):
|
||||
|
||||
- **Group 0 产物**(`Cfg` / `CfgReg` / `DataDir` / `AgentWorkDir` / `CLISocket` /
|
||||
`HTTPAddr`)—— 它们不由任何可调度模块提供,靠"阶段前提"保证。
|
||||
这类靠各模块自己的 nil 检查守(见 `nilcheck_test.go`)
|
||||
- **自产自读**(如 text-memory 读自己的 `ks.TextMemory`)
|
||||
- **KernelState 的方法**(`AddCloser` / `AddQuiescer`),不是字段
|
||||
|
||||
### 2.4 ⚪ `deploy/depgraph-decls.json` 已无消费者
|
||||
|
||||
手写声明清单,生成器早已改为读模块的 `Requires()/Provides()`。文件仍在仓库里,
|
||||
是误导性遗留(新人会以为要维护它)。
|
||||
|
||||
**要做的**:删文件,或改成明确的 deprecated 说明。
|
||||
|
||||
---
|
||||
|
||||
## 三、记忆
|
||||
|
||||
### 3.1 🟡 `OversizeToolReports` / `oversizeHintFor` 零消费者
|
||||
|
||||
**现状**:`toolresult_budget.go` 统计了「本任务有多少次超大工具结果」,
|
||||
但除了它自己没人读。`offloadToolResult`(超长结果转存 + 句柄回读)**已经接好**了,
|
||||
所以主路径不受影响。
|
||||
|
||||
**缺的是**:把统计接进调度器/下一轮决策,让模型知道"你这次查询太宽了"。
|
||||
|
||||
### 3.2 ⚪ 图库 `memory_blocks.data` 单表承载媒体
|
||||
|
||||
**现状**:媒体字节直接存在 `memory_blocks.data` BLOB(块 union 化,CAS 已淘汰)。
|
||||
生产实测:330MB 库中 299MB 是这个列(563 个有负载的块,去重后 556 个 —— 重复率低,不是问题)。
|
||||
|
||||
**观察,非缺陷**:这是刻意的 union 化设计(元数据与负载同行)。若将来库膨胀,
|
||||
优化点是分表或外置 blob 存储,不是"加回 CAS"。
|
||||
|
||||
---
|
||||
|
||||
## 四、日志与运维
|
||||
|
||||
### 4.1 🔴 日志保留策略只清 1/4 的归档类型
|
||||
|
||||
**现状**(实测,见 `internal/log/retention.go`):
|
||||
|
||||
`cleanupBeforeMonths`(默认 `n_months` / 3)**只处理 `month_` 和 `year_`**,
|
||||
完全不碰 `week_` 和裸 `homed_*.log`。
|
||||
|
||||
探针实测(cutoff 3 个月):
|
||||
|
||||
```
|
||||
输入: month_2026-18 month_2026-02 week_2026-W18 week_2026-W44 year_2026
|
||||
保留后: month_2026-18 week_2026-W18 week_2026-W44
|
||||
(month_2026-02 与 year_2026 被删,符合预期;半年前的 week_ 永远留着)
|
||||
```
|
||||
|
||||
**更硬的证据:`month_*.tar.gz` 的命名根本没有月份。**
|
||||
|
||||
`compressMonthly` 用 `key := fmt.Sprintf("%s-%s", m[1], m[2][:2])`
|
||||
(m[1]=年,m[2]=ISO 周)⇒ 产出 `month_2026-27` / `month_2026-38`。
|
||||
而 `cleanupBeforeMonths` 用 `time.Parse("2006-01", f[6:13])` 解析 ⇒
|
||||
**月=27 越界解析失败 ⇒ `continue` ⇒ 一个都不删**。
|
||||
|
||||
生产目录里 `month_2026-27` ~ `month_2026-39` 全都是这种畸形名,
|
||||
且 `compressMonthly` 遇到同名文件会 `continue` —— 这 13 个文件永远不会被合并或清理。
|
||||
|
||||
**为什么判据没抓到**:`retention_test.go` 的 5 条判据是**手写**合规文件名
|
||||
(`month_%04d-%02d`),从没跑过 `compressMonthly` 的真实产物。
|
||||
|
||||
**要做的**:
|
||||
|
||||
1. `compressMonthly` 用真实月份做键(从周名推出所属月,或改用 `month_YYYY-MM`)
|
||||
2. `cleanupBeforeMonths` 覆盖 `week_` 与裸 `homed_*.log`
|
||||
3. 判据必须**先跑 `compressMonthly` 拿产物、再跑 retention 删它** ——
|
||||
手写文件名的判据测不到命名不一致
|
||||
|
||||
### 4.2 🟡 生产日志存量未清
|
||||
|
||||
生产 `/home/hologramagent/log`:491 个裸 `homed_*.log`(75MB),其中 **459 个是
|
||||
2026-10-26 崩溃循环**留下的。`week_2026-W43` 只含 1 个文件(应为 22 个)——
|
||||
因为那批崩溃日志在周压缩跑之前就被 `fileExists` 判断挡住了。
|
||||
|
||||
retention 修好之后这些会被逐步清掉,但不会立刻(要等跨月压缩)。
|
||||
|
||||
---
|
||||
|
||||
## 五、文档与工程卫生
|
||||
|
||||
### 5.1 🟡 README / ARCHITECTURE 的工具名陈旧
|
||||
|
||||
实际工具名是 `stablemem_*` / `coldcontext_*`,但对外文档仍写 `memory_recall` /
|
||||
`memory_commit` / `doc_query`。
|
||||
|
||||
| 文件 | 处数 | 性质 |
|
||||
|---|---|---|
|
||||
| `assets/docs/zh/ARCHITECTURE.md` | 6 | **对外文档,必须改** |
|
||||
| `assets/docs/zh/OVERVIEW.md` | 1 | 对外文档 |
|
||||
| `README.md` / `README_EN.md` | 3 / 3 | 对外文档 |
|
||||
| `docs/zh/resident-subagent-design.md` | 2 | 活设计文档 |
|
||||
|
||||
**2026-10-31 状态**:已修正(见 §六 变更记录)。
|
||||
|
||||
### 5.2 🟡 plan.md 不进 git,却被 43 个 .go 文件当"单一事实来源"引用
|
||||
|
||||
- `.gitignore` 第 101 行忽略 `/plan.md`
|
||||
- 43 个 `.go` 文件的注释里写着「见 plan.md §X」
|
||||
- 而 plan.md 的状态表**已经谎报过一次**(把 `SetSecurity` 记成已完成,
|
||||
实际生产 0 调用方)
|
||||
|
||||
**要做的**:要么把 plan.md 纳入 git 并建立"状态表必须有判据背书"的规则,
|
||||
要么把引用改为指向具体设计文档而不是 plan.md。
|
||||
|
||||
### 5.3 ⚪ CI 不跑 gofmt
|
||||
|
||||
`make lint` 有 golangci-lint(warn-only,本机未安装),但 `ci.yml` 没有 gofmt 步骤。
|
||||
2026-10-30 曾检测到 1 处漂移(`rtchild_route_test.go`),已顺手清掉。
|
||||
|
||||
### 5.4 ⚪ 工作区体积
|
||||
|
||||
`build/` 7.2G + `dist/` 6.6G + `.npm-cache` 5G = 约 20G(git 只跟踪 1,313 文件)。
|
||||
`dist/_old-20261017` 3.2G 是历史发行包。
|
||||
|
||||
---
|
||||
|
||||
## 六、分散在各设计文档里的待办(索引)
|
||||
|
||||
下面这些待办**留在原文档**里更合适(它们与那份文档的实验/设计强耦合),
|
||||
这里只做索引,避免"想找待办要翻 30 篇文档"。
|
||||
|
||||
| 待办 | 在原文档 | 性质 |
|
||||
|---|---|---|
|
||||
| **弃答:泛指词检测未实现** | `abstention-criterion.md` §未完成 | 🟡 拒答主路径已接(`RecallBlocksGuarded`),泛指词这一支没做 |
|
||||
| **弃答:n=10 判据样本偏小** | 同上 | ⚪ 方向确定(确定性事实+完全分离),样本可扩 |
|
||||
| **遗留表退役:生产仍在用旧三表** | `legacy-table-retirement.md` | 🔴 迁移工具已就绪(`internal/memory/migrate_entities.go`),生产未切换。2026-10-31 实测:entities 1292 / relations 978 / sentences 66 行仍在。方案原文已随计划文档删除,剩余读方清单见 `legacy-table-retirement.md` §八/§十二 |
|
||||
| **场景:现网垃圾场景清理 + 收口验证** | `future-plan.md`(原计划已删) | ⚪ 代码侧 7 步里 5 步已完成(归一化/ScenePolicy/R3/R5/R4 覆盖面);剩「清现网垃圾场景」与「收口验证」属现场操作 |
|
||||
| **中断子系统独立化** | `interrupt-subsystem-split-plan.md` | ⚪ 计划未实施;当前中断实现仍在 `agent/core`(`interrupt.go` + `scheduler.go`) |
|
||||
| 测试模板库(压力/接口/编译预置模板) | `commondev-design.md` §4「尚未做」 | ⚪ 机制已落地,模板按需补 |
|
||||
| 记忆召回「形态 3 / 形态 4」两个缺口 | `recall-capability-tiers.md` §「待补的能力」 | 🟡 需新实验,方向已列 |
|
||||
| 判据口径与契约对齐 | `abstention-criterion.md` §「待办」 | 🟡 |
|
||||
| 生产召回验证的 3 条遗留 sentence→块 边 | `production-recall-validation.md` | ⚪ 非迁移产生 |
|
||||
| 遗留问题清单 | `legacy-issues.md` §二「真正待办」 | ⚪ 长期清单 |
|
||||
|
||||
---
|
||||
|
||||
## 七、变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|---|---|
|
||||
| 2026-10-30 | 工具策略闸接线(依赖图补 `agent→security`);父为子声明主体策略 + 撤销 |
|
||||
| 2026-10-31 | 建立本文;全量校订 35 篇文档的状态声明与失真表述;修正对外文档的工具名;从 `kernel-secret-firewall-design.md` §5 迁入 T5–T10 |
|
||||
@ -1,5 +1,15 @@
|
||||
# 输入调度器设计(四级中断优先级 · 两类别 · 可抢占 · 现场保存)
|
||||
|
||||
> **状态:已实现并部署。** 两类别、L1–L4、可抢占与现场保存、中断栈、
|
||||
> 任务级回执、`preemptCooldown` 均在生产运行(`internal/agent/core/scheduler.go`)。
|
||||
>
|
||||
> ★ 一个下游的真实差异(2026-10-31 补记):rtchild(实时子 agent)**不走**这套阶梯,
|
||||
> 而是用单槽覆盖(最新者胜)。原因:四级中断的目标函数是「公平、不饥饿」,
|
||||
> rt 要的是「可饿死、只要最新」——两者目标相反,不该共用机制。
|
||||
> 详见 `plan.md` 十一.三 B。
|
||||
>
|
||||
> **未实现项**不在本文维护,见 [`future-plan.md`](./future-plan.md)。
|
||||
>
|
||||
> **模型更正(2026-09-13,据用户澄清重写 §2/§3/§4.1/§6.3/§9/§11/§12)**
|
||||
>
|
||||
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
# 内核模块化与并行装配设计
|
||||
|
||||
> **状态**:设计已定(2026-10-16),实现分阶段推进。
|
||||
> **状态**:**已实现并部署**(设计 2026-10-16,落地完成 2026-10-24,2026-10-31 全量校订)。
|
||||
> 本文是**唯一的装配架构权威**。任何新增内核模块或插件都必须遵循本文的依赖声明契约。
|
||||
>
|
||||
> **本文与 `future-plan.md` 的分工**:本文只写**已落地的事实与设计理由**;
|
||||
> 未实现的事项(如 device 第二阶段、漂移判据补回)一律登记在
|
||||
> [`future-plan.md`](./future-plan.md),本文不重复维护「未做」列表 ——
|
||||
> 那份列表历史上滞后过一次(§2.7.4 曾写「main 仍走串行代码」而实际早已接完)。
|
||||
>
|
||||
> **数字类断言的取数方式**:模块数/层数一律用 `go run ./cmd/homed-depgraph -print`
|
||||
> 现场取,**不要凭文档记忆** —— 本文历史上写错过一次(17 模块 vs 实际 20)。
|
||||
>
|
||||
> 关联文档:`kernel-secret-firewall-design.md`(安全模块的装配点约束是本文 §2.3 的实例)、
|
||||
> `resident-subagent-design.md`(inputch 共享总线是 §5 的既有先例)。
|
||||
|
||||
@ -418,7 +426,7 @@ CGO_ENABLED=1 go build ... \
|
||||
3. **Step 4 后续增量**:把 12 个 `init*` 迁成 `Module`,声明清单即废止,
|
||||
生成器改为 import 模块登记包(那时"清单与代码不符"**结构上不可能**)
|
||||
|
||||
> **★ 当前已产出的分层结果(实测)**:
|
||||
> **★ 当时的推演结果(2026-10-16,已过时,仅作设计推演保留)**:
|
||||
>
|
||||
> ```
|
||||
> 第 0 层(1 个): config
|
||||
@ -427,7 +435,12 @@ CGO_ENABLED=1 go build ... \
|
||||
> 第 3 层(1 个): agent
|
||||
> ```
|
||||
>
|
||||
> 对比现在硬编码的 **12 步串行** —— 这是本设计给出的**并行度提升的直接证据**。
|
||||
> 对比当时硬编码的 **12 步串行** —— 这是本设计给出的**并行度提升的直接证据**。
|
||||
>
|
||||
> ⚠️ **这 4 层 / 14 模块是 2026-10-16 的状态,不是现状。**
|
||||
> 现状是 **6 层 / 20 模块**(含 `update` / `migration` / `onnx-parser` / `device`),
|
||||
> 随时用 `go run ./cmd/homed-depgraph -print` 取,权威数字见 §2.7。
|
||||
> 保留这段是为了留下「并行度确实提升了」的原始论证,**不要拿它当前情况用**。
|
||||
|
||||
## 2.6 syncbus:插件与内核的状态同步(插件调度的唯一驱动)
|
||||
|
||||
@ -738,11 +751,27 @@ loadOne(name):
|
||||
|
||||
`internal/kernelmod/all` 的聚合 import 把**全部模块引入编译**后,各模块在自己
|
||||
文件中用 Requires() 把 **main() 里隐含在语句顺序中的依赖关系**变成显式声明。
|
||||
实测分层结果(2026-10-24 复核:`go test ./internal/kernelmod/all/ -run TestLayeringYieldsParallelism -v`):
|
||||
|
||||
```
|
||||
6 层 / 17 模块,最宽层 9 个可并行(原来是 17 步严格串行)
|
||||
```
|
||||
> **实测分层结果(2026-10-31 复核)**:
|
||||
> 命令 `go run ./cmd/homed-depgraph -print`
|
||||
>
|
||||
> ```
|
||||
> 第 0 层(3 个,可并行): config, kernel-api, update
|
||||
> 第 1 层(10 个,可并行): doc-store, knowledge-migration, lua, memory,
|
||||
> migration, multimodal, onnx-parser, security,
|
||||
> supervisor, tracker
|
||||
> 第 2 层(3 个,可并行): knowledge-store, llm, text-memory
|
||||
> 第 3 层(1 个): stage-host
|
||||
> 第 4 层(2 个,可并行): agent, plugin-host
|
||||
> 第 5 层(1 个): device
|
||||
> → 20 个模块 / 6 层,最宽层 10 个可并行
|
||||
> digest bd520ec9(图与二进制配套由内嵌 hash 校验)
|
||||
> ```
|
||||
>
|
||||
> ⚠️ **历史数字**:本节早期版本写的是「6 层 / 17 模块,最宽层 9 个」。
|
||||
> 那是 2026-10-24 的事实;此后新增了 `update`(第 0 层)、`migration`、
|
||||
> `onnx-parser`(第 1 层)与 `device`(第 5 层)。**当前数字以上方代码块为准**,
|
||||
> 任何时候都可以用上面那条命令重新取,不要凭文档记忆。
|
||||
|
||||
#### 2.7.1 ★ cfg 的归属:修掉了"无主模块"(用户指出的问题)
|
||||
|
||||
@ -783,28 +812,86 @@ platform-check → heartbeat-early → logging → data-dirs
|
||||
(`cmd/homed/bootstrap_order_test.go` 断言真实相对行号)。
|
||||
判据 `TestGroup0StaysOutOfScheduler` 保证 Group 0 与可调度模块**不重叠**。
|
||||
|
||||
#### 2.7.3 判据 + 变异(全部咬住)
|
||||
#### 2.7.3 判据 + 变异(当时全部咬住)
|
||||
|
||||
| 判据 | 守什么 |
|
||||
> **⚠️ 本节是 2026-10-16 的历史记录,判据名已随迁包发生变化。**
|
||||
> 2026-10-24 的 `3864f36` 删除了死包 `internal/kernelboot/modules`,
|
||||
> 整机启动图判据迁到 `internal/kernelmod/all`。迁移时**只搬了 5 条整机判据**
|
||||
> (`TestRealRegistryAssemblesWholeMachine` / `TestLayerTopologyIsRespected` /
|
||||
> `TestShutdownIsStrictlyReverseLayerOrder` / `TestShutdownLeavesNoResidue` /
|
||||
> `TestEveryRegisteredModuleIsCompiledIn`),下表里另外几条**没搬**。
|
||||
>
|
||||
> 下表左栏是**当时**的名字,右栏是**现在**仓库里真实的判据。
|
||||
> 找不到对应物的那 4 条已登记到 [`future-plan.md` §2.3](./future-plan.md)。
|
||||
|
||||
| 当时写的判据 | 现在真实存在的位置 |
|
||||
|---|---|
|
||||
| `TestDeclaredLayeringMatchesRealTopology` | 9 条关键顺序(每条都来自**真实签名**,非抄写法顺序) |
|
||||
| `TestLayeringYieldsRealParallelism` | 分层确实带来并行(≥2 模块同层) |
|
||||
| `TestKernelAPIIsInFirstLayer` | `initKernelAPI()` 真无依赖 |
|
||||
| `TestGroup0StaysOutOfScheduler` | 副作用顺序不被误放进图 |
|
||||
| `TestConfigModuleRunsAfterGroup0` | cfg 的**阶段前提**(安全块在 Group 0 里、config 不在) |
|
||||
| `TestDeclarationsTraceToRealCallSites` | **声明与真实签名不漂移**(改了签名要改声明) |
|
||||
| `TestNoOrphanFacilities` | 设施名无重复提供者 |
|
||||
| `TestDeclaredLayeringMatchesRealTopology` | `kernelmod/all/registry_test.go` 的 `TestLayeringPreservesCriticalOrder`(10 条关键顺序) |
|
||||
| `TestLayeringYieldsRealParallelism` | 同上 `TestLayeringYieldsParallelism`(仅改名) |
|
||||
| `TestKernelAPIIsInFirstLayer` | 同上 `TestFirstLayerHasNoDependencies` |
|
||||
| `TestGroup0StaysOutOfScheduler` | ❌ **不存在**(已登记 future-plan §2.3) |
|
||||
| `TestConfigModuleRunsAfterGroup0` | ❌ **不存在**(同上) |
|
||||
| `TestDeclarationsTraceToRealCallSites` | ❌ **不存在** — 它正是能拦住 `agent→security` 这类「读了没声明」漂移的判据,见 future-plan §2.3 |
|
||||
| `TestNoOrphanFacilities` | ❌ **不存在**(`kernelboot.Build` 会检重名,但不是独立判据) |
|
||||
|
||||
**变异 5/5 咬住**:漏 lua→llm、漏 migration→store、config 挪回 Group 0、
|
||||
**变异 5/5 咬住**(当时的验收):漏 lua→llm、漏 migration→store、config 挪回 Group 0、
|
||||
去掉 security、签名漂移。其中**变异 3 正是"无主模块"的复现**。
|
||||
|
||||
#### 2.7.4 未做(Step 4b 的剩余)
|
||||
> ★ **2026-10-31 补记**:`TestDeclarationsTraceToRealCallSites` 缺失的代价已经真实现过 ——
|
||||
> `kernelmod/agent` 的 `Requires()` 漏了 `FacSecurity`,代码读 `ks.Security`
|
||||
> 却不在图上声明,导致工具策略闸在生产里恒放行一整轮发布。详见 future-plan §2.3。
|
||||
|
||||
声明层已完成且可验证,但 `main.go` **仍走原来的串行代码**。
|
||||
下一步是把 `Run(plan)` 真接进 main(届时 17 步变 6 层),
|
||||
并让 `loadPluginsScheduled` 取代 `loadPlugins`(syncbus 驱动插件)。
|
||||
|
||||
这两步都要改生产启动路径 ⇒ 需要更谨慎的验证(含真机启动对比)。
|
||||
#### 2.7.4 Step 4b 已完成(原文写「未做」,2026-10-31 更正)
|
||||
|
||||
> **⚠️ 本节原标题是「2.7.4 未做(Step 4b 的剩余)」,内容是错的。**
|
||||
> 它写着「`main.go` **仍走原来的串行代码**,下一步是把 `Run(plan)` 真接进 main」。
|
||||
> 实际 `cmd/homed/main.go:229` 早已调用 `RunKernelModules` → `kernelboot.Run`,
|
||||
> 插件调度也已归位到 `plugin-host` 模块(见 §8)。
|
||||
> 下面的「完成证据」是 2026-10-31 实测。
|
||||
|
||||
**两条当时规划的剩余步骤,现在都完成了:**
|
||||
|
||||
| 当时写的「下一步」 | 完成证据 |
|
||||
|---|---|
|
||||
| 把 `Run(plan)` 真接进 main(17 步变 6 层) | `cmd/homed/main.go:229` `RunKernelModules(ks, hooks)` → `cmd/homed/kmod_runtime.go:120` `kernelboot.Run(plan, ...)`;生产日志逐层打印 `kernel assembly layer N complete` |
|
||||
| 让 `loadPluginsScheduled` 取代 `loadPlugins`(syncbus 驱动插件) | 插件调度已归位 `internal/kernelmod/pluginhost`(§8);生产日志 `[pluginhost] ... tools_ready published` |
|
||||
|
||||
**结构性判据**(守这两步不退化的):
|
||||
|
||||
- `cmd/homed/bootstrap_order_test.go` —— 断言 `RunKernelModules(` 在 main 里的**真实相对行号**
|
||||
(必须晚于 Group 0 的安全块与 `ToConfig`)
|
||||
- `internal/kernelmod/all/bootgraph_test.go` 的 5 条整机判据 —— 真实登记表装配、
|
||||
层间有序、逆序关闭、无残留、模块不漏挂
|
||||
|
||||
#### 2.7.5 模块产物的**实例化粒度**(2026-10-31 补充)
|
||||
|
||||
依赖图保证的是"设施**已就绪**",但没说设施该有几个实例。这在本模块化设计里
|
||||
是个独立决策,已有两个不同的答案:
|
||||
|
||||
| 设施 | 粒度 | 理由 |
|
||||
|---|---|---|
|
||||
| `memory` / `knowledge` / `llm` | **进程级 1 个** | 共享同一份数据源,复制没有意义 |
|
||||
| `security` | **1 个 Runtime + 每 agent 1 个执行视图** | 全局基线共享(否则插件声明对部分 agent 不生效),但**轮次状态**必须 per-agent |
|
||||
|
||||
security 的切分值得记下来,因为**整份复制是错的**:
|
||||
|
||||
```
|
||||
共享(进程级 1 份) per-agent(N 份)
|
||||
───────────────────── ─────────────────────
|
||||
gate / policy / content sessionProfile ← 轮次级,End 即清
|
||||
profiles / principals longtimeProfile ← agent 生命期,跨轮保留
|
||||
scopes / eventPolicy
|
||||
```
|
||||
|
||||
若把整个 Runtime 复制给每个 agent:插件往 A `DeclareProfile("qq.guest")`,
|
||||
B 就看不到这个档位 ⇒ B 上注入 `qq.guest` 报"未声明"。那是最难查的一类缺陷
|
||||
(表现为"时灵时不灵")。所以切分原则是**共享注册表 + per-agent 执行视图**。
|
||||
|
||||
**给未来新模块的判据**:决定实例化粒度时先问两件事 ——
|
||||
|
||||
1. 这份状态是**共享的事实**(配置/注册表)还是**某个执行体的私有状态**(轮次/会话)?
|
||||
2. 复制它会不会让"某个模块声明的东西"对另一个实例不可见?
|
||||
|
||||
### 2.8 完整启动流程与真实依赖(逐行核对,2026-10-16)
|
||||
|
||||
@ -901,7 +988,9 @@ func (d *Distiller) distillLoop() {
|
||||
|
||||
原 main 用 `defer closeMem()` 等 5 处,释放顺序 = **注册顺序的逆序**(LIFO)。
|
||||
模块化后改为 `Put("<facility>.close", fn)` + `CloseAll()` 按**发布顺序逆序**执行
|
||||
⇒ 语义**逐字等价**(判据 `TestBootCtxCloseAllIsReverseOrder` 守)。
|
||||
⇒ 语义**逐字等价**(判据 `TestKernelStateCloseAllIsReverseOrder` 守 ——
|
||||
原名 `TestBootCtxCloseAllIsReverseOrder`,`BootCtx` 改名为 `KernelState` 时判据跟着改名,
|
||||
本文此前未同步,2026-10-31 更正)。
|
||||
|
||||
**(D) 包级 `init()` 注册(BuiltinFactory)**
|
||||
|
||||
@ -1294,7 +1383,8 @@ var i SyncState = nb // ★ i != nil!经典的 typed-nil 陷阱
|
||||
|
||||
接口值**非 nil**(它带类型信息),于是 `bus.IsReady(...)` 解引用空指针 ⇒ **panic**。
|
||||
|
||||
**实测复现**:`TestNilTypedBusSafety` 确认 panic 真实发生
|
||||
**实测复现**:`TestNilTypedBusSafety`(**规划中的判据名,仓库中不存在**;
|
||||
该 panic 形态由 §2.6 的 `SyncBus` 现行为规避)确认 panic 真实发生
|
||||
(`invalid memory address or nil pointer dereference`)。
|
||||
而 panic 在**插件拉起路径**上 ⇒ 整个内核启动失败。
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# 内核性能指标(实测基线)
|
||||
|
||||
> **状态:实测基线。** 数据来自标注的日期/版本;引用前先确认它是否仍对应当前版本。
|
||||
|
||||
本文记录**实测**数据,不是估算。每条都标出测量方法与采集时间,
|
||||
便于复现与追回归。
|
||||
|
||||
|
||||
@ -1,6 +1,44 @@
|
||||
# 内核安全模块(Secret Firewall)设计 · 计划追加
|
||||
|
||||
> **状态:已实现(持续审计中)。** 本文的安全模块、配置加密、变量库统一与 C1–C8 判据已落地;E-1/E-2/E-3/E-4 已完成;剩余项见文末实现边界。本文保留原始设计与逐条审计记录。
|
||||
> **架构形态(2026-10-31 收敛后)**:security 是**外围内核到核心内核之间的防火墙**
|
||||
> —— 它自己提供接口、判定全部内聚在自己内部。调用方只提供**事实**
|
||||
> (谁在跑、有没有父、原始载荷),不提供**结论**(我是哪类主体、该走哪条判定链)。
|
||||
>
|
||||
> 落地形态:**每 agent 一个策略执行视图**(`AgentScope`),共享一个注册表
|
||||
> (`profiles` / `principals` / `scopes`)。档位分两层:
|
||||
> `sessionProfile`(轮次级,End 即清)/ `longtimeProfile`(agent 生命期)。
|
||||
> 详见 `internal/security/agentscope.go` 与 `round.go` 的包注释。
|
||||
>
|
||||
> **状态:部分实现。** 凭据防火墙、配置加密、变量库统一与 C1–C8 判据已落地并**在生产线生效**;
|
||||
> 但**三条能力机制完整却零生产调用方**(敏感词/注入预警、路径沙箱、网络闸)。
|
||||
> 本文保留原始设计与逐条审计记录;未实现项统一登记在
|
||||
> [`future-plan.md`](./future-plan.md) §一。
|
||||
>
|
||||
> ## 接线状态表(2026-10-31 实测,逐个查生产调用方)
|
||||
>
|
||||
> 本仓反复吃亏在「机制写了、判据绿了、生产里从不执行」。所以这里不写✅/❌定性,
|
||||
> 只写**实测的生产调用点**:
|
||||
>
|
||||
> | 能力 | 机制 | 生产调用点 | 状态 |
|
||||
> |---|---|---|---|
|
||||
> | 出向凭据拦截 `PrepareOutbound` | 完整 | `eventguard.go` / 各界代理 | ✅ 生效 |
|
||||
> | 入向凭据清洗 `ScrubInbound` | 完整 | `eventguard.go`、`events/bus.go`、`device/mod.go` | ✅ 生效 |
|
||||
> | 执行前回填 `FillForExecution` | 完整 | stage 管道 | ✅ 生效 |
|
||||
> | 工具黑白名单 `CheckTool` / `checkToolWithProfile` | 完整 | `security/agentscope.go` → `AgentScope.CheckTool` | ✅ **2026-10-31 接通** |
|
||||
> | 按主体授权 `CheckToolFor` | 完整 | 同上(`checkToolForWithProfile` 同时叠主体策略) | ✅ **2026-10-30 接通** |
|
||||
> | 插件命名档位 `DeclareProfile` | 完整 | `sdk/security_impl.go`、`proc/corehandler_security.go` | ✅ 声明侧通 |
|
||||
> | 插件命名档位 `ActivateProfile` | 完整 | `security/agentscope.go` 的 `setSession` + `BeginRound` | ✅ **2026-10-31 接通**(每 agent 独立实例) |
|
||||
> | 敏感词/注入 `ScanContent` | 完整(254 行) | **无** | 🔴 配置里的敏感词一条不生效,见 future-plan §1.2 |
|
||||
> | 路径沙箱 `CheckPath` | 完整 | **无**(只被 Runtime 自己调 Policy) | 🔴 见 future-plan §1.3 |
|
||||
> | 网络闸 `CheckNetwork` | 完整 | **无**(同上) | 🔴 见 future-plan §1.3 |
|
||||
> | 事件总线授权 + 载荷过闸 | 完整 | `security/mod.go`(唯一装配点) | ✅ 生效 |
|
||||
>
|
||||
> ★ **历史的教训(两轮都错)**:
|
||||
> 第一轮把「`Agent.security` 从未被赋值」诊断成「少一个注入点」而补了 `SetSecurity()`,
|
||||
> 但生产装配链从不调它;第二轮才看清根因是 **`kernelmod/agent` 的 `Requires()` 没声明
|
||||
> `FacSecurity`** —— 依赖图没理由把 security 的产物交给 agent。
|
||||
> 所以「接线」必须在**依赖图层面**表达,不能靠事后注入。
|
||||
>
|
||||
> 记录一条由用户纠正后确立的**架构决定**,以及它带来的简化与下沉。
|
||||
>
|
||||
> 撰写时间:2026-10-14。触发:用户在讨论 commondev 加密 shell 时提出两点纠正。
|
||||
@ -387,19 +425,24 @@ internal/security/ 里的一个**进程级单例**(或挂在 ProviderManager
|
||||
|
||||
## 5. 待办与未决
|
||||
|
||||
| # | 事项 | 说明 |
|
||||
> ★ **2026-10-31 迁出说明**:本节里**仍未实现**的事项已全部迁到
|
||||
> [`future-plan.md`](./future-plan.md) §一「安全」。下面保留原表作为**设计讨论的历史记录**
|
||||
> (它们的推理过程本身有价值),但**不要再把这张表当待办清单用** ——
|
||||
> 它历史上滞后过(T3/T4 已落地却仍挂在表里)。
|
||||
> 每行的「现状」列是 2026-10-31 实测结果。
|
||||
|
||||
| # | 事项 | 现状(2026-10-31) |
|
||||
|---|---|---|
|
||||
| **T1** | ~~修 `splitKeyValue` 对 `assigned_secret` 的键名丢失~~ | ✅ **已修**(插件侧;迁内核时可一并搬)。根因:键名后常带空格(`api_key = "..."`),旧判据把“含空格”当成“不像键名”就放弃拆分 ⇒ 整段被当 secret。已配变异验证 |
|
||||
| **T9** | **不自动淘汰的规模上限** | 落盘文件只增不减(除非人主动删)。需定一个**可观测**的降级策略(如超 N 条时**告警**,而不是静默删某条) |
|
||||
| **T10** | **提交工具的泄露面** | “主动提交”要把明文交给内核 —— 与 `enc_store` 同问题(明文会进会话/日志)。
|
||||
应优先支持 `${ENV}` / `@file:` 两条不明文入会话的路径 |
|
||||
| **T2** | 把前稿的 `intercept.go` / `encstore.go` 从插件**迁到内核** | 模式库可复用,别名/存储需重写为内核形态。★ **迁后本插件的 `enc_*` 工具族与加密 shell 应大幅删减**(用户:插件对安全机制无感知) |
|
||||
| **T3** | **密钥从哪来**(内核侧) | 内核是长驻进程;密钥须由部署方提供(systemd 环境变量)还是内核自持密钥文件?未定 |
|
||||
| **T4** | 变量**持久化与生命周期** | ✅ **已定**:落盘加密持久化(重启不丢)、**不自动淘汰**、可主动删(§2.4)。剩余:落盘位置与 masterKey 来源(同 T3) |
|
||||
| **T5** | 压缩(compaction)对别名的影响 | 摘要可能把 `@{sec_1}` 当成普通文本;压缩后别名是否仍有效? |
|
||||
| **T6** | **明文在内存中的擦除** | Go 字符串不可变 ⇒ 只能擦可写副本。要定"努力擦除"还是"不承诺" |
|
||||
| **T7** | 替代方案评估 | 内核是否可能拿不到 secret(如 SSH 密钥文件)⇒ 也许该支持"引用外部文件、内核只持路径" |
|
||||
| **T8** | **入向回填的安全边界** | 回填后 agent 可把 `@{别名}` 写进**任意**命令参数 ⇒ 等于获得“在任意命令里用明文”的能力。这**正是必需的**(否则没法用密钥),但“能做什么”的边界因此回到**命令本身**。要定:要不要限制哪些工具允许触发回填? |
|
||||
| **T1** | ~~修 `splitKeyValue` 对 `assigned_secret` 的键名丢失~~ | ✅ **已修**(插件侧;迁内核时可一并搬) |
|
||||
| **T2** | 把前稿的 `intercept.go` / `encstore.go` 从插件**迁到内核** | ✅ **已迁**(`internal/security/gate.go` + `store.go`;插件对安全机制无感知) |
|
||||
| **T3** | 密钥从哪来 | ✅ **已定且已落地**:内嵌主密钥(构建期 `-ldflags`)+ 磁盘钥回退 + keyring(`internal/security/key.go`) |
|
||||
| **T4** | 变量持久化与生命周期 | ✅ **已落地**:落盘加密持久化(`security/variables.json`),不自动淘汰 |
|
||||
| **T9** | 不自动淘汰的规模上限 | ❌ **未做** → future-plan §1.5 |
|
||||
| **T10** | 提交工具的泄露面(`${ENV}` / `@file:` 不明文路径) | ❌ **未做** → future-plan §1.5 |
|
||||
| **T5** | 压缩对别名的影向 | ❌ **未做** → future-plan §1.5 |
|
||||
| **T6** | 明文在内存中的擦除 | ❌ **未做**(Go 字符串不可变,需先定“努力擦除” vs “不承诺”)→ future-plan §1.5 |
|
||||
| **T7** | 引用外部文件、内核只持路径 | ❌ **未做** → future-plan §1.5 |
|
||||
| **T8** | 入向回填的安全边界(哪些工具允许触发回填) | ❌ **未做** → future-plan §1.5 |
|
||||
|
||||
### 5.1 ★ 配置落盘加密 [已完成 · 真机验证]
|
||||
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# HologramAgent 遗留问题清单
|
||||
|
||||
> **状态:长期清单(部分条目已过时)。** 本文按「仍未完成且经核实」筛选,
|
||||
> 但清单会随代码演进滞后 —— 引用某条前请先用源码复核。
|
||||
> 它**不是** future-plan 的替代:future-plan 收「能力缺失」与「机制在但零调用方」,
|
||||
> 本文收「已知问题与收尾项」。两者有交叉时以 future-plan 为准。
|
||||
|
||||
> **本文定位(2026-09-25 重写)**:只写**仍未完成、且经源码/实测核实确属本仓库**的问题。
|
||||
>
|
||||
> **判定方法**:不继承任何旧档的复选框,回到源码与构建现场逐条重验
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# 旧表清理可行性评估(2026-10-04)
|
||||
|
||||
> **状态:计划(收尾中)。** 遗留表退役的执行记录;剩余读方见文内 §八 / §十二。
|
||||
|
||||
> 结论:**现在不能清理**。55 处生产调用点仍在读写 `entities` / `relations` / `sentences`。
|
||||
> 这份评估回答「能不能删」以及「删之前要先做什么」。
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
# 实战证据:新内核在真实跑分中的行为(2026-10-04 07:13~07:29)
|
||||
|
||||
> **状态:归档说明(历史证据快照)。** 记录 2026-10-04 那次实跑的现场数据。
|
||||
> **不要用它的结论覆盖现在的实现判断** —— 代码已演进,快照只证明"当时是这样"。
|
||||
|
||||
内核 `657ccd7`(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis + memory_recall 时的实例日志。
|
||||
|
||||
## 一、召回来源标签(今天新增的功能)
|
||||
|
||||
@ -1,450 +0,0 @@
|
||||
# 三层记忆系统修复计划:块化重构后的失效面
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**已实施**(2026-10-22)。实施记录见文末 §九。
|
||||
> 原计划写作于同日,下述前提与失效面均经源码与生产数据核实。
|
||||
> 触发:块化统一(`docs/zh/memory-restructure-plan.md`)重构了底层数据结构,
|
||||
> 但上层「层的流动」没有跟着改,导致 L1↔L2 这条链出现结构性失效。
|
||||
>
|
||||
> 本文只写**经源码与生产数据核实**的失效点。每条都标了证据位置,
|
||||
> 不含推测、不含性能断言。
|
||||
|
||||
---
|
||||
|
||||
## 〇、设计前提(不可动摇,任何修法违反即错)
|
||||
|
||||
### 前提 A:记忆块是唯一的物理载体
|
||||
|
||||
`docs/zh/memory-restructure-plan.md` §〇 纠错表原话:
|
||||
|
||||
> | 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。
|
||||
> **blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 |
|
||||
|
||||
`MemoryBlock` 是 union:文本块负载在 `Text`,媒体块在 `Data`(配 MIME/宽高/尺寸)。
|
||||
**同一个块 ID 在 context → doc → graph 全程不变。层是持有关系,不是数据种类的划分。**
|
||||
|
||||
### 前提 B:L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
|
||||
|
||||
这是**层与层的定义**,不是实现细节:
|
||||
|
||||
| | L1 Context | L2 Document |
|
||||
|---|---|---|
|
||||
| 承载单元 | 事件(内联正文 + 块) | 事件(内联正文 + 块) |
|
||||
| 结构 | 同构 | 同构 |
|
||||
| 与模型的关系 | **直接投喂**(进 system prompt 时序) | **不直接投喂**(需经召回才进入 L1) |
|
||||
| 容量 | 最近 n 条 / 上限 m 条 | 无上限 |
|
||||
| 流动性 | 超限时按相似度迁出 | 冷到阈值后蒸馏进 L3 |
|
||||
|
||||
⇒ L2 **不是**「L1 的摘要层」,也**不是**「另一种数据形态」。
|
||||
它就是**同样的事件 + 同样的块**,只是不直接进模型视野。
|
||||
召回 = 把块(连同它所属事件)从"不投喂"的持有层**迁回**"投喂"的持有层。
|
||||
|
||||
### 前提 C:正文(含多媒体正文)内联在块里
|
||||
|
||||
文本块的 `Text` 就是正文;媒体块的 `Data` 就是字节。
|
||||
**不存在"块 + 外部正文"的分离形态**,也不存在靠渲染字符串承载原文的路径。
|
||||
事件是块的容器,不是块的替代品。
|
||||
|
||||
### 前提 D:任何一层都不压缩
|
||||
|
||||
原文必须完整保留。视图(检索摘要、给模型看的渲染)可以是压缩的,
|
||||
但**视图不是记忆**。把视图当记忆存,等于在存储端就丢了原文。
|
||||
|
||||
### 前提 E:Graph 顶层仍是三元组,块只是它的节点实现
|
||||
|
||||
`internal/memory/sentence_block.go` — `putTripleBlocksTx`:
|
||||
|
||||
```go
|
||||
src := MemoryBlock{ID: TripleBlockID(t.Subject), Text: t.Subject, SemanticType: t.SubjectType}
|
||||
dst := MemoryBlock{ID: TripleBlockID(t.Object), Text: t.Object, SemanticType: t.ObjectType}
|
||||
// 边:src --edge_type=Relation--> dst,落在 memory_block_edges
|
||||
```
|
||||
|
||||
三元组 =【节点】【关系边】【节点】,这个**顶层结构没有变**。
|
||||
变的是节点的物理表示:`entities` 表 → `memory_blocks` 表。
|
||||
`entities` 退场不等于三元组退场。
|
||||
|
||||
### 前提 F:内核不得硬编码任何插件行为
|
||||
|
||||
这是**铁律**。任何依赖具体插件名(`agentcli` / `terminal` / `qq` …)的内核分支都是错的。
|
||||
插件的行为差异一律由插件自己**声明**:`ToolDef.NoMemory`、`ToolDef.Cleaner`、
|
||||
`ChannelDef.NoMemory`、`ChannelDef.Cleaner`、`InjectOptions`。
|
||||
|
||||
### 前提 G:SDK 已通过一套等价接口实现同等能力
|
||||
|
||||
公开 SDK(`third_party/homeagent-sdk/sdk/memory.go`)已提供与内核同构的表达:
|
||||
|
||||
| SDK 接口 | 对应的内核概念 |
|
||||
|---|---|
|
||||
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
|
||||
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
|
||||
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
|
||||
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
|
||||
| `MediaAttachment{Digest, MIME, Data}` | 块引用:带 `Data` 为新内容,只带 `Digest` 为引用已有 |
|
||||
| `TextMemoryAPI.Append(TextEvent)` | 时间序文本日志 |
|
||||
|
||||
⇒ **SDK 契约不需要改动**。修的是内核实现没跟上契约语义,不是契约本身。
|
||||
|
||||
---
|
||||
|
||||
## 一、失效面清单(经核实)
|
||||
|
||||
### 失效 1:L2 只存了事件的渲染视图,事件本体被丢弃
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `ContextToDoc`
|
||||
|
||||
`Prune` 交给 L2 的是结构完整的 `[]ContextEntry`(Timestamp/Source/Content/
|
||||
Response/ToolsUsed/ToolResults/Blocks),但落库时被拍平成一个字符串:
|
||||
|
||||
```go
|
||||
for _, e := range entries {
|
||||
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
|
||||
line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) // 正文 → 视图
|
||||
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
|
||||
}
|
||||
content := strings.Join(parts, "\n")
|
||||
```
|
||||
|
||||
只有 `Doc.Content` 被保存。**后果**:事件的时间戳只剩 `HH:MM`,
|
||||
`Response` 只剩 100 rune,工具输出只剩 200 rune,`ToolsUsed` 完全不存。
|
||||
|
||||
这直接违反前提 B(L1/L2 同构)与前提 C(正文内联在块里)——
|
||||
L1 里是完整事件,L2 里却退化成一段文本。
|
||||
|
||||
**生产证据**(`/home/newqqagent/memory/documents/`,1000 篇归档文档):
|
||||
|
||||
| 度量 | 实测值 |
|
||||
|---|---|
|
||||
| response 段恰好 103 字符(=100+`...`) | 311 / 2094 段 = **15.1%** |
|
||||
| 工具输出段恰好 203 字符(=200+`...`) | **7593 / 13261 段** |
|
||||
|
||||
⇒ 原文**在落库那一刻就不存在了**。这不是"召回时没取全",是存储端丢失。
|
||||
|
||||
### 失效 2:L2 里没有文本块,L1/L2 不同构
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `blocksFromEntries`
|
||||
|
||||
块的 `modality` 实测分布(同一批生产文档):
|
||||
|
||||
```
|
||||
L2 块总数 444:image 437 / audio 7 / text 0
|
||||
```
|
||||
|
||||
**后果**:L2 里"块"只承载媒体,文本内容全靠 `Doc.Content` 这个视图。
|
||||
违反前提 B/C——L1 的事件正文 + 块,到 L2 变成了"字符串 + 仅媒体块"。
|
||||
(对照:L3 的文本是有块的,`putTripleBlocksTx` 的主语块/宾语块都是 `BlockText`。)
|
||||
|
||||
### 失效 3:召回时新建合成事件,而非把块按原时序迁回
|
||||
|
||||
**位置**:`internal/agent/core/toolcall.go` — `executeDocTool` 的 `doc_query` 分支
|
||||
|
||||
```go
|
||||
docs := a.docStore.Consume(query, topK) // 读 + 从 L2 删除
|
||||
...
|
||||
a.context.InsertByTimestamp(ContextEvent{
|
||||
Timestamp: d.CreatedAt, // ← 归档时刻,非事件时刻
|
||||
Source: "cold_storage", // ← 新造来源
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content, // ← 渲染视图,且再截 2000
|
||||
})
|
||||
// ← 完全没有把 d.Blocks 挂回去
|
||||
```
|
||||
|
||||
三处偏离设计(`ARCHITECTURE.md` L107-108、L178):
|
||||
|
||||
> doc_query → Consume(读取并删除)
|
||||
> → **逐条** context.Append{Timestamp: **原始时间戳**, Source: 原来源}
|
||||
> → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
|
||||
|
||||
**生产证据**(块自带 `created_at`,与归档时刻差近两小时):
|
||||
|
||||
```
|
||||
媒体块 created_at = 2026-10-12 11:09:51 ← 事件发生时刻(captureBlockMedia 里 time.Now())
|
||||
doc.created_at = 2026-10-12 13:04:54 ← 归档时刻
|
||||
```
|
||||
|
||||
443/443 个媒体块都带有效 `created_at`。**块自带它该回到哪个时刻的坐标,现在没人用。**
|
||||
|
||||
### 失效 4:块在 Consume 时两层同时消失
|
||||
|
||||
**位置**:`internal/memory/document/document.go` — `Consume` → `removeDoc`
|
||||
|
||||
```go
|
||||
func (s *Store) removeDoc(id string) {
|
||||
delete(s.docs, id)
|
||||
...
|
||||
os.Remove(filepath.Join(s.dir, id+".json")) // 块的字节只在这个文件里
|
||||
}
|
||||
```
|
||||
|
||||
`doc_query` 调用 `Consume` 后,文档(含 `d.Blocks`)从内存与磁盘同时删除;
|
||||
而 `toolcall.go` 不回填 `Blocks`。**后果**:这些块既不在 L2(已删)也不在 L1(没挂),
|
||||
只剩局部变量引用,函数返回后即不可达。
|
||||
|
||||
**生产风险敞口**:L2 现有 444 个块,其中 **396 个不在 L3**(`graph.db` 里查不到),
|
||||
原始字节约 **248 MB**。一旦被 `doc_query` 命中即永久丢失。
|
||||
|
||||
### 失效 5:时序渲染用固定 120 rune 截断正文
|
||||
|
||||
**位置**:`internal/agent/core/process.go` — `formatMergedTimeline`
|
||||
|
||||
```go
|
||||
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120)))
|
||||
```
|
||||
|
||||
上下文预算可以是几百 K token(`ComputeTokenBudget`),但每条正文**永远只显示前 120 字**。
|
||||
原文即使用前提 D 完整保存在库里,模型也看不到——这是"不压缩"在**渲染环节**的漏口。
|
||||
|
||||
**生产证据**(`context.json`):2 条 `cold_storage` 事件的 response 分别 1541 / 1209 字符,
|
||||
渲染出来都是 123 字符。
|
||||
|
||||
### 失效 6:`Prune` 的归档过滤要求本身违反前提 F
|
||||
|
||||
**位置**:`ARCHITECTURE.md` L13 写「过滤掉 `agentcli` 和 `terminal` 来源后归档」。
|
||||
|
||||
`agentcli` / `terminal` 是**插件名**。把它们写进内核归档规则 = 硬编码插件行为,违反前提 F。
|
||||
|
||||
**当前代码**:`Prune` 里没有任何源名过滤,该要求**未实现**;
|
||||
生产库里有 325 行 `agentcli` 归档(全部是终端状态行)。
|
||||
|
||||
⇒ 这不是"补上过滤",而是**该要求应作废,改由插件声明驱动**(前提 F)。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复顺序(按依赖)
|
||||
|
||||
失效 1/2 是存储形态,3/4 是召回形态,5 是渲染,6 是文档纠错。
|
||||
**必须先修存储**——存储里没有事件本体,召回就没有东西可放回。
|
||||
|
||||
| # | 动作 | 依赖 | 涉及文件 |
|
||||
|---|---|---|---|
|
||||
| ① | L2 存储形态改为「事件 + 块」(与 L1 同构) | — | `memory/document/document.go` |
|
||||
| ② | 文本正文以块承载(消灭 `Content` 作为唯一文本载体) | ① | 同上 + `agent/core/context.go` |
|
||||
| ③ | 召回按块/事件原始时序迁回 L1 | ①② | `agent/core/toolcall.go` |
|
||||
| ④ | 时序渲染改为按预算分配 | 独立 | `agent/core/process.go` |
|
||||
| ⑤ | 作废失效 6 的文档要求(改由 `NoMemory` 声明驱动) | 独立 | `ARCHITECTURE.md` |
|
||||
|
||||
### ① 的具体形状
|
||||
|
||||
`Doc` 与 `RelevanceContext` 的事件列表**同构**:
|
||||
|
||||
```
|
||||
L1: events []ContextEvent // Timestamp/Source/Input/Response/ToolsUsed/ToolResults/Blocks
|
||||
L2: events []ContextEvent // 同一结构,同一字段
|
||||
```
|
||||
|
||||
- `Doc` 保留 `ID` / `Summary` / `Vector` / `DenseVec` / `DenseFP` / `AccessCount` 等
|
||||
**检索元数据**(这些是 L2 作为"可检索容器"的附加属性,L1 不需要)
|
||||
- 事件列表**内联正文**(`Response` 完整,不截断)+ 持有的块
|
||||
- `Doc.Content` 降级为**纯渲染视图**(供摘要/人看/兼容 SDK 的 `Doc.Content`),
|
||||
不再作为原文载体;原文从事件读
|
||||
|
||||
> ⚠️ 注意与「已否决做法」的区别:不是新造一个异构字段把 `ContextEvent` 旁挂上去,
|
||||
> 而是让 **L2 的承载结构就是 L1 的那一套**。事件列表是文档的主体,不是附件。
|
||||
|
||||
### ② 的具体形状
|
||||
|
||||
- 归档时,每条事件的正文(`Input` / `Response`)生成对应文本块(`Modality=BlockText`,
|
||||
`Text` 即正文),块 `CreatedAt` = 事件时间戳
|
||||
- 媒体块照旧(`Data` 内联字节)
|
||||
- 事件 `Blocks` 里同时含文本块与媒体块 → L1/L2/L3 三处同构
|
||||
|
||||
### ③ 的具体形状
|
||||
|
||||
```
|
||||
doc_query → 取出 L2 事件列表(迁移语义:取出后 L2 不再持有)
|
||||
→ 逐条按各自 Timestamp 插回 L1 时序
|
||||
→ 事件持有的块随之迁回(同一块 ID,向量不重算)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、判据(先写判据再改,避免自证)
|
||||
|
||||
> 教训(本次已发生):先改代码再写测试,测试就只会证明"我改的是我以为的"。
|
||||
> 判据必须**从设计前提推导**,且必须能在改动前**红**。
|
||||
|
||||
### ① 存储形态(L1/L2 同构)
|
||||
|
||||
- [ ] 归档后能从 L2 取回**逐条**事件的原始 `Timestamp`(完整时刻,非 `HH:MM`)、
|
||||
`Source`、`Response`(完整,非截断)、`ToolsUsed`、`ToolResults`
|
||||
- [ ] 变异:把 `Response` 改回 `truncate(..,100)` ⇒ 红
|
||||
- [ ] 变异:丢掉 `ToolsUsed` ⇒ 红
|
||||
- [ ] 变异:时间戳改回 `Format("15:04")` ⇒ 红
|
||||
|
||||
### ② 文本块
|
||||
|
||||
- [ ] 归档文档的文本块 `Text` 与事件正文逐字一致
|
||||
- [ ] 文本块 `CreatedAt` == 事件 `Timestamp`
|
||||
- [ ] 变异:不生成文本块(只存 `Doc.Content`)⇒ 红
|
||||
|
||||
### ③ 召回
|
||||
|
||||
- [ ] `doc_query` 后事件按**各自原始时间戳**回到 L1
|
||||
- [ ] 块 `ID`/`Vector`/`CreatedAt` 全程不变(迁移而非重建)
|
||||
- [ ] 块的持有层从 L2 变为 L1,**不同时留在两层**
|
||||
- [ ] 变异:用 `d.CreatedAt` 当时间戳 ⇒ 红
|
||||
- [ ] 变异:不回填 `Blocks` ⇒ 红
|
||||
|
||||
### ④ 渲染
|
||||
|
||||
- [ ] 预算宽裕时正文完整可见(含结尾哨兵)
|
||||
- [ ] 预算紧张时仍保留开头(不能整条消失)
|
||||
- [ ] 单条超长正文不得把其余事件整段挤掉
|
||||
|
||||
### ⑤ 文档纠错
|
||||
|
||||
- [ ] `ARCHITECTURE.md` 不再出现任何插件名作为内核归档规则
|
||||
- [ ] 判据:`grep -rn '"agentcli"\|"terminal"' internal/agent/core internal/memory`(非测试)为空
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项(此前已犯,写下来防复发)
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 给 `Doc` 加异构字段旁挂事件快照(如 `Entries []ContextEntry`) | L2 主体就是事件列表本身,不是"Doc + 附件" |
|
||||
| 改 `truncateStr` 的常数当作"修复压缩" | 视图截断不是记忆压缩问题;根因在存储形态 |
|
||||
| 任何插件名字面量进内核分支 | 违反前提 F |
|
||||
| 编造不存在的 API 再补注释解释它"应该"怎样 | 实现必须以真实存在的设施为准 |
|
||||
| 先按自己的理解写测试再改代码 | 会把误解固化成判据 |
|
||||
| 改公开 SDK 契约 | 前提 G:SDK 已等价,修内核实现 |
|
||||
|
||||
---
|
||||
|
||||
## 五、已否决的做法
|
||||
|
||||
### 否决 1:新增 `Doc.Entries` 存事件快照(2026-10-22,已实现又撤销)
|
||||
|
||||
错在**异构旁挂**:`Doc` 仍是字符串主体,事件列表被当成附件挂上去。
|
||||
正确形态是 §二①:事件列表**成为 L2 的主体**,与 L1 同构。
|
||||
|
||||
### 否决 2:把渲染层截断常数调大(2026-10-22,已撤销)
|
||||
|
||||
根因在存储形态,不在渲染常数。§二④ 单独处理,且应是**按预算分配**而非改常数。
|
||||
|
||||
---
|
||||
|
||||
## 六、已拍板的决定
|
||||
|
||||
### 1. 事件本体在 L2 的形态(2026-10-22 拍板)
|
||||
|
||||
**L2 就是 doc 存储,本身与 Context 同构,区别只在于是否被直接投喂给模型。**
|
||||
|
||||
⇒ 不是“复用 `ContextEvent` 当旁挂字段”,也不是“把事件也变成块”这种额外抽象。
|
||||
**事件列表就是 L2 文档的主体**,与 L1 事件列表同一结构。
|
||||
|
||||
### 2. 正文形态(2026-10-22 拍板)
|
||||
|
||||
**正文(包括多媒体正文)本身就内联在块里。**
|
||||
|
||||
⇒ 文本块的 `Text` 是正文,媒体块的 `Data` 是字节。
|
||||
不存在“块 + 外部正文”的分离形态,也不靠渲染字符串承载原文。
|
||||
|
||||
### 3. SDK(2026-10-22 拍板)
|
||||
|
||||
**记忆部分 SDK 已通过一套接口实现等价功能** ⇒ **不改公开 SDK 契约**。
|
||||
|
||||
对照表(`third_party/homeagent-sdk/sdk/memory.go`):
|
||||
|
||||
| SDK 接口 | 等价的内核概念 |
|
||||
|---|---|
|
||||
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
|
||||
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
|
||||
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
|
||||
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
|
||||
| `MediaAttachment{Digest, MIME, Data}` | 块引用(带 Data 为新内容,仅 Digest 为引用) |
|
||||
|
||||
⇒ 修的是内核实现没跟上契约语义,不是契约本身。
|
||||
|
||||
### 4. 存量数据(2026-10-22 拍板)
|
||||
|
||||
**等待系统自然冲刷**,不做迁移脚本。
|
||||
|
||||
约束:
|
||||
- 新代码必须能**读旧格式**(缺事件列表/缺文本块时降级),不得 panic、不得静默丢内容
|
||||
- 降级路径要有明确日志,便于判断存量退出进度
|
||||
- 旧格式文档被召回时按旧行为处理,**不伪造成“有原始事件”**
|
||||
|
||||
---
|
||||
|
||||
## 七、待确认的细节(实现层面,不阻塞开工)
|
||||
|
||||
1. **文本块与事件是一对一还是一对多?**
|
||||
一条事件生成一个文本块(`Input` + `Response` 合在一个 `Text` 里),还是各一块(便于分别检索)?
|
||||
|
||||
2. **`Doc.Content` 保留为渲染视图,还是彻底删除?**
|
||||
若保留,与事件列表信息重复;若删除,SDK 的 `Doc.Content` 则由事件列表现场渲染填充。
|
||||
|
||||
3. **L2 事件的检索向量怎么算?**
|
||||
现在 `denseFor` 用 `Summary + Content`。改为事件列表后,
|
||||
是用 `Summary + 各事件正文拼接`,还是用文本块自己的向量做融合(与媒体块一致)?
|
||||
|
||||
---
|
||||
|
||||
## 八、验收口径
|
||||
|
||||
- `go build ./...` 通过
|
||||
- 全量 `go test ./... -count=1` 0 失败(当前基线:58 包通过 / 0 失败)
|
||||
- §三 每条判据有对应测试,且**变异测试**各自能被抓住
|
||||
- 生产侧(可在副本库验证):`doc_query` 召回后块不消失;新旧两种格式文档都能正确召回
|
||||
|
||||
---
|
||||
|
||||
## 九、实施记录(2026-10-22)
|
||||
|
||||
### 已落地
|
||||
|
||||
| # | 动作 | 落地位置 |
|
||||
|---|---|---|
|
||||
| ① | L2 存储形态改为「事件 + 块」 | `document.Doc.Events []DocEvent`;`ContextToDoc` 落盘事件列表 |
|
||||
| ② | 正文各自成文本块 | `docBlocksForEntry` / `entriesToEvents`;Input、Response 各一块(`BlockText`) |
|
||||
| ③ | 召回按原始时序迁回 | `Agent.restoreDocEvents`;`DocEvent.InOut()` 从块读回正文 |
|
||||
| ④ | 蒸馏改用原文 | `documentOriginalText`(有 Events 用块内原文,存量退回 Content) |
|
||||
| ⑤ | 文档纠错 | `ARCHITECTURE.md` 删除「过滤 agentcli/terminal」,改为「由插件声明驱动」 |
|
||||
|
||||
### Content 的定位(按拍板)
|
||||
|
||||
`Doc.Content` **保留为渲染视图**,由 `contentFromEvents` 生成:
|
||||
|
||||
- 供摘要、人工阅读、SDK 的 `Doc.Content` 字段使用
|
||||
- **不是**原文载体 —— 原文在 `Events[i].Blocks` 里文本块的 `Text`
|
||||
- 视图也不主动截断(存储层不替渲染层做决定)
|
||||
|
||||
### 存量
|
||||
|
||||
按拍板**不做迁移**,等系统自然冲刷。降级路径:
|
||||
|
||||
- `Doc.HasEvents() == false` ⇒ 召回时整篇渲成一条 `cold_storage` 事件(旧行为)
|
||||
- 不伪造成「有原始事件」;有日志可观测
|
||||
|
||||
### 判据与变异
|
||||
|
||||
新增两组判据(各条都在改动前设计、改动后可跑):
|
||||
|
||||
| 文件 | 判据数 | 覆盖 |
|
||||
|---|---|---|
|
||||
| `internal/memory/document/blockflow_test.go` | 7 | 存储形态、文本块粒度、逐条保留、视图定位、幂等、存量兼容、原文可用性 |
|
||||
| `internal/agent/core/recalldoc_test.go` | 5 | 逐条回填、原始时间戳、块迁回、回填不截断、存量降级 |
|
||||
|
||||
**变异测试(全部被抓住)**:
|
||||
|
||||
| 变异 | 被哪条抓住 |
|
||||
|---|---|
|
||||
| 正文落库时 truncate(100) | `TestL2_ArchivePreservesFullText` / `TestL2_ContentIsRenderedViewNotSource` |
|
||||
| Input/Response 合成一块 | `TestL2_InputAndResponseAreSeparateTextBlocks`(及 3 条连带) |
|
||||
| 召回用 `d.CreatedAt` 当时间戳 | `TestRecallByDoc_RestoresEachEventAtOriginalTimestamp` |
|
||||
| 召回不回填 Blocks | `TestRecallByDoc_MigratesBlocksBackToContext` |
|
||||
| 召回再截 2000 字 | `TestRecallByDoc_TextIsNotTruncatedOnTheWayBack` |
|
||||
|
||||
### 未做(明确不做)
|
||||
|
||||
- **不改公开 SDK 契约**:`TextEvent` / `DocMemoryAPI` / `InsertWithMedia` 已等价
|
||||
- **不迁移存量文档**:等自然冲刷
|
||||
- **不改渲染层截断常数**(失效 5):根因已在存储形态修掉;渲染按预算分配是独立议题,
|
||||
避免与本次修复混在一起、互相掩盖
|
||||
@ -1,164 +0,0 @@
|
||||
# 记忆系统重构计划:块节点统一载体
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**已执行完毕(2026-10-04)**。本文是当时的执行计划,保留作为决策记录。
|
||||
> 日期:2026-10-02(计划) / 2026-10-04(完成)
|
||||
>
|
||||
> ## 执行结果摘要(2026-10-04 回填)
|
||||
>
|
||||
> | 步骤 | 状态 | 关键提交 |
|
||||
> |---|---|---|
|
||||
> | ① 重写设计文档 | ✅ | `6a0d6b7` `971cd72` `7248558` |
|
||||
> | ② distill 沉库改块节点 | ✅ | `4b54c03` `1c260e3` `0586f6b` |
|
||||
> | ③ memory_recall 加块召回路 | ✅ | `688ad08` `40a6acb` |
|
||||
> | ④ memory_commit 止血 | ⚠️ 部分(见下) | `3bafe1c` |
|
||||
> | ⑤ 存量迁移 | ✅ | `09f31e2` `e0a234c` `5a70452` |
|
||||
> | ⑥ scene 挂接 | ❌ **取消**(见下) | — |
|
||||
>
|
||||
> ### 计划与实际的偏差(都是实测纠正的)
|
||||
>
|
||||
> **⑥ scene 挂接取消**:计划假设 blocks 是新层、scene 挂在它上面。
|
||||
> 但 blocks 不是新层而是图节点升级体,scene 无处可挂。
|
||||
>
|
||||
> **蒸馏路线整体否决**(990b824):生产库 429 条实体里 **424 条(99%)无主语**,
|
||||
> 而 `Split` 的第五道闸门「无主语不写悬空属性」会把 LLM 已拆对的字段全丢弃。
|
||||
> 蒸馏命令已改为**只对有主语的实体跑**(b21410b),实际处理 5 条。
|
||||
>
|
||||
> **注意力网络路线否决**(`6a0d6b7` ~ `57eca02`):分域后 token F1 0.918
|
||||
> 超过门槛,但**跨域不可泛化**(域外零字段),而通用 Agent 无法分域。
|
||||
>
|
||||
> ### 计划里未被采纳的替代方案
|
||||
>
|
||||
> - 「给块补语义描述」→ 实验否定(中性描述反而更差,排名 26 → 96,`7248558`)
|
||||
> - 「给 URL 类事实换更强向量」→ 未测,但裸 URL 无文本可供语义匹配(`971cd72`)
|
||||
>
|
||||
> ### 现行架构
|
||||
>
|
||||
> ```
|
||||
> memory_blocks 统一节点载体(文本/媒体都进这里)
|
||||
> memory_block_edges source_kind → target_kind 多态端点,唯一边表
|
||||
> graph_centroid 中心向量(迁移/蒸馏后自动重建)
|
||||
> ```
|
||||
>
|
||||
> 原句由 `blk_src_<sha256>` 原句块承载(内容派生、幂等、不带向量),
|
||||
> `sentences` 表的**写入点已清零**(迁移与蒸馏都不再写)。
|
||||
>
|
||||
> 详见 `production-recall-validation.md`(三十三~三十八节)、
|
||||
> `recall-capability-tiers.md`(能力分层)、
|
||||
> `vector-provider-decision.md`(provider 决策与缺口)。
|
||||
> 背景:本会话前期的记忆系统改动(630d3f4 / 978bada / 69150d2 / 25d35f8)建立在对记忆架构的错误认知上,经用户逐条纠正后全部重审。本计划是重审后的执行路线。
|
||||
> 前置事实核查:全部基于代码与真库实测,标注来源。
|
||||
|
||||
## 〇、认知纠错记录(为什么推翻重来)
|
||||
|
||||
| 错误认知(当时的做法) | 正确认知(用户纠正 + 代码核实) |
|
||||
| --- | --- |
|
||||
| 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。**blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 |
|
||||
| 给 entities 加向量字段(graph-vector-design.md) | **entities 该退场**。memory_blocks 已是带 vector+fingerprint 的节点载体(46f833c) |
|
||||
| LLM 拆句产物 Commit 成 entities+relations(630d3f4) | 拆出的节点放入 block,保留原句;媒体引用是已删除的老设计(mediaref.go 只剩注释) |
|
||||
| 三元组 = 我发明的 (主语,维度,值) | 三元组 =【节点】【关系边】【节点】;三元组设计文档在知识库 assets/knowledge/homeagent_architecture/content.md |
|
||||
| provider 是蒸馏的配件 | provider 是内核的模型接入接口,内核对 tfidf 等传统算法与向量化 LLM **无感**,payload 一致才能动态接入 |
|
||||
| 多模态要"引用"进图 | 多模态节点由 LLM 多模态能力拆分入库 |
|
||||
| 场景/scene 表忽略不看 | scene 是第三种召回:条件型记忆不吃词法/向量相似,按场面**前缀匹配**直接取回(scene.go:12-38) |
|
||||
|
||||
沉痛教训:630d3f4 的 16 三元组端到端验证——验证的是拆分质量,不是正确的落库形态。**验证通过 ≠ 架构正确。**
|
||||
|
||||
## 一、目标形态(一句话)
|
||||
|
||||
```
|
||||
入库:拆解器(provider 接口,内核无感)把对话/文档/媒体拆成
|
||||
memory_blocks 节点(modality/text_content/vector/fingerprint)
|
||||
+ memory_block_edges 结构边;原句保留为块。
|
||||
召回:块向量检索为主 + scene 场景召回补条件型记忆;
|
||||
entities+LIKE 是存量过渡路径,逐步退场。
|
||||
```
|
||||
|
||||
## 二、既有设施盘点(不重复实现)
|
||||
|
||||
| 能力 | 已有 | 位置 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| 块节点存储 | `PutMemoryBlocks` / `MemoryBlocks` | internal/memory/block.go:67/144 | ✔ 可用 |
|
||||
| 结构边 | `AddMemoryBlockEdge`(端点校验,测试 453/491 行) | block.go:199 | ✔ 可用 |
|
||||
| 块检索 | `BlocksForNode`(按端点反查) | block.go:254 | ✔ 可用 |
|
||||
| 向量检索 | `vector.Store`(Insert/SearchScored/Remove) | internal/memory/vector/store.go | ✔ 可用 |
|
||||
| provider SPI | `pkg/embedding`(Embed/Info/Close,Fingerprint) | pkg/embedding/embedding.go | ✔ 可用 |
|
||||
| VL 权重双模式 | qwen3vl(Embed 出向量)+ qwen3vlgen(tied head 生成) | 25d35f8 | ✔ 已验证 |
|
||||
| 文本拆解 | distill.Extractor(schema 约束+闸门,16 三元组/幻觉0) | internal/memory/distill | 拆分逻辑可复用,**沉库去向要改** |
|
||||
| 旧实体迁移先例 | `MigrateLegacyMediaEntities` | internal/memory/migrate.go:47 | ✔ 方向先例 |
|
||||
| scene 召回 | `RecallByScene` | internal/memory/scene.go:323 | ✔ 已有 |
|
||||
| 拆解生成模型 | ollama+qwen3:1.7b(schema 6/6 零幻觉) | providers/ollama | ✔ 端到端验证过 |
|
||||
|
||||
## 三、分步执行
|
||||
|
||||
### 第 1 步:重写两份设计文档 ✗ 未开始
|
||||
|
||||
- [ ] 删 `docs/zh/graph-vector-design.md`(给 entities 加向量——前提错误)
|
||||
- [ ] 重写 `docs/zh/triple-distill-llm-design.md`:拆解产物=块节点;entities 标注退场路径
|
||||
- [ ] 修正 `providers/qwen3vlgen` 与 `pkg/generation` 的定位注释:不是"蒸馏配件",是入库拆解器的模型面
|
||||
- **验收**:文档里的每张表名/函数名都能 grep 到;无 entities 加向量字段的任何残留表述
|
||||
|
||||
### 第 2 步:distill 沉库改块节点(核心) ✗ 未开始
|
||||
|
||||
- [ ] `distill.Extractor.Split` 产出改为块结构(新增 `distill.Blocks` 类型,保留 Triple 输出做对照期)
|
||||
- [ ] 原句 → text block(text_content=原句, vector, fingerprint)
|
||||
- [ ] 字段 → text block(text_content=字段内容, vector, fingerprint)
|
||||
- [ ] 边:原句 block —contains→ 字段 block
|
||||
- [ ] 向量来源走 `pkg/embedding`(默认 provider 由配置决定;无 provider 时 vector 留空+fingerprint 空,**不静默编造向量**)
|
||||
- [ ] pipeline.go 的 distillBatch 改调块写入
|
||||
- **判据**:
|
||||
- [ ] 拆一条真实记录后,MemoryBlocks/MemoryBlockEdges 能查到原句块+字段块+边
|
||||
- [ ] 断电安全:PutMemoryBlocks 是事务(block.go 已有端点校验事务先例)
|
||||
- [ ] 无 provider 时块仍入库(vector=''),检索退化为符号路,**不报错不编造**
|
||||
- **变异自证**:把"contains 边"删掉,块查询测试必须红
|
||||
|
||||
### 第 3 步:memory_recall 增加块召回路 ✗ 未开始
|
||||
|
||||
- [ ] GraphDB 增加 `RecallBlocks(queryVec, topK)`:走 vector.SearchScored
|
||||
- [ ] 工具层 `memory_recall`:块向量召回为主,entities LIKE 为存量兜底,输出统一格式
|
||||
- [ ] 工具名/参数不变(调用方模型无感知)
|
||||
- [ ] fingerprint 不匹配时**拒绝比较**(报"向量空间已变更,需回填"),绝不拿 2048 维查询比 512 维库
|
||||
- **判据**(真库 + 跨记录探针,用本会话已验证的判据):
|
||||
- [ ] 跨维度定位 ≥4/5(现状 LIKE 0/5)
|
||||
- [ ] 端口类数字串不退化(向量弱、符号强——混合必要性)
|
||||
- **变异自证**:去掉块召回路,跨维度判据必须退化到现状水平
|
||||
|
||||
### 第 4 步:memory_commit 同步止血 ✗ 未开始
|
||||
|
||||
- [ ] description 明确要求:拆解后走块形态;禁止 43-50 字符整句实体
|
||||
- [ ] 工具实现侧加闸门:超长 subject 拒绝并提示(与 distill 闸门同款)
|
||||
- **判据**:新写入的实体长度分布不再出现 >25 字符主体
|
||||
|
||||
### 第 5 步:存量迁移 ✗ 未开始
|
||||
|
||||
- [ ] 沿 `MigrateLegacyMediaEntities` 方向写 `MigrateLegacyTextEntities`:
|
||||
188 实体 → 原句块(带向量)+ 字段块 + contains 边
|
||||
- [ ] 独立命令入口(参照 homed-kb-migrate),**可回滚**:导出原库快照→迁移→对比→确认后替换
|
||||
- [ ] 孤儿实体(49/108 无关系无原句)保持原样,不编造
|
||||
- **判据**:迁移后跨维度探针 ≥4/5;原库快照可完整回滚
|
||||
|
||||
### 第 6 步:scene 挂接(可选,后置) ✗ 未开始
|
||||
|
||||
- [ ] 拆解块带 scene 键(memory_blocks.scene 字段已在 schema 里)
|
||||
- [ ] 验证 `RecallByScene` 对块节点生效
|
||||
- 前置:第 2-5 步稳定后再做
|
||||
|
||||
## 四、每步通用约束
|
||||
|
||||
1. **provider 边界**:内核不出现任何 provider 名分支;一切经 `pkg/embedding`/`pkg/generation` 接口
|
||||
2. **禁止静默错误**:fingerprint/维度不匹配必须显式报;无向量必须显式留空
|
||||
3. **回填必落盘**(document.go:137 陷阱:不落盘则判定条件永远成立、每次启动重算)
|
||||
4. **变异自证**:每条关键判据写测试后,手动破坏对应实现,测试必须变红
|
||||
5. **长任务纪律**:导出/回填等内存密集任务**绝不与测试并行**(本会话三次 OOM 教训)
|
||||
6. **验证通过 ≠ 架构正确**:每步完成后对照本计划第一节逐条自检
|
||||
|
||||
## 五、暂不做
|
||||
|
||||
- entities 表的物理删除(存量过渡期内仍服务 LIKE 兜底)
|
||||
- KV cache(qwen3vlgen 全量前向 O(N²),生成低频,已注释标明)
|
||||
- 多模态拆解(第 6 步之后,依赖块路稳定)
|
||||
- 切换生产实例 /var/tmp/ha-c 的 provider 配置(需人工确认)
|
||||
@ -1,297 +0,0 @@
|
||||
# 三层记忆可用性修复计划:统一向量化 + 提示词让 agent 真正理解
|
||||
|
||||
> **归档说明(2026-10-27)**:本文是历史计划,**术语已过时** ——
|
||||
> `doc_query` / `doc_commit` 已改名为 `coldcontext_query` / `coldcontext_commit`,
|
||||
> 图记忆(Graph)在对外语义上改称 **stablemem(稳定记忆)**。
|
||||
> 现行架构与术语以 `assets/docs/zh/ARCHITECTURE.md` 为准;本文保留作为决策记录。
|
||||
|
||||
|
||||
> 状态:**待执行**(2026-10-22)
|
||||
> 前置:`docs/zh/memory-blockflow-repair-plan.md`(L1↔L2 结构同构,已实施)
|
||||
> 本文修的是**上一轮没修的那一层**:结构对了,但检索质量与提示词仍然不可用。
|
||||
|
||||
---
|
||||
|
||||
## 〇、起点:承认上一轮的判断错误
|
||||
|
||||
上一轮我修了 L1↔L2 的结构(事件列表 + 正文进块 + 按原时序回填),
|
||||
然后声称「机制已通」。**结构确实通了,但功能不可用。**
|
||||
|
||||
用生产数据实测(1054 篇文档、chineseclip 512 维)三个互不相关的查询:
|
||||
|
||||
```
|
||||
查询 "doc 层召回是怎么设计的"
|
||||
score=0.7834 来自 4 个来源的 61 条对话 (cold_storage, agentcli...)
|
||||
score=0.7795 来自 1 个来源的 2 条对话 (agent) 涉及: map, content, dsh, 占用
|
||||
score=0.7768 来自 1 个来源的 1 条对话 (agent) 涉及: 自检, OK
|
||||
|
||||
查询 "hmap 插件安装验签"
|
||||
score=0.7922 来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是, pi
|
||||
|
||||
查询 "QQ 头像"
|
||||
score=0.7601 来自 1 个来源的 1 条对话 (agent) 涉及: CQ, qq, ok
|
||||
```
|
||||
|
||||
**分数全挤在 0.75~0.79,区分度≈0,命中内容与查询无关。**
|
||||
|
||||
### 判据缺失(最该记的一条)
|
||||
|
||||
上一轮写了 16 条判据、6 个变异,全部通过。但**没有一条验证「检索结果必须与查询相关」**。
|
||||
所以「全绿」证明的只是我实现了我以为的东西 —— 不是它可用。
|
||||
|
||||
> ★ 教训:**结构判据不能替代质量判据**。
|
||||
> 「字段非空」「条数正确」「不 panic」都可能在检索完全失效时依然全绿。
|
||||
|
||||
---
|
||||
|
||||
## 一、根因(三条,按依赖排序)
|
||||
|
||||
### 根因 1:向量的输入是 jieba 词袋,不是正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```go
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil {
|
||||
```
|
||||
|
||||
而 `Summary` 由 `summarizeEntries` 生成,后者调用链是:
|
||||
|
||||
```
|
||||
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
|
||||
extractTags → 同上
|
||||
extractEntities → 同上
|
||||
```
|
||||
|
||||
`TokenizeContentWords`(`internal/memory/cut.go:256`)就是 `jieba.Tag()` 分词 + 停用词过滤,
|
||||
产出形如 `涉及: map, content, 是不是, pi`。
|
||||
|
||||
**这是与设计直接矛盾的**。`ARCHITECTURE.md` L131 / L174 明写向量层级:
|
||||
|
||||
```
|
||||
统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
```
|
||||
|
||||
`Summary` 是**主路径的输入**,却由**最末一级的词法分词**产出。
|
||||
全库所有文档的 Summary 因此高度同质(都是「来自 N 个来源的 M 条对话 涉及: …」),
|
||||
余弦相似度自然全挤在高位、无区分度。
|
||||
|
||||
### 根因 2:媒体块没有语义,且不需要单独生成
|
||||
|
||||
设计要求「doc 支持多模态摘要」。当前实现里:
|
||||
|
||||
- `Doc.Summary` 只有文本路径,**媒体块从不参与**
|
||||
- `crossModalMarkdown` 里媒体只渲染成 `[image/png blk_media_xxx]`(`blockLabel`:MIME + 短 ID)
|
||||
- 一篇带 20 张图的文档,摘要里没有「有图」的任何信息
|
||||
|
||||
**但「多模态摘要」这个提法本身是歧路**(用户 2026-10-22 指出):
|
||||
|
||||
> 媒体 doc 块一般是和文本紧邻的,所以对应的文本就足够了
|
||||
|
||||
`Doc.Events[i].Blocks` 里媒体块与文本块**在同一事件内**。媒体块的语义由
|
||||
**同事件的文本**天然覆盖,不需要为每个媒体单独生成描述(那正是 CAS 时代
|
||||
「描述式索引」被拆除的同一个错误)。
|
||||
|
||||
⇒ 正确做法不是「给媒体写摘要」,而是:**向量用事件正文算,媒体自动被覆盖**。
|
||||
|
||||
### 根因 3:提示词与注入文案没有任何引导
|
||||
|
||||
**(a)`doc_query` 的工具描述只有一句话**
|
||||
|
||||
```go
|
||||
toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", ...)
|
||||
```
|
||||
|
||||
逐条对照问题:
|
||||
|
||||
| 应有信息 | 现状 |
|
||||
|---|---|
|
||||
| L2 是什么 | ❌ 只说「文档记忆」,没有定义 |
|
||||
| 何时该用 | ❌ 无 |
|
||||
| 调用后会发生什么 | ❌ 「返回相关文档摘要」**与真实行为不符**(真实是逐条按原时序回填到对话时序) |
|
||||
| 与 `memory_recall` 的分工 | ❌ 无 |
|
||||
|
||||
对比同文件里 `【输出规则】`(6 条)、`【工具执行顺序】`(4 条)的详细程度,
|
||||
`doc_query` 是最敷衍的一个。
|
||||
|
||||
**(b)预填充注入文案没有引导**
|
||||
|
||||
`crossModalMarkdown` 输出:
|
||||
|
||||
```
|
||||
【跨模态相关记忆】
|
||||
[1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
|
||||
```
|
||||
|
||||
**没有一个字说「可以用 doc_query 取全文」**。模型看到这段只能忽略。
|
||||
|
||||
**(c)系统提示词从未解释三层记忆是什么**
|
||||
|
||||
`grep 记忆 internal/agent/core/tooldefs.go` 全量命中里,没有任何一段说明
|
||||
Context / Document / Graph 三者的分工与使用时机。agent 只能从工具名猜。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复步骤(按依赖,每步先写会红的判据)
|
||||
|
||||
### 步骤 1:判据先行 —— 检索质量
|
||||
|
||||
**先写测试,此时必须红。**
|
||||
|
||||
`internal/memory/document/retrieval_quality_test.go`:
|
||||
|
||||
- 构造真实语料(约 50 篇,内容取自生产副本的真实事件正文 + 人工标注的查询-答案对)
|
||||
- 断言:给定查询,**正确文档必须排第一**(top1 准确率 100%,或至少 top3 内)
|
||||
- 断言:互不相关的查询,top1 结果**必须不同**
|
||||
- 断言:正确命中的分数必须**显著高于**非命中(区分度阈值)
|
||||
|
||||
**变异**:把 `denseFor` 改回 `Summary + Content` ⇒ 必须红。
|
||||
**变异**:把正文替换成 jieba 词袋 ⇒ 必须红。
|
||||
|
||||
这一步是整个计划的地基。没有它,后面每一步都只是「我以为改对了」。
|
||||
|
||||
### 步骤 2:向量输入改为正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```
|
||||
现在:VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
改为:VectorizeDense(事件正文拼接)
|
||||
有 Events → 按事件顺序取 InOut() 的 input + response(文本块内容)
|
||||
无 Events(存量)→ 退回 Content
|
||||
```
|
||||
|
||||
**媒体如何被覆盖**:媒体块与文本块在同一事件内,事件的正文进入向量即覆盖它。
|
||||
不需要为媒体单独编码 —— 若将来要「按图搜图」,那是视觉路(`RecallBlocks`)的职责,
|
||||
已有独立通路,与文本路不冲突。
|
||||
|
||||
### 步骤 3:删掉 jieba 摘要路径
|
||||
|
||||
`Summary` / `Tags` / `Entities` 目前全部由 `ExtractKeywords`(jieba)产出。
|
||||
|
||||
- `Tags`:唯一消费点是 `doc_query` 返回值里的「标签: …」(`toolcall.go:1038`),
|
||||
**是词袋噪声,删除该行**
|
||||
- `Entities`:`Doc.Entities` 无任何非测试读取点(跨包的 `injected.Entities`
|
||||
是 `memory.Entity` 另一个类型,无关),**字段可删**
|
||||
- `Summary`:改为**纯展示**(人工阅读 / 日志),由正文首句截取生成,**不参与向量**
|
||||
|
||||
⚠️ 需先确认:`Doc.Tags` / `Doc.Entities` 是否有 WebUI / SDK 侧读取。
|
||||
(已知 `sdk/memory_impl.go` 的 `Doc` 只有 `ID/Title/Content`,无 Tags/Entities。)
|
||||
|
||||
### 步骤 4:预填充注入正文片段 + 引导
|
||||
|
||||
`crossModalMarkdown` 改为:
|
||||
|
||||
```
|
||||
【历史记忆(按相关度)】
|
||||
[1] 2026-10-12 11:09 · qq · 相关度 0.83
|
||||
紫蓝红三色带的配色参考,老大要求…(正文首 2 行)
|
||||
[2] …(含 2 张图:image/png, image/jpeg)
|
||||
|
||||
这些是**历史**片段(不是当前对话)。要看完整原文,
|
||||
调用 doc_query 把对应事件按原时间戳拉回对话时序。
|
||||
```
|
||||
|
||||
要点:
|
||||
- 去掉「来自 N 个来源的 M 条对话」这类统计描述
|
||||
- 给出**时间 + 来源 + 正文开头**(真实内容,让模型能判断相关性)
|
||||
- 媒体只报数量与类型,不编描述
|
||||
- **明确告知 doc_query 的作用**
|
||||
|
||||
### 步骤 5:`doc_query` 工具描述重写
|
||||
|
||||
按 `memory_*` 的详细度,必须覆盖:
|
||||
|
||||
- L2 是什么(历史事件层,不直接进当前对话)
|
||||
- 何时用(记得办过但想不起细节 / 跨会话跨天 / 需要原始过程而非结论)
|
||||
- 调用后发生什么(**把事件逐条按原时间戳回填到对话时序**,不是返回一段摘要)
|
||||
- 与 `memory_recall` 的分工(图记忆=关系与最新值;doc=整段过程与原文)
|
||||
- 副作用(从 L2 取出,取走后该文档不再在 L2)
|
||||
|
||||
### 步骤 6:系统提示词增加「三层记忆」段落
|
||||
|
||||
在 `buildSystemPrompt` 里加一段,说清三层是什么、怎么用:
|
||||
|
||||
```
|
||||
【三层记忆】记忆分三层,你现在看到的是第一层。
|
||||
- 对话时序(L1):最近的对话,已在你的上下文里。超过窗口的会被移出。
|
||||
- 历史记忆(L2):移出后的事件与原文仍完整保存。自动预填充会给你相关片段;
|
||||
要给看全文/完整过程,用 doc_query —— 它会把那些事件按原时间戳插回对话时序。
|
||||
- 图记忆(L3):关系与事实的长期存储(谁是什么、什么等于什么、最新值是什么)。
|
||||
用 memory_recall 查;用 memory_commit 记。
|
||||
选择:要"原文与过程"→ doc_query;要"关系与当前值"→ memory_recall;
|
||||
要"成文知识"→ knowledge_search。
|
||||
```
|
||||
|
||||
**注意**:这段属于「内核运行时块」(提示词分层规范:内核行为由内核生成),
|
||||
不是工具用法(工具用法归 `description`)。它讲的是**内核的真实执行语义**
|
||||
(回填到时序、自动预填充),符合分层原则。
|
||||
|
||||
---
|
||||
|
||||
## 三、判据清单(每步都要能红)
|
||||
|
||||
| # | 判据 | 变异(必须红) |
|
||||
|---|---|---|
|
||||
| 1 | 检索质量:top1 命中 / 不相关查询 top1 不同 / 分数有区分度 | `denseFor` 改回 `Summary+Content`;或换回 jieba 词袋 |
|
||||
| 2 | 向量输入是正文:断言 `denseFor` 读的是 Events 正文(可用哨兵串) | 正文里埋哨兵,改回 Summary ⇒ 哨兵不出现在向量输入 |
|
||||
| 3 | 摘要不含 jieba 词袋:`Summary` 不出现「来自 N 个来源的 M 条对话」 | 恢复 `summarizeEntries` ⇒ 红 |
|
||||
| 4 | 预填充文案含正文与引导:必须含 `doc_query` 字样 + 正文片段 | 改回旧渲染 ⇒ 红 |
|
||||
| 5 | 工具描述覆盖五项(L2 定义/何时用/调用后行为/分工/副作用) | 删任一项 ⇒ 红 |
|
||||
| 6 | 系统提示词含三层记忆段落且语义与实现一致 | 回填行为改掉而提示词不改 ⇒ 红(跨文件一致性) |
|
||||
|
||||
**判据 6 特别说明**:提示词说「按原时间戳插回时序」这件事,
|
||||
必须与 `restoreDocEvents` 的真实行为绑在一条判据里 ——
|
||||
否则就是「提示词在说谎」,那比没说更糟。
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 用 jieba 词袋当向量输入或摘要内容 | 与「统一向量化」直接矛盾;全库同质,检索失效 |
|
||||
| 为媒体块单独生成文本描述 | CAS 时代的「描述式索引」错误;媒体语义由同事件文本覆盖 |
|
||||
| 只改结构不改提示词 | 机制改了模型不知道 = 等于没改 |
|
||||
| 用「字段非空 / 条数正确 / 不 panic」当检索判据 | 检索完全失效时这些也全绿 |
|
||||
| 声称「机制已通」而不给出生产数据的检索质量实测 | 上一轮的教训 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验证
|
||||
|
||||
1. `go test ./... -count=1` 全绿(现有 58 包基线)
|
||||
2. 新判据全绿,且 6 个变异各自被抓住
|
||||
3. **生产副本实测**:用真实数据跑检索,给出 top1/top3 命中率与分数分布
|
||||
(对比修复前的 0.75~0.79 无区分度)
|
||||
4. 生产部署后用真实对话观察:agent 是否在需要时调用 `doc_query`
|
||||
|
||||
---
|
||||
|
||||
## 六、标准流程(本次严格执行)
|
||||
|
||||
上次直接提交 `main` + rebase + amend + 绕过 CI,不符合 `docs/git-branching.md`。
|
||||
这次必须:
|
||||
|
||||
```
|
||||
1. git checkout -b feature/memory-usability main
|
||||
2. 按步骤 1→6 提交(每步独立 commit,先写判据再改)
|
||||
3. git push -u origin feature/memory-usability
|
||||
4. 开 PR
|
||||
5. 等 CI 10 项全部通过
|
||||
6. 合并 main(不绕过必需检查)
|
||||
7. 部署 + 生产验证
|
||||
```
|
||||
|
||||
⚠️ 远端仓库已改名 `JianFeeeee/HologramAgent`(origin 与 gitcode 均已更新)。
|
||||
|
||||
---
|
||||
|
||||
## 七、待确认
|
||||
|
||||
1. **`Doc.Tags` / `Doc.Entities` 是否可删?**
|
||||
我查到非测试读取点只有 `doc_query` 返回里的「标签:」一行(可删)。
|
||||
但需确认 WebUI / 外部插件是否依赖(SDK 的 `Doc` 无这两个字段)。
|
||||
|
||||
2. **判据 1 的语料从哪来?**
|
||||
建议从生产副本抽 50 篇真实文档 + 人工标注 10~15 个查询-答案对。
|
||||
是否允许我把这些真实文本(脱敏后)作为测试 fixture 入库?
|
||||
@ -1,5 +1,9 @@
|
||||
# 统一多模态向量空间
|
||||
|
||||
> **状态:参考 / 实测。** 多模态向量空间的设计与实测数据。
|
||||
> 注意:文中 `TestEmbedderVideoMatchesONNXReference` 是**规划中的判据名**,
|
||||
> 仓库中不存在(视频尚未纳入契约,见文内说明)。
|
||||
|
||||
核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个
|
||||
向量空间。仓库内自带两个:
|
||||
|
||||
|
||||
@ -1,151 +0,0 @@
|
||||
# 生产迁移执行清单
|
||||
|
||||
**日期**:2026-10-04
|
||||
**分支**:`feat/context-overflow-l4`(未 push)
|
||||
**状态**:HologramAgent 已停机(inactive + disabled)
|
||||
|
||||
---
|
||||
|
||||
## 0. 前置条件(必须全部满足)
|
||||
|
||||
| 项 | 要求 | 实测 |
|
||||
|---|---|---|
|
||||
| 服务停机 | `homeagent.service` inactive | ✅ inactive + disabled |
|
||||
| WAL 已 checkpoint | `PRAGMA wal_checkpoint(TRUNCATE)` 返回 0\|0\|0 | ⬜ 执行前重查 |
|
||||
| 快照已存在 | 一致副本(非 `cp`,用 `.backup`) | ✅ `/var/tmp/ha-prod-migrate/before.db` |
|
||||
| 快照基线 | 1294/980/66/98/97 | ✅ 已核对 |
|
||||
| 副本验证 | 结构 7 项 + 召回 7/9 | ✅ 已通过 |
|
||||
| 代码提交 | 43 包全绿 | ✅ |
|
||||
| 磁盘空间 | 迁移后约 18MB(副本实测) | ⬜ 执行前确认 |
|
||||
|
||||
★ **副本用的就是生产快照**,所以"在副本验证"等价于"在生产数据形态上验证"。
|
||||
|
||||
---
|
||||
|
||||
## 1. 执行步骤
|
||||
|
||||
```bash
|
||||
# ① 停机确认(已停,此处防呆)
|
||||
systemctl is-active homeagent.service # 期望: inactive
|
||||
systemctl is-enabled homeagent.service # 期望: disabled
|
||||
|
||||
# ② WAL checkpoint
|
||||
sqlite3 ${HA_DATA}/memory/graph.db "PRAGMA wal_checkpoint(TRUNCATE)"
|
||||
# 期望输出: 0|0|0
|
||||
|
||||
# ③ 生成新快照(.backup,不是 cp)
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
sqlite3 ${HA_DATA}/memory/graph.db ".backup '/var/tmp/ha-prod-migrate/prod-$TS.db'"
|
||||
# 核对副本基线与源库一致
|
||||
sqlite3 /var/tmp/ha-prod-migrate/prod-$TS.db \
|
||||
"SELECT COUNT(*) FROM entities; SELECT COUNT(*) FROM relations; SELECT COUNT(*) FROM sentences;"
|
||||
|
||||
# ④ 先报告(不写库)
|
||||
go run -tags onnxruntime ./cmd/homed-graph-migrate \
|
||||
-db ${HA_DATA}/memory/graph.db \
|
||||
-embed-provider chineseclip \
|
||||
-model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx
|
||||
# ★ 核对:实体 1294,关系 966,块 98,块边 97
|
||||
# ★ 若数字对不上 → 停止,不要 -apply
|
||||
|
||||
# ⑤ 执行迁移(约 200s)
|
||||
go run -tags onnxruntime ./cmd/homed-graph-migrate \
|
||||
-db ${HA_DATA}/memory/graph.db -apply \
|
||||
-embed-provider chineseclip \
|
||||
-model-dir /var/tmp/ha-c/models/chinese-clip-vit-b16-onnx
|
||||
|
||||
# ⑥ 验证
|
||||
bash scripts/verify-migration.sh ${HA_DATA}/memory/graph.db
|
||||
# 期望:✅ 结构验证全部通过
|
||||
|
||||
# ⑦ 召回探针
|
||||
PROD_SNAPSHOT=${HA_DATA}/memory/graph.db \
|
||||
go test -count=1 -tags onnxruntime ./internal/memory/ \
|
||||
-run 'TestProbe_生产规模召回' -v
|
||||
# 期望:casual 3/4, confusable 1/1, abstention 3/3, 合计 7/9
|
||||
```
|
||||
|
||||
★ 全程**串行**。迁移涉及 1294 个块的 embedding,
|
||||
并行会导致资源争用与长时间卡死(此前实测过 600 秒卡死)。
|
||||
|
||||
★ **旧表不删**。迁移只写块与边,`entities/relations/sentences`
|
||||
保持原样(验证脚本第 1 项逐表核对)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 期望产出(副本实测值)
|
||||
|
||||
```
|
||||
句子 +66,块 +1294,边 +980,scene_refs +718
|
||||
块 2752(带向量 1391),边 2371
|
||||
悬空端点 0
|
||||
向量维度唯一 512,fingerprint 唯一
|
||||
confidence 为 0 的关系边 0 条
|
||||
deleted 状态关系边 14 条(对应旧表 14 条)
|
||||
中心向量已重建(各向异性 detected,双边中心化启用)
|
||||
耗时约 200s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 回滚
|
||||
|
||||
### 回滚条件
|
||||
|
||||
迁移后出现以下任一情况 ⇒ 立即回滚:
|
||||
|
||||
- 验证脚本报 `❌`
|
||||
- 召回探针合计低于 4/9(迁移前基线)
|
||||
- 启动后 `memory_recall` 报「检索失败」
|
||||
- 健康检查异常
|
||||
|
||||
### 回滚方式
|
||||
|
||||
★ **旧表未被改动**,所以回滚很简单:
|
||||
|
||||
```bash
|
||||
# ① 恢复代码到迁移前的提交
|
||||
git checkout 75d4609 # 或 d4eda47 / 2fe4ca7 等任一已验证提交
|
||||
|
||||
# ② 恢复数据库(若需要)
|
||||
TS=<迁移时的快照时间戳>
|
||||
cp /var/tmp/ha-prod-migrate/prod-$TS.db ${HA_DATA}/memory/graph.db
|
||||
# ★ 生产库必须用 .backup 恢复,不要直接 cp 源库
|
||||
|
||||
# ③ 重建二进制并重启(需你确认后才启服务)
|
||||
go build -tags onnxruntime -o build/homed ./cmd/homed
|
||||
systemctl enable homeagent.service && systemctl start homeagent.service
|
||||
```
|
||||
|
||||
### 回滚安全性
|
||||
|
||||
| 保证 | 依据 |
|
||||
|---|---|
|
||||
| 旧表数据完好 | 迁移只 INSERT 块/边,验证脚本第 1 项核对 |
|
||||
| 迁移单事务 | 任一步失败整体回滚 |
|
||||
| 迁移自动快照 | 命令自身会生成 `.bak-<时间戳>` |
|
||||
| 代码可回退 | 全部改动已提交,未 push |
|
||||
|
||||
---
|
||||
|
||||
## 4. 迁移后仍存在的问题(不阻塞迁移,但要记录)
|
||||
|
||||
| 问题 | 状态 | 补法 |
|
||||
|---|---|---|
|
||||
| `coexist` 泛指提问召不回端口号 | 0/1 | BFS 接入 `RecallBlocksFused` |
|
||||
| 纯中文编造查询不拒答 | 契约外 | 需符号切分改进或换判定信号 |
|
||||
| 旧表仍在增长 | 待停双写 | 读方已全切,可摘 `commit()` 里的 INSERT |
|
||||
| `TestResidualKeepReturnsTasksToParent` | 既有 flake | 单独复验,勿误判 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 与旧快照的关系
|
||||
|
||||
```
|
||||
/var/tmp/ha-prod-migrate/before.db 本轮开始时的生产基线(1294/980/66/98/97)
|
||||
/var/tmp/ha-prod-migrate/probe.db 副本,已完整迁移并验证
|
||||
/var/tmp/ha-prod-migrate/base-copy.db 迁移前基线探针用
|
||||
```
|
||||
|
||||
★ `before.db` 与 `probe.db` 是**同一份数据的迁移前后对照**,
|
||||
两者都在,可随时重跑任何验证。
|
||||
@ -1,3 +1,8 @@
|
||||
# 生产召回验证:探针 PASS 但结论是假的
|
||||
|
||||
> **状态:实测报告(结论有效,日期见文内)。** 记录了「探针通过≠能力成立」
|
||||
> 这一类失败的具体形态,是判据设计的反面教材。
|
||||
> 文末 3 条遗留 sentence→块 边仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。
|
||||
|
||||
## 二十五、生产规模验证:**探针 PASS 了,但结论是假的**
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
# 召回即联想
|
||||
|
||||
> **状态:参考 / 机制说明。** 描述召回作为关联的模型,非待办清单。
|
||||
> 未实现项见 [`future-plan.md`](./future-plan.md)。
|
||||
|
||||
> **「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」**
|
||||
> —— 这个机制就是在模仿人类的联想能力。
|
||||
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
# 生产召回能力分层
|
||||
|
||||
> **状态:实测报告 + 待补清单。** 能力分层与缺口来自生产实测。
|
||||
> 文末「待补的能力」两个缺口仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。
|
||||
|
||||
> 单一分数(如「4/9」)会掩盖真实情况。真正有用的问题是:
|
||||
> **什么形态的查询能用、什么不能用、为什么。**
|
||||
|
||||
|
||||
@ -1,5 +1,16 @@
|
||||
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
|
||||
|
||||
> **状态:已实现并部署。** 驻留子、两级记忆、父子 L3/L4 中断、残余任务四态均已落地。
|
||||
> 本轮(2026-10-31)校订时补充的下沉事实:
|
||||
>
|
||||
> - 安全:驻留子会**继承父的安全运行时实例**,但**权限不继承** —— 父在
|
||||
> `SpawnResident` 时通过 `ResidentOptions.AllowedTools / DenyTools`
|
||||
> **为这个子单独声明**主体策略,内核持有并执行;子销毁时撤销。
|
||||
> 工具面参数名:`allowed_tools` / `deny_tools`。
|
||||
> - 本文正文里的 `memory_recall` 等旧工具名已统一改为现行的 `stablemem_*`。
|
||||
>
|
||||
> **未实现项**不在本文维护,见 [`future-plan.md`](./future-plan.md)。
|
||||
>
|
||||
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
|
||||
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
|
||||
> 现场保存/恢复、中断栈(LIFO,结构上界 4 帧)、任务级回执、panic→L4。
|
||||
@ -502,7 +513,7 @@
|
||||
| **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`** | 根与子都要 |
|
||||
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go`(`stablemem_recall`) | **只有 `Recall` + `Commit`** | 根与子都要 |
|
||||
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
|
||||
|
||||
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
|
||||
@ -527,7 +538,7 @@ type GraphMemory interface {
|
||||
(`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
|
||||
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`:
|
||||
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
|
||||
- 工具表:`memory_recall`(读)对子开放;整理类
|
||||
- 工具表:`stablemem_recall`(读)对子开放;整理类
|
||||
(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`)
|
||||
**不进子的工具表**(而不是让它们进去再报"不可用")。
|
||||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||||
|
||||
@ -1,256 +0,0 @@
|
||||
# 场景式记忆修复 plan
|
||||
|
||||
> 起点:2026-09-26 生产库实测(`${HA_DATA}/memory/graph.db`)。
|
||||
> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
|
||||
> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
|
||||
> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
|
||||
|
||||
---
|
||||
|
||||
## 根因(三条,逐条修)
|
||||
|
||||
### R1 建键不过归一化(病因)
|
||||
|
||||
`internal/memory/scene_emerge.go:329`
|
||||
|
||||
```go
|
||||
base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
|
||||
```
|
||||
|
||||
而同一层的另外三条路**都**过了归一化:
|
||||
|
||||
| 位置 | 是否归一化 |
|
||||
|---|---|
|
||||
| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
|
||||
| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
|
||||
| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
|
||||
| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
|
||||
|
||||
`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
|
||||
`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
|
||||
|
||||
实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
|
||||
|
||||
```
|
||||
auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
|
||||
auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
|
||||
auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
|
||||
auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
|
||||
auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
|
||||
auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
|
||||
```
|
||||
|
||||
### R2 `part`(时段,权重 0.2)污染主键与门槛
|
||||
|
||||
`Label(2)` 取权重最高的 2 个特征。特征顺序是
|
||||
`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
|
||||
只有当前 2 个强特征不足时 `part` 才会进键——于是出现
|
||||
`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
|
||||
|
||||
**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
|
||||
所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
|
||||
|
||||
实测证据(键名与实际指纹自相矛盾):
|
||||
|
||||
```
|
||||
场景键: auto:chan:qq+part:morning strength=270
|
||||
实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
|
||||
```
|
||||
|
||||
这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
|
||||
并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
|
||||
|
||||
### R3 图整理心跳漏扫 `scenes`
|
||||
|
||||
`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
|
||||
`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
|
||||
所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
|
||||
|
||||
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
|
||||
|
||||
### R4 场景身份只有「输入通道」一个可靠维度
|
||||
|
||||
现网 65 个场景键的维度分布(实测):
|
||||
|
||||
```
|
||||
auto:chan(涌现·通道主导) 36
|
||||
chan: (输入通道) 26
|
||||
tool: (工具) 3
|
||||
peer 主导 0
|
||||
topic 主导 0
|
||||
```
|
||||
|
||||
两个独立原因叠加:
|
||||
|
||||
1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找
|
||||
`peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput
|
||||
时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。
|
||||
⇒ 日志中 `peer` 特征出现次数为 **0**。
|
||||
2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重
|
||||
降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer,
|
||||
`Label(2)` 也轮不到它。
|
||||
|
||||
后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了
|
||||
场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。
|
||||
|
||||
**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达
|
||||
什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`,
|
||||
但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。
|
||||
|
||||
### R5 `situation_evidence` 全表清空
|
||||
|
||||
`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence`
|
||||
(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
|
||||
攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
|
||||
|
||||
### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口)
|
||||
|
||||
`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴:
|
||||
|
||||
```
|
||||
NoMemory 进不进记忆计算
|
||||
ContextPolicy 裁不裁上下文(破坏性,默认关)
|
||||
RecallPolicy 召不召回记忆(只读,默认开)
|
||||
```
|
||||
|
||||
**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**:
|
||||
`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征,
|
||||
没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类
|
||||
**纯内部信噪通道也在参与场面聚类**。
|
||||
|
||||
穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace),
|
||||
`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。
|
||||
|
||||
已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件
|
||||
在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。
|
||||
现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。
|
||||
|
||||
### R7 渠道本身可以覆盖多个场景
|
||||
|
||||
`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时,
|
||||
`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`)
|
||||
支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。
|
||||
|
||||
---
|
||||
|
||||
## 步骤
|
||||
|
||||
### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成
|
||||
|
||||
- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey`
|
||||
- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景
|
||||
- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源)
|
||||
- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿)
|
||||
- 提交:`1fa9ef6`
|
||||
|
||||
### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动)
|
||||
|
||||
按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状):
|
||||
|
||||
- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"`
|
||||
- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认)
|
||||
- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty`
|
||||
- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认)
|
||||
- [ ] 内核接线:
|
||||
- `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy`
|
||||
- `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产——
|
||||
一个不参与场面识别的通道不该留下时段噪声)
|
||||
- 声明路 `sceneKeysFor` 同样受 `none` 约束
|
||||
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
|
||||
- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
|
||||
少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
|
||||
召回返回空;且 declared 场景**不进**相似度空间
|
||||
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
|
||||
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
|
||||
|
||||
### 步骤 3:修 R3(让图整理覆盖全库)✅ 已完成
|
||||
|
||||
- [x] `GraphDB.DedupeScenes()`:归一化后同名场景合成一个
|
||||
- [x] 接到 `mergeLoop` 尾部(不塞进实体的 O(n²) 双重循环)
|
||||
- [x] 判据 8 例(先红后绿)
|
||||
- 生产库副本实测:65 → 57,合并 8 组,`auto:chan:qq_part:morning`
|
||||
的 refs/rel/ent 一条没丢,strength 1 → 272
|
||||
- 提交:`758ec11`
|
||||
|
||||
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
|
||||
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
|
||||
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
|
||||
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
|
||||
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
|
||||
|
||||
### 步骤 4:清理现网垃圾场景,让它重新生成
|
||||
|
||||
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
|
||||
|
||||
> ⚠️ **硬约束:必须先部署含 R1 修复的二进制**。反过来的话,重新涌现出来的
|
||||
> 还是带 `+`/`#` 的旧键,清一场白清。
|
||||
|
||||
#### 部署前基线(2026-09-26 20:1x 实测)
|
||||
|
||||
```
|
||||
PID 92115 / 启动于 Sat 19:12:43
|
||||
子进程插件 25 个
|
||||
近 24h 异常日志(Fatal/panic/DATA RACE): 0
|
||||
场景数 65
|
||||
`homed --version` 不存在(flag 未定义)⇒ 纪律清单那条用 status 接口或 ps 核对
|
||||
```
|
||||
|
||||
#### 执行顺序
|
||||
|
||||
- [ ] 1. `make build`(默认 `HOMED_TAGS=onnxruntime`,cgo;校验 onnx/cgo 标记)
|
||||
- [ ] 2. 备份:`sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"`(**禁用 cp**,WAL 不一致)
|
||||
- [ ] 3. `install -m 0755 build/homed /usr/local/bin/homed`(**禁用 cp**,会写坏运行中进程映像)
|
||||
- [ ] 4. `systemctl restart homeagent`
|
||||
- [ ] 5. 后置验证:进程起来 / 子进程插件数恢复 / 异常日志为 0 / 真实对话跑通
|
||||
- [ ] 6. 清理:删 `origin='emergent'` 全部 + 关联 features/refs + `situation_evidence`
|
||||
- [ ] 7. 等 ≥2 次同类交互让场景重新涌现
|
||||
- [ ] 8. 复验:新键不含 `+`/`#`、有 features **且**有 refs、strength 合理
|
||||
|
||||
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
|
||||
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
|
||||
- [ ] 同步清 `situation_evidence`
|
||||
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
|
||||
- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs
|
||||
|
||||
> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在
|
||||
> `relations`/`entities` 表,与 `scenes` 无外键依赖。
|
||||
> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
|
||||
> 涌现重建期间它继续承担 QQ 场景召回。
|
||||
|
||||
### 步骤 5:修 R5(证据桶别全表清)✅ 已完成
|
||||
|
||||
- [x] `DELETE FROM situation_evidence` → `... WHERE label = ?`(桶键 = `sig.Label(2)`,
|
||||
**不带 `auto:` 前缀**——这点修的时候差点栽)
|
||||
- [x] 判据 3 例:判据实测「6 个通道各来 3 次只长出 2 个场景」
|
||||
- 提交:`758ec11`
|
||||
|
||||
### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
|
||||
|
||||
前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。
|
||||
`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。
|
||||
|
||||
- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么
|
||||
- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序),
|
||||
即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权
|
||||
- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
|
||||
|
||||
### 步骤 7:验证与收口
|
||||
|
||||
- [ ] `go build ./...` + 全仓 `go test ./...`
|
||||
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
|
||||
- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化
|
||||
**已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、
|
||||
老插件行为不变」
|
||||
- [ ] 现网观察:场景命中后能查到 refs(非 0)
|
||||
- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
|
||||
- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期)
|
||||
|
||||
## 不做的事(防反复挂账)
|
||||
|
||||
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
|
||||
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
|
||||
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
|
||||
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
|
||||
混进本次修复会让 diff 失焦。单独开条目。
|
||||
- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义:
|
||||
新增项是纯追加,不借机改旧行为。
|
||||
@ -1,5 +1,7 @@
|
||||
# 站点基础设施运维手册
|
||||
|
||||
> **状态:操作手册(现行)。** 站点基础设施的操作说明。
|
||||
|
||||
> 适用:本项目**文档站与产品站**的生产部署(多站点、统一入口、多层 TLS 终止)。
|
||||
> 读者:后续维护者 / AI agent。
|
||||
> **本文件不含任何密钥、token、真实域名与 IP** —— 全部用占位符,取值见本机 `deploy/` 外的私有笔记或基础设施面板。
|
||||
|
||||
@ -780,3 +780,60 @@ if ret, err := parent.ExecuteTool(name, args); err == nil {
|
||||
|
||||
**存量插件不需改一行、不需重编**(新增方法/字段由**插件调用、内核实现**,
|
||||
不调就不受影响——这是 1.1.0 / 1.2.0 / 1.3.0 三次跃迁共同的结论)。
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:更新前后压测结果(2026-09-27)
|
||||
|
||||
> 原本在执行计划文档里。计划随实施完结删除,这些**数据**有长期价值,故并入本文。
|
||||
|
||||
方法:两版内核(旧 `85e3d66` / 新 `d3eaff4`)在**隔离 netns** 内各起一个实例,
|
||||
共用同一个 mock LLM(保证 LLM 行为完全一致,消除变量),驱动脚本
|
||||
`scripts/kernel-stress/cmp.py`。规模 3 = 24 连接 × 12 输入 = 288 条。
|
||||
|
||||
### A.1 批内工具调用(核心差异)
|
||||
|
||||
| 场景 | 旧版 中位/工具数 | 新版 中位/工具数 |
|
||||
|---|---|---|
|
||||
| `!slowbatch2` | 0.221s / **0 个** | 0.380s / **2 个** |
|
||||
| `!slowbatch4` | 0.222s / **0 个** | 0.399s / **4 个** |
|
||||
| `!slowbatch8` | 0.220s / **0 个** | 0.409s / **8 个** |
|
||||
|
||||
★ **旧版"更快"是假的**:它的 `openai.lua` 缺 `stream_index` 透传,多个分片
|
||||
并到槽 0、argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」而**一个都没真跑**。
|
||||
|
||||
这正是「只看耗时会被骗」的典型 —— 所以 `cmp.py` 每轮都记录**处理数**
|
||||
并把它当作通过条件之一。
|
||||
|
||||
### A.2 并发 vs 强制串行(新版内部对照)
|
||||
|
||||
`!slowbatchN`(全部 ParallelSafe ⇒ 并发)vs `!serialbatchN`(混入
|
||||
`knowledge_create` ⇒ 整批降级串行):
|
||||
|
||||
| N | 并发 | 强制串行 | 加速 |
|
||||
|---|---|---|---|
|
||||
| 2 | 0.378s | 0.532s | 1.41× |
|
||||
| 4 | 0.389s | 0.864s | 2.22× |
|
||||
| 8 | 0.409s | 1.585s | **3.88×** |
|
||||
|
||||
### A.3 关于 stage 锁告警(不是内核缺陷)
|
||||
|
||||
压测中出现过 5 次 `stage.lock` 告警。**不要当内核缺陷去修**。链路:
|
||||
|
||||
1. SDK 生成的模板在每个 stage handler 入口**自动**调
|
||||
`callCoreVoid("stage.lock", nil)`(跨进程写锁,内核仲裁)
|
||||
2. `lock.go` —— 锁**不可重入**:`held && owner == plugin` 即报错
|
||||
3. 所以只要同一次 `before_toolcall` 被触发两次且首次未释放,就会命中
|
||||
|
||||
已排查并排除:QQ 的 `beforeToolcall` 函数体里只有 `ctx.Lock()`(SDK **数据**锁,
|
||||
与 proc stage 锁是两把锁)与纯本地调用,**无任何再次触发 stage 的路径**。
|
||||
|
||||
⇒ 成因在**插件进程侧的运行时**(编译进 `plugin.bin`),不在插件业务代码里。
|
||||
生产 `plugin.bin` 是独立构建产物、**不随 homed 部署** —— 要修需改 SDK 模板
|
||||
并重编该二进制。
|
||||
|
||||
**内核这边的行为是正确的**:插件持锁失败时内核强制释放("锁仲裁回内核"
|
||||
的自愈机制),目的是避免后续插件死锁;错误收进 `ctx.Errors` 而不中断流程,
|
||||
所以那一轮 212 秒正常跑完。
|
||||
|
||||
⇒ 5 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就会变成真死锁。
|
||||
|
||||
@ -1,574 +0,0 @@
|
||||
# 工具调用并行化改造:执行计划
|
||||
|
||||
> **设计依据**:`docs/zh/toolcall-contract-and-sequence-design.md`(本文只讲**怎么一步步做**,
|
||||
> 设计取舍与实证依据在那份文档里,不重复)。
|
||||
>
|
||||
> **状态**:阶段 0 已完成。阶段 0.5 起为待办。
|
||||
> **纪律**:每个阶段的「判据」必须**先写且必须能失败**,再改实现;
|
||||
> 判据通过前不进入下一阶段(见文末「阶段纪律」)。
|
||||
|
||||
---
|
||||
|
||||
## 阶段总览与依赖
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| **0** | 修 map 迭代顺序(flush 乱序) | — | ✅ **已完成** |
|
||||
| **0.2** | **工具错误类型化(已完成)** | — | ✅ **已完成** |
|
||||
| **0.5** | 补多 tool_call 批内路径判据 | — | ✅ **已完成** |
|
||||
| **1** | 结果契约 + 参数预校验 | — | ✅ **已完成**(1a/1b/1c/1d) |
|
||||
| **2** | 并行执行层 | 1 | ✅ **已完成**(2a/2b/2c/2d) |
|
||||
| **2.5** | 提示词改为「默认并行」 | 2 | ✅ **已完成** |
|
||||
| **3/4** | 序列插件 | 2 | ✅ **已完成**(P1–P4,见下) |
|
||||
|
||||
**主线(内核)0 → 0.2 → 0.5 → 1 → 2 → 2.5 已全部完成**;
|
||||
**插件线 P1 → P2 → P3 → P4 也已完成**。两部分详见下文。
|
||||
|
||||
### ⚠️ 顺序不可调换的两处
|
||||
|
||||
- **2.5 必须在 2 之后**:先改提示词说「默认并行」而内核仍串行 = 提示词对模型说谎。
|
||||
- **1 必须在 2 之前**:并行化需要「诚实的 Success」与「结构化值」来判断结果与做条件;
|
||||
先并行后补契约,等于把两处改动叠在同一段逻辑上,回归时无法定位是哪一处引起。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0 ✅ 已完成
|
||||
|
||||
**问题**:`flushToolCall` 由 `for idx := range accs` 驱动,Go map 迭代顺序随机化
|
||||
⇒ 同一批并行 tool_call 进入 `resp.ToolCalls` 的顺序**每次运行都可能不同**。
|
||||
对 `output_send__` 这类用户可见通道 ⇒ 分段消息到达顺序不可复现。
|
||||
|
||||
**改动**:`process.go` —— 抽出闭包 `flushAll`,收集 index 后 `sort.Ints` 再 flush。
|
||||
两个调用点(`ch` 关闭、`ctx.Done()`)统一走它。
|
||||
|
||||
**判据**:`stream_flush_order_test.go` —— 8 工具 × 200 轮,断言输出严格按投递顺序。
|
||||
**已做变异验证**:退回 map 遍历后判据于 round 0 即 FAIL(实际顺序 `[tool_02…tool_00 tool_01]`)。
|
||||
修复后 core 包全绿。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0.2 ✅ 工具错误类型化(已完成)
|
||||
|
||||
**问题**(两条,第二个是静默 bug):
|
||||
|
||||
1. 内核用 `strings.Contains(err, "not found in any plugin")` 判别「工具不存在」
|
||||
(原 `toolcall.go:104`)——**约定**不是契约。插件错误文案若含该子串即被误判。
|
||||
2. ⚠️ `IOManager` 向父兜底时**吞掉父的执行失败**(`channel.go:655-660`),
|
||||
误报为「工具不存在」。后果放大:设备离线这类**本该 retry** 的失败被判为
|
||||
「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。
|
||||
|
||||
**改动**:
|
||||
|
||||
- `io/channel.go`:新增哨兵 `ErrToolNotFound` + `ToolNotFound(name)` + `IsToolNotFound(err)`
|
||||
(沿用仓内 `ErrInputChannelUnknown` 的先例)。`IOManager.ExecuteTool` 的父兜底
|
||||
改为**只传递「确实不存在」**,其余错误如实上抛。
|
||||
- `core/stages.go`:`StageHost` 的 not-found 改用 `agentIO.ToolNotFound`。
|
||||
- `core/toolcall.go`:字符串匹配 → `agentIO.IsToolNotFound`;「不存在」时给出
|
||||
**可执行**文案(提示 `get_plugin_tools` / `output_list_channels`),
|
||||
而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。
|
||||
|
||||
**判据**:`toolcall_error_test.go`(类型化 vs 诱饵子串、%w 穿透、执行期文案)+
|
||||
`channel_error_test.go`(父失败不吞、真的不存在仍可判别)。
|
||||
|
||||
**变异验证**:退回父兜底吞噬后,`TestExecuteTool_DoesNotSwallowParentFailureAsNotFound`
|
||||
FAIL(`父的执行失败被误报为『工具不存在』`);修复后全绿。
|
||||
|
||||
⚠️ **过程中的一次自伤**:我先改了 `channel.go` 却漏了 `stages.go` 的 import,
|
||||
导致整包 build 失败。已修。另:第一次写判据时只覆盖了类型化,**没覆盖父兜底吞噬**,
|
||||
变异后仍绿 —— 说明「判据通过」不等于「修的东西被测到」。补了 `channel_error_test.go` 才闭合。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 0.5 ✅ 补批内路径判据(已完成)
|
||||
|
||||
**前提更正**(此前我写「无任何测试直接驱动批内路径」——**不准确**):
|
||||
`scheduler_critical_test.go:125` 的 `TestBatch_NotAbandonedWithoutPreemption`
|
||||
**已经**驱动了「同批两个工具按序执行」。漏查是因为我只 grep 了
|
||||
`PendingTools` / `ToolIdx` 这两个**字段名**,没查断言**内容**。
|
||||
|
||||
**真正缺的是**该测试**未覆盖**的三条(已核实全仓无对应断言):
|
||||
|
||||
| 缺口 | 风险 | 新增判据 |
|
||||
| --- | --- | --- |
|
||||
| `tool_call_id` 配对完整性 | 阶段 2 改消息落法时,配对一旦断裂上游直接报错 | `TestBatchToolCallIDsAllPaired` |
|
||||
| `ContentOnce` 批内语义 | 同一段 assistant 文本在批内重复 N 次,撑爆上下文 | `TestBatchAssistantTextAppearsOnce` |
|
||||
| denied 后**继续**批内 | 改成 abort 会丢掉本可执行的后续调用 | `TestBatchContinuesAfterDeniedTool` |
|
||||
|
||||
另加 `TestBatchExecutesEveryToolCall`(批内全序列 + 顺序 + ToolResults 完整性)。
|
||||
|
||||
**判据**:`toolbatch_test.go`(4 条)+ 复用既有 `TestBatch_NotAbandonedWithoutPreemption`。
|
||||
全部**确定性**断言(阶段 0 已消除 map 随机性)。
|
||||
|
||||
**变异验证**:令 `stepToolBegin` 跳过批内最后一个工具后,**4 条判据同时 FAIL**
|
||||
(既有那条也 FAIL),报错直指 `ToolsUsed = [tool_alpha]`、`tool_call_id "c2" 被声明 0 次`。
|
||||
|
||||
⚠️ **过程中三次自伤**(都靠"判据先写"暴露):
|
||||
|
||||
1. 臆造了不存在的 helper(`sdkToolDef` / `sdkStageCtx`)⇒ build 失败
|
||||
2. 把 `a.stageHost = nil` 后又用它注册 stage handler
|
||||
3. 给 `newTaskFrame` 传 `nil` ⇒ `stepPrepare` 于 `task.go:519` **nil 解引用 panic**
|
||||
⇒ 改用仓内既有的 `a.stageCtxFromInput(...)`(与 `scheduler_preempt_test` 一致)
|
||||
★ 顺带记录:生产路径两处 `newTaskFrame` 调用(`task.go:228/422`)都传真实 ctx,
|
||||
但 `stepPrepare` 对 `f.StageCtx` **无 nil 兜底**。本次不修(无生产触发路径),
|
||||
记为潜在健壮性缺口。
|
||||
|
||||
## 阶段 2 ✅ 并行执行层(已完成)
|
||||
|
||||
| 子项 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| **2a** | 消息落法:一条 assistant 带全部 tool_calls | ✅ `28b42bc` |
|
||||
| **2b** | `ConsumeToolBlocks` 改 per-call 归档 | ✅ `25f5c53` |
|
||||
| **2c** | `StageContext` 拆 per-tool | ✅ `3d12e82`(判据由 2d 闭合) |
|
||||
| **2d** | 批次调度与保序(`ParallelSafe` + 同通道保序) | ✅ `34c0df2` |
|
||||
|
||||
**改动要点**(细节见设计文档 §6):
|
||||
|
||||
- **2a** 现状每工具一对消息;改为一条 assistant 带全部 tool_calls。
|
||||
这本身是协议上更正确的形态(现状不表达「这是一批」)。
|
||||
- **2b** `ConsumeToolBlocks` 原本是 IOManager 级单队列,并发下会互相抢媒体 ⇒
|
||||
破坏「媒体必须紧跟自己 toolMsg」那条三轮实测的结论。改按 `call_id` 归档。
|
||||
- **2c** `f.StageCtx` 原本是单槽;改为每工具一份,`Extra` 逐份浅拷贝
|
||||
(`output_channel` 被 `stage.go:18` 依赖)。
|
||||
- **2d** 全批 `ParallelSafe` 才并发,否则**整批**降级串行(不做部分并发——
|
||||
收益不抵不可预测性);同 `output_send__<通道>` 多次发送**保序**。
|
||||
|
||||
**并发规则**(三条全满足才并发):批内 >1 个工具;**全部**声明 `ParallelSafe`;
|
||||
不含需保序的同通道输出发送。
|
||||
|
||||
**结构**:`StepToolBatch` —— fan-out(每工具一 goroutine,各写自己的
|
||||
`toolCtxs[i]`)→ join → **按索引顺序**串行收尾。收尾必须串行且按索引:
|
||||
`f.Msgs` 是共享切片,且按索引落才能让模型读到的上下文顺序与它自己发出的
|
||||
顺序一致。
|
||||
|
||||
⚠️ **修掉一个我自己引入的竞争**:`resolveTurnScenes` 会把结果记进**共享**的
|
||||
`f.sceneDone` / `f.turnScene`。最初在每个 goroutine 里各调一次 —— 既是数据
|
||||
竞争,又会各自触发一次 `EnterSceneWithHint`,**重复计入场景强度**
|
||||
(正是 `sceneDone` 注释警告过的问题)。改为 fan-out **之前**解析一次。
|
||||
|
||||
### 2c 判据缺口:已由 2d 闭合
|
||||
|
||||
2c 落地时做过变体验证(`toolCtxFor` 退回单槽),**两条判据仍全绿** ——
|
||||
因为串行路径下「单槽」与「per-tool」行为完全一致,差别只在并发下显现。
|
||||
当时据实记为「实现已就位、判据未闭合」。
|
||||
|
||||
2d 落地后补了**并发版**判据(`TestBatchConcurrentEachToolSeesOwnContext`):
|
||||
退回单槽时触发 **6 处 DATA RACE 报告 + 串味断言失败**。缺口至此关闭。
|
||||
|
||||
### 遇到的一处**既有**测试竞态(非本次引入,但会污染回归信号)
|
||||
|
||||
`TestResidualKeepReturnsTasksToParent` / `TestResidualDropNotifiesSyncCaller`
|
||||
偶发失败,报「应处置 2 条,实际 1」。
|
||||
|
||||
**根因**(已核实):`offload_test.go:473` 的 `SpawnResident` 会启动**子 agent 的
|
||||
调度器 goroutine**,而测试随后 `child.sched.enqueue(...)` 两条任务、
|
||||
立刻 `ApplyResidual` 去读同一队列——**全程无任何同步**。调度器与测试读并发
|
||||
同一份 `sched.queue`,条数可能已被取走。
|
||||
|
||||
**为什么以前没暴露**:干净基线(阶段 2 之前)连跑 3 次恰好全绿,是**运气**,
|
||||
不是确定性。`-race` 单跑该用例也过(无并发源)。本次改动让 core 包耗时略增、
|
||||
调度时序变化,才把它翻出来。
|
||||
|
||||
**处置**:属测试侧缺陷,不在本阶段范围内,**记为待修**(修法:测试里改用
|
||||
不启动调度器的子 agent,或给 enqueue/读取加同步)。记录在此以免后续误判为
|
||||
「并行化引入的回归」。
|
||||
|
||||
### 2c 的诚实记录:判据在串行下测不出差别
|
||||
|
||||
`toolCtxFor` 退回单槽后,两条 2c 判据**仍然全绿**。原因是:
|
||||
**串行路径下「单槽」与「per-tool」行为完全一致**——每个工具跑完才进下一个,
|
||||
不存在交错。差别只在**并发**下显现(互相覆写 / after 读到别人的结果)。
|
||||
|
||||
⇒ 因此 2c 的真正判据**必须与 2d 一起写**:并发执行批内多工具时,
|
||||
断言每个工具的 ctx 只带自己的 ToolCalls、after_toolcall 读到自己结果,
|
||||
并以 `go test -race` 确认无数据竞争。
|
||||
**在 2d 落地前,2c 只能算"实现已就位、判据未闭合"**,不得记为已验证。
|
||||
|
||||
---
|
||||
|
||||
## 阶段 1 ✅ 结果契约 + 参数预校验(已完成)
|
||||
|
||||
对应设计文档 §4。**动机**:`Success` 硬编码 `true`(`task.go:757` 唯一赋值点)、
|
||||
`required` 无消费方、结果被降级为 `string`——这三项让阶段 2/3 都缺地基。
|
||||
|
||||
### 1a. SDK 侧新增(纯新增,无签名变更)
|
||||
|
||||
`third_party/homeagent-sdk/sdk/plugin.go`:
|
||||
|
||||
```go
|
||||
// ToolError 描述失败原因。存在的理由:失败若只表达为文本,模型无法定位到字段,
|
||||
// 只能原样重试(实测 cmd_run 失败率 34%~48% 源于同一成因)。
|
||||
type ToolError struct {
|
||||
Field string `json:"field,omitempty"`
|
||||
Reason string `json:"reason"` // required/type/unauthorized/timeout/not_found
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
- `ToolDef` 增 `ParallelSafe bool`(零值 `false` = 不可并行 = 现状行为,
|
||||
刻意让存量插件升级后得到**保守**行为)。
|
||||
- 仓内 `internal/sdk/plugin.go` 补对应别名再导出。
|
||||
|
||||
### 1b. 诚实化 Success
|
||||
|
||||
`Success = (err == nil) && !isToolError(result)`。
|
||||
|
||||
**判据必须同时覆盖新旧两种形态**:存量插件返回 `map[string]interface{}{"error": ...}`
|
||||
(如 `files/plugin.go:228`)要判为失败,而返回普通 map/string 仍算成功。
|
||||
**漏了后者 = 升级会把存量插件的成功误判成失败**,这是本阶段最大的回归风险。
|
||||
|
||||
### 1c. 参数预校验
|
||||
|
||||
在 `executeToolCallInner`(`toolcall.go:47`)分派**之前**执行:逐项查 `required`、
|
||||
逐项查 `properties.type`。失败返回结构化 `ToolError`,不进分派。
|
||||
|
||||
⚠️ **`getBool`(`utils.go:26`)的注释记载 bool/string/float 三种都出现过** ⇒
|
||||
校验**不能**把合法的 `"true"` 判为非法。判据必须覆盖这一点。
|
||||
|
||||
### 1d. 保留结构化值
|
||||
|
||||
`executeToolCall` 内部保留 `rawResult interface{}`(不降级为 string),
|
||||
渲染交给统一函数:**紧凑 JSON,禁止 `fmt.Sprintf("%v")`**(那会产出
|
||||
`map[status:sent id:123]` 这种模型读不懂的 Go 语法)。
|
||||
|
||||
**顺带修一个静默失效**:`task.go:771` 的 `ToolResults[0].Result.(string)` 断言
|
||||
几乎恒失败(插件返回的多是 map)⇒ `after_toolcall` 阶段插件对结构化结果的改写当前无效。
|
||||
|
||||
### 阶段 1 判据
|
||||
|
||||
- `toolerror_test.go`:`isToolError` 对新旧两种形态的判定(各 3 例:失败 map /
|
||||
成功 map / 成功 string)
|
||||
- `argvalidate_test.go`:缺 required、类型不符、`"true"` 宽松放行
|
||||
- 回归:core 包全绿;**存量插件 smoke**(`internal/plugins` 不得新增 FAIL)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 2.5 ✅ 提示词改为「默认并行」(已完成)
|
||||
|
||||
⚠️ 有**硬性顺序约束**:必须在阶段 2 落地**之后**。反序(先说"并发"、内核仍
|
||||
串行)会让提示词**对模型说谎** —— 模型据此推断安全性,写出真正依赖顺序的
|
||||
调用。宁可晚改,不可错改。
|
||||
|
||||
新增【工具执行顺序】段,讲清四件事:
|
||||
|
||||
1. 同一条回复里的多个工具调用**默认并行**(同时跑),不是依次执行
|
||||
2. **不要依赖执行顺序** —— 参数依赖前一个结果就分两轮
|
||||
3. **例外一:同通道 `output_send__` 保序**(用户可见消息顺序敏感)
|
||||
4. **例外二:不并发安全的工具整批退回串行**(写类工具 / 未声明者)
|
||||
|
||||
措辞刻意与 `batchRunnable` 的**真实**判据一致,而不是理想化表述 ——
|
||||
**提示词与实现不符,比不说更坏**。
|
||||
|
||||
顺带改掉 `spawn_child` 的落空表述(原文「应并行 spawn,不要自己串行逐个执行」
|
||||
在并行化之前是落空的)。
|
||||
|
||||
判据 `prompt_parallel_test.go`(5 条),其中**负向**那条最关键:
|
||||
只讲并行、不讲例外 ⇒ FAIL("提示词说谎"的失败模式已被覆盖)。
|
||||
|
||||
## 阶段 3 / 4 ✅ 工具序列插件(已完成)
|
||||
|
||||
| 阶段 | 内容 | 提交 |
|
||||
| --- | --- | --- |
|
||||
| **P1** | AST 解析 + 静态校验 | `76030ae` |
|
||||
| **P2** | 执行引擎(组内并行 + 具名槽 + 条件求值) | `7532af7` |
|
||||
| **P3** | 存储 + 跨序列调用图 + missing 策略 | `3d75312` |
|
||||
| **P4** | 六个 `seq_*` 工具 + 插件装配 | `71c894c` |
|
||||
|
||||
**插件线复用内核并行面,但不要求内核开任何新接口**(见设计文档 §7 边界声明)。
|
||||
|
||||
### 顺带补上的内核两处缺口
|
||||
|
||||
P3 落地时暴露的**真实缺陷**(不是新需求):
|
||||
|
||||
- `GetAllTools` **丢 `ParallelSafe`** ⇒ 插件看到的设备工具一律"不可并发",
|
||||
设备工具的并发声明对插件**不可见**。
|
||||
- `ToolAPI` **缺按名查** ⇒ 新增 `ToolDefByName`。插件需在**运行前**判断
|
||||
目标是否存在(动态注册下"不存在"是常态),而 `GetAllTools` 只能拿全量列表。
|
||||
|
||||
### 本阶段解决的两个设计问题
|
||||
|
||||
1. **跨序列目标存在性**:`Save` 若要求"目标必须先存在",互调的两条序列
|
||||
谁也存不下来(A 要 B 先在、B 要 A 先在)——**依赖在设计上无解**。
|
||||
⇒ `Save` 只校验同序列内的 group 引用;跨序列目标由 `CheckGraph` 兜底。
|
||||
|
||||
2. **黑名单分层**:我一度把 `seq_call`/`seq_when_call` 也禁掉,但那正是
|
||||
本包的核心能力(按名调用),禁掉序列就退化成单层脚本。
|
||||
⇒ 黑名单只管**对外发消息 / 起子 agent / 改插件表 / 再跑整条序列**;
|
||||
`seq_call` 系列留给序列内部组合,递归由 `maxCallDepth` + 环检测负责
|
||||
(设计文档 §8.3 本来就这么定,是我把两层混了)。
|
||||
|
||||
### 遗留项状态
|
||||
|
||||
| 项 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| **D4** | 设备授权闸在 `ToolAPI` 路径失效 | ✅ **已解决**(`994f198`)。`ToolAPI` 新增 `CanUse`,由 bootstrap 注入 agent 的授权判据;两条路径判定一致性由 `TestCanUseAgreesWithInnerPath` 钉住 |
|
||||
| 端到端 | 真机跑一次 `seq_create → seq_run` | ✅ **已完成**(`3d31037`)。并抓出「传参方式完全不可用」的真 bug |
|
||||
| 提权 | `seq` 是否默认启用 | ✅ **无需决策**:仓内已有 `IsPluginDisabled` / `AddDisabledPlugin` 机制,任何插件(含内置)都可被用户禁用,`seq` 无需特殊处理 |
|
||||
|
||||
### 仍需注意的两点
|
||||
|
||||
1. **缺 `device_id` 时是 fail-open**(放行)。这是内核既有语义
|
||||
(`TestDeviceToolAuth_*` 依赖它),本次**未擅自改**;已由
|
||||
`TestCanUseMatchesInnerFailOpenOnMissingDeviceID` 钉住现状。
|
||||
若要改成 fail-closed,**必须内核与 `ToolAPI.CanUse` 两处同时改**,
|
||||
否则两条路径判定不一致本身就是漏洞。
|
||||
|
||||
2. **`TestResidual*` 既有竞态**(`offload_test.go`):`SpawnResident` 起了
|
||||
子调度器 goroutine,而测试 enqueue 后无同步就读同一队列。
|
||||
与本次改动无关,已定位根因,待修。
|
||||
|
||||
## 阶段纪律 [沿用本仓既有教训]
|
||||
|
||||
1. **判据先写,且必须能失败**。写完先跑一次确认 FAIL,再改实现。
|
||||
2. **变异验证**:改完把修复回退一次,确认判据重新 FAIL。
|
||||
「判据全绿」不等于「判据有效」——本仓 T23 教训(fixture 照臆测字段编,
|
||||
判据全绿而实现全错)就是跳过这一步。
|
||||
3. **判据只断言代码真实产出的字段**。我本次就栽过一次:先写了
|
||||
`assert tc.StreamIndex == i`,而 `flushToolCall` 根本不给 `ToolCall` 赋 `StreamIndex`
|
||||
⇒ 恒假信号。判据必须对着**实际输出**写。
|
||||
4. **期望值由独立来源算出**,不引用被测代码。
|
||||
5. **每阶段结束跑全包** `go test ./internal/agent/core/ -count=1`,
|
||||
并检查存量插件 smoke 无新增 FAIL。
|
||||
|
||||
---
|
||||
|
||||
## 附:更新前后全面压测结果(2026-09-27)
|
||||
|
||||
方法:两版内核(旧 `85e3d66` / 新 `d3eaff4`)在**隔离 netns** 内各起一个实例,
|
||||
共用同一个 mock LLM(保证 LLM 行为完全一致,消除变量),驱动脚本
|
||||
`scripts/kernel-stress/cmp.py`。规模 3 = 24 连接 × 12 输入 = 288 条。
|
||||
|
||||
### 批内工具调用(核心差异)
|
||||
|
||||
| 场景 | 旧版 中位/工具数 | 新版 中位/工具数 |
|
||||
| --- | --- | --- |
|
||||
| `!slowbatch2` | 0.221s / **0 个** | 0.380s / **2 个** |
|
||||
| `!slowbatch4` | 0.222s / **0 个** | 0.399s / **4 个** |
|
||||
| `!slowbatch8` | 0.220s / **0 个** | 0.409s / **8 个** |
|
||||
|
||||
★ **旧版"更快"是假的**:它的 `openai.lua` 缺 `stream_index` 透传,多个分片
|
||||
并到槽 0、argsRaw 混拼 ⇒ 每个工具报「参数不是合法 JSON」而**一个都没真跑**。
|
||||
这正是「只看耗时会被骗」的典型,所以 cmp.py 每轮都记录**处理数**并把它当作
|
||||
通过条件之一。
|
||||
|
||||
### 并发 vs 强制串行(新版内部对照)
|
||||
|
||||
`!slowbatchN`(全部 ParallelSafe ⇒ 并发)vs `!serialbatchN`(混入
|
||||
`knowledge_create` ⇒ 整批降级):
|
||||
|
||||
| N | 并发 | 强制串行 | 加速 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2 | 0.378s | 0.532s | 1.41× |
|
||||
| 4 | 0.389s | 0.864s | 2.22× |
|
||||
| 8 | 0.409s | 1.585s | **3.88×** |
|
||||
|
||||
并发批耗时**几乎不随 N 增长**,串行批严格线性 ⇒ N 越大加速比越贴近上限。
|
||||
|
||||
### 通过率与稳定性
|
||||
|
||||
| 维度 | 旧版 | 新版 |
|
||||
| --- | --- | --- |
|
||||
| 调度器并发轰炸(288 输入) | 288/288 **100%** | 288/288 **100%** |
|
||||
| 连续稳定性(20 轮无错误) | 20/20 | 20/20 |
|
||||
| 内核队列 / 拒绝 / panic | 空 / 0 / 无 | 空 / 0 / 无 |
|
||||
|
||||
规模 1(32 输入)与规模 3(288 输入)结果**完全一致** ⇒ 可复现,非偶然。
|
||||
|
||||
### 本次改动与 ONNX 无关
|
||||
|
||||
79 个改动文件全部集中在:内核并发调度(22)、seq 插件(15)、Lua 适配器(17)、
|
||||
压测脚本(4)、SDK(3)、io(3)、cmd 插件(2)。**未触及** `distill.go` /
|
||||
`onnx.go` / `nlp`,而工具调用路径本身不经过 ONNX ⇒ 上面的结论对生产成立。
|
||||
|
||||
### ✅ 部署(已完成 2026-09-27)
|
||||
|
||||
生产实例已更新到 `d3eaff4`+ 并验证通过:
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| 二进制 | 86,496,624 → **86,784,400** 字节(onnxruntime) |
|
||||
| 服务 | `active`、`kernel ready`、LLM 可达(unreachable=0) |
|
||||
| 多模态空间 | `provider=chineseclip dim=512 modalities=[text image]` |
|
||||
| `seq_*` 工具 | **7 个全部注册** |
|
||||
| 适配器 | md5 **完全一致**(`bf1dff86…`)—— 无 `.bundled` 清单 ⇒ 首次升级不覆盖已有文件 |
|
||||
| 备份 | `/var/tmp/homed-backup-20260927-194554`(含 `ROLLBACK.sh`) |
|
||||
|
||||
> 注:部署前生产二进制构建于**当天 06:36**,而 `seq` 引入于 `71c894c`(更晚)
|
||||
> ⇒ 旧实例的 `strings /usr/local/bin/homed | grep -c internal/plugins/seq` 为 **0**。
|
||||
> 它在 QQ 上如实回答"没有编排工具"**并不是说谎**,是确实没有。
|
||||
> 这类"实例自述与代码状态不一致"应先查二进制构建时间,别急着怀疑提示词。
|
||||
|
||||
### ⚠ 部署前置条件
|
||||
|
||||
生产二进制是 **`-tags=onnxruntime`** 构建(strip 后 75MB、`.rodata` 62.5MB),
|
||||
普通 `go build` 只有 28MB。`deploy/packaging/package-linux.sh:139` 会显式拒绝
|
||||
非 onnxruntime 构建:
|
||||
|
||||
if ! go version -m "$homed_bin" | grep -Eq 'build[[:space:]]+-tags=.*onnxruntime'; then
|
||||
echo "ERROR: homed 不是 onnxruntime 构建,拒绝打 server/full 包" >&2
|
||||
|
||||
⇒ **必须走 `deploy/packaging/build.sh`(需 `libonnxruntime.so` 与
|
||||
`CHINESECLIP_BUNDLE_DIR` 资产)才能部署**,否则依存句法分析与多模态向量化失效。
|
||||
|
||||
---
|
||||
|
||||
## 附:设备命令白名单改为可配置(2026-09-27)
|
||||
|
||||
与上面的 toolcall 并行是同一次排查的**另一条线**,记在这里是因为它同样属于
|
||||
"能力声明不该硬编码"这个主题。
|
||||
|
||||
### 起因
|
||||
|
||||
agent 通过 `device_ctl_cmdrun` 下发命令,命令在**设备侧**执行
|
||||
(`cmd/waiter/device.go` 的 `exec.CommandContext`),而白名单是**源码里
|
||||
硬编码的正则**(18 个命令:`ls/pwd/cat/df/…`)。`waiter.yaml` 里**没有任何键
|
||||
能改它** ⇒ `find` / `grep` / `sed` / `sort` / `tr` 这些排查问题最常用的
|
||||
**只读**命令一律被拒:
|
||||
|
||||
device_ctl_cmdrun device_id:waiter-fnnas error: command not in whitelist
|
||||
|
||||
注意 `device_authorized` 当时**已经是 `true`**、两台 token 相同、进程正常 ——
|
||||
所以"没开启设备桥授权"这个判断是错的,问题在白名单。
|
||||
|
||||
### 改动
|
||||
|
||||
`waiter.yaml` 新增 `device_cmd_allowlist`(字符串数组):
|
||||
|
||||
```yaml
|
||||
device_cmd_allowlist:
|
||||
- ls
|
||||
- find
|
||||
- grep
|
||||
- sed
|
||||
```
|
||||
|
||||
- **替换**默认集而非追加:避免"以为加了 find、结果还留着 `python3 -c` 任意执行"
|
||||
- 留空 ⇒ 用内置默认集(★ **绝不能变成"全放行"**,那等于静默拆掉闸门)
|
||||
- 只取命令名**第一段**再整词匹配:`grep -rn x .` 能过,而 `grepXxx` / `mygrep`
|
||||
不会因 `contains` 蒙混过关
|
||||
|
||||
### ★ 一次真实的疏漏
|
||||
|
||||
waiter 有**两条**设备桥启动路径:
|
||||
|
||||
| 路径 | 场景 |
|
||||
| --- | --- |
|
||||
| `main.go` 的 `startDeviceBridge` | 交互 / 一次性模式 |
|
||||
| `daemon.go` 的 `startDaemonDeviceBridge` | **`waiter --daemon`(生产两台都这么跑)** |
|
||||
|
||||
最初只在 `main.go` 里赋值 ⇒ daemon 路径不经过那里 ⇒ 配置**完全不生效**。
|
||||
症状极难定位:**配置写了、启动也打了招呼、命令照样被拒** ——
|
||||
看起来像"配置没读到",实际是"那条路径没接线"。
|
||||
已加 `TestDaemonPathAppliesAllowlist` 守住。
|
||||
|
||||
### ★ 已知局限:只匹配命令名,不看参数
|
||||
|
||||
实测(22 条白名单下):
|
||||
|
||||
| 命令 | 结果 | 实际副作用 |
|
||||
| --- | --- | --- |
|
||||
| `find . -name x.go` | 放行 | 只读 ✓ |
|
||||
| `find . -delete` | **放行** | ★ 删文件 |
|
||||
| `find . -exec rm {} ;` | **放行** | ★ 执行删除 |
|
||||
| `sed -i s/a/b/ f` | **放行** | ★ 原地改文件 |
|
||||
| `sort -o out.txt in.txt` | **放行** | ★ 写文件 |
|
||||
|
||||
即:**白名单是"命令名清单",不是"只读保证"**。用户已知悉并选择先下发
|
||||
(`ship_now`),参数级拦截(拒绝 `-i` / `-delete` / `-exec` / `-o` / `> 重定向`)
|
||||
作为后续项。
|
||||
|
||||
⇒ 写文档时不要把这一层叫"只读白名单",那会让人以为写操作被挡住了。
|
||||
|
||||
### 部署
|
||||
|
||||
| | 106 (fnnas) | 30 (mainnas) |
|
||||
| --- | --- | --- |
|
||||
| 二进制 | 11,388,177 → **12,691,402** | 同 |
|
||||
| 版本 | 8月27日 → `1.4.0` | 同 |
|
||||
| 白名单 | 22 条(启动日志确认读到) | 同 |
|
||||
| 备份 | `waiter.bak-20260927-194730` | `waiter.bak-20260927-194750` |
|
||||
|
||||
**顺带解答了一个悬案**:106 此前一直没有 `online` 日志,而 30 正常。
|
||||
两台配置与 token 完全相同 ⇒ 差异只可能在旧 waiter 二进制。8月27日那版
|
||||
落在"未 bind 时收到 ping 会关连接"的缺陷窗口里 ⇒ 更新后已正常:
|
||||
|
||||
19:46:12 device waiter-fnnas online → 输出通道 device-waiter-fnnas
|
||||
19:47:30 device waiter-fnnas offline → online (更新二进制时重连)
|
||||
|
||||
### 部署脚本的一个坑
|
||||
|
||||
`deploy/scripts/deploy-waiter.sh 里 `ssh` 会从 stdin 读,把后续 `read -p "确认更新"` 的输入吃掉:
|
||||
|
||||
bash deploy/scripts/deploy-waiter.sh deploy <ip> <<< "yes" # 喂了 yes 却打印「已取消」
|
||||
|
||||
脚本本身完全正常、备份逻辑没问题,只是"明明喂了 yes 却什么也没发生"。
|
||||
已给 5 处 `ssh` 统一加 `-n`。
|
||||
|
||||
---
|
||||
|
||||
## 附:生产日志里的两个 toolcall 告警(2026-09-27 21:0x 排查)
|
||||
|
||||
部署后逐条核对了 `homeagent.service` 的告警。结论:**适配器无缺陷;
|
||||
toolcall 侧一个真缺陷(已修)、一个插件侧 bug(内核自愈,非内核缺陷)。**
|
||||
|
||||
### 适配器:干净
|
||||
|
||||
| 检查项 | 次数 |
|
||||
| --- | --- |
|
||||
| `finish_reason=length`(输出截断) | **0** |
|
||||
| `unmarshal unified response` 失败 | **0** |
|
||||
| 流式分片解析错误 | **0** |
|
||||
| `非法 JSON 帧` | 11,**全在 19:46:03–09 启动握手期**,此后 3 小时零发生 |
|
||||
|
||||
`stream_index` 透传亦已核实:生产 `adapters/openai.lua:122` 与仓库版一致。
|
||||
|
||||
### ① `has empty arguments` —— 真缺陷,已修
|
||||
|
||||
原始响应里参数**完好**:
|
||||
|
||||
```json
|
||||
"tool_calls":[{"function":{"arguments":"{}","name":"clawhubadapter_list"},...}]
|
||||
```
|
||||
|
||||
根因:诊断条件用 `len(tc.Arguments)==0 && RawArguments==""`,而
|
||||
`parseToolArguments("{}")` 返回**非 nil 的空 map** ⇒ 零参数工具
|
||||
(`seq_list` / `*_list` / `seq_help`,其 `properties` 本就是 `{}`)全部误报。
|
||||
部署后共 **14 次**。
|
||||
|
||||
**危害不是"日志吵"**,而是这条诊断的本职是抓「上游/适配器真的丢了参数」——
|
||||
真发生时会被这堆噪音淹没。**诊断日志失去信噪比就等于没有。**
|
||||
|
||||
修法:新增 `argsLookDropped(rawArgs)`,判 `RawArguments` 原文而非解析后的 map:
|
||||
空串/空白 ⇒ 真丢;能解析成 JSON(哪怕是 `{}`)⇒ 没丢;解析失败(半截 JSON)⇒ 等同丢失。
|
||||
|
||||
判据 3 条,其中 `TestArgsLookDroppedEndToEnd` 用**日志里出现过的真实 body**
|
||||
走 `normalizeOpenAIToolCalls` 到判定的完整接缝 —— 单测过了但接缝不对的情况,
|
||||
只有端到端才抓得到。
|
||||
|
||||
### ② `重复申请 stage 锁` —— 插件侧 bug,内核自愈正常
|
||||
|
||||
```
|
||||
stage.go:106 qq stage before_toolcall 失败后强制释放其持有的 stage 锁
|
||||
stages.go:260 before_toolcall handler error: 插件 qq 重复申请 stage 锁(handler 内不应嵌套加锁)
|
||||
```
|
||||
|
||||
**不要当内核缺陷去修。** 链路是:
|
||||
|
||||
1. `proc_main.go.tmpl:1569` —— SDK 生成的模板在**每个** stage handler 入口
|
||||
**自动**调 `callCoreVoid("stage.lock", nil)`(跨进程写锁,内核仲裁)
|
||||
2. `lock.go:51` —— 锁**不可重入**:`l.held && l.owner == plugin` 即报错
|
||||
3. 所以只要**同一次 `before_toolcall` 被触发两次且首次未释放**,就会命中
|
||||
|
||||
已排查并排除的可能:qq 的 `beforeToolcall`(`plugin.go:1303-1350`)函数体里
|
||||
只有 `ctx.Lock()`(SDK **数据**锁,与 proc stage 锁是两把锁)与
|
||||
`currentToolAllowed` / `clearPreviousDenial` 等纯本地调用,**无任何再次触发 stage 的路径**。
|
||||
|
||||
⇒ 成因在**插件进程侧的运行时**(编译进 `plugin.bin`),不在 example/qq 的业务代码里。
|
||||
生产 `plugin.bin` 是 **9月14日**的独立构建产物,**不随 homed 部署** ——
|
||||
要修需改 SDK 模板并重编该二进制,改动面比内核大得多。
|
||||
|
||||
**内核这边的行为是正确的**:`stage.go:102-106` 在插件持锁失败时强制释放,
|
||||
注释写明这是"锁仲裁回内核"的自愈机制(实验 9),目的正是**避免后续插件死锁**;
|
||||
`stages.go:258` 把错误收进 `ctx.Errors` 而不中断流程,所以那轮 212 秒正常跑完。
|
||||
|
||||
⇒ 5 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就是真死锁。
|
||||
@ -1,5 +1,7 @@
|
||||
# 三元组抽取:注意力模型方案
|
||||
|
||||
> **状态:参考 / 机制说明。** 三元组抽取与注意力机制说明。
|
||||
|
||||
> 2026-10-03。目标是**推理时零 LLM**——一次 LLM 标注,训一个专用网络。
|
||||
|
||||
## 一、为什么必须是「注意力学任务结构」而不是更好的向量
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
# 向量 provider 决策:Qwen3-VL vs chineseclip
|
||||
|
||||
> **状态:决策记录(已定)。** 记录了 provider 选型过程与结论,属决策归档性质。
|
||||
|
||||
> 结论先行(2026-10-03 实测,真库 260 个块,两套 ONNX 均已导出校验通过)
|
||||
>
|
||||
> **不切换 provider。** 各用其位:
|
||||
|
||||
@ -77,6 +77,14 @@ type Agent struct {
|
||||
// 为什么必须留档:交接是「推信号 + 拉状态」两步(见 CloseRTChild),
|
||||
// 推完中断后实例已出登记表 —— 若不留档,主 agent 收到"可调用 rt_transcript
|
||||
// 取回原文"的提示时,原文已经取不到了。
|
||||
//
|
||||
// ★ 键是 id,所以**同名复用会撞键**。串线的真因在读侧:RTTranscript 曾
|
||||
// "先查留档、命中即返回",于是新实例读到的永远是上一通已回收的原文
|
||||
// (2026-10-30 实测复现)。现在改为**在跑实例优先**(见 RTTranscript)。
|
||||
//
|
||||
// ★ 刻意**不**在 Spawn 时 delete 这份留档:留档存在的意义就是"回收后
|
||||
// 主 agent 按需取回原文",提前删掉等于让推了信号却拉不到正文。
|
||||
// 同名键只在下一次 CloseRTChild 写入时被自然覆写。
|
||||
rtClosedTranscripts map[string][]agentAPI.Message
|
||||
// rtSlot 是**本 agent 作为 rtchild 时**的输入槽(作为父时无用)。
|
||||
rtSlot *rtSlot
|
||||
@ -274,6 +282,15 @@ type Agent struct {
|
||||
// builtinHandlers 是交给 device 路由层的族处理器适配器(装配期构造一次)。
|
||||
builtinHandlers agentAPI.BuiltinToolHandlers
|
||||
|
||||
// securityScopeView / securityScopeOnce 是本 agent 的**策略执行视图**
|
||||
// (security 按 agentID 持有,见 security_profile.go 的 securityScope)。
|
||||
//
|
||||
// ★ 为什么是 per-agent 而不是全局:轮次档位(sessionProfile)是该 agent
|
||||
// 独有的状态。放在共享单例上,主 agent 的 guest 会被驻留子的 owner 覆盖
|
||||
// —— 实测可复现(两者同进程并发)。
|
||||
securityScopeView *security.AgentScope
|
||||
securityScopeOnce sync.Once
|
||||
|
||||
// security 是安全防火墙运行时(security 模块产出,可 nil)。
|
||||
//
|
||||
// ★ 可 nil:测试与独立工具(memgc 等)不需要安全模块。
|
||||
@ -361,6 +378,31 @@ type AgentConfig struct {
|
||||
// nil 或空 = **完整授权**(默认);非空 = 白名单,只允许列出的输出通道。
|
||||
// 父 agent 创建驻留子时用它收窄子的输出能力。
|
||||
AllowedOutputs []string
|
||||
// Security 是安全防火墙运行时(security 模块产出,可 nil)。
|
||||
//
|
||||
// ## 为什么它是**装配期字段**而不是事后 Set
|
||||
//
|
||||
// 这个字段曾经只能靠 `SetSecurity()` 注入,而生产装配链里**没有任何
|
||||
// 地方调用它** —— 唯一的调用方是 4 条测试。于是 `securityDecisionFor`
|
||||
// 在生产中恒走 nil 分支、恒放行,而策略表本身完全正确:这是
|
||||
// 「机制 ≠ 接线」的教科书样本(GUI 无拒绝记录,看起来像"没人违规")。
|
||||
//
|
||||
// ★ 根因在**依赖图**,不在这行赋值:`agent.Requires()` 没声明
|
||||
// `FacSecurity`,图就没有理由把 security 的产物交给 agent(只有声明了
|
||||
// 的 device 拿到了)。所以修复必须同时改三处:
|
||||
// ① kernelmod/agent 的 Requires() 加 FacSecurity
|
||||
// ② 这里加字段 + New() 赋值 + buildAgent 传 k.Security
|
||||
// ③ 装配级判据(删 ① 或 ② 必须红)
|
||||
// 只做 ②不做 ① 会让代码读 `k.Security` 而图不保证它已就绪 ——
|
||||
// 今天恰好对(security 在第 1 层)是运气,不是设计。
|
||||
//
|
||||
// ## 子 agent 必须继承
|
||||
//
|
||||
// 驻留子 / rtchild 各自 `New(AgentConfig{...})`,不继承就等于子 agent
|
||||
// 完全没有闸门(而它们恰是"能指挥设备、能写记忆"的高风险执行体)。
|
||||
// 见 resident.go / rtchild.go 里 `Security: a.security`。
|
||||
Security *security.Runtime
|
||||
|
||||
// IsRealtime 标记本 agent 是 **rtchild**(实时子 agent,plan.md 十一)。
|
||||
//
|
||||
// 置 true 后:
|
||||
@ -531,8 +573,11 @@ func New(cfg AgentConfig) *Agent {
|
||||
embeddingProvider: cfg.EmbeddingProvider,
|
||||
embeddingError: cfg.EmbeddingError,
|
||||
fusionCfg: cfg.FusionCfg,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
// ★ 安全运行时在**构造期**落位(不再依赖事后 SetSecurity)。
|
||||
// 为 nil 表示调用方显式不启用(独立工具/单测),不是"忘了接线"。
|
||||
security: cfg.Security,
|
||||
noMergeMarkers: make(map[string]int),
|
||||
lastInput: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 输入路由:inputch 是可分配资源,划给某个 agent 后输入**只**流向那个 agent
|
||||
@ -563,6 +608,13 @@ func (a *Agent) Start() {
|
||||
func (a *Agent) Stop() {
|
||||
// 父退出**必须**销毁全部驻留子(设计 §10 硬约束:子不得比父活得久、不留孤儿)。
|
||||
a.StopResidents()
|
||||
// ★ 释放本 agent 的策略执行视图(2026-10-31)。
|
||||
//
|
||||
// 视图里存着 **longtimeProfile**(跨轮保留的档位)。不释放的话,
|
||||
// 同名 agent 重建会继承上一代的 long-time 档 —— 那时可能是
|
||||
// "上一个任务留下的访客权限"被新一代默默沿用。
|
||||
// 与主体策略撤销(revokeChildPolicy)同一纪律。
|
||||
a.ReleaseSecurityScope()
|
||||
// 停机前给待办任务补终态。运行中的任务会经 cancel → LLM 失败 → emitResponse
|
||||
// 自然拿到终态,但**从未运行**(排队/待处理)与**已挂起**的任务不会有任何人
|
||||
// 回它们;带 ResponseCh 的同步注入方(cli / clawhubadapter 均无超时)会永久挂起
|
||||
|
||||
@ -62,6 +62,22 @@ type ResidentOptions struct {
|
||||
// 但**绝不能**把人(父的模型)建的子算进去或拿去复用 ——
|
||||
// 那会把人工安排的工作负载搬到一个本来在做别的事的 agent 上。
|
||||
OffloadOwned bool
|
||||
// AllowedTools 是**父为这个子声明**的工具白名单(空 = 不作额外限制)。
|
||||
//
|
||||
// ## 为什么由父在创建时给(用户裁定 2026-10-30)
|
||||
//
|
||||
// 子 agent 没有配置中心句柄(轻量内核刻意不接),也**没有资格自己决定权限**
|
||||
// —— 权限只能来自委派者。这与 PreferredProvider 同一道理:
|
||||
// “用哪个源/给多少权限”是**委派策略**,本来就该在创建点决定。
|
||||
//
|
||||
// 内核持有这份声明(`security.Runtime` 的主体表),子崩了/被替换也不会丢:
|
||||
// 否则“挂掉子就能绕过”就是现成的提权口子。
|
||||
AllowedTools []string
|
||||
// DenyTools 是**父为这个子声明**的追加禁止(父自己能用、但不给这个子用)。
|
||||
//
|
||||
// 与 AllowedTools 同时给时:两者**同时生效**(先 deny 后 allow,deny 优先)。
|
||||
// 典型用法:不给白名单(沿用父的),只禁掉几个高危工具。
|
||||
DenyTools []string
|
||||
}
|
||||
|
||||
// ResidentInfo 是父对某个驻留子的可查询状态(登记表条目 + 状态面摘要)。
|
||||
@ -199,6 +215,12 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
KernelSource: parentID, // 子的 L4 只属于父
|
||||
ParentID: parentID,
|
||||
TaskPrompt: opts.TaskPrompt,
|
||||
// ★ 安全运行时必须继承(2026-10-30 补)。
|
||||
//
|
||||
// 驻留子是长期挂着、无人盯的执行体:它自己跑调度器、接自己的输入、
|
||||
// 长时间不被人工审阅。它是本仓**最需要闸门**的一类 agent,
|
||||
// 而原先它是唯一**完全没有闸门**的(子从 New 起 a.security 就是 nil)。
|
||||
Security: a.security,
|
||||
})
|
||||
|
||||
rc := &residentChild{
|
||||
@ -210,6 +232,23 @@ func (a *Agent) SpawnResident(opts ResidentOptions) (ResidentInfo, error) {
|
||||
offloadOwned: opts.OffloadOwned,
|
||||
}
|
||||
|
||||
// ★ 父为这个子**声明主体策略**(用户裁定 2026-10-30)。
|
||||
//
|
||||
// 位置必须在“子开始跑”之前:子一旦接到第一条输入就会执行工具,
|
||||
// 那个时刻策略必须已经在内核里 —— 否则存在一个“无约束执行”的窗口。
|
||||
//
|
||||
// 声明失败要**回滚整个创建**:一个“本该受限却无限”的执行体
|
||||
// 比创建失败危险得多(而创建失败是可重试的)。
|
||||
if err := a.declareChildPolicy(opts.ID, opts.AllowedTools, opts.DenyTools); err != nil {
|
||||
child.Stop()
|
||||
if light != nil {
|
||||
_ = light.Close()
|
||||
}
|
||||
_ = mainRO.Close()
|
||||
_ = os.RemoveAll(filepath.Dir(opts.TempPath))
|
||||
return ResidentInfo{}, fmt.Errorf("为驻留子 %s 声明安全策略失败(拒绝创建一个无约束的子执行体): %w", opts.ID, err)
|
||||
}
|
||||
|
||||
// ④ 子 → 父的主动消息(**L3 中断**,带子标识):投进父的 inputch。
|
||||
parentInCh := a.residentInboundChannel(opts.ID)
|
||||
// 登记了入站 inputch 就必须有回滚路径:注册点与 residents 登记之间
|
||||
@ -564,6 +603,13 @@ func (a *Agent) teardownResidentWith(rc *residentChild, disp ChannelDisposition,
|
||||
rc.state = "stopped"
|
||||
rc.mu.Unlock()
|
||||
|
||||
// ★ 撤销父为它声明的主体策略(与 teardown 同时,早于一切资源回收)。
|
||||
//
|
||||
// 不撤销的后果:主体表以**调用方给的名字**为键,回收后同名重建是常规
|
||||
// 操作 ⇒ 新一代用上一代父留的约束运行;且表随 create/destroy 单调增长。
|
||||
// 与 rtchild 留档串线(rtClosedTranscripts)同一形态。
|
||||
a.revokeChildPolicy(rc.id)
|
||||
|
||||
rc.agent.Stop() // 停子的调度器(取消其运行中的任务)
|
||||
if rc.light != nil {
|
||||
_ = rc.light.Close() // 关掉 temp 实例
|
||||
|
||||
@ -80,6 +80,9 @@ func (a *Agent) executeResidentAgents(tc agentAPI.ToolCall) string {
|
||||
AllowedOutputs: splitArg(strArg(tc, "allowed_outputs")),
|
||||
Capacity: intArg(tc, "capacity"),
|
||||
TempPath: tempPath,
|
||||
// ★ 父为子声明工具策略(用户裁定 2026-10-30)。
|
||||
AllowedTools: splitArg(strArg(tc, "allowed_tools")),
|
||||
DenyTools: splitArg(strArg(tc, "deny_tools")),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("创建驻留子失败: %v", err)
|
||||
|
||||
@ -174,6 +174,21 @@ func newRTSlot() *rtSlot {
|
||||
return &rtSlot{wake: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// ── 同名复用的清理纪律 ──
|
||||
//
|
||||
// rtchild 的两张表都以 **id 字符串**为键,而 id 由调用方(模型)给。
|
||||
// 于是同一个 id 被回收后重建(反复"拨同一通电话")时,上一代留下的
|
||||
// 东西会被当成这一代的。两个具体的串线点(都有判据钉住):
|
||||
//
|
||||
// ① rtClosedTranscripts[id] —— CloseRTChild 写入,读侧原本"先查留档、
|
||||
// 命中即返回" ⇒ 同名重建后新实例的 rt_transcript 返回上一代已回收的原文。
|
||||
// 修法:RTTranscript 改为**在跑实例优先**(留档只在没有活实例时才用)。
|
||||
// ② inboundChannelName(id) —— StopRTChildren(复数)漏了注销,
|
||||
// 与 StopRTChild(单数)不对称。
|
||||
// 修法:复数路径补上同一步注销。
|
||||
//
|
||||
// 两处都修在真正出错的那一侧(一个在读、一个在停),**不引入代际号**
|
||||
// 之类的额外抽象:问题本身只需两句定位准确的改动。
|
||||
// Push 覆盖写入一条输入,返回本次的 seq。
|
||||
//
|
||||
// 旧值**直接丢弃**(不是排队、不是补偿)—— 这正是契约 1/3 的实现。
|
||||
@ -353,6 +368,13 @@ type RTChildOptions struct {
|
||||
// 与 `spawn_child` 的 max_turns 同义,但默认值必须是 0(不限):
|
||||
// rt 是**常驻对话**而非一次性任务,跑满就结束是错的。
|
||||
MaxTools int
|
||||
// AllowedTools / DenyTools 是**父为这个 rtchild 声明**的工具策略。
|
||||
//
|
||||
// 与驻留子同理(见 ResidentOptions.AllowedTools)。rt 流不禁工具(用户裁定 R3),
|
||||
// 但 R3 说的是“内核不拔工具”,不是“父不能收窄”—— 通话场景下
|
||||
// 把 cmd_run 这类高危工具从子手里拿掉是合理且必要的。
|
||||
AllowedTools []string
|
||||
DenyTools []string
|
||||
}
|
||||
|
||||
// rtChildDefaultPrompt 是 rtchild 的默认系统提示词。
|
||||
@ -473,7 +495,14 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
AllowedOutputs: []string{opts.Output},
|
||||
KernelSource: string(a.id),
|
||||
ParentID: string(a.id),
|
||||
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
|
||||
// ★ 安全运行时**必须继承父的**(2026-10-30 补)。
|
||||
//
|
||||
// rtchild 是高频、低延迟的执行体,且它照样能调工具(R3:rt 流不禁工具)。
|
||||
// 不继承的后果与主 agent 同型:`securityDecisionFor` 恒走 nil ⇒ 恒放行。
|
||||
// 而它比主 agent 更危险 —— 通话场景下它一边听用户说话一边执行工具,
|
||||
// 没有任何人工复核的窗口。
|
||||
Security: a.security,
|
||||
IsRealtime: true, // ★ 类型标签:只有它可被划 rt 通道
|
||||
})
|
||||
// ★ 不调 carrier.Start():那会起 schedulerLoop / interceptLoop / 3 个记忆
|
||||
// 整理循环。rt 要的是"不吃队列、无轮询",这些 goroutine 对它是**反向**的。
|
||||
@ -562,8 +591,36 @@ func (a *Agent) SpawnRTChild(opts RTChildOptions) (string, error) {
|
||||
return "", fmt.Errorf("划入 outputch(rt) %q: %w", opts.Output, err)
|
||||
}
|
||||
|
||||
// ★ 父为这个 rtchild **声明主体策略**(用户裁定 2026-10-30)。
|
||||
//
|
||||
// 位置在这一步(通道已划好、rtLoop 尚未起):子一旦开始跑就会执行工具,
|
||||
// 那时策略必须已在内核里,否则存在一个“无约束执行”的窗口。
|
||||
//
|
||||
// 声明失败要回滚:一个“本该受限却无限”的执行体比创建失败危险得多。
|
||||
if err := a.declareChildPolicy(opts.ID, opts.AllowedTools, opts.DenyTools); err != nil {
|
||||
cancel()
|
||||
rollback()
|
||||
return "", fmt.Errorf("为 rtchild %s 声明安全策略失败(拒绝创建一个无约束的子执行体): %w", opts.ID, err)
|
||||
}
|
||||
|
||||
a.rtMu.Lock()
|
||||
a.rtChildren[opts.ID] = rc
|
||||
// ★ 这里**不**清留档(与一个曾被写错一版的修复相反,记录以示后人)。
|
||||
//
|
||||
// 我一度在这里加过 `delete(a.rtClosedTranscripts, opts.ID)`,理由是
|
||||
// "同名重开就该让旧留档失效"。实测后撤回,两个原因:
|
||||
//
|
||||
// ① **它治的不是那个病**。串线的真因在**读取顺序**(RTTranscript
|
||||
// 曾先查留档、命中即返回),已在那边修好。把这里改回保留留档,
|
||||
// 判据 TestRTChild_ReuseAfterCloseKeepsTranscriptsApart 仍然绿
|
||||
// —— 反证它不解决问题。
|
||||
// ② **它本身有害**。留档的全部意义就是"回收后、主 agent 按需取回
|
||||
// 原文"(交接 = 推信号 + 拉状态两步)。在 Spawn 时删掉,等于在
|
||||
// 父还没拉之前就把上一通的正文抹了 —— 正是 CloseRTChild 注释里
|
||||
// 反复强调要避免的"推了信号但拉不到正文"。
|
||||
//
|
||||
// 现在的语义:留档只在**下一次 CloseRTChild 写同名键**时被覆写
|
||||
// (自然淘汰),在那之前一直可取。
|
||||
a.rtMu.Unlock()
|
||||
|
||||
go carrier.rtLoop(rc)
|
||||
@ -586,6 +643,12 @@ func (a *Agent) StopRTChild(id string) error {
|
||||
if !ok {
|
||||
return fmt.Errorf("rtchild %s 不存在", id)
|
||||
}
|
||||
// ★ 撤销父为它声明的主体策略(与 StopRTChild 同时)。
|
||||
//
|
||||
// 与 resident 同理:主体表以调用方给的名字为键,同名重建会继承旧策略。
|
||||
// 而且若不撤销,`rt_children create id=x` → `stop x` → `create id=x`
|
||||
// 这个循环会让策略表持续增长且语义错误。
|
||||
a.revokeChildPolicy(id)
|
||||
rc.mu.Lock()
|
||||
rc.stopped = true
|
||||
rc.mu.Unlock()
|
||||
@ -618,6 +681,13 @@ func (a *Agent) StopRTChild(id string) error {
|
||||
}
|
||||
|
||||
// StopRTChildren 停止全部 rtchild(父退出时必须;不留孤儿)。
|
||||
//
|
||||
// ★ 必须与 StopRTChild(单数)**行为对称**:那个会注销入站登记
|
||||
// (child/rt-<id>),本函数曾漏了这一步。
|
||||
//
|
||||
// 漏的后果与驻留子那边记录的是同一个形态:登记表是共享的、按 name 全局唯一,
|
||||
// 残留随 create/destroy 次数**单调累积**,而外部没有任何工具能单独注销
|
||||
// 一条 inputch —— 只能重启 homed 清。
|
||||
func (a *Agent) StopRTChildren() int {
|
||||
a.rtMu.Lock()
|
||||
all := make([]*rtChild, 0, len(a.rtChildren))
|
||||
@ -630,8 +700,22 @@ func (a *Agent) StopRTChildren() int {
|
||||
rc.mu.Lock()
|
||||
rc.stopped = true
|
||||
rc.mu.Unlock()
|
||||
// ★ 撤销主体策略(必须与单数路径对称)。
|
||||
//
|
||||
// 这里曾漏过一步(入站登记未注销),与今天修的 StopRTChildren
|
||||
// 不对称是同一类缺陷形态 —— 所以两处必须逐字对齐。
|
||||
a.revokeChildPolicy(rc.id)
|
||||
rc.agent.cancel()
|
||||
_, _, _ = rc.slot.Take()
|
||||
// 注销入站登记(与 StopRTChild 的单数路径逐字一致)。
|
||||
//
|
||||
// 不碰插件声明的 rt 通道本身(那两条销毁后**悬空**,用户裁定):
|
||||
// 这里只摘本内核自己建的 child/rt-<id>。
|
||||
if a.io != nil {
|
||||
if reg := a.io.ChannelRegistry(); reg != nil {
|
||||
reg.Unregister(inboundChannelName("rt/" + rc.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(all) > 0 {
|
||||
log.Printf("[rtchild] stopped all %d instance(s)", len(all))
|
||||
@ -766,6 +850,19 @@ func (a *Agent) rtRunOnce(rc *rtChild, evt *agentIO.InputEvent, seq uint64) {
|
||||
return
|
||||
}
|
||||
|
||||
// ★ 安全轮次边界(rtchild 路径)。
|
||||
//
|
||||
// rtchild 不走 `runInputTask`(它没有调度器、不吃队列,见 plan.md 十一.三 D),
|
||||
// 所以这是一条**独立**的输入路径。收敛前 agent 侧的档位激活只写在
|
||||
// `prepareInputTask` 里,rtchild 根本不会经过那里 —— 若只接一条,
|
||||
// rtchild 就永远没有档位。
|
||||
//
|
||||
// 这是“每多一条输入路径就要多接一次”的典型代价。收敛后,
|
||||
// 判定逻辑在 security 内部只剩一份,但**轮次边界仍得每条路径自己声明** ——
|
||||
// 因为只有该路径知道一轮何时开始/结束。
|
||||
secRound := a.beginSecurityRound(evt.Payload, evt.Source)
|
||||
defer secRound.End()
|
||||
|
||||
rc.mu.Lock()
|
||||
rc.round++
|
||||
round := rc.round
|
||||
@ -1238,6 +1335,9 @@ func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
}
|
||||
|
||||
// ① 先取全文规模(此时实例还活着,history 完整)。
|
||||
//
|
||||
// ★ 必须在 Stop 之前取:Stop 会把实例从登记表摘掉,之后
|
||||
// RTTranscript 就只能走留档分支了。
|
||||
transcript, _ := a.RTTranscript(id)
|
||||
text := FormatRTTranscript(transcript)
|
||||
n, chars := len(transcript), len(text)
|
||||
@ -1252,8 +1352,11 @@ func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
|
||||
// ③ 推 L4 信号(contextfull 同款),正文留在 RTTranscript 里等按需拉取。
|
||||
//
|
||||
// ★ 实例已出登记表,但原文必须仍可取 —— 所以把 transcript 存进
|
||||
// a.rtTranscripts 留档(见 RTTranscript 的读取顺序)。
|
||||
// ★ 实例已出登记表,但原文必须仍可取 —— 所以留档(见 RTTranscript 的读取顺序)。
|
||||
//
|
||||
// ★★ 同名键在这里**自然覆写**(2026-10-30):留档以 id 为键,同名回收
|
||||
// 会替换掉上一次的留档。串线问题不在写侧而在**读侧**(见 RTTranscript
|
||||
// 的"在跑实例优先"),所以这里不需要额外清理动作。
|
||||
a.rtMu.Lock()
|
||||
if a.rtClosedTranscripts == nil {
|
||||
a.rtClosedTranscripts = map[string][]agentAPI.Message{}
|
||||
@ -1268,29 +1371,37 @@ func (a *Agent) CloseRTChild(id string) (int, error) {
|
||||
|
||||
// RTTranscript 返回一个 rtchild 的**完整**上下文原文(不过窗口裁剪)。
|
||||
//
|
||||
// 读顺序:先查**已回收留档**(CloseRTChild 保存的),再查在用实例的 history。
|
||||
// 后者保证"通话进行中也能看",前者保证"回收之后仍能取" —— 两者缺一,
|
||||
// 「回收时向主 agent 展示全部上下文」就会在某些时序下拿不到原文。
|
||||
// ## 读顺序(2026-10-30 修:留档不得遮蔽**在跑的新一代**)
|
||||
//
|
||||
// 原实现是「先查留档,有就返回」,于是同名重建后**新实例的对话永远读不到**:
|
||||
// 留档一直命中,`rt_transcript` 返回的是上一代已回收的原文(实测复现:
|
||||
// 新实例读出的第一条是上一通的对话)。现在按**在跑实例优先**:
|
||||
//
|
||||
// 在跑实例存在 ⇒ 返回它的 history(通话进行中也能看)
|
||||
// 否则查留档 ⇒ 返回该代留档(回收之后仍能取)
|
||||
//
|
||||
// 两条都保留,缺一条就会在某个时序下拿不到原文。二者的先后由
|
||||
// "实例还在不在" 决定,而不是由"留档里有没有" 决定。
|
||||
func (a *Agent) RTTranscript(id string) ([]agentAPI.Message, bool) {
|
||||
a.rtMu.Lock()
|
||||
archived, hasArchived := a.rtClosedTranscripts[id]
|
||||
rc := a.rtChildren[id]
|
||||
archived := a.rtClosedTranscripts[id]
|
||||
a.rtMu.Unlock()
|
||||
|
||||
if hasArchived {
|
||||
if rc != nil {
|
||||
rc.mu.Lock()
|
||||
// 深拷贝:调用方可能长期持有,不能被后续轮次 append 影响。
|
||||
out := make([]agentAPI.Message, len(rc.history))
|
||||
copy(out, rc.history)
|
||||
rc.mu.Unlock()
|
||||
return out, true
|
||||
}
|
||||
if archived != nil {
|
||||
out := make([]agentAPI.Message, len(archived))
|
||||
copy(out, archived)
|
||||
return out, true
|
||||
}
|
||||
if rc == nil {
|
||||
return nil, false
|
||||
}
|
||||
rc.mu.Lock()
|
||||
defer rc.mu.Unlock()
|
||||
// 深拷贝:调用方可能长期持有,不能被后续轮次 append 影响。
|
||||
out := make([]agentAPI.Message, len(rc.history))
|
||||
copy(out, rc.history)
|
||||
return out, true
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ── rt_transcript:主 agent 按需拉取 rtchild 的完整上下文 ──
|
||||
@ -1435,6 +1546,9 @@ func (a *Agent) executeRTChildren(tc agentAPI.ToolCall) string {
|
||||
Output: output,
|
||||
SystemPrompt: strArg(tc, "system_prompt"),
|
||||
MaxTools: rtIntArg(tc, "max_tools", 0),
|
||||
// ★ 父为子声明工具策略(用户裁定 2026-10-30)。
|
||||
AllowedTools: splitArg(strArg(tc, "allowed_tools")),
|
||||
DenyTools: splitArg(strArg(tc, "deny_tools")),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("创建 rtchild 失败: %v\n%s", err, a.listRTChannels())
|
||||
|
||||
224
internal/agent/core/rtchild_reuse_test.go
Normal file
224
internal/agent/core/rtchild_reuse_test.go
Normal file
@ -0,0 +1,224 @@
|
||||
package core
|
||||
|
||||
// rtchild 的**同名复用**与**批量停止**的回收纪律(2026-10-30 修)。
|
||||
//
|
||||
// ## 这一组判据锁的是什么
|
||||
//
|
||||
// rtchild 的留档表与通道登记表都以 **id 字符串**为键,而 id 由模型给。
|
||||
// 「拨完一通、再拨同名的一通」是完全正常的使用方式,但它会让上一代
|
||||
// 留下的东西被当成这一代的 —— 而且**完全不报错**,只是内容错了。
|
||||
//
|
||||
// 三条缺陷、三条判据、每条都能变红(变异方式写在各自注释里):
|
||||
//
|
||||
// ① 留档跨代串线 —— rt_transcript 读到上一通的对话
|
||||
// ② 批量停止漏注销 —— child/rt-<id> 登记单调累积(只能重启 homed 清)
|
||||
// ③ 留档遮蔽在跑实例 —— 通话进行中 rt_transcript 读不到自己的历史
|
||||
//
|
||||
// ③ 与 ① 是同一个读路径的两端:修复前 RTTranscript 先查留档,只要留档
|
||||
// 命中就返回。于是不仅"重建后读到老的",连"老的在跑、留档还在"时也会
|
||||
// 读到旧的那份。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// TestRTChild_ReuseAfterCloseKeepsTranscriptsApart 钉住①:
|
||||
// 同名 rtchild 回收后重建,新实例的 rt_transcript **不得**返回上一代原文。
|
||||
//
|
||||
// 变异方式:把 RTTranscript 改回"先查留档、命中即返回"(archive-first)
|
||||
// ⇒ 本判据必红(新实例读出的第一条是"上一通的原话")。已实测。
|
||||
func TestRTChild_ReuseAfterCloseKeepsTranscriptsApart(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
const id = "reuse"
|
||||
first := rtSpawn(t, host, id)
|
||||
host.PushRTInput(first, "上一通的原话")
|
||||
waitFor(t, "上一通第一轮完成", func() bool { return p.rounds() >= 1 })
|
||||
|
||||
if n, err := host.CloseRTChild(id); err != nil {
|
||||
t.Fatalf("回收上一通失败:%v", err)
|
||||
} else if n == 0 {
|
||||
t.Fatal("上一通没有交接任何消息 —— 判据前提不成立")
|
||||
}
|
||||
// 回收后留档必须仍在(这是「回收后原文仍可取」的契约)。
|
||||
if got, ok := host.RTTranscript(id); !ok || len(got) == 0 {
|
||||
t.Fatalf("回收后取不到原文,留档机制本身坏了(ok=%v len=%d)", ok, len(got))
|
||||
}
|
||||
|
||||
// 同一个 id 再开一通 —— 这是真实用法(模型常常复用 id)。
|
||||
second := rtSpawn(t, host, id)
|
||||
|
||||
got, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("重建后的实例取不到 transcript")
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Content == "上一通的原话" {
|
||||
t.Fatalf("★ 同名重建后 rt_transcript 读到了**上一通**的原话 —— 两通电话串线")
|
||||
}
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("新一代刚创建、还没说话,transcript 应为空;实际 %d 条:%+v",
|
||||
len(got), truncateStr(FormatRTTranscript(got), 200))
|
||||
}
|
||||
|
||||
// 新一代说自己的话,必须只看到自己的。
|
||||
p.mu.Lock()
|
||||
base := len(p.reqs)
|
||||
p.mu.Unlock()
|
||||
host.PushRTInput(second, "这一通的原话")
|
||||
waitFor(t, "这一通第一轮完成", func() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.reqs) > base
|
||||
})
|
||||
|
||||
got, _ = host.RTTranscript(id)
|
||||
if len(got) == 0 || got[0].Content != "这一通的原话" {
|
||||
t.Fatalf("新一代的历史第一条应是「这一通的原话」,实际:%s",
|
||||
truncateStr(FormatRTTranscript(got), 200))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRTTranscript_PrefersLiveInstanceOverArchive 钉住③:
|
||||
// 通话**进行中**(实例在跑)时,rt_transcript 必须返回在跑实例的历史,
|
||||
// 而不是该 id 上一代的留档。
|
||||
//
|
||||
// ## 为什么要先 Spawn 再注入留档(两版假绿的教训)
|
||||
//
|
||||
// 这个判据前两版都是**假绿**的,教训值得钉在注释里:
|
||||
//
|
||||
// 版本 A:先手塞留档再 Spawn → 活实例建立时留档已存在,但当时判据
|
||||
// 断言过弱(只查"没有旧内容"),变异下仍绿。
|
||||
// 版本 B:真回收再重建,但新实例不 push(history 空)
|
||||
// ⇒ "读到旧留档"与"读到空"表面都是 len==0 ⇒ 仍然假绿。
|
||||
//
|
||||
// 现在:先 Spawn 建活实例 → **再**注入同 id 的陈旧留档 → 再 push 一句。
|
||||
// 这样两种实现才分得清楚(活实例优先 / 留档优先)。
|
||||
func TestRTTranscript_PrefersLiveInstanceOverArchive(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
|
||||
// ① 先建活实例。
|
||||
const id = "live"
|
||||
rtSpawn(t, host, id)
|
||||
|
||||
// ② 再注入同 id 的陈旧留档 —— "活实例与留档共存"的那一瞬。
|
||||
host.rtMu.Lock()
|
||||
if host.rtClosedTranscripts == nil { // 懒初始化:没人回收过就该是 nil
|
||||
host.rtClosedTranscripts = map[string][]agentAPI.Message{}
|
||||
}
|
||||
host.rtClosedTranscripts[id] = []agentAPI.Message{
|
||||
{Role: "user", Content: "上一通的陈旧原话"},
|
||||
}
|
||||
host.rtMu.Unlock()
|
||||
|
||||
// ③ 让活实例真的说一句(否则 history 为空,判据会假绿)。
|
||||
host.PushRTInput(id, "正在说的这句")
|
||||
waitFor(t, "活实例第一轮完成", func() bool { return p.rounds() >= 1 })
|
||||
|
||||
got, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("在跑实例取不到 transcript")
|
||||
}
|
||||
for _, m := range got {
|
||||
if m.Content == "上一通的陈旧原话" {
|
||||
t.Fatalf("★ 在跑实例的 transcript 返回了留档里的陈旧原文 —— 留档遮蔽了活实例")
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, m := range got {
|
||||
if m.Content == "正在说的这句" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("在跑实例的 transcript 里没有它自己的输入:%s",
|
||||
truncateStr(FormatRTTranscript(got), 200))
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopRTChildren_UnregistersInbound 钉住②:
|
||||
// 批量停止(父退出路径)必须与单实例停止**行为对称**,注销 child/rt-<id>。
|
||||
//
|
||||
// 为什么必须一致:登记表是共享的、按 name 全局唯一,残留会随
|
||||
// create/destroy 次数单调累积,而外部没有任何工具能单独注销一条 inputch
|
||||
// —— 只能重启 homed 清(驻留子那边已有同形态的历史缺陷记录)。
|
||||
//
|
||||
// 变异方式:去掉 StopRTChildren 循环里的 Unregister ⇒ 本判据红。
|
||||
func TestStopRTChildren_UnregistersInbound(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
reg := host.io.ChannelRegistry()
|
||||
|
||||
const id = "bulk"
|
||||
rtSpawn(t, host, id)
|
||||
|
||||
inbound := inboundChannelName("rt/" + id)
|
||||
if _, ok := reg.Lookup(inbound); !ok {
|
||||
t.Fatalf("创建后没有登记入站通道 %q —— 判据前提不成立", inbound)
|
||||
}
|
||||
|
||||
if n := host.StopRTChildren(); n != 1 {
|
||||
t.Fatalf("StopRTChildren 返回 %d,期望 1", n)
|
||||
}
|
||||
if _, ok := reg.Lookup(inbound); ok {
|
||||
t.Fatalf("★ 批量停止后入站登记 %q 仍留在共享登记表里 —— "+
|
||||
"会随 create/destroy 次数单调累积,且外部无法单独注销", inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopRTChild_SingleAlsoUnregisters 是②的对照组:
|
||||
// 单实例停止**一直**是对的,这条留着防止"修复数时把单数改坏"。
|
||||
func TestStopRTChild_SingleAlsoUnregisters(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
reg := host.io.ChannelRegistry()
|
||||
|
||||
const id = "single"
|
||||
rtSpawn(t, host, id)
|
||||
inbound := inboundChannelName("rt/" + id)
|
||||
if _, ok := reg.Lookup(inbound); !ok {
|
||||
t.Fatalf("创建后没有登记入站通道 %q", inbound)
|
||||
}
|
||||
if err := host.StopRTChild(id); err != nil {
|
||||
t.Fatalf("停止失败:%v", err)
|
||||
}
|
||||
if _, ok := reg.Lookup(inbound); ok {
|
||||
t.Fatalf("单实例停止后入站登记 %q 仍残留", inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStopRTChildren_RtChannelsStayDangling 守住用户裁定:
|
||||
// 批量停止**不得**动插件声明的 rt 通道(那两条销毁后悬空)。
|
||||
//
|
||||
// 这条是②的护栏:注销入站登记很容易顺手写成"把 rt 通道也清掉",
|
||||
// 而那样会让主 agent(不持有 rt 能力)通过 Owner=="" 的兜底分支
|
||||
// 静默接管一条 rt 通道 —— 正是裁定明确禁止的。
|
||||
func TestStopRTChildren_RtChannelsStayDangling(t *testing.T) {
|
||||
p := &rtLongHistoryProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
reg := host.io.ChannelRegistry()
|
||||
|
||||
const id = "dangling"
|
||||
rtSpawn(t, host, id)
|
||||
host.StopRTChildren()
|
||||
|
||||
for _, name := range []string{"voice-in", "voice"} {
|
||||
ch, ok := reg.Lookup(name)
|
||||
if !ok {
|
||||
t.Fatalf("批量停止把插件声明的 rt 通道 %q 也删了 —— 通道应保持悬空而非消失", name)
|
||||
}
|
||||
if ch.Owner != id {
|
||||
t.Fatalf("rt 通道 %q 的归属被改成了 %q(期望仍悬空指向已销毁的 %q)",
|
||||
name, ch.Owner, id)
|
||||
}
|
||||
}
|
||||
// 悬空是可诊断的:路由查不到该 owner 时应留日志并兜底给父。
|
||||
// 这里只断言"不会凭空变成可复用通道"。
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
@ -459,9 +459,10 @@ func TestRTChildrenTool_NotVisibleToChild(t *testing.T) {
|
||||
//
|
||||
// 只断言"返回里有错"不够 —— 那可能只是碰巧提到"创建失败"。
|
||||
// 所以同时断言:
|
||||
// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么
|
||||
// ② **没有**创建出 rtchild(不能半成功后失败)
|
||||
// ③ 通道归属没被改动(不能"创建失败但通道已经被占了")
|
||||
//
|
||||
// ① 返回里出现正确的参数名(inputch / outputch),让人知道该写什么
|
||||
// ② **没有**创建出 rtchild(不能半成功后失败)
|
||||
// ③ 通道归属没被改动(不能"创建失败但通道已经被占了")
|
||||
func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) {
|
||||
p := &rtCountingProvider{}
|
||||
host, _ := newRTHost(t, p)
|
||||
@ -471,10 +472,10 @@ func TestRTChildrenTool_WrongParamNameIsRejectedNotSilent(t *testing.T) {
|
||||
out := host.executeRTChildren(agentAPI.ToolCall{
|
||||
Name: "rt_children",
|
||||
Arguments: map[string]interface{}{
|
||||
"action": "create",
|
||||
"id": "typo-child",
|
||||
"input": "voice-in", // ← 错的参数名
|
||||
"output": "voice", // ← 错的参数名
|
||||
"action": "create",
|
||||
"id": "typo-child",
|
||||
"input": "voice-in", // ← 错的参数名
|
||||
"output": "voice", // ← 错的参数名
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@ -1,23 +1,39 @@
|
||||
package core
|
||||
|
||||
// 插件声明限式的**生效点**:inputch 输入进来时激活,轮次内据此判定。
|
||||
// agent 侧与安全模块的**接线点**。
|
||||
//
|
||||
// ## 为什么在这里(而不是在工具调用时)
|
||||
// ## 边界(2026-10-31 收敛后的形态)
|
||||
//
|
||||
// 插件在注入时带上 `security_profile`,那一刻是**唯一**能确定
|
||||
// "这条输入属于哪个身份档位"的时刻。等 LLM 决定调工具时,插件早已
|
||||
// 不在调用栈上(异步通道 + 多轮工具循环),档位信息就断了。
|
||||
// security 是**外围内核到核心内核之间的防火墙**:它自己提供接口、自己做判定。
|
||||
// agent 只负责两件事:
|
||||
//
|
||||
// 所以:输入到达 → 激活档位(带 TTL)→ 该轮的工具/内容判定都读它。
|
||||
// ① 提供**事实**:谁在跑、有没有父、原始输入载荷
|
||||
// ② 在输入开始/结束时各调一次 security 的轮次入口
|
||||
//
|
||||
// ## 缺省语义
|
||||
// ★ 本文件**不得**再出现任何策略语义:
|
||||
//
|
||||
// 没有携带 `security_profile` = **不使用档位**(沿用内核默认策略)。
|
||||
// 这是可选参数:不声明档位的插件行为与之前完全一致。
|
||||
// ✗ 不该解析 `security_profile`(那是 security 的协议)
|
||||
// ✗ 不该判断“我该走 CheckTool 还是 CheckToolFor”(那是策略决策)
|
||||
// ✗ 不该声明自己是哪一类主体(Kind 由 security 自己推)
|
||||
// ✗ 不该拼 `PrincipalPolicy{...}` 字面量(那是策略结构)
|
||||
//
|
||||
// 携带了**未声明**的档位名 ⇒ 记日志并回退到默认策略,但**不改写输入**:
|
||||
// 注入是 fire-and-forget,插件看不到内核错误;至少要在日志里留下
|
||||
// 「你声明的档位没生效」的痕迹(与 cleaner_name 同一纪律)。
|
||||
// 这些全部已收回到 `internal/security`:
|
||||
//
|
||||
// security.RoundInput —— 调用方能提供的全部事实(无 Kind)
|
||||
// security.BeginRound —— security 解析档位 + 判定主体类别
|
||||
// security.Round —— 本轮句柄,只暴露 CheckTool / End
|
||||
// security.DeclareChildPolicy / RevokeChildPolicy —— 子策略的生命周期
|
||||
//
|
||||
// ## 为什么这样收(教训)
|
||||
//
|
||||
// 收敛前本文件有 214 行,其中绝大部分是策略逻辑。它带来三个后果:
|
||||
//
|
||||
// · 三条输入路径(主 agent / 驻留子 / rtchild)各自得接一遍,接漏一条就静默失效
|
||||
// · 判据只能钉住“接线之后行为对”,钉不住“有没有接线”
|
||||
// · `Kind: "resident"` 让调用方**声明自己的类别** ⇒ 提权口子
|
||||
//
|
||||
// 对应的成功范例就在树里:`events.Bus.SetGuard` + `security.AsEventGuard` ——
|
||||
// 总线只交事件,guard 自己判,总线一行策略都没有。
|
||||
|
||||
import (
|
||||
"log"
|
||||
@ -25,49 +41,126 @@ import (
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
)
|
||||
|
||||
// activateSecurityProfile 按输入 payload 激活插件声明的安全档位。
|
||||
// beginSecurityRound 在一条输入开始处理时调用:
|
||||
// 把**原始事实**交给 security,拿回本轮句柄。
|
||||
//
|
||||
// 返回被激活的档位名(空 = 未使用档位),仅供日志/诊断。
|
||||
func (a *Agent) activateSecurityProfile(payload map[string]interface{}) string {
|
||||
if a == nil || a.security == nil || payload == nil {
|
||||
return ""
|
||||
// 调用方必须在轮次结束时 `End()`(defer)。未装配 security 时返回一个
|
||||
// 零成本句柄(所有判定放行),与收敛前行为一致。
|
||||
//
|
||||
// ★ 为什么由 agent 调而不是 io 层调:轮次的生命周期由 agent 的
|
||||
// `prepareInputTask` 界定(那里才知道一条输入何时真正开始/结束)。
|
||||
// security 不依赖 agent,所以只能由 agent 主动把边界告诉它。
|
||||
func (a *Agent) beginSecurityRound(payload map[string]interface{}, source string) *security.Round {
|
||||
if a == nil || a.security == nil {
|
||||
return nil
|
||||
}
|
||||
name, _ := payload["security_profile"].(string)
|
||||
if name == "" {
|
||||
return ""
|
||||
rc, err := a.security.BeginRound(security.RoundInput{
|
||||
AgentID: string(a.id),
|
||||
// IsChild 是**事实**(有父就是子),不是类别结论。
|
||||
IsChild: a.parentID != "",
|
||||
Payload: payload,
|
||||
Source: source,
|
||||
})
|
||||
if err != nil {
|
||||
// security 内部已记录原因并把档位归位。这里不阻断输入:
|
||||
// 注入是 fire-and-forget,插件看不到内核错误,丢输入比降级更糟。
|
||||
// 但**必须留痕**,否则“档位一直没生效”会完全静默。
|
||||
a.securityRoundDegraded(err)
|
||||
}
|
||||
if err := a.security.ActivateProfile(name, 0); err != nil {
|
||||
// 响亮但不断流:拼错档位名不该让整条输入丢失,
|
||||
// 但也绝不能静默 —— 那等于悄悄降级到默认策略。
|
||||
log.Printf("[agent] input declared security_profile=%q but activation failed: %v", name, err)
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
return rc
|
||||
}
|
||||
|
||||
// securityDecisionFor 用当前活跃档位 + 主体判定某工具是否可用。
|
||||
// securityRoundDegraded 记录“本轮策略降级”这一事实。
|
||||
//
|
||||
// 工具执行前的统一检查点:调用方拿到 !Allowed 时应把 Reason 原样回给模型
|
||||
// (统一拒绝语义 —— 模型据此能修正,而不是反复重试同一调用)。
|
||||
// 独立成方法是为了让降级有一个**可被检索的日志锚点**:
|
||||
// 收敛前这条链断了整整一轮发布,而没有任何一行日志提示。
|
||||
func (a *Agent) securityRoundDegraded(err error) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[security] round policy degraded for agent %q: %v "+
|
||||
"(continuing with the default global policy)", a.id, err)
|
||||
}
|
||||
|
||||
// securityDecisionFor 判定某工具是否可用(**兼容入口**)。
|
||||
//
|
||||
// ★ 2026-10-29:本函数此前是**死代码**(`a.security` 从未被赋值 ⇒ 永远
|
||||
// 走 nil 分支 ⇒ 永远放行),`Policy.CheckTool` 因此对工具执行链完全不生效。
|
||||
// 修复分两步:① 补 `SetSecurity` 注入通道;② 在 `executeToolCallInner`
|
||||
// 的**所有实际执行分支之前**调用本函数。
|
||||
// ## 它现在只做转发,不含任何策略
|
||||
//
|
||||
// 收敛前这里写着两条分支(父走 CheckTool、子走 CheckToolFor)并硬编码
|
||||
// `Kind: "resident"`。现在那两条判断都在 security 内部
|
||||
// (`classifyPrincipal`),本函数退化成一次裸转发。
|
||||
//
|
||||
// ★ 保留它的理由:工具执行链(`executeToolCallInner`)需要一次判定,
|
||||
// 而那里只持有 `*Agent`、不持有轮次句柄。走 security 的**默认轮次**
|
||||
// (按 `AgentID` + 有无父自动分类)等价于原来“父/子分流”的行为,
|
||||
// 但语义只写在 security 一处。
|
||||
func (a *Agent) securityDecisionFor(tool string) security.Decision {
|
||||
if a == nil || a.security == nil {
|
||||
return security.Decision{Allowed: true}
|
||||
}
|
||||
return a.security.CheckTool(tool)
|
||||
// ★ 走本 agent 的**执行视图**(session 档 > longtime 档 > 全局基线)。
|
||||
//
|
||||
// 这是 2026-10-31「每个 agent 一个实例」的落地:视图由 security 按
|
||||
// agentID 持有,本 agent 的 sessionProfile 不会污染别的 agent。
|
||||
return a.securityScope().CheckTool(tool)
|
||||
}
|
||||
|
||||
// SetSecurity 注入安全运行时(装配期调用,幂等)。
|
||||
// securityScope 返回(并缓存)本 agent 的策略执行视图。
|
||||
//
|
||||
// ## 为什么需要显式注入点
|
||||
// 为什么要缓存:`ForAgent` 是幂等的,但每条输入/每次工具调用都取一次会
|
||||
// 反复加锁;而视图本身在整个 agent 生命周期内不变。
|
||||
func (a *Agent) securityScope() *security.AgentScope {
|
||||
if a == nil || a.security == nil {
|
||||
return nil
|
||||
}
|
||||
a.securityScopeOnce.Do(func() {
|
||||
a.securityScopeView = a.security.ForAgent(string(a.id), a.parentID != "")
|
||||
})
|
||||
return a.securityScopeView
|
||||
}
|
||||
|
||||
// ReleaseSecurityScope 在 agent 销毁时释放它的策略视图。
|
||||
//
|
||||
// `Agent.security` 曾经是**只有测试赋过值**的字段:生产装配链里没有任何
|
||||
// 地方写它,于是 `securityDecisionFor` 恒定放行。这不是"策略写错了",
|
||||
// 而是"策略根本没接上",而两种故障在日志上看起来一样(都是没有拒绝记录)。
|
||||
// ★ 必须调:视图里存着 **longtimeProfile**(跨轮保留的档位)。
|
||||
// agent/子 agent 回收后同名重建是常规操作,残留的 long-time 档会让
|
||||
// 新一代继承上一代父留的约束 —— 与 `RevokePrincipalPolicy` 同一纪律。
|
||||
func (a *Agent) ReleaseSecurityScope() {
|
||||
if a == nil || a.security == nil {
|
||||
return
|
||||
}
|
||||
a.security.ReleaseAgent(string(a.id))
|
||||
}
|
||||
|
||||
// declareChildPolicy 让父为刚创建的子执行体声明主体策略。
|
||||
//
|
||||
// 这里只做转发:策略结构的组装与作用域落点由 security 负责
|
||||
// (`security.DeclareChildPolicy`)。
|
||||
func (a *Agent) declareChildPolicy(childID string, allow, deny []string) error {
|
||||
if a == nil || a.security == nil {
|
||||
return nil
|
||||
}
|
||||
return a.security.DeclareChildPolicy(childID, allow, deny)
|
||||
}
|
||||
|
||||
// revokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
|
||||
func (a *Agent) revokeChildPolicy(childID string) {
|
||||
if a == nil || a.security == nil {
|
||||
return
|
||||
}
|
||||
a.security.RevokeChildPolicy(childID)
|
||||
}
|
||||
|
||||
// SetSecurity 注入安全运行时(**仅限测试与独立工具**)。
|
||||
//
|
||||
// ## 不要在生产装配里调它
|
||||
//
|
||||
// 生产路径是 `AgentConfig.Security`(构造期落位),因为那一条由依赖图
|
||||
// 保证 `ks.Security` 已就绪(`kernelmod/agent` 的 `Requires()` 声明了
|
||||
// `FacSecurity`)。用 SetSecurity 事后补会造出一个图看不见的隐式依赖 ——
|
||||
// 正是本仓反复踩的那个坑。
|
||||
//
|
||||
// 保留它是因为进程内判据需要一个「只换安全运行时」的开关,
|
||||
// 而它们不跑整机装配。
|
||||
//
|
||||
// ## 为什么允许 nil
|
||||
//
|
||||
@ -78,3 +171,51 @@ func (a *Agent) SetSecurity(rt *security.Runtime) {
|
||||
}
|
||||
a.security = rt
|
||||
}
|
||||
|
||||
// ───────────────────── 判据专用探针(非生产接口) ─────────────────────
|
||||
//
|
||||
// 下面几个函数只为 `internal/kernelmod/all/security_wiring_test.go` 服务。
|
||||
//
|
||||
// ## 为什么不写在 _test.go 里
|
||||
//
|
||||
// 判据住在 `kernelmod/all` 包(那里才有真实登记表与整机装配),
|
||||
// 而 Go 的 `_test.go` 符号不跨包导出 ⇒ 跨包判据必须有一个导出面。
|
||||
//
|
||||
// ## 为什么叫 Probe*
|
||||
//
|
||||
// 这是**测试专用**接口。生产代码不应调用它们 —— 需要判定时用
|
||||
// `securityDecisionFor`(工具执行链上的那条)。带 Probe 前缀是为了让
|
||||
// "有人在生产里调了它"在 code review 时一眼可见。
|
||||
|
||||
// ProbeSecurityRuntime 返回本 agent 持有的安全运行时(判据用)。
|
||||
func (a *Agent) ProbeSecurityRuntime() *security.Runtime {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return a.security
|
||||
}
|
||||
|
||||
// ProbeSecurityDecision 判定工具可用性(判据用)。
|
||||
//
|
||||
// 它走的就是生产路径(securityDecisionFor),不是另写一份逻辑。
|
||||
func (a *Agent) ProbeSecurityDecision(tool string) security.Decision {
|
||||
return a.securityDecisionFor(tool)
|
||||
}
|
||||
|
||||
// ProbeSecurityRound 按真实输入路径开一轮,返回句柄(判据用)。
|
||||
//
|
||||
// 判据用它验证「档位由 security 自己从 payload 解析」这条链真的通了,
|
||||
// 而不需要直接调 security 的内部方法。
|
||||
func (a *Agent) ProbeSecurityRound(payload map[string]interface{}, source string) *security.Round {
|
||||
return a.beginSecurityRound(payload, source)
|
||||
}
|
||||
|
||||
// ProbeDeclareChildPolicy 是 declareChildPolicy 的导出包装(判据用)。
|
||||
func (a *Agent) ProbeDeclareChildPolicy(childID string, allow, deny []string) error {
|
||||
return a.declareChildPolicy(childID, allow, deny)
|
||||
}
|
||||
|
||||
// ProbeRevokeChildPolicy 是 revokeChildPolicy 的导出包装(判据用)。
|
||||
func (a *Agent) ProbeRevokeChildPolicy(childID string) {
|
||||
a.revokeChildPolicy(childID)
|
||||
}
|
||||
|
||||
@ -8,6 +8,16 @@ package core
|
||||
// > 选择其中一种 —— 正好对应 QQ 插件内部 owner 与非 owner 的差别对待。
|
||||
//
|
||||
// > 这是可选参数,不声明安全策略则使用内核默认安全策略。
|
||||
//
|
||||
// ## 2026-10-31 重写(收敛后的形态)
|
||||
//
|
||||
// 本文件原先调用 `a.activateSecurityProfile(payload)` —— 一个**在 agent 里
|
||||
// 解析 security 协议**的函数。收敛后该函数已删除:档位解析归 security
|
||||
// (`security.BeginRound` 自己读 `payload["security_profile"]`)。
|
||||
//
|
||||
// 所以这里改用 `a.beginSecurityRound(payload, source)`,它走真实的生产入口,
|
||||
// **不由判据手工解析字段**。这样判据钉的是「security 自己会解析吗」,
|
||||
// 而不是「agent 有没有帮它解析」。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@ -18,7 +28,12 @@ import (
|
||||
// ① 没有携带 security_profile ⇒ 不激活任何档位(零行为变化)。
|
||||
func TestNoSecurityProfileKeepsDefault(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
if got := a.activateSecurityProfile(map[string]interface{}{"content": "hi"}); got != "" {
|
||||
a.security = security.NewRuntime(nil)
|
||||
|
||||
round := a.beginSecurityRound(map[string]interface{}{"content": "hi"}, "probe")
|
||||
defer round.End()
|
||||
|
||||
if got := round.Profile(); got != "" {
|
||||
t.Fatalf("未携带档位时不应激活,实际 %q", got)
|
||||
}
|
||||
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
|
||||
@ -29,7 +44,6 @@ func TestNoSecurityProfileKeepsDefault(t *testing.T) {
|
||||
// ② 声明档位 + 输入携带档位名 ⇒ 该轮工具判定按档位生效(owner/非 owner 差别)。
|
||||
func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
// 用一个独立运行时(不依赖全局单例),模拟"security 模块已装配"。
|
||||
rt := security.NewRuntime(nil)
|
||||
if err := rt.DeclareProfile(security.Profile{
|
||||
Name: "qq.guest", Owner: "qq",
|
||||
@ -42,24 +56,55 @@ func TestSecurityProfileFromInputChangesToolDecision(t *testing.T) {
|
||||
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("激活前应放行: %s", d.Reason)
|
||||
}
|
||||
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.guest"}); got != "qq.guest" {
|
||||
t.Fatalf("应激活 qq.guest,实际 %q", got)
|
||||
|
||||
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
|
||||
defer round.End()
|
||||
|
||||
if got := round.Profile(); got != "qq.guest" {
|
||||
t.Fatalf("★ security 应从 payload 自己解析出档位并激活 qq.guest,实际 %q —— "+
|
||||
"若这里是空,说明 BeginRound 没读 security_profile(解析又落回调用方了)", got)
|
||||
}
|
||||
if d := a.securityDecisionFor("cmd_run"); d.Allowed {
|
||||
// 判定走本轮句柄(生产上工具执行链用的是同一条判定语义)。
|
||||
if d := round.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("guest 档激活后 cmd_* 必须被拒 —— 这是 owner 与非 owner 的差别")
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且不 panic。
|
||||
// ③ 携带**未声明**的档位名 ⇒ 不激活(回退默认),且**不沿用上一轮的档**。
|
||||
//
|
||||
// 判据只钉"不激活"这一半:日志侧由 security 模块保证响亮(返回 error)。
|
||||
// 不在这里断言日志文案 —— 那会让判据绑死文案。
|
||||
// 两种失败都必须咬住:
|
||||
//
|
||||
// · 静默沿用上一轮的 guest 档 ⇒ 拼错名却仍收窄(用户被无辜禁工具)
|
||||
// · 静默当作"无档"却继续用旧档 ⇒ 同上
|
||||
func TestUnknownSecurityProfileFallsBackWithoutPanic(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
a.security = security.NewRuntime(nil)
|
||||
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.typo"}); got != "" {
|
||||
rt := security.NewRuntime(nil)
|
||||
if err := rt.DeclareProfile(security.Profile{
|
||||
Name: "qq.guest", Owner: "qq",
|
||||
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.security = rt
|
||||
|
||||
// 先开一个合法档位并**不结束**,模拟"进程里还挂着 guest 档"。
|
||||
prev := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
|
||||
if prev.Profile() != "qq.guest" {
|
||||
t.Fatalf("前置失败:合法档位未激活,实际 %q", prev.Profile())
|
||||
}
|
||||
|
||||
// 再来一条拼错的。
|
||||
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.typo"}, "probe")
|
||||
defer round.End()
|
||||
|
||||
if got := round.Profile(); got != "" {
|
||||
t.Fatalf("未声明的档位不应被激活,实际 %q", got)
|
||||
}
|
||||
if got := rt.ActiveProfile(); got != "" {
|
||||
t.Fatalf("★ 拼错的档位名必须在降级时**清掉**上一轮的档,实际仍是 %q —— "+
|
||||
"否则“配件名写错”会静默沿用旧档,用户被无辜收窄", got)
|
||||
}
|
||||
prev.End()
|
||||
}
|
||||
|
||||
// ④ 未装配安全模块(a.security == nil)⇒ 全部放行,且不 panic。
|
||||
@ -68,7 +113,39 @@ func TestSecurityDecisionWithoutRuntime(t *testing.T) {
|
||||
if d := a.securityDecisionFor("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("未装配安全模块时应放行: %s", d.Reason)
|
||||
}
|
||||
if got := a.activateSecurityProfile(map[string]interface{}{"security_profile": "qq.owner"}); got != "" {
|
||||
|
||||
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.owner"}, "probe")
|
||||
defer round.End()
|
||||
if got := round.Profile(); got != "" {
|
||||
t.Fatalf("未装配时不应激活,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ ★ 档位必须**随轮次结束归位**,不泄漏到下一条输入。
|
||||
//
|
||||
// 这是收敛的附带收益:`End()` 恢复进入前的档位。没有它,
|
||||
// 进程级 active 会让 guest 的收窄误伤后续输入(方向相反就是提权)。
|
||||
func TestProfileRestoredAfterRoundEnd(t *testing.T) {
|
||||
a := newPreemptAgent(t, newPreemptProvider())
|
||||
rt := security.NewRuntime(nil)
|
||||
if err := rt.DeclareProfile(security.Profile{
|
||||
Name: "qq.guest", Owner: "qq",
|
||||
Policy: security.PrincipalPolicy{DenyTools: []string{"cmd_*"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.security = rt
|
||||
|
||||
func() {
|
||||
round := a.beginSecurityRound(map[string]interface{}{"security_profile": "qq.guest"}, "probe")
|
||||
defer round.End()
|
||||
if round.Profile() != "qq.guest" {
|
||||
t.Fatalf("前置:应激活 guest,实际 %q", round.Profile())
|
||||
}
|
||||
}()
|
||||
|
||||
if got := rt.ActiveProfile(); got != "" {
|
||||
t.Fatalf("★ 轮次结束后档位应归位为空,实际 %q —— "+
|
||||
"进程级 active 泄漏到下一条输入,guest 的收窄会误伤后续输入", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,6 +326,18 @@ func (a *Agent) runInputTask(evt *agentIO.InputEvent) (*TaskFrame, stepOutcome)
|
||||
// interceptLoop 读它来决定“能不能取消”,因此必须是原子的。
|
||||
defer a.sched.setCritical(false)
|
||||
|
||||
// ★ 安全轮次边界(2026-10-31 收敛)。
|
||||
//
|
||||
// 一条输入 = 一个安全轮次。这里把**原始事实**交给 security,
|
||||
// 由它自己解析档位、自己判定主体类别;本 agent 不参与任何策略判断。
|
||||
//
|
||||
// ★ defer End() 而非在末尾调:一条输入可能经过多个 return 出口
|
||||
// (跳过 / 短路 / 挂起 / 正常),逐个加必然漏一个 —— 与
|
||||
// `executeToolCallOutcome` 的入向脱敏用 defer 同一理由。
|
||||
round := a.beginSecurityRound(evt.Payload, evt.Source)
|
||||
defer round.End()
|
||||
_ = round // 句柄随后续轮次上下文下沉使用;当前先保证激活/归位正确
|
||||
|
||||
f, term := a.prepareInputTask(evt)
|
||||
switch term {
|
||||
case terminalSkipped, terminalStageShortCircuit, terminalConsolidation:
|
||||
|
||||
@ -843,6 +843,18 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"system_prompt": map[string]interface{}{"type": "string", "description": "create:系统提示词(留空用内置的实时通话提示词)"},
|
||||
"max_tools": map[string]interface{}{"type": "number", "description": "create:单轮最多几批工具调用(0=不限,rt 默认不限)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要注入的输入(会打断当前轮)"},
|
||||
// ★ 父为子声明工具策略(用户裁定 2026-10-30)。
|
||||
//
|
||||
// 描述里必须说清“这是限制”而不是“这是授权”:模型很容易把
|
||||
// allowed_tools 误读成“额外授予”。留空 = 不作额外限制(沿用全局策略)。
|
||||
"allowed_tools": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "create:**你为它声明**的工具白名单(逗号分隔,支持 cmd_* 这类通配)。留空=不额外限制(沿用全局策略)。只能收窄,不能扩大。",
|
||||
},
|
||||
"deny_tools": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "create:**你为它声明**的追加禁止(逗号分隔,支持通配)。你能用但不给它用的工具写这里。与 allowed_tools 同时给时两者同时生效(deny 优先)。",
|
||||
},
|
||||
},
|
||||
[]string{"action"}, parallelOpts()))
|
||||
}
|
||||
@ -881,7 +893,17 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
"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": "create:temp 图记忆路径(留空则用 data_dir/residents/<id>/graph.db)"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
// ★ 父为子声明工具策略(用户裁定 2026-10-30)。
|
||||
// 描述必须点明“声明限制”而非“授予”:模型很容易把白名单误读成授权。
|
||||
"allowed_tools": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "create:**你为它声明**的工具白名单(逗号分隔,支持 cmd_* 这类通配)。留空=不额外限制(沿用全局策略)。只能收窄,不能扩大。",
|
||||
},
|
||||
"deny_tools": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "create:**你为它声明**的追加禁止(逗号分隔,支持通配)。你能用但不给它用的工具写这里。与 allowed_tools 同时给时两者同时生效(deny 优先)。",
|
||||
},
|
||||
"text": map[string]interface{}{"type": "string", "description": "send:要发给子 agent 的消息"},
|
||||
"residual": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "reclaim/destroy:该子手头**未处理消息**的处置。" +
|
||||
|
||||
@ -36,7 +36,25 @@ func (Module) Name() string { return "agent" }
|
||||
func (Module) Requires() []string {
|
||||
return []string{kernelmod.FacLLM, kernelmod.FacMemory, kernelmod.FacTracker,
|
||||
kernelmod.FacDocStore, kernelmod.FacKnowledge, kernelmod.FacTextMem,
|
||||
kernelmod.FacStageHost}
|
||||
kernelmod.FacStageHost,
|
||||
// ★ FacSecurity(2026-10-30 补):这是**真实存在**的依赖,
|
||||
// 之前只是没在图上声明。
|
||||
//
|
||||
// 为什么它必须在这里而不是“反正 security 在第 1 层、agent 在第 4 层”:
|
||||
//
|
||||
// 1. 图是**唯一**保证顺序的地方。不声明 => `buildAgent` 读 `ks.Security`
|
||||
// 就是读一个“恰好已被别人填好”的字段,那是隐式顺序依赖,
|
||||
// 正是本模块化设计要消灭的东西(见 kernelboot.Module 的 Requires 注释)。
|
||||
// 2. 如果将来 security 因为新依赖被推到第 5 层,本模块的
|
||||
// `ks.Security == nil` 检查会**在运行期**报错 —— 而声明了依赖,
|
||||
// 图会直接把它排后,运行期不会发生。
|
||||
// 3. 判据 TestAgentDeclaresSecurityDependency 钉住它(删掉必红)。
|
||||
//
|
||||
// 是否应该反过来把工具执行完全迁进 device,让 agent 不持有 security?
|
||||
// 那是 device 第二阶段的事(见 device/mod.go 包注释)。当前阶段 toolcall
|
||||
// 仍在 core,所以依赖是真实的,就该如实声明。
|
||||
kernelmod.FacSecurity,
|
||||
}
|
||||
}
|
||||
func (Module) Provides() []string { return []string{kernelmod.FacAgent} }
|
||||
|
||||
@ -50,6 +68,18 @@ func (Module) Init(k *kernelboot.KernelState) error {
|
||||
if k.PluginRegistry == nil {
|
||||
return errMissing("agent", kernelmod.FacStageHost)
|
||||
}
|
||||
// ★ FacSecurity 已声明在 Requires 里,到这里它必须非 nil。
|
||||
//
|
||||
// 为何在这里“响亮失败”而不让 nil 传给 Agent:nil 会让
|
||||
// `securityDecisionFor` 恒放行,而策略表本身完全正确 —— 结果是
|
||||
// “配置里写了黑名单、日志里一条拒绝都没有”,看起来像没人违规。
|
||||
// 这种静默降级比启动失败难查得多。
|
||||
//
|
||||
// 注意区分:**配置显式关闭**时 security 模块装的是 Disabled Runtime
|
||||
// (非 nil,零成本透传),所以这里报错只可能是装配顺序错了。
|
||||
if k.Security == nil {
|
||||
return errMissing("agent", kernelmod.FacSecurity)
|
||||
}
|
||||
k.Agent = buildAgent(k)
|
||||
|
||||
// 插件 SDK 接线(必须在 loadPlugins 之前)。
|
||||
@ -89,6 +119,7 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
mmErr := k.MultimodalErr
|
||||
stageHost := k.StageHost
|
||||
evBus := k.EventBus
|
||||
secRT := k.Security
|
||||
|
||||
personality := loadPersonality(cfg, cfgReg)
|
||||
|
||||
@ -165,8 +196,16 @@ func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
EmbeddingError: mmErr,
|
||||
StageHost: stageHost,
|
||||
EventBus: evBus,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
// ★ 安全运行时必须在**构造期**传入(2026-10-30)。
|
||||
//
|
||||
// 依赖链:security.Init → ks.Security → 这里 → AgentConfig.Security
|
||||
// → New() 写 a.security → securityDecisionFor → CheckTool。
|
||||
//
|
||||
// 判据 TestAgentAssemblyWiresSecurity 走真实登记表装配 + 真工具执行,
|
||||
// 删掉本行或 Requires 里的 FacSecurity 都会红。
|
||||
Security: secRT,
|
||||
ThinkingEnabled: cfg.LLM.ThinkingEnabled,
|
||||
InputProcessing: cfg.InputProcessing,
|
||||
})
|
||||
|
||||
// 图记忆读写钩子 → 事件总线。
|
||||
|
||||
479
internal/kernelmod/all/security_wiring_test.go
Normal file
479
internal/kernelmod/all/security_wiring_test.go
Normal file
@ -0,0 +1,479 @@
|
||||
package all
|
||||
|
||||
// 装配级安全判据(2026-10-30)。
|
||||
//
|
||||
// ## 为什么必须新开一个文件、而不是加在 security_toolgate_test.go 里
|
||||
//
|
||||
// 那边 4 条判据全绿了**整整一轮发布**,而生产里的闸门根本没接上。原因很简单:
|
||||
//
|
||||
// 它们自己调了 a.SetSecurity(...)。
|
||||
//
|
||||
// 判据把缺失的那根线补上了,所以永远绿 —— 测的是「接线之后行为对」,
|
||||
// 而不是「生产有没有接线」。这是「机制 ≠ 接线」最纯粹的样本。
|
||||
//
|
||||
// 本文件钉的是**装配**:
|
||||
//
|
||||
// kernelmod.Registered() → kernelboot.Build → kernelboot.Run
|
||||
// → security.Init 产出 ks.Security
|
||||
// → agent.Init 把 ks.Security 装进 Agent
|
||||
// → 真实工具执行链读到它(securityDecisionFor → CheckTool)
|
||||
//
|
||||
// ★ 变异要求(每条都在实现上验过,见文件末尾的变异表):
|
||||
// ① 从 agent.Requires() 删掉 FacSecurity ⇒ 判据 1 红
|
||||
// ② 从 buildAgent 的 AgentConfig 删 Security ⇒ 判据 2 红
|
||||
// ③ 从 resident/rtchild 的 AgentConfig 删 Security ⇒ 判据 3 红
|
||||
// ④ 从 New() 删掉 `security: cfg.Security` ⇒ 判据 2 红
|
||||
// ⑤ 从 agent.Init 删掉 nil 检查 ⇒ 判据 4 红
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentcore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
"github.com/JianFeeeee/HologramAgent/pkg/types"
|
||||
)
|
||||
|
||||
// ───────────────── 判据 1:agent 模块**声明**了 security 依赖 ─────────────────
|
||||
|
||||
// ★★★ 判据:`agent` 模块必须在 Requires() 里声明 FacSecurity。
|
||||
//
|
||||
// ## 为什么不能靠"层号碰巧对"来代替这条判据
|
||||
//
|
||||
// 即使不声明,security 也在第 1 层、agent 在第 4 层 ⇒ `ks.Security` 恰好
|
||||
// 已被填好,运行期照样能读到。**这正是这个缺陷藏了整轮的原因。**
|
||||
//
|
||||
// 但那个"恰好"是偶然的:security 只依赖 config,所以天然在前面。一旦
|
||||
// security 将来加了自己的依赖(比如判某些策略要先读 llm 配置),它就会被推到
|
||||
// agent 之后 —— 那时 agent 读到一个 nil 的 ks.Security,而**图不会拦**,
|
||||
// 因为图根本不知道 agent 需要它。
|
||||
//
|
||||
// 所以这条判据断言的是**声明**,不是顺序。与 TestHiddenDependencyOnnxDeclared
|
||||
// 同型(那条也明确写了"不能用层号断言")。
|
||||
func TestAgentDeclaresSecurityDependency(t *testing.T) {
|
||||
var matched *kernelboot.Module
|
||||
var requires []string
|
||||
for _, m := range kernelmod.Registered() {
|
||||
if m.Name() != "agent" {
|
||||
continue
|
||||
}
|
||||
requires = m.Requires()
|
||||
matched = &m
|
||||
}
|
||||
if matched == nil {
|
||||
t.Fatal("★ agent 模块没注册")
|
||||
}
|
||||
for _, r := range requires {
|
||||
if r == kernelmod.FacSecurity {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("★ agent 模块未声明对 %q 的依赖(当前 Requires=%v)。\n"+
|
||||
" 后果:buildAgent 读 ks.Security 就成了**隐式顺序依赖** ——\n"+
|
||||
" 今天恰好成立(security 在第 1 层),但那是运气不是设计,\n"+
|
||||
" 而隐式顺序依赖正是本仓模块化设计要消灭的形态。\n"+
|
||||
" 修法:kernelmod/agent 的 Requires() 加 kernelmod.FacSecurity。",
|
||||
kernelmod.FacSecurity, requires)
|
||||
}
|
||||
|
||||
// ───────────────── 判据 2:装配后 Agent 的执行链真的读到它 ─────────────────
|
||||
|
||||
// ★★★ 判据:走真实登记表装配整机后,给 `ks.Security` 配的黑名单
|
||||
// **必须**能拦住 Agent 的工具执行链。
|
||||
//
|
||||
// ## 与前一代判据的区别(本文件存在的全部理由)
|
||||
//
|
||||
// 旧判据:`a := newAgent(); a.SetSecurity(rt); assert(a.securityDecisionFor(...))`
|
||||
//
|
||||
// ⇒ 自己补线,永远绿。
|
||||
//
|
||||
// 本判据:不碰 SetSecurity,只用**模块装配**产出的 ks.Agent 与 ks.Security,
|
||||
//
|
||||
// 并且走**真实执行入口** `ExecuteTool`(即 api.ToolHost,生产里
|
||||
// device 与插件工具面都通过它进来)。
|
||||
//
|
||||
// ## 为什么用"黑名单里的**不存在**的工具"
|
||||
//
|
||||
// 闸门在 `executeToolCallInner` 里位于**分派之前**(参数校验之后)。
|
||||
// 所以拿一个未注册的工具名去试:
|
||||
//
|
||||
// · 闸门生效 ⇒ 得到 "not permitted"
|
||||
// · 闸门失效 ⇒ 落到分派,得到 "does not exist"
|
||||
//
|
||||
// 两者文本完全不同,无从混淆。这样判据不需要注册探针工具、不需要造插件,
|
||||
// 也不会因为工具不存在而"碰巧"通过。
|
||||
func TestAgentAssemblyWiresSecurity(t *testing.T) {
|
||||
m := bootMachine(t)
|
||||
ks := m.ks
|
||||
|
||||
if ks.Security == nil {
|
||||
t.Fatal("★ security 模块没产出 ks.Security(装配链断了)")
|
||||
}
|
||||
if ks.Agent == nil {
|
||||
t.Fatal("★ agent 模块没产出 ks.Agent")
|
||||
}
|
||||
|
||||
const deniedTool = "boot_probe_denied_tool"
|
||||
|
||||
// ① 先证明这个工具名**没被注册**(否则下面"not permitted"可能来自别处)。
|
||||
out, _ := ks.Agent.ExecuteTool(deniedTool, nil)
|
||||
if strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("前置不成立:未配黑名单时就已被拒(%q)—— 检查环境里的实际策略", out)
|
||||
}
|
||||
|
||||
// ② 关键一步:给**模块化产出**的 ks.Security 装黑名单。
|
||||
// 如果 Agent 里拿的是另一个实例(或 nil),它看不到这条 ⇒ 判据红。
|
||||
ks.Security.SetPolicy(security.Policy{ToolDeny: []string{deniedTool}})
|
||||
|
||||
out, _ = ks.Agent.ExecuteTool(deniedTool, nil)
|
||||
if !strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("★ 已给 ks.Security 配 %q 黑名单,Agent 的执行链却没拦住(实际返回 %q)。\n"+
|
||||
" 可能原因(按概率):\n"+
|
||||
" ① buildAgent 没把 ks.Security 传进 AgentConfig.Security\n"+
|
||||
" ② New() 没把 cfg.Security 写进 a.security\n"+
|
||||
" ③ 执行链上少了 securityDecisionFor 这道闸\n"+
|
||||
" 三者任一都会让策略表正确而完全无效,且日志里一条拒绝都没有。",
|
||||
deniedTool, out)
|
||||
}
|
||||
|
||||
// ③ 反向:未命中的工具必须放行。
|
||||
// 否则是"全拒"型假绿(闸门写死了),而不是接线正确。
|
||||
if out, _ := ks.Agent.ExecuteTool("boot_probe_unlisted_tool", nil); strings.Contains(out, "not permitted") {
|
||||
t.Fatalf("未命中黑名单的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 判据 4:装配失败时响亮,而不是静默放行 ─────────────────
|
||||
|
||||
// ★★★ 判据:`ks.Security == nil` 时 agent 模块必须**拒绝装配**。
|
||||
//
|
||||
// 防的失败方式:把 nil 一路传下去 —— Agent 装好了、日志写着 "kernel ready"、
|
||||
// 工具照常执行,只是策略永远不生效。本项目已经踩过一次这个坑
|
||||
// ("策略表存在、判定函数正确、日志里一条拒绝都没有")。
|
||||
//
|
||||
// 注意区分:配置显式关闭安全模块时装的是 **Disabled Runtime(非 nil)**,
|
||||
// 所以本判据不会误伤"用户选择不用安全模块"这个合法场景。
|
||||
//
|
||||
// 实现要点:用**真实模块的 Init**,不是一个复制品。bootMachine 已把整机跑过
|
||||
// 一遍(所有依赖真实就绪),这里借它的 KernelState 复制一份、只破坏一个前提。
|
||||
// agent.Init 的 nil 检查在所有副作用之前(第一个 if),所以本判据不产生资源。
|
||||
func TestAgentAssemblyRefusesNilSecurity(t *testing.T) {
|
||||
m := bootMachine(t)
|
||||
base := m.ks
|
||||
if base.Security == nil {
|
||||
t.Fatal("前置失败:整机装配没产出 Security")
|
||||
}
|
||||
|
||||
var agentMod kernelboot.Module
|
||||
for _, mod := range kernelmod.Registered() {
|
||||
if mod.Name() == "agent" {
|
||||
agentMod = mod
|
||||
}
|
||||
}
|
||||
if agentMod == nil {
|
||||
t.Fatal("★ agent 模块没注册")
|
||||
}
|
||||
|
||||
// ★ 不能复制 KernelState(它含 sync.Mutex,复制即 vet 报错,且真的不安全)。
|
||||
// 改为新建一个:把**真实状态里已就绪的全部依赖**逐字段搬过去,
|
||||
// 唯独 Security 留 nil —— 这样 Init 只会因为这一个前提失败。
|
||||
//
|
||||
// 为什么这样做而不是直接用 base:Init 成功时会覆盖 base.Agent,
|
||||
// 破坏后续判据的上下文。新建一份是干净的。
|
||||
var probe kernelboot.KernelState
|
||||
probe.Cfg = base.Cfg
|
||||
probe.CfgReg = base.CfgReg
|
||||
probe.DataDir = base.DataDir
|
||||
probe.AgentWorkDir = base.AgentWorkDir
|
||||
probe.CLISocket = base.CLISocket
|
||||
probe.HTTPAddr = base.HTTPAddr
|
||||
probe.Ctx = base.Ctx
|
||||
probe.Memory = base.Memory
|
||||
probe.LLMProvider = base.LLMProvider
|
||||
probe.LLMManager = base.LLMManager
|
||||
probe.Tracker = base.Tracker
|
||||
probe.DocStore = base.DocStore
|
||||
probe.Knowledge = base.Knowledge
|
||||
probe.TextMemory = base.TextMemory
|
||||
probe.StageHost = base.StageHost
|
||||
probe.PluginRegistry = base.PluginRegistry
|
||||
probe.EventBus = base.EventBus
|
||||
probe.Embedder = base.Embedder
|
||||
probe.MultimodalSpace = base.MultimodalSpace
|
||||
probe.MultimodalProviderName = base.MultimodalProviderName
|
||||
probe.MultimodalErr = base.MultimodalErr
|
||||
probe.BaseAPIKey = base.BaseAPIKey
|
||||
probe.Security = nil // ← 唯一被破坏的前提
|
||||
|
||||
err := agentMod.Init(&probe)
|
||||
if err == nil {
|
||||
t.Fatal("★ ks.Security 为 nil 时 agent 模块居然装配成功了 —— " +
|
||||
"nil 会被一路传到 securityDecisionFor,那里恒放行且不留任何日志。\n" +
|
||||
" 必须在 Init 里响亮失败(与 device 模块对 ks.Security 的检查同型)。")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "security") {
|
||||
t.Fatalf("失败信息必须点名缺失的设施(便于运维一眼定位),实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 判据 4:父为子**声明**策略,而不是子继承父的权限 ─────────────────
|
||||
|
||||
// ★★★ 判据:父创建子 agent 时,父声明的工具策略必须**真的限制住那个子**,
|
||||
// 且**只影响那个子**(不是全局)。
|
||||
//
|
||||
// ## 为什么这版取代了上一版(用户裁定 2026-10-30)
|
||||
//
|
||||
// 上一版的判据断言「子继承父的 `a.security`」—— 那只保证了**机制**到位,
|
||||
// 却把权限模型写错成了「子 = 父的完整权限」。
|
||||
//
|
||||
// 正确模型是 **父为子声明策略**:
|
||||
//
|
||||
// 父:能调全部工具(受全局策略约束)
|
||||
// 子:父在创建时决定它能调什么 —— 内核持有并执行,子不能自己扩大
|
||||
//
|
||||
// 这不是新机制:`security.CheckToolFor(Principal, tool)` 与
|
||||
// `PrincipalPolicy{AllowTools, DenyTools}` 早就在树里,只是**零生产调用方**
|
||||
// (与工具策略闸、ScanContent 同型:机制完整、接线缺失)。
|
||||
//
|
||||
// ## 本判据钉三件事
|
||||
//
|
||||
// ① 父声明的 deny 真的拦住**子**
|
||||
// ② 同一条策略**不影响父**(不是全局策略)
|
||||
// ③ 子销毁后策略被撤销(同名重建不能继承上一代父的约束)
|
||||
func TestParentDeclaresChildPolicy(t *testing.T) {
|
||||
rt := security.NewRuntime(nil)
|
||||
|
||||
// 父:parentID 为空 ⇒ 走 CheckTool(只受全局策略约束)。
|
||||
const forbidden = "resident_denied_tool"
|
||||
parent := agentcore.New(agentcore.AgentConfig{
|
||||
ID: "main",
|
||||
Provider: nil,
|
||||
Security: rt,
|
||||
})
|
||||
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||||
t.Fatalf("父不应受限(它是内核自身,此工具不受任何全局策略限制): %s", d.Reason)
|
||||
}
|
||||
|
||||
// 父为子声明策略:允许 probe_ok_*,禁止 forbidden。
|
||||
childID := "policy-probe-child"
|
||||
if err := parent.ProbeDeclareChildPolicy(childID, []string{"probe_ok_*"}, []string{forbidden}); err != nil {
|
||||
t.Fatalf("父为子声明策略失败: %v", err)
|
||||
}
|
||||
|
||||
// ① 子被判住:子 parentID 非空 ⇒ 走 CheckToolFor(Principal{self})
|
||||
child := agentcore.New(agentcore.AgentConfig{
|
||||
ID: types.AgentID(childID),
|
||||
ParentID: "main",
|
||||
Provider: nil,
|
||||
Security: rt, // 同一个 Runtime 实例(它只是机制,权限靠主体声明)
|
||||
})
|
||||
if d := child.ProbeSecurityDecision(forbidden); d.Allowed {
|
||||
t.Fatalf("★ 父已为子 %q 声明禁止 %q,子的判定却仍是 Allowed —— "+
|
||||
"主体策略没有被工具执行链读到(CheckToolFor 未接线)", childID, forbidden)
|
||||
}
|
||||
if d := child.ProbeSecurityDecision("other_tool"); d.Allowed {
|
||||
t.Fatal("★ 父给了 probe_ok_* 白名单,other_tool 却仍被放行 —— 白名单未生效")
|
||||
}
|
||||
if d := child.ProbeSecurityDecision("probe_ok_1"); !d.Allowed {
|
||||
t.Fatalf("白名单内的工具被误拒(%q)—— 判定逻辑坏了,不是接线问题", d.Reason)
|
||||
}
|
||||
|
||||
// ② 同一条策略不影响父(主体隔离)。
|
||||
if d := parent.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||||
t.Fatal("★ 子策略泄漏成了全局策略 —— 父也被禁了。主体隔离失效")
|
||||
}
|
||||
|
||||
// ③ 撤销后子恢复(同名重建不能继承上一代父的约束)。
|
||||
parent.ProbeRevokeChildPolicy(childID)
|
||||
if d := child.ProbeSecurityDecision(forbidden); !d.Allowed {
|
||||
t.Fatal("★ 策略已撤销,子却仍被禁 —— 主体表以调用方给的名字为键," +
|
||||
"不清理就会让同名重建的新一代继承上一代父的约束(与 rtchild 留档串线同型)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**销毁路径必须撤销策略**(两处对称:单数与复数)。
|
||||
//
|
||||
// 这条单列,因为本仓反复吃亏在"单数路径改了、复数路径漏了":
|
||||
// `StopRTChild` 注销入站登记而 `StopRTChildren` 漏了,就是这么来的。
|
||||
//
|
||||
// 用源码断言:真销毁需要完整记忆栈/rt 通道,而"前置不满足就 skip"
|
||||
// 的判据等于没有(本仓已有同型先例:TestHiddenDependencyOnnxDeclared)。
|
||||
func TestChildTeardownRevokesPolicy(t *testing.T) {
|
||||
cases := []struct{ file, fn, why string }{
|
||||
{"../../agent/core/resident.go", "func (a *Agent) teardownResidentWith(",
|
||||
"驻留子销毁必须撤销父为它声明的策略"},
|
||||
{"../../agent/core/rtchild.go", "func (a *Agent) StopRTChild(",
|
||||
"rtchild 单数停止必须撤销策略"},
|
||||
{"../../agent/core/rtchild.go", "func (a *Agent) StopRTChildren(",
|
||||
"rtchild 批量停止必须与单数路径对称地撤销策略"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
src := readMod(t, c.file)
|
||||
i := strings.Index(src, c.fn)
|
||||
if i < 0 {
|
||||
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn)
|
||||
}
|
||||
body := src[i:]
|
||||
if end := strings.Index(body, "\n}\n"); end > 0 {
|
||||
body = body[:end]
|
||||
}
|
||||
if !strings.Contains(body, "revokeChildPolicy") {
|
||||
t.Errorf("★ %s 里没有 revokeChildPolicy —— %s。\n"+
|
||||
" 后果:主体表以调用方给的名字为键,回收后同名重建会继承\n"+
|
||||
" 上一代父留下的约束(而那个父可能已经不在了),且表单调增长。",
|
||||
c.fn, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:创建路径必须在**子开始执行前**声明策略。
|
||||
//
|
||||
// 顺序错(比如在 go carrier.rtLoop 之后声明)会留下一个"无约束执行"窗口:
|
||||
// 子一接到输入就会跑工具,那时策略可能还没进内核。
|
||||
func TestPolicyDeclaredBeforeChildStarts(t *testing.T) {
|
||||
cases := []struct{ file, declAnchor, startAnchor, name string }{
|
||||
{"../../agent/core/resident.go", "declareChildPolicy(opts.ID", "child.Start()", "驻留子"},
|
||||
{"../../agent/core/rtchild.go", "declareChildPolicy(opts.ID", "go carrier.rtLoop(rc)", "rtchild"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
src := readMod(t, c.file)
|
||||
di := strings.Index(src, c.declAnchor)
|
||||
si := strings.Index(src, c.startAnchor)
|
||||
if di < 0 || si < 0 {
|
||||
t.Fatalf("%s: 找不到锚点(decl=%v start=%v)", c.file, di >= 0, si >= 0)
|
||||
}
|
||||
if di > si {
|
||||
t.Errorf("★ %s:策略声明在 `%s` **之后** —— 存在一个「子已开始执行、策略尚未生效」的窗口",
|
||||
c.name, c.startAnchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────── 判据 5:策略语义只能在 security 内部 ─────────────────
|
||||
|
||||
// ★★★ 判据:`agent/core` **不得**再做策略决策。
|
||||
//
|
||||
// ## 收敛前它做了什么(2026-10-31)
|
||||
//
|
||||
// security_profile.go 里 214 行,其中绝大部分是策略逻辑:
|
||||
// · 自己从 payload 抠 `security_profile` 字段
|
||||
// · 自己判断 "parentID != "" → 走 CheckToolFor,否则 CheckTool"
|
||||
// · 自己拼 `PrincipalPolicy{AllowTools:…, DenyTools:…}` 字面量
|
||||
// · 自己写 `Kind: "resident"` **声明自己的主体类别**
|
||||
//
|
||||
// 最后一条是提权口子:调用方说自己是内部主体,就享受内部豁免
|
||||
// (不过 HardPrivateTools)。类别必须由 security 从**事实**推出。
|
||||
//
|
||||
// ## 本判据用什么钉
|
||||
//
|
||||
// 源码断言(跨包行为断言做不到"禁止某种写法")。三类越界各一条正则:
|
||||
//
|
||||
// ① `payload["security_profile"]` 出现在 agent/core ⇒ 解析回落调用方
|
||||
// ② `Kind:` 字面量出现在 agent/core 的安全文件 ⇒ 调用方声明类别
|
||||
// ③ `CheckTool(` / `CheckToolFor(` 直接出现在 agent/core ⇒ 越级选链
|
||||
//
|
||||
// ★ 例外:`security_profile.go` 的**注释**里会提到这些名字(解释历史)。
|
||||
// 所以逐行判断,跳过纯注释行。
|
||||
func TestAgentCoreHoldsNoPolicySemantics(t *testing.T) {
|
||||
src := readMod(t, "../../agent/core/security_profile.go")
|
||||
|
||||
// 会被禁止的代码形态(不含注释)。
|
||||
banned := []struct{ pattern, why string }{
|
||||
{`payload["security_profile"]`,
|
||||
"档位字段名是 security 的协议,解析必须留在 security.BeginRound"},
|
||||
{`Kind:`,
|
||||
"主体类别必须由 security 从事实(有无父)推出,调用方不得声明"},
|
||||
{`.CheckToolFor(`,
|
||||
"选哪条判定链是策略决策,调用方只该调 Round.CheckTool"},
|
||||
{`PrincipalPolicy{`,
|
||||
"策略结构由 security 组装,调用方只该传 allow/deny 列表"},
|
||||
}
|
||||
for _, b := range banned {
|
||||
for i, line := range strings.Split(src, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "//") {
|
||||
continue // 注释里解释历史是允许的
|
||||
}
|
||||
if strings.Contains(line, b.pattern) {
|
||||
t.Errorf("★ agent/core/security_profile.go:%d 出现 %q —— %s\n\t%s",
|
||||
i+1, b.pattern, b.why, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:主体类别由 security 从**事实**推出,且**不信**调用方声明。
|
||||
//
|
||||
// 收敛前 `Kind: "resident"` 由 agent 写死 —— 它同时意味着"我是内部"
|
||||
// 与"我不受收窄",把两件不同的事绑在一个字符串上。现在分类规则只有一处
|
||||
// (`classifyPrincipal`),本判据钉住三条映射。
|
||||
func TestPrincipalKindIsDerivedBySecurity(t *testing.T) {
|
||||
rt := security.NewRuntime(nil)
|
||||
|
||||
// ① 有父 ⇒ 子执行体(内部豁免,但可被父收窄)
|
||||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "kid", IsChild: true}); p.Kind != security.KindSubAgent {
|
||||
t.Fatalf("有父的执行体应归为 KindSubAgent,实际 %q", p.Kind)
|
||||
} else if !p.IsInternal() || !p.IsSubAgent() {
|
||||
t.Fatal("KindSubAgent 必须同时满足 IsInternal(豁免 HardPrivate)与 IsSubAgent(可被收窄)")
|
||||
}
|
||||
|
||||
// ② 无 id ⇒ 内核自身
|
||||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{}); p.Kind != security.KindInternal {
|
||||
t.Fatalf("无 id 应归为 KindInternal,实际 %q", p.Kind)
|
||||
}
|
||||
|
||||
// ③ 有 id 无父 ⇒ 用户
|
||||
if p := rt.ClassifyPrincipalForTest(security.RoundInput{AgentID: "qq-user-1"}); p.Kind != security.KindUser {
|
||||
t.Fatalf("经插件通道进来的应归为 KindUser,实际 %q", p.Kind)
|
||||
}
|
||||
|
||||
// ④ 枚举不接受自由字符串:拼错必须能被识别(而不是静默变成用户主体)
|
||||
if _, ok := security.ParsePrincipalKind("residnet"); ok {
|
||||
t.Fatal("★ 拼错的主体类别被当成合法值 —— 拼写错误会静默漂移权限语义")
|
||||
}
|
||||
if k, ok := security.ParsePrincipalKind("resident"); !ok || k != security.KindSubAgent {
|
||||
t.Fatal("旧值 resident 应兼容映射为 KindSubAgent(不破坏既有调用方)")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:轮次边界在**全部输入路径**上都被声明。
|
||||
//
|
||||
// 收敛把判定逻辑收到了一处,但"一轮何时开始/结束"只有各路径自己知道。
|
||||
// 本仓已有三条独立输入路径,漏一条就是"那类 agent 永远没有档位":
|
||||
//
|
||||
// 主 agent + 驻留子 → runInputTask(驻留子有自己的 Agent 实例)
|
||||
// rtchild → rtRunOnce(不走调度器,单独一条)
|
||||
//
|
||||
// 用源码断言:真跑三条路径需要完整记忆栈/rt 通道,
|
||||
// 而"前置不满足就 skip"的判据等于没有(与 TestChildTeardownRevokesPolicy 同一理由)。
|
||||
func TestEveryInputPathDeclaresRound(t *testing.T) {
|
||||
cases := []struct{ file, fn, why string }{
|
||||
{"../../agent/core/task.go", "func (a *Agent) runInputTask(",
|
||||
"主 agent 与驻留子共用的输入入口"},
|
||||
{"../../agent/core/rtchild.go", "func (a *Agent) rtRunOnce(",
|
||||
"rtchild 不走调度器、不经过 runInputTask,是独立的一条输入路径"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
src := readMod(t, c.file)
|
||||
i := strings.Index(src, c.fn)
|
||||
if i < 0 {
|
||||
t.Fatalf("%s 里找不到 %s —— 符号改名了?判据需要跟着改", c.file, c.fn)
|
||||
}
|
||||
body := src[i:]
|
||||
if end := strings.Index(body, "\n}\n"); end > 0 {
|
||||
body = body[:end]
|
||||
}
|
||||
if !strings.Contains(body, "beginSecurityRound(") {
|
||||
t.Errorf("★ %s 里没有 beginSecurityRound —— %s 缺少安全轮次边界。\n"+
|
||||
" 后果:这条路径上的输入永远不会激活插件声明的档位,\n"+
|
||||
" 而它在**其他**路径上是生效的 ⇒ 表现为「时灵时不灵」,最难查。",
|
||||
c.fn, c.why)
|
||||
}
|
||||
if !strings.Contains(body, "End()") {
|
||||
t.Errorf("★ %s 里没有 End() —— 档位会在轮次结束后泄漏到下一轮,"+
|
||||
"guest 的收窄误伤后续输入(方向相反就是提权)", c.fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
271
internal/plugins/rtchild_reuse_e2e_test.go
Normal file
271
internal/plugins/rtchild_reuse_e2e_test.go
Normal file
@ -0,0 +1,271 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package plugins
|
||||
|
||||
// 端到端判据:已发布的 rtchild 修复在**真实外部插件 + 真 RPC** 下成立。
|
||||
//
|
||||
// ## 为什么这一组必须单独存在(与 FullChain 的分工)
|
||||
//
|
||||
// `TestExternalRTChild_FullChain` 证明"全链路能跑通"——登记→划拨→注入→
|
||||
// 输出回写→回收悬空。但它**一次都不测同名复用**,也不测批量停止:
|
||||
// 它用的是全新的 id、且只在末尾 Cleanup 里 StopRTChildren。
|
||||
//
|
||||
// 而 2026-10-30 修的三处恰好全在"复用/清理"这条线上:
|
||||
//
|
||||
// ① RTTranscript 的读序(留档遮蔽在跑实例 ⇒ 两通电话串线)
|
||||
// ② StopRTChildren 漏注销入站登记(child/rt-<id> 单调累积)
|
||||
// ③ 留档跨代覆写语义
|
||||
//
|
||||
// 三者都是**跨通话生命周期**的缺陷 —— 单实例 e2e 天然测不到。这一组用
|
||||
// 同一个插件产物、同一个通道对,反复"拨号—挂断",把清理路径跑成主路径。
|
||||
//
|
||||
// ## 前置(与 FullChain 同口径)
|
||||
//
|
||||
// RTCHILD_E2E_PLUGIN_BIN=/path/to/plugin.bin.linux.amd64 \
|
||||
// go test ./internal/plugins/ -run TestExternalRTChild_Reuse -v
|
||||
//
|
||||
// 未提供产物时 **skip** 而非 fail(CI 不强制先构建插件)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentCore "github.com/JianFeeeee/HologramAgent/internal/agent/core"
|
||||
)
|
||||
|
||||
// rtE2EHost 装配一个"插件面 + 内核 Agent"的现场,供复用类判据共用。
|
||||
//
|
||||
// 与 FullChain 内联装配的唯一差别是抽出来复用:三条判据各要一份干净现场,
|
||||
// 内联复制会让"为什么这三条要各自 spawn 一套"变得不可读。
|
||||
func rtE2EHost(t *testing.T, bin string) (*testPluginEnv, *agentCore.Agent, string, string) {
|
||||
t.Helper()
|
||||
env := setupIntegration(t)
|
||||
|
||||
plgDir := env.tmpDir + "/plugins"
|
||||
installRTPlugin(t, plgDir, "rtvoice-e2e", bin)
|
||||
if err := env.pluginReg.Load(plgDir); err != nil {
|
||||
t.Fatalf("加载外部插件: %v", err)
|
||||
}
|
||||
if env.pluginReg.Get("rtvoice-e2e") == nil {
|
||||
t.Fatal("rtvoice-e2e 未加载")
|
||||
}
|
||||
|
||||
host := agentCore.New(agentCore.AgentConfig{
|
||||
ID: "reuse-host",
|
||||
IO: env.iom,
|
||||
Provider: &e2eEchoProvider{},
|
||||
})
|
||||
return env, host, "rtvoice-e2e-in", "rtvoice-e2e"
|
||||
}
|
||||
|
||||
// TestExternalRTChild_ReuseDoesNotLeakTranscript 钉住①在**真实链路**下成立:
|
||||
// 同名 rtchild 回收后重建,新建实例的 transcript 不得含上一通的内容;
|
||||
// 同时上一通的留档(在覆写之前)必须仍然可取 —— 那是交接契约。
|
||||
//
|
||||
// 变异方式:RTTranscript 改回 archive-first ⇒ 第二条断言必红(进程内判据
|
||||
// 已实测;本判据保证同样的错误在跨进程装配下也拦得住)。
|
||||
func TestExternalRTChild_ReuseDoesNotLeakTranscript(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
defer host.StopRTChildren()
|
||||
|
||||
const id = "e2e-reuse"
|
||||
|
||||
// ── 第一通:注入 → 跑一轮 → 回收 ──
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("第一通创建失败: %v", err)
|
||||
}
|
||||
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
|
||||
map[string]interface{}{"text": "第一通说的话"}); err != nil {
|
||||
t.Fatalf("第一通注入失败: %v", err)
|
||||
}
|
||||
waitHistory(t, host, id, 2, "第一通(user+assistant)")
|
||||
|
||||
firstMsgs, ok := host.RTTranscript(id)
|
||||
if !ok || len(firstMsgs) == 0 {
|
||||
t.Fatalf("第一通 transcript 为空(ok=%v len=%d)", ok, len(firstMsgs))
|
||||
}
|
||||
firstText := agentCore.FormatRTTranscript(firstMsgs)
|
||||
if n, err := host.CloseRTChild(id); err != nil || n == 0 {
|
||||
t.Fatalf("第一通回收失败(n=%d err=%v)", n, err)
|
||||
}
|
||||
|
||||
// ★ 回收后留档必须仍可取 —— 这是「推信号 + 拉状态」的另一半。
|
||||
// 若这条红,说明有人为了"修串线"把留档删了(我本人曾写错过这一版)。
|
||||
archived, ok := host.RTTranscript(id)
|
||||
if !ok || len(archived) == 0 {
|
||||
t.Fatalf("★ 回收后取不到上一通原文 —— 交接契约断链(推了信号却拉不到正文)")
|
||||
}
|
||||
if !strings.Contains(agentCore.FormatRTTranscript(archived), "第一通说的话") {
|
||||
t.Fatalf("留档里没有第一通的内容:%s",
|
||||
truncateE2E(agentCore.FormatRTTranscript(archived)))
|
||||
}
|
||||
|
||||
// ── 第二通:同名重建,必须"忘掉"上一通 ──
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("同名重建失败: %v", err)
|
||||
}
|
||||
if _, err := env.stageHost.ExecuteTool("rtvoice_inject",
|
||||
map[string]interface{}{"text": "第二通说的话"}); err != nil {
|
||||
t.Fatalf("第二通注入失败: %v", err)
|
||||
}
|
||||
waitHistory(t, host, id, 2, "第二通(user+assistant)")
|
||||
|
||||
second, ok := host.RTTranscript(id)
|
||||
if !ok {
|
||||
t.Fatal("第二通取不到 transcript")
|
||||
}
|
||||
secondText := agentCore.FormatRTTranscript(second)
|
||||
if strings.Contains(secondText, "第一通说的话") {
|
||||
t.Fatalf("★ 同名重建后 transcript 含**第一通**的内容 —— 两通电话串线:%s",
|
||||
truncateE2E(secondText))
|
||||
}
|
||||
if !strings.Contains(secondText, "第二通说的话") {
|
||||
t.Fatalf("第二通 transcript 里没有它自己的输入:%s", truncateE2E(secondText))
|
||||
}
|
||||
_ = firstText
|
||||
}
|
||||
|
||||
// TestExternalRTChild_BulkStopUnregistersInbound 钉住②在真实链路下成立:
|
||||
// 批量停止必须把 child/rt-<id> 从共享登记表里摘掉。
|
||||
//
|
||||
// 为什么要在跨进程层测:入站登记是**内核自己建的**,但登记表由插件
|
||||
// 装配共用。进程内判据用的是裸 IOManager;这里用的是真实插件装好的那张表
|
||||
// ——"在真实装配下也摘得干净"才算数。
|
||||
func TestExternalRTChild_BulkStopUnregistersInbound(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
|
||||
reg := env.iom.ChannelRegistry()
|
||||
|
||||
const id = "e2e-bulk"
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{ID: id, Input: inCh, Output: outCh}); err != nil {
|
||||
t.Fatalf("创建失败: %v", err)
|
||||
}
|
||||
|
||||
// 入站登记的名字由内核约定:child/rt-<id>。
|
||||
// 这里不直接调用未导出的 inboundChannelName,而是按登记表现实探测:
|
||||
// 批量停止前后必须有**恰好一条** child/rt- 前缀的登记消失。
|
||||
const inboundPrefix = "child/rt/"
|
||||
countInbound := func() int {
|
||||
n := 0
|
||||
for _, ch := range reg.List() {
|
||||
if strings.HasPrefix(ch.Name, inboundPrefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
before := countInbound()
|
||||
if before == 0 {
|
||||
t.Fatalf("创建后没有任何 %q 前缀的入站登记 —— 判据前提不成立", inboundPrefix)
|
||||
}
|
||||
|
||||
if n := host.StopRTChildren(); n != 1 {
|
||||
t.Fatalf("StopRTChildren 返回 %d,期望 1", n)
|
||||
}
|
||||
after := countInbound()
|
||||
if after != before-1 {
|
||||
t.Fatalf("★ 批量停止后入站登记 %d → %d(期望减少 1)—— "+
|
||||
"登记会随 create/destroy 次数单调累积,而外部无法单独注销",
|
||||
before, after)
|
||||
}
|
||||
|
||||
// 插件声明的 rt 通道必须**悬空**而非消失(用户裁定:主 agent 无 rt 能力)。
|
||||
for _, n := range []string{inCh, outCh} {
|
||||
ch, ok := reg.Lookup(n)
|
||||
if !ok {
|
||||
t.Fatalf("批量停止把插件声明的 rt 通道 %q 也删了 —— 应保持悬空", n)
|
||||
}
|
||||
if ch.Owner == "" {
|
||||
t.Fatalf("rt 通道 %q 被归还为未分配(应悬空指向已销毁的 %q)", n, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExternalRTChild_RepeatedCyclesDoNotAccumulate 是"长跑"形态的护栏:
|
||||
// 连续多轮建—停,登记表与 rtChildren 都不得累积。
|
||||
//
|
||||
// 这条对应生产里的真实场景:模型被反复要求"打个电话问一句"。
|
||||
// 单次回收正确、但多次之后残留堆积,是典型的"每次少清一点"型缺陷
|
||||
// (②正是这种:单数路径对、复数路径漏)。
|
||||
func TestExternalRTChild_RepeatedCyclesDoNotAccumulate(t *testing.T) {
|
||||
bin := rtE2EPluginBin(t)
|
||||
env, host, inCh, outCh := rtE2EHost(t, bin)
|
||||
defer env.cleanup()
|
||||
|
||||
reg := env.iom.ChannelRegistry()
|
||||
countInbound := func() int {
|
||||
n := 0
|
||||
for _, ch := range reg.List() {
|
||||
if strings.HasPrefix(ch.Name, "child/rt/") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
const cycles = 3
|
||||
base := countInbound()
|
||||
for i := 0; i < cycles; i++ {
|
||||
id := fmt.Sprintf("cycle-%d", i)
|
||||
if _, err := host.SpawnRTChild(agentCore.RTChildOptions{
|
||||
ID: id, Input: inCh, Output: outCh,
|
||||
}); err != nil {
|
||||
t.Fatalf("第 %d 轮创建失败: %v", i, err)
|
||||
}
|
||||
if n := host.StopRTChildren(); n != 1 {
|
||||
t.Fatalf("第 %d 轮 StopRTChildren 返回 %d,期望 1", i, n)
|
||||
}
|
||||
}
|
||||
|
||||
if got := countInbound(); got != base {
|
||||
t.Fatalf("★ %d 轮建—停后入站登记从 %d 涨到 %d —— 每轮都有残留", cycles, base, got)
|
||||
}
|
||||
if ids := host.RTChildren(); len(ids) != 0 {
|
||||
t.Fatalf("多轮建—停后 rtChildren 仍有 %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 共用小工具 ──
|
||||
|
||||
// waitHistory 等到 rtchild 的 history 里出现 want 条消息(超时即失败)。
|
||||
//
|
||||
// ## 为什么等 history 而不等 Rounds(本仓已有专门教训)
|
||||
//
|
||||
// `rtRunOnce` 先 `rc.round++`(Rounds 立刻 +1),**之后**才流式生成、
|
||||
// 最后才 `rtAppendHistory`。所以"Rounds 到了"不等于"话说完了、历史入账了"——
|
||||
// 此时 RTTranscript 可能仍是空的。
|
||||
//
|
||||
// 这正是 dff745c「rt_children stop 判据改为等历史入账,不等 Rounds」的
|
||||
// 同一个坑,我第一版又踩了一遍(首轮判据报"transcript 为空(len=0)")。
|
||||
// 等 history 才是可观测的正确条件。
|
||||
func waitHistory(t *testing.T, host *agentCore.Agent, id string, want int, what string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
var last int
|
||||
for time.Now().Before(deadline) {
|
||||
if msgs, ok := host.RTTranscript(id); ok {
|
||||
if len(msgs) >= want {
|
||||
return
|
||||
}
|
||||
last = len(msgs)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
st, ok := host.RTChildStats(id)
|
||||
t.Fatalf("%s:history 只到 %d 条(期望 ≥%d,ok=%v stats=%+v)—— "+
|
||||
"输入可能没到达 rtSlot,或话说完了但历史没入账", what, last, want, ok, st)
|
||||
}
|
||||
|
||||
func truncateE2E(s string) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= 240 {
|
||||
return s
|
||||
}
|
||||
return string(r[:240]) + "…"
|
||||
}
|
||||
263
internal/security/agentscope.go
Normal file
263
internal/security/agentscope.go
Normal file
@ -0,0 +1,263 @@
|
||||
package security
|
||||
|
||||
// per-agent 策略作用域:**每个 agent 一个实例**。
|
||||
//
|
||||
// ## 为什么必须 per-agent(2026-10-31 用户裁定)
|
||||
//
|
||||
// 收敛前 `Runtime` 是进程级单例,而「当前活跃档位」(`active`)挂在它上面。
|
||||
// 多 agent 并发时档位会串:
|
||||
//
|
||||
// 主 agent 轮次 A:注入 qq.guest → active = "qq.guest"
|
||||
// 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖
|
||||
// 主 agent 轮次 A 继续执行工具 → 判定按 qq.owner 走 ← 串档
|
||||
//
|
||||
// 这不是理论风险:驻留子与 rtchild 与主 agent **同进程并发**(见 plan.md 十一)。
|
||||
//
|
||||
// ## 但**不能**整份复制 Runtime
|
||||
//
|
||||
// `profiles` / `principals` / `scopes` 是**插件声明出来的注册表**。
|
||||
// 若每个 agent 各持一份:
|
||||
//
|
||||
// 插件往 A 声明了 qq.guest ⇒ B 看不见 ⇒ B 上注入 qq.guest 报"未声明"
|
||||
//
|
||||
// 那是最难查的一类缺陷("时灵时不灵")。所以切分必须是:
|
||||
//
|
||||
// 共享(进程级 1 份) per-agent(N 份)
|
||||
// ───────────────────── ─────────────────────
|
||||
// gate / policy / content sessionProfile ← 轮次级,End 即清
|
||||
// profiles / principals longtimeProfile ← 跨轮保留
|
||||
// scopes / eventPolicy roundActive ← 本轮正在生效的那个
|
||||
//
|
||||
// ## 两层档位(用户裁定的语义)
|
||||
//
|
||||
// sessionProfile —— 属于**这一轮**。输入携带它,`End()` 就清掉。
|
||||
// 典型:一条 QQ 消息来自访客 ⇒ 这一轮按 guest 判定。
|
||||
// ★ 严格 per-round:同一用户下一条消息要重新声明。
|
||||
//
|
||||
// longtimeProfile —— 属于**这个 agent 的一段生命**。设置后跨轮保留,
|
||||
// 直到显式改掉或 agent 销毁。
|
||||
// 典型:把这个驻留子长期派驻为"访客权限"。
|
||||
//
|
||||
// 优先级(`roundActive` 的计算):session > longtime > 无。
|
||||
//
|
||||
// session 是"这一轮的事实",它必须能盖住 long-term 的默认值 ——
|
||||
// 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。
|
||||
//
|
||||
// 反向(longtime 盖 session)会造成**提权**:长期档是 owner,
|
||||
// 访客这一轮声明的 guest 被忽略 ⇒ 访客拿到 owner 权限。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentScope 是**一个 agent 的策略执行视图**。
|
||||
//
|
||||
// 它由 `Runtime.ForAgent` 创建,持有该 agent 独有的轮次状态,
|
||||
// 同时共享 `Runtime` 上的全部注册表与全局基线。
|
||||
//
|
||||
// ★ 生命周期跟随 agent:创建时 `ForAgent`,销毁时 `ReleaseAgent`。
|
||||
// 不释放不会泄漏内存(视图只持有几个字符串),但**残留的 longtime 档位**
|
||||
// 会让同名重建的 agent 继承上一代的约束 —— 与主体策略的撤销同一形态。
|
||||
type AgentScope struct {
|
||||
rt *Runtime
|
||||
agentID string
|
||||
isChild bool
|
||||
|
||||
mu sync.RWMutex
|
||||
// sessionProfile 是本轮输入声明的档位(End 清空)。
|
||||
sessionProfile string
|
||||
// longtimeProfile 是跨轮保留的档位(显式设置/清除)。
|
||||
longtimeProfile string
|
||||
// sessionExpires 是 session 档位的 TTL 到期时刻(0 = 不过期)。
|
||||
//
|
||||
// 为什么 session 也要 TTL:输入到达后若轮次异常终止(panic/取消),
|
||||
// `End()` 可能没跑到。TTL 是兜底 —— 避免档位永远卡在某一轮的值上。
|
||||
sessionExpires time.Time
|
||||
}
|
||||
|
||||
// ForAgent 为一个 agent 创建策略执行视图(幂等:同一 id 返回同一个)。
|
||||
//
|
||||
// 幂等很重要:`BeginRound` 在每条输入上都会走一遍,若每次新建视图,
|
||||
// `longtimeProfile` 会在下一轮凭空消失。
|
||||
func (r *Runtime) ForAgent(agentID string, isChild bool) *AgentScope {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.scopeMu.Lock()
|
||||
defer r.scopeMu.Unlock()
|
||||
if r.scopesByAgent == nil {
|
||||
r.scopesByAgent = map[string]*AgentScope{}
|
||||
}
|
||||
if sc, ok := r.scopesByAgent[agentID]; ok {
|
||||
return sc
|
||||
}
|
||||
sc := &AgentScope{rt: r, agentID: agentID, isChild: isChild}
|
||||
r.scopesByAgent[agentID] = sc
|
||||
return sc
|
||||
}
|
||||
|
||||
// ReleaseAgent 销毁一个 agent 的视图(含它的 longtime 档位)。
|
||||
//
|
||||
// ★ 必须调:agent/子 agent 回收后同名重建是常规操作(`create id="bot1"`),
|
||||
// 残留的 longtime 档会让新一代继承上一代父留的约束。
|
||||
// 与 `RevokePrincipalPolicy` 同一纪律。
|
||||
func (r *Runtime) ReleaseAgent(agentID string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.scopeMu.Lock()
|
||||
delete(r.scopesByAgent, agentID)
|
||||
r.scopeMu.Unlock()
|
||||
}
|
||||
|
||||
// BeginRound 开始一轮:由 security 自己从原始载荷解析 session 档位。
|
||||
//
|
||||
// 与 `Runtime.BeginRound` 的区别:这个版本的状态落在 **per-agent 视图**上,
|
||||
// 不污染其他 agent。
|
||||
func (sc *AgentScope) BeginRound(in RoundInput) (*Round, error) {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return &Round{}, nil
|
||||
}
|
||||
round := &Round{
|
||||
rt: sc.rt,
|
||||
scope: sc,
|
||||
principal: sc.rt.classifyPrincipal(in),
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(roundProfileName(in.Payload))
|
||||
if name == "" {
|
||||
// 不带档位 = 这一轮显式不使用 session 档。
|
||||
// ★ 只清 session,**不动** longtime —— 后者是 agent 的生命状态。
|
||||
// 所以这里 effective 仍可能是 longtime 档。
|
||||
sc.clearSession()
|
||||
round.profile = sc.EffectiveProfile()
|
||||
return round, nil
|
||||
}
|
||||
|
||||
if err := sc.setSession(name); err != nil {
|
||||
// 响亮但不断流;同时**清掉**上一轮的 session(不能沿用旧档)。
|
||||
sc.clearSession()
|
||||
log.Printf("[security] agent %q round profile %q rejected: %v "+
|
||||
"(falling back to long-term profile %q / default policy)",
|
||||
sc.agentID, name, err, sc.LongtimeProfile())
|
||||
round.profile = sc.EffectiveProfile() // 降级后可能是 longtime 档
|
||||
return round, err
|
||||
}
|
||||
// Round.profile 报告**本轮生效的档位**(session 优先,其次 longtime)。
|
||||
round.profile = sc.EffectiveProfile()
|
||||
return round, nil
|
||||
}
|
||||
|
||||
// setSession 设置本轮档位(校验档位已声明)。
|
||||
func (sc *AgentScope) setSession(name string) error {
|
||||
if _, ok := sc.rt.profiles.Get(name); !ok {
|
||||
return &policyErr{fmt.Sprintf(
|
||||
"security: 未声明的安全档位 %q(已声明: %v)—— 拼错不会退化,直接报错",
|
||||
name, sc.rt.profiles.Names())}
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.sessionProfile = name
|
||||
sc.sessionExpires = time.Now().Add(scopeTTLDefault)
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearSession 清除本轮档位(不影响 longtime)。
|
||||
func (sc *AgentScope) clearSession() {
|
||||
if sc == nil {
|
||||
return
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.sessionProfile = ""
|
||||
sc.sessionExpires = time.Time{}
|
||||
sc.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetLongtimeProfile 设置跨轮保留的档位(空串 = 清除)。
|
||||
//
|
||||
// 用途:把一个驻留子**长期**派驻为某个权限档(如"访客权限的数字助手"),
|
||||
// 不必每条输入都声明。
|
||||
//
|
||||
// 校验与 session 一致:未声明的档位名返回错误,不静默接受。
|
||||
func (sc *AgentScope) SetLongtimeProfile(name string) error {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return nil
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
sc.mu.Lock()
|
||||
sc.longtimeProfile = ""
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if _, ok := sc.rt.profiles.Get(name); !ok {
|
||||
return &policyErr{fmt.Sprintf(
|
||||
"security: 未声明的安全档位 %q(已声明: %v)", name, sc.rt.profiles.Names())}
|
||||
}
|
||||
sc.mu.Lock()
|
||||
sc.longtimeProfile = name
|
||||
sc.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// LongtimeProfile 返回跨轮档位名(空 = 未设置)。
|
||||
func (sc *AgentScope) LongtimeProfile() string {
|
||||
if sc == nil {
|
||||
return ""
|
||||
}
|
||||
sc.mu.RLock()
|
||||
defer sc.mu.RUnlock()
|
||||
return sc.longtimeProfile
|
||||
}
|
||||
|
||||
// SessionProfile 返回本轮档位名(空 = 本轮未声明;已过期视为空)。
|
||||
func (sc *AgentScope) SessionProfile() string {
|
||||
if sc == nil {
|
||||
return ""
|
||||
}
|
||||
sc.mu.RLock()
|
||||
name, exp := sc.sessionProfile, sc.sessionExpires
|
||||
sc.mu.RUnlock()
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
if !exp.IsZero() && time.Now().After(exp) {
|
||||
return "" // TTL 兜底:End 没跑到也不至于永远卡住
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// EffectiveProfile 返回本轮**实际生效**的档位:session > longtime > 空。
|
||||
//
|
||||
// ★ 这个优先级本身是安全语义,写在这里而不是调用方:
|
||||
//
|
||||
// session 是"这一轮的事实",必须能盖住 long-term 默认值 ——
|
||||
// 否则访客发一条消息也无法在长期为 owner 的 agent 上收窄权限。
|
||||
//
|
||||
// 反向(longtime 盖 session)就是提权:长期档 owner 会把
|
||||
// 访客这一轮声明的 guest 吞掉 ⇒ 访客拿到 owner 权限。
|
||||
func (sc *AgentScope) EffectiveProfile() string {
|
||||
if s := sc.SessionProfile(); s != "" {
|
||||
return s
|
||||
}
|
||||
return sc.LongtimeProfile()
|
||||
}
|
||||
|
||||
// CheckTool 判定某工具在**本 agent 当前状态**下是否可用。
|
||||
//
|
||||
// 它把三件事合成一次调用,调用方不需要知道任何组合规则:
|
||||
//
|
||||
// ① 全局基线(policy)
|
||||
// ② 生效档位(session 优先,其次 longtime)
|
||||
// ③ 主体策略(父为子声明的 Allow/Deny)
|
||||
func (sc *AgentScope) CheckTool(tool string) Decision {
|
||||
if sc == nil || sc.rt == nil {
|
||||
return allowed()
|
||||
}
|
||||
return sc.rt.checkToolForWithProfile(sc.rt.classifyScopePrincipal(sc.agentID, sc.isChild),
|
||||
sc.EffectiveProfile(), tool)
|
||||
}
|
||||
166
internal/security/agentscope_test.go
Normal file
166
internal/security/agentscope_test.go
Normal file
@ -0,0 +1,166 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
// ★★★ 判据:**两个 agent 的 session 档互不干扰**(2026-10-31 用户裁定)。
|
||||
//
|
||||
// ## 防的失败方式
|
||||
//
|
||||
// 收敛前档位挂在 `Runtime.active`(进程级单例)上,多 agent 并发时串档:
|
||||
//
|
||||
// 主 agent 轮次 A:注入 qq.guest → active = "qq.guest"
|
||||
// 驻留子 轮次 B:注入 qq.owner → active = "qq.owner" ← 覆盖 A
|
||||
// 主 agent 轮次 A 继续执行工具 → 判定按 owner 走 ← 串档
|
||||
//
|
||||
// 这不是理论风险:驻留子与 rtchild 与主 agent 同进程并发。
|
||||
func TestAgentScopesDoNotShareSessionProfile(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
for _, p := range []Profile{
|
||||
{Name: "probe.owner", Owner: "probe"},
|
||||
{Name: "probe.guest", Owner: "probe",
|
||||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}}},
|
||||
} {
|
||||
if err := rt.DeclareProfile(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
main := rt.ForAgent("main", false)
|
||||
child := rt.ForAgent("kid", true)
|
||||
|
||||
rm, err := main.BeginRound(RoundInput{AgentID: "main", Payload: map[string]interface{}{
|
||||
"security_profile": "probe.guest",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rm.End()
|
||||
|
||||
rc, err := child.BeginRound(RoundInput{AgentID: "kid", IsChild: true, Payload: map[string]interface{}{
|
||||
"security_profile": "probe.owner",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rc.End()
|
||||
|
||||
// ① 各自的生效档位必须是自己的。
|
||||
if got := main.EffectiveProfile(); got != "probe.guest" {
|
||||
t.Fatalf("主 agent 的档位应是 probe.guest,实际 %q —— 被别的 agent 覆盖了", got)
|
||||
}
|
||||
if got := child.EffectiveProfile(); got != "probe.owner" {
|
||||
t.Fatalf("子 agent 的档位应是 probe.owner,实际 %q —— 被别的 agent 覆盖了", got)
|
||||
}
|
||||
|
||||
// ② 行为上:主 agent 仍被 guest 收窄,子 agent 不受影响。
|
||||
if d := main.CheckTool("cmd_run"); d.Allowed {
|
||||
t.Fatal("★ 主 agent 的 guest 档没生效 —— 它被别的 agent 的 owner 档盖掉了")
|
||||
}
|
||||
if d := child.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("子 agent 是 owner 档,不该被 guest 的收窄影响: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:**longtimeProfile 跨轮保留**,sessionProfile 随轮结束清除。
|
||||
//
|
||||
// 这是用户裁定的两层语义:
|
||||
//
|
||||
// sessionProfile —— 属于这一轮,End 即清
|
||||
// longtimeProfile —— 属于 agent 的生命,跨轮保留
|
||||
//
|
||||
// 优先级:session > longtime(session 是"这一轮的事实",必须能收窄)。
|
||||
func TestLongtimeProfileSurvivesRounds(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
for _, p := range []Profile{
|
||||
{Name: "probe.guest", Owner: "probe",
|
||||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}}},
|
||||
{Name: "probe.owner", Owner: "probe"},
|
||||
} {
|
||||
if err := rt.DeclareProfile(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
sc := rt.ForAgent("bot1", true)
|
||||
if err := sc.SetLongtimeProfile("probe.guest"); err != nil {
|
||||
t.Fatalf("设置 long-time 档失败: %v", err)
|
||||
}
|
||||
|
||||
// 第一轮:没带 session 档 ⇒ 用 long-time。
|
||||
r1, _ := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true})
|
||||
if got := r1.EffectiveProfile(); got != "probe.guest" {
|
||||
t.Fatalf("无 session 档时应落到 long-time 档,实际 %q", got)
|
||||
}
|
||||
r1.End()
|
||||
|
||||
// 第二轮:仍然有 long-time(不能因为 End 就丢了)。
|
||||
r2, _ := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true})
|
||||
defer r2.End()
|
||||
if got := r2.EffectiveProfile(); got != "probe.guest" {
|
||||
t.Fatalf("★ long-time 档在轮次结束后丢了(%q)—— 它应跨轮保留", got)
|
||||
}
|
||||
|
||||
// 第三轮:带 session 档 ⇒ session 优先(必须能收窄或放宽)。
|
||||
r3, err := sc.BeginRound(RoundInput{AgentID: "bot1", IsChild: true,
|
||||
Payload: map[string]interface{}{"security_profile": "probe.owner"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r3.EffectiveProfile(); got != "probe.owner" {
|
||||
t.Fatalf("session 档应优先于 long-time 档,实际 %q", got)
|
||||
}
|
||||
// ★ 反向最危险:若 longtime 优先,长期 owner 的 agent 上访客声明的
|
||||
// guest 会被吞掉 ⇒ 访客拿到 owner 权限。
|
||||
if d := r3.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("★ session=owner 时 cmd_* 应放行;若被 long-time 的 guest 卡住,"+
|
||||
"说明优先级写反了(反之则更危险): %s", d.Reason)
|
||||
}
|
||||
r3.End()
|
||||
|
||||
// 归位:session 清了,long-time 还在。
|
||||
if got := sc.EffectiveProfile(); got != "probe.guest" {
|
||||
t.Fatalf("session 结束后应回落到 long-time 档,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:agent 释放后 long-time 档**不得残留**。
|
||||
//
|
||||
// agent/子 agent 回收后同名重建是常规操作(`create id="bot1"`)。
|
||||
// 残留会让新一代继承上一代父留的约束 —— 与主体策略撤销同一纪律。
|
||||
func TestReleaseAgentDropsLongtimeProfile(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
if err := rt.DeclareProfile(Profile{
|
||||
Name: "probe.guest", Owner: "probe",
|
||||
Policy: PrincipalPolicy{DenyTools: []string{"cmd_*"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sc := rt.ForAgent("bot1", true)
|
||||
if err := sc.SetLongtimeProfile("probe.guest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt.ReleaseAgent("bot1")
|
||||
|
||||
// 同名重建:必须是干净的视图。
|
||||
fresh := rt.ForAgent("bot1", true)
|
||||
if got := fresh.LongtimeProfile(); got != "" {
|
||||
t.Fatalf("★ 同名重建的 agent 继承了上一代的 long-time 档 %q —— "+
|
||||
"新一代用上一代父留的约束运行", got)
|
||||
}
|
||||
if d := fresh.CheckTool("cmd_run"); !d.Allowed {
|
||||
t.Fatalf("重建后的 agent 应无收窄,实际被拒: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:未声明的档位名必须报错,且**不沿用**上一个档。
|
||||
func TestLongtimeProfileRejectsUndeclared(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
sc := rt.ForAgent("bot1", false)
|
||||
if err := sc.SetLongtimeProfile("probe.typo"); err == nil {
|
||||
t.Fatal("★ 未声明的档位名被接受了 —— 拼写错误会静默变成'无档位'")
|
||||
}
|
||||
if got := sc.LongtimeProfile(); got != "" {
|
||||
t.Fatalf("设置失败后不应留下任何档位,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@ -27,25 +27,85 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PrincipalKind 是主体**类别**。内核侧固定枚举,不接受自由字符串。
|
||||
//
|
||||
// ## 为什么必须是枚举而不是 string(2026-10-31 收敛)
|
||||
//
|
||||
// 原先 `Principal.Kind` 是裸 `string`,而实际填充点只有一处:
|
||||
// `agent/core/security_profile.go` 里写死的 `Kind: "resident"`。
|
||||
// 那意味着:
|
||||
//
|
||||
// ① **判定逻辑散在两侧**:`IsInternal()` 在 security 里靠字符串比较,
|
||||
// 而“我该算哪一类”由调用方(agent)决定 —— 调用方说自己是内部,
|
||||
// 就享受内部主体的豁免(不过 HardPrivateTools)。那是提权口子。
|
||||
// ② **拼错不报错**:写成 "residnet" ⇒ IsInternal 为 false ⇒
|
||||
// 静默升级成用户主体语义(反方向)或静默降级。
|
||||
//
|
||||
// 收敛后:类别由**security 自己根据可信事实决定**(谁在调、有没有父),
|
||||
// 调用方只能传事实,不能传结论。
|
||||
type PrincipalKind string
|
||||
|
||||
const (
|
||||
// KindUnset 表示未分类。**不要**用它做任何判定 —— 它是零值,
|
||||
// 只用于“调用方没给”这一情形,由 security 自己补全。
|
||||
KindUnset PrincipalKind = ""
|
||||
// KindInternal 是内核自身发起的调用(模块、内置定时任务)。
|
||||
KindInternal PrincipalKind = "internal"
|
||||
// KindSubAgent 是内核派出的子执行体(驻留子 / rtchild / 工作式子)。
|
||||
//
|
||||
// ★ 与 KindInternal 的区别:子执行体**是**内部委派(不该被
|
||||
// HardPrivateTools 拦),但它们是长期自主运行的 —— 父可以在创建时
|
||||
// 为它们**收窄**权限(AllowTools / DenyTools),而内核自身不受收窄。
|
||||
KindSubAgent PrincipalKind = "subagent"
|
||||
// KindUser 是终端用户(通过插件通道进来的真实用户)。
|
||||
KindUser PrincipalKind = "user"
|
||||
// KindGroup 是群/频道类主体。
|
||||
KindGroup PrincipalKind = "group"
|
||||
)
|
||||
|
||||
// ParsePrincipalKind 把外部字符串解析成枚举(仅用于默认实现兼容)。
|
||||
//
|
||||
// 未识别的值返回 KindUnset 与 false:**不静默回退到某个默认类别** ——
|
||||
// 那会让拼写错误变成权限语义漂移(与“未知档位名不静默放行”同一条纪律)。
|
||||
func ParsePrincipalKind(s string) (PrincipalKind, bool) {
|
||||
switch PrincipalKind(s) {
|
||||
case KindInternal, KindSubAgent, KindUser, KindGroup:
|
||||
return PrincipalKind(s), true
|
||||
// 兼容旧调用方写过的 "resident":语义等价于 KindSubAgent。
|
||||
case "resident":
|
||||
return KindSubAgent, true
|
||||
}
|
||||
return KindUnset, false
|
||||
}
|
||||
|
||||
// Principal 是一次调用的**主体**:谁在请求。
|
||||
type Principal struct {
|
||||
// ID 是主体标识(QQ 用户 id、群 id、驻留子 id、内部标记)。
|
||||
ID string
|
||||
// Kind 是主体类别("user" / "group" / "internal" / "resident" …)。
|
||||
// Kind 是主体类别(固定枚举,见上方常量)。
|
||||
//
|
||||
// ★ 为什么不只看 ID:内部委派与用户请求的 ID 可能相同
|
||||
//(驻留子继承父的输入事件),但**权限模型完全不同** ——
|
||||
// 这正是 QQ 那条 `isInternalToolCall` 分支存在的原因。
|
||||
Kind string
|
||||
// ★ 为何不只看 ID:内部委派与用户请求的 ID 可能相同
|
||||
//(驻留子继承父的输入事件),但**权限模型完全不同**。
|
||||
Kind PrincipalKind
|
||||
// Tags 是附加标签(如 "admin"、"trusted"),策略可按标签授予。
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// IsInternal 报告是否内部主体(agent 体系自己发起)。
|
||||
// IsInternal 报告是否内部主体(内核自身或内核派出的子执行体)。
|
||||
//
|
||||
// 语义:内部主体**不受** HardPrivateTools 限制 —— 那条规则防的是
|
||||
// “用户偷看私人数据”,而内部委派本就有完整权限(QQ 的实测教训:
|
||||
// 再拦一次等于“机器人自己派出去的活被自己拦住”)。
|
||||
func (p Principal) IsInternal() bool {
|
||||
return p.Kind == "internal" || p.Kind == "resident"
|
||||
return p.Kind == KindInternal || p.Kind == KindSubAgent
|
||||
}
|
||||
|
||||
// IsSubAgent 报告是否内核派出的子执行体。
|
||||
//
|
||||
// 与 IsInternal 分开的理由:子执行体**可以**被父收窄(Allow/Deny),
|
||||
// 而内核自身不受收窄。两者共用 HardPrivateTools 豁免,但不共用收窄语义。
|
||||
func (p Principal) IsSubAgent() bool { return p.Kind == KindSubAgent }
|
||||
|
||||
// PrincipalPolicy 是某个主体的能力策略。
|
||||
type PrincipalPolicy struct {
|
||||
// AllowTools 是该主体**可用**的工具模式(空 = 不额外限制)。
|
||||
@ -94,6 +154,28 @@ func (r *principalRegistry) SetByTag(tag string, p PrincipalPolicy) {
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveByID 撤销某个主体的策略声明。
|
||||
//
|
||||
// ## 为什么必须有它(2026-10-30)
|
||||
//
|
||||
// 主体 ID 对**子 agent** 来说是脚本给的(`ring_agents create id="bot1"`)。
|
||||
// 子被回收后同名重建是常规操作,而若旧策略留在表里:
|
||||
//
|
||||
// 新一代用**上一代父当时留的约束**运行 —— 而那个父可能已经不在了。
|
||||
//
|
||||
// 这与今天修的 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)
|
||||
// 是**同一种形态**:以调用方给的名称为键的表,必须在生命周期结束时清理。
|
||||
//
|
||||
// 不清理还会单调增长(每建一个不同名字的子就多一条永不释放的 Polciy)。
|
||||
func (r *principalRegistry) RemoveByID(id string) {
|
||||
if r == nil || strings.TrimSpace(id) == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
delete(r.byID, id)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// lookup 取出与主体相关的全部策略(ID + 每个标签)。
|
||||
func (r *principalRegistry) lookup(p Principal) []PrincipalPolicy {
|
||||
if r == nil {
|
||||
@ -134,6 +216,32 @@ func (r *Runtime) DeclarePrincipalPolicy(scope, key string, p PrincipalPolicy) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokePrincipalPolicy 撤销一个按 ID 声明的主体策略(合规性:子 agent 销毁时调)。
|
||||
//
|
||||
// 只支持 scope="id":标签策略属于**部署级**声明(如 tag=external),
|
||||
// 它们的生命周期不跟随某个具体执行体,撤销只能由声明方自己做。
|
||||
func (r *Runtime) RevokePrincipalPolicy(scope, key string) error {
|
||||
if r == nil || r.principals == nil {
|
||||
return errNoRuntime
|
||||
}
|
||||
if scope != "id" {
|
||||
return &policyErr{"security: revoke only supports scope=\"id\" (tag policies are deployment-level), got " + scope}
|
||||
}
|
||||
r.principals.RemoveByID(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasPrincipalPolicy 报告某 ID 是否已声明主体策略(诊断/状态面用)。
|
||||
func (r *Runtime) HasPrincipalPolicy(id string) bool {
|
||||
if r == nil || r.principals == nil {
|
||||
return false
|
||||
}
|
||||
r.principals.mu.RLock()
|
||||
defer r.principals.mu.RUnlock()
|
||||
_, ok := r.principals.byID[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// CheckToolFor 按**主体**判定工具是否可用。
|
||||
//
|
||||
// ★ 这是替代"插件在 stage 里自己拦"的入口:插件只声明策略,
|
||||
@ -142,8 +250,22 @@ func (r *Runtime) CheckToolFor(p Principal, tool string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
// ① 全局策略(deny 优先)。
|
||||
if d := r.CheckTool(tool); !d.Allowed {
|
||||
// 用进程级档位(旧路径)。per-agent 视图走 checkToolForWithProfile。
|
||||
return r.checkToolForWithProfile(p, r.ActiveProfile(), tool)
|
||||
}
|
||||
|
||||
// checkToolForWithProfile 是**主体判定 + 指定档位**的组合实现。
|
||||
//
|
||||
// ★ 为什么必须与档位一起传(2026-10-31):
|
||||
// per-agent 视图的档位是**它自己的**(session/longtime)。若这里回头
|
||||
// 调 `r.CheckTool(tool)`(读进程级 active),per-agent 的档位就被丢了 ——
|
||||
// 表现为「档位设了但不生效」,而全局 active 恰好为空时看不出问题。
|
||||
func (r *Runtime) checkToolForWithProfile(p Principal, profileName, tool string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
// ① 全局策略 + 档位(deny 优先)。
|
||||
if d := r.checkToolWithProfile(p, profileName, tool); !d.Allowed {
|
||||
return d
|
||||
}
|
||||
pols := r.principals.lookup(p)
|
||||
|
||||
65
internal/security/principal_test.go
Normal file
65
internal/security/principal_test.go
Normal file
@ -0,0 +1,65 @@
|
||||
package security
|
||||
|
||||
import "testing"
|
||||
|
||||
// ★★★ 判据:主体策略可**撤销**,且撤销后不影响其他主体。
|
||||
//
|
||||
// ## 为什么必须能撤销(2026-10-30)
|
||||
//
|
||||
// 主体 ID 对子 agent 来说是**调用方给的名字**(`create id="bot1"`)。
|
||||
// 子被回收后同名重建是常规操作,而若旧策略留在表里:
|
||||
//
|
||||
// 新一代用上一代父当时留的约束运行 —— 而那个父可能已经不在了。
|
||||
//
|
||||
// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键、
|
||||
// 全仓 0 处删除)是**同一种形态**:以调用方给的名称为键的表,
|
||||
// 必须在生命周期结束时清理,否则既是语义错误也是单调增长。
|
||||
func TestPrincipalPolicyCanBeRevoked(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
if err := rt.DeclarePrincipalPolicy("id", "child-x", PrincipalPolicy{
|
||||
DenyTools: []string{"cmd_*"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 另声明一个,用来证明撤销不会误伤别人。
|
||||
if err := rt.DeclarePrincipalPolicy("id", "child-y", PrincipalPolicy{
|
||||
DenyTools: []string{"cmd_*"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := Principal{ID: "child-x", Kind: "resident"}
|
||||
if d := rt.CheckToolFor(p, "cmd_run"); d.Allowed {
|
||||
t.Fatal("前置:已声明的 deny 应生效")
|
||||
}
|
||||
|
||||
if err := rt.RevokePrincipalPolicy("id", "child-x"); err != nil {
|
||||
t.Fatalf("撤销失败: %v", err)
|
||||
}
|
||||
if d := rt.CheckToolFor(p, "cmd_run"); !d.Allowed {
|
||||
t.Fatal("★ 撤销后仍被禁 —— 同名重建的子会继承上一代父留下的约束")
|
||||
}
|
||||
// 隔离:另一个主体的策略必须还在。
|
||||
if d := rt.CheckToolFor(Principal{ID: "child-y", Kind: "resident"}, "cmd_run"); d.Allowed {
|
||||
t.Fatal("★ 撤销 child-x 时误伤了 child-y —— 撤销必须只作用于指定 ID")
|
||||
}
|
||||
}
|
||||
|
||||
// ★★★ 判据:撤销只支持 scope="id"(标签策略是部署级声明)。
|
||||
//
|
||||
// 允许撤销 tag 会让一个执行体的销毁顺手抹掉全站策略 —— 那是提权口子。
|
||||
func TestRevokeRejectsTagScope(t *testing.T) {
|
||||
rt := NewRuntime(nil)
|
||||
if err := rt.DeclarePrincipalPolicy("tag", "external", PrincipalPolicy{
|
||||
HardPrivateTools: []string{"calendar_*"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.RevokePrincipalPolicy("tag", "external"); err == nil {
|
||||
t.Fatal("★ 支持撤销 tag 策略 = 任何执行体销毁时都能抹掉全站策略")
|
||||
}
|
||||
// 原策略仍在。
|
||||
if d := rt.CheckToolFor(Principal{ID: "u1", Kind: "user", Tags: []string{"external"}}, "calendar_list"); d.Allowed {
|
||||
t.Fatal("tag 策略应仍然生效")
|
||||
}
|
||||
}
|
||||
@ -242,8 +242,20 @@ func TTLSeconds(n int) time.Duration {
|
||||
|
||||
// profilePolicy 取当前活跃档位的策略(无档位时返回零值 + false)。
|
||||
func (r *Runtime) profilePolicy() (Profile, bool) {
|
||||
name := r.ActiveProfile()
|
||||
if name == "" || r.profiles == nil {
|
||||
return r.profilePolicyByName(r.ActiveProfile())
|
||||
}
|
||||
|
||||
// profilePolicyByName 按**指定**档位名取策略(per-agent 视图用)。
|
||||
//
|
||||
// ★ 与 profilePolicy 的分工:
|
||||
//
|
||||
// profilePolicy —— 读进程级 active(旧路径,单执行体场景)
|
||||
// profilePolicyByName —— 由调用方给出档位(per-agent 视图必须走这条)
|
||||
//
|
||||
// 之所以要拆:per-agent 视图的档位是**它自己的** session/longtime,
|
||||
// 不能去读 `Runtime.active` —— 那样多 agent 并发时仍会串档。
|
||||
func (r *Runtime) profilePolicyByName(name string) (Profile, bool) {
|
||||
if name == "" || r == nil || r.profiles == nil {
|
||||
return Profile{}, false
|
||||
}
|
||||
return r.profiles.Get(name)
|
||||
|
||||
319
internal/security/round.go
Normal file
319
internal/security/round.go
Normal file
@ -0,0 +1,319 @@
|
||||
package security
|
||||
|
||||
// 轮次上下文(RoundContext):**security 对外的输入接入点**。
|
||||
//
|
||||
// ## 为什么需要这一层(2026-10-31 收敛)
|
||||
//
|
||||
// 收敛前的形态:security 只暴露细粒度判定原语(`CheckTool` / `CheckToolFor` /
|
||||
// `ActivateProfile`),于是**每个消费方都得自己理解策略语义**:
|
||||
//
|
||||
// agent/core/security_profile.go 里曾这样写:
|
||||
//
|
||||
// name, _ := payload["security_profile"].(string) // 自己抠字段
|
||||
// a.security.ActivateProfile(name, 0) // 再告诉 security
|
||||
// if a.parentID != "" { // 自己判断走哪条链
|
||||
// return a.security.CheckToolFor(Principal{Kind: "resident"}, tool)
|
||||
// }
|
||||
// return a.security.CheckTool(tool)
|
||||
//
|
||||
// 三个问题全出在这段:
|
||||
//
|
||||
// ① `security_profile` 是 security 自己的协议概念,解析却在外人手里
|
||||
// ② “我是父还是子”是策略语义,判断却在 agent 手里
|
||||
// ③ `Kind: "resident"` 是调用方**声明自己的类别** ⇒ 说自己是内部就享受
|
||||
// 内部豁免,那是提权口子
|
||||
//
|
||||
// ## 本文件确立的形态
|
||||
//
|
||||
// 调用方只提供**事实**(谁在跑、有没有父、原始载荷)
|
||||
// security 自己做**结论**(类别、档位、走哪条判定链)
|
||||
//
|
||||
// 对应 EventBus 那个已经跑通的范例:总线只交事件,guard 自己判。
|
||||
//
|
||||
// events.Bus.Publish → Guard.AllowPublish(Publisher, EventType) → 判定在 guard 内
|
||||
//
|
||||
// ## 生命周期
|
||||
//
|
||||
// rc, err := rt.BeginRound(RoundInput{...}) // 输入到达
|
||||
// defer rc.End() // 轮次结束(归位,防跨轮泄漏)
|
||||
// rc.CheckTool(name) // 本轮的判定(只含本轮的档位/主体)
|
||||
//
|
||||
// `End` 必须调:档位在 `Runtime` 上是**进程级状态**,不归位就会泄漏到下一轮
|
||||
// (guest 的收窄误伤后续输入;方向相反就是提权)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RoundInput 是一次「输入到达」时调用方能提供的**全部事实**。
|
||||
//
|
||||
// ★ 这里刻意**不含 Kind**:类别由 security 自己从 AgentID/IsChild 推。
|
||||
// 调用方给不出“我是内部主体”这种结论 —— 它只能说自己是谁。
|
||||
type RoundInput struct {
|
||||
// AgentID 是执行本次输入的执行体标识(主 agent = "main",子 = 子的 id)。
|
||||
AgentID string
|
||||
|
||||
// IsChild 表示本次输入由**某个父 agent 派出的子执行体**处理。
|
||||
//
|
||||
// 这是调用方能提供的事实(驻留子/rtchild 自己知道有父),
|
||||
// 而“子属于哪一类主体、该不该豁免 HardPrivate”由 security 决定。
|
||||
IsChild bool
|
||||
|
||||
// Payload 是**原始输入载荷**(`InputEvent.Payload`)。
|
||||
//
|
||||
// ★ security 自己从中解析 `security_profile` —— 那是 security 的协议,
|
||||
// 不该让每个消费方各写一遍解析。字段名定义见 `profileKey`。
|
||||
Payload map[string]interface{}
|
||||
|
||||
// Source 是来源通道名(诊断与审计用,不参与判定)。
|
||||
Source string
|
||||
}
|
||||
|
||||
// profileKey 是输入载荷里携带命名档位的键名。
|
||||
//
|
||||
// 与公开 SDK 的 `InjectOptions.SecurityProfile`(JSON 名 `security_profile`)
|
||||
// **逐字一致** —— 那是插件与 security 之间的协议,解析权属于 security。
|
||||
const profileKey = "security_profile"
|
||||
|
||||
// Round 是一轮的策略上下文句柄。
|
||||
//
|
||||
// 它把「本轮生效的档位」与「本轮的主体」绑在一起,使判定成为**纯函数式**的
|
||||
// 一次调用,调用方不再需要知道任何组合规则。
|
||||
type Round struct {
|
||||
rt *Runtime
|
||||
// scope 是**该 agent 独有的**策略执行视图(2026-10-31 起的正路)。
|
||||
//
|
||||
// 为 nil 表示走旧的进程级单例路径(Round.End 会恢复 Runtime.active)。
|
||||
scope *AgentScope
|
||||
// principal 是 security **自己判定**出来的主体(不是调用方声明的)。
|
||||
principal Principal
|
||||
// profile 是本轮生效的档位名(空 = 未使用档位)。
|
||||
profile string
|
||||
// restored 仅在 scope == nil 时使用:记录进程级 active 的进入值。
|
||||
//
|
||||
// ★ 为什么旧的进程级路径要"恢复"而不是"清空":那时多个 agent
|
||||
// 共享一个 active,清空会把别处正在用的档抹掉。per-agent 视图
|
||||
// 没有这个问题(每个 agent 自己一份),所以走 scope 时直接清 session。
|
||||
restored string
|
||||
ended bool
|
||||
}
|
||||
|
||||
// CheckToolForAgent 按**执行体身份**判定工具是否可用(无轮次句柄时的入口)。
|
||||
//
|
||||
// ## 什么时候用它
|
||||
//
|
||||
// 工具执行链(`executeToolCallInner`)只持有执行体身份(id + 有无父),
|
||||
// 不持有本轮句柄。此时由 security **自己**从事实推出主体类别,
|
||||
// 而不是让调用方声明 `Kind` —— 后者是提权口子。
|
||||
//
|
||||
// 与 `BeginRound` + `Round.CheckTool` 的区别:
|
||||
//
|
||||
// BeginRound 路线 —— 带档位(本轮激活的 profile 参与判定)
|
||||
// 本函数 —— 不带档位处理,只看全局策略 + 主体策略
|
||||
//
|
||||
// ★ 为什么不用把档位也算进来:档位是**输入**携带的(`security_profile`),
|
||||
// 它属于某一轮的事实。工具执行链上没有轮次上下文,硬塞一个“进程当前档”
|
||||
// 才是不对的(那正是收敛前 `ActivateProfile` 全局单例的毛病)。
|
||||
// 真正的修法是让执行链拿到本轮句柄 —— 见 future-plan。
|
||||
func (r *Runtime) CheckToolForAgent(agentID string, isChild bool, tool string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
return r.CheckToolFor(r.classifyPrincipal(RoundInput{
|
||||
AgentID: agentID,
|
||||
IsChild: isChild,
|
||||
}), tool)
|
||||
}
|
||||
|
||||
// BeginRound 在**一条输入开始处理时**调用,由 security 完成:
|
||||
//
|
||||
// ① 判定主体类别(据 AgentID + IsChild,不信调用方声明)
|
||||
// ② 从原始 Payload 解析 `security_profile`
|
||||
// ③ 激活档位(未声明/拼错时**响亮降级**,不静默沿用上一轮的档)
|
||||
//
|
||||
// 返回的 `Round` 必须 `End()`(defer 即可)。
|
||||
//
|
||||
// ★ 为什么档位解析在这里而不是在调用方:
|
||||
// 档位名是 `security` 的协议(`Profile.Name` 约定 `<owner>.<档位>`)。
|
||||
// 把它交给调用方解析,等于让 N 个调用方各写一遍,且每写一遍都可能漏。
|
||||
func (r *Runtime) BeginRound(in RoundInput) (*Round, error) {
|
||||
if r == nil {
|
||||
return &Round{}, nil // 未装配 security:零成本句柄
|
||||
}
|
||||
// ★ 正路:per-agent 视图(每个 agent 一份 session 档,互不干扰)。
|
||||
//
|
||||
// 2026-10-31 用户裁定「每个 agent 一个,分为 sessionProfile 和
|
||||
// longtimeProfile」后,这里的档位状态落在 `AgentScope` 上,
|
||||
// 不再写进程级 `Runtime.active`。
|
||||
sc := r.ForAgent(in.AgentID, in.IsChild)
|
||||
return sc.BeginRound(in)
|
||||
}
|
||||
|
||||
// roundProfileName 从原始载荷里取档位名。
|
||||
//
|
||||
// 独立成小函数是为了让“解析规则只有一处”这件事在代码上可见:
|
||||
// 任何调用方都不该再写 `payload["security_profile"]`。
|
||||
func roundProfileName(payload map[string]interface{}) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := payload[profileKey].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// classifyPrincipal 由 security **自己**判定主体类别。
|
||||
//
|
||||
// ## 判定规则(唯一事实来源)
|
||||
//
|
||||
// IsChild ⇒ KindSubAgent (内核派出的子执行体)
|
||||
// AgentID == "" ⇒ KindInternal (内核自身发起的自循环任务)
|
||||
// 其他 ⇒ KindUser (经插件通道进来的终端用户)
|
||||
//
|
||||
// ★ 为什么"子"优先于"内核自身":
|
||||
// 子执行体也是内部委派(共享 HardPrivate 豁免),但它**可以被父收窄**。
|
||||
// 若先判成 KindInternal,父在创建时声明的 Allow/Deny 就落不到它头上 ——
|
||||
// 那正是收敛前 `Kind: "resident"` 掩盖的问题(它同时意味着
|
||||
// “我是内部”和“我不受收窄”)。
|
||||
func (r *Runtime) classifyPrincipal(in RoundInput) Principal {
|
||||
p := Principal{ID: in.AgentID}
|
||||
switch {
|
||||
case in.IsChild:
|
||||
p.Kind = KindSubAgent
|
||||
case strings.TrimSpace(in.AgentID) == "":
|
||||
p.Kind = KindInternal
|
||||
default:
|
||||
p.Kind = KindUser
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Principal 返回本轮的主体(security 判定出的,只读)。
|
||||
func (rc *Round) Principal() Principal {
|
||||
if rc == nil {
|
||||
return Principal{}
|
||||
}
|
||||
return rc.principal
|
||||
}
|
||||
|
||||
// EffectiveProfile 返回本轮**实际生效**的档位名。
|
||||
//
|
||||
// per-agent 视图下它是 `session > longtime > 空` 的结果;
|
||||
// 旧路径下等于 Round.Profile()。
|
||||
func (rc *Round) EffectiveProfile() string {
|
||||
if rc == nil {
|
||||
return ""
|
||||
}
|
||||
if rc.scope != nil {
|
||||
return rc.scope.EffectiveProfile()
|
||||
}
|
||||
return rc.profile
|
||||
}
|
||||
|
||||
// Profile 返回本轮生效的档位名(空 = 未使用档位)。
|
||||
func (rc *Round) Profile() string {
|
||||
if rc == nil {
|
||||
return ""
|
||||
}
|
||||
return rc.profile
|
||||
}
|
||||
|
||||
// CheckTool 判定本轮的某个工具是否可用。
|
||||
//
|
||||
// 这就是工具执行链**唯一**需要的调用:调用方不再需要知道
|
||||
// “我是父还是子、该走 CheckTool 还是 CheckToolFor”。
|
||||
func (rc *Round) CheckTool(name string) Decision {
|
||||
if rc == nil || rc.rt == nil {
|
||||
return allowed()
|
||||
}
|
||||
// per-agent 视图:用**本 agent 的**生效档位(session > longtime)。
|
||||
if rc.scope != nil {
|
||||
return rc.rt.checkToolWithProfile(rc.principal, rc.scope.EffectiveProfile(), name)
|
||||
}
|
||||
// 旧路径:进程级档位。
|
||||
return rc.rt.checkToolWithProfile(rc.principal, rc.rt.ActiveProfile(), name)
|
||||
}
|
||||
|
||||
// End 结束本轮:恢复进入前的档位。
|
||||
//
|
||||
// 幂等:重复调用无副作用(defer + 显式调用并存是安全的)。
|
||||
func (rc *Round) End() {
|
||||
if rc == nil || rc.ended {
|
||||
return
|
||||
}
|
||||
rc.ended = true
|
||||
|
||||
// per-agent 视图:只清**自己的** session 档,longtime 档保留
|
||||
//(它是该 agent 的生命状态,不是本轮事实)。
|
||||
if rc.scope != nil {
|
||||
rc.scope.clearSession()
|
||||
return
|
||||
}
|
||||
|
||||
// 旧的进程级路径:恢复进入前的值(那时多个 agent 共享一个 active)。
|
||||
if rc.rt == nil || rc.rt.active == nil {
|
||||
return
|
||||
}
|
||||
if rc.restored != "" {
|
||||
rc.rt.active.Activate(rc.restored, scopeTTLDefault)
|
||||
return
|
||||
}
|
||||
rc.rt.active.Clear()
|
||||
}
|
||||
|
||||
// DeclareChildPolicy 让**父**为刚创建的子执行体声明主体策略。
|
||||
//
|
||||
// ## 为什么这个入口也在 security 里(而不是 agent 组装结构体)
|
||||
//
|
||||
// 收敛前 `agent/core` 自己拼 `PrincipalPolicy{AllowTools: …, DenyTools: …}`
|
||||
// 字面量 —— 那是策略结构,属于 security 的领域。调用方只该说
|
||||
// “这个子允许这些、禁止那些”,由 security 负责把语义落到正确的作用域。
|
||||
//
|
||||
// 语义约束(security 保证):声明只能**收窄**。`CheckToolFor` 内部先跑
|
||||
// `CheckTool`(全局 deny 优先),所以父的黑名单对子依然有效,子无法提权。
|
||||
func (r *Runtime) DeclareChildPolicy(childID string, allow, deny []string) error {
|
||||
if r == nil {
|
||||
return nil // 未装配 security:不为子声明,保持无限制(与之前一致)
|
||||
}
|
||||
childID = strings.TrimSpace(childID)
|
||||
if childID == "" {
|
||||
return fmt.Errorf("security: declare child policy requires a non-empty child id")
|
||||
}
|
||||
if len(allow) == 0 && len(deny) == 0 {
|
||||
return nil // 父选择不给额外限制
|
||||
}
|
||||
return r.DeclarePrincipalPolicy("id", childID, PrincipalPolicy{
|
||||
AllowTools: allow,
|
||||
DenyTools: deny,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokeChildPolicy 在子执行体销毁时撤销父为它声明的策略。
|
||||
//
|
||||
// ★ 必须撤销:主体 ID 是调用方给的名字,子回收后**同名重建**是常规操作。
|
||||
// 旧策略留着 ⇒ 新一代用上一代父留的约束运行(且那个父可能已不存在),
|
||||
// 主体表还会随 create/destroy 单调增长。
|
||||
// 这与 rtchild 留档串线(`rtClosedTranscripts` 以模型给的名字为键)同一形态。
|
||||
func (r *Runtime) RevokeChildPolicy(childID string) {
|
||||
if r == nil || strings.TrimSpace(childID) == "" {
|
||||
return
|
||||
}
|
||||
_ = r.RevokePrincipalPolicy("id", strings.TrimSpace(childID))
|
||||
}
|
||||
|
||||
// ClassifyPrincipalForTest 暴露主体分类规则给跨包判据(**仅测试用**)。
|
||||
//
|
||||
// 为什么不写在 _test.go:判据住在 `kernelmod/all`(那里才有整机装配),
|
||||
// 而 Go 的 _test.go 符号不跨包导出。带 ForTest 后缀是为了让
|
||||
// "生产里调了它"在 code review 时一眼可见。
|
||||
func (r *Runtime) ClassifyPrincipalForTest(in RoundInput) Principal {
|
||||
return r.classifyPrincipal(in)
|
||||
}
|
||||
|
||||
// classifyScopePrincipal 按 per-agent 视图的事实判定主体类别。
|
||||
//
|
||||
// 与 classifyPrincipal 同一套规则(**唯一事实来源**),
|
||||
// 只是入参来自 AgentScope 而不是 RoundInput。
|
||||
func (r *Runtime) classifyScopePrincipal(agentID string, isChild bool) Principal {
|
||||
return r.classifyPrincipal(RoundInput{AgentID: agentID, IsChild: isChild})
|
||||
}
|
||||
@ -69,6 +69,20 @@ type Runtime struct {
|
||||
//(QQ 的 beforeToolcall):插件只声明谁可以调什么,
|
||||
// 判定与拒绝文案由内核统一产出。
|
||||
principals *principalRegistry
|
||||
|
||||
// scopesByAgent 是**每 agent 一个**策略执行视图(见 agentscope.go)。
|
||||
//
|
||||
// ★ 为何需要(2026-10-31 用户裁定):每个 agent / 子 agent 一个实例,
|
||||
// 因为轮次档位(sessionProfile)是**该 agent 独有**的状态。
|
||||
// 放在 Runtime 单例上会让并发 agent 互相覆写(主 agent 的 guest
|
||||
// 被驻留子的 owner 盖掉,或反之)。
|
||||
//
|
||||
// ★ 为何不把整个 Runtime 复制一份:`profiles` / `principals` / `scopes`
|
||||
// 是**插件声明出来的注册表**,每个 agent 各持一份就会变成
|
||||
// “插件往 A 声明了档位,B 看不见”—— 那是最难查的一类缺陷。
|
||||
// 所以切分是「共享注册表 + per-agent 执行视图」。
|
||||
scopesByAgent map[string]*AgentScope
|
||||
scopeMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRuntime 用已装好的变量库构造运行时。
|
||||
@ -217,7 +231,30 @@ func (r *Runtime) DeclareTurnPolicy(opts ScopeOptions) error {
|
||||
// 组合顺序(**deny 永远优先**,这是安全策略的基本直觉):
|
||||
//
|
||||
// 全局 deny → 作用域 deny → 全局 allow(非空则必须命中)→ 作用域 allow
|
||||
//
|
||||
// ## 与 per-agent 视图的关系(2026-10-31 用户裁定)
|
||||
//
|
||||
// 本函数用 `Runtime.active`(**进程级**)上的档位 —— 新代码不要依赖它。
|
||||
// 新调用方应走 `Runtime.ForAgent(id, isChild)` 拿 `*AgentScope`:
|
||||
// 那里的档位是**该 agent 独有**的,不会与其他 agent 串。
|
||||
//
|
||||
// 保留本函数是为了:独立工具(memgc 等,进程内只有一个执行体)、判据、
|
||||
// 以及尚未按 agent 切分的旧路径。
|
||||
func (r *Runtime) CheckTool(name string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
return r.checkToolWithProfile(Principal{}, r.ActiveProfile(), name)
|
||||
}
|
||||
|
||||
// checkToolWithProfile 是工具判定的**唯一实现**:档位由调用方显式给定。
|
||||
//
|
||||
// `profileName` 为空 = 不使用档位(零影响)。
|
||||
//
|
||||
// ★ 为什么档位作参数、而不是读全局 `r.active`:
|
||||
// per-agent 视图必须传入**它自己的**档位(session 或 longtime)。
|
||||
// 若这里读 `r.active`,per-agent 视图就白做了 —— 那正是本轮要修的缺陷。
|
||||
func (r *Runtime) checkToolWithProfile(p Principal, profileName, name string) Decision {
|
||||
if r == nil {
|
||||
return allowed()
|
||||
}
|
||||
@ -227,7 +264,7 @@ func (r *Runtime) CheckTool(name string) Decision {
|
||||
// ★ 活跃档位(可选):插件没声明/没激活时这里零影响。
|
||||
// 档位只参与**收窄**:它的 deny / allow 与主体、全局同一条链,
|
||||
// 并且不能绕过全局黑名单(上面已先判过)。
|
||||
if prof, ok := r.profilePolicy(); ok {
|
||||
if prof, ok := r.profilePolicyByName(profileName); ok {
|
||||
for _, pat := range prof.Policy.DenyTools {
|
||||
if matchTool(pat, name) {
|
||||
return deny("tool %q is disabled by profile %q", name, prof.Name)
|
||||
|
||||
Reference in New Issue
Block a user