264 lines
8.8 KiB
Go
264 lines
8.8 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/mcp/plugintools"
|
|
"github.com/kandev/kandev/internal/mcp/toolschema"
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const maxAgentToolResultBytes = 1 << 20
|
|
|
|
const agentToolOutcomeError = "error"
|
|
|
|
type AgentToolInvocationContext struct {
|
|
InvocationID string
|
|
TaskID string
|
|
SessionID string
|
|
WorkspaceID string
|
|
Surface string
|
|
}
|
|
|
|
// AgentToolCatalog returns the current active-plugin tool snapshot. The
|
|
// revision changes only when the effective tool set changes; generation is
|
|
// stable for the lifetime of the Service instance.
|
|
func (s *Service) AgentToolCatalog() (plugintools.Snapshot, error) {
|
|
tools, err := buildAgentToolDefinitions(s.registry.List())
|
|
if err != nil {
|
|
return plugintools.Snapshot{}, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.agentToolGeneration == "" {
|
|
s.agentToolGeneration = fmt.Sprintf("service-%d", time.Now().UnixNano())
|
|
}
|
|
current := plugintools.Snapshot{Generation: s.agentToolGeneration, Tools: tools}
|
|
previous := s.agentToolSnapshot
|
|
previous.Revision = 0
|
|
if !s.agentToolSnapshotReady || !plugintools.Equal(previous, current) {
|
|
s.agentToolRevision++
|
|
current.Revision = s.agentToolRevision
|
|
s.agentToolSnapshot = plugintools.Normalize(current)
|
|
s.agentToolSnapshotReady = true
|
|
}
|
|
return plugintools.Normalize(s.agentToolSnapshot), nil
|
|
}
|
|
|
|
func buildAgentToolDefinitions(records []*store.Record) ([]plugintools.Definition, error) {
|
|
seen := make(map[string]string)
|
|
var definitions []plugintools.Definition
|
|
for _, record := range records {
|
|
if record.Status != StatusActive {
|
|
continue
|
|
}
|
|
for _, tool := range record.AgentTools {
|
|
input, err := json.Marshal(tool.InputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugin %q tool %q input schema: %w", record.ID, tool.Name, err)
|
|
}
|
|
var output json.RawMessage
|
|
if len(tool.OutputSchema) > 0 {
|
|
output, err = json.Marshal(tool.OutputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugin %q tool %q output schema: %w", record.ID, tool.Name, err)
|
|
}
|
|
}
|
|
exposed := plugintools.ExposedName(record.ID, tool.Name)
|
|
if previous, ok := seen[exposed]; ok && previous != record.ID {
|
|
return nil, fmt.Errorf("agent tool name collision: %q from %q and %q", exposed, previous, record.ID)
|
|
}
|
|
seen[exposed] = record.ID
|
|
definitions = append(definitions, plugintools.Definition{
|
|
PluginID: record.ID,
|
|
PluginDisplayName: record.DisplayName,
|
|
LocalName: tool.Name,
|
|
ExposedName: exposed,
|
|
Description: tool.Description,
|
|
Surfaces: append([]string(nil), tool.Surfaces...),
|
|
InputSchema: input,
|
|
OutputSchema: output,
|
|
ReadOnlyHint: boolValue(tool.Annotations.ReadOnlyHint, false),
|
|
DestructiveHint: boolValue(tool.Annotations.DestructiveHint, true),
|
|
IdempotentHint: boolValue(tool.Annotations.IdempotentHint, false),
|
|
OpenWorldHint: boolValue(tool.Annotations.OpenWorldHint, true),
|
|
})
|
|
}
|
|
}
|
|
return plugintools.Normalize(plugintools.Snapshot{Tools: definitions}).Tools, nil
|
|
}
|
|
|
|
func (s *Service) validateAgentToolInstall(candidate *manifest.Manifest) error {
|
|
if candidate == nil {
|
|
return nil
|
|
}
|
|
seen := make(map[string]string)
|
|
for _, record := range s.registry.List() {
|
|
if record.ID == candidate.ID {
|
|
continue
|
|
}
|
|
for _, tool := range record.AgentTools {
|
|
seen[plugintools.ExposedName(record.ID, tool.Name)] = record.ID
|
|
}
|
|
}
|
|
for _, tool := range candidate.AgentTools {
|
|
exposed := plugintools.ExposedName(candidate.ID, tool.Name)
|
|
if previous, ok := seen[exposed]; ok && previous != candidate.ID {
|
|
return fmt.Errorf("agent tool name collision: %q from %q and %q", exposed, previous, candidate.ID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func boolValue(value *bool, fallback bool) bool {
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
return *value
|
|
}
|
|
|
|
// InvokeAgentTool validates the declaration and arguments, then forwards one
|
|
// bounded call to the plugin's gRPC subprocess. It deliberately does not
|
|
// retry because tools may have side effects.
|
|
func (s *Service) InvokeAgentTool(ctx context.Context, pluginID, localName string, arguments map[string]any, invocation AgentToolInvocationContext) (result *pluginsdk.AgentToolResult, err error) {
|
|
started := time.Now()
|
|
defer func() {
|
|
if s.log == nil {
|
|
return
|
|
}
|
|
outcome := "success"
|
|
if err != nil {
|
|
outcome = agentToolOutcomeError
|
|
} else if result != nil && result.IsError {
|
|
outcome = "tool_error"
|
|
}
|
|
s.log.Info("plugin agent tool invocation",
|
|
zap.String("invocation_id", invocation.InvocationID),
|
|
zap.String("plugin_id", pluginID),
|
|
zap.String("local_name", localName),
|
|
zap.String("task_id", invocation.TaskID),
|
|
zap.String("session_id", invocation.SessionID),
|
|
zap.Duration("duration", time.Since(started)),
|
|
zap.String("outcome", outcome))
|
|
}()
|
|
|
|
record, err := s.Get(pluginID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record.Status != StatusActive {
|
|
return nil, fmt.Errorf("plugins: plugin %q is not active", pluginID)
|
|
}
|
|
declaration, err := findAgentTool(record.AgentTools, localName, invocation.Surface)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arguments, err = validateAgentToolArguments(pluginID, localName, declaration, arguments)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
remote, ok := s.pluginRemote(pluginID)
|
|
if !ok {
|
|
return nil, fmt.Errorf("plugins: plugin %q is not running", pluginID)
|
|
}
|
|
result, err = invokeRemoteAgentTool(ctx, remote, localName, arguments, invocation)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return validateAgentToolResult(pluginID, localName, declaration, result)
|
|
}
|
|
|
|
func findAgentTool(tools []manifest.AgentTool, localName, surface string) (*manifest.AgentTool, error) {
|
|
for i := range tools {
|
|
if tools[i].Name != localName {
|
|
continue
|
|
}
|
|
if !containsString(tools[i].Surfaces, surface) {
|
|
return nil, fmt.Errorf("plugins: agent tool %q is not available on surface %q", localName, surface)
|
|
}
|
|
return &tools[i], nil
|
|
}
|
|
return nil, fmt.Errorf("plugins: agent tool %q is not declared", localName)
|
|
}
|
|
|
|
func validateAgentToolArguments(pluginID, localName string, declaration *manifest.AgentTool, arguments map[string]any) (map[string]any, error) {
|
|
if arguments == nil {
|
|
arguments = map[string]any{}
|
|
}
|
|
inputSchema, err := compileInvocationSchema(pluginID+"/"+localName, declaration.InputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugins: invalid input schema: %w", err)
|
|
}
|
|
if err := inputSchema.Validate(arguments); err != nil {
|
|
return nil, fmt.Errorf("plugins: invalid arguments for %q", localName)
|
|
}
|
|
return arguments, nil
|
|
}
|
|
|
|
func invokeRemoteAgentTool(ctx context.Context, remote interface {
|
|
InvokeAgentTool(context.Context, *pluginsdk.AgentToolRequest) (*pluginsdk.AgentToolResult, error)
|
|
}, localName string, arguments map[string]any, invocation AgentToolInvocationContext) (*pluginsdk.AgentToolResult, error) {
|
|
callCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
return remote.InvokeAgentTool(callCtx, &pluginsdk.AgentToolRequest{
|
|
InvocationID: invocation.InvocationID,
|
|
Name: localName,
|
|
Arguments: arguments,
|
|
Context: pluginsdk.AgentToolContext{
|
|
TaskID: invocation.TaskID, SessionID: invocation.SessionID,
|
|
WorkspaceID: invocation.WorkspaceID, Surface: invocation.Surface,
|
|
},
|
|
})
|
|
}
|
|
|
|
func validateAgentToolResult(pluginID, localName string, declaration *manifest.AgentTool, result *pluginsdk.AgentToolResult) (*pluginsdk.AgentToolResult, error) {
|
|
if result == nil || strings.TrimSpace(result.Text) == "" {
|
|
return nil, errors.New("plugins: agent tool returned empty text")
|
|
}
|
|
encoded, err := json.Marshal(result.StructuredContent)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugins: encode agent tool result: %w", err)
|
|
}
|
|
if len(result.Text)+len(encoded) > maxAgentToolResultBytes {
|
|
return nil, errors.New("plugins: agent tool result exceeds 1 MiB")
|
|
}
|
|
if len(declaration.OutputSchema) > 0 && result.StructuredContent != nil {
|
|
outputSchema, err := toolschema.Compile(pluginID+"/"+localName+"/output", declaration.OutputSchema)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plugins: invalid output schema: %w", err)
|
|
}
|
|
if err := outputSchema.Validate(result.StructuredContent); err != nil {
|
|
return nil, errors.New("plugins: agent tool result does not match output schema")
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func compileInvocationSchema(name string, document map[string]any) (*jsonschema.Schema, error) {
|
|
copy := make(map[string]any, len(document)+1)
|
|
for key, value := range document {
|
|
copy[key] = value
|
|
}
|
|
copy["additionalProperties"] = false
|
|
return toolschema.Compile(name, copy)
|
|
}
|
|
|
|
func containsString(values []string, wanted string) bool {
|
|
for _, value := range values {
|
|
if value == wanted {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|