Merge pull request #30 from JianFeeeee/fix/kernel-1.4.0-runtime-issues

fix(kernel): 修 3 处运行时缺陷 —— graph_centroid 回退 / agentcli 状态回声 / 超长工具结果
This commit is contained in:
JianFeeeee
2026-10-25 20:43:07 +08:00
committed by GitHub
17 changed files with 1906 additions and 49 deletions

641
cmd/homed-upgrade/main.go Normal file
View File

@ -0,0 +1,641 @@
// Command homed-upgrade 是**内核**的本地升级闭环(默认只探测,不写盘)。
//
// ## 范围:只面向内核
//
// 只处理 homed / depgraph.bin / waiter 三个文件。**不碰插件**——插件有独立的
// 升级通道(pluginmgr),且插件与内核的兼容性由"自动禁用不兼容插件"处理
// (见 internal/plugin/registry.go 的 maybeAutoDisableIncompatible):
// 换内核后旧 SDK 编的插件会握手失败,内核把它持久禁用并写明原因,
// 用户在界面上直接看到"为什么没了",而不是翻日志。
//
// ## 为什么默认只探测
//
// 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做
// 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。
// 沿同仓 homed-kb-migrate / homed-predicate-migrate 的惯例。
//
// ## 探测什么(-probe)
//
// 1. 当前运行的内核版本(从 /proc 找进程,读它自己的 --version 输出)
// 2. 新包里的内核版本
// 3. 两者比较:能否升级、是不是降级、commit 是否真的变了
// 4. depgraph.bin 与新二进制是否匹配(包内自带,必须同批替换)
// 5. 现有插件与**新**内核的兼容性预判(SDK 主版本)
//
// 第 5 条是 OTA 里最容易出事的地方:内核换了、插件没换。这里提前算出来,
// 好让用户在动手前就知道"升完会有 N 个插件被自动禁用"。
package main
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func main() {
pkgPath := flag.String("pkg", "", "新安装包(.tar.gz);给出即做探测")
binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径")
pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)")
apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)")
flag.Parse()
if *pkgPath == "" {
fail("必须提供 -pkg <新安装包.tar.gz>")
}
tmp, err := os.MkdirTemp("", "homed-upgrade-*")
if err != nil {
fail("建临时目录: %v", err)
}
defer os.RemoveAll(tmp)
fmt.Printf("== 解包 %s ==\n", *pkgPath)
root, err := extractPkg(*pkgPath, tmp)
if err != nil {
fail("解包: %v", err)
}
// ── 1. 当前版本 ──
cur, curErr := readBinVersion(*binPath)
if curErr != nil {
fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr)
cur = nil
} else {
fmt.Printf("当前内核: %s (commit %s)\n", cur.Version, shortCommit(cur.Commit))
}
// ── 2. 新版本 ──
newBin := findFile(root, "homed")
if newBin == "" {
fail("包内找不到 homed")
}
nv, err := readBinVersion(newBin)
if err != nil {
fail("新内核无法自报版本(可能不是本仓构建的二进制): %v", err)
}
fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, shortCommit(nv.Commit))
// ── 3. 版本比较 ──
verdict := compareVersions(cur, nv)
fmt.Printf("\n== 结论 ==\n%s\n", verdict)
// ── 4. depgraph 必须与新二进制同批 ──
newDG := findFile(root, "depgraph.bin")
if newDG == "" {
fmt.Println("⚠️ 包内缺 depgraph.bin —— 内核启动会因依赖图不匹配拒绝启动")
} else {
fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", fileSize(newDG))
}
// ── 5. 插件兼容性预判(OTA 最容易出事的点)──
incompat := probePluginCompat(*pluginsDir, nv.SDKVersion)
if len(incompat) > 0 {
fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat))
fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):")
for _, p := range incompat {
fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor)
}
fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。")
} else {
fmt.Println("\n== 插件兼容性 ==\n现有插件与新内核 SDK 主版本一致,无需重编。")
}
if !*apply {
fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)")
return
}
// ── 6. 真正升级(备份 → 替换 → 重启 → 后置验证 → 失败回滚)──
if err := doUpgrade(root, *binPath, newDG, *pluginsDir); err != nil {
fail("升级失败: %v", err)
}
}
// binVersion 是 homed --version 的输出。
type binVersion struct {
Version string
Commit string
BuildTime string
SDKVersion string
}
// readBinVersion 跑 `<bin> --version` 并解析。
//
// ★ 为什么用"跑它自己"而不是读文件里的字符串:
//
// 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核
// 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。
func readBinVersion(bin string) (*binVersion, error) {
if _, err := os.Stat(bin); err != nil {
return nil, err
}
out, err := exec.Command(bin, "--version").CombinedOutput()
if err != nil {
// ★ 回退:老版本内核没有 --version(它是 2026-10-25 才加的)。
//
// 这正是 OTA 的**第一次**升级必然撞到的情况:新版工具要读线上版本,
// 而线上那个版本还不支持被读。此时不能失败退出——否则所有人第一次
// 升级都要人工介入。改为从运行中进程的环境/文件里找线索,找不到就
// 诚实地说"未知",让版本比较降级(其余预检照常)。
if v := inferVersionFromRunning(); v != nil {
return v, nil
}
return nil, fmt.Errorf("执行 --version: %v(老版本内核不支持 --version,且无法从运行进程推断)", err)
}
v := &binVersion{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
k, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "version":
v.Version = val
case "commit":
v.Commit = val
case "build_time":
v.BuildTime = val
case "sdk":
v.SDKVersion = val
}
}
if v.Version == "" {
return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out)))
}
return v, nil
}
// compareVersions 给出可读结论。返回文案而不是 bool:
// "能升但不能降"这类判断需要说明理由,用户才敢按 -apply。
func compareVersions(cur, nv *binVersion) string {
if cur == nil {
return "无法读取当前版本,跳过比较(其余预检照常)"
}
c, n := parseSemver(cur.Version), parseSemver(nv.Version)
switch {
case n == nil || c == nil:
// 版本号形态不标准(如 1.4.0dev):退化成字符串比较并说明
if cur.Version == nv.Version && cur.Commit == nv.Commit {
return "⚠️ 版本与 commit 都相同 —— 升级后内容不会变化,请确认包是否拿错"
}
return "版本号非标准形态,按字符串比较;实测内容有差异(commit 不同)"
case cmpSemver(n, c) > 0:
return "✅ 可升级:包内版本高于当前"
case cmpSemver(n, c) == 0:
if cur.Commit != nv.Commit {
return fmt.Sprintf("⚠️ 同为 %s 但 commit 不同(%s → %s)—— 这是一次**同版本重发**,请确认是否有意为之",
nv.Version, shortCommit(cur.Commit), shortCommit(nv.Commit))
}
return "⚠️ 与当前完全相同 —— 无需升级"
default:
return "❌ 这是**降级**(包内版本低于当前)。默认不建议;确认要回退请加 -allow-downgrade"
}
}
type semver struct{ major, minor, patch int }
func parseSemver(s string) *semver {
// 只认纯 X.Y.Z(1.4.0dev 这类走字符串回退,避免把 dev 当成正式版)
if strings.ContainsAny(s, "dev-+") {
return nil
}
parts := strings.Split(s, ".")
if len(parts) < 2 {
return nil
}
var nums [3]int
for i := 0; i < 3 && i < len(parts); i++ {
n, err := strconv.Atoi(parts[i])
if err != nil {
return nil
}
nums[i] = n
}
return &semver{nums[0], nums[1], nums[2]}
}
func cmpSemver(a, b *semver) int {
switch {
case a.major != b.major:
return a.major - b.major
case a.minor != b.minor:
return a.minor - b.minor
default:
return a.patch - b.patch
}
}
// pluginCompat 是一个与新内核 SDK 主版本不匹配的插件。
type pluginCompat struct {
Name string
SDKVersion string
WantMajor string
}
// probePluginCompat 扫插件目录,读每个插件编译时的 SDK 版本。
//
// 数据来源:plugin.json 里**没有** sdk_version 字段,所以读不到就诚实地说
// "无法预判",而不是瞎猜。能读到的(有 meta 文件的)才比较。
func probePluginCompat(dir, wantSDK string) []pluginCompat {
if dir == "" || wantSDK == "" {
return nil
}
wantMajor := majorOf(wantSDK)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []pluginCompat
for _, e := range entries {
if !e.IsDir() {
continue
}
pj := filepath.Join(dir, e.Name(), "plugin.json")
b, err := os.ReadFile(pj)
if err != nil {
continue
}
var m struct {
SDKVersion string `json:"sdk_version"`
}
json.Unmarshal(b, &m)
if m.SDKVersion == "" {
continue // plugin.json 不含该字段:无法预判,不虚报
}
if majorOf(m.SDKVersion) != wantMajor {
out = append(out, pluginCompat{Name: e.Name(), SDKVersion: m.SDKVersion, WantMajor: wantMajor})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func majorOf(v string) string {
if i := strings.IndexByte(v, '.'); i > 0 {
return v[:i]
}
return v
}
func shortCommit(c string) string {
if c == "" || c == "unknown" {
return "unknown"
}
if len(c) > 8 {
return c[:8]
}
return c
}
// extractPkg 解包并返回包含 bin/ 的根目录。
func extractPkg(pkg, dst string) (string, error) {
f, err := os.Open(pkg)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", fmt.Errorf("非 gzip: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
// 防路径穿越(包是被信任的,但这条成本几乎为零)
clean := filepath.Clean(h.Name)
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return "", fmt.Errorf("包内有非法路径: %s", h.Name)
}
p := filepath.Join(dst, clean)
switch h.Typeflag {
case tar.TypeDir:
os.MkdirAll(p, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(p), 0755)
out, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(h.Mode))
if err != nil {
return "", err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return "", err
}
out.Close()
}
}
// 找含 bin/homed 的那层
if findFile(dst, "homed") == "" {
return "", fmt.Errorf("包内无 homed")
}
return dst, nil
}
// findFile 在 root 下递归找文件名匹配的第一个。
func findFile(root, name string) string {
var found string
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || found != "" {
return nil
}
if info.Name() == name {
found = p
}
return nil
})
return found
}
func fileSize(p string) int64 {
if st, err := os.Stat(p); err == nil {
return st.Size()
}
return 0
}
func fail(format string, a ...interface{}) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
// doUpgrade 执行真实升级:备份 → 预检 → 原子替换 → 重启 → 后置验证 → 失败回滚。
//
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
// 宁可升级失败,不可让机器起不来。
func doUpgrade(root, binPath, newDG, pluginsDir string) error {
newBin := findFile(root, "homed")
if newBin == "" {
return fmt.Errorf("包内无 homed")
}
// ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)──
nv, err := readBinVersion(newBin)
if err != nil {
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
}
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
ts := nowStamp()
backup := filepath.Join("/root", "homed-upgrade-backup-"+ts)
if err := os.MkdirAll(backup, 0755); err != nil {
return fmt.Errorf("建备份目录: %w", err)
}
targets := []string{binPath}
if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); fileExists(dg) {
targets = append(targets, dg)
}
for _, t := range targets {
if err := copyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil {
return fmt.Errorf("备份 %s: %w", t, err)
}
}
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
rollback := func(cause error) error {
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
for _, t := range targets {
src := filepath.Join(backup, filepath.Base(t))
if err := copyFile(src, t); err != nil {
fmt.Printf(" ⚠️ 回滚 %s 失败: %v(需人工处理)\n", t, err)
continue
}
fmt.Printf(" 已恢复 %s\n", t)
}
if err := restartService(); err != nil {
fmt.Printf(" ⚠️ 重启失败: %v(需人工处理)\n", err)
} else if !waitHealthy(60) {
fmt.Println(" ⚠️ 回滚后仍不健康,需人工介入")
} else {
fmt.Println(" ✅ 已回滚并恢复正常运行")
}
return fmt.Errorf("升级失败已回滚: %w", cause)
}
// ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)──
if newDG != "" {
dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin")
if err := copyFile(newDG, dst); err != nil {
return rollback(fmt.Errorf("替换 depgraph.bin: %w", err))
}
fmt.Println("已替换 depgraph.bin")
}
if err := copyFile(newBin, binPath); err != nil {
return rollback(fmt.Errorf("替换 homed: %w", err))
}
fmt.Println("已替换 homed")
// ── 重启 + 后置验证 ──
if err := restartService(); err != nil {
return rollback(fmt.Errorf("重启: %w", err))
}
if !waitHealthy(90) {
return rollback(fmt.Errorf("新版本未在超时内达到健康状态"))
}
// 版本复核:确认跑起来的**确实**是新版本(而不是重启失败后旧的还在)
got, err := readBinVersion(binPath)
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, shortCommit(nv.Commit), got))
}
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, shortCommit(got.Commit))
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
return nil
}
// waitHealthy 轮询 systemd 的 active 状态。
func waitHealthy(seconds int) bool {
for i := 0; i < seconds; i++ {
out, err := exec.Command("systemctl", "is-active", "homeagent").Output()
if err == nil && strings.TrimSpace(string(out)) == "active" {
// active 还不够:给它几秒让插件真正加载完再判一次
if i > 3 {
return true
}
}
sleepSecond()
}
return false
}
func restartService() error {
out, err := exec.Command("systemctl", "restart", "homeagent").CombinedOutput()
if err != nil {
return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out)))
}
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
st, err := in.Stat()
if err != nil {
return err
}
tmp := dst + ".tmp-upgrade"
out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
// 原子替换:先写临时文件再 rename,避免"写到一半进程在跑"
return os.Rename(tmp, dst)
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func nowStamp() string {
return time.Now().Format("20060102-150405")
}
// sleepSecond 隔一秒(抽出来便于将来换成可注入的等待,测试不必真睡)。
func sleepSecond() { time.Sleep(time.Second) }
// inferVersionFromRunning 在老内核不支持 --version 时,尽力推断当前版本。
//
// 手段按可靠性排序,任一成功即返回:
// 1. 读 /proc/<pid>/exe 指向的文件,用 strings 找 "version=" 形态(不保证有)
// 2. 读 WebUI 的 /api/v1/agents(那里返回 meta.Version)—— 需服务在跑
//
// 都失败则返回 nil,调用方把比较降级为"未知",**不阻断**升级。
func inferVersionFromRunning() *binVersion {
// 手段 1:从二进制里找注入的版本串。
//
// ldflags 注入的字符串在 rodata 里是裸的,形如 "1.4.0dev";
// 配合 "commit=" 一起找才有意义,所以这里只认成对出现的情况。
if v := scanBinaryForVersion("/usr/local/bin/homed"); v != nil {
return v
}
// 手段 2:问 waiter。
//
// ★ 这条最可靠:waiter 与 homed **同批构建**(同一个版本、同一个 commit),
// 而且它一直支持 --version(输出形如
// `waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)`)。
// 老内核没有 --version,但同批的 waiter 有 —— 用它反推。
if v := versionFromWaiter("/usr/local/bin/waiter"); v != nil {
return v
}
return nil
}
// versionFromWaiter 解析 waiter 的版本行反推内核版本。
//
// 只认 "waiter X.Y.Z (commit ABC, built ...)" 这一种形态;
// 格式变了就返回 nil(宁可说"未知",不可瞎猜)。
func versionFromWaiter(path string) *binVersion {
if _, err := os.Stat(path); err != nil {
return nil
}
out, err := exec.Command(path, "--version").CombinedOutput()
if err != nil {
return nil
}
line := strings.TrimSpace(string(out))
// 取第一个语义化版本号
v := firstSemverLike(line)
if v == "" {
return nil
}
commit := ""
if i := strings.Index(line, "commit "); i >= 0 {
rest := line[i+len("commit "):]
if j := strings.IndexAny(rest, ",)"); j > 0 {
commit = strings.TrimSpace(rest[:j])
}
}
return &binVersion{Version: v, Commit: commit}
}
// scanBinaryForVersion 在二进制里搜版本串(老内核的兜底手段)。
func scanBinaryForVersion(path string) *binVersion {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
// 只读前 200MB:版本串在 rodata,通常在文件前 1/3
buf := make([]byte, 200<<20)
n, _ := io.ReadFull(f, buf)
buf = buf[:n]
// 找 SDKCompatibleVersion 附近的三元组:本仓 meta 变量相邻存放,
// 命中 "1.3.0" 且附近有语义化版本形态时,取最近的一个当内核版本。
// ★ 这是**尽力而为**:可能撞到别的版本号,所以只作为"未知"的替代,
// 且返回的 commit 恒为 unknown(无法可靠恢复)。
idx := strings.Index(string(buf), "SDKCompatibleVersion")
if idx < 0 {
return nil
}
// 在附近 4KB 里找 X.Y.Z 形态
window := string(buf[maxInt(0, idx-4096):minInt(len(buf), idx+4096)])
if v := firstSemverLike(window); v != "" {
return &binVersion{Version: v, Commit: "unknown"}
}
return nil
}
func firstSemverLike(s string) string {
for i := 0; i+5 <= len(s); i++ {
if s[i] < '0' || s[i] > '9' {
continue
}
j := i
dots := 0
for j < len(s) && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.') {
if s[j] == '.' {
dots++
}
j++
}
if dots == 2 && j-i >= 5 {
return s[i:j]
}
}
return ""
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}

View File

@ -0,0 +1,106 @@
package main
import "testing"
// ── 版本比较的判据 ────────────────────────────────────────────
//
// 这些是 OTA 敢不敢按 -apply 的依据。判错一次就可能把新版换成旧版,
// 或让"内容其实没变"被当成升级成功。
func TestCompareVersions(t *testing.T) {
cases := []struct {
name string
cur *binVersion
nv *binVersion
wantSub string // 结论文案必须包含的片段
}{
{
name: "正常升级",
cur: &binVersion{Version: "1.3.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
wantSub: "可升级",
},
{
name: "降级必须显式警告",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.3.0", Commit: "bbb"},
wantSub: "降级",
},
{
name: "同版本同 commit = 无需升级",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "aaa"},
wantSub: "完全相同",
},
{
// ★ 这条最容易被忽略:版本号没变但内容变了(同版本重发)。
// 不报出来会让用户以为"升级了其实没升"或反之。
name: "同版本不同 commit 必须点明",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0", Commit: "bbb"},
wantSub: "同版本重发",
},
{
// 非标准形态(1.4.0dev):走字符串回退,不能说"降级"
name: "非标准版本号走字符串比较",
cur: &binVersion{Version: "1.4.0", Commit: "aaa"},
nv: &binVersion{Version: "1.4.0dev", Commit: "bbb"},
wantSub: "commit 不同",
},
}
for _, c := range cases {
got := compareVersions(c.cur, c.nv)
if !containsStr(got, c.wantSub) {
t.Errorf("%s: 结论应含 %q,实际 %q", c.name, c.wantSub, got)
}
}
}
// 判据:读不到当前版本时**不能**阻断(第一次升级必然撞到)。
//
// 这是实测踩到的:线上内核是加 --version 之前的版本,第一次探测必然失败。
// 若此时直接退出,所有用户第一次升级都要人工介入。
func TestCompareVersions_UnknownCurrentDoesNotBlock(t *testing.T) {
got := compareVersions(nil, &binVersion{Version: "1.4.0", Commit: "bbb"})
if containsStr(got, "❌") {
t.Errorf("当前版本未知不应判失败,实际: %s", got)
}
if !containsStr(got, "跳过比较") {
t.Errorf("应说明跳过比较,实际: %s", got)
}
}
// ── 版本解析 ─────────────────────────────────────────────────
func TestParseSemver(t *testing.T) {
if v := parseSemver("1.4.0"); v == nil || v.major != 1 || v.minor != 4 || v.patch != 0 {
t.Errorf("1.4.0 解析错: %+v", v)
}
// dev 后缀不算正式语义版本(否则 1.4.0dev 会被当成 1.4.0 比大小)
if v := parseSemver("1.4.0dev"); v != nil {
t.Errorf("1.4.0dev 应回退字符串比较,实际解析成 %+v", v)
}
}
// ── waiter 兜底解析 ──────────────────────────────────────────
func TestFirstSemverLike(t *testing.T) {
// waiter 的真实输出形态
line := "waiter 1.4.0 (commit 98850e1, built 2026-10-17T09:20:25Z)"
if got := firstSemverLike(line); got != "1.4.0" {
t.Errorf("应从 waiter 输出取到 1.4.0,实际 %q", got)
}
// 不能把日期当版本号
if got := firstSemverLike("built 2026-10-17"); got != "" {
t.Errorf("日期不应被识别为版本号,实际 %q", got)
}
}
func containsStr(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@ -20,6 +20,7 @@ import (
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/pipeline"
"github.com/JianFeeeee/HologramAgent/internal/memory/social"
"github.com/JianFeeeee/HologramAgent/internal/meta"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/recovery"
safeclose "github.com/JianFeeeee/HologramAgent/internal/safeclose"
@ -403,7 +404,21 @@ func parseFlags() options {
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
role := flag.String("role", "agent", "process role: guard (父守护) | agent (工作进程)")
boot := flag.String("boot", "normal", "agent boot mode: normal | failback (受限启动,仅 failback 插件集)")
// --version:打印版本后退出,不启动任何模块。
//
// ★ 为 OTA 而加:升级前要能**问一个二进制它是什么版本**,否则只能先替换
// 再启动看日志——那时旧版本已经被覆盖了。有了它,OTA 可以在临时目录里
// 跑 `新homed --version` 做预检,不通过就不碰线上文件。
//
// 输出保持单行 key=value 形态,便于脚本解析(与 BuildTime/Commit 一起给,
// 因为"同一个版本号不同 commit"会让"升级成功但内容没变"难以察觉)。
showVersion := flag.Bool("version", false, "打印版本信息后退出(不启动内核)")
flag.Parse()
if *showVersion {
fmt.Printf("version=%s\ncommit=%s\nbuild_time=%s\nsdk=%s\n",
meta.Version, meta.Commit, meta.BuildTime, meta.SDKCompatibleVersion)
os.Exit(0)
}
return options{dataDir: *dataDir, httpAddr: *httpAddr, cliSocket: *cliSocket, role: *role, boot: *boot}
}

View File

@ -3,6 +3,7 @@ package core
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
@ -35,6 +36,17 @@ type ContextEvent struct {
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。
//
// ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**,
// 存活期由事件的存活期决定(事件离开 L1 ⇒ 正文目录一并删除)。
// 见 toolresult_store.go 的说明。
//
// 这里只存元信息(工具名、文件名、总字节/行数),正文在
// <context.json 同级>/toolresults/<eventID>/tool_N.txt —— 因为
// context.json 每 5s 全量重写一次,内联大正文会让每次 flush 都写几 MB。
ToolResultStores []ToolResultStore `json:"tool_result_stores,omitempty"`
// NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏),
// 但**照常参与归档**(离开 L1 时会写回 L2)。
//
@ -60,15 +72,21 @@ type ContextEvent struct {
const contextFlushInterval = 5 * time.Second
type RelevanceContext struct {
mu sync.Mutex
events []*ContextEvent
embedder *memory.StaticEmbedder
denseSpace vector.MultimodalEmbedder
savePath string
saveTimer *time.Timer
dirty bool
toolDefLookup func(name string) *sdk.ToolDef
channelDefLookup func(name string) (sdk.ChannelDef, bool)
mu sync.Mutex
events []*ContextEvent
embedder *memory.StaticEmbedder
denseSpace vector.MultimodalEmbedder
savePath string
// pendingToolResults 存放"已落盘但事件还没进 L1"的超长工具结果记录。
//
// 为什么需要:同一帧可能先落盘、后 append 事件(task.go 的顺序)。
// 没有它,句柄会在事件进入前悬空,模型读到的就是"未找到"。
// 事件 append 后由 claimPendingToolResults 认领并清空。
pendingToolResults map[string][]ToolResultStore
saveTimer *time.Timer
dirty bool
toolDefLookup func(name string) *sdk.ToolDef
channelDefLookup func(name string) (sdk.ChannelDef, bool)
// protectedCount 是裁剪时**无条件保留**的最近事件条数。
//
@ -400,15 +418,36 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
c.computeVector(&evt)
c.events = append(c.events, &evt)
// 认领"先落盘、后进 L1"的超长工具结果记录(已在锁内,直接操作)。
//
// 为什么挂在 Append 而不是各调用点:Append 是事件进入 L1 的唯一咽喉
// (task.go 有三处调用),散点调用必然漏一条路径——那一条上的句柄
// 就永久悬空(模型拿到 event_id 去读得到"未找到")。
c.claimPendingToolResultsLocked(&evt)
c.save()
}
// claimPendingToolResultsLocked 是 claimPendingToolResults 的持锁版本,
// 供 Append/InsertByTimestamp 这类已持锁的路径调用。
func (c *RelevanceContext) claimPendingToolResultsLocked(evt *ContextEvent) {
if c == nil || evt == nil || evt.ID == "" || len(c.pendingToolResults) == 0 {
return
}
if sts, ok := c.pendingToolResults[evt.ID]; ok {
evt.ToolResultStores = append(evt.ToolResultStores, sts...)
delete(c.pendingToolResults, evt.ID)
c.dirty = true
}
}
func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
c.mu.Lock()
defer c.mu.Unlock()
scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
c.computeVector(&evt)
c.claimPendingToolResultsLocked(&evt) // 同 Append:认领待登记的落盘记录
idx := sort.Search(len(c.events), func(i int) bool {
return c.events[i].Timestamp.After(evt.Timestamp)
@ -573,6 +612,17 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
s.event.Blocks = nil
}
}
// 超长工具结果全文同样**跟随事件**离开 L1,不单独过期。
//
// 与 Blocks 一致:块迁移到 L2(块本身进 doc),而全文是纯现场
// 物料、不进 doc,所以这里直接删目录。删除失败只在日志留痕
// (readToolResultBody 会答复"已随事件淘汰"),不会静默成功。
for _, s := range archive {
if s.event != nil && s.event.ID != "" && len(s.event.ToolResultStores) > 0 {
c.purgeToolResultDir(s.event.ID)
s.event.ToolResultStores = nil
}
}
}
}
@ -666,3 +716,119 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
}
return result
}
// ── 超长工具结果全文的持久化(记忆面 IO 的合法落点)──────────────────
//
// 放这里而不是 toolresult_store.go:本包有架构守卫
// TestKernelHasNoMediaFileReading,内核除「记忆面持久化」(本文件)外不得
// 做本地文件读。工具结果全文落盘本质是"记住一份东西",属同一职责。
// persistToolResult 把正文写入事件目录,返回元信息。
func (c *RelevanceContext) persistToolResult(evtID, tool, text string) (*ToolResultStore, error) {
if c == nil {
return nil, fmt.Errorf("toolresult: context 为空")
}
if evtID == "" {
return nil, fmt.Errorf("toolresult: evtID 为空(调用方须先分配事件 ID)")
}
base := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID)
if err := os.MkdirAll(base, 0755); err != nil {
return nil, fmt.Errorf("toolresult: 建目录 %s: %w", base, err)
}
name := fmt.Sprintf("tool_%d.txt", nextToolResultSeq(base))
body := text
truncated := 0
if len(body) > toolResultStoreHardCap {
truncated = len(body) - toolResultStoreHardCap
body = body[:toolResultStoreHardCap]
}
if err := os.WriteFile(filepath.Join(base, name), []byte(body), 0644); err != nil {
return nil, fmt.Errorf("toolresult: 写正文: %w", err)
}
return &ToolResultStore{
Tool: tool,
File: name,
TotalBytes: len(text),
TotalLines: strings.Count(body, "\n") + 1,
TruncatedBytes: truncated,
}, nil
}
// nextToolResultSeq 数出事件目录里已有的 tool_*.txt,返回下一个序号。
//
// 不用内存计数器:进程重启后计数器归零会覆盖旧文件。数目录是幂等的。
func nextToolResultSeq(dir string) int {
entries, err := os.ReadDir(dir)
if err != nil {
return 0
}
n := 0
for _, e := range entries {
var idx int
if _, err := fmt.Sscanf(e.Name(), "tool_%d.txt", &idx); err == nil && idx >= n {
n = idx + 1
}
}
return n
}
// readToolResultBody 按字节区间读取正文。
//
// offset 从 0 开始;limit<=0 表示读到结尾。返回内容与「是否已到末尾」。
func (c *RelevanceContext) readToolResultBody(evtID string, st *ToolResultStore, offset, limit int) (string, bool, error) {
if c == nil || st == nil {
return "", true, fmt.Errorf("toolresult: 无记录")
}
if offset < 0 {
offset = 0
}
p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID, st.File)
f, err := os.Open(p)
if err != nil {
// 事件已被淘汰时走到这里。给**明确**答复而不是静默空串:
// 模型应当知道"这份结果已不可读",而不是以为结果是空的。
if os.IsNotExist(err) {
return "", true, fmt.Errorf("toolresult: 该结果已随事件淘汰(%s)", st.File)
}
return "", true, fmt.Errorf("toolresult: 打开正文: %w", err)
}
defer f.Close()
if offset > 0 {
if _, err := f.Seek(int64(offset), 0); err != nil {
return "", true, fmt.Errorf("toolresult: 定位 %d: %w", offset, err)
}
}
if limit <= 0 {
limit = st.TotalBytes - offset
}
if limit < 0 {
limit = 0
}
buf := make([]byte, limit)
n, rerr := io.ReadFull(f, buf)
if rerr != nil && rerr != io.EOF && rerr != io.ErrUnexpectedEOF {
return "", true, fmt.Errorf("toolresult: 读取: %w", rerr)
}
out := string(buf[:n])
atEnd := offset+n >= st.TotalBytes
return out, atEnd, nil
}
// purgeToolResultDir 删除某事件的全文目录。
//
// 由 Prune 在事件离开 L1 时调用:全文跟随事件生命周期,不单独过期。
func (c *RelevanceContext) purgeToolResultDir(evtID string) {
if c == nil || c.savePath == "" || evtID == "" {
return
}
p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID)
if err := os.RemoveAll(p); err != nil {
// 删不掉不影响正确性(readToolResultBody 会给"已淘汰"),但要留痕,
// 否则残留会无声地占满磁盘。
log.Printf("[agent] purge toolresult dir %s: %v", p, err)
}
}

View File

@ -117,6 +117,14 @@ type TaskFrame struct {
// 在若干帧后突然失去超页处理能力(而那时恰恰最需要它)。
OverflowRecover int
// EventID 是本帧工具结果落盘时使用的**事件标识**。
//
// 为什么要挂在帧上:同一帧内可能有多条超长结果,它们都属于同一个
// 上下文事件,应当落在同一个事件目录里(tool_0.txt / tool_1.txt)。
// 惰性生成(首次落盘时分配),因为绝大多数帧没有任何超长结果——
// 全量生成会给每条事件多加一个字段,与 ContextEvent.ID 的取舍一致。
EventID string
// 当前工具批
PendingTools []agentAPI.ToolCall
ToolIdx int
@ -1173,14 +1181,27 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
}
f.Msgs = append(f.Msgs, toolMsg)
// 工具结果大小统计(方案 B:**只统计不裁剪**)。
// 工具结果大小处置:超限则**全文落盘 + 上下文只放预览**。
//
// 为什么在这里而不是 stepToolExec:那里拿到的 outcome.Text 尚未经
// after_toolcall 阶段改写,而模型最终看到的是**这里**的内容。
// ⚠️ 刻意不截断 —— 截断会让模型基于残缺数据下结论,且它不知道被截过
// (与本仓「静默降级」同族)。处置权交回调度器/上层。
//
// ★ 刻意**不截断**(截断会让模型拿到残缺信息且不知道被截过,与本仓
// 「静默降级」同族)。也刻意**不原样放行**(那正是 36783 token 单条
// 结果冲破预算、整轮终止的成因)。
//
// 走的是第三条路:正文完整保存到事件目录,上下文里换成
// 「预览 + 句柄 + 总量」+ toolresult_read 工具按需取区间。
// 取哪一段由模型决定,信息不丢(判据要求分段拼回逐字节相同)。
if a.checkToolResultSize(tc.Name, toolMsg.Content) {
f.noteOversizeTool(tc.Name)
if replaced, ok := a.offloadToolResult(f, tc.Name, toolMsg.Content); ok {
toolMsg.Content = replaced
result = replaced
if len(f.ToolResults) > 0 {
f.ToolResults[len(f.ToolResults)-1].Output = replaced
}
}
}
if mediaMsg != nil {
// 延迟到批末(见 batchAttachments 的注释):当场追加会把后续 tool 消息

View File

@ -389,6 +389,12 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
return ""
}
switch tc.Name {
case "toolresult_read":
txt, err := a.readStoredToolResult(tc.Arguments)
if err != nil {
return "toolresult_read 失败:" + err.Error()
}
return txt
case "memory_recall":
query, _ := tc.Arguments["query_intent"].(string)
depth, _ := tc.Arguments["depth"].(float64)

View File

@ -577,6 +577,25 @@ func (a *Agent) buildToolDefs() []interface{} {
}
}
// 超长工具结果的按范围回读工具。
//
// ★ 只在**真的存在**落盘结果时才暴露:
// · 绝大多数会话没有超长结果,无条件暴露等于每轮多付一份 schema 开销
// (本仓已有"175 个工具占满上下文"的实测教训);
// · 没有句柄时这个工具对模型毫无用处,给了只会诱导它乱猜 event_id。
if a.hasStoredToolResults() {
tools = append(tools, toolDef("toolresult_read",
"【工具结果回读】读取某个超大工具结果的指定字节区间。"+
"当工具返回内容过大时,上下文里给你的只是预览,会附一个 event_id 句柄和总字节数。"+
"用本工具按 offset/limit 取其余部分;单次最多 64KB。"+
"结果不会自动过期,但会**随那条上下文事件淘汰**——事件被裁掉后再读会明确告知已淘汰。",
map[string]interface{}{
"event_id": map[string]interface{}{"type": "string", "description": "预览里给出的句柄(形如 evt_1770000000000000000)"},
"offset": map[string]interface{}{"type": "number", "description": "起始字节偏移,从 0 开始;省略即从头"},
"limit": map[string]interface{}{"type": "number", "description": "读取字节数,省略或 <=0 取 64KB;不该超过剩余量"},
}, "event_id"))
}
if a.personaStore != nil {
tools = append(tools, toolDef("persona_set", "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。", map[string]interface{}{
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},

View File

@ -0,0 +1,294 @@
package core
// 超长工具结果的「全文落盘 + 按范围回读」(纯逻辑部分)。
//
// ## 为什么需要它
//
// 工具结果进上下文时没有任何长度上限(task.go 直接 `Content: result`)。
// 实测生产一次 36783 token 的单条结果直接冲破整个对话预算,overflow 判定
// 「单条超限,裁剪救不了」→ 整轮终止。模型看到的是「对话没反应」。
//
// 之前有两条错的路子,这里都不走:
//
// - **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺
// 数据下结论。与本仓反复吃亏的「静默降级」同族。
// - **只统计不裁剪**(方案 B,toolresult_budget.go):设计上是对的(不欺骗
// 模型),但停在「报告」这一步 —— oversizeTools 计数没有任何消费者,
// 所以那条超大结果照样整条进上下文,照样撞窗口。
//
// 本文件补的是方案 B 缺的那一半:结果**全文保存**,上下文里只放
// 「预览 + 句柄 + 总量导航」,模型用 toolresult_read 按 offset/limit
// 取任意区间。
//
// - 模型不会被骗:它明确知道看到的是预览,并有手段取全文
// - 取哪一段由**模型**决定,不是内核武断裁一刀
// - 上下文预算可控,且**信息不丢**(判据要求分段拼回逐字节相同)
//
// ## 存活期:跟随事件,不引入 TTL
//
// 全文存在**事件自己的目录**里(与 Blocks 同一套处置,见 mediaref.go 的
// 「块存储在事件自身,不维护引用账本」)。所以:
//
// 事件在 L1 ⇒ 全文可读
// 事件被淘汰 ⇒ 全文随事件目录一并删除
//
// 刻意**不做**独立的过期时间/清理定时器:那会让「一件事记两处」,
// 且两处一旦不一致,就会出现「事件还在但全文没了」或反过来的静默错误。
//
// ★ 磁盘读写不在这里,而在 context.go(persistToolResult / readToolResultBody /
// purgeToolResultDir)。原因:本包有架构守卫 TestKernelHasNoMediaFileReading,
// 内核除「记忆面持久化」(context.go)外不得做本地文件读。这本来就是一件
// 「记住一份东西」的事,归到持久化文件既满足守卫也符合职责。
import (
"fmt"
"log"
"strings"
)
// 工具结果全文的存储目录名(与 context.json 同级)。
const toolResultDirName = "toolresults"
// ToolResultStore 是一条「超大工具结果」的落盘记录。
//
// 它**只存元信息**进 context.json;正文在
//
// <context.json 所在目录>/toolresults/<eventID>/tool_<index>.txt
//
// 这样 context.json 不会因为几条大结果膨胀成几 MB(它每 5s 全量重写一次)。
type ToolResultStore struct {
// Tool 是产出这条结果的工具名(模型据此判断"是哪个工具给的")。
Tool string `json:"tool"`
// File 是正文相对事件目录的文件名(如 tool_0.txt),不含绝对路径——
// 数据目录整体搬迁后仍然有效。
File string `json:"file"`
// TotalBytes / TotalLines 供模型规划怎么读(先读尾部?还是按行定位?)。
TotalBytes int `json:"total_bytes"`
TotalLines int `json:"total_lines"`
// TruncatedBytes 是**没有**落盘的字节数。非 0 表示落盘时触到了硬上限,
// 必须让模型知道(否则它以为全文就这么长)。默认 0。
TruncatedBytes int `json:"truncated_bytes,omitempty"`
}
// toolResultStoreHardCap 是单条结果落盘的硬上限(1 GiB)。
//
// 为什么还要上限:不设的话一个 `cat /dev/urandom` 就能把磁盘写满。
// 但它**不是**静默截断 —— 超限部分记进 TruncatedBytes 并明确告诉模型。
const toolResultStoreHardCap = 1 << 30
// storeToolResult 把超长结果正文落盘,返回元信息。IO 委托给 context.go。
func (a *Agent) storeToolResult(evtID, tool, text string) (*ToolResultStore, error) {
if a == nil || a.context == nil {
return nil, fmt.Errorf("toolresult: agent 未就绪")
}
if a.context.savePath == "" {
return nil, fmt.Errorf("toolresult: 未配置持久化目录,无法落盘")
}
return a.context.persistToolResult(evtID, tool, text)
}
// previewToolResult 生成进上下文的预览:头部若干行 + 句柄 + 读法。
//
// ★ 文案三要素(缺一不可,判据钉住):
// 1. 明确说明「这不是全文」以及总大小 —— 否则模型会当成完整内容下结论
// 2. 给出句柄(事件 ID)与工具名 —— 否则它没法取
// 3. 给出可用区间导航 —— 否则它不知道能读到哪
func previewToolResult(evtID string, st *ToolResultStore, head string, headLines int) string {
var b strings.Builder
fmt.Fprintf(&b, "[工具 %s 返回内容过大,已完整保存,此处仅为预览]\n", st.Tool)
fmt.Fprintf(&b, "全文:%d 字节 / %d 行;句柄 event_id=%s\n", st.TotalBytes, st.TotalLines, evtID)
if st.TruncatedBytes > 0 {
fmt.Fprintf(&b, "⚠️ 落盘时超出上限,有 %d 字节**未保存**(不是全文)\n", st.TruncatedBytes)
}
fmt.Fprintf(&b, "\n--- 预览(前 %d 行)---\n%s\n--- 预览结束 ---\n", headLines, head)
fmt.Fprintf(&b, "\n如需其它部分,调用 toolresult_read(event_id=%q, offset=<字节偏移>, limit=<字节数>)。\n", evtID)
return b.String()
}
// attachToolResultStore 把落盘记录挂到事件上(与 bindEventMedia 同构)。
func attachToolResultStore(evt *ContextEvent, st *ToolResultStore) {
if evt == nil || st == nil {
return
}
if evt.ID == "" {
evt.ID = newEventID()
}
evt.ToolResultStores = append(evt.ToolResultStores, *st)
}
// headLinesOf 截出前 n 行(按 rune 安全,不切断多字节字符)。
func headLinesOf(s string, n int) string {
if n <= 0 {
n = previewHeadLines
}
lines := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
lines++
if lines >= n {
return s[:i+1]
}
}
}
return s
}
// previewHeadLines 是预览默认保留的行数。
const previewHeadLines = 40
// offloadToolResult 把超长结果落盘,返回替换后的预览文本。
//
// 返回 ok=false 时调用方保持原内容不变(落盘失败不能吞掉结果——
// 宁可撞窗口也不能让模型看不到任何东西,那是更严重的静默降级)。
func (a *Agent) offloadToolResult(f *TaskFrame, tool, content string) (string, bool) {
if a == nil || a.context == nil || content == "" {
return "", false
}
evtID := ""
if f != nil {
evtID = f.EventID
}
if evtID == "" {
evtID = newEventID()
if f != nil {
f.EventID = evtID
}
}
st, err := a.storeToolResult(evtID, tool, content)
if err != nil {
// 落盘失败就保持原样:模型至少还能看到完整内容(哪怕撞窗口),
// 这比"什么都没看到"好。日志留痕供运维发现磁盘/权限问题。
log.Printf("[agent] toolresult offload failed (tool=%s): %v — 保持原内容", tool, err)
return "", false
}
// ★ 登记到上下文里,否则 toolresult_read 找不到句柄。
//
// storeToolResult 只负责写文件;"这条结果属于哪个事件、能被回读"
// 是上下文侧的记录。两者必须一起成立——只有文件没有登记,模型拿到
// 句柄去读会得到"未找到",那正是本判据抓到的缺口。
a.context.registerToolResultStore(evtID, st)
return previewToolResult(evtID, st, headLinesOf(content, previewHeadLines), previewHeadLines), true
}
// readStoredToolResult 实现 toolresult_read:按事件句柄 + 字节区间读回全文。
//
// 这是「工具环」设计里缺的那一半——超长结果不再整条进上下文,而是存起来
// 由模型自己决定读哪一段。取哪一段是**模型**的判断,不是内核的武断裁剪。
func (a *Agent) readStoredToolResult(args map[string]interface{}) (string, error) {
evtID, _ := args["event_id"].(string)
if evtID == "" {
return "", fmt.Errorf("缺少 event_id(预览里给出的句柄)")
}
offset := 0
if v, ok := args["offset"].(float64); ok {
offset = int(v)
}
limit := 0
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if offset < 0 {
return "", fmt.Errorf("offset 不能为负")
}
// 单次读取上限:防止模型一次要 500MB 把上下文再撞一次。
// 64KB ≈ 16K token,留出余量给其它内容。
const maxReadBytes = 64 * 1024
if limit <= 0 {
limit = maxReadBytes
}
if limit > maxReadBytes {
limit = maxReadBytes
}
if a.context == nil {
return "", fmt.Errorf("上下文不可用")
}
st := a.context.findToolResultStore(evtID)
if st == nil {
// 三种可能:句柄打错、事件已被淘汰、或这条结果当时落盘失败。
// 都给出**明确**答复,不静默返回空串(空串会被误当成"内容就是空")。
return "", fmt.Errorf("未找到 event_id=%s 的工具结果记录:可能句柄有误,或该事件已随上下文淘汰(全文跟随事件生命周期,不单独保留)", evtID)
}
body, atEnd, err := a.context.readToolResultBody(evtID, st, offset, limit)
if err != nil {
return "", err
}
header := fmt.Sprintf("[%s 的第 %d-%d 字节,共 %d 字节%s]",
st.Tool, offset, offset+len(body), st.TotalBytes, map[bool]string{true: ",已到末尾", false: ""}[atEnd])
return header + "\n" + body, nil
}
// findToolResultStore 按事件 ID 找到落盘记录(在 L1 事件里查)。
func (c *RelevanceContext) findToolResultStore(evtID string) *ToolResultStore {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
for _, evt := range c.events {
if evt == nil || evt.ID != evtID {
continue
}
if len(evt.ToolResultStores) == 0 {
return nil
}
return &evt.ToolResultStores[0]
}
// 事件还没进 L1(同一帧内刚落盘、尚未 append):查待登记表。
//
// ★ 不能只看 c.events:落盘发生在 toolMsg 定稿时,而事件 append 在更晚。
// 少了这一支,模型拿到句柄立刻去读会得到"未找到"——判据
// TestReadBackReassemblesByteIdentical 抓到的正是这个。
if sts, ok := c.pendingToolResults[evtID]; ok && len(sts) > 0 {
return &sts[0]
}
return nil
}
// hasStoredToolResults 判断当前上下文里是否有可回读的超长工具结果。
//
// 供 buildToolDefs 决定要不要暴露 toolresult_read(见那里的说明)。
func (a *Agent) hasStoredToolResults() bool {
if a == nil || a.context == nil {
return false
}
return a.context.hasAnyToolResultStore()
}
// hasAnyToolResultStore 扫一遍 L1 事件,看有没有落盘记录。
func (c *RelevanceContext) hasAnyToolResultStore() bool {
if c == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
for _, evt := range c.events {
if evt != nil && len(evt.ToolResultStores) > 0 {
return true
}
}
return false
}
// registerToolResultStore 把落盘记录登记到对应事件上。
//
// 若该事件还没进 L1(同一帧内多条超长结果时可能先于事件 append),
// 先挂在 context 的待登记表里,等事件进来后再认领——否则句柄会悬空。
func (c *RelevanceContext) registerToolResultStore(evtID string, st *ToolResultStore) {
if c == nil || st == nil || evtID == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for _, evt := range c.events {
if evt != nil && evt.ID == evtID {
evt.ToolResultStores = append(evt.ToolResultStores, *st)
c.dirty = true
return
}
}
if c.pendingToolResults == nil {
c.pendingToolResults = map[string][]ToolResultStore{}
}
c.pendingToolResults[evtID] = append(c.pendingToolResults[evtID], *st)
}

View File

@ -0,0 +1,233 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
)
// newStoreTestAgent 造一个带持久化目录的 agent(用临时目录,不碰生产数据)。
func newStoreTestAgent(t *testing.T) (*Agent, string) {
t.Helper()
dir := t.TempDir()
savePath := filepath.Join(dir, "context.json")
a := &Agent{context: NewRelevanceContext(savePath, nil)}
return a, savePath
}
// ── 判据 1:超大结果落盘,上下文只拿预览(不含全文)──────────────
//
// 这是本特性存在的理由:36783 token 的单条结果曾经整条进上下文,
// 直接冲破整个对话预算、整轮终止。
func TestOffloadReplacesFullTextWithPreview(t *testing.T) {
a, _ := newStoreTestAgent(t)
f := &TaskFrame{}
// 造一个明显超长的结果(500 行)
var sb strings.Builder
for i := 1; i <= 500; i++ {
sb.WriteString("line-")
sb.WriteString(strings.Repeat("x", 100))
sb.WriteString("\n")
}
full := sb.String()
prev, ok := a.offloadToolResult(f, "cmd_run", full)
if !ok {
t.Fatal("超长结果应当落盘")
}
// ① 上下文里拿到的是预览,不是全文
if len(prev) >= len(full) {
t.Fatalf("预览不应不小于全文(预览 %d 字节,全文 %d 字节)", len(prev), len(full))
}
// ② 三要素必须齐(缺任一条模型就成了被骗的一方)
if !strings.Contains(prev, "已完整保存") {
t.Error("预览必须说明这是预览、全文已保存")
}
if !strings.Contains(prev, "evt_") {
t.Error("预览必须给出 event_id 句柄")
}
if !strings.Contains(prev, "toolresult_read") {
t.Error("预览必须告知怎么读其余部分")
}
if !strings.Contains(prev, "字节") {
t.Error("预览必须给出总大小")
}
// ③ 记录挂在事件上(供 findToolResultStore 找到)
if len(a.context.events) == 0 {
// offloadToolResult 只是落盘并返回预览,不负责 append 事件;
// 事件由调用方(task.go)创建。这里直接验证帧上的 EventID 已分配。
}
if f.EventID == "" {
t.Fatal("落盘后帧上必须有 EventID,否则没法回读")
}
}
// ── 判据 2:分段读回拼接后与原结果**逐字节相同** ────────────────
//
// 这是「信息不丢」的唯一硬证据。没有它,本特性只是另一种形式的截断。
func TestReadBackReassemblesByteIdentical(t *testing.T) {
a, _ := newStoreTestAgent(t)
f := &TaskFrame{}
// 刻意用多字节字符(中文)+ 换行,确保按字节切不会切坏 UTF-8
var sb strings.Builder
for i := 0; i < 300; i++ {
sb.WriteString("第")
sb.WriteString(strings.Repeat("测", 40))
sb.WriteString("行\n")
}
full := sb.String()
if _, ok := a.offloadToolResult(f, "files_read", full); !ok {
t.Fatal("落盘失败")
}
// 按 4KB 分段读回,拼起来必须逐字节等于原文
var got strings.Builder
offset := 0
chunk := 4096
for {
txt, err := a.readStoredToolResult(map[string]interface{}{
"event_id": f.EventID,
"offset": float64(offset),
"limit": float64(chunk),
})
if err != nil {
t.Fatalf("读 offset=%d 失败: %v", offset, err)
}
// 去掉头部那行元信息(形如 "[tool 的第 a-b 字节,共 n 字节]")
if idx := strings.Index(txt, "\n"); idx >= 0 {
got.WriteString(txt[idx+1:])
}
if strings.Contains(txt, "已到末尾") {
break
}
offset += chunk
if offset > len(full)+chunk {
t.Fatal("读回死循环:offset 已超过总长仍未到末尾")
}
}
if got.String() != full {
t.Fatalf("分段拼接与原结果不一致:读回 %d 字节,原文 %d 字节", got.Len(), len(full))
}
}
// ── 判据 3:事件淘汰后回读给**明确**答复,不是静默空串 ──────────
//
// 静默返回空串会让模型以为"内容就是空的",那是比失败更糟的误导。
func TestReadAfterPurgeIsExplicit(t *testing.T) {
a, _ := newStoreTestAgent(t)
f := &TaskFrame{}
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("z", 200000)); !ok {
t.Fatal("落盘失败")
}
evtID := f.EventID
// 模拟事件淘汰:删除其全文目录
a.context.purgeToolResultDir(evtID)
_, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID})
if err == nil {
t.Fatal("事件淘汰后回读必须报错(明确告知已淘汰),不能返回空内容")
}
if !strings.Contains(err.Error(), "淘汰") {
t.Errorf("错误文案必须说明是「随事件淘汰」,实际: %v", err)
}
}
// ── 判据 4:小结果不落盘(不改变既有行为)──────────────────────
func TestSmallResultNotOffloaded(t *testing.T) {
a, _ := newStoreTestAgent(t)
// 直接调 storeToolResult 是小结果场景的旁证:
// 触发权在 checkToolResultSize,这里验证"没触发就不产生文件"。
if a.hasStoredToolResults() {
t.Fatal("初始状态不应有任何落盘记录")
}
}
// ── 判据 5:handle 打错给出可执行提示 ──────────────────────────
func TestReadWithBadHandleIsActionable(t *testing.T) {
a, _ := newStoreTestAgent(t)
_, err := a.readStoredToolResult(map[string]interface{}{"event_id": "evt_不存在"})
if err == nil {
t.Fatal("打错的句柄必须报错")
}
if !strings.Contains(err.Error(), "淘汰") && !strings.Contains(err.Error(), "句柄") {
t.Errorf("应说明是句柄有误或已淘汰,实际: %v", err)
}
}
// ── 判据 6:落盘文件确实在事件目录下(不是内联进 context.json)────
//
// 这条钉住"为什么不用内联":context.json 每 5s 全量重写,内联几 MB
// 正文会让每次 flush 都写几 MB。
func TestPersistedToEventDirNotInline(t *testing.T) {
a, savePath := newStoreTestAgent(t)
f := &TaskFrame{}
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("y", 200000)); !ok {
t.Fatal("落盘失败")
}
p := filepath.Join(filepath.Dir(savePath), toolResultDirName, f.EventID, "tool_0.txt")
st, err := os.Stat(p)
if err != nil {
t.Fatalf("正文应在事件目录 %s: %v", p, err)
}
if st.Size() != 200000 {
t.Errorf("正文大小应为 200000,实际 %d", st.Size())
}
}
// ── 判据 7:事件进 L1 后必须认领自己的落盘记录 ──────────────────
//
// 缺陷现场(自测抓到):registerToolResultStore 把记录放进 pendingToolResults,
// 但认领函数没有任何调用点 ⇒ 事件进入 L1 后记录仍躺在待定表里。
// 后果:进程重启后从 context.json 读回事件,ToolResultStores 是空的,
// 句柄全部失效(模型回读得到"未找到")。
//
// ★ 这条必须走真实的 Append 路径,不能直接调认领函数——
//
// 直接调就盖住了"没人调它"这个缺陷(变异验证:删掉 Append 里那行,
// 本判据必须变红)。
func TestAppendClaimsPendingToolResults(t *testing.T) {
a, _ := newStoreTestAgent(t)
f := &TaskFrame{}
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("w", 200000)); !ok {
t.Fatal("落盘失败")
}
evtID := f.EventID
// 落盘时事件还没进 L1,记录应在待定表里
if len(a.context.pendingToolResults[evtID]) == 0 {
t.Fatal("落盘后记录应先进入待定表")
}
// 事件进入 L1(走真实 Append)
a.context.Append(ContextEvent{ID: evtID, Source: "agent", Input: "x"})
// ① 待定表应被清空
if len(a.context.pendingToolResults[evtID]) != 0 {
t.Error("Append 后待定表应已认领清空")
}
// ② 事件上应挂上记录
var found bool
for _, evt := range a.context.events {
if evt != nil && evt.ID == evtID && len(evt.ToolResultStores) > 0 {
found = true
}
}
if !found {
t.Fatal("Append 后事件上必须有 ToolResultStores,否则重启后句柄失效")
}
// ③ 依然可回读
if _, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID}); err != nil {
t.Fatalf("认领后应仍可回读: %v", err)
}
}

View File

@ -88,14 +88,29 @@ func (r *ConfigRegistry) initCoreTable() {
r.db.Exec(`CREATE TABLE IF NOT EXISTS disabled_plugins (
name TEXT PRIMARY KEY,
disabled_at TEXT NOT NULL,
disabled_by TEXT NOT NULL DEFAULT ''
disabled_by TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT ''
)`)
// 迁移:旧库没有 reason 列(已有表时 CREATE TABLE IF NOT EXISTS 不补列)。
//
// 为什么需要 reason:内核 OTA 换版本后,与旧 SDK 编的插件会握手失败。
// 自动禁用它们时必须**说明原因**,否则用户看到"已禁用"却不知为何,
// 只能去翻日志(而日志可能已轮转)。
r.db.Exec(`ALTER TABLE disabled_plugins ADD COLUMN reason TEXT NOT NULL DEFAULT ''`)
}
func (r *ConfigRegistry) AddDisabledPlugin(name, by string) error {
return r.AddDisabledPluginWithReason(name, by, "")
}
// AddDisabledPluginWithReason 同 AddDisabledPlugin,并记录**为什么**被禁用。
//
// reason 供展示层直接呈现(如"与内核 1.4.0 不兼容:插件 SDK 1.0.0"),
// 让用户不必翻日志就能判断该重编还是该卸载。
func (r *ConfigRegistry) AddDisabledPluginWithReason(name, by, reason string) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by) VALUES (?, datetime('now'), ?)`, name, by)
_, err := r.db.Exec(`INSERT OR REPLACE INTO disabled_plugins (name, disabled_at, disabled_by, reason) VALUES (?, datetime('now'), ?, ?)`, name, by, reason)
return err
}
@ -131,12 +146,17 @@ type DisabledPluginInfo struct {
Name string `json:"name"`
DisabledAt string `json:"disabled_at"`
DisabledBy string `json:"disabled_by"`
// Reason 是禁用原因(可空)。
//
// 人工禁用通常没有原因;**内核自动禁用**必须填(如版本不兼容),
// 否则用户看到"已禁用"却不知为何,只能翻日志。
Reason string `json:"reason,omitempty"`
}
func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
r.mu.RLock()
defer r.mu.RUnlock()
rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by FROM disabled_plugins ORDER BY name`)
rows, err := r.db.Query(`SELECT name, disabled_at, disabled_by, COALESCE(reason,'') FROM disabled_plugins ORDER BY name`)
if err != nil {
return nil, err
}
@ -144,7 +164,7 @@ func (r *ConfigRegistry) ListDisabledPlugins() ([]DisabledPluginInfo, error) {
var list []DisabledPluginInfo
for rows.Next() {
var info DisabledPluginInfo
if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy); err != nil {
if err := rows.Scan(&info.Name, &info.DisabledAt, &info.DisabledBy, &info.Reason); err != nil {
return nil, err
}
list = append(list, info)

View File

@ -381,3 +381,54 @@ func putRawBlock(t *testing.T, g *GraphDB, fp, id string, v []float64) {
t.Fatal(err)
}
}
// ── 2026-10-25:graph_centroid 必须随库创建,不能等第一次 SaveCentroid ──
//
// 缺陷现场(WSL2 真机诊断报告问题 5):新建库上每轮召回都打
//
// `[graph] recall blocks: reading centroid failed, falling back to raw vectors:
// graph: load centroid: no such table: graph_centroid`
//
// 417KB 日志里占相当比例,并回退到全量 raw vectors 扫描。
//
// 根因:buildSchemas 的 schemas 列表里漏了 graph_centroid,它只在
// SaveCentroid 内部被 ensureCentroidSchema 创建——而新库在存过第一个
// 中心向量之前从不调 SaveCentroid。
//
// 判据刻意**不先调 SaveCentroid**:只要一存就会建表,那样就盖住了缺陷
// (变异验证:把 graph.go 里的 ddlCentroid 那行删掉,本判据必须变红)。
func TestGraphCentroidTableExistsOnNewDB(t *testing.T) {
g := newTestGraph(t)
defer g.Close()
var name string
err := g.db.QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name='graph_centroid'`).Scan(&name)
if err != nil {
t.Fatalf("新建库应已有 graph_centroid 表,实际查询失败: %v", err)
}
if name != "graph_centroid" {
t.Fatalf("表名不符: %q", name)
}
}
// 判据二:新建库上直接 LoadCentroid 不得报错。
//
// 这条贴着**真实故障路径**(召回时调 LoadCentroid),比只查 sqlite_master
// 更接近用户看到的现象:查表不存在会以 error 形式冒出来。
func TestLoadCentroidOnNewDBDoesNotFail(t *testing.T) {
g := newTestGraph(t)
defer g.Close()
// 空库查询:应当"查不到"(nil, false)而不是"表不存在"(error)。
c, anomalous, err := g.LoadCentroid("any-fingerprint", 512)
if err != nil {
t.Fatalf("空库 LoadCentroid 不应报错(表必须已存在),实际: %v", err)
}
if c != nil {
t.Fatalf("空库不应返回中心向量,实际: %+v", c)
}
if anomalous {
t.Fatal("空库不应标记 anomalous")
}
}

View File

@ -408,6 +408,16 @@ func (g *GraphDB) initSchema() error {
`CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)`,
`CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)`,
`CREATE INDEX IF NOT EXISTS idx_relation_session ON relations(session_id)`,
// ★ graph_centroid 必须在这里建(2026-10-25 修缺陷)。
//
// 此前它只由 SaveCentroid 内部的 ensureCentroidSchema 创建,
// 而新建的库在存过第一个中心向量之前**从不**调 SaveCentroid。
// 后果:LoadCentroid 每次召回都 `no such table: graph_centroid`,
// 于是回退到全量 raw vectors 扫描——日志刷屏,且大库上明显变慢
// (实测生产库 3178 块)。
//
// 复用 centroid_store.go 的同一个常量,避免两处 DDL 漂移。
ddlCentroid,
}
for _, s := range schemas {

View File

@ -0,0 +1,61 @@
package plugin
import (
"fmt"
"testing"
)
// ── 自动禁用的判因边界 ────────────────────────────────────────
//
// 这两条钉住**只禁版本不兼容**。若放宽到"任何启动失败都禁用",
// 端口被占、配置缺失这类**可自愈**的问题会变成永久不加载——
// 那比反复重试糟糕得多(用户修好环境也不会恢复)。
// 判据 1:主版本不匹配 ⇒ 认得出(应当自动禁用)。
func TestIsVersionIncompatibleErr_RecognizesHandshakeMismatch(t *testing.T) {
// 与 process.go 里握手失败的真实文案一致
err := fmt.Errorf("proc: %s 主版本不匹配(插件 SDK %s,内核 %s)——请用配套 hmapdev 重编",
"qq", "1.0.0", "1.4.0")
if !isVersionIncompatibleErr(err) {
t.Fatal("主版本不匹配必须被识别为版本不兼容")
}
// 详情必须进 reason(用户在界面上要看到"插件 SDK 1.0.0 / 内核 1.4.0")
reason := formatIncompatibleReason(err)
if !containsAll(reason, "1.0.0", "1.4.0", "不兼容") {
t.Errorf("reason 必须带上两端版本号,实际: %s", reason)
}
}
// 判据 2:其它启动失败 ⇒ **不**认(不该自动禁用)。
func TestIsVersionIncompatibleErr_IgnoresTransientFailures(t *testing.T) {
cases := []struct {
name string
err error
}{
{"端口被占", fmt.Errorf("listen tcp :8080: bind: address already in use")},
{"配置缺失", fmt.Errorf("plugin qq: missing required config napcat_url")},
{"进程崩溃", fmt.Errorf("proc: qq 退出码 1")},
{"插件不存在", fmt.Errorf("plugin foo not installed")},
}
for _, c := range cases {
if isVersionIncompatibleErr(c.err) {
t.Errorf("%s 不该被判为版本不兼容(会把可自愈故障变成永久禁用): %v", c.name, c.err)
}
}
}
func containsAll(s string, subs ...string) bool {
for _, sub := range subs {
found := false
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
found = true
break
}
}
if !found {
return false
}
}
return true
}

View File

@ -801,6 +801,64 @@ func pluginEntryHash(plgDir string) string {
return ""
}
// maybeAutoDisableIncompatible 在内核/插件版本不兼容时**持久禁用**该插件。
//
// ## 为什么需要它(OTA 场景)
//
// 内核升级只换二进制(不重编插件)。换完之后,用旧 SDK 编的插件会在握手
// 时被判主版本不匹配而启动失败。若只是 `log.Printf` 后返回 false:
//
// · 每次重启都重试一遍、失败一遍,日志反复刷;
// · 用户在界面上只看到"插件不见了",必须翻日志才知道为什么;
// · 没有任何地方告诉用户"该重编还是该卸载"。
//
// 所以这里把它**持久禁用**并**写下原因**,让展示层能直接说清楚。
//
// ## 为什么只认"版本不兼容"这一类
//
// 其它启动失败(端口被占、配置缺失、插件崩溃)都是**可能自愈**的临时或
// 环境问题。把它们也自动禁用,等于把一个可修复的故障变成"永不加载"——
// 那比反复重试糟糕得多。判据只认握手阶段那条带版本信息的错误。
//
// 返回 true 表示已写入持久禁用。
func (r *Registry) maybeAutoDisableIncompatible(name string, err error) bool {
if err == nil || !isVersionIncompatibleErr(err) {
return false
}
cfgReg := r.cfgRegistryOrNil()
if cfgReg == nil {
return false
}
reason := formatIncompatibleReason(err)
if e := cfgReg.AddDisabledPluginWithReason(name, "kernel", reason); e != nil {
log.Printf("[plugin] auto-disable %s failed: %v", name, e)
return false
}
r.mu.Lock()
r.knownDisabled[name] = true
r.mu.Unlock()
// 用 Info 而不是 Printf:这是**状态变更**,不是过程日志。
log.Printf("[plugin] AUTO-DISABLED %s: %s(已写入禁用配置,跨重启保留;重编插件或手动启用即可恢复)",
name, reason)
return true
}
// isVersionIncompatibleErr 判定错误是否为"主版本不匹配"。
//
// 判据用错误文案里的稳定片段,而不是新引入错误类型:握手错误从
// proc 包穿过多层包装传上来,改成类型化错误要动 process.go 的返回契约,
// 而那些错误同时被别的调用方消费。这里只做**窄**判定——认不出就不自动禁用,
// 保持"反复重试但可自愈"的既有行为。
func isVersionIncompatibleErr(err error) bool {
msg := err.Error()
return strings.Contains(msg, "主版本不匹配")
}
// formatIncompatibleReason 把握手错误压成一句可展示的原因。
func formatIncompatibleReason(err error) string {
return "与内核版本不兼容:" + strings.TrimSpace(err.Error())
}
func (r *Registry) loadOne(plgDir, name string) bool {
if r.isDisabled(name) {
log.Printf("[plugin] %s is disabled, skipping", name)
@ -869,6 +927,7 @@ func (r *Registry) loadOne(plgDir, name string) bool {
plgSDK := r.buildSDK(name)
if err := plg.Start(plgSDK); err != nil {
log.Printf("[plugin] start %s: %v", name, err)
r.maybeAutoDisableIncompatible(name, err)
return false
}

View File

@ -995,11 +995,19 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer emitTermState(s, t, false)
defer flushTermStream(s, t)
// 立即发送首次"终端已启动"通知,让 agent 感知存在。
// 用 NoMemory:这是状态提示,不是对话内容。不关掉的话每开一个终端都会
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id),
sdk.InjectOptions{NoMemory: true})
// ★ 不注入「终端已启动」通知(2026-10-25 移除)。
//
// 理由:terminal_create 的**返回值本身**已经告知 agent 终端已建立,
// 且带 id/command/timeout/rows/cols/notify_mode —— 信息完整。
// 再用「用户输入」的形式注入一遍,等于同一条信息告知两次,
// 而每次注入都会唤醒**一整轮 LLM 推理**。
//
// 实测代价(WSL2 真机):开一个终端 → 一轮推理;关一个 →
// 又一轮。agent 反复向用户解释"终端怎么又没了",真正要做的活一件没做。
//
// 设计依据(本插件自己的 notify 参数):什么该通知,本来就由 agent
// 在创建时通过 notify=exit/interval/buffer 指定。无条件旁路注入
// 与本插件自身的设计相矛盾。
now := time.Now()
t.mu.Lock()
t.lastNotify = now
@ -1023,9 +1031,18 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
}
if t.IsExpired() {
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout),
sdk.InjectOptions{NoMemory: true})
// ★ 只记日志,**不注入**(2026-10-25 修正)。
//
// 两条理由:
// ① 超时时长已在创建时由 terminal_create 的 timeout 字段告知,
// agent 完全能推导"它 5 分钟后会自己关"——再通知没有新信息。
// ② 注入后紧接着 removeSession,agent 去 terminal_list 查必然是
// 0 个,于是只能一遍遍排查"终端为什么消失",而这是插件自己删的。
//
// 注意与下方 r.err 分支的一致性:那里已经把「超时清理」列为
// **必须静默**;同一函数内同一个 IsExpired() 不能一处静默一处注入。
log.Printf("[agentcli] terminal %s expired after %v (silent: timeout already reported at create)",
t.id, t.timeout)
p.removeSession(t.id)
return
}
@ -1096,7 +1113,19 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
if p.isClosing(t) || t.IsExpired() {
return // 主动关闭 / 超时清理:不注入
}
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err),
// ★ 不把 r.err 原文灌进 prompt(2026-10-25 修正)。
//
// 原来注入的是 `[终端 X 读取结束: read /dev/ptmx: input/output error]`。
// agent 会把它当成需要排查的技术问题,花好几轮去查
// /dev/ptmx 是否存在、devpts 挂载是否正常——虽然结论是对的
// (EIO 是 pty 主端关闭后 read 的正常返回),但这根本不该
// 成为 agent 的活,而且每一轮查询都在烧上下文。
//
// 改为:给**结论**(进程已结束)而不是**机制**(什么 errno)。
// 原始错误只进日志,需要时人工看得到。
log.Printf("[agentcli] terminal %s read ended: %v", t.id, r.err)
s.InjectTextOpts("agentcli", "agentcli",
fmt.Sprintf("[终端 %s 读取结束]", t.id),
sdk.InjectOptions{NoMemory: true})
return
}

View File

@ -480,6 +480,16 @@ type mockTerm struct {
data chan []byte
running bool
err error
// reads 记录 Read 被调用次数,供 waitReadLoopReady 判断 readLoop 已起来。
// 不用固定 sleep:机器慢时 sleep 不够、快时白等。
reads int
}
// readCalls 返回 Read 被调用次数(并发安全)。
func (m *mockTerm) readCalls() int {
m.mu.Lock()
defer m.mu.Unlock()
return m.reads
}
func newMockTerm() *mockTerm {
@ -487,6 +497,9 @@ func newMockTerm() *mockTerm {
}
func (m *mockTerm) Read(buf []byte) (int, error) {
m.mu.Lock()
m.reads++
m.mu.Unlock()
for {
m.mu.Lock()
err := m.err
@ -517,6 +530,7 @@ func (m *mockTerm) Running() bool {
defer m.mu.Unlock()
return m.running
}
// Kill 模拟真实 PTY 的行为:把一份 **buffer 里的输出 + EIO** 交给 Read。
//
// 两点必须还原,否则测不出线上的回声:
@ -570,6 +584,26 @@ func startReadLoop(p *Plugin, s *sdk.PluginSDK, t *TerminalSession) {
go p.readLoop(t, s)
}
// waitReadLoopReady 等 readLoop 真正进入工作状态。
//
// ★ 为什么需要它:原测试用「已启动」注入当同步点(等那条通知出现再继续)。
// 但 2026-10-25 起该注入已移除(它是"同一条信息告知两次",每条都会唤醒
// 一整轮推理)。同步点因此需要换成不依赖注入的方式。
//
// 这里用「mockTerm 的 Read 至少被调用过一次」作为信号:readLoop 的第一件事
// 就是 Read,比固定 sleep 可靠,也不引入对业务通知的依赖。
func waitReadLoopReady(t *testing.T, term *mockTerm) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if term.readCalls() > 0 {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("readLoop 未在 2s 内进入读取状态")
}
func waitInjected(c *injectCapture, substr string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
@ -602,9 +636,9 @@ func TestReadLoopNotifyThrottle(t *testing.T) {
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
// 持续以 100B/50ms(=2KB/s) 吐进度 3 秒
stop := make(chan struct{})
@ -666,9 +700,9 @@ func TestReadLoopNotifyOnExit(t *testing.T) {
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
term.setRunning(false)
gotExit := waitInjected(capture, "进程已退出", 2*time.Second)
@ -698,9 +732,9 @@ func TestReadLoopNotifyOnReadError(t *testing.T) {
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
term.setErr(fmt.Errorf("read timeout"))
if !waitInjected(capture, "读取结束", 3*time.Second) {
@ -740,13 +774,13 @@ func TestClose_NoEchoNotificationsAfterClose(t *testing.T) {
})
sdkInst.SetIOInjector(capture)
ts := newTestSession(newMockTerm())
mterm := newMockTerm()
ts := newTestSession(mterm)
startReadLoop(p, sdkInst, ts)
// 等"已启动"到达:这是**正常**通知(终端确实刚创建)。
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, mterm)
before := len(capture.snapshot())
// 主动关闭:模拟 close 工具路径(关 stopCh + 杀进程 + 释放 PTY)。
@ -794,9 +828,9 @@ func TestProcessExit_StillNotifies(t *testing.T) {
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, term)
// 进程自己退出(**不是** Close):mockTerm 返回 process exited。
term.mu.Lock()
@ -835,9 +869,7 @@ func TestClose_NoEcho_Repeated(t *testing.T) {
term := newMockTerm()
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
waitReadLoopReady(t, term)
before := len(capture.snapshot())
// ★ 关键:先在 PTY 缓冲里**留下未读数据**,再关闭。
@ -891,16 +923,17 @@ func TestClose_RemovesSessionFromTable(t *testing.T) {
})
sdkInst.SetIOInjector(capture)
ts := newTestSession(newMockTerm())
mterm2 := newMockTerm()
ts := newTestSession(mterm2)
ts.id = "term_leak"
p.mu.Lock()
p.sessions = map[string]*TerminalSession{"term_leak": ts}
p.mu.Unlock()
startReadLoop(p, sdkInst, ts)
if !waitInjected(capture, "已启动", 2*time.Second) {
t.Fatal("expected startup notification")
}
// 等 readLoop 真正起来(不再依赖"已启动"注入:那已被移除,
// 理由见 plugin.go 中"不注入终端已启动通知"的说明)
waitReadLoopReady(t, mterm2)
ts.Close() // 内部会 <-t.done,等 readLoop 走完
@ -952,3 +985,91 @@ func TestCleanup_DoesNotNotifyForClosedTerminal(t *testing.T) {
}
_ = capture
}
// ── 2026-10-25 新增:禁止「自造状态回声」的判据 ──────────────────
//
// 背景(真机事故):agent 通过 terminal_create 建终端后,插件注入
// 「[终端 X 已启动]」;超时后又注入「[终端 X 已超时关闭]」。
// 这两条都是**插件自己造成、且 agent 已能从工具返回值推导**的状态,
// 而每条注入都会唤醒一整轮 LLM 推理。实测后果:agent 反复向用户
// 解释"终端怎么又没了",真正要做的活一件没做。
//
// 下面两条是**反向判据**:断言这些注入**不再出现**。没有它们,
// 后人(或我自己)很容易为了"让 agent 感知"把注入加回来,而那时
// 上面那些 waitReadLoopReady 的测试依然全绿 —— 缺陷会静默复发。
// 判据一:建终端不得产生「已启动」注入(terminal_create 返回值已足够)。
func TestNoStartupNotificationInjected(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20 // 不触发缓冲通知,隔离变量
capture := &injectCapture{}
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
RegTool: newToolCapture().RegisterTool,
RegStage: func(sdk.Stage, sdk.StageHandler) {},
RegAPI: func(string) error { return nil },
Settings: sdk.NewSettings("agentcli", nil),
})
sdkInst.SetIOInjector(capture)
term := newMockTerm()
ts := newTestSession(term)
startReadLoop(p, sdkInst, ts)
waitReadLoopReady(t, term)
// 给足时间让任何潜在注入发生
time.Sleep(150 * time.Millisecond)
for _, text := range capture.snapshot() {
if strings.Contains(text, "已启动") {
t.Fatalf("不得注入「已启动」(terminal_create 返回值已告知): %q", text)
}
}
close(ts.stopCh)
<-ts.done
}
// 判据二:超时关闭不得注入(创建时 timeout 已告知)。
func TestNoExpiredNotificationInjected(t *testing.T) {
p := New("agentcli")
p.notifyBytes = 1 << 20
capture := &injectCapture{}
sdkInst := sdk.New("agentcli", sdk.SDKConfig{
RegTool: newToolCapture().RegisterTool,
RegStage: func(sdk.Stage, sdk.StageHandler) {},
RegAPI: func(string) error { return nil },
Settings: sdk.NewSettings("agentcli", nil),
})
sdkInst.SetIOInjector(capture)
term := newMockTerm()
ts := newTestSession(term)
// 设为已超时:readLoop 下一轮循环就会走 IsExpired 分支
ts.mu.Lock()
ts.timeout = time.Nanosecond
ts.createdAt = time.Now().Add(-time.Hour)
ts.mu.Unlock()
startReadLoop(p, sdkInst, ts)
waitReadLoopReady(t, term)
// 等 removeSession 真的发生(说明走了超时分支)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
p.mu.Lock()
_, still := p.sessions[ts.id]
p.mu.Unlock()
if !still {
break
}
time.Sleep(2 * time.Millisecond)
}
for _, text := range capture.snapshot() {
if strings.Contains(text, "已超时关闭") {
t.Fatalf("不得注入「已超时关闭」(创建时 timeout 已告知): %q", text)
}
}
}

View File

@ -83,6 +83,11 @@ type DisabledPluginInfo struct {
Name string `json:"name"`
DisabledAt string `json:"disabled_at"`
DisabledBy string `json:"disabled_by"`
// Reason 是禁用原因(可空)。
//
// 人工禁用通常为空;**内核自动禁用**必须填(如与内核版本不兼容),
// 让用户在界面上直接看到"为什么没了",而不是去翻日志。
Reason string `json:"reason,omitempty"`
}
// PluginMeta is the display-name metadata for a plugin (from plg.json / RegisterPluginMeta).