feat(plugin): add phase-1 compile-time plugin contract

This commit is contained in:
xj 2026-02-22 02:14:47 -08:00
parent ac905d478b
commit 1b576db6a1
4 changed files with 296 additions and 0 deletions

View file

@ -22,6 +22,7 @@ import (
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/hooks" "github.com/sipeed/picoclaw/pkg/hooks"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/plugin"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
@ -40,6 +41,7 @@ type AgentLoop struct {
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
hooks *hooks.HookRegistry hooks *hooks.HookRegistry
pluginManager *plugin.Manager
} }
// processOptions configures how a message is processed // 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. // sendOutbound wraps bus.PublishOutbound with the message_sending hook.
// Returns true if the message was sent, false if canceled by a 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 { func (al *AgentLoop) sendOutbound(ctx context.Context, msg bus.OutboundMessage) bool {

73
pkg/agent/plugin_test.go Normal file
View 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
View 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
View 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)
}
}