mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 09:28:14 +00:00
Merge branch 'main' of gitcode.com:JianFeeeee/HomeAgent
This commit is contained in:
@ -101,4 +101,7 @@ const (
|
||||
CoreSettingsPlugins = 45
|
||||
CoreRegisterInputCh = 46
|
||||
CoreInjectInputSync = 47
|
||||
CorePluginReloadOne = 48
|
||||
CorePluginListLoaded = 49
|
||||
CorePluginIsDisabled = 50
|
||||
)
|
||||
|
||||
@ -887,6 +887,40 @@ func go_core_dispatch(methodID C.int, ctx unsafe.Pointer, s1, s2, s3 *C.char, i1
|
||||
}
|
||||
s.RegisterInputChannel(a1, chDef)
|
||||
return 0
|
||||
|
||||
case 48: // CORE_PLUGIN_RELOAD_ONE
|
||||
if s.PluginMgr() == nil {
|
||||
setErr(errorOut, fmt.Errorf("plugin manager not available"))
|
||||
return 1
|
||||
}
|
||||
if err := s.PluginMgr().ReloadOne(a1); err != nil {
|
||||
setErr(errorOut, err)
|
||||
return 1
|
||||
}
|
||||
setResult(result, "reloaded: "+a1)
|
||||
return 0
|
||||
|
||||
case 49: // CORE_PLUGIN_LIST_LOADED
|
||||
if s.PluginMgr() == nil {
|
||||
setErr(errorOut, fmt.Errorf("plugin manager not available"))
|
||||
return 1
|
||||
}
|
||||
if b, err := json.Marshal(s.PluginMgr().ListLoadedPlugins()); err == nil {
|
||||
setResult(result, string(b))
|
||||
}
|
||||
return 0
|
||||
|
||||
case 50: // CORE_PLUGIN_IS_DISABLED
|
||||
if s.PluginMgr() == nil {
|
||||
setErr(errorOut, fmt.Errorf("plugin manager not available"))
|
||||
return 1
|
||||
}
|
||||
if s.PluginMgr().IsPluginDisabled(a1) {
|
||||
setResult(result, "1")
|
||||
} else {
|
||||
setResult(result, "0")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func TestExtractDescription(t *testing.T) {
|
||||
@ -195,3 +201,60 @@ A tool with no parameters`
|
||||
t.Errorf("expected no params, got %d", len(props))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginManagerInterfaceReloadOne(t *testing.T) {
|
||||
// 编译期契约:Registry 必须实现 PluginManager(含 ReloadOne 单插件重载)。
|
||||
var _ sdk.PluginManager = (*Registry)(nil)
|
||||
}
|
||||
|
||||
func TestRegistryIncrementalReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
plgDir := filepath.Join(dir, "plugins")
|
||||
os.MkdirAll(plgDir, 0755)
|
||||
|
||||
// 一个 Lua 插件
|
||||
luaDir := filepath.Join(plgDir, "reloaddemo")
|
||||
os.MkdirAll(luaDir, 0755)
|
||||
os.WriteFile(filepath.Join(luaDir, "plugin.json"), []byte(`{"name":"reloaddemo","entry":"main.lua"}`), 0644)
|
||||
writeLua := func(body string) {
|
||||
os.WriteFile(filepath.Join(luaDir, "main.lua"), []byte(`local plugin = { name = "reloaddemo" }
|
||||
function plugin.start(sdk) sdk.log("info", "`+body+`") end
|
||||
function plugin.stop() end
|
||||
return plugin
|
||||
`), 0644)
|
||||
}
|
||||
writeLua("v1")
|
||||
|
||||
reg := NewRegistry()
|
||||
reg.SetPluginDir(plgDir)
|
||||
reg.SetConfigRegistry(internalConfig.NewConfigRegistry(""))
|
||||
|
||||
|
||||
// 首次 Reload:应加载 1 个
|
||||
msg, err := reg.Reload(plgDir)
|
||||
if err != nil {
|
||||
t.Fatalf("first reload: %v", err)
|
||||
}
|
||||
if len(reg.List()) != 1 {
|
||||
t.Fatalf("first reload loaded=%d, want 1 (%s)", len(reg.List()), msg)
|
||||
}
|
||||
|
||||
// 无变更再 Reload:不应重载(0 changed, 1 unchanged)
|
||||
msg, err = reg.Reload(plgDir)
|
||||
if err != nil {
|
||||
t.Fatalf("second reload: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "0 plugins") || !strings.Contains(msg, "1 unchanged") {
|
||||
t.Errorf("unchanged reload should skip: %q", msg)
|
||||
}
|
||||
|
||||
// 修改 main.lua → 应重载该插件
|
||||
writeLua("v2")
|
||||
msg, err = reg.Reload(plgDir)
|
||||
if err != nil {
|
||||
t.Fatalf("changed reload: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "1 plugins") {
|
||||
t.Errorf("changed reload should reload 1: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@ -97,6 +99,10 @@ type Registry struct {
|
||||
|
||||
knownDisabled map[string]bool
|
||||
allowlist map[string]bool
|
||||
|
||||
// pluginHashes 记录各插件二进制(plugin.so/main.lua)的 SHA256,
|
||||
// 供增量重载(Reload)对比:仅重载有变更的插件,避免全量 StopAll+Load 导致重复加载。
|
||||
pluginHashes map[string]string
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
@ -106,6 +112,7 @@ func NewRegistry() *Registry {
|
||||
pluginAutoRestart: make(map[string]bool),
|
||||
sdkRefs: make(map[string]*sdk.PluginSDK),
|
||||
knownDisabled: make(map[string]bool),
|
||||
pluginHashes: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@ -345,6 +352,19 @@ func (r *Registry) isDisabled(name string) bool {
|
||||
return r.cfgReg.IsPluginDisabled(name)
|
||||
}
|
||||
|
||||
// pluginEntryHash 计算插件入口文件(plugin.so 或 main.lua)的 SHA256,用于增量重载对比。
|
||||
// 无入口文件(内置纯工厂插件)返回空字符串(始终视为已加载)。
|
||||
func pluginEntryHash(plgDir string) string {
|
||||
for _, candidate := range []string{"plugin.so", "plugin.dll", "main.lua", "SKILL.md"} {
|
||||
path := filepath.Join(plgDir, candidate)
|
||||
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (r *Registry) loadOne(plgDir, name string) bool {
|
||||
if r.isDisabled(name) {
|
||||
log.Printf("[plugin] %s is disabled, skipping", name)
|
||||
@ -410,6 +430,11 @@ func (r *Registry) loadOne(plgDir, name string) bool {
|
||||
r.pluginAutoRestart[name] = plgSDK.AutoRestart()
|
||||
r.sdkRefs[name] = plgSDK
|
||||
r.instances = append(r.instances, plg)
|
||||
if h := pluginEntryHash(plgDir); h != "" {
|
||||
r.pluginHashes[name] = h
|
||||
} else {
|
||||
delete(r.pluginHashes, name)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
log.Printf("[plugin] loaded: %s", name)
|
||||
return true
|
||||
@ -445,11 +470,55 @@ func (r *Registry) StopAll() {
|
||||
}
|
||||
|
||||
func (r *Registry) Reload(dir string) (string, error) {
|
||||
r.StopAll()
|
||||
if err := r.Load(dir); err != nil {
|
||||
if dir == "" {
|
||||
dir = r.plgDir
|
||||
}
|
||||
// 增量重载:扫描插件目录,对比入口文件 hash,仅 Stop+重载有变更的插件。
|
||||
// 未变更插件保持运行,避免 plgreload 触发全量 StopAll+Load 导致所有插件重复加载
|
||||
// 及内置插件(如 healthcheck)状态机错乱。
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("loaded %d plugins", len(r.instances)), nil
|
||||
changed := 0
|
||||
remaining := 0
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !r.allowlistAllows(name) {
|
||||
continue
|
||||
}
|
||||
plgDir := filepath.Join(dir, name)
|
||||
h := pluginEntryHash(plgDir)
|
||||
r.mu.RLock()
|
||||
old := r.pluginHashes[name]
|
||||
loaded := r.plugins[name] != nil
|
||||
r.mu.RUnlock()
|
||||
// 无入口文件(纯内置工厂插件)始终视为已加载;
|
||||
// 有变更或首次出现且未加载 → 需要重载。
|
||||
if !loaded {
|
||||
if r.loadOne(plgDir, name) {
|
||||
changed++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if h == "" {
|
||||
remaining++
|
||||
continue
|
||||
}
|
||||
if old != h {
|
||||
if err := r.ReloadOne(name); err != nil {
|
||||
log.Printf("[plugin] reload %s: %v", name, err)
|
||||
} else {
|
||||
changed++
|
||||
}
|
||||
} else {
|
||||
remaining++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("reloaded %d plugins, %d unchanged", changed, remaining), nil
|
||||
}
|
||||
|
||||
func (r *Registry) ReloadOne(name string) error {
|
||||
|
||||
@ -53,6 +53,9 @@ type Plugin struct {
|
||||
sdk *sdk.PluginSDK
|
||||
dispatcher *RegistryDispatcher
|
||||
httpClient *http.Client
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// pluginSingleton 内核单例引用(Start 时设置),供 SendToChannel/ChannelSender 使用
|
||||
@ -65,6 +68,7 @@ func New(name, skillsDir string) *Plugin {
|
||||
}
|
||||
return &Plugin{
|
||||
name: name,
|
||||
stopCh: make(chan struct{}),
|
||||
skillsDir: skillsDir,
|
||||
simulatorDir: sd,
|
||||
dispatcher: NewDispatcher(),
|
||||
@ -300,10 +304,14 @@ func (p *Plugin) ipcGoroutine(s *sdk.PluginSDK) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
p.mu.Lock()
|
||||
simDir := p.simulatorDir
|
||||
p.mu.Unlock()
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
p.mu.Lock()
|
||||
simDir := p.simulatorDir
|
||||
p.mu.Unlock()
|
||||
if simDir == "" {
|
||||
continue
|
||||
}
|
||||
@ -339,6 +347,7 @@ func (p *Plugin) ipcGoroutine(s *sdk.PluginSDK) {
|
||||
}
|
||||
os.Remove(reloadPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1247,6 +1256,10 @@ func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error {
|
||||
func (p *Plugin) Stop() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
// 关停 ipcGoroutine(stopCh),避免重载后旧 goroutine 残留导致线程累积
|
||||
p.stopOnce.Do(func() {
|
||||
close(p.stopCh)
|
||||
})
|
||||
for _, sp := range p.sidecars {
|
||||
sp.Close()
|
||||
}
|
||||
|
||||
@ -49,8 +49,7 @@ type Plugin struct {
|
||||
selfToolNames map[string]bool
|
||||
checkMu sync.Mutex
|
||||
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
stopCh chan struct{}
|
||||
perfData PerfData
|
||||
|
||||
autoInterval time.Duration
|
||||
@ -83,6 +82,15 @@ func New(name string) *Plugin {
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
// 幂等重启 auto-check:若实例被 Stop 过(stopCh 已关闭)后再次 Start
|
||||
// (如 plgreload 复用实例),重建 stopCh 使 startAutoCheck 能重新调度 ticker。
|
||||
p.mu.Lock()
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
p.stopCh = make(chan struct{})
|
||||
default:
|
||||
}
|
||||
p.mu.Unlock()
|
||||
s.SetAutoRestart(true)
|
||||
p.autoInterval = 30 * time.Minute
|
||||
p.llmTimeout = 120 * time.Second
|
||||
@ -280,9 +288,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error {
|
||||
p.stopOnce.Do(func() {
|
||||
// 幂等关闭:仅当 stopCh 未被关闭时 close。
|
||||
p.mu.Lock()
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
default:
|
||||
close(p.stopCh)
|
||||
})
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -74,6 +74,8 @@ type PluginManager interface {
|
||||
// onRemove 回调(RegisterOnRemoveHandler),最后从注册表移除。目录删除由调用方负责。
|
||||
RemovePlugin(name string) error
|
||||
ReloadPlugins() (string, error)
|
||||
// ReloadOne 重载单个插件(停止后重新加载,处理 dlclose/dynamic 句柄)。
|
||||
ReloadOne(name string) error
|
||||
PluginMetas() map[string]PluginMeta
|
||||
PluginDir() string
|
||||
}
|
||||
|
||||
5
third_party/homeagent-sdk/meta/meta.go
vendored
5
third_party/homeagent-sdk/meta/meta.go
vendored
@ -99,4 +99,9 @@ const (
|
||||
CoreSettingsDefs = 43
|
||||
CoreSettingsDump = 44
|
||||
CoreSettingsPlugins = 45
|
||||
CoreRegisterInputCh = 46
|
||||
CoreInjectInputSync = 47
|
||||
CorePluginReloadOne = 48
|
||||
CorePluginListLoaded = 49
|
||||
CorePluginIsDisabled = 50
|
||||
)
|
||||
|
||||
19
third_party/homeagent-sdk/sdk/plugin.go
vendored
19
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -147,6 +147,17 @@ type EventSubscriber interface {
|
||||
Subscribe(eventType EventType, handler EventHandler) func()
|
||||
}
|
||||
|
||||
// PluginMgrAPI 提供插件管理能力(外部插件可调用)。
|
||||
// 由 bridge 注入 dispatch 实现,走 C ABI CORE_PLUGIN_RELOAD_ONE 等。
|
||||
type PluginMgrAPI interface {
|
||||
// ReloadOne 重载单个插件(停止后重新加载)。
|
||||
ReloadOne(name string) error
|
||||
// ListLoadedPlugins 列出已加载插件。
|
||||
ListLoadedPlugins() []string
|
||||
// IsPluginDisabled 查询插件是否被禁用。
|
||||
IsPluginDisabled(name string) bool
|
||||
}
|
||||
|
||||
// StageScope controls which events a stage handler receives.
|
||||
type StageScope int
|
||||
|
||||
@ -200,6 +211,7 @@ type PluginSDK struct {
|
||||
sett SettingsAPI
|
||||
social SocialAPI
|
||||
events EventSubscriber
|
||||
plgMgr PluginMgrAPI
|
||||
|
||||
autoRestart bool
|
||||
|
||||
@ -347,6 +359,13 @@ func (s *PluginSDK) SetLLMAPI(llm LLMAPI) { s.llm = llm }
|
||||
func (s *PluginSDK) SetSocialAPI(social SocialAPI) { s.social = social }
|
||||
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber) { s.events = es }
|
||||
|
||||
// SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
|
||||
func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI) { s.plgMgr = pm }
|
||||
|
||||
// PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
|
||||
// May be nil if the host did not wire it.
|
||||
func (s *PluginSDK) PluginMgr() PluginMgrAPI { return s.plgMgr }
|
||||
|
||||
// ---- IO Convenience Methods ----
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
|
||||
Reference in New Issue
Block a user