Files
HomeAgent/internal/agent/core/overflow.go
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

271 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"errors"
"fmt"
"log"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 上下文超页(context overflow)处理。
//
// 背景(2026-10-01 记忆召回跑分 v4 实测):HA 的 prompt 是锯齿波,峰值达
// 窗口的 7 倍,每轮都在「远超窗口 → 紧急修剪」。根因之一是超页的三条触发
// 路径没有统一出口:
//
// ① 上游 context_full 错误 —— 完全没接,整轮 outcomeFailed(用户在报错里看到它)
// ② 本地积累超限 —— 只在轮首 checkContextFull,且根 agent no-op、一次性
// ③ 子 agent contextfull —— 已在用 L4 上报,但只推信号不携带动作
//
// 本文件把三条路径收敛到 L4 唯一入口(raiseKernelInterrupt),并把「裁剪」
// 做成同步动作 —— 详见 docs/zh/context-overflow-l4-design.md。
// overflowRatio 是触发超页的本地预判水位:积累上下文超过窗口的这个比例就裁剪。
//
// 为什么是 1.25 而不是 1.0:请求体本身被 buildMessages 按 targetUsage=0.8×窗口
// 裁过(见 buildMessages),所以「请求装不下」在本地永不会发生;能观测到的
// 超限信号是**积累上下文**(a.context 里全部事件)。1.25 留了一点余量,避免
// 在水位线附近反复触发。
const overflowRatio = 1.25
// overflowRecoverBudget 是「同一 TaskFrame 内超页恢复次数」的上限。
//
// 为什么需要上限:裁剪的目标是让积累量回到窗口以内,一次通常就够;但若
// topK 与窗口脱钩(max_context_size 是**条数**不是 token,见 pruneByQuery),
// 一次裁剪可能仍不达标。没有上限就会变成「裁剪 → 复原 → 又超页 → 再裁剪」的
// 循环,每次都付一轮任务调度开销。
//
// 超限时**不做静默重试**:保存现场并明确终止,把问题暴露给用户与状态面。
//
// 声明为 var 而非 const:测试要能变异它做判据自证(变异后超预算用例必须变红),
// 将来若要按窗口大小动态给额度(窗口越大越值得多试几次),也不用改结构。
var overflowRecoverBudget = 2
// accumulatedTokens 估算**未裁剪**的积累上下文 token 数。
//
// ⚠️ 判据必须用积累量,不能用 f.Msgs:后者已被 buildMessages 按
// targetUsage = 0.8×窗口 裁过,结构上封顶 80%,对任何 >0.8 的阈值都**永远
// 不成立**(这个坑已由 resident.go:checkContextFull 的注释记录过一次,
// 这里抽成函数是为了让两处共用同一口径,避免再次漂移)。
func (a *Agent) accumulatedTokens() int {
if a == nil || a.context == nil {
return 0
}
acc := 0
for _, e := range a.context.Recent(0) {
acc += eventTokens(e)
}
return acc
}
// eventTokens 估算单条上下文事件的 token 占用。
//
// ★ ToolResults 必须计入 —— 这是实测踩过的坑:v4 与 T10 的 prompt 峰值
// (17 万~40 万 token)几乎全部来自工具回灌,而 ContextEvent 里工具输出是
// **独立字段**(ToolResults []ToolResultItem),不算它的话 accumulatedTokens
// 会返回 0,超页判据永远不触发(诊断日志 ratio=0.00/ events=1 就是这么来的)。
//
// resident.go 的 checkContextFull 有同样缺陷,但它只对驻留子生效、一直没暴露;
// 现在两处共用本函数,口径不会再漂移。
func eventTokens(e ContextEvent) int {
n := EstimateTokens(e.Input) + EstimateTokens(e.Response)
for _, tr := range e.ToolResults {
n += EstimateTokens(tr.Output)
}
return n
}
// contextWindowTokens 取本 agent 的窗口上限(token)。
func (a *Agent) contextWindowTokens() int {
if a != nil && a.provider != nil {
if max := a.provider.MaxContextTokens(); max > 0 {
return max
}
}
return defaultMaxContextTokens
}
// overflowRatioNow 返回「积累量 / 窗口」。窗口拿不到时返回 0(表示不判定)。
func (a *Agent) overflowRatioNow() float64 {
max := a.contextWindowTokens()
if max <= 0 {
return 0
}
return float64(a.accumulatedTokens()) / float64(max)
}
// maybeHandleContextOverflow 是本地预判的入口:**每次发 LLM 请求之前**调用,
// 覆盖轮内 tool 回环(现状只在轮首检查,轮内撑爆时才发现,浪费一整轮工具调用)。
//
// 返回 true 表示已处理(本帧应当重跑),调用方需要重建 f.Msgs —— 因为裁剪
// 之后挂起帧里那份超限请求不能复用(见设计文档约束三)。
//
// 幂等:未超页时零开销(一次 token 估算 + 一次比较),可安全地在热路径调用。
func (a *Agent) maybeHandleContextOverflow(f *TaskFrame, query string) (handled bool, pruned int) {
// 诊断留痕:超页判据在**每次 LLM 请求**都会走,而生产上从未见它触发过。
// 没有这行日志时,「没触发」和「没执行」无法区分(2026-10-01 T10 排查
// 花了 10 轮工具调用就卡在这里)。只记比值与判定,不记内容。
light := a != nil && a.isLightKernel()
noCtx := a == nil || a.context == nil
var ratio float64
if !noCtx {
ratio = a.overflowRatioNow()
}
if a != nil {
a.overflowStat.Lock()
checked := a.overflowStat.Checked
a.overflowStat.Checked++
a.overflowStat.Unlock()
if checked%20 == 0 {
log.Printf("[agent] overflow check #%d: ratio=%.2f window=%d events=%d "+
"(threshold=%.2f lightKernel=%v noContext=%v)",
checked+1, ratio, a.contextWindowTokens(), a.contextLenSafe(),
overflowRatio, light, noCtx)
}
}
if noCtx || light {
// 轻量内核(驻留子)不做按相关度裁剪,见 pruneByQuery 的同款理由。
return false, 0
}
if ratio < overflowRatio {
return false, 0
}
// 恢复预算:同一帧内裁剪仍裁不下 ⇒ 说明 topK 与窗口脱钩(见 pruneByQuery),
// 再裁一次也是同样结果。超预算后**明确终止**,不静默重试。
if f != nil {
if f.OverflowRecover >= overflowRecoverBudget {
log.Printf("[agent] context overflow: recover budget exhausted (%d/%d), "+
"terminating this frame instead of looping",
f.OverflowRecover, overflowRecoverBudget)
a.markOverflowAborted(ratio, a.context.Len())
f.Err = fmt.Errorf("上下文超限:已裁剪 %d 次仍超出窗口,"+
"本轮终止(请检查 core.agent.max_context_size 与窗口是否匹配)",
f.OverflowRecover)
f.Terminal = terminalError
return true, 0 // 终止也是「已处理」:调用方不要再重跑
}
f.OverflowRecover++
}
return true, a.handleContextOverflow(query, ratio)
}
// handleContextOverflow 执行「裁剪 + L4 打断」。
//
// 关键取舍:裁剪在**这里同步做完**,L4 只负责打断与告知。理由:
// - Prune 全路径无 IO、无 panic(打分→排序→重排→可选写 docStore),
// 把它放进 L4 任务里做只会多付一次完整任务调度开销;
// - 这样「L4 里 Prune 出错 → 再触发 L4」在结构上就不可能发生。
//
// 出错处理(用户口径:保存退出,不重试):裁剪 0 条说明「本来装得下」
// 却报超页 —— 这是判定逻辑的 bug,不是可恢复状态。此时保存现场并明确终止,
// 绝不再次触发中断。
func (a *Agent) handleContextOverflow(query string, ratio float64) int {
// 单条事件本身就超预算 ⇒ 裁剪在任何保护数下都无解(实测 T10c:
// 事件 40026 token vs 预算 10667)。此时直接终止并报真实原因,
// 而不是裁剪两轮后 Budget 耗尽 —— 后者会丢失已经裁掉的记忆。
if !a.singleEventFitsBudget() {
avg := a.accumulatedTokens() / a.context.Len()
log.Printf("[agent] context overflow: single event (%d tok) exceeds budget (%d tok) — "+
"pruning cannot help, terminating", avg, a.computeTokenBudget().ContextTokens)
a.markOverflowAborted(ratio, a.context.Len())
return 0
}
before := a.context.Len()
beforeTokens := a.accumulatedTokens()
// pruned 是**写进 docStore 的条数**(Prune 内部计数),不是「少了几条」。
// 判据必须看上下文是否真的变小了 —— 因为 docStore == nil 时(轻量内核、
// 文档记忆未初始化)pruned 恒为 0,但裁剪其实照常发生了。用 pruned 判会
// 把正常裁剪误判成 bug 而终止。
pruned := a.pruneByQuery(query)
after := a.context.Len()
afterTokens := a.accumulatedTokens()
if after >= before || afterTokens >= beforeTokens {
// 超页了却裁不动 ⇒ 判定逻辑有问题(或 topK ≥ 实际条数)。
// 按用户口径:保存现场 + 明确终止,**不重试不触发中断**。
// 为什么不再试一次:L4 遇 L4 不能抢占(canPreempt 用严格大于),
// 第二个 L4 只会排队等,永远等不到能执行的时机。
log.Printf("[agent] context overflow aborted: ratio=%.2f no reduction "+
"(%d→%d events, %d→%d tokens) — 判定逻辑可能有 bug,保存现场并终止本轮,不重试",
ratio, before, after, beforeTokens, afterTokens)
a.markOverflowAborted(ratio, before)
return 0
}
pruned = before - after
log.Printf("[agent] context overflow: ratio=%.2f pruned=%d (%d→%d events), raising L4",
ratio, pruned, before, after)
a.raiseContextOverflow(pruned)
return pruned
}
// markOverflowAborted 记录一次「超页但裁不出东西」的终止,供状态面观测。
func (a *Agent) markOverflowAborted(ratio float64, events int) {
a.overflowStat.Lock()
a.overflowStat.Aborted++
a.overflowStat.LastRatio = ratio
a.overflowStat.LastEvents = events
a.overflowStat.Unlock()
}
// raiseContextOverflow 通过 **L4 唯一入口**上报上下文超页。
//
// 与 panic / selfip 并列,是 L4 的第三个来源。
//
// 消息内容不是通知而是**必要的认知输入**:L4 任务按调度器设计拿不到被打断
// 者的上下文(suspend 的 D1=B 注释明确「不把被打断任务的任何内容交给它」),
// 所以「已裁剪了什么、查不到不等于不存在」必须显式写进去。
//
// 这条提示直接对应 v4 实测的一类失败:HA 对查不到的事实给出
// 「库里根本没有 X,任何数字都是编的」——它不知道有内容被移走,
// 于是把「没检索到」推断成「不存在」。
func (a *Agent) raiseContextOverflow(pruned int) {
a.overflowStat.Lock()
a.overflowStat.Triggered++
a.overflowStat.LastPruned = pruned
a.overflowStat.Unlock()
a.raiseKernelInterrupt("kernel/overflow", "kernel", overflowNotice(pruned))
}
// overflowNotice 构造超页中断消息。
//
// 抽成纯函数是为了可测:这条消息的措辞不是装饰,它是 agent 唯一的认知输入
// (L4 任务按调度器设计拿不到被打断者的上下文)。「查不到不等于不存在」这句
// 直接对应 v4 实测的一类真实失败 —— HA 对检索不到的事实回答
// 「库里根本没有 X,任何数字都是编的」,因为它不知道有内容被移走了。
func overflowNotice(pruned int) string {
return fmt.Sprintf("[内核] 上下文超页:已裁剪 %d 条低相关事件到文档记忆。"+
"被裁内容仍可检索,但需显式查询;查不到不等于不存在。", pruned)
}
// handleUpstreamContextFull 处理上游返回的 ErrContextFull:与本地预判走**同一条**
// 路径(裁剪 → L4),避免两条路径行为不一致。
//
// 必须在 provider fallback **之前**调用:fallback 会换 provider 重发同一个
// 超限请求,换谁都一样超。
//
// 返回 true 表示已裁剪,调用方应重试本轮;false 表示裁不出东西,应终止。
func (a *Agent) handleUpstreamContextFull(llmErr error) bool {
var pe *agentAPI.ProviderError
if !errors.As(llmErr, &pe) || pe.Kind != agentAPI.ErrContextFull {
return false
}
pruned := a.handleContextOverflow("", 0)
a.overflowStat.Lock()
a.overflowStat.Upstream++
a.overflowStat.Unlock()
return pruned > 0
}
// contextLenSafe 是 nil 安全的上下文条数(诊断日志用)。
func (a *Agent) contextLenSafe() int {
if a == nil || a.context == nil {
return 0
}
return a.context.Len()
}
// overflowStat 字段定义在 Agent 上(见 agent.go),此处只做访问辅助。