feat(plugin): add phase-1 compile-time plugin contract
This commit is contained in:
parent
ac905d478b
commit
1b576db6a1
4 changed files with 296 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/plugin"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
|
|
@ -40,6 +41,7 @@ type AgentLoop struct {
|
|||
fallback *providers.FallbackChain
|
||||
channelManager *channels.Manager
|
||||
hooks *hooks.HookRegistry
|
||||
pluginManager *plugin.Manager
|
||||
}
|
||||
|
||||
// processOptions configures how a message is processed
|
||||
|
|
@ -241,6 +243,27 @@ func (al *AgentLoop) SetHooks(h *hooks.HookRegistry) {
|
|||
}
|
||||
}
|
||||
|
||||
// SetPluginManager installs a plugin manager and routes its hook registry into the loop.
|
||||
// Must be called before Run starts.
|
||||
func (al *AgentLoop) SetPluginManager(pm *plugin.Manager) {
|
||||
al.pluginManager = pm
|
||||
if pm == nil {
|
||||
al.SetHooks(nil)
|
||||
return
|
||||
}
|
||||
al.SetHooks(pm.HookRegistry())
|
||||
}
|
||||
|
||||
// EnablePlugins is a convenience helper to build and install a plugin manager.
|
||||
func (al *AgentLoop) EnablePlugins(plugins ...plugin.Plugin) error {
|
||||
pm := plugin.NewManager()
|
||||
if err := pm.RegisterAll(plugins...); err != nil {
|
||||
return err
|
||||
}
|
||||
al.SetPluginManager(pm)
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendOutbound wraps bus.PublishOutbound with the message_sending hook.
|
||||
// Returns true if the message was sent, false if canceled by a hook.
|
||||
func (al *AgentLoop) sendOutbound(ctx context.Context, msg bus.OutboundMessage) bool {
|
||||
|
|
|
|||
73
pkg/agent/plugin_test.go
Normal file
73
pkg/agent/plugin_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
"github.com/sipeed/picoclaw/pkg/plugin"
|
||||
)
|
||||
|
||||
type blockingPlugin struct{}
|
||||
|
||||
func (p blockingPlugin) Name() string {
|
||||
return "block-outbound"
|
||||
}
|
||||
|
||||
func (p blockingPlugin) Register(r *hooks.HookRegistry) error {
|
||||
r.OnMessageSending("block-all", 0, func(_ context.Context, e *hooks.MessageSendingEvent) error {
|
||||
e.Cancel = true
|
||||
e.CancelReason = "blocked by plugin"
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSetPluginManagerInstallsHookRegistry(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-plugin-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, &mockProvider{})
|
||||
|
||||
pm := plugin.NewManager()
|
||||
if err := pm.Register(blockingPlugin{}); err != nil {
|
||||
t.Fatalf("register plugin: %v", err)
|
||||
}
|
||||
|
||||
al.SetPluginManager(pm)
|
||||
|
||||
if al.pluginManager == nil {
|
||||
t.Fatal("expected plugin manager to be set")
|
||||
}
|
||||
if al.hooks != pm.HookRegistry() {
|
||||
t.Fatal("expected agent loop hooks to use plugin manager registry")
|
||||
}
|
||||
|
||||
sent := al.sendOutbound(context.Background(), bus.OutboundMessage{
|
||||
Channel: "cli",
|
||||
ChatID: "direct",
|
||||
Content: "hello",
|
||||
})
|
||||
if sent {
|
||||
t.Fatal("expected outbound message to be blocked by plugin")
|
||||
}
|
||||
}
|
||||
|
||||
88
pkg/plugin/manager.go
Normal file
88
pkg/plugin/manager.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
)
|
||||
|
||||
// APIVersion identifies the compile-time plugin contract version.
|
||||
const APIVersion = "v1alpha1"
|
||||
|
||||
// Plugin is the Phase-1 compile-time contract for PicoClaw extensions.
|
||||
type Plugin interface {
|
||||
Name() string
|
||||
Register(*hooks.HookRegistry) error
|
||||
}
|
||||
|
||||
// Manager owns a shared hook registry and loaded plugin metadata.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
registry *hooks.HookRegistry
|
||||
names []string
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// NewManager creates an empty plugin manager with a fresh hook registry.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
registry: hooks.NewHookRegistry(),
|
||||
seen: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// HookRegistry returns the shared registry where plugins register hooks.
|
||||
func (m *Manager) HookRegistry() *hooks.HookRegistry {
|
||||
return m.registry
|
||||
}
|
||||
|
||||
// Names returns loaded plugin names in registration order.
|
||||
func (m *Manager) Names() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return slices.Clone(m.names)
|
||||
}
|
||||
|
||||
// Register loads one plugin into the shared hook registry.
|
||||
func (m *Manager) Register(p Plugin) error {
|
||||
if p == nil {
|
||||
return errors.New("plugin is nil")
|
||||
}
|
||||
name := strings.TrimSpace(p.Name())
|
||||
if name == "" {
|
||||
return errors.New("plugin name is required")
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.seen[name]; exists {
|
||||
return fmt.Errorf("plugin %q already registered", name)
|
||||
}
|
||||
if err := p.Register(m.registry); err != nil {
|
||||
return fmt.Errorf("register plugin %q: %w", name, err)
|
||||
}
|
||||
m.seen[name] = struct{}{}
|
||||
m.names = append(m.names, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterAll loads plugins sequentially.
|
||||
func (m *Manager) RegisterAll(plugins ...Plugin) error {
|
||||
for _, p := range plugins {
|
||||
if err := m.Register(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
112
pkg/plugin/manager_test.go
Normal file
112
pkg/plugin/manager_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/hooks"
|
||||
)
|
||||
|
||||
type testPlugin struct {
|
||||
name string
|
||||
registerFn func(*hooks.HookRegistry) error
|
||||
}
|
||||
|
||||
func (p testPlugin) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p testPlugin) Register(r *hooks.HookRegistry) error {
|
||||
if p.registerFn != nil {
|
||||
return p.registerFn(r)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNewManager(t *testing.T) {
|
||||
m := NewManager()
|
||||
if m == nil {
|
||||
t.Fatal("expected manager")
|
||||
}
|
||||
if m.HookRegistry() == nil {
|
||||
t.Fatal("expected non-nil hook registry")
|
||||
}
|
||||
if len(m.Names()) != 0 {
|
||||
t.Fatalf("expected empty names, got %v", m.Names())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPluginAndTriggerHook(t *testing.T) {
|
||||
m := NewManager()
|
||||
called := false
|
||||
p := testPlugin{
|
||||
name: "audit",
|
||||
registerFn: func(r *hooks.HookRegistry) error {
|
||||
r.OnSessionStart("audit-session", 0, func(_ context.Context, _ *hooks.SessionEvent) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
if err := m.Register(p); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
}
|
||||
if got := m.Names(); len(got) != 1 || got[0] != "audit" {
|
||||
t.Fatalf("unexpected names: %v", got)
|
||||
}
|
||||
|
||||
m.HookRegistry().TriggerSessionStart(context.Background(), &hooks.SessionEvent{
|
||||
AgentID: "a1",
|
||||
SessionKey: "s1",
|
||||
})
|
||||
if !called {
|
||||
t.Fatal("expected plugin hook to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsNilPlugin(t *testing.T) {
|
||||
m := NewManager()
|
||||
if err := m.Register(nil); err == nil {
|
||||
t.Fatal("expected error for nil plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsEmptyName(t *testing.T) {
|
||||
m := NewManager()
|
||||
if err := m.Register(testPlugin{}); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRejectsDuplicateName(t *testing.T) {
|
||||
m := NewManager()
|
||||
p := testPlugin{name: "dup"}
|
||||
if err := m.Register(p); err != nil {
|
||||
t.Fatalf("unexpected first register error: %v", err)
|
||||
}
|
||||
if err := m.Register(p); err == nil {
|
||||
t.Fatal("expected duplicate name error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterPropagatesPluginError(t *testing.T) {
|
||||
m := NewManager()
|
||||
want := errors.New("register failed")
|
||||
p := testPlugin{
|
||||
name: "bad",
|
||||
registerFn: func(_ *hooks.HookRegistry) error {
|
||||
return want
|
||||
},
|
||||
}
|
||||
err := m.Register(p)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected wrapped error %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue