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/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
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