Files
HomeAgent/internal/plugin/manifest.go
JianFeeeee 3f834794c5 refactor: SDK module 路径统一改为 hologramagentsdk(59 文件 + go.mod)
## 为什么必须做

SDK 仓(独立仓库)已把 module 从 `github.com/JianFeeeee/homeagentsdk`
改名为 `hologramagentsdk`。主仓通过 `replace` 直接引用它的**物理目录**:

    replace github.com/JianFeeeee/homeagentsdk => ./third_party/homeagent-sdk

一旦 SDK 侧改成新 module 名,主仓立刻炸:

    go: ./third_party/homeagent-sdk@ used for two different module paths
        (github.com/JianFeeeee/hologramagentsdk and github.com/JianFeeeee/homeagentsdk)

Go **不允许**同一目录被两个 module 路径 replace。所以不能"新旧并存",
只能把主仓 59 个文件的 import 全部改到新名,并删掉旧 replace。

## 一个被证伪的前提

我最初按"third_party/homeagent-sdk 是独立仓库,不能在此仓修改其源码"
来处理,并因此判断两个仓的改动可以隔离。实际上主仓是靠 `replace` 指向
该目录的物理副本 —— **两个仓的改动不可能真正隔离**:SDK 侧改 module 名,
主仓必然跟着改 import。

这个前提应该在做任何改动之前用 `go list -m` 验证,而不是假设。

## 连带修掉两处

| 缺陷 | 性质 |
|---|---|
| `TestLuaSDKMockSingleSource` 失败 | SDK 的 `sdk/lua/sdk.lua` 变了,主仓内嵌副本未同步(跑 `scripts/sync-lua-sdk.sh` 修复) |
| `sdkModuleNames` 分散两处 | 已在 SDK 仓合并为单一数据源(见 SDK 仓 725732b) |

第一条是**副本一致性判据抓到的**,不是我自己发现的 —— 这类判据的价值在此:
两个仓必然有副本,靠人记住"改了 A 要同步 B"是做不到的。

## 验证

- `go build ./...` / `go vet ./...` 干净
- `go test ./... -count=1`:**58 包 0 失败**
- `internal/plugin`(Lua SDK 副本一致性)+ `internal/sdk` 全绿
- 主仓 `third_party/homeagent-sdk/` 与 SDK 仓 `main` 无差异
2026-10-23 15:51:02 +08:00

190 lines
8.6 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 plugin
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/JianFeeeee/HologramAgent/internal/plugin/proc"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
const PackageExt = ".hmap"
// SDKProxyDef 是 SDK 反代声明在本包的别名(避免调用方两处 import)。
type SDKProxyDef = pubsdk.ProxyDef
// PluginManifest 每个插件目录中的 plugin.json 元数据。
type PluginManifest struct {
Name string `json:"name"`
NameZh string `json:"name_zh,omitempty"`
NameEn string `json:"name_en,omitempty"`
Version string `json:"version"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
License string `json:"license,omitempty"`
Homepage string `json:"homepage,omitempty"`
Repository string `json:"repository,omitempty"`
Entry string `json:"entry"` // "plugin.bin"(子进程) | "main.lua" | "SKILL.md"
Platforms []string `json:"platforms,omitempty"` // 声明的支持平台: ["linux","darwin","windows"]
MinVersion string `json:"min_version,omitempty"`
Tags []string `json:"tags,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
// HandshakeTimeoutSec 是本插件**自行声明**的建链(握手)超时,单位秒。
//
// 省略或 <=0 时用内核默认值(proc.defaultHandshakeTimeout,10s)。
//
// ## 为何要让插件自己声明
//
// 实测(2026-10-16):生产启动 13 秒,其中 **10 秒是串行等待**插件内部的
// 外部资源就绪(clawhubadapter 起两个 pysidecar,各 5 秒 ping timeout),
// 而它阻塞了后面 20+ 个插件的加载。
//
// 根因不是“超时值选错了”,而是**超时值是内核猜的**:
// · 轻插件(纯 Go,秒起)用不到 10s
// · 重插件(要起 Python/JVM/加载大模型)10s 可能不够,
// 于是它会“失败”且失败得很晚(白等 10 秒才报错)
// 只有插件自己知道“我大概要多久”,所以这个值应由它声明。
//
// ## 为什么不叫 Timeout 而叫 HandshakeTimeout
//
// 插件内部等待**自己的子资源**(如 pysidecar ping)不属于 SDK 契约,
// 那是插件自己的实现细节(内置插件应走自己的 ConfigDef)。
// 本字段只管**内核与插件之间**的建链——那才是内核能干预的边界。
HandshakeTimeoutSec int `json:"handshake_timeout_sec,omitempty"`
// RequiresFacilities 声明本插件需要**内核哪些设施就绪**后才能启动。
//
// 这决定插件的**启动时机**(设计 docs/zh/kernel-modularization-design.md §2.6):
// · **不声明(本字段缺省)** ⇒ 等内核**完全**初始化完成后拉起 ← **现行为**
// · 声明如 ["config","llm"] ⇒ 这些设施一就绪就拉起(**尽早启动**)
//
// ## ★ 为何"不声明"必须等于现行为(存量插件零改动)
//
// 生产有 42 个外部插件,它们全都不声明 ⇒ **启动时机逐字不变**。
// 若"不声明"被当成"无依赖/依赖已满足",它们会在第 0 层完成后就被拉起,
// 早于现在得多 ⇒ 42 个插件里任何一个若假设"我 Start 时内核已就绪",
// 就会出现**静默竞态**(系统看着正常,偶发才炸)。
//
// 所以判据里有一条专门守这个:未声明的插件在内核完成前**不得**被拉起。
//
// ## 设施名从哪来
//
// 内核模块在 `Provides()` 里声明(见 internal/kernelboot):
// config / crypto / keystore / memory / lua / llm / tracker / textmem /
// docmem / knowledge / multimodal / stagehost / agent ...
//
// 拼错的名字**不会**静默忽略:它永远等不到就绪 ⇒ 插件最终由
// 「内核完成 + 超时兜底」拉起,并留下"还差 X"的日志(见调度器 WaitString)。
RequiresFacilities []string `json:"requires_facilities,omitempty"`
// AutoStart 声明是否**伴随内核启动**(true/缺省)还是等显式启用(false)。
//
// 用 *bool 而非 bool,因为要区分三种情形:
// · 缺省(nil) ⇒ 视为 true(伴随启动)← 与现状一致
// · 显式 true ⇒ 伴随启动
// · 显式 false ⇒ **不**伴随启动,等 agent 经 plugin_enable 启用
//
// ★ 为何不能用普通 bool:JSON 里 bool 的零值是 false,
// 于是"没写这个字段"与"写了 false"**无法区分**,
// 前者会被误判成"不自动启动"⇒ 存量插件全都不启动了(灾难性)。
// 这个坑与本仓 handshake_timeout_sec 用 int 而非 *int 是同一类;
// 那里能接受(0 与"未声明"语义相同),这里**不能**。
AutoStart *bool `json:"auto_start,omitempty"`
// Proxies 声明本插件需要 HologramAgent 反代出去的服务(自带 Web UI / HTTP API)。
//
// 契约定义在公开 SDK(sdk.ProxyDecl),这里只做载体:HologramAgent 加载插件时
// 读取并聚合,按 Host 子域路由从 webui 的同一端口转发出去。**不声明 = 不被反代**。
//
// 字段解析忽略未知键(本仓无 DisallowUnknownFields),因此加这个字段
// 对「旧内核读新插件」与「新内核读旧插件」都是无害的。
Proxies []SDKProxyDef `json:"proxies,omitempty"`
// Capabilities 声明本插件需要的内核能力组(§3.8 权限梯度)。
//
// 取值见 internal/plugin/proc.KnownCapabilities():
// io / memory / doc_memory / knowledge / text_memory / llm / social /
// events / plugin_mgr / settings_cross
//
// **省略或为空 = 不受限**,而不是「只有基础能力」。
// 理由:17 个存量插件的 plugin.json 都没有这个字段,若空声明当作最小权限,
// 它们会全部失去 IO 注入、记忆读写等能力而**静默降级**——
// 违反「外部插件零改动」的硬约束。收紧的路径是让插件显式声明。
//
// core(注册自身工具/阶段/通道 + 读写自己的配置)无需声明,始终可用。
Capabilities []string `json:"capabilities,omitempty"`
}
// ShouldAutoStart 返回本插件是否应伴随内核启动(缺省 = true)。
//
// 与 RequiresFacilities 配合决定启动时机;两者正交:
//
// · AutoStart=false ⇒ 无论依赖是否就绪都**不**自动拉起
// · AutoStart=true(默认)+ 声明依赖 ⇒ 依赖齐了就拉起
// · AutoStart=true(默认)+ 不声明依赖 ⇒ 等内核完全初始化
func (m *PluginManifest) ShouldAutoStart() bool {
if m == nil || m.AutoStart == nil {
return true // ★ 缺省即伴随启动(存量插件零改动)
}
return *m.AutoStart
}
// HandshakeTimeout 把声明的秒数换算成时长,并施加**上限**。
//
// ## 默认回退(存量插件零改动的硬约束)
//
// 未声明(0)或非法(负)⇒ 返回 def(调用方传 proc.DefaultHandshakeTimeout)。
//
// ★ 为何负数也回退默认而不是报错:“0 表示未声明”是 JSON 的常见约定,
// 而手写配置里出现 `-1` 很可能只是“想表达无/未设”的笔误。
// 若因此拒绝加载整个插件,代价远大于“按默认值处理”。
// 但**绝不**把负数直接当时长用(那会变成“立即超时”,有隐蔽的破坏性)。
//
// ## 为何必须有上限
//
// 插件声明的值直接决定**内核**等多久。一个错误声明(如 86400 = 一天)
// 会让内核在插件坏掉时**卡一整天**——那不是“插件自己负责”,
// 而是把内核拖住(而插件加载是启动路径,会阻塞后续插件)。
//
// 上限取 5 分钟:远超任何合理的插件启动耗时(实测最重的也只需数十秒),
// 但仍在“可承受的人工等待”范围内(远小于“一天”)。
func (m *PluginManifest) HandshakeTimeout(def time.Duration) time.Duration {
if def <= 0 {
def = proc.DefaultHandshakeTimeout
}
if m == nil || m.HandshakeTimeoutSec <= 0 {
return def
}
d := time.Duration(m.HandshakeTimeoutSec) * time.Second
if d > maxHandshakeTimeout {
return maxHandshakeTimeout
}
return d
}
// maxHandshakeTimeout 是插件可声明的建链超时上限。
//
// 见 HandshakeTimeout 的说明:没有上限时,一个错误声明能把内核卡住。
const maxHandshakeTimeout = 5 * time.Minute
func ReadManifest(dir string) (*PluginManifest, error) {
data, err := os.ReadFile(filepath.Join(dir, "plugin.json"))
if err != nil {
return nil, err
}
var m PluginManifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, err
}
return &m, nil
}
// IsPluginDir 判断目录是否为有效的插件目录(包含 plugin.json)
func IsPluginDir(dir string) bool {
_, err := os.Stat(filepath.Join(dir, "plugin.json"))
return err == nil
}