first commit
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// This file bridges the agent's event stream to subscribed plugins (US-017,
|
||||
// #133). The agent loop emits agentcore.AgentEvent values; a plugin declares
|
||||
// which event types it wants in its manifest. EventNotifier maps each observed
|
||||
// event to a small, wire-safe payload and hands it to the Manager for
|
||||
// fire-and-forget delivery — the same "never secrets, only observable fields"
|
||||
// discipline the stream-json envelope uses.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// EventNotifier forwards agent lifecycle events to the plugins that subscribed
|
||||
// to them. It is created once per run and its Handle method is wired as the
|
||||
// event-stream OnEvent callback. A nil *Manager (no plugins) makes NewNotifier
|
||||
// return nil, and calling Handle on a nil notifier is a safe no-op — callers can
|
||||
// wire it unconditionally.
|
||||
type EventNotifier struct {
|
||||
mgr *Manager
|
||||
warnLog io.Writer
|
||||
}
|
||||
|
||||
// NewEventNotifier returns a notifier over mgr, or nil when mgr is nil or has no
|
||||
// plugins — so the caller can skip the OnEvent wiring entirely in the common
|
||||
// no-plugin case. warnLog (when non-nil) receives per-plugin delivery failures.
|
||||
func NewEventNotifier(mgr *Manager, warnLog io.Writer) *EventNotifier {
|
||||
if mgr == nil || len(mgr.plugins) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &EventNotifier{mgr: mgr, warnLog: warnLog}
|
||||
}
|
||||
|
||||
// Handle maps ev to a wire payload and dispatches it to subscribed plugins. It
|
||||
// is a no-op on a nil notifier and when no plugin subscribes to ev's type, so it
|
||||
// builds a payload only when someone is listening. Delivery is bounded and
|
||||
// isolated by the Manager, so Handle never blocks the loop beyond the
|
||||
// per-plugin event timeout.
|
||||
func (n *EventNotifier) Handle(ev agentcore.AgentEvent) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
t := ev.EventType()
|
||||
if !n.mgr.Subscribers(t) {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(eventPayload(ev))
|
||||
if err != nil {
|
||||
data = nil // deliver the bare type rather than dropping the event
|
||||
}
|
||||
n.mgr.DispatchEvent(EventParams{Type: t, Data: data}, n.warnLog)
|
||||
}
|
||||
|
||||
// eventPayload derives the wire-safe payload for an event: only observable,
|
||||
// non-secret fields (ids, names, counts, stop reasons, streamed text). It
|
||||
// mirrors the stream-json envelope's field selection so plugin authors and
|
||||
// stream consumers see the same shape.
|
||||
func eventPayload(ev agentcore.AgentEvent) map[string]any {
|
||||
switch e := ev.(type) {
|
||||
case agentcore.AgentEndEvent:
|
||||
return map[string]any{"messageCount": len(e.Messages)}
|
||||
case agentcore.TurnEndEvent:
|
||||
p := map[string]any{"stopReason": e.Message.StopReason}
|
||||
if text := agentcore.ContentToText(e.Message.Content); text != "" {
|
||||
p["text"] = text
|
||||
}
|
||||
if calls := e.Message.ToolCalls(); len(calls) > 0 {
|
||||
names := make([]string, len(calls))
|
||||
for i, c := range calls {
|
||||
names[i] = c.Name
|
||||
}
|
||||
p["toolCalls"] = names
|
||||
}
|
||||
return p
|
||||
case agentcore.ToolExecutionStartEvent:
|
||||
return map[string]any{"toolCallId": e.ToolCallID, "toolName": e.ToolName}
|
||||
case agentcore.ToolExecutionEndEvent:
|
||||
return map[string]any{"toolCallId": e.ToolCallID, "toolName": e.ToolName, "isError": e.IsError}
|
||||
case agentcore.CompactionEvent:
|
||||
return map[string]any{
|
||||
"reason": e.Reason,
|
||||
"tokensBefore": e.TokensBefore,
|
||||
"tokensAfter": e.TokensAfter,
|
||||
"summarizedCount": e.SummarizedCount,
|
||||
"keptCount": e.KeptCount,
|
||||
}
|
||||
default:
|
||||
return map[string]any{}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Tests for plugin lifecycle event subscription and delivery (US-017, #133):
|
||||
// a plugin declares subscribed event types in its manifest, pigo delivers only
|
||||
// those via one-way `event` notifications, and a slow/hung plugin is isolated by
|
||||
// the per-event timeout rather than blocking the caller.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// eventPluginSrc declares a subscription to two event types and appends every
|
||||
// received `event` notification (as one JSON line: {"type":...,"data":...}) to a
|
||||
// file whose path is passed via the PIGO_EVENT_LOG env var. It lets the test
|
||||
// assert exactly which events were delivered, in order.
|
||||
const eventPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Params json.RawMessage ` + "`json:\"params\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
logPath := os.Getenv("PIGO_EVENT_LOG")
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for sc.Scan() {
|
||||
var r req
|
||||
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
|
||||
continue
|
||||
}
|
||||
switch r.Method {
|
||||
case "initialize":
|
||||
reply(w, r.ID, json.RawMessage(` + "`" + `{"name":"watcher","version":"1.0","events":["agent_start","tool_execution_end"]}` + "`" + `))
|
||||
case "event":
|
||||
if logPath != "" {
|
||||
f, _ := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if f != nil {
|
||||
fmt.Fprintf(f, "%s\n", r.Params)
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
case "shutdown":
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reply(w *bufio.Writer, id *json.RawMessage, result json.RawMessage) {
|
||||
if id == nil {
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
}
|
||||
`
|
||||
|
||||
// TestPluginSubscribesReportsManifestEvents checks Subscribes reflects exactly
|
||||
// the manifest's declared event types.
|
||||
func TestPluginSubscribesReportsManifestEvents(t *testing.T) {
|
||||
p := &Plugin{Manifest: Manifest{Name: "w", Events: []string{"agent_start", "tool_execution_end"}}}
|
||||
if !p.Subscribes("agent_start") {
|
||||
t.Error("should subscribe to agent_start")
|
||||
}
|
||||
if !p.Subscribes("tool_execution_end") {
|
||||
t.Error("should subscribe to tool_execution_end")
|
||||
}
|
||||
if p.Subscribes("turn_end") {
|
||||
t.Error("should NOT subscribe to unlisted turn_end")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventNotifierDeliversSubscribedOnly runs a real event-logging plugin and
|
||||
// verifies the notifier delivers a subscribed event but drops an unsubscribed
|
||||
// one — the full path: manifest events → Subscribers gate → payload → RPC.
|
||||
func TestEventNotifierDeliversSubscribedOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell/exec plugin test is unix-oriented")
|
||||
}
|
||||
logPath := filepath.Join(t.TempDir(), "events.log")
|
||||
t.Setenv("PIGO_EVENT_LOG", logPath)
|
||||
|
||||
bin := buildTestPlugin(t, "watcher", eventPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
m := &Manager{plugins: []*Plugin{p}}
|
||||
if !m.Subscribers("agent_start") {
|
||||
t.Fatal("manager should report a subscriber for agent_start")
|
||||
}
|
||||
if m.Subscribers("turn_end") {
|
||||
t.Fatal("manager should report NO subscriber for turn_end")
|
||||
}
|
||||
|
||||
n := NewEventNotifier(m, os.Stderr)
|
||||
if n == nil {
|
||||
t.Fatal("NewEventNotifier should be non-nil with a subscribing plugin")
|
||||
}
|
||||
// Subscribed → delivered.
|
||||
n.Handle(agentcore.AgentStartEvent{})
|
||||
// Unsubscribed → dropped (never written).
|
||||
n.Handle(agentcore.TurnEndEvent{Message: agentcore.AssistantMessage{}})
|
||||
// Subscribed → delivered with a payload.
|
||||
n.Handle(agentcore.ToolExecutionEndEvent{ToolCallID: "c1", ToolName: "grep", IsError: false})
|
||||
|
||||
// The plugin writes asynchronously; poll briefly for the two expected lines.
|
||||
var lines []string
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, _ := os.ReadFile(logPath)
|
||||
lines = splitNonEmpty(string(data))
|
||||
if len(lines) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 delivered events, got %d: %q", len(lines), lines)
|
||||
}
|
||||
var first EventParams
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("decode first event: %v", err)
|
||||
}
|
||||
if first.Type != "agent_start" {
|
||||
t.Errorf("first event type = %q, want agent_start", first.Type)
|
||||
}
|
||||
var second EventParams
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("decode second event: %v", err)
|
||||
}
|
||||
if second.Type != "tool_execution_end" {
|
||||
t.Errorf("second event type = %q, want tool_execution_end", second.Type)
|
||||
}
|
||||
if !containsField(second.Data, "toolName", "grep") {
|
||||
t.Errorf("second event data missing toolName=grep: %s", second.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewEventNotifierNilWhenNoSubscribers checks the notifier is nil when there
|
||||
// are no plugins, so the caller can skip wiring OnEvent entirely.
|
||||
func TestNewEventNotifierNilWhenNoSubscribers(t *testing.T) {
|
||||
if NewEventNotifier(nil, os.Stderr) != nil {
|
||||
t.Error("nil manager should yield nil notifier")
|
||||
}
|
||||
if NewEventNotifier(&Manager{}, os.Stderr) != nil {
|
||||
t.Error("empty manager should yield nil notifier")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendEventTimesOutOnHungPlugin verifies event delivery is bounded: a plugin
|
||||
// that initializes then stops reading stdin does not block SendEvent beyond the
|
||||
// timeout — it returns a timeout error instead of hanging.
|
||||
func TestSendEventTimesOutOnHungPlugin(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell/exec plugin test is unix-oriented")
|
||||
}
|
||||
// hungPluginSrc initializes, then blocks forever without reading further
|
||||
// stdin, so the OS pipe buffer fills and a Notify write eventually blocks.
|
||||
const hungPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
if sc.Scan() {
|
||||
var r req
|
||||
json.Unmarshal(sc.Bytes(), &r)
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": json.RawMessage(` + "`" + `{"name":"hung","events":["agent_start"]}` + "`" + `)})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
}
|
||||
time.Sleep(60 * time.Second) // never reads stdin again
|
||||
}
|
||||
`
|
||||
bin := buildTestPlugin(t, "hung", hungPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
// A single small event will not fill the pipe; force the write to block by
|
||||
// sending a large payload many times until SendEvent reports the timeout. The
|
||||
// key assertion is that SendEvent RETURNS (bounded) rather than hanging.
|
||||
big := make([]byte, 256*1024)
|
||||
for i := range big {
|
||||
big[i] = 'x'
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"blob": string(big)})
|
||||
|
||||
start := time.Now()
|
||||
var lastErr error
|
||||
for range 50 {
|
||||
lastErr = p.SendEvent(EventParams{Type: "agent_start", Data: payload})
|
||||
if lastErr != nil {
|
||||
break
|
||||
}
|
||||
if time.Since(start) > 10*time.Second {
|
||||
t.Fatal("SendEvent never reported a timeout on a hung plugin")
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
t.Fatal("expected a timeout error from a hung plugin, got nil")
|
||||
}
|
||||
// The bounded return is the contract; the elapsed time per call is ~eventTimeout.
|
||||
if elapsed := time.Since(start); elapsed > 12*time.Second {
|
||||
t.Errorf("SendEvent took too long overall (%s) — not bounded", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// splitNonEmpty splits s on newlines, dropping empty lines.
|
||||
func splitNonEmpty(s string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// containsField reports whether raw JSON object has key == value (string).
|
||||
func containsField(raw json.RawMessage, key, value string) bool {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return false
|
||||
}
|
||||
s, ok := m[key].(string)
|
||||
return ok && s == value
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// This file implements plugin discovery and lifecycle management (US-016, #132):
|
||||
// finding plugin executables under a config directory, loading each, and
|
||||
// aggregating their tools. Loading is fault-tolerant — one plugin that fails to
|
||||
// start or handshake is logged and skipped so the rest still load.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// Manager owns a set of loaded plugins and their aggregated tools. It is not safe
|
||||
// for concurrent modification; load once at startup, then read.
|
||||
type Manager struct {
|
||||
plugins []*Plugin
|
||||
}
|
||||
|
||||
// Discover finds and loads every plugin under dir. A plugin is any executable
|
||||
// regular file directly inside dir (non-executable files and subdirectories are
|
||||
// ignored). Each plugin is launched and handshaked; a failure is written to
|
||||
// warnLog (when non-nil) and that plugin is skipped. A missing dir is not an
|
||||
// error — it yields an empty Manager. pluginStderr, when non-nil, receives every
|
||||
// plugin's stderr.
|
||||
func Discover(dir string, warnLog, pluginStderr io.Writer) (*Manager, error) {
|
||||
m := &Manager{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return m, nil // no plugins directory → no plugins
|
||||
}
|
||||
return nil, fmt.Errorf("plugin: read dir %q: %w", dir, err)
|
||||
}
|
||||
// Deterministic load order for stable tool ordering and diagnostics.
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil || !isExecutable(info.Mode()) {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
p, err := Load(path, nil, pluginStderr)
|
||||
if err != nil {
|
||||
if warnLog != nil {
|
||||
fmt.Fprintf(warnLog, "pigo: plugin %q failed to load: %v\n", e.Name(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.plugins = append(m.plugins, p)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// isExecutable reports whether the file mode has any execute bit set.
|
||||
func isExecutable(mode os.FileMode) bool {
|
||||
return mode&0o111 != 0
|
||||
}
|
||||
|
||||
// Tools returns the aggregated tools of every loaded plugin, in load order.
|
||||
func (m *Manager) Tools() []agentcore.AgentTool {
|
||||
var out []agentcore.AgentTool
|
||||
for _, p := range m.plugins {
|
||||
out = append(out, p.Tools()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Plugins returns the loaded plugins (for command aggregation and diagnostics).
|
||||
func (m *Manager) Plugins() []*Plugin { return m.plugins }
|
||||
|
||||
// PluginCommand pairs a plugin-declared slash command with the plugin that owns
|
||||
// it, so a caller can dispatch the command back to its plugin via
|
||||
// Plugin.CallCommand.
|
||||
type PluginCommand struct {
|
||||
// Plugin is the plugin that declared and handles this command.
|
||||
Plugin *Plugin
|
||||
// Spec is the command's declaration from the owning plugin's manifest.
|
||||
Spec CommandSpec
|
||||
}
|
||||
|
||||
// Commands returns the aggregated slash commands of every loaded plugin, in load
|
||||
// order (and, within a plugin, in manifest order). Each carries the owning
|
||||
// plugin so the caller can dispatch it via Plugin.CallCommand.
|
||||
func (m *Manager) Commands() []PluginCommand {
|
||||
var out []PluginCommand
|
||||
for _, p := range m.plugins {
|
||||
for _, spec := range p.Manifest.Commands {
|
||||
out = append(out, PluginCommand{Plugin: p, Spec: spec})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Subscribers reports whether any loaded plugin subscribes to the given event
|
||||
// type. It lets a caller skip building an event payload when nobody is listening
|
||||
// (US-017, #133).
|
||||
func (m *Manager) Subscribers(eventType string) bool {
|
||||
for _, p := range m.plugins {
|
||||
if p.Subscribes(eventType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DispatchEvent delivers one lifecycle event to every plugin subscribed to its
|
||||
// type (US-017, #133). Delivery is best-effort and isolated: each plugin's send
|
||||
// is bounded by eventTimeout, and a delivery failure to one plugin (timeout,
|
||||
// dead process) is written to warnLog when non-nil and does not stop delivery to
|
||||
// the others. It never blocks the agent loop beyond the per-plugin timeout.
|
||||
func (m *Manager) DispatchEvent(params EventParams, warnLog io.Writer) {
|
||||
for _, p := range m.plugins {
|
||||
if !p.Subscribes(params.Type) {
|
||||
continue
|
||||
}
|
||||
if err := p.SendEvent(params); err != nil && warnLog != nil {
|
||||
fmt.Fprintf(warnLog, "pigo: plugin %q event %q: %v\n", p.Manifest.Name, params.Type, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down every loaded plugin, returning the first error encountered
|
||||
// (all plugins are attempted regardless).
|
||||
func (m *Manager) Close() error {
|
||||
var firstErr error
|
||||
for _, p := range m.plugins {
|
||||
if err := p.Close(); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Tests for plugin discovery (US-016, #132): executable detection, deterministic
|
||||
// order, fault tolerance (a bad plugin is skipped, not fatal), and empty/missing
|
||||
// directory handling.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDiscoverMissingDir checks a missing directory yields an empty manager.
|
||||
func TestDiscoverMissingDir(t *testing.T) {
|
||||
m, err := Discover(filepath.Join(t.TempDir(), "nope"), nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover missing dir: %v", err)
|
||||
}
|
||||
if len(m.Plugins()) != 0 {
|
||||
t.Errorf("want no plugins, got %d", len(m.Plugins()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverSkipsNonExecutable checks non-executable files are ignored.
|
||||
func TestDiscoverSkipsNonExecutable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix executable-bit semantics not applicable on windows")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("hi"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := Discover(dir, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
if len(m.Plugins()) != 0 {
|
||||
t.Errorf("non-executable/dir entries should be skipped, got %d plugins", len(m.Plugins()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiscoverLoadsAndIsolatesBad checks that a good plugin loads and a bad one
|
||||
// (executable that isn't a valid plugin) is logged and skipped rather than
|
||||
// aborting discovery.
|
||||
func TestDiscoverLoadsAndIsolatesBad(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script plugins are unix-only in this test")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Good plugin: compiled from echoPluginSrc, placed inside the discovery dir.
|
||||
good := buildTestPlugin(t, "aaa-echo", echoPluginSrc)
|
||||
if err := os.Rename(good, filepath.Join(dir, "aaa-echo")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bad plugin: an executable that immediately exits without speaking the
|
||||
// protocol, so the initialize handshake fails.
|
||||
bad := filepath.Join(dir, "zzz-bad")
|
||||
if err := os.WriteFile(bad, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var warn bytes.Buffer
|
||||
m, err := Discover(dir, &warn, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if len(m.Plugins()) != 1 {
|
||||
t.Fatalf("want 1 good plugin loaded, got %d", len(m.Plugins()))
|
||||
}
|
||||
if m.Plugins()[0].Manifest.Name != "echo" {
|
||||
t.Errorf("loaded wrong plugin: %q", m.Plugins()[0].Manifest.Name)
|
||||
}
|
||||
if !strings.Contains(warn.String(), "zzz-bad") {
|
||||
t.Errorf("bad plugin should be logged, warn=%q", warn.String())
|
||||
}
|
||||
if tools := m.Tools(); len(tools) != 1 || tools[0].Name() != "shout" {
|
||||
t.Errorf("aggregated tools = %+v, want one 'shout'", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerCommandsAggregatesInLoadOrder checks that Commands() returns every
|
||||
// loaded plugin's commands in load order (plugins ordered by discovery, and
|
||||
// within a plugin by manifest order), each carrying its owning plugin.
|
||||
func TestManagerCommandsAggregatesInLoadOrder(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script/compiled plugins are unix-only in this test")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Two command plugins. Load order is by filename, so "a-cmd" loads before
|
||||
// "b-cmd"; within each plugin the manifest declares greet then bye.
|
||||
a := buildTestPlugin(t, "a-cmd", cmdPluginSrc)
|
||||
if err := os.Rename(a, filepath.Join(dir, "a-cmd")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := buildTestPlugin(t, "b-cmd", cmdPluginSrc)
|
||||
if err := os.Rename(b, filepath.Join(dir, "b-cmd")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, err := Discover(dir, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Discover: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
cmds := m.Commands()
|
||||
// Both plugins report the same name "cmd" (from cmdPluginSrc's manifest), so
|
||||
// the two loaded *Plugin pointers are what distinguish load order.
|
||||
if len(m.Plugins()) != 2 {
|
||||
t.Fatalf("want 2 plugins loaded, got %d", len(m.Plugins()))
|
||||
}
|
||||
p0, p1 := m.Plugins()[0], m.Plugins()[1]
|
||||
want := []PluginCommand{
|
||||
{Plugin: p0, Spec: CommandSpec{Name: "greet", Description: "greets"}},
|
||||
{Plugin: p0, Spec: CommandSpec{Name: "bye", Description: "farewell"}},
|
||||
{Plugin: p1, Spec: CommandSpec{Name: "greet", Description: "greets"}},
|
||||
{Plugin: p1, Spec: CommandSpec{Name: "bye", Description: "farewell"}},
|
||||
}
|
||||
if len(cmds) != len(want) {
|
||||
t.Fatalf("Commands() len = %d, want %d (%+v)", len(cmds), len(want), cmds)
|
||||
}
|
||||
for i, w := range want {
|
||||
if cmds[i].Plugin != w.Plugin || cmds[i].Spec != w.Spec {
|
||||
t.Errorf("Commands()[%d] = {%p, %+v}, want {%p, %+v}", i, cmds[i].Plugin, cmds[i].Spec, w.Plugin, w.Spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package plugin implements pigo's external plugin system (US-016, #132): an
|
||||
// executable written in any language registers custom tools (and, later, slash
|
||||
// commands) with pigo without touching pigo's source. pigo launches each plugin
|
||||
// as a child process and speaks line-delimited JSON-RPC 2.0 over its stdio,
|
||||
// reusing internal/jsonrpc as the transport.
|
||||
//
|
||||
// Protocol (client = pigo, server = plugin):
|
||||
//
|
||||
// - initialize → Manifest {name, version, tools[], commands[]}
|
||||
// The handshake. The plugin declares everything it offers up front.
|
||||
// - tools/call {name, arguments} → CallResult {content, isError}
|
||||
// pigo forwards a tool invocation; the plugin runs it and returns the text.
|
||||
// - event {type, data} (notification)
|
||||
// pigo pushes a subscribed agent lifecycle event (US-017, #133). One-way,
|
||||
// fire-and-forget: the plugin never replies and a slow plugin is isolated.
|
||||
// - shutdown (notification)
|
||||
// Sent on Close so a well-behaved plugin can exit before stdin EOF.
|
||||
//
|
||||
// A plugin that crashes or misbehaves is isolated: its Start failure is logged
|
||||
// and skipped (other plugins still load), and a tool call against a dead plugin
|
||||
// returns an error result rather than propagating up.
|
||||
//
|
||||
// This file defines the wire types exchanged during the handshake and tool call.
|
||||
package plugin
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Manifest is the plugin's self-description, returned from the initialize call.
|
||||
type Manifest struct {
|
||||
// Name identifies the plugin; used to namespace its tools and in diagnostics.
|
||||
Name string `json:"name"`
|
||||
// Version is an optional free-form version string for diagnostics.
|
||||
Version string `json:"version,omitempty"`
|
||||
// Tools are the tools this plugin registers with the agent.
|
||||
Tools []ToolSpec `json:"tools,omitempty"`
|
||||
// Commands are the slash commands this plugin registers.
|
||||
Commands []CommandSpec `json:"commands,omitempty"`
|
||||
// Events lists the agent lifecycle event types this plugin subscribes to
|
||||
// (US-017, #133). pigo delivers only these via one-way `event` notifications;
|
||||
// an empty list means the plugin observes no events. Valid values are the
|
||||
// agentcore.Event* discriminants (e.g. "agent_start", "tool_execution_end").
|
||||
Events []string `json:"events,omitempty"`
|
||||
}
|
||||
|
||||
// ToolSpec declares one tool a plugin exposes. Schema is the JSON Schema for the
|
||||
// tool's arguments, passed through verbatim to the agent's tool registry.
|
||||
type ToolSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Schema json.RawMessage `json:"schema"`
|
||||
}
|
||||
|
||||
// CommandSpec declares one slash command a plugin exposes. Prompt is the text
|
||||
// injected as the next user prompt when the command is invoked (matching the
|
||||
// declarative-command convention); it may be empty if the plugin handles the
|
||||
// command by other means.
|
||||
type CommandSpec struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
|
||||
// CallParams is the parameter object for a tools/call request.
|
||||
type CallParams struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
// CallResult is the reply to a tools/call request. Content is the tool output as
|
||||
// text; IsError marks a tool-level failure (distinct from a transport error).
|
||||
type CallResult struct {
|
||||
Content string `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// CommandCallParams is the parameter object for a commands/call request. It
|
||||
// mirrors CallParams' naming (name/arguments) so a plugin can decode tool and
|
||||
// command invocations with the same conventions. Name is the command's name;
|
||||
// Args carries its free-form arguments (e.g. the text following the slash
|
||||
// command) as raw JSON, passed through verbatim to the plugin.
|
||||
type CommandCallParams struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
// CommandCallResult is the reply to a commands/call request. Prompt is the text
|
||||
// injected as the next agent turn (matching the declarative-command
|
||||
// convention); it may be empty if the command produces no prompt. Notifications
|
||||
// are messages the plugin asks pigo to surface to the user out of band from the
|
||||
// prompt.
|
||||
type CommandCallResult struct {
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
Notifications []CommandNotification `json:"notifications,omitempty"`
|
||||
}
|
||||
|
||||
// CommandNotification is a single message a command asks pigo to surface to the
|
||||
// user. Message is the human-readable text; Type is an optional severity or
|
||||
// category hint (e.g. "info", "warning", "error") that pigo may use to style
|
||||
// the message.
|
||||
type CommandNotification struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// EventParams is the parameter object for an `event` notification. Type is the
|
||||
// event discriminant (an agentcore.Event* value); Data carries a small,
|
||||
// wire-safe payload for that event (never secrets — see plugin.EventData).
|
||||
type EventParams struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Tests for the plugin wire types (#261). These assert the commands/call
|
||||
// request and result types round-trip through JSON marshal/unmarshal so the
|
||||
// on-the-wire shape (field names, omitempty behavior) stays stable.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandCallParamsRoundTrip(t *testing.T) {
|
||||
want := CommandCallParams{
|
||||
Name: "review",
|
||||
Args: json.RawMessage(`{"path":"foo.go","verbose":true}`),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
// Params must name its argument field "arguments" to match CallParams.
|
||||
var shape struct {
|
||||
Name string `json:"name"`
|
||||
Args json.RawMessage `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &shape); err != nil {
|
||||
t.Fatalf("decode shape: %v", err)
|
||||
}
|
||||
if shape.Name != want.Name {
|
||||
t.Errorf("name = %q, want %q", shape.Name, want.Name)
|
||||
}
|
||||
|
||||
var got CommandCallParams
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got.Name != want.Name {
|
||||
t.Errorf("Name = %q, want %q", got.Name, want.Name)
|
||||
}
|
||||
if !json.Valid(got.Args) || string(got.Args) != string(want.Args) {
|
||||
t.Errorf("Args = %s, want %s", got.Args, want.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandCallResultRoundTrip(t *testing.T) {
|
||||
want := CommandCallResult{
|
||||
Prompt: "Please summarize the following changes.",
|
||||
Notifications: []CommandNotification{
|
||||
{Message: "loaded 3 files", Type: "info"},
|
||||
{Message: "1 file skipped", Type: "warning"},
|
||||
{Message: "done"},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(want)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
var got CommandCallResult
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandCallResultOmitEmpty confirms empty optional fields drop out of the
|
||||
// wire form, keeping notifications-free results minimal.
|
||||
func TestCommandCallResultOmitEmpty(t *testing.T) {
|
||||
data, err := json.Marshal(CommandCallResult{})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if got := string(data); got != "{}" {
|
||||
t.Errorf("empty result marshaled to %s, want {}", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// This file implements the plugin client (US-016, #132): it launches a plugin
|
||||
// executable, performs the initialize handshake, and exposes the plugin's
|
||||
// declared tools as agentcore.AgentTool values that forward invocations over
|
||||
// JSON-RPC. Crash isolation lives here — a call against a plugin whose process
|
||||
// has died returns an error result, never a panic.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
"github.com/smallnest/pigo/internal/jsonrpc"
|
||||
)
|
||||
|
||||
// initTimeout bounds the initialize handshake so a plugin that never replies
|
||||
// cannot hang plugin discovery.
|
||||
const initTimeout = 10 * time.Second
|
||||
|
||||
// eventTimeout bounds one fire-and-forget lifecycle-event delivery (US-017,
|
||||
// #133). It is short so a slow or hung plugin adds only a small, bounded delay
|
||||
// per event rather than stalling the agent loop; the event is dropped on
|
||||
// timeout.
|
||||
const eventTimeout = 2 * time.Second
|
||||
|
||||
// Plugin is a running plugin: its JSON-RPC client plus the manifest it declared
|
||||
// during initialize.
|
||||
type Plugin struct {
|
||||
Manifest Manifest
|
||||
client *jsonrpc.Client
|
||||
}
|
||||
|
||||
// Load starts the plugin executable at path (with optional args) and performs
|
||||
// the initialize handshake. stderr, when non-nil, receives the plugin's stderr
|
||||
// for logging. The caller must Close the returned Plugin.
|
||||
func Load(command string, args []string, stderr io.Writer) (*Plugin, error) {
|
||||
client, err := jsonrpc.NewClient(jsonrpc.Config{
|
||||
Command: command,
|
||||
Args: args,
|
||||
Stderr: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin: launch %q: %w", command, err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), initTimeout)
|
||||
defer cancel()
|
||||
|
||||
raw, err := client.Call(ctx, "initialize", nil)
|
||||
if err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("plugin: initialize %q: %w", command, err)
|
||||
}
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("plugin: decode manifest from %q: %w", command, err)
|
||||
}
|
||||
if m.Name == "" {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("plugin %q: manifest has empty name", command)
|
||||
}
|
||||
return &Plugin{Manifest: m, client: client}, nil
|
||||
}
|
||||
|
||||
// Tools adapts each tool the plugin declared into an agentcore.AgentTool that
|
||||
// forwards Execute over JSON-RPC.
|
||||
func (p *Plugin) Tools() []agentcore.AgentTool {
|
||||
out := make([]agentcore.AgentTool, 0, len(p.Manifest.Tools))
|
||||
for _, spec := range p.Manifest.Tools {
|
||||
out = append(out, &pluginTool{plugin: p, spec: spec})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close shuts the plugin down: it sends a best-effort shutdown notification then
|
||||
// closes the transport (which closes stdin and, if needed, kills the child). The
|
||||
// shutdown notify is bounded by eventTimeout so a plugin that has stopped reading
|
||||
// its stdin (whose write pipe is full) cannot make Close block on the transport
|
||||
// write mutex — Close falls through to client.Close, which kills the child.
|
||||
func (p *Plugin) Close() error {
|
||||
done := make(chan struct{})
|
||||
go func() { _ = p.client.Notify("shutdown", nil); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(eventTimeout):
|
||||
}
|
||||
return p.client.Close()
|
||||
}
|
||||
|
||||
// call forwards a tool invocation to the plugin and returns its result.
|
||||
func (p *Plugin) call(ctx context.Context, name string, args json.RawMessage) (CallResult, error) {
|
||||
raw, err := p.client.Call(ctx, "tools/call", CallParams{Name: name, Arguments: args})
|
||||
if err != nil {
|
||||
return CallResult{}, err
|
||||
}
|
||||
var res CallResult
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return CallResult{}, fmt.Errorf("plugin %q: decode result for %q: %w", p.Manifest.Name, name, err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// CallCommand forwards a slash-command invocation to the plugin over JSON-RPC
|
||||
// (commands/call) and returns the plugin's result. args carries the command's
|
||||
// free-form arguments (passed through verbatim). A transport error (e.g. the
|
||||
// plugin crashed) or a malformed reply is surfaced as a returned error rather
|
||||
// than a panic — mirroring how pluginTool.Execute isolates a dead plugin, but
|
||||
// leaving the caller to decide how to present the failure.
|
||||
func (p *Plugin) CallCommand(ctx context.Context, name string, args json.RawMessage) (CommandCallResult, error) {
|
||||
raw, err := p.client.Call(ctx, "commands/call", CommandCallParams{Name: name, Args: args})
|
||||
if err != nil {
|
||||
return CommandCallResult{}, fmt.Errorf("plugin %q: command %q: %w", p.Manifest.Name, name, err)
|
||||
}
|
||||
var res CommandCallResult
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return CommandCallResult{}, fmt.Errorf("plugin %q: decode command result for %q: %w", p.Manifest.Name, name, err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Subscribes reports whether the plugin asked to receive the given event type in
|
||||
// its manifest (US-017, #133). pigo only delivers subscribed events.
|
||||
func (p *Plugin) Subscribes(eventType string) bool {
|
||||
return slices.Contains(p.Manifest.Events, eventType)
|
||||
}
|
||||
|
||||
// SendEvent delivers one lifecycle event to the plugin as a one-way `event`
|
||||
// notification (US-017, #133). Delivery is fire-and-forget and bounded by
|
||||
// eventTimeout: the underlying write runs on its own goroutine so a plugin that
|
||||
// has stopped reading its stdin (a hung or slow plugin) cannot block the agent
|
||||
// loop — the send is abandoned when the timeout elapses and its error returned.
|
||||
// The dropped write goroutine ends on its own when the plugin dies or Close
|
||||
// tears the pipe down.
|
||||
func (p *Plugin) SendEvent(params EventParams) error {
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- p.client.Notify("event", params) }()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-time.After(eventTimeout):
|
||||
return fmt.Errorf("plugin %q: event %q delivery timed out after %s", p.Manifest.Name, params.Type, eventTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// pluginTool adapts one plugin-declared tool to the agentcore.AgentTool
|
||||
// interface. All invocations are forwarded to the owning plugin over RPC.
|
||||
type pluginTool struct {
|
||||
plugin *Plugin
|
||||
spec ToolSpec
|
||||
}
|
||||
|
||||
// Name implements AgentTool.
|
||||
func (t *pluginTool) Name() string { return t.spec.Name }
|
||||
|
||||
// Description implements AgentTool.
|
||||
func (t *pluginTool) Description() string { return t.spec.Description }
|
||||
|
||||
// Schema implements AgentTool. An empty schema declared by the plugin degrades
|
||||
// to a permissive object schema so registration never fails.
|
||||
func (t *pluginTool) Schema() json.RawMessage {
|
||||
if len(t.spec.Schema) == 0 {
|
||||
return json.RawMessage(`{"type":"object"}`)
|
||||
}
|
||||
return t.spec.Schema
|
||||
}
|
||||
|
||||
// ExecutionMode implements AgentTool. Plugin calls cross a process boundary and
|
||||
// have unknown side effects, so they run sequentially to be safe.
|
||||
func (t *pluginTool) ExecutionMode() agentcore.ToolExecutionMode {
|
||||
return agentcore.ToolExecutionSequential
|
||||
}
|
||||
|
||||
// Execute implements AgentTool by forwarding the call to the plugin process. A
|
||||
// transport error (e.g. the plugin crashed) is isolated: it degrades to an error
|
||||
// result so a dead plugin cannot take down the agent loop.
|
||||
func (t *pluginTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
||||
res, err := t.plugin.call(ctx, t.spec.Name, args)
|
||||
if err != nil {
|
||||
return agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(
|
||||
fmt.Sprintf("%s: plugin call failed: %v", t.spec.Name, err))},
|
||||
}, nil
|
||||
}
|
||||
result := agentcore.AgentToolResult{
|
||||
Content: agentcore.ContentList{agentcore.NewTextContent(res.Content)},
|
||||
}
|
||||
if res.IsError {
|
||||
result.Details = map[string]any{"isError": true}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// Tests for the plugin system (US-016, #132). A test plugin is a tiny Go program
|
||||
// compiled once per test run; it speaks the JSON-RPC protocol over stdio so the
|
||||
// tests exercise the real subprocess transport, handshake, tool forwarding, and
|
||||
// crash isolation — no network or mocks.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/smallnest/pigo/internal/agentcore"
|
||||
)
|
||||
|
||||
// buildTestPlugin compiles the given Go source into an executable under a temp
|
||||
// dir and returns its path. The source is a standalone main package.
|
||||
func buildTestPlugin(t *testing.T, name, src string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
srcPath := filepath.Join(dir, name+".go")
|
||||
if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write plugin source: %v", err)
|
||||
}
|
||||
bin := filepath.Join(dir, name)
|
||||
if runtime.GOOS == "windows" {
|
||||
bin += ".exe"
|
||||
}
|
||||
cmd := exec.Command("go", "build", "-o", bin, srcPath)
|
||||
cmd.Env = os.Environ()
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build test plugin: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// echoPluginSrc is a plugin that declares one "shout" tool which uppercases its
|
||||
// "text" argument.
|
||||
const echoPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Params json.RawMessage ` + "`json:\"params\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for sc.Scan() {
|
||||
var r req
|
||||
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
|
||||
continue
|
||||
}
|
||||
switch r.Method {
|
||||
case "initialize":
|
||||
reply(w, r.ID, json.RawMessage(` + "`" + `{"name":"echo","version":"1.0","tools":[{"name":"shout","description":"uppercase text","schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}]}` + "`" + `))
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string ` + "`json:\"name\"`" + `
|
||||
Arguments json.RawMessage ` + "`json:\"arguments\"`" + `
|
||||
}
|
||||
json.Unmarshal(r.Params, &p)
|
||||
var a struct{ Text string ` + "`json:\"text\"`" + ` }
|
||||
json.Unmarshal(p.Arguments, &a)
|
||||
res, _ := json.Marshal(map[string]any{"content": strings.ToUpper(a.Text)})
|
||||
reply(w, r.ID, res)
|
||||
case "shutdown":
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reply(w *bufio.Writer, id *json.RawMessage, result json.RawMessage) {
|
||||
if id == nil {
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
}
|
||||
`
|
||||
|
||||
// TestPluginLoadAndCall exercises the full path: build → load (handshake) →
|
||||
// adapt tool → call → result.
|
||||
func TestPluginLoadAndCall(t *testing.T) {
|
||||
bin := buildTestPlugin(t, "echo", echoPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
if p.Manifest.Name != "echo" {
|
||||
t.Errorf("manifest name = %q, want echo", p.Manifest.Name)
|
||||
}
|
||||
tools := p.Tools()
|
||||
if len(tools) != 1 || tools[0].Name() != "shout" {
|
||||
t.Fatalf("tools = %+v, want one 'shout'", tools)
|
||||
}
|
||||
if tools[0].ExecutionMode() != agentcore.ToolExecutionSequential {
|
||||
t.Errorf("plugin tool should be sequential")
|
||||
}
|
||||
|
||||
res, err := tools[0].Execute(context.Background(), "c1", json.RawMessage(`{"text":"hello"}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute Go error: %v", err)
|
||||
}
|
||||
if txt := agentcore.ContentToText(res.Content); txt != "HELLO" {
|
||||
t.Errorf("result = %q, want HELLO", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// crashPluginSrc initializes fine but exits abruptly on the first tools/call.
|
||||
const crashPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for sc.Scan() {
|
||||
var r req
|
||||
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
|
||||
continue
|
||||
}
|
||||
switch r.Method {
|
||||
case "initialize":
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": json.RawMessage(` + "`" + `{"name":"crash","tools":[{"name":"boom","description":"crashes"}]}` + "`" + `)})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
case "tools/call":
|
||||
os.Exit(1) // crash mid-call: no response is ever sent
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// TestPluginCrashIsolation checks that a plugin crashing during a tool call
|
||||
// degrades to an error result, not a panic or a hang.
|
||||
func TestPluginCrashIsolation(t *testing.T) {
|
||||
bin := buildTestPlugin(t, "crash", crashPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
tools := p.Tools()
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("want one tool, got %d", len(tools))
|
||||
}
|
||||
res, err := tools[0].Execute(context.Background(), "c1", json.RawMessage(`{}`), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute must not return a Go error even on crash: %v", err)
|
||||
}
|
||||
txt := agentcore.ContentToText(res.Content)
|
||||
if !strings.Contains(txt, "plugin call failed") {
|
||||
t.Errorf("expected isolated error result, got %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// cmdPluginSrc declares two slash commands and answers commands/call by echoing
|
||||
// the command name back as a prompt plus one notification. Its manifest command
|
||||
// order (greet, then bye) lets tests assert manifest-order aggregation.
|
||||
const cmdPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
Params json.RawMessage ` + "`json:\"params\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for sc.Scan() {
|
||||
var r req
|
||||
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
|
||||
continue
|
||||
}
|
||||
switch r.Method {
|
||||
case "initialize":
|
||||
reply(w, r.ID, json.RawMessage(` + "`" + `{"name":"cmd","commands":[{"name":"greet","description":"greets"},{"name":"bye","description":"farewell"}]}` + "`" + `))
|
||||
case "commands/call":
|
||||
var p struct {
|
||||
Name string ` + "`json:\"name\"`" + `
|
||||
Args json.RawMessage ` + "`json:\"arguments\"`" + `
|
||||
}
|
||||
json.Unmarshal(r.Params, &p)
|
||||
res, _ := json.Marshal(map[string]any{
|
||||
"prompt": "did:" + p.Name,
|
||||
"notifications": []map[string]any{
|
||||
{"message": "ran " + p.Name, "type": "info"},
|
||||
},
|
||||
})
|
||||
reply(w, r.ID, res)
|
||||
case "shutdown":
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reply(w *bufio.Writer, id *json.RawMessage, result json.RawMessage) {
|
||||
if id == nil {
|
||||
return
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
}
|
||||
`
|
||||
|
||||
// TestPluginCallCommand checks CallCommand round-trips a prompt and its
|
||||
// notifications from a plugin over the real subprocess transport.
|
||||
func TestPluginCallCommand(t *testing.T) {
|
||||
bin := buildTestPlugin(t, "cmd", cmdPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
res, err := p.CallCommand(context.Background(), "greet", json.RawMessage(`{"text":"hi"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("CallCommand: %v", err)
|
||||
}
|
||||
if res.Prompt != "did:greet" {
|
||||
t.Errorf("prompt = %q, want did:greet", res.Prompt)
|
||||
}
|
||||
if len(res.Notifications) != 1 {
|
||||
t.Fatalf("notifications = %+v, want one", res.Notifications)
|
||||
}
|
||||
if res.Notifications[0].Message != "ran greet" || res.Notifications[0].Type != "info" {
|
||||
t.Errorf("notification = %+v, want {ran greet, info}", res.Notifications[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginCallCommandTransportError checks that a transport error (the plugin
|
||||
// crashed mid-call) surfaces as a returned error, never a panic.
|
||||
func TestPluginCallCommandTransportError(t *testing.T) {
|
||||
bin := buildTestPlugin(t, "cmdcrash", cmdCrashPluginSrc)
|
||||
p, err := Load(bin, nil, os.Stderr)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
defer p.Close()
|
||||
|
||||
_, err = p.CallCommand(context.Background(), "greet", json.RawMessage(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("CallCommand must return an error when the plugin crashes mid-call")
|
||||
}
|
||||
}
|
||||
|
||||
// cmdCrashPluginSrc initializes with one command then exits abruptly on the
|
||||
// first commands/call, so no response is ever sent.
|
||||
const cmdCrashPluginSrc = `package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type req struct {
|
||||
ID *json.RawMessage ` + "`json:\"id\"`" + `
|
||||
Method string ` + "`json:\"method\"`" + `
|
||||
}
|
||||
|
||||
func main() {
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
for sc.Scan() {
|
||||
var r req
|
||||
if err := json.Unmarshal(sc.Bytes(), &r); err != nil {
|
||||
continue
|
||||
}
|
||||
switch r.Method {
|
||||
case "initialize":
|
||||
out, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": r.ID, "result": json.RawMessage(` + "`" + `{"name":"cmdcrash","commands":[{"name":"greet","description":"greets"}]}` + "`" + `)})
|
||||
fmt.Fprintf(w, "%s\n", out)
|
||||
w.Flush()
|
||||
case "commands/call":
|
||||
os.Exit(1) // crash mid-call: no response is ever sent
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
Reference in New Issue
Block a user