197 lines
7.4 KiB
Go
197 lines
7.4 KiB
Go
// 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
|
|
}
|