From 54c8e7ab680cb7789a2cefe34099aa550038133a Mon Sep 17 00:00:00 2001 From: xiexian <3468714225@qq.com> Date: Sat, 3 Oct 2026 15:16:42 +0800 Subject: [PATCH] feat(sdk): add installable offline provider example --- .../assembly/boot/effect_sdk_provider_test.go | 155 ++++++++++++++++++ sdk/go/README.md | 5 + sdk/go/examples/providerextension/README.md | 105 ++++++++++++ sdk/go/examples/providerextension/main.go | 73 +++++++++ .../examples/providerextension/main_test.go | 134 +++++++++++++++ .../providerextension/reasonix-plugin.json | 22 +++ 6 files changed, 494 insertions(+) create mode 100644 internal/assembly/boot/effect_sdk_provider_test.go create mode 100644 sdk/go/examples/providerextension/README.md create mode 100644 sdk/go/examples/providerextension/main.go create mode 100644 sdk/go/examples/providerextension/main_test.go create mode 100644 sdk/go/examples/providerextension/reasonix-plugin.json diff --git a/internal/assembly/boot/effect_sdk_provider_test.go b/internal/assembly/boot/effect_sdk_provider_test.go new file mode 100644 index 0000000000..2fe8a803ef --- /dev/null +++ b/internal/assembly/boot/effect_sdk_provider_test.go @@ -0,0 +1,155 @@ +package boot + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "reasonix/internal/contract/event" + "reasonix/internal/contract/provider" + "reasonix/internal/ext/installsource" + "reasonix/internal/ext/pluginpkg" +) + +func buildSDKProviderPackage(t *testing.T) string { + t.Helper() + example, err := filepath.Abs(filepath.Join("..", "..", "..", "sdk", "go", "examples", "providerextension")) + if err != nil { + t.Fatal(err) + } + source := robustTempDir(t) + binary := filepath.Join(source, "bin", "echo-provider.exe") + if err := os.MkdirAll(filepath.Dir(binary), 0o755); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-o", binary, ".") + cmd.Dir = example + cmd.Env = append(os.Environ(), "GOWORK=off", "GOTOOLCHAIN=local", "GOPROXY=off") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build SDK provider example: %v\n%s", err, out) + } + manifest, err := os.ReadFile(filepath.Join(example, pluginpkg.NativeManifest)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, pluginpkg.NativeManifest), manifest, 0o644); err != nil { + t.Fatal(err) + } + return source +} + +func TestEffectInstalledSDKProviderCompletesFirstTurn(t *testing.T) { + source := buildSDKProviderPackage(t) + home := isolateConfigHome(t) + reasonixHome := filepath.Join(home, ".reasonix") + t.Setenv("REASONIX_HOME", reasonixHome) + workspace := robustTempDir(t) + t.Chdir(workspace) + const ref = "plugin/echo-provider/offline/echo" + writePluginDefaultFixture(t, workspace, ref) + installer := installsource.NewTool(installsource.Options{ProjectRoot: workspace, HomeDir: home, RequireApprovedPlan: true}) + install := func(args map[string]any) string { + t.Helper() + raw, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + out, err := installer.Execute(t.Context(), raw) + if err != nil { + t.Fatal(err) + } + var result struct { + OK bool `json:"ok"` + Applied bool `json:"applied"` + Status string `json:"status"` + PlanID string `json:"planId"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil || !result.OK { + t.Fatalf("install_source = %s, err=%v", out, err) + } + if args["apply"] == true || args["op"] == "uninstall" { + if !result.Applied || result.Status != "done" { + t.Fatalf("apply = %s", out) + } + } else if result.Applied || result.Status != "planned" || result.PlanID == "" { + t.Fatalf("preview = %s", out) + } + return result.PlanID + } + runPhase := func(t *testing.T, enabled bool) { + t.Helper() + res, err := BuildRuntime(t.Context(), Options{Sink: event.Discard}) + if !enabled { + if res != nil { + res.Controller.Close() + } + if !errors.Is(err, ErrUnknownModel) { + t.Fatalf("inactive plugin default_model: got %v, want ErrUnknownModel", err) + } + return + } + if err != nil { + t.Fatal(err) + } + defer res.Controller.Close() + if res.Controller.ModelRef() != ref || res.Extensions == nil || res.Extensions.Client("echo-provider") == nil { + t.Fatalf("first boot did not select the installed SDK provider: %q", res.Controller.ModelRef()) + } + client := res.Extensions.Client("echo-provider") + defer func() { + res.Controller.Close() + waitForCond(t, "SDK provider process exit", 10*time.Second, client.Exited) + if !res.Runtime.Closed() { + t.Fatal("SDK provider runtime did not close") + } + }() + for _, input := range []string{"SDK-FIRST-TURN 世界", "SDK-SECOND-TURN again"} { + before := len(res.Controller.History()) + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + err := res.Controller.RunTurn(ctx, input) + cancel() + if err != nil { + t.Fatal(err) + } + var text strings.Builder + for _, message := range res.Controller.History()[before:] { + if len(message.ToolCalls) != 0 || message.Role == provider.RoleTool { + t.Fatalf("offline provider produced a tool call or result: %+v", message) + } + if message.Role == provider.RoleAssistant { + text.WriteString(message.Content) + } + } + if !strings.HasPrefix(text.String(), "Offline echo: ") || !strings.Contains(text.String(), input) { + t.Fatalf("assistant turn = %q, want the SDK echo of %q", text.String(), input) + } + } + } + t.Run("absent", func(t *testing.T) { runPhase(t, false) }) + args := map[string]any{"source": source, "kind": "plugin", "scope": "global", "mode": "copy"} + args["planId"] = install(args) + args["apply"] = true + install(args) + if err := os.RemoveAll(source); err != nil { + t.Fatal(err) + } + t.Run("installed", func(t *testing.T) { runPhase(t, true) }) + if err := pluginpkg.SetEnabled(reasonixHome, "echo-provider", false); err != nil { + t.Fatal(err) + } + t.Run("disabled", func(t *testing.T) { runPhase(t, false) }) + if err := pluginpkg.SetEnabled(reasonixHome, "echo-provider", true); err != nil { + t.Fatal(err) + } + t.Run("re-enabled", func(t *testing.T) { runPhase(t, true) }) + install(map[string]any{"op": "uninstall", "name": "echo-provider", "scope": "global"}) + t.Run("removed", func(t *testing.T) { runPhase(t, false) }) +} diff --git a/sdk/go/README.md b/sdk/go/README.md index f17fb95437..0cdcc91059 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -93,6 +93,11 @@ input-rewrite check. tool-serving package: manifest declarations, `Options.Tools`, catalog discovery, and an observable word-count result through the real host. +[`examples/providerextension`](examples/providerextension/README.md) is a +text-only offline provider: manifest identity, `Options.Provider`, catalog, +cancellable streaming, and a first controller turn with no API key or tool +calls. It echoes the last provider-visible user message. + [`examples/fullsidecar`](examples/fullsidecar/README.md) is the reference extension: protocol-level input rewriting, tool interception (block + argument rewrite), system-prompt strategy replacement, a fake diff --git a/sdk/go/examples/providerextension/README.md b/sdk/go/examples/providerextension/README.md new file mode 100644 index 0000000000..a96f75935e --- /dev/null +++ b/sdk/go/examples/providerextension/README.md @@ -0,0 +1,105 @@ +--- +owner: @SivanCola +backup: @esengine +status: active +reviewed: 2026-10-03 +--- + +# Offline Provider Extension + +This installable Go SDK example contributes one text-only provider through +`Options.Provider`. It returns `Offline echo: ` followed by the last user +message's content, then a done chunk. It needs no API key or network service +and emits no tool calls. It is an integration example, not a language model. + +The host may compose workspace context and reminders into that message. The +example echoes the provider-visible content unchanged; it does not extract +the original typed input. It does not implement vision, reasoning, token +accounting, or structured response formats. + +## Build and install + +From this directory in a Reasonix source checkout, with Go 1.23+ and the +`reasonix` CLI available, on macOS or Linux: + +```sh +go test . +go build -o bin/echo-provider.exe . +plugin_root="$(pwd -P)" +reasonix plugin install "$plugin_root" --dry-run +reasonix plugin install "$plugin_root" --replace --yes +reasonix plugin doctor echo-provider +``` + +From PowerShell on Windows: + +```powershell +go test . +go build -o bin/echo-provider.exe . +$pluginRoot = (Resolve-Path .).Path +reasonix plugin install $pluginRoot --dry-run +reasonix plugin install $pluginRoot --replace --yes +reasonix plugin doctor echo-provider +``` + +The fixed `.exe` path works on Unix and gives Windows its required suffix. +Review the dry-run's `FULL TRUST` block before installation. The CLI copies +the package by default; rebuilding the source does not change that copy. +For development, add `--link` to authorize changes in the source directory. + +## Run a turn + +From your workspace, use the installed provider directly: + +```sh +reasonix --model plugin/echo-provider/offline/echo -p "SDK-PROVIDER-CHECK hello" +``` + +The assistant text MUST begin with `Offline echo: ` and include +`SDK-PROVIDER-CHECK hello`. Additional host-composed context may also appear. +The provider does not call another model or execute tools. + +In an existing session, run `/reload` while idle, then use `/model` to choose +`plugin/echo-provider/offline/echo`. The same reference works as +`default_model` in `reasonix.toml`, including on the first runtime build. + +`internal/assembly/boot/effect_sdk_provider_test.go` builds this actual +example, installs a copy through preview and approved apply, deletes the +source, and checks the first controller turn and a second turn on the same +sidecar. + +It repeats after re-enabling the package and verifies the typed +unknown-model error when the configured plugin default is absent, disabled, +or removed. This is offline host evidence, not model quality evidence. + +## Author contract + +| Part | Rule | +| --- | --- | +| Manifest | Declare the `providers` runtime capability and `plugin/echo-provider` provider capability with ID `offline/echo`. | +| Identity | The host sets `REASONIX_PLUGIN_NAME`; both initialize and catalog MUST return the matching `plugin//offline/echo` reference. | +| Fingerprint | This example's `schemaHash` is SHA-256 of the compact JSON encoding of its default `ProviderDescriptor`, as checked by `go test .`; it is an author capability fingerprint, not the full protocol schema hash. | +| Streaming | Return promptly, produce chunks on the channel, and close it. `DoneChunk` ends the assistant turn; channel closure lets the SDK emit the stream end. | +| Cancellation | The producer MUST stop when the callback's context is cancelled, including while waiting to send. | +| Concurrency | This provider is stateless. Added shared mutable state MUST follow the [SDK concurrency contract](../../README.md#concurrency-contract). | +| Output | stdout belongs to the SDK protocol. Diagnostics MUST go to stderr. | + +## Iterate and remove + +Run `go test .`, rebuild, and replace the installed copy after editing. For a +linked development install, rebuild and run `/reload` while idle. Start a new +session or reload after changing activation: + +```sh +reasonix plugin disable echo-provider +reasonix plugin enable echo-provider +reasonix plugin uninstall echo-provider --yes +``` + +Select another model before disabling or removing the active provider. A +`default_model` pointing to an inactive plugin cannot resolve at boot; change +that default before starting a new session. + +See [Plugin Packages](../../../../docs/PLUGIN_PACKAGES.md#manifest-v2-extensions) +for manifest and trust rules, and [Extensions](../../../../docs/EXTENSIONS.md#runtime-reload) +for reload behavior. diff --git a/sdk/go/examples/providerextension/main.go b/sdk/go/examples/providerextension/main.go new file mode 100644 index 0000000000..7b34a99c53 --- /dev/null +++ b/sdk/go/examples/providerextension/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + + extension "github.com/esengine/DeepSeek-Reasonix/sdk/go" +) + +type echoProvider struct{ id string } + +func (p echoProvider) descriptor() extension.ProviderDescriptor { + return extension.ProviderDescriptor{ + Ref: "plugin/" + p.id + "/offline/echo", + DisplayName: "Offline echo (SDK example)", + Model: "echo", + ContextWindow: 64000, + } +} + +func (p echoProvider) Initialize(_ context.Context, params extension.InitializeParams) (*extension.InitializeResult, error) { + return &extension.InitializeResult{ + Providers: []extension.ProviderDescriptor{p.descriptor()}, + Provides: append([]extension.CapabilityWire(nil), params.Manifest.Provides...), + }, nil +} + +func (p echoProvider) Catalog(context.Context) ([]extension.ProviderDescriptor, error) { + return []extension.ProviderDescriptor{p.descriptor()}, nil +} + +func (p echoProvider) Stream(ctx context.Context, req extension.StreamRequest) (<-chan extension.StreamChunk, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if req.ProviderRef != p.descriptor().Ref { + return nil, fmt.Errorf("unknown offline provider ref %q", req.ProviderRef) + } + var text string + for i := len(req.Request.Messages) - 1; i >= 0; i-- { + if req.Request.Messages[i].Role == extension.ProviderRoleUser { + text = req.Request.Messages[i].Content + break + } + } + chunks := make(chan extension.StreamChunk) + go func() { + defer close(chunks) + for _, chunk := range []extension.StreamChunk{extension.TextChunk("Offline echo: "), extension.TextChunk(text), extension.DoneChunk()} { + select { + case <-ctx.Done(): + return + case chunks <- chunk: + } + } + }() + return chunks, nil +} + +func main() { + id := strings.TrimSpace(os.Getenv("REASONIX_PLUGIN_NAME")) + if id == "" { + id = "echo-provider" + } + p := echoProvider{id: id} + if err := extension.Serve(context.Background(), p, extension.Options{ + Name: id, Version: "0.1.0", Provider: p, + }); err != nil { + os.Exit(1) + } +} diff --git a/sdk/go/examples/providerextension/main_test.go b/sdk/go/examples/providerextension/main_test.go new file mode 100644 index 0000000000..58efae3136 --- /dev/null +++ b/sdk/go/examples/providerextension/main_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "os" + "testing" + "time" + + extension "github.com/esengine/DeepSeek-Reasonix/sdk/go" +) + +func TestEchoProviderManifestAndCatalog(t *testing.T) { + raw, err := os.ReadFile("reasonix-plugin.json") + if err != nil { + t.Fatal(err) + } + var manifest struct { + Name string `json:"name"` + Provides []extension.CapabilityWire `json:"provides"` + } + if err := json.Unmarshal(raw, &manifest); err != nil { + t.Fatal(err) + } + p := echoProvider{id: manifest.Name} + result, err := p.Initialize(context.Background(), extension.InitializeParams{ + Manifest: extension.ManifestExpectation{Provides: manifest.Provides}, + }) + if err != nil { + t.Fatal(err) + } + catalog, err := p.Catalog(context.Background()) + if err != nil || len(catalog) != 1 || len(result.Providers) != 1 || len(result.Provides) != 1 { + t.Fatalf("initialize=%+v catalog=%+v err=%v", result, catalog, err) + } + capability := result.Provides[0] + if capability.Namespace+"/"+capability.ID != catalog[0].Ref || capability.Kind != "provider" || catalog[0].Tools || catalog[0].Reasoning || catalog[0].Vision { + t.Fatalf("manifest capability=%+v catalog=%+v", capability, catalog) + } + encoded, err := json.Marshal(catalog[0]) + if err != nil { + t.Fatal(err) + } + if want := fmt.Sprintf("sha256:%x", sha256.Sum256(encoded)); capability.SchemaHash != want { + t.Fatalf("descriptor fingerprint=%s, want %s", capability.SchemaHash, want) + } + if result.Providers[0].Ref != catalog[0].Ref { + t.Fatalf("initialize ref=%q catalog ref=%q", result.Providers[0].Ref, catalog[0].Ref) + } + if got := (echoProvider{id: "renamed"}).descriptor().Ref; got != "plugin/renamed/offline/echo" { + t.Fatalf("renamed provider ref=%q", got) + } +} + +func TestEchoProviderStreamsLastUserContent(t *testing.T) { + for _, tc := range []struct { + name string + messages []extension.ProviderMessage + want string + }{ + {"no user", []extension.ProviderMessage{{Role: extension.ProviderRoleSystem, Content: "system"}}, ""}, + {"unicode", []extension.ProviderMessage{{Role: extension.ProviderRoleUser, Content: "第一行\nsecond line"}}, "第一行\nsecond line"}, + {"last user", []extension.ProviderMessage{ + {Role: extension.ProviderRoleUser, Content: "old"}, + {Role: extension.ProviderRoleAssistant, Content: "answer"}, + {Role: extension.ProviderRoleUser, Content: "host context\nnew"}, + {Role: extension.ProviderRoleTool, Content: "tool result"}, + }, "host context\nnew"}, + {"empty last user", []extension.ProviderMessage{{Role: extension.ProviderRoleUser, Content: "old"}, {Role: extension.ProviderRoleUser}}, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + p := echoProvider{id: "echo-provider"} + stream, err := p.Stream(ctx, extension.StreamRequest{ + ProviderRef: p.descriptor().Ref, Request: extension.ProviderRequest{Messages: tc.messages}, + }) + if err != nil { + t.Fatal(err) + } + var text string + var types []extension.ProviderChunkType + for chunk := range stream { + types = append(types, chunk.Type) + text += chunk.Text + } + if text != "Offline echo: "+tc.want || len(types) != 3 || types[0] != extension.ChunkText || types[1] != extension.ChunkText || types[2] != extension.ChunkDone { + t.Fatalf("text=%q types=%v", text, types) + } + }) + } +} + +func TestEchoProviderCancellationAndUnknownRef(t *testing.T) { + p := echoProvider{id: "echo-provider"} + if _, err := p.Stream(context.Background(), extension.StreamRequest{ProviderRef: "plugin/other/offline/echo"}); err == nil { + t.Fatal("accepted another provider's ref") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := p.Stream(ctx, extension.StreamRequest{ProviderRef: p.descriptor().Ref}); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled Stream=%v", err) + } + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + stream, err := p.Stream(ctx, extension.StreamRequest{ProviderRef: p.descriptor().Ref}) + if err != nil { + cancel() + t.Fatal(err) + } + select { + case chunk := <-stream: + if chunk.Type != extension.ChunkText || chunk.Text != "Offline echo: " { + t.Fatalf("first chunk=%+v", chunk) + } + case <-time.After(time.Second): + t.Fatal("producer did not send its first chunk") + } + cancel() + deadline := time.After(time.Second) + for { + select { + case _, ok := <-stream: + if !ok { + return + } + case <-deadline: + t.Fatal("producer did not stop after cancellation") + } + } +} diff --git a/sdk/go/examples/providerextension/reasonix-plugin.json b/sdk/go/examples/providerextension/reasonix-plugin.json new file mode 100644 index 0000000000..21f9ee4526 --- /dev/null +++ b/sdk/go/examples/providerextension/reasonix-plugin.json @@ -0,0 +1,22 @@ +{ + "apiVersion": "reasonix.io/plugin/v2", + "name": "echo-provider", + "version": "0.1.0", + "description": "Offline Go SDK provider example: echo the last user message", + "provides": [ + { + "namespace": "plugin/echo-provider", + "kind": "provider", + "id": "offline/echo", + "version": "1.0.0", + "schemaHash": "sha256:6387b7d7ff7acb18360dec8d514dea146e65bde0f7dafcf0b5d6eeac64a13dea" + } + ], + "contributes": {}, + "runtime": { + "command": "${REASONIX_PLUGIN_ROOT}/bin/echo-provider.exe", + "capabilities": [ + "providers" + ] + } +}