first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+92
View File
@@ -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{}
}
}
+262
View File
@@ -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
}
+140
View File
@@ -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
}
+137
View File
@@ -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)
}
}
}
+111
View File
@@ -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"`
}
+81
View File
@@ -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)
}
}
+196
View File
@@ -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
}
+320
View File
@@ -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
}
}
}
`