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:
JianFeeeee
2026-10-31 09:19:24 +08:00
53 changed files with 3613 additions and 2150 deletions

View File

@ -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) — 尚未实现 / 已知缺口的**唯一登记处**
## 下载

View File

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

View File

@ -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:媒体即块)

View File

@ -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
View 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)。

View File

@ -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_生产规模召回`:

View File

@ -1,5 +1,7 @@
# CI/CD 流水线手册(GitHub Actions)
> **状态:操作手册(现行)。** 照着做即可;命令与配置如有变更需同步更新本文。
> 2026-09-29 随仓库迁移 gitcode → GitHub 而建。此前 AtomGit 停止对普通用户
> 提供流水线,本仓无任何自动化验证。本文记录两条流水线的用法、机制与边界。
> 分支模型见 `docs/git-branching.md`(本文与其 §七「发版产物清单」衔接)。

View File

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

View 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`):怎么做
---
## 附:这些教训的共同点
回头看,几乎每一条都是同一个形态的变体:
> **某种"看起来对了"的信号,与"真的对了"之间存在缺口。**
- 判据全绿 ≠ 功能可用
- 机制存在 ≠ 接线生效
- 层号碰巧对 ≠ 依赖声明正确
- 单数路径对 ≠ 复数路径也对
- 名字相同 ≠ 是同一个东西
- 计划写完 ≠ 事情做完
**所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号,
在什么情况下会是假的?**

View File

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

View File

@ -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)**
>
> 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名

View File

@ -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 在**插件拉起路径**上 ⇒ 整个内核启动失败。

View File

@ -1,5 +1,7 @@
# 内核性能指标(实测基线)
> **状态:实测基线。** 数据来自标注的日期/版本;引用前先确认它是否仍对应当前版本。
本文记录**实测**数据,不是估算。每条都标出测量方法与采集时间,
便于复现与追回归。

View File

@ -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 ★ 配置落盘加密 [已完成 · 真机验证]

View File

@ -1,5 +1,10 @@
# HologramAgent 遗留问题清单
> **状态:长期清单(部分条目已过时)。** 本文按「仍未完成且经核实」筛选,
> 但清单会随代码演进滞后 —— 引用某条前请先用源码复核。
> 它**不是** future-plan 的替代:future-plan 收「能力缺失」与「机制在但零调用方」,
> 本文收「已知问题与收尾项」。两者有交叉时以 future-plan 为准。
> **本文定位(2026-09-25 重写)**:只写**仍未完成、且经源码/实测核实确属本仓库**的问题。
>
> **判定方法**:不继承任何旧档的复选框,回到源码与构建现场逐条重验

View File

@ -1,5 +1,7 @@
# 旧表清理可行性评估(2026-10-04)
> **状态:计划(收尾中)。** 遗留表退役的执行记录;剩余读方见文内 §八 / §十二。
> 结论:**现在不能清理**。55 处生产调用点仍在读写 `entities` / `relations` / `sentences`。
> 这份评估回答「能不能删」以及「删之前要先做什么」。

View File

@ -1,5 +1,8 @@
# 实战证据:新内核在真实跑分中的行为(2026-10-04 07:13~07:29)
> **状态:归档说明(历史证据快照)。** 记录 2026-10-04 那次实跑的现场数据。
> **不要用它的结论覆盖现在的实现判断** —— 代码已演进,快照只证明"当时是这样"。
内核 `657ccd7`(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis + memory_recall 时的实例日志。
## 一、召回来源标签(今天新增的功能)

View File

@ -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):根因已在存储形态修掉;渲染按预算分配是独立议题,
避免与本次修复混在一起、互相掩盖

View File

@ -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 配置(需人工确认)

View File

@ -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 入库?

View File

@ -1,5 +1,9 @@
# 统一多模态向量空间
> **状态:参考 / 实测。** 多模态向量空间的设计与实测数据。
> 注意:文中 `TestEmbedderVideoMatchesONNXReference` 是**规划中的判据名**,
> 仓库中不存在(视频尚未纳入契约,见文内说明)。
核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个
向量空间。仓库内自带两个:

View File

@ -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` 是**同一份数据的迁移前后对照**,
两者都在,可随时重跑任何验证。

View File

@ -1,3 +1,8 @@
# 生产召回验证:探针 PASS 但结论是假的
> **状态:实测报告(结论有效,日期见文内)。** 记录了「探针通过≠能力成立」
> 这一类失败的具体形态,是判据设计的反面教材。
> 文末 3 条遗留 sentence→块 边仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。
## 二十五、生产规模验证:**探针 PASS 了,但结论是假的**

View File

@ -1,5 +1,8 @@
# 召回即联想
> **状态:参考 / 机制说明。** 描述召回作为关联的模型,非待办清单。
> 未实现项见 [`future-plan.md`](./future-plan.md)。
> **「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」**
> —— 这个机制就是在模仿人类的联想能力。

View File

@ -1,5 +1,8 @@
# 生产召回能力分层
> **状态:实测报告 + 待补清单。** 能力分层与缺口来自生产实测。
> 文末「待补的能力」两个缺口仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。
> 单一分数(如「4/9」)会掩盖真实情况。真正有用的问题是:
> **什么形态的查询能用、什么不能用、为什么。**

View File

@ -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` 等)/

View File

@ -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` 的既有语义:
新增项是纯追加,不借机改旧行为。

View File

@ -1,5 +1,7 @@
# 站点基础设施运维手册
> **状态:操作手册(现行)。** 站点基础设施的操作说明。
> 适用:本项目**文档站与产品站**的生产部署(多站点、统一入口、多层 TLS 终止)。
> 读者:后续维护者 / AI agent。
> **本文件不含任何密钥、token、真实域名与 IP** —— 全部用占位符,取值见本机 `deploy/` 外的私有笔记或基础设施面板。

View File

@ -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 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就会变成真死锁。

View File

@ -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 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就是真死锁。

View File

@ -1,5 +1,7 @@
# 三元组抽取:注意力模型方案
> **状态:参考 / 机制说明。** 三元组抽取与注意力机制说明。
> 2026-10-03。目标是**推理时零 LLM**——一次 LLM 标注,训一个专用网络。
## 一、为什么必须是「注意力学任务结构」而不是更好的向量

View File

@ -1,5 +1,7 @@
# 向量 provider 决策:Qwen3-VL vs chineseclip
> **状态:决策记录(已定)。** 记录了 provider 选型过程与结论,属决策归档性质。
> 结论先行(2026-10-03 实测,真库 260 个块,两套 ONNX 均已导出校验通过)
>
> **不切换 provider。** 各用其位:

View File

@ -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 均无超时)会永久挂起

View File

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

View File

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

View File

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

View 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)
}

View File

@ -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", // ← 错的参数名
},
})

View File

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

View File

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

View File

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

View File

@ -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:该子手头**未处理消息**的处置。" +

View File

@ -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,
})
// 图记忆读写钩子 → 事件总线。

View 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)
}
}
}

View 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]) + "…"
}

View 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)
}

View 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)
}
}

View File

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

View 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 策略应仍然生效")
}
}

View File

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

View File

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