mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-23 18:38:11 +00:00
用户指出实现不自洽:"子 agent 是传统上下文,没有裁剪"。查证属实:子仍走 `formatMergedTimeline` 的预算裁剪、也可能走 `pruneOnInput`(既裁剪又向 doc 记忆归档)。 即"动态上下文"(父专属能力)漏进了轻量内核。 ## 三处闸门(按 isLightKernel() 判) 1. **拼装预算**:新增 `contextTokenBudget(b)` —— 父用动态上下文的 `ContextTokens`, 子用**整个窗口** `MaxContext`。两个调用点都改(`stepPrepare`、`rebaseFramePrefix`; 漏掉后者时 prepare 之后的重建仍会裁,实测就是这么被抓出来的)。 2. **pruneOnInput**:轻量内核前置返回 —— 不做按相关度裁剪、不向 doc 记忆归档。 3. doc 记忆 / 整理流水线:早已由 `a.memory == nil` 关闭(N2c)。 ## "不裁"的准确含义 不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个硬上限约束; 且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ⇒ **丢事件的决定权在父**(压缩/回收/销毁),不在内核。文档 §16.0.0 记录三处闸门表。 ## 顺带修掉的既有 bug(单位混用) `formatMergedTimeline` 逐事件估算原来是 `len(e.Source)+len(e.Input)+40` 再 ×2 —— `len()` 是**字节**,而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍: 实测 2384 字的中文事件被估成 14398 token > 8192,于是窗口还有余量也提前 break、 把更早的事件整段丢掉。改为统一走 `EstimateTokens`(对父同样生效:中文长会话不再被过早裁剪)。 ## 测试 - 新增 `TestLightKernel_TraditionalContextNoTrimming`:用**记录模型实收消息**的 provider 断言"更早的事件仍在"(为什么不看 TaskFrame:`prepareInputTask` 只做前半段, 消息在 `runTaskSteps`/`stepPrepare` 才拼出来 —— 我第一版断言就打在了空帧上); 填充量取"超过动态份额、但仍在窗口内",从而能区分父/子两种行为。 - 新增 `TestFullKernel_StillUsesDynamicContext` 作对照(父仍用动态份额)。 全量 go test ./... 37 包 ok / 0 FAIL;-race 干净。
604 lines
38 KiB
Markdown
604 lines
38 KiB
Markdown
# 驻留式子 Agent 设计(轻量内核 · 两级记忆 · 父子中断)
|
||
|
||
> **前置**:本文建立在《输入调度器设计》(`docs/zh/input-scheduler-design.md`)之上。
|
||
> 那里已经落地了:两类别输入(中断 / 排队)、四级中断优先级、可抢占、
|
||
> 现场保存/恢复、中断栈(LIFO,结构上界 4 帧)、任务级回执、panic→L4。
|
||
> 本文只描述**驻留式子 agent** 这一新能力,以及它对既有实现的改动。
|
||
>
|
||
> 标记:**[已定]**= 用户明确拍板;**[默认]**= 本文给出的可逆默认取值,实现时在提交信息里标注。
|
||
|
||
---
|
||
|
||
## 1. 背景与目标
|
||
|
||
现状的子 agent 是**工作式轻量子**:`spawn_child` 起一个一次性 goroutine(临时 `msgs`),
|
||
跑完把结果经 `selfInputCh` 回投父,然后销毁。它**没有**自己的中断机制、没有通道分配、
|
||
没有可查询的状态面,父也无法在它运行途中干预它。
|
||
|
||
要让父 agent 能**长期派驻**一个下属去持续处理某类工作(一个 inputch 上的来源、一段长期目标),
|
||
就需要一种新的子 agent:**驻留子**。它必须满足:
|
||
|
||
- 有自己的**轻量内核**(自己的调度器、中断机制、上下文),所以父能"打断它"、"查它"、"回收它";
|
||
- 有自己的**记忆空间**(可写),但**改不了父的主记忆** —— 记忆入库的决策权留在父手里;
|
||
- 与父之间有**双向、可寻址**的通道(子→父、父→指定子),且父对子的消息是**最高级中断**;
|
||
- 有一张**可被父查询的状态面**(inputch 处理表),使父不打断它也能知道它做到哪;
|
||
- 生命周期由父掌握:父可随时**销毁**它,父退出时**必须**销毁全部。
|
||
|
||
**两类子 agent 并存**[已定]:工作式轻量子**原样保留**(它是"内核自循环"的一部分,
|
||
不是对等 agent);驻留子是新增的第二类。
|
||
|
||
---
|
||
|
||
## 2. 术语
|
||
|
||
| 术语 | 含义 |
|
||
|---|---|
|
||
| **根 agent** | 进程级主 agent,拥有**完整内核**(含记忆读写、全部通道) |
|
||
| **驻留子** | 父创建、长期驻留的子 agent,拥有**轻量内核**与**临时记忆空间** |
|
||
| **工作式轻量子** | 现状 `spawn_child` 的一次性子任务(**不是内核实例**) |
|
||
| **inputch** | **最基本的输入路由单位**:对"中断输入 / 排队输入"两者的高层抽象,是**路由与分配**的单位;**由插件注册,一个插件可注册多个** |
|
||
| **outputch** | 输出通道;输出是 agent 的**主动调用**,并**可寻址到具体 agent** |
|
||
| **main 空间** | 主记忆空间:父读写、所有子只读、全部子共享 |
|
||
| **temp 空间** | 子临时记忆空间:该子读写、子之间互不可见 |
|
||
| **状态面** | 子对外(对父)可查询的状态:含 **inputch 处理表**与产出 |
|
||
| **处理表** | 按 inputch 记录每轮处理信息的状态表(子持有,父 pull) |
|
||
| **登记表** | 父持有的全部子 agent 名录(id / 状态 / 通道 / 状态面句柄) |
|
||
| **contextfull** | 子的上下文窗口满,产生 L4 中断通知父 |
|
||
| **压缩 / 回收 / 销毁** | 父对 contextfull 的三种处置(见 §9) |
|
||
|
||
---
|
||
|
||
## 3. 角色与内核形态
|
||
|
||
| | **根 agent** | **驻留子** | **工作式轻量子**(保留不动) |
|
||
|---|---|---|---|
|
||
| 内核 | 完整 | **轻量** | 无(不是内核实例) |
|
||
| 调度器 / 四级中断 / 中断栈 | ✅ | ✅(**L4 只来自父**) | ❌ |
|
||
| 上下文 | 完整(记忆介导) | 传统上下文(消息序列) | 临时 `msgs` |
|
||
| 记忆 | main 读写 | **读 temp ∪ main,写 temp** | 无 |
|
||
| 通道 | 全部 | 父**划入**的输入通道 + **授权**的输出通道 | 无(结果回投父) |
|
||
| 插件与工具 | 全部 | **父授权,默认完整授权**[已定] | 现有剔除规则不变 |
|
||
| 状态面 / 处理表 | — | ✅(父 pull) | ❌ |
|
||
| 子→父 | — | 主动消息 = **L3 中断** | `injectSelfChannel` → `selfInputCh`(排队) |
|
||
| 主→子 | — | **L4 中断**(取消当前状态 + 插入新消息) | 无 |
|
||
| 生命周期 | 进程级 | 父持登记表;父可随时销毁;**父退出必须全部销毁** | 跑完即销毁 |
|
||
|
||
---
|
||
|
||
## 4. 通道模型
|
||
|
||
### 4.1 `inputch` 的定义 [已定]
|
||
|
||
`inputch` **不是**通道名标签,也**不是**与 outputch 配对的东西。它是:
|
||
|
||
1. **最基本的输入路由单位** —— 路由粒度到此为止:比"插件"细、比"通道名字符串"实;
|
||
2. **由插件注册,且一个插件可注册多个** —— 同一个插件的多个 inputch 是**彼此独立**的
|
||
路由单位(可以绑给不同 agent、可以分别授权)。登记接口即现有的
|
||
`RegisterInputChannel(name, def)`,调 N 次就是 N 个 inputch;
|
||
3. **对"中断输入"与"排队输入"两者的高层抽象** —— 两类输入都从 inputch 进出;
|
||
4. **路由与分配的单位**:
|
||
- **路由**:一条输入投给哪个 inputch,就是"该由谁处理"的**既定事实**
|
||
⇒ **路由发生在进内核之前**;
|
||
- **分配**:inputch 是**可分配资源**(父把输入通道划给子)。
|
||
|
||
「中断 vs 排队」是**每条输入自己的类别**(由注入 API 决定),级别(L1–L4)也是
|
||
**每条输入的属性**,都不是 inputch 的属性。
|
||
|
||
### 4.2 三个入场(沿用现有实现)
|
||
|
||
| 入口 | 承载 |
|
||
|---|---|
|
||
| `inputCh` | 外部客户端 / 插件的**排队与中断**输入(已预寻址) |
|
||
| `interruptCh` | 中断入站(外部 / 插件 / 内核自身) |
|
||
| `selfInputCh` | **内核自循环**:consolidation、工作式轻量子回投 |
|
||
|
||
### 4.3 输出是**主动调用** [已定]
|
||
|
||
- 异步通道(qq / 微信 / 群聊…):必须显式调用 `output_send__{通道名}` 才真正送达;
|
||
- 同步通道(webui / cli / 终端):返回纯文本,内核把文本交给等待方 ——
|
||
走输入事件自带的 `ResponseCh`,这是**事前定好的回程**。
|
||
|
||
⇒ **内核不持有"当前通道"可变状态**,**提示词也不预设 outputch**。
|
||
(要删 `Agent.currentOutputChannel`;要删 `tooldefs.go` 里"当前输入来源通道是 X,
|
||
对应输出门工具是 output_send__X"那两行。)
|
||
|
||
> 之前把这件事说成"内核路由"是错的:内核只负责**投递到既定的回程**与**执行显式的输出调用**。
|
||
|
||
### 4.4 通道分配:不对称 [已定]
|
||
|
||
- **不按对划分**。父为子:**划入若干 inputch**(单位是 inputch,可以来自同一个插件的不同 inputch)
|
||
+ 授权**一组可用输出通道**(授权集合,不是一对一)。
|
||
- **输出通道可寻址到具体 agent**:
|
||
- **子 → 主**:子直接打到主(经输出通道投进主的 inputch);
|
||
- **主 → 指定某个子**:父经输出通道投进**指定子**的 inputch。
|
||
|
||
### 4.5 已经落地/待落地的两件事
|
||
|
||
**已落地(N1a)**:inputch 登记表(归属插件 / 归属 agent / 容量 / 默认回程 / 策略)+ 共享登记表
|
||
+ 单工具多视图总览(`input_channels`,见 §4.6)。
|
||
|
||
**已落地(N1b)**:
|
||
- **输出通道授权集合**[已定:默认完整授权,父可收窄]:
|
||
`AgentConfig.AllowedOutputs`(nil/空 = 全部)。三处过滤点必须一致,
|
||
否则会出现"列表里看不到、按名字还能调"的裂缝:
|
||
1. **工具表**:不为未授权的通道生成 `output_send__X`(模型看不到就不会调);
|
||
2. **列表工具**:`output_list_channels` 只列授权的(已登记目标的会标出"目标: agent / inputch");
|
||
3. **调用点**:凭名字直调未授权的输出门**必须被拒**(纵深防御)。
|
||
- **输出通道 → 目标 agent 的 inputch 解析**:`ChannelRegistry.BindOutputTarget` /
|
||
`ResolveOutputTarget`(未登记的通道由传输层 device 自行处理,如 qq/webui)。
|
||
这是"输出可寻址到具体 agent"的数据面;真正的跨 agent 投递在 N4。
|
||
|
||
### 4.5.1 通道的一等化(后续要求)
|
||
|
||
现在 `Source` / `OutputChannel` 只是字符串标签,`IOManager.inputCh` 是**一条全局 channel**,
|
||
`inputChannels` 只是策略表(`ChannelDef`:NoMemory / Cleaner / ContextPolicy),
|
||
**没有归属、没有绑定、没有容量**。要实现 §4.4 需要新增:
|
||
|
||
- **通道注册层**:`inputch` 成为一等对象。它已经是**最基本的输入路由单位**,
|
||
所以注册表以 **inputch 为键**(一个插件 → N 个 inputch),并给每个 inputch 带上:
|
||
**归属/被划给的 agent · 容量 · 可接收类别 · 输出目标解析**;
|
||
⇒ 同一个插件的两个 inputch 可以**分别划给不同 agent**、**分别限额**;
|
||
- **输出通道 → 目标 agent 的 inputch** 的解析;
|
||
- **授权过滤**:`output_list_channels` 只列该 agent 被授权的通道。
|
||
|
||
### 4.6 inputch 总览:**单工具多视图** [已定]
|
||
|
||
父 agent 必须能看清两件事:**有哪些 inputch 已注册(谁注册的)**、**它们是怎么划分的**。
|
||
按用户要求构筑为**单工具多视图**(一个工具 + 一个 `view` 参数),而不是一堆小工具 ——
|
||
视图切换比工具增殖更好用,也更省提示词预算。
|
||
|
||
工具:**`input_channels`**
|
||
|
||
| view | 内容 |
|
||
|---|---|
|
||
| `all`(默认) | 全部已注册 inputch:名字 / **归属插件** / 归属 agent / 容量 / 记忆策略标记 |
|
||
| `mine` | 划给**本 agent** 的 |
|
||
| `unassigned` | **尚未划出**的(可按需分配) |
|
||
| `by_agent` | **划分情况总览**:按归属 agent 分组列出各自拥有哪些 inputch |
|
||
| `detail`(需 `name`) | 单个 inputch 的全字段(注册插件 / 归属 / 容量 / 默认回程 / 记忆策略) |
|
||
|
||
- 未知 `view` **必须报错并列出可用值**(拼错不得被静默当成默认视图)。
|
||
- 登记表是**可共享对象**(`*ChannelRegistry`):根 agent 与它的驻留子共用同一份,
|
||
这样"划入/授权"才有意义(默认每个 agent 自带一份,向后兼容)。
|
||
- **插件重载不得抹掉划分**:重复登记只更新「归属插件 + 策略」,
|
||
保留已有的 Owner / Capacity / Output。
|
||
|
||
---
|
||
|
||
## 5. 记忆模型:两级空间 [已定]
|
||
|
||
> **适用范围**:这套"两级空间"是针对**图记忆**的。子 agent 的记忆面是
|
||
> **传统上下文 + 图记忆**;**doc 记忆**与 **context 动态上下文**是内核独立设计的
|
||
> 记忆能力,**只有根 agent 有**(子不可见、不可用,见 §5.5)。
|
||
|
||
```
|
||
主记忆空间(main) ← 子【只读,可看到全部】;父【读写】;所有子共享
|
||
子临时记忆空间(temp) ← 该子【读写】;每个子独立、互不可见
|
||
|
||
子的记忆查询 = temp ∪ main 子的记忆写入 → 只落 temp
|
||
```
|
||
|
||
| | 根 agent | 驻留子 |
|
||
|---|---|---|
|
||
| main | **读写** | **只读**(可见全部) |
|
||
| temp | —(它自己就是 main 的所有者) | **读写**(自己的空间) |
|
||
| 查询范围 | main | **temp ∪ main** |
|
||
|
||
### 5.1 "轻量内核"的真正理由
|
||
|
||
不是砍功能,而是**记忆层被作用域化**:
|
||
|
||
- **写目标**被限定到 `temp`(子自己的命名空间);
|
||
- **读视图**被扩成 `temp ∪ main`(两个空间的并集)。
|
||
|
||
完整内核的记忆层**硬绑定在单一 main 空间**上。要让每个 agent 都有自己的空间 +
|
||
并集读视图,记忆层就必须接受**每个 agent 一份的作用域参数** —— 这就是轻量内核存在的理由。
|
||
|
||
### 5.2 推论:记忆写工具不禁用,而是**重定向**
|
||
|
||
子是**能写**的(写自己的 temp)。因此:
|
||
|
||
- 记忆写工具(图记忆写、文本记忆写、**文档记忆写**、**向量索引写**、**媒体落盘**)
|
||
**不禁用,而是重定向到 temp 命名空间**;
|
||
- 检索注入算"读",范围 `temp ∪ main`;
|
||
- 子**不能**写 main ⇒ "哪些内容进入主记忆"的决策**结构上**只在父手里(§9.2)。
|
||
|
||
### 5.3 三种处置对记忆的作用
|
||
|
||
| 动作 | temp | main | 子 |
|
||
|---|---|---|---|
|
||
| **压缩** | 保留 | 不写 | 继续 |
|
||
| **回收** | 父读 → **选中的 promote 进 main** → 丢弃 temp | 父写 | 取消 |
|
||
| **销毁** | 直接丢弃 | 不写 | 立刻移除 |
|
||
|
||
### 5.5 子的记忆面:**传统上下文 + 图记忆** [已定]
|
||
|
||
| 记忆能力 | 根 agent | 驻留子 |
|
||
|---|---|---|
|
||
| **图记忆**(实体/关系/句子,含其向量检索) | ✅ main 读写 | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||
| **doc 记忆**(文档记忆) | ✅ | ❌ **不可用**(内核独立设计的记忆能力,父专属) |
|
||
| **context 动态上下文**(动态上下文装配/裁剪) | ✅ | ❌ **不可用**;子用**传统上下文**(纯消息序列) |
|
||
| 蒸馏 / 归档 / consolidation | ✅ | ❌(属上述父专属能力) |
|
||
| 文本记忆 / 知识库 / 媒体 / 社交 | ✅ | ❌(同上) |
|
||
|
||
⇒ 所以"轻量内核"的准确表述是:**传统上下文 + 图记忆(作用域化)** ——
|
||
不是"记忆变轻了",而是**记忆面被裁到只剩图记忆,且图记忆被作用域化**。
|
||
|
||
### 5.6 实现形态:**独立存储实例**(不做 space 列)[已定]
|
||
|
||
轻量内核的记忆**不是**把共享记忆层加一个 `space` 维度,而是**换一套装配**:
|
||
|
||
```
|
||
子的轻量内核
|
||
├─ temp 图记忆实例(独立存储,**读写**) ← 子的一切图记忆写入落这里,与子同生共死
|
||
└─ 主图记忆的**受限句柄**(只读) ← 子只能读
|
||
· OpenGraphDBReadOnly:连接可读/可恢复 WAL,但 SQLite 层 `PRAGMA query_only=1`
|
||
把一切写入直接拒掉 —— "子改不了 main" 是**结构性**保证,不靠自觉
|
||
子的图记忆查询 = temp 实例 与 主实例 各查一次,应用层合并(并集)
|
||
```
|
||
|
||
- **不碰共享记忆层**:不加 `space` 列、不做 schema 迁移、55 处 SQL 原样。
|
||
- **隔离靠"不同存储实例"**,不靠 where 条件 —— 漏写条件也不会串台。
|
||
- **回收时由父合入**:父读子的 temp 实例,选出要保留的记录,写进主图记忆(父有写权)。
|
||
- **可选简化**[用户给的备选]:把"允许子写图记忆"做成 profile 开关
|
||
(`AllowTempGraphWrite`,默认开)。设为 `false` 时子对图记忆**完全只读**,
|
||
没有 temp 实例、没有合入 —— 代价是回收时只剩状态面/处理表可收割。
|
||
|
||
### 5.4 待钉的边界 [默认]
|
||
|
||
- temp 与 main 是**同一套记忆子系统里的命名空间**(同一批表/索引 + 一个 space 维度),
|
||
不是独立存储 ⇒ "并集读"就是一次查询里的两个 space 条件;
|
||
- 父**可读**子的 temp(要决定 promote 什么),属"查看状态面"的一部分;
|
||
- temp **与子同生共死**;子之间 temp **互不可见**。
|
||
|
||
---
|
||
|
||
## 6. 中断模型:两条独立阶梯 [已定]
|
||
|
||
```
|
||
在【父的】中断阶梯上:
|
||
子的主动消息 = L3 中断 (子主动汇报,带子标识)
|
||
子的 contextfull = L4 中断 (资源耗尽,需父立即决策,带子标识)
|
||
|
||
在【子的】中断阶梯上:
|
||
父的消息(发送消息) = L4 中断 ← 子的 L4 归父独占
|
||
```
|
||
|
||
### 6.1 L4 归属通则
|
||
|
||
> **某个 agent 的 L4 只属于它的"内核"。**
|
||
|
||
- 根 agent 的内核 = 内核自身(panic / 内核事件 selfip)+ 内核级插件(WebUI 终止按钮);
|
||
- 驻留子的内核 = **父 agent**。
|
||
|
||
⇒ 现有 `isKernelLevelSource`(只认编译期内置插件)**泛化为"该 agent 的上级"**,不为子开特例。
|
||
|
||
### 6.1.1 **父消息 = 子的 L4**(落地机制,钉死)
|
||
|
||
父 → 子的"发送消息"是一条 **L4 中断**,它是**子的阶梯上唯一的 L4 来源**。具体落地:
|
||
|
||
```
|
||
父【发送消息】到指定子
|
||
└─ 经输出通道寻址到该子的某个 inputch
|
||
└─ 在该子的调度器里按 L4 登记(子的内核级来源 = 父)
|
||
└─ 子的 L4 > 子内部一切 ⇒ 立即打断子的当前任务
|
||
```
|
||
|
||
- **子内部任何来源都够不到 L4**:子自己的输入注入、工具、定时器、插件……
|
||
一律被夹到 **≤ L3**(与根 agent 里"外部插件被夹到 L3"完全同一条规则,
|
||
只是"内核级来源"从"编译期内置插件"变成了"父 agent")。
|
||
- 后果一:**子内部任何东西都压不过父**(父的话是子的最高级输入)。
|
||
- 后果二:父的"取消当前状态 + 插入新消息"因此是**确定能生效**的
|
||
—— 不会因子内部正在跑什么而被挡住(除非子处于不可抢占临界区,
|
||
此时按调度器既有规则在安全点生效)。
|
||
|
||
**子侧内核级事件如何上报**(推论,待确认):子的 panic / contextfull 属于**子侧的内核级事件**,
|
||
但在**子的**阶梯上 L4 归父独占,所以它们不能作为"子自己的 L4";应当**上报给父**,
|
||
在**父的阶梯上以 L4(带子标识)**出现 —— 与 §9 的 contextfull 同一条通路。
|
||
(即:父侧 L4 = 子侧内核级事件的接收位;子侧 L4 = 父控制语的发射位。)
|
||
|
||
### 6.2 为什么 contextfull 是 L4 而不是 L3
|
||
|
||
它是**必须由父立刻决策**的场合(三个处置都与子的存续有关),且决策要读**一整个状态面** ⇒
|
||
只能"**推信号 + 拉状态**",不能把状态塞进中断消息里。
|
||
|
||
---
|
||
|
||
## 7. 父对子的控制面(6 个动作)[已定:原语在内核,决策在父的模型]
|
||
|
||
| 动作 | 语义 | 子是否继续存在 | 走哪条路 |
|
||
|---|---|---|---|
|
||
| **创建** | 划入输入通道 + 授权输出通道 + 授权插件/工具(默认完整)+ 在固定提示词之上注入任务提示词 | — | 内核原语 |
|
||
| **发送消息** | 经输出通道寻址到子的 inputch:**取消当前状态 + 插入新消息** | 是 | 对子 = **L4 中断** |
|
||
| **查看** | **pull** 子的状态面(处理表 + temp 产出),**不打断**子 | 是 | 纯查询,**不走中断** |
|
||
| **压缩** | **保留语义**:压上下文 + **清理处理表** | **是**(同一驻留子) | 见 §9 |
|
||
| **回收** | **取消语义**:父看状态面 → 选择哪些 **promote 进 main** → **取消**该驻留子 | 否 | 见 §9 |
|
||
| **销毁** | **立刻销毁并从登记表移除**(不收割) | 否 | 立即 |
|
||
|
||
- **创建/销毁/回收/查看/发送**是**父可调用的原语(工具)**;**决策**(压还是收、收哪些)
|
||
在父的模型手里 —— 内核不替父决定。
|
||
- **默认完整授权**[已定]:子默认拿到全部插件与工具(含输出门);
|
||
父可在创建时**收窄**(收窄工具子集、收窄可用输出通道集合)。
|
||
⚠️ 默认含输出门意味着**子可以直接对用户通道发消息**;若要默认收窄,改一处默认即可。
|
||
|
||
---
|
||
|
||
## 8. inputch 处理表
|
||
|
||
### 8.1 归属与方向
|
||
|
||
- **子是持有者**;父**主动查看(pull)**,**不是**推给父。
|
||
- **内容**:按 inputch 记录**每一轮**子对该 inputch 的处理信息。
|
||
- **存在意义**:长期驻留子的**进度可见性** —— 父不必打断它就能知道它做到哪。
|
||
|
||
### 8.2 写入规则
|
||
|
||
- 子**主动写入**时,系统**不**自动写;
|
||
- 子**未主动写入**时,系统**自动**把该轮 inputch 对应的信息写进去;
|
||
- ⇒ **每一轮必有记录**,父不会看到空洞。
|
||
|
||
[默认]"主动写入"的动作形态 = 子调用一个 `inputch_note` 类**工具**;
|
||
自动写入在轮次结束时由内核兜底。
|
||
|
||
### 8.3 生命周期 = 上下文窗口
|
||
|
||
处理表记的是"**当前这段上下文窗口**里每轮 inputch 做了什么"。
|
||
因此:
|
||
|
||
- **压缩必须清表**(窗口被压成摘要后,逐轮记录被摘要取代;留着会让父看到与当前窗口
|
||
不对应的陈旧状态);
|
||
- **回收不必清表**(表就是父刚读过的收割材料,子都没了,表自然作废);
|
||
- ⇒ 处理表天然有**大小上界**(窗口多大、表最长多长),不需要额外容量策略。
|
||
|
||
---
|
||
|
||
## 9. contextfull 的处置
|
||
|
||
```
|
||
子的上下文窗口满
|
||
└─ 产生 contextfull → **L4 中断**通知父(中断信息里标明是哪个子)—— 只推信号
|
||
└─ 父【查看】子的状态面(处理表 + temp 产出)
|
||
├─ 【压缩】压成摘要 → 清处理表 → 子续用 (保留语义)
|
||
├─ 【回收】选择 temp 中哪些 promote 进 main (取消语义)
|
||
│ → 丢弃 temp → 取消该驻留子
|
||
└─ 【销毁】立刻销毁并移除 (不收割)
|
||
```
|
||
|
||
| 动作 | 语义 | 子上下文/成果 | 子 agent | 处理表 |
|
||
|---|---|---|---|---|
|
||
| **压缩** | **保留** | 压成摘要 | **继续存在** | **必须清理** |
|
||
| **回收** | **取消** | 选中的 promote 进 main | **取消** | 不必清 |
|
||
| **销毁** | 立刻销毁并移除 | 不收割 | 立刻销毁 + 出登记表 | 无关 |
|
||
|
||
[默认]压缩由**子的轻量内核自己执行**(它拥有自己的上下文与 LLM)。
|
||
|
||
---
|
||
|
||
## 10. 登记表与生命周期硬约束
|
||
|
||
- 父持 **agent 登记表**,记录全部子:`id / 状态 / 划入的输入通道 / 授权的输出通道 /
|
||
授权的插件与工具 / 状态面句柄`。
|
||
- 它是**查看 · 发送 · 压缩 · 回收 · 销毁**的寻址依据。
|
||
- **硬约束(必须写成测试)**:
|
||
1. 父 `Stop()` ⇒ 销毁全部子(取消运行中的任务、停轻量内核、释放其通道),
|
||
**登记表清空、不留孤儿**;
|
||
2. **子不得比父活得久**(无孤儿 goroutine / 无悬空通道 / 无残留 temp)。
|
||
|
||
---
|
||
|
||
## 11. 并发与不变量
|
||
|
||
沿用输入调度器的并发模型,并按多 agent 扩展:
|
||
|
||
- **每个 agent 一个调度器 goroutine**(根 agent 与每个驻留子各一个),
|
||
它**独占**自己的队列 / running / 中断栈 / 帧。
|
||
- 跨 agent 投递只经**通道**(值传递),**不共享帧**;父**永远不能**直接改子的帧。
|
||
- **子不得比父活得久**(§10)。
|
||
- 父的"查看"是**只读快照**,不阻塞子、不参与子的调度决策。
|
||
- **L4 独占**:子的调度器只接受来自父的 L4(§6.1)。
|
||
|
||
---
|
||
|
||
## 12. 与现有实现的接合点(差距清单)
|
||
|
||
| 设计项 | 现状 | 要做 |
|
||
|---|---|---|
|
||
| 两类别 + 四级中断 + 抢占/挂起/恢复/中断栈 | ✅ 已落地(见 input-scheduler-design.md) | 复用 |
|
||
| inputch 一等化(归属/容量/授权) | ❌ `inputCh` 是全局单 channel;`ChannelDef` 只是策略表 | **新增通道注册与分配层** |
|
||
| 输出通道可寻址到 agent | ❌ 只有字符串标签;`output_list_channels` 列全部 | 通道解析表 + 授权过滤 |
|
||
| 子的 L4 = 父 | ⚠️ `isKernelLevelSource` 只认内置插件 | 泛化为"该 agent 的上级"(分层) |
|
||
| 轻量内核(记忆作用域化) | ❌ 记忆层绑定单一 main 空间 | 记忆子系统加 **space 维度**;`AgentConfig` 加**作用域参数** |
|
||
| 记忆写路径重定向到 temp | ❌ 写路径无空间概念 | 所有写入口带 space;子的一切写 → temp |
|
||
| 驻留子生命周期 + 登记表 | ❌ 只有一次性 `runChildTask` | 驻留子 + 父的登记表 + 退出清理 |
|
||
| 跨 agent 投递(子→父 L3 / 父→子 L4) | ❌ 无 | 投递原语(复用注入层 + 通道寻址) |
|
||
| inputch 处理表 | ❌ 无 | 新数据结构 + 主动写入工具 + 自动写兜底 |
|
||
| contextfull 检测 | ❌ **完全没有** | 检测 + L4 通知(带子标识)+ 三处置 |
|
||
| 内核不持有"当前通道" | ❌ `currentOutputChannel` + 提示词预设 | 删字段、删预设 |
|
||
| 工作式轻量子 | ✅ | **不动** |
|
||
|
||
### 16.0 N2c 施工方案(轻量内核接线)[已定方案:**窄接口 + nil 即禁用**]
|
||
|
||
**先按"谁在调"把 `a.memory` 的 42 处使用分类**(`grep` 实测,非估计):
|
||
|
||
| 分组 | 位置 | 方法 | 谁用 |
|
||
|---|---|---|---|
|
||
| **A 记忆整理流水线** | `distill.go`(10):`archiveLoop` / `reviewLoop` / `mergeLoop` / `detectEntityMerge` / `reviewRelations` / `archiveColdDocs` | `Recall`, `ClearSentenceID`, `CleanupOrphanedSentences` | **root-only**(后台定时器) |
|
||
| **B 记忆块 + 媒体桥** | `graphmedia.go`(18)、`medialoop.go`(4) | `PutMemoryBlocks`, `AddMemoryBlockEdge`, `BlocksForNode`, `PutDocumentNode`, `MemoryBlocks`, `MigrateLegacyMediaEntities`, `mediaContextFor*` | **root-only** |
|
||
| **C 记忆整理工具** | `toolcall.go::executeMemoryTool`(8) | `Introspect`, `MergeEntities`, `DeleteEntity`, `Purge`, `Commit` | **root-only**(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`) |
|
||
| **D 共同面** | `graphmedia.go:114`(自动写入)、`toolcall.go:130`(`memory_recall`) | **只有 `Recall` + `Commit`** | 根与子都要 |
|
||
| **E 判空/状态** | 22 处 `if a.memory != nil` + `GetKernelStatus` + `buildToolDefs` | — | 既有关卡 |
|
||
|
||
⇒ **子 agent 需要的记忆面只有 `Recall` + `Commit`**;其余全是"整理记忆 / 记忆整理流水线"
|
||
(用户指出的关键点),**子根本不该有那些代码路径**。
|
||
|
||
#### 设计:窄接口 + nil 即禁用(不写"18 个方法返回错误"的受限包装)
|
||
|
||
```go
|
||
// core 内部:共同面(根与子都要)
|
||
type GraphMemory interface {
|
||
Recall(keywords, seedEntities []string, depth int, sessionFilter string) (*memory.RecallResult, error)
|
||
Commit(triples []memory.Triple, sessionID string, turnID int) (int, int, error)
|
||
}
|
||
```
|
||
|
||
| | `a.graph`(共同面) | `a.memory`(整理面:块/媒体/流水线/整理工具) |
|
||
|---|---|---|
|
||
| **根 agent** | 同一个 `*GraphDB` | `*GraphDB` |
|
||
| **驻留子** | `*LightMemory` | **`nil`** |
|
||
|
||
- 子把 `a.memory` 设为 `nil` ⇒ **既有的 22 处 nil 关卡自动禁掉全部 root-only 路径**
|
||
(`executeMemoryTool` 开头已经是 `if a.memory == nil { return "图记忆系统不可用" }`)。
|
||
- 唯一要拆的是**自动写入路径** `commitTriplesWithMedia`:
|
||
图部分 → `a.graph.Commit`;块/媒体部分 → 由 `a.memory != nil` 守卫。
|
||
- 工具表:`memory_recall`(读)对子开放;整理类
|
||
(`memory_merge`/`memory_delete_entity`/`memory_block_merge`/`memory_purge`/`memory_edit`/`memory_stats`)
|
||
**不进子的工具表**(而不是让它们进去再报"不可用")。
|
||
- 轻量 profile 另外不接线的装配:doc 记忆 / context 动态上下文(`pruneOnInput` 等)/
|
||
蒸馏 / 归档 / 关系复审 / 实体合并定时器 / consolidation / 人格门禁。
|
||
|
||
### 16.0.0 传统上下文的实现口径(子 vs 父)
|
||
|
||
"传统上下文"不是一句口号,它对应三处**代码闸门**(都按 `isLightKernel()` 判):
|
||
|
||
| 能力 | 父(完整内核) | 子(轻量内核) | 闸门位置 |
|
||
|---|---|---|---|
|
||
| 时间线拼装预算 | `budget.ContextTokens`(动态上下文算出的份额,≈窗口 32%~53%) | **整个窗口** `budget.MaxContext` | `contextTokenBudget()`(`stepPrepare` 与 `rebaseFramePrefix` 两处) |
|
||
| 按相关度裁剪 + 向 doc 记忆归档 | 通道/注入点声明 `context_policy=prune` 时执行 | **不执行** | `pruneOnInput()` 前置返回 |
|
||
| doc 记忆 / 记忆整理流水线 | 有 | 无(`a.memory == nil` ⇒ 既有 22 处关卡自动关闭) | `memoryface.go` / `tooldefs.go` |
|
||
|
||
**"不裁"的准确含义**:不做**策略性**裁剪(不按相关度挑、不归档),只受"模型能收多少"这个
|
||
**硬上限**约束;而且在撞到硬上限之前,contextfull(90% 窗口)已按 L4 上报父 agent ——
|
||
**丢事件的决定权在父,不在内核**(父可压缩/回收/销毁)。
|
||
|
||
**顺带修掉的既有 bug**:`formatMergedTimeline` 逐事件估算原用 `len()`(**字节**)再 ×2,
|
||
而 `EstimateTokens` 是 rune×2 ⇒ 中文事件被高估 3 倍,窗口还有余量也提前 break、
|
||
把更早事件整段丢掉(实测 2384 字中文事件被估成 14398 token > 8192)。已改为统一的
|
||
`EstimateTokens`。这条 bug 对父同样有效(中文长会话会被过早裁剪)。
|
||
|
||
### 16.0.1 工具面(已实现)
|
||
|
||
| 工具 | 谁用 | 作用 |
|
||
|---|---|---|
|
||
| `resident_agents` | 父 | **单工具多动作**:`list` / `create`(划入 inputch + 授权输出通道 + 注入任务提示词)/ `send`(对子 = L4)/ `inspect`(pull 处理表,不打断)/ `compress`(保留)/ `reclaim`(取消 + 合入)/ `destroy` |
|
||
| `notify_parent` | 子 | 主动汇报(父侧 = **L3 中断**) |
|
||
| `inputch_note` | 子 | 主动写本轮 inputch 处理信息(写了就不自动写) |
|
||
|
||
> 声明是条件式的:父(`parentID == ""`)才有 `resident_agents`;子才有 `notify_parent` / `inputch_note`。
|
||
|
||
### 16.1 N2 的记忆面清单(现状)
|
||
|
||
`internal/memory/` 下需要加 space 维度的面:
|
||
|
||
| 面 | 载体 | 表 | 子 agent 可用? |
|
||
|---|---|---|---|
|
||
| **图记忆** | `memory.GraphDB` | `entities` / `sentences` / `relations` | ✅ **作用域化**(读 temp∪main,写 temp) |
|
||
| **图记忆的向量检索** | `memory.Indexer` | (索引侧,与图记忆同步) | ✅ 同图记忆(需按 space 过滤) |
|
||
| 文档记忆(doc 记忆) | `document.Store` | `documents` | ❌ 父专属 |
|
||
| context 动态上下文 | 内核上下文装配/裁剪(`pruneOnInput` 等) | — | ❌ 父专属;子用传统上下文 |
|
||
| 知识库 | `knowledge.Store` | 各自表 | ❌ |
|
||
| 文本记忆 | `text.Memory` | 各自表 | ❌ |
|
||
| 媒体 | `media.Store` | 落盘 + 索引 | ❌ |
|
||
| 社交 | `social` | 各自表 | ❌ |
|
||
|
||
⇒ N2a 做**图记忆 + 其向量检索**这一条纵切(子唯一可用的记忆面,
|
||
也是"子写 temp / 父 promote 进 main"的主战场);其余面在 v1 **不加 space 维度**
|
||
(子根本够不到,加了是白工)——若将来子扩展记忆面再逐面补。
|
||
|
||
---
|
||
|
||
## 13. 非目标(本文明确不做)
|
||
|
||
1. 跨进程 / 跨主机的驻留子(v1 只在同进程内)。
|
||
2. 子的**子**(驻留子再创建驻留子)——先不做,保留扩展位。
|
||
3. main 空间的**多写者**(父是唯一写者,不做并发合并)。
|
||
4. temp 空间的持久化(跟子同生共死,不落盘)。
|
||
5. 工作式轻量子的任何行为变更。
|
||
|
||
---
|
||
|
||
## 14. 测试点、方式与预期
|
||
|
||
| 编号 | 测试点 | 方式 | 预期 |
|
||
|---|---|---|---|
|
||
| S1 | 创建:划入输入通道 + 授权输出通道 | 创建子,向划入的 inputch 投输入 | 子处理它;未划入的 inputch 投不进(或报错) |
|
||
| S2 | 授权收窄 | 创建时只授权部分工具/插件 | 子的工具表恰为该子集;`output_list_channels` 只列授权的 |
|
||
| S3 | 默认完整授权 | 不传授权参数创建 | 子拿到全部插件/工具 |
|
||
| S4 | 子→父 L3 | 子在工作中主动发消息 | 父侧收到 **L3 中断**且带子标识;父可被打断(非临界区时) |
|
||
| S5 | contextfull → L4 | 灌满子的上下文 | 父侧收到 **L4 中断**、带子标识,且**只推信号** |
|
||
| S6 | 父→子 L4 | 父"发送消息"到指定子 | 子在收到时被打断(L4 > 子内部一切),按 §15 的默认挂起/恢复 |
|
||
| S7 | 查看(pull 不打断) | 子在跑长任务时父"查看" | 返回状态面快照;**子的 step/帧不变**、未被抢占 |
|
||
| S8 | 处理表:自动写兜底 | 子一轮不主动写 | 该轮仍有记录(系统自动写) |
|
||
| S9 | 处理表:主动写优先 | 子主动写 `inputch_note` | 该轮只有主动写的内容,无自动写 |
|
||
| S10 | 压缩(保留语义) | 父选压缩 | 上下文变短;**处理表被清空**;**子继续存在**且能继续干活 |
|
||
| S11 | 回收(取消语义) | 父选回收并挑若干条 promote | 选中内容进 **main**、其余丢弃;**temp 被丢弃**;**子被取消** |
|
||
| S12 | 销毁(立刻) | 父销毁(含子正在跑工具/LLM 时) | 子立刻消失、出登记表、其 temp 丢弃、通道释放 |
|
||
| S13 | 子不得写 main | 子调记忆写工具 | 落在 **temp**;main 无新增 |
|
||
| S14 | 子查询范围 = temp ∪ main | 子查只在 main 里的内容 / 只在 temp 里的内容 | 两者都能查到 |
|
||
| S15 | 子之间 temp 隔离 | 两个子各写 temp,互相查 | 查不到对方的 temp |
|
||
| S16 | 父退出清理 | 父 `Stop()`(多个子、有子在工作中) | 全部子被销毁;登记表空;**无孤儿 goroutine / 无悬空通道 / 无残留 temp** |
|
||
| S17 | L4 独占 | 子内部(子自己的输入/工具/定时器)试图产生 L4 | 被夹到 **≤L3**;只有父的消息是 L4 |
|
||
| S21 | 一个插件多个 inputch 可分别路由 | 同一插件注册 2 个 inputch,分别划给父与子后各投一条输入 | 各自只到被划给的 agent,互不串台 |
|
||
| S22 | inputch 总览(单工具多视图) | 一个插件注册 2 个 inputch、另一插件 1 个;把其中若干划给本 agent | `view=all` 列出全部(带归属插件);`mine`/`unassigned` 各自正确;`by_agent` 给出划分总览;`detail` 给出单条全字段;未知 view 报错并列出可用值 |
|
||
| S23 | 插件重载不抹划分 | 先划分 inputch,再重复登记(模拟插件重载) | Owner/Capacity 保留,仅策略被更新 |
|
||
| S19 | 父消息必能打断子 | 子在长任务中(LLM 流式段)时父发送消息 | 子按 L4 被打断;若子在不可抢占临界区,则在安全点生效 |
|
||
| S20 | 子的内核级事件上报 | 子 panic / 子 contextfull | 在**父的阶梯上以 L4(带子标识)**出现;子侧不自己产生 L4 |
|
||
| S18 | 内核不持有"当前通道" | 抢占/中断后被打断任务恢复并发响应 | 提示词与事件标签都**只来自输入事件**(不再有被覆盖的字段) |
|
||
|
||
---
|
||
|
||
## 15. 待确认决策(含默认取值)
|
||
|
||
| 编号 | 问题 | 取值 |
|
||
|---|---|---|
|
||
| **R1** | 驻留子的内核形态 | **[已定]独立轻量内核**(自己的调度器/中断栈/上下文/记忆作用域) |
|
||
| **R2** | 插件与工具 | **[已定]父授权,默认完整授权**(可收窄) |
|
||
| **R3** | 记忆模型 | **[已定]两级空间**:读 temp∪main,写 temp(**范围 = 图记忆**) |
|
||
| **R3b** | 子的记忆面 | **[已定]传统上下文 + 图记忆**;doc 记忆与 context 动态上下文是**父专属**(内核独立设计的记忆能力) |
|
||
| **R13** | inputch 总览的形态 | **[已定]单工具多视图**(`input_channels` + `view`) |
|
||
| **R11** | 父消息的级别 | **[已定]对子 = L4**(子的阶梯上唯一 L4 来源;子内部一律 ≤L3) |
|
||
| **R12** | 子的内核级事件(panic / contextfull)上报级别 | **[默认/推论]在父的阶梯上以 L4(带子标识)上报** |
|
||
| **R4** | 父→子消息落地 | **[默认]挂起/恢复**(现场不丢);一处开关可改"直接丢弃" |
|
||
| **R5** | "主动写入处理表"的形态 | **[默认]子调用 `inputch_note` 类工具**;未调用则轮末自动写 |
|
||
| **R6** | 输入通道"划入"的语义与容量 | **[默认]读写授权(不转移所有权)+ 创建时给定容量**;**划入单位 = inputch**(不是插件、不是通道组) |
|
||
| **R7** | 压缩由谁执行 | **[默认]子的轻量内核自己压**(它有自己的上下文与 LLM),压缩后清表 |
|
||
| **R8** | 压缩前父是否"查看后决定" | **[默认]纯机械压缩**(父只在选"压缩 vs 回收"时决策) |
|
||
| **R9** | 回收时处理表 | **[默认]不必清**(子都没了);若日后要留作审计需单独策略 |
|
||
| **R10** | 默认授权是否含输出门 | **[默认]含**("默认完整授权"的字面含义);若嫌宽,改默认即可 |
|
||
|
||
---
|
||
|
||
## 16. 实现里程碑(每步 = 一个可独立验收的提交)
|
||
|
||
| 里程碑 | 内容 | 验收 |
|
||
|---|---|---|
|
||
| **N0** | **无状态化**:删 `Agent.currentOutputChannel`、删提示词里的通道预设 | S18;既有全部测试通过(这是纯收敛,不含新能力) |
|
||
| **N1a** | **通道登记层**:inputch 一等化(归属插件 / 归属 agent / 容量 / 共享登记表)+ **单工具多视图总览** | S21–S23 |
|
||
| **N1b** | 输出通道授权过滤 + 目标解析(outputch → 目标 agent 的 inputch) | S1–S3 |
|
||
| **N2a** | ~~作用域对象 + 图记忆 space 维度~~ **已完成(改为独立存储实例)**:`OpenGraphDBReadOnly`(query_only 受限句柄)+ `LightMemory`(temp 可写 / 主库只读 / 并集查询 + 应用层合并) | S13–S15 ✅ |
|
||
| **N2b** | 图记忆的向量检索在并集下的排序/去重(当前按实体名/三元组合并,检索排序沿用单库语义) | 待做(非阻塞) |
|
||
| — | 其余记忆面(doc 记忆 / 动态上下文 / 知识库 / 文本 / 媒体 / 社交):**v1 不加 space**(子不可达) | 由 S13/S14 隐含 |
|
||
| **N2c** | ~~Agent 级 profile~~ **已完成**:`GraphMemory` 窄接口(Recall/Commit)+ `a.graph` 共同面;子 `a.memory = nil` ⇒ 22 处既有关卡自动禁用整理面 | S13–S15 ✅ |
|
||
| **N2d** | ~~晋升与丢弃~~ **数据面已完成**:`GraphDB.ExportTriples` + 复用 `Commit` 合入(父选哪几条);`LightMemory.Close()` 丢弃 temp | S11 数据面 ✅ |
|
||
| **N3** | ~~驻留子生命周期~~ **已完成**:`SpawnResident` / `DestroyResident` / `Residents()`(登记表)/ `Stop()` 内 `StopResidents()`(父退出不留孤儿)/ 归还划入的 inputch / 丢弃 temp 目录 | S12、S16 ✅ |
|
||
| **N4** | ~~跨 agent 投递~~ **已完成**:子→父 `notify_parent`(L3,投父的 `child/<id>` inputch);父→子 `SendToResident`(L4,`KernelSource` 分层使父在子的阶梯上是唯一 L4 来源);子的 contextfull 经 `raiseKernelInterrupt` 以 L4 上报父 | S4、S6、S17、S19、S20 ✅ |
|
||
| **N5** | ~~inputch 处理表~~ **已完成**:`inputch_note`(主动写优先)+ `autoRecordInputch`(轮末兜底)+ `CompressResident` 清表 + 父 `ResidentTable(id)` pull 查看 | S8、S9 ✅ |
|
||
| **N6** | ~~contextfull~~ **已完成**:判据 = **未裁剪的积累上下文**超过窗口 90%(不能用拼好的 `f.Msgs`——它被 token 预算封在 ~80% 窗口内,是永不成立的判据);通知 = 父侧 L4(`child/<id>`);三处置 = `CompressResident`(保留:`TrimKeepRecent` + 清表)/ `ReclaimResident`(取消:`ExportTriples` 选出后 `Commit` 进 main)/ `DestroyResident` | S5、S10、S11 ✅ |
|
||
| **N7** | ~~e2e + 压力~~ **已完成**:`resident_test.go` 五项(生命周期/双向投递/处理表/contextfull 三处置/8 子×12 轮压力 + 双向汇报),`-race -count=3` 干净 | S1–S20 覆盖 ✅ |
|
||
|
||
每步收尾命令:
|
||
|
||
```bash
|
||
export GOCACHE=/tmp/gocache GOPATH=/tmp/gopath TMPDIR=/var/tmp/gotmp
|
||
gofmt -l internal/agent internal/plugin internal/sdk # 本步新增文件必须为空
|
||
go build ./... && go vet ./...
|
||
go test -count=1 ./... && go test -race -count=1 ./internal/agent/... ./internal/plugin/...
|
||
```
|
||
|
||
---
|
||
|
||
## 17. 与发布纪律的关系
|
||
|
||
- 本设计在 `feature/input-semantics` 之后的特性分支上开发,完成后合回 `main`。
|
||
- 若需要动公开 SDK(例如新增 `agent_*` 控制面原语、通道授权字段),按"**只增不减、签名不改**"
|
||
追加,并同步 `docs/zh/plugin-interface-matrix.md` 与 SDK 仓版本。
|