diff --git a/pkg/plugins/sdk/channel.go b/pkg/plugins/sdk/channel.go new file mode 100644 index 0000000..b42eca4 --- /dev/null +++ b/pkg/plugins/sdk/channel.go @@ -0,0 +1,29 @@ +package sdk + +import "context" + +// Channel registers a chat platform adapter (QQ, WeChat, Telegram, ...). +// +// The SDK pins only the lifecycle shape. The authoritative contract for +// adapters lives in internal/channel/adapter (design authority +// DevDocs/design/08-渠道接入.md §1, the PlatformAdapter contract) and is +// implemented in milestone M6. This interface mirrors the lifecycle portion of +// that contract — Name/Connect/Disconnect/IsConnected/HealthCheck — without +// depending on the internal message model, so a platform plugin can register +// itself here while the message flow is provided by the channel layer. +// +// Implementations must be safe for concurrent use. +type Channel interface { + // Name returns the platform identifier (for example "qq", "telegram"). + Name() string + // Connect establishes the platform connection using config. It may be + // called more than once and must be idempotent. + Connect(ctx context.Context, config any) error + // Disconnect tears the connection down gracefully. It returns nil when the + // adapter was already disconnected. + Disconnect(ctx context.Context) error + // IsConnected reports whether the connection is currently up. + IsConnected() bool + // HealthCheck probes the platform and returns an error when it is unhealthy. + HealthCheck(ctx context.Context) error +} diff --git a/pkg/plugins/sdk/sdk.go b/pkg/plugins/sdk/sdk.go new file mode 100644 index 0000000..c0da38e --- /dev/null +++ b/pkg/plugins/sdk/sdk.go @@ -0,0 +1,90 @@ +// Package sdk is the standalone plugin SDK for Cyrene v2. Third-party plugins +// import this package, implement the Tool, Skill and Channel interfaces, and +// register themselves through the Registrar that the core capability registry +// (internal/capability/registry) implements. +// +// Plugins are compiled into the binary and registered at startup; there is no +// Go plugin dynamic loading. That keeps the plugin surface portable across +// Windows, ARM and other targets, as required by DevDocs/design/09 §2. +// +// The three plugin categories mirror DevDocs/design/09 §0: +// +// - Tool: a function plus an input JSON Schema, callable directly by the +// agent (calculator, web-search, file, ...). +// - Skill: a reusable workflow (an ordered set of steps) with prompts, +// references and guardrails, higher-level than a Tool (daily brief, code +// review, multi-stage report). +// - Channel: a platform adapter (QQ, WeChat, Telegram, ...). +// +// Capabilities are globally shared and always available to every character and +// mode; the registry is what makes them visible. A plugin never needs to know +// the registry's concrete type — it registers through the Registrar interface. +package sdk + +import ( + "context" + "errors" +) + +// ErrNilRegistrar is returned by the helper registration functions when the +// supplied Registrar is nil. +var ErrNilRegistrar = errors.New("sdk: nil registrar") + +// Registrar is the subset of a capability registry that accepts plugins. The +// core registry (internal/capability/registry) implements it, and third-party +// plugins register through it. +type Registrar interface { + // RegisterTool registers a Tool. + RegisterTool(t Tool) error + // RegisterSkill registers a Skill. + RegisterSkill(s Skill) error + // RegisterChannel registers a Channel adapter. + RegisterChannel(c Channel) error +} + +// ConfigHook validates or mutates a plugin's configuration section at startup, +// before the runtime uses it. The host applies hooks in registration order and +// stops at the first error or on context cancellation. +type ConfigHook func(ctx context.Context, config map[string]any) error + +// RegisterTool registers t with r. It returns ErrNilRegistrar when r is nil. +func RegisterTool(r Registrar, t Tool) error { + if r == nil { + return ErrNilRegistrar + } + return r.RegisterTool(t) +} + +// RegisterSkill registers s with r. It returns ErrNilRegistrar when r is nil. +func RegisterSkill(r Registrar, s Skill) error { + if r == nil { + return ErrNilRegistrar + } + return r.RegisterSkill(s) +} + +// RegisterChannel registers c with r. It returns ErrNilRegistrar when r is nil. +func RegisterChannel(r Registrar, c Channel) error { + if r == nil { + return ErrNilRegistrar + } + return r.RegisterChannel(c) +} + +// RunConfigHooks applies hooks in order against config, stopping at the first +// error or when ctx is canceled. Nil hooks are skipped. It returns nil when +// every hook succeeds. +func RunConfigHooks(ctx context.Context, config map[string]any, hooks ...ConfigHook) error { + for _, h := range hooks { + if err := ctx.Err(); err != nil { + return err + } + if h == nil { + continue + } + if err := h(ctx, config); err != nil { + return err + } + } + return nil +} diff --git a/pkg/plugins/sdk/sdk_test.go b/pkg/plugins/sdk/sdk_test.go new file mode 100644 index 0000000..dd3d3d8 --- /dev/null +++ b/pkg/plugins/sdk/sdk_test.go @@ -0,0 +1,212 @@ +package sdk + +import ( + "context" + "errors" + "testing" +) + +// stubTool is a mock Tool used to verify the registration helpers forward to a +// Registrar. +type stubTool struct{ name string } + +func (s stubTool) Name() string { return s.name } +func (s stubTool) Description() string { return "desc " + s.name } +func (s stubTool) Parameters() (map[string]any, error) { return map[string]any{"type": "object"}, nil } +func (s stubTool) Execute(_ context.Context, _ any) (any, error) { return "exec " + s.name, nil } + +// stubSkill is a mock Skill used to verify the registration helpers forward. +type stubSkill struct{ name string } + +func (s stubSkill) Name() string { return s.name } +func (s stubSkill) Description() string { return "desc " + s.name } +func (s stubSkill) Prompt() string { return "prompt " + s.name } +func (s stubSkill) Steps() []Step { return []Step{{Name: "s1", Prompt: "p1"}} } +func (s stubSkill) Refs() []Ref { return []Ref{{Name: "r1", Type: "doc"}} } +func (s stubSkill) Guardrails() []Guardrail { + return []Guardrail{{ID: "g1", Message: "no", Enforced: true}} +} + +// stubChannel is a mock Channel used to verify the registration helpers forward. +type stubChannel struct{ name string } + +func (s stubChannel) Name() string { return s.name } +func (s stubChannel) Connect(_ context.Context, _ any) error { return nil } +func (s stubChannel) Disconnect(_ context.Context) error { return nil } +func (s stubChannel) IsConnected() bool { return true } +func (s stubChannel) HealthCheck(_ context.Context) error { return nil } + +// stubRegistrar records registrations and always succeeds. +type stubRegistrar struct { + tools []Tool + skills []Skill + channels []Channel +} + +func (s *stubRegistrar) RegisterTool(t Tool) error { + s.tools = append(s.tools, t) + return nil +} + +func (s *stubRegistrar) RegisterSkill(skl Skill) error { + s.skills = append(s.skills, skl) + return nil +} + +func (s *stubRegistrar) RegisterChannel(c Channel) error { + s.channels = append(s.channels, c) + return nil +} + +// errRegistrar fails every registration with the error it carries. +type errRegistrar struct{ err error } + +func (e errRegistrar) RegisterTool(Tool) error { return e.err } +func (e errRegistrar) RegisterSkill(Skill) error { return e.err } +func (e errRegistrar) RegisterChannel(Channel) error { return e.err } + +func TestRegisterTool_Forwards(t *testing.T) { + t.Parallel() + r := &stubRegistrar{} + tool := stubTool{name: "calc"} + if err := RegisterTool(r, tool); err != nil { + t.Fatalf("RegisterTool: %v", err) + } + if len(r.tools) != 1 || r.tools[0] != tool { + t.Fatalf("registered %d tools (%v), want exactly tool %v", len(r.tools), r.tools, tool) + } +} + +func TestRegisterSkill_Forwards(t *testing.T) { + t.Parallel() + r := &stubRegistrar{} + skill := stubSkill{name: "review"} + if err := RegisterSkill(r, skill); err != nil { + t.Fatalf("RegisterSkill: %v", err) + } + if len(r.skills) != 1 || r.skills[0] != skill { + t.Fatalf("registered %d skills (%v), want exactly skill %v", len(r.skills), r.skills, skill) + } +} + +func TestRegisterChannel_Forwards(t *testing.T) { + t.Parallel() + r := &stubRegistrar{} + channel := stubChannel{name: "telegram"} + if err := RegisterChannel(r, channel); err != nil { + t.Fatalf("RegisterChannel: %v", err) + } + if len(r.channels) != 1 || r.channels[0] != channel { + t.Fatalf("registered %d channels (%v), want exactly channel %v", len(r.channels), r.channels, channel) + } +} + +func TestRegisterHelpers_NilRegistrar(t *testing.T) { + t.Parallel() + tests := []struct { + name string + call func() error + }{ + {name: "tool", call: func() error { return RegisterTool(nil, stubTool{name: "t"}) }}, + {name: "skill", call: func() error { return RegisterSkill(nil, stubSkill{name: "s"}) }}, + {name: "channel", call: func() error { return RegisterChannel(nil, stubChannel{name: "c"}) }}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.call(); !errors.Is(err, ErrNilRegistrar) { + t.Fatalf("err = %v, want ErrNilRegistrar", err) + } + }) + } +} + +func TestRegisterHelpers_PropagateError(t *testing.T) { + t.Parallel() + want := errors.New("sdk: propagate error") + tests := []struct { + name string + call func() error + }{ + {name: "tool", call: func() error { return RegisterTool(errRegistrar{err: want}, stubTool{name: "t"}) }}, + {name: "skill", call: func() error { return RegisterSkill(errRegistrar{err: want}, stubSkill{name: "s"}) }}, + {name: "channel", call: func() error { return RegisterChannel(errRegistrar{err: want}, stubChannel{name: "c"}) }}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if err := tt.call(); !errors.Is(err, want) { + t.Fatalf("err = %v, want %v", err, want) + } + }) + } +} + +func TestRunConfigHooks_RunsInOrder(t *testing.T) { + t.Parallel() + var order []string + hooks := []ConfigHook{ + func(_ context.Context, _ map[string]any) error { order = append(order, "a"); return nil }, + func(_ context.Context, _ map[string]any) error { order = append(order, "b"); return nil }, + } + if err := RunConfigHooks(context.Background(), nil, hooks...); err != nil { + t.Fatalf("RunConfigHooks: %v", err) + } + if len(order) != 2 || order[0] != "a" || order[1] != "b" { + t.Fatalf("hook order = %v, want [a b]", order) + } +} + +func TestRunConfigHooks_MutatesConfig(t *testing.T) { + t.Parallel() + cfg := map[string]any{"a": "1"} + hook := func(_ context.Context, c map[string]any) error { c["a"] = "2"; return nil } + if err := RunConfigHooks(context.Background(), cfg, hook); err != nil { + t.Fatalf("RunConfigHooks: %v", err) + } + if cfg["a"] != "2" { + t.Fatalf("config a = %v, want 2", cfg["a"]) + } +} + +func TestRunConfigHooks_StopsOnError(t *testing.T) { + t.Parallel() + hookErr := errors.New("sdk: hook error") + ranSecond := false + hooks := []ConfigHook{ + func(_ context.Context, _ map[string]any) error { return hookErr }, + func(_ context.Context, _ map[string]any) error { ranSecond = true; return nil }, + } + if err := RunConfigHooks(context.Background(), nil, hooks...); !errors.Is(err, hookErr) { + t.Fatalf("err = %v, want hookErr", err) + } + if ranSecond { + t.Fatal("second hook ran after the first returned an error") + } +} + +func TestRunConfigHooks_NilHookSkipped(t *testing.T) { + t.Parallel() + if err := RunConfigHooks(context.Background(), nil, nil, nil); err != nil { + t.Fatalf("RunConfigHooks(all nil): %v", err) + } +} + +func TestRunConfigHooks_ContextCanceled(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ran := false + err := RunConfigHooks(ctx, nil, func(_ context.Context, _ map[string]any) error { + ran = true + return nil + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want context.Canceled", err) + } + if ran { + t.Fatal("hook ran on an already-canceled context") + } +} diff --git a/pkg/plugins/sdk/skill.go b/pkg/plugins/sdk/skill.go new file mode 100644 index 0000000..eab69e2 --- /dev/null +++ b/pkg/plugins/sdk/skill.go @@ -0,0 +1,55 @@ +package sdk + +// Step is one unit of a Skill's reusable workflow. +type Step struct { + // Name is a short identifier for the step. + Name string + // Prompt is the instruction the model follows for this step. + Prompt string + // Depends lists the Names of steps that must finish before this step runs. + Depends []string +} + +// Ref is a reference a Skill may pull in for extra context or shared tooling. +type Ref struct { + // Name identifies the reference. + Name string + // Type is a free-form kind, for example "tool", "doc" or "file". + Type string + // Value is the reference payload or pointer. + Value string + // Optional reports whether the reference may be absent without failing. + Optional bool +} + +// Guardrail is a constraint a Skill applies while running. +type Guardrail struct { + // ID names the guardrail. + ID string + // Message is the rule text enforced on the workflow. + Message string + // Enforced reports whether a violation aborts the Skill (true) or is only a + // warning (false). + Enforced bool +} + +// Skill is a reusable, higher-order workflow: an ordered set of steps paired +// with prompts, references and guardrails. It is more structured than a Tool +// and typically composes several steps. +// +// Implementations must be safe for concurrent use. +type Skill interface { + // Name returns the unique skill identifier. + Name() string + // Description explains what the Skill accomplishes; the model uses it to + // select a Skill for a task. + Description() string + // Prompt returns the base system prompt for the Skill. + Prompt() string + // Steps returns the ordered workflow steps. + Steps() []Step + // Refs returns the references the Skill may draw on. + Refs() []Ref + // Guardrails returns the constraints the Skill enforces. + Guardrails() []Guardrail +} diff --git a/pkg/plugins/sdk/tool.go b/pkg/plugins/sdk/tool.go new file mode 100644 index 0000000..fe83b7d --- /dev/null +++ b/pkg/plugins/sdk/tool.go @@ -0,0 +1,23 @@ +package sdk + +import "context" + +// Tool is a callable function plus its input JSON Schema. Agents invoke tools +// directly by name; the dispatcher decodes the agent-provided arguments into +// args before calling Execute. +// +// Implementations must be safe for concurrent use, because Tool dispatch in the +// runtime may invoke them from multiple goroutines. +type Tool interface { + // Name returns the unique tool identifier the agent uses to invoke it. + Name() string + // Description explains what the tool does; the model uses it to select a + // tool for a task. + Description() string + // Parameters returns the tool input JSON Schema as a JSON object, for + // example {"type":"object","properties":{...}}. A nil map with a nil error + // means the tool accepts no arguments. + Parameters() (map[string]any, error) + // Execute runs the tool with args decoded from the caller's JSON input. + Execute(ctx context.Context, args any) (any, error) +}