first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user