diff --git a/README.md b/README.md index 9b130df0..2682d1a5 100644 --- a/README.md +++ b/README.md @@ -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
权重 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) — 尚未实现 / 已知缺口的**唯一登记处** ## 下载 diff --git a/README_EN.md b/README_EN.md index 14ae40c6..334b17eb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -166,7 +166,7 @@ flowchart TB subgraph G[③ Graph Database] DB[(SQLite)] IDX[Indexer vector+jieba→BFS depth=2] -->|Memory Index| SP - MEM[memory_recall/commit/merge/purge/edit] + MEM[stablemem_*/coldcontext_*] SOC[person_query/set_trait] subgraph SC[Scene recognition: declared + emergent] FE[① fingerprint chan/peer/tool/topic/part
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). diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 82094f43..d7a619de 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -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`,并由内核把 `[ <短digest>] <描述>` 标记补进句子/正文——**标记由内核拼,模型只给 digest**。 要求调用方知道格式,等于让一个拼写错误静默切断引用绑定而全链路无人报错。 -`memory_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。 +`stablemem_commit` 同时新增 `sentence_text`:媒体引用挂在句子上,没有句子就无处可挂。 ### 媒体记忆(块 union:媒体即块) diff --git a/assets/docs/zh/OVERVIEW.md b/assets/docs/zh/OVERVIEW.md index bba320c2..5df0572d 100644 --- a/assets/docs/zh/OVERVIEW.md +++ b/assets/docs/zh/OVERVIEW.md @@ -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` 参数关联媒体 - **旧机制已整体拆除**:此前把视觉模型生成的描述写进正文、以 `[ <短digest>] <描述>` 标记 参与检索的**描述式索引**,以及 `media_refs` 引用计数,均已在 v1.2.0 删除——描述是模型生成的 二手信息,检索「别人转述的图片」不如检索图片自己的向量 diff --git a/docs/zh/README.md b/docs/zh/README.md new file mode 100644 index 00000000..6896d424 --- /dev/null +++ b/docs/zh/README.md @@ -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)。 diff --git a/docs/zh/abstention-criterion.md b/docs/zh/abstention-criterion.md index ea9c1a63..0bfb1633 100644 --- a/docs/zh/abstention-criterion.md +++ b/docs/zh/abstention-criterion.md @@ -1,5 +1,8 @@ # 拒答判据:符号存在性 +> **状态:参考 / 机制说明。** 描述拒答判据的设计口径与实现位置,非逐条审计记录。 +> 未实现项见 [`future-plan.md`](./future-plan.md)。 + ## 结论先行 **「查询符号在库中零出现」可以安全拒答。** 依据是确定性事实 @@ -85,11 +88,19 @@ ## 未完成 -- 拒答逻辑尚未接入生产路径(`recallByBlocks` 仍无条件返回内容) -- 泛指词检测未实现 -- n=10 的判据仍偏小,但方向与上一轮 n=4 时的判断相反: +> **2026-10-31 复核**:第一条已不成立,其余仍开放。 + +- ~~拒答逻辑尚未接入生产路径~~ ✅ **已接入**: + `GraphDB.RecallBlocksGuarded` 把拒答下沉到**图库层**, + 生产路径 `toolcall.go:1312` 与端到端探针**走同一条路**。 + (下沉的理由值得记住:第一版把拒答放在 `core/toolcall.go` 里, + 探针绕过它 ⇒「判据测不到被测路径,判据就等于不存在」。) +- ❌ 泛指词检测仍未实现 +- ⚠️ n=10 的判据仍偏小,但方向与上一轮 n=4 时相反: 这次是**确定性事实 + 完全分离 + 新增样本不破**,而不是「差 0.047 的运气」 +两者已登记到 [`future-plan.md`](./future-plan.md) §六。 + ## 迁移前后对照(2026-10-04,生产快照实测) 用同一份生产库快照,迁移前后各跑一次 `TestProbe_生产规模召回`: diff --git a/docs/zh/ci-cd-runbook.md b/docs/zh/ci-cd-runbook.md index bbfb2757..21f400c8 100644 --- a/docs/zh/ci-cd-runbook.md +++ b/docs/zh/ci-cd-runbook.md @@ -1,5 +1,7 @@ # CI/CD 流水线手册(GitHub Actions) +> **状态:操作手册(现行)。** 照着做即可;命令与配置如有变更需同步更新本文。 + > 2026-09-29 随仓库迁移 gitcode → GitHub 而建。此前 AtomGit 停止对普通用户 > 提供流水线,本仓无任何自动化验证。本文记录两条流水线的用法、机制与边界。 > 分支模型见 `docs/git-branching.md`(本文与其 §七「发版产物清单」衔接)。 diff --git a/docs/zh/deploy-runbook.md b/docs/zh/deploy-runbook.md index b7e41030..4549d4d8 100644 --- a/docs/zh/deploy-runbook.md +++ b/docs/zh/deploy-runbook.md @@ -1,5 +1,7 @@ # HologramAgent 生产部署手册(homed / waiter) +> **状态:操作手册(现行)。** 照着做即可;命令与配置如有变更需同步更新本文。 + > 适用范围:本机(`.60`)的 `homeagent.service`,以及两台设备桥宿主上的 > `waiter-remote.service`。 > @@ -354,7 +356,7 @@ journalctl -u homeagent.service --since "-10 min" | grep -E "finish_reason=lengt 先看**是不是内核的兜底在工作**。例:`重复申请 stage 锁` 是 SDK 模板在每个 stage handler 入口自动加锁 + 锁不可重入所致,**插件侧问题**;内核的强制释放 是有意设计(避免后续插件死锁),不要当内核缺陷去修。详见 -[`toolcall-parallel-execution-plan.md`](./toolcall-parallel-execution-plan.md) 末节。 +[`toolcall-contract-and-sequence-design.md`](./toolcall-contract-and-sequence-design.md) --- diff --git a/docs/zh/engineering-lessons.md b/docs/zh/engineering-lessons.md new file mode 100644 index 00000000..da649d08 --- /dev/null +++ b/docs/zh/engineering-lessons.md @@ -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`):怎么做 + +--- + +## 附:这些教训的共同点 + +回头看,几乎每一条都是同一个形态的变体: + +> **某种"看起来对了"的信号,与"真的对了"之间存在缺口。** + +- 判据全绿 ≠ 功能可用 +- 机制存在 ≠ 接线生效 +- 层号碰巧对 ≠ 依赖声明正确 +- 单数路径对 ≠ 复数路径也对 +- 名字相同 ≠ 是同一个东西 +- 计划写完 ≠ 事情做完 + +**所以写判据、写文档、写代码时,都要问一句:我现在拿到的这个信号, +在什么情况下会是假的?** diff --git a/docs/zh/entities-retirement-plan.md b/docs/zh/entities-retirement-plan.md deleted file mode 100644 index 4e9a4dfb..00000000 --- a/docs/zh/entities-retirement-plan.md +++ /dev/null @@ -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-) -- 生产实例 ${HA_DATA} **不在本次范围**(需人工确认后单独做) - -## 四、明确不变 - -- memory_recall 工具名与参数(调用方模型无感知) -- pkg/embedding provider SPI(内核无感边界) -- scene 机制(改挂块节点,机制本身保留) -- vector.Store / qwen3vl 双模式权重(直接复用) diff --git a/docs/zh/future-plan.md b/docs/zh/future-plan.md new file mode 100644 index 00000000..1e5ab960 --- /dev/null +++ b/docs/zh/future-plan.md @@ -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.` 的读取,与 `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 | diff --git a/docs/zh/input-scheduler-design.md b/docs/zh/input-scheduler-design.md index 95277480..b97a6cc9 100644 --- a/docs/zh/input-scheduler-design.md +++ b/docs/zh/input-scheduler-design.md @@ -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)** > > 本稿早期版本把「四级优先级」当成了**所有任务**的通用优先级,并按通道名 diff --git a/docs/zh/kernel-modularization-design.md b/docs/zh/kernel-modularization-design.md index 2f814e06..25fa4c77 100644 --- a/docs/zh/kernel-modularization-design.md +++ b/docs/zh/kernel-modularization-design.md @@ -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(".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 在**插件拉起路径**上 ⇒ 整个内核启动失败。 diff --git a/docs/zh/kernel-performance.md b/docs/zh/kernel-performance.md index 81d734a5..9a4d556d 100644 --- a/docs/zh/kernel-performance.md +++ b/docs/zh/kernel-performance.md @@ -1,5 +1,7 @@ # 内核性能指标(实测基线) +> **状态:实测基线。** 数据来自标注的日期/版本;引用前先确认它是否仍对应当前版本。 + 本文记录**实测**数据,不是估算。每条都标出测量方法与采集时间, 便于复现与追回归。 diff --git a/docs/zh/kernel-secret-firewall-design.md b/docs/zh/kernel-secret-firewall-design.md index ed9b4264..157fdf14 100644 --- a/docs/zh/kernel-secret-firewall-design.md +++ b/docs/zh/kernel-secret-firewall-design.md @@ -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 ★ 配置落盘加密 [已完成 · 真机验证] diff --git a/docs/zh/legacy-issues.md b/docs/zh/legacy-issues.md index 2d69d993..4acd9035 100644 --- a/docs/zh/legacy-issues.md +++ b/docs/zh/legacy-issues.md @@ -1,5 +1,10 @@ # HologramAgent 遗留问题清单 +> **状态:长期清单(部分条目已过时)。** 本文按「仍未完成且经核实」筛选, +> 但清单会随代码演进滞后 —— 引用某条前请先用源码复核。 +> 它**不是** future-plan 的替代:future-plan 收「能力缺失」与「机制在但零调用方」, +> 本文收「已知问题与收尾项」。两者有交叉时以 future-plan 为准。 + > **本文定位(2026-09-25 重写)**:只写**仍未完成、且经源码/实测核实确属本仓库**的问题。 > > **判定方法**:不继承任何旧档的复选框,回到源码与构建现场逐条重验 diff --git a/docs/zh/legacy-table-retirement.md b/docs/zh/legacy-table-retirement.md index 9ba921be..290fe378 100644 --- a/docs/zh/legacy-table-retirement.md +++ b/docs/zh/legacy-table-retirement.md @@ -1,5 +1,7 @@ # 旧表清理可行性评估(2026-10-04) +> **状态:计划(收尾中)。** 遗留表退役的执行记录;剩余读方见文内 §八 / §十二。 + > 结论:**现在不能清理**。55 处生产调用点仍在读写 `entities` / `relations` / `sentences`。 > 这份评估回答「能不能删」以及「删之前要先做什么」。 diff --git a/docs/zh/live-run-evidence-2026-10-04.md b/docs/zh/live-run-evidence-2026-10-04.md index 376285cc..09c1e688 100644 --- a/docs/zh/live-run-evidence-2026-10-04.md +++ b/docs/zh/live-run-evidence-2026-10-04.md @@ -1,5 +1,8 @@ # 实战证据:新内核在真实跑分中的行为(2026-10-04 07:13~07:29) +> **状态:归档说明(历史证据快照)。** 记录 2026-10-04 那次实跑的现场数据。 +> **不要用它的结论覆盖现在的实现判断** —— 代码已演进,快照只证明"当时是这样"。 + 内核 `657ccd7`(含今天全部 19 个 memory 层提交)跑 tasks.zerobasis + memory_recall 时的实例日志。 ## 一、召回来源标签(今天新增的功能) diff --git a/docs/zh/memory-blockflow-repair-plan.md b/docs/zh/memory-blockflow-repair-plan.md deleted file mode 100644 index d995ec36..00000000 --- a/docs/zh/memory-blockflow-repair-plan.md +++ /dev/null @@ -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):根因已在存储形态修掉;渲染按预算分配是独立议题, - 避免与本次修复混在一起、互相掩盖 diff --git a/docs/zh/memory-restructure-plan.md b/docs/zh/memory-restructure-plan.md deleted file mode 100644 index 8d6337f4..00000000 --- a/docs/zh/memory-restructure-plan.md +++ /dev/null @@ -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_` 原句块承载(内容派生、幂等、不带向量), -> `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 配置(需人工确认) diff --git a/docs/zh/memory-usability-plan.md b/docs/zh/memory-usability-plan.md deleted file mode 100644 index f1742c3f..00000000 --- a/docs/zh/memory-usability-plan.md +++ /dev/null @@ -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 入库? diff --git a/docs/zh/multimodal-space.md b/docs/zh/multimodal-space.md index 2d6bfe21..cb3e7006 100644 --- a/docs/zh/multimodal-space.md +++ b/docs/zh/multimodal-space.md @@ -1,5 +1,9 @@ # 统一多模态向量空间 +> **状态:参考 / 实测。** 多模态向量空间的设计与实测数据。 +> 注意:文中 `TestEmbedderVideoMatchesONNXReference` 是**规划中的判据名**, +> 仓库中不存在(视频尚未纳入契约,见文内说明)。 + 核心不绑定任何具体模型:它按 provider 名从公共注册表(`pkg/embedding`)打开一个 向量空间。仓库内自带两个: diff --git a/docs/zh/prod-migration-checklist.md b/docs/zh/prod-migration-checklist.md deleted file mode 100644 index e0b1d010..00000000 --- a/docs/zh/prod-migration-checklist.md +++ /dev/null @@ -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` 是**同一份数据的迁移前后对照**, -两者都在,可随时重跑任何验证。 diff --git a/docs/zh/production-recall-validation.md b/docs/zh/production-recall-validation.md index 33d8938f..5141cd8d 100644 --- a/docs/zh/production-recall-validation.md +++ b/docs/zh/production-recall-validation.md @@ -1,3 +1,8 @@ +# 生产召回验证:探针 PASS 但结论是假的 + +> **状态:实测报告(结论有效,日期见文内)。** 记录了「探针通过≠能力成立」 +> 这一类失败的具体形态,是判据设计的反面教材。 +> 文末 3 条遗留 sentence→块 边仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。 ## 二十五、生产规模验证:**探针 PASS 了,但结论是假的** diff --git a/docs/zh/recall-as-association.md b/docs/zh/recall-as-association.md index dda301bd..39f29f8f 100644 --- a/docs/zh/recall-as-association.md +++ b/docs/zh/recall-as-association.md @@ -1,5 +1,8 @@ # 召回即联想 +> **状态:参考 / 机制说明。** 描述召回作为关联的模型,非待办清单。 +> 未实现项见 [`future-plan.md`](./future-plan.md)。 + > **「召回是针对节点的,然后召回的是节点与 n 层 bfs 结果」** > —— 这个机制就是在模仿人类的联想能力。 diff --git a/docs/zh/recall-capability-tiers.md b/docs/zh/recall-capability-tiers.md index 3ae62f25..896cca4e 100644 --- a/docs/zh/recall-capability-tiers.md +++ b/docs/zh/recall-capability-tiers.md @@ -1,5 +1,8 @@ # 生产召回能力分层 +> **状态:实测报告 + 待补清单。** 能力分层与缺口来自生产实测。 +> 文末「待补的能力」两个缺口仍开放,已索引到 [`future-plan.md`](./future-plan.md) §六。 + > 单一分数(如「4/9」)会掩盖真实情况。真正有用的问题是: > **什么形态的查询能用、什么不能用、为什么。** diff --git a/docs/zh/resident-subagent-design.md b/docs/zh/resident-subagent-design.md index fb82f431..75893218 100644 --- a/docs/zh/resident-subagent-design.md +++ b/docs/zh/resident-subagent-design.md @@ -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` 等)/ diff --git a/docs/zh/scene-memory-fix-plan.md b/docs/zh/scene-memory-fix-plan.md deleted file mode 100644 index 13b443c6..00000000 --- a/docs/zh/scene-memory-fix-plan.md +++ /dev/null @@ -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-'"`(**禁用 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` 的既有语义: - 新增项是纯追加,不借机改旧行为。 diff --git a/docs/zh/site-infra-runbook.md b/docs/zh/site-infra-runbook.md index 210d7335..101cd21d 100644 --- a/docs/zh/site-infra-runbook.md +++ b/docs/zh/site-infra-runbook.md @@ -1,5 +1,7 @@ # 站点基础设施运维手册 +> **状态:操作手册(现行)。** 站点基础设施的操作说明。 + > 适用:本项目**文档站与产品站**的生产部署(多站点、统一入口、多层 TLS 终止)。 > 读者:后续维护者 / AI agent。 > **本文件不含任何密钥、token、真实域名与 IP** —— 全部用占位符,取值见本机 `deploy/` 外的私有笔记或基础设施面板。 diff --git a/docs/zh/toolcall-contract-and-sequence-design.md b/docs/zh/toolcall-contract-and-sequence-design.md index 6e9f6a0d..928b5380 100644 --- a/docs/zh/toolcall-contract-and-sequence-design.md +++ b/docs/zh/toolcall-contract-and-sequence-design.md @@ -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 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就会变成真死锁。 diff --git a/docs/zh/toolcall-parallel-execution-plan.md b/docs/zh/toolcall-parallel-execution-plan.md deleted file mode 100644 index 98e5e95a..00000000 --- a/docs/zh/toolcall-parallel-execution-plan.md +++ /dev/null @@ -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 <<< "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 次告警全部有惊无险。**唯一风险**是:哪天自愈逻辑变动,就是真死锁。 diff --git a/docs/zh/triple-extract-attention.md b/docs/zh/triple-extract-attention.md index 6927de66..8a96ad9d 100644 --- a/docs/zh/triple-extract-attention.md +++ b/docs/zh/triple-extract-attention.md @@ -1,5 +1,7 @@ # 三元组抽取:注意力模型方案 +> **状态:参考 / 机制说明。** 三元组抽取与注意力机制说明。 + > 2026-10-03。目标是**推理时零 LLM**——一次 LLM 标注,训一个专用网络。 ## 一、为什么必须是「注意力学任务结构」而不是更好的向量 diff --git a/docs/zh/vector-provider-decision.md b/docs/zh/vector-provider-decision.md index cbb52a9e..41d4a624 100644 --- a/docs/zh/vector-provider-decision.md +++ b/docs/zh/vector-provider-decision.md @@ -1,5 +1,7 @@ # 向量 provider 决策:Qwen3-VL vs chineseclip +> **状态:决策记录(已定)。** 记录了 provider 选型过程与结论,属决策归档性质。 + > 结论先行(2026-10-03 实测,真库 260 个块,两套 ONNX 均已导出校验通过) > > **不切换 provider。** 各用其位: diff --git a/internal/agent/core/agent.go b/internal/agent/core/agent.go index 8fd6d883..b56e666a 100644 --- a/internal/agent/core/agent.go +++ b/internal/agent/core/agent.go @@ -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 均无超时)会永久挂起 diff --git a/internal/agent/core/resident.go b/internal/agent/core/resident.go index 73926212..81b5e028 100644 --- a/internal/agent/core/resident.go +++ b/internal/agent/core/resident.go @@ -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 实例 diff --git a/internal/agent/core/resident_tools.go b/internal/agent/core/resident_tools.go index 4a5617d2..f37b3dde 100644 --- a/internal/agent/core/resident_tools.go +++ b/internal/agent/core/resident_tools.go @@ -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) diff --git a/internal/agent/core/rtchild.go b/internal/agent/core/rtchild.go index c17f63af..ee39b9ac 100644 --- a/internal/agent/core/rtchild.go +++ b/internal/agent/core/rtchild.go @@ -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-),本函数曾漏了这一步。 +// +// 漏的后果与驻留子那边记录的是同一个形态:登记表是共享的、按 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-。 + 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()) diff --git a/internal/agent/core/rtchild_reuse_test.go b/internal/agent/core/rtchild_reuse_test.go new file mode 100644 index 00000000..8efded3d --- /dev/null +++ b/internal/agent/core/rtchild_reuse_test.go @@ -0,0 +1,224 @@ +package core + +// rtchild 的**同名复用**与**批量停止**的回收纪律(2026-10-30 修)。 +// +// ## 这一组判据锁的是什么 +// +// rtchild 的留档表与通道登记表都以 **id 字符串**为键,而 id 由模型给。 +// 「拨完一通、再拨同名的一通」是完全正常的使用方式,但它会让上一代 +// 留下的东西被当成这一代的 —— 而且**完全不报错**,只是内容错了。 +// +// 三条缺陷、三条判据、每条都能变红(变异方式写在各自注释里): +// +// ① 留档跨代串线 —— rt_transcript 读到上一通的对话 +// ② 批量停止漏注销 —— child/rt- 登记单调累积(只能重启 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-。 +// +// 为什么必须一致:登记表是共享的、按 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) +} diff --git a/internal/agent/core/rtchild_route_test.go b/internal/agent/core/rtchild_route_test.go index 714d95d7..7fdacfef 100644 --- a/internal/agent/core/rtchild_route_test.go +++ b/internal/agent/core/rtchild_route_test.go @@ -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", // ← 错的参数名 }, }) diff --git a/internal/agent/core/security_profile.go b/internal/agent/core/security_profile.go index 1cf9adda..873b1589 100644 --- a/internal/agent/core/security_profile.go +++ b/internal/agent/core/security_profile.go @@ -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) +} diff --git a/internal/agent/core/security_profile_test.go b/internal/agent/core/security_profile_test.go index 010f76c4..119817e1 100644 --- a/internal/agent/core/security_profile_test.go +++ b/internal/agent/core/security_profile_test.go @@ -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) + } +} diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index c2c7e593..0da6644b 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -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: diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 11a3a41b..ff114fbc 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -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//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:该子手头**未处理消息**的处置。" + diff --git a/internal/kernelmod/agent/mod.go b/internal/kernelmod/agent/mod.go index b32c53a6..78d7de16 100644 --- a/internal/kernelmod/agent/mod.go +++ b/internal/kernelmod/agent/mod.go @@ -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, }) // 图记忆读写钩子 → 事件总线。 diff --git a/internal/kernelmod/all/security_wiring_test.go b/internal/kernelmod/all/security_wiring_test.go new file mode 100644 index 00000000..a8b61fc9 --- /dev/null +++ b/internal/kernelmod/all/security_wiring_test.go @@ -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) + } + } +} diff --git a/internal/plugins/rtchild_reuse_e2e_test.go b/internal/plugins/rtchild_reuse_e2e_test.go new file mode 100644 index 00000000..b7ba2ff8 --- /dev/null +++ b/internal/plugins/rtchild_reuse_e2e_test.go @@ -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- 单调累积) +// ③ 留档跨代覆写语义 +// +// 三者都是**跨通话生命周期**的缺陷 —— 单实例 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- 从共享登记表里摘掉。 +// +// 为什么要在跨进程层测:入站登记是**内核自己建的**,但登记表由插件 +// 装配共用。进程内判据用的是裸 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-。 + // 这里不直接调用未导出的 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]) + "…" +} diff --git a/internal/security/agentscope.go b/internal/security/agentscope.go new file mode 100644 index 00000000..d8c252e7 --- /dev/null +++ b/internal/security/agentscope.go @@ -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) +} diff --git a/internal/security/agentscope_test.go b/internal/security/agentscope_test.go new file mode 100644 index 00000000..a5ceaea2 --- /dev/null +++ b/internal/security/agentscope_test.go @@ -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) + } +} diff --git a/internal/security/principal.go b/internal/security/principal.go index 5237e809..b26d0d0c 100644 --- a/internal/security/principal.go +++ b/internal/security/principal.go @@ -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) diff --git a/internal/security/principal_test.go b/internal/security/principal_test.go new file mode 100644 index 00000000..5b9a55ec --- /dev/null +++ b/internal/security/principal_test.go @@ -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 策略应仍然生效") + } +} diff --git a/internal/security/profile.go b/internal/security/profile.go index 4d0f1a9a..d1d0816e 100644 --- a/internal/security/profile.go +++ b/internal/security/profile.go @@ -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) diff --git a/internal/security/round.go b/internal/security/round.go new file mode 100644 index 00000000..0578d31a --- /dev/null +++ b/internal/security/round.go @@ -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` 约定 `.<档位>`)。 +// 把它交给调用方解析,等于让 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}) +} diff --git a/internal/security/runtime.go b/internal/security/runtime.go index beb3f695..797bfaea 100644 --- a/internal/security/runtime.go +++ b/internal/security/runtime.go @@ -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)