Files
HomeAgent/docs/zh/resident-subagent-design.md
JianFeeeee a68d6a428c feat: 模块化内核(编译期依赖图定序)+ 密钥防火墙 + 插件三态生命周期 (#10)
* feat(kernel): 模块化内核 + 编译期依赖图定序

把单体 init 序列拆成「模块自声明依赖 + 编译期生成依赖图 + 运行期只读图」。

## 为什么

原 cmd/homed/main.go 里 17 步 init 是**手写顺序**,依赖关系只存在于
注释和人脑里。实测踩到的坑(见 docs/zh/kernel-modularization-design.md):
 · nlp.defaultParser 被 initONNXParser 写、被 distiller 读,两者相距 60 行,
   只靠 ticker 10min 才没炸(隐式依赖,无声明)
 · 加一个模块要人肉找"该插在哪",插错的表现是运行期 nil 解引用
 · 关停是 defer ks.CloseAll(),而热更新路径 os.Exit(42) **跳过 defer**
   ⇒ docStore/distiller/textMem 的 flush 全部丢失(每次热更新都发生)

## 怎么改

- internal/kernelboot/:KernelState(一个结构体收全部包级共享变量,字段
  nil 安全)、Module 接口、graphfile(依赖图编解码 + 与二进制 hash 对账)、
  scheduler(按 BFS 层并行 Init)、syncbus(层边界写通知)、
  lifecycle(Quiesce/Resume 可逆伪关机,区别于一次性 CloseAll)
- internal/kernelmod/<name>/:每个模块一个文件夹,Requires/Provides 自声明;
  all/all.go 是唯一的 import 清单(Go 循环导入约束)
- cmd/homed-depgraph:编译期生成依赖图,Makefile 注入 digest
- 运行期只**读**图,不计算顺序(顺序是编译期决定的,源码里没有中央清单)

## 判据

kernelboot 8 条 + 图失败模式 7 条(全部拒绝并给准确错误)+ 模块目录数
与注册表一致性。变异测试 15/15 咬住,含:
 · 删掉 AddCloser 的锁 → -race 报数据竞争
 · Scheduler 同批跑去重 → 203 vs 200
 · 图 hash 不匹配 → 拒绝启动

## 顺带修的缺陷

热更新路径漏调模块关闭 ⇒ 显式 stopKernelModules(),并用 AST 判据钉住
"必须在 os.Exit 之前"(字符串检查在本仓已骗过我三次)。

实测:冷启动 1630ms(5 层/17 模块,原 17 步串行);生产 22 插件全部建链。

* feat(security): 密钥防火墙 + 配置加密 + close 纪律

## 密钥防火墙

模型只见别名、不见明文;插件工具调用入口(StageHost.ExecuteTool)做入向
回填。三维能力:
 · 出向:工具输出里的密钥被替换成 [[secret:name]] 别名后才进上下文
 · 入向:模型传别名,入口处填回真值(插件完全无感知)
 · 存储:config.db 里不存明文(0644 的库可读),敏感值独立 0600 文件

模式库「宁漏勿误伤」:宁可漏掉一次替换,也不把正常文本误当密钥。

## 配置加密

- internal/security/{codec,key,keyring,store,global}.go:AES-256-GCM,
  enc:v1: 前缀,密钥轮换(OldKeys 解码 + 新钥加密)
- harden.go:数据目录权限收紧
- embedded.go:支持内嵌主密钥(可选,仍保留外部 key 文件路径)
- EncryptExistingSecrets / AuditSecrets:启动时幂等迁移 + 审计

## close 纪律(用户明确要求:只要调 close 就要有前置检查)

internal/safeclose:Safe / SafeFunc。全仓 37 处裸 close(chan) → 0。
关键教训(都写进注释和判据):
 · `select { case <-ch: default: close(ch) }` **不是**并发安全的
   (两个 goroutine 可同时命中 default)—— healthcheck 原先是这种写法
 · `sync.Once` **不可重入**:X.Do(func(){ Safe(&X, ...) }) 会自锁,
   症状是 go test 直接挂住(3 个包 600s 超时),栈里只有嵌套的 Once.Do
 · map 里按请求持有的 channel(mcp/stdio.go、clawhubadapter/sidecar.go)
   改成 *pendingRPC 包装,每通道一个 once

## 判据

security 全量 + TestNoBareCloseOutsideSafeclose(AST 级扫描,含注释剥离)
+ TestNoNestedSelfOnceDeadlock(正则修正:\w+ 不匹配点号,捕获组要含 '.')。
变异 2/2 咬住。全量 52 包 ok。

* feat(plugin+deploy): 插件三态生命周期、数据目录迁移、发行包修复

## 插件三态生命周期(用户要求:自动启动 / 启用但不自动启动 / 禁用)

- Manifest 新增 requires_facilities / auto_start(*bool,用于区分
  "未声明"与"显式 false")
- plugin-host 模块(internal/kernelmod/pluginhost):用 kernelboot.Scheduler
  按依赖时机拉起,**不再手写两阶段**
- 运行态停启 StopPlugin/StartPlugin **可逆**:不写 disabled_plugins
  (不改用户决定)、不置 shuttingDown(之后还能加载)、会摘掉注册面
- 与 StopAll 的区别写进注释 —— StopAll 置 shuttingDown 后**永久无法加载**,
  那是进程退出用的,误用于"暂停"就是关了就死

## 数据目录迁移(伪关机 → 迁移 → 唤醒)

- kernelboot/lifecycle.go:Quiesce/Resume(可逆,区别于一次性 CloseAll),
  逆序暂停、逆序恢复、Pause 失败回滚
- kernelboot/migrate.go:BeginMigration/End 编排(先停插件 → 再停内核写者
  → 等在途归零)
- distiller/textmem 增 Pause/Resume 门(不是 cancel —— cancel 不可逆,
  恢复要重建整个对象)
- 模块 migration:体检 + 生成可执行迁移脚本

★ 迁移脚本的三个实测教训(都写成了判据):
 1. 无条件改 /etc/systemd/system ⇒ 演练把**生产** -data 改成了演练目录。
    加 --dry-run(零改动)+ 强制校验 systemd 当前 -data == OLD
 2. 本机没装 rsync,脚本在"服务已停、数据未搬"处硬失败 ⇒ cp -a 回退,
    并明确警告不保硬链接/ACL(生产 plugindev 有大量 git 硬链接)

## 发行包修复(全部经实测确认)

 1. package-linux.sh **完全漏打 depgraph.bin** ⇒ 内核 exit 1
    ("生产构建(内嵌 hash 非空)但读不到依赖图")
 2. build_homed 不走 Makefile ⇒ 单独修 Makefile 无效,必须同时生成图+注入
 3. build.sh 的 LDFLAGS 用**历史模块路径 gitcode.com** ⇒ -X 静默 no-op
    ⇒ --version 显示 unknown
 4. Makefile -ldflags 用**单引号** ⇒ 注入了字面 $GRAPH_HASH

## ★ build.sh 的版本号必须校验(真实事故)

原 `VERSION="$(git describe --tags --dirty)"`。无 tag 分支上它返回**分支名**
`backup/pre-identity-rewrite-20261005-51-g03d87a4e-dirty`,被注入 meta.Version
⇒ 插件握手判据 MajorVersion() 把整串当主版本 ⇒ **22 个子进程插件全部
握手失败**("主版本不匹配")。内核活着、systemd active、日志每条都像
插件的问题 —— 实际是发行包带着非法版本号。

修法:git describe 只在形如 vX.Y.Z 时采用,否则回退 meta.Version(权威源),
并对最终值做形态守卫。deploy/packaging/version_guard_test.go 3 条判据,
变异 2/2 咬住。

## deb 的 systemd unit 改为 guard 模式

ExecStart=/usr/bin/homed --role=guard --data /var/lib/homeagent
(worker exit 42 → guard 重拉,不经过 systemd、不计失败)
含 RestartSteps/RestartMaxDelaySec/TimeoutStopSec + 硬化
(NoNewPrivileges/ProtectSystem/ProtectHome/DevicePolicy/ReadWritePaths)。

## 其他

- internal/plugin/proc:非法 JSON 帧的报错**可定位**(内容摘要 + 可能原因 +
  修法)。生产实测 1038 条非法帧(插件往 stdout 打日志 = 污染协议通道)
- 示例插件 4 个 14 处 fmt.Printf → log.Printf
- hmapdev 模板同步修复;scaffold 判据剥离注释(模板注释里就含 fmt.Printf)

* chore: 收尾(主密钥轮换命令、IPC/知识库加固、SDK 对齐)

- cmd/homed-key-rotate:把 security 里的 RotateKey/RotateKeyFile/RekeySecrets
  串成一条**可执行**的运维路径。没有它,E-1 就只是库里的能力而不是
  "出事时能做的事",而轮换的价值恰恰在于能执行。
- cmd/homed:kmod_runtime(模块装配入口)、plugins_scheduled(failback
  白名单)、memcandidate、bootstrap_order_test / shutdown_flush_test
- internal/ipc、internal/devicebridge、internal/knowledge:配套加固
- third_party/homeagent-sdk/sdk:PluginMgrAPI 增三态能力(mgrStop/mgrStart/
  mgrIsRuntimeStopped)+ settings 对齐

判据:bootstrap_order_test(启动顺序不依赖手写序列)、
shutdown_flush_test(停机的两条路径必须对称 —— exit 42 会跳过 defer,
热更新路径下模块关闭/flush 曾全部丢失)。

* test(security): 测试夹具改用不可误认的占位值(修 push protection 拦截)

GitHub secret scanning 拦住了推送:三处测试夹具用了"看起来像真密钥"的
字面量:

  internal/security/prefilter_test.go:39
  internal/security/store_test.go:177
  internal/security/survey_test.go:20

它们全是**假密钥**(`AKIAIOSFODNN7EXAMPLE` 是 AWS 官方文档的标准示例,
其余是 `abcdefghij...` 这类可读占位符),但扫描器无法区分 —— 它只看形态。

★ 不绕过(不用 unblock URL、不加 allowlist):直接改夹具。
原因:一旦允许"假密钥可以通过",真密钥混进来时就没有最后一道门了。

改法:保留**模式库匹配所需的结构**,把可识别内容换成 FAKE:
  glpat-abcdefghijklmnopqrst       → glpat-FAKE-TEST-TOKEN-NOT-REAL
  sk-abcdefghij...012345           → sk-FAKE-TEST-KEY-NOT-REAL-0000000
  AKIAIOSFODNN7EXAMPLE             → AKIA0000FAKE0000TEST   (保持 20 字符)
  ASIAIOSFODNN7EXAMPLE             → ASIA0000FAKE0000TEST
  xoxb-123456789012-abcdefghijklmnop → xoxb-FAKE-TEST-TOKEN-NOT-REAL

★ 中途踩到一次:AWS 键的模式是 `\b(AKIA|ASIA)[0-9A-Z]{16}\b`(共 20 字符),
我第一次替换成 19 字符的 `AKIAFAKETESTKEY0000` ⇒ 模式匹配不上 ⇒
TestSurvey_WhatIsCaught / TestRecognizesCommonShapes /
TestPrefilterNeverBlocksRealMatches 三条当场变红。
判据正确地拒绝了"为了过扫描器而削弱覆盖"。

顺带核实:全仓 grep 无任何真实密钥泄漏。

* fix(packaging): package-linux.sh 的版本解析与 build.sh 统一

上一版只修了 build.sh,漏了 package-linux.sh —— 它仍只看 git describe:

  VERSION="${VERSION:-$(git describe --tags --dirty)}"

无 tag 分支上 git describe 返回**分支名**
(backup/pre-identity-rewrite-20261005-56-g98850e1),守卫会正确拒绝,
但要求人**手动传 VERSION** 才能打包。而 build.sh 早已能自动回退 meta.Version
⇒ 同一个版本,走 build.sh 打得出来、走 package-linux.sh 打不出来。

危害不只是"要手动传参":运维为了跑通会去掉守卫,而分支名一旦进
meta.Version 就是**插件协议主版本**的来源 ⇒ 22 个子进程插件全部握手失败
(这是真实发生过的事故,见 3efa4e5)。

修法:两个脚本用同一套规则(git describe 仅在 vX.Y.Z 形态时采用,
否则回退 meta.Version),并对最终值做形态守卫。

判据:TestBothPackagingScriptsShareVersionRules —— 逐个读两个脚本,
要求都能从 meta/meta.go 取权威版本、都校验 git describe 形态。
变异(把 package-linux.sh 改回旧规则)咬住并准确指出是哪个脚本。

实测:不传 VERSION 跑 package-linux.sh,输出
  [PKG] warn: git describe 给出非版本串(...),改用 meta.Version=1.4.0
  Version: 1.4.0

* fix(test): 跨仓判据在 CI 上应 skip 而非 fail(修 PR 双红)

CI 上 "Go build / vet / test" 与 "Build linux/amd64" 双红:

  --- FAIL: TestHmapdevTemplateImplementsAllPluginMgrMethods
      bridge_sync_test.go:170: 读模板: open
      ../../../third_party/homeagent-sdk/tools/hmapdev/templates/proc_main.go.tmpl:
      no such file or directory

## 根因:判据依赖了一个**被 gitignore 的路径**

`.gitignore:33` 有 `third_party/homeagent-sdk/tools/`(SDK 仓自治,本仓不跟踪)。
于是:
  · 本机(完整 SDK 检出)有该文件 ⇒ 判据通过
  · CI(只 checkout 本仓)没有该文件 ⇒ Fatalf ⇒ **必红**

我提交时只在本机跑测试、没看 CI,所以这个问题拖到 PR 才暴露。

## 修法

文件缺失时 `t.Skipf` 而不是 `t.Fatalf`。

★ 这不是"放弃检查":
  · 跨仓一致性由 **SDK 仓自己的判据**守着
    (tools/hmapdev/scaffold_stdout_test.go 等),那里才是模板的真实归属地
  · 本判据在本机开发时**仍然生效**(已验证:有文件时 PASS,缺文件时 SKIP)

## 验证

 · 本机(有文件)  → PASS(判据真跑)
 · 模拟 CI(移走 tools/)→ SKIP(不再 fail)
 · 全量 53 包 ok / 0 fail

## 顺带

同目录还有 e2e_template_test.go 也读 tools/,但它是 `//go:build` 门控的
e2e 判据,CI 默认不跑,无需处理。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-17 19:18:24 +08:00

45 KiB
Raw Blame History

驻留式子 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。

已落地(N3):子的 io 通道视图 = 对父的实时回退**

输出通道在 io 层就是 Device,而它们由插件登记在父的 IOManager 上。驻留子确实该有 自己的输入入口与 outputCh(所以子是 NewIOManager()),但必须继承父的设备视图 —— 否则上面 §4.4 的授权集合形同虚设:output_list_channels 为空、output_send__X 连工具都不生成、按名字直调也会被判「通道不存在或不可用」。

落地方式:IOManager.SetParentIO(父)(子创建时挂上)。

方法 行为
GetDevice / GetChannelCapabilities 自己设备表没有时回退到父
ListChannels 自己的登记优先(可覆盖/屏蔽同名通道),其次并入父的可见通道,按名字去重
ExecuteTool 自己的设备工具没有时回退到父
  • 为什么是实时回退、不是创建时复制快照:设备随资源生灭(远程设备上线/掉线以分钟计, 现场日志 60 秒一个来回),快照转瞬即过期;回退永远与父一致(父注销后子立刻看不见)。
  • 回退只解决"看得见",不解决"能不能用":§4.4 的三处过滤点仍然在白名单之后, 授权收窄不受影响(判据:白名单外的通道既不出现在工具表里,直调也被拒且不送达)。
  • 现场缺陷(2026-09-13 用户线上联调实录):父侧通道装载完整、子侧 childIO 空壳, 子调 output_send__cli 被判「通道 [cli] 不存在或不可用」。修复后线上实测:子调 output_send__cli → result: ok,并经 notify_parent 回报。

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
销毁 立刻销毁并从登记表移除(不收割) 否 立即
  • 创建/销毁/回收/查看/发送是父可调用的原语(工具);决策(压还是收、收哪些) 在父的模型手里 —— 内核不替父决定。

7.1 积压任务的及时反馈(内核主动拉起分诊助手)[已定]

背景(2026-09-19 线上实测):主 agent 被一条长任务占住时(当天现场:13 分 5 秒、 8 次 cmd_run),后来的 QQ 消息全部以 level insufficient 排进中断队列干等 —— 同级中断不能抢占同级运行任务(scheduler.canPreempt),只能等前一个跑完。 用户在这十几分钟里收不到任何回复。

定性(用户明确):这不是"内核替父决定",而是及时反馈 —— 主 agent 忙时不该让用户干等。分诊助手的职责是:

  • 简单的、不需主 agent 介入的 → 直接处理并回复;
  • 需要主 agent 介入的 → 立刻回「主 agent 忙碌中,请稍候」,不勉强作答。

行为:当运行任务已持续超过 core.agent.offload_busy_after(默认 5m) 且排队输入积到 offload_min_pending(默认 3)条时,内核:

  1. 拉起(或在 offload_max_residents 内复用一个)分诊助手(OffloadOwned);
  2. 把积压的纯排队输入交给它先行分诊;
  3. 在原队列位置留下一条说明:[系统] N 条积压消息已在主 agent 忙期间交由临时助手 X 先行分诊…。

分诊助手的通道配置(刻意与人工创建的子不同):

  • 不配 inputch:它是内核的干活 agent,不接收任何插件的用户输入;
  • 持有全部输出通道(AllowedOutputs 为空 = 完整授权):它必须能把结果发回 qq/webui 等正确通道(否则干活结果无处可去)。

为什么这套机制自然(用户观察):分诊助手就在同一张登记表里 —— 父能 inspect 它的处理表与轮次、能 send、能按需 compress/reclaim/destroy。 控制面 6 个动作均按 id 生效、不区分来源,因此回收策略对它自动适用。 状态面额外暴露 offload_owned,让父能分清"我建的子"与"内核临时拉的助手"。

残余任务由父显式决定(用户 2026-09-19 要求):回收/销毁一个分诊助手时, 它手头可能还有尚未处理的消息。内核不自己决定这些消息的命运,而是:

  • residual=keep(默认):逐条转回父自己的队列,父稍后处理;
  • residual=drop:明确丢弃,逐条记日志(不可追溯的丢弃是不允许的);
  • 两种路径都仍要给 ResponseCh 补终态,否则 cli/a2a 这类无超时同步调用方 会永久挂起(设计 §7 I5)。

默认关闭(core.agent.offload_enabled=false):它改变的是系统行为而非修 bug, 按「显式才是特权」(与 scheduler.DefaultLevel 同一条理由)由部署方打开。

不做的事(边界):

  • 只转投 TaskQueued 纯排队输入。中断任务带级别语义(转投会打乱中断阶梯)、 self 任务是内核内部记账(与父的记忆面绑定)—— 两者都不动。
  • 只对根 agent 生效:子再去拉孙子会形成无界增殖,而积压的源头是根那条链。
  • 实现上检查跑在独立 goroutine:schedulerLoop 是同步执行的, 放在那里在「正忙」期间根本不会回到循环顶部(等于永不触发)。
  • 转投时推原事件(DeliverRouted)而不是重建:重建会丢掉 ResponseCh, 使同步调用方永久挂起。

💡 两条容易重犯的坑(都已在实现里修掉并写进测试):

  1. 积压可能全堆在 io.inputCh(因为忙时 pumpInbox 没被调用), 只数 sched.queue 会得到 0 ⇒ 永不触发。
  2. 分诊助手必须继承父的 SystemPrompt:它里面写着「面向 qq 等异步通道时 必须显式 output_send,纯文本会被静默丢弃」。缺了它,子处理完却发不出去。
  • 默认完整授权[已定]:子默认拿到全部插件与工具(含输出门); 父可在创建时收窄(收窄工具子集、收窄可用输出通道集合)。 ⚠️ 默认含输出门意味着子可以直接对用户通道发消息;若要默认收窄,改一处默认即可。

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 个方法返回错误"的受限包装)

// 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 覆盖 ✅

每步收尾命令:

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/git-branching.md §八 的接线清单同步(含 hmapdev 模板)与 SDK 仓版本(§七)。

18. 与 kernel-modularization-design.md 的关系

本文 §4.1 的 inputch 共享总线(父与驻留子共用同一份 ChannelRegistry)是 kernel-modularization-design.md §5 引用的"运行期共享总线的既有先例":

本文的 inputch 共享总线 该文的启动期依赖声明
时机 运行期(消息路由、按 Owner 投递) 启动期(模块装配顺序)
机制 共享登记表 + 惰性查找 Requires/Provides + 拓扑排序
失败语义 找不到 owner ⇒ 兜底给根并留痕(不吞输入) 缺依赖 ⇒ 拒绝启动

★ 两者不可互相替代:inputch 总线解决的是"运行期谁处理这条消息", 它对"启动顺序"没有任何保证;而启动期需要的是拓扑确定性, 不是"事件到达时再决定"(见该文 §2.2 的反对理由)。

实测关联(2026-10-16):驻留子被通道插件的用户权限模型误拦, 修法是在工具调用帧上加 comefrom 标识(内核侧 internal/agent/core/comefrom.go)。 这不是装配顺序问题,而是运行期身份传递问题——正好印证上表的区分。