返回 DeepSeek-Reasonix
main.go
根目录 / sdk / go / examples / fullsidecar / main.go
1 // Command fullsidecar is the reference Reasonix extension sidecar: one small
2 // program that exercises every Extension Protocol v1 contribution kind —
3 // input rewriting, tool interception, system-prompt strategy replacement, an
4 // extension-hosted streaming provider, structured UI surfaces and prompts,
5 // and a clean bounded shutdown. It is the example third parties copy.
6 //
7 // Behavior map:
8 //
9 // input "/fs <text>" → input.receive replaces the input with
10 // "<text> [rewritten by fullsidecar]"
11 // tool "dangerous_exec" → tool.before blocks it with a policy reason
12 // tool "read" → tool.before rewrites the arguments (sandbox)
13 // system_prompt.build → the strategy slot owner wraps the prompt
14 // session.start → publishes a status line and a card
15 // action "demo" → asks a form prompt, greets via notification
16 // provider plugin/<id>/fake/echo → streams a fixed completion: two text
17 // chunks, one tool call, usage, done
18 //
19 // Environment:
20 //
21 // REASONIX_PLUGIN_NAME plugin ID, set by the host at launch (provider
22 // refs must live in the plugin/<id>/ namespace);
23 // defaults to "fullsidecar" when run standalone
24 // FULLSIDECAR_STREAM_INTERVAL_MS
25 // pacing between provider chunks (default 15)
26 //
27 // The two hooks below exist for the host↔SDK conformance suite
28 // (internal/extension/conformance); they are inert unless set:
29 //
30 // FULLSIDECAR_CRASH_ON_INPUT exit(3) without answering when an
31 // input.receive text matches exactly
32 // FULLSIDECAR_STALL_ON_INPUT hold an input.receive answer until the
33 // intercept context ends when the text matches
34 package main
35
36 import (
37 "context"
38 "encoding/json"
39 "errors"
40 "fmt"
41 "log"
42 "os"
43 "strconv"
44 "strings"
45 "sync/atomic"
46 "time"
47
48 extension "github.com/esengine/DeepSeek-Reasonix/sdk/go"
49 )
50
51 const (
52 rewritePrefix = "/fs "
53 rewriteSuffix = " [rewritten by fullsidecar]"
54 deniedTool = "dangerous_exec"
55 rewrittenTool = "read"
56 fakeModel = "echo"
57 defaultPluginID = "fullsidecar"
58 )
59
60 // plugin is the extension handler. The session context arrives with the
61 // handshake and is read by later callbacks, so it travels through an atomic.
62 type plugin struct {
63 id string
64 log *log.Logger
65 ui extension.HostUI
66 session atomic.Pointer[extension.SessionContext]
67 }
68
69 func main() {
70 logger := log.New(os.Stderr, "fullsidecar: ", log.LstdFlags)
71 id := strings.TrimSpace(os.Getenv("REASONIX_PLUGIN_NAME"))
72 if id == "" {
73 id = defaultPluginID
74 }
75 p := &plugin{id: id, log: logger}
76 provider := &fakeProvider{id: id, interval: streamInterval(), log: logger}
77 err := extension.Serve(context.Background(), p, extension.Options{
78 Name: id,
79 Version: "1.0.0",
80 Interceptors: map[string]extension.InterceptorFunc{
81 "input.receive": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
82 return p.interceptInput(ctx, payload)
83 },
84 "tool.before": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
85 return p.interceptTool(ctx, payload)
86 },
87 "system_prompt.build": func(ctx context.Context, _ string, payload json.RawMessage) (*extension.InterceptResult, error) {
88 return p.interceptSystemPrompt(ctx, payload)
89 },
90 },
91 Observer: p.observe,
92 Provider: provider,
93 UI: extension.UIHandler{
94 Action: p.action,
95 Submit: p.submit,
96 },
97 Shutdown: func(context.Context) { logger.Print("shutdown requested; exiting") },
98 Logger: logger,
99 })
100 if err != nil {
101 logger.Printf("serve: %v", err)
102 os.Exit(1)
103 }
104 // Serve returned nil: the host asked for shutdown. Exit 0 so the host
105 // reaps the process as an orderly stop.
106 }
107
108 // streamInterval reads FULLSIDECAR_STREAM_INTERVAL_MS with a 15ms default.
109 func streamInterval() time.Duration {
110 if raw := strings.TrimSpace(os.Getenv("FULLSIDECAR_STREAM_INTERVAL_MS")); raw != "" {
111 if ms, err := strconv.Atoi(raw); err == nil && ms > 0 {
112 return time.Duration(ms) * time.Millisecond
113 }
114 }
115 return 15 * time.Millisecond
116 }
117
118 // Initialize declares everything this extension contributes. The host rejects
119 // anything the installed manifest did not declare first.
120 func (p *plugin) Initialize(_ context.Context, params extension.InitializeParams) (*extension.InitializeResult, error) {
121 session := params.Session
122 p.session.Store(&session)
123 p.log.Printf("initialized for session %s (workspace %s)", session.SessionID, session.WorkspaceRoot)
124 return &extension.InitializeResult{
125 Subscriptions: []string{"input.receive", "tool.before", "system_prompt.build", "session.start"},
126 Replaces: []string{"system_prompt"},
127 Providers: []extension.ProviderDescriptor{fakeDescriptor(p.id)},
128 UIActions: []extension.UIActionDecl{{ActionID: "demo", Label: "Run the fullsidecar demo"}},
129 }, nil
130 }
131
132 // ---------------------------------------------------------------------------
133 // Interceptors
134 // ---------------------------------------------------------------------------
135
136 // interceptInput rewrites any input that starts with the "/fs " trigger.
137 func (p *plugin) interceptInput(ctx context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
138 var in struct {
139 Text string `json:"text"`
140 }
141 if err := json.Unmarshal(payload, &in); err != nil {
142 return extension.Continue(), nil
143 }
144 // Conformance hooks (see the package comment); inert when unset.
145 if crash := os.Getenv("FULLSIDECAR_CRASH_ON_INPUT"); crash != "" && in.Text == crash {
146 p.log.Printf("crash hook triggered by input %q", in.Text)
147 os.Exit(3)
148 }
149 if stall := os.Getenv("FULLSIDECAR_STALL_ON_INPUT"); stall != "" && in.Text == stall {
150 <-ctx.Done()
151 return nil, ctx.Err()
152 }
153 if !strings.HasPrefix(in.Text, rewritePrefix) {
154 return extension.Continue(), nil
155 }
156 rewritten := strings.TrimPrefix(in.Text, rewritePrefix) + rewriteSuffix
157 p.log.Printf("input.receive: rewrote %q → %q", in.Text, rewritten)
158 return extension.Replace(map[string]string{"text": rewritten})
159 }
160
161 // interceptTool blocks the denied tool outright and rewrites the arguments of
162 // the rewritten tool; every other tool continues untouched.
163 func (p *plugin) interceptTool(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
164 var call struct {
165 Name string `json:"name"`
166 Arguments string `json:"arguments"`
167 }
168 if err := json.Unmarshal(payload, &call); err != nil {
169 return extension.Continue(), nil
170 }
171 switch call.Name {
172 case deniedTool:
173 return extension.Block("fullsidecar: tool " + deniedTool + " is denied by the demo policy"), nil
174 case rewrittenTool:
175 args := map[string]any{}
176 if strings.TrimSpace(call.Arguments) != "" {
177 if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
178 return extension.Continue(), nil
179 }
180 }
181 args["sandbox"] = true
182 encoded, err := json.Marshal(args)
183 if err != nil {
184 return extension.Continue(), nil
185 }
186 return extension.Replace(map[string]string{"name": call.Name, "arguments": string(encoded)})
187 default:
188 return extension.Continue(), nil
189 }
190 }
191
192 // interceptSystemPrompt owns the system_prompt strategy slot: it wraps the
193 // base prompt instead of letting the default assembler render it.
194 func (p *plugin) interceptSystemPrompt(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
195 var in struct {
196 Prompt string `json:"prompt"`
197 WorkspaceRoot string `json:"workspaceRoot"`
198 }
199 if err := json.Unmarshal(payload, &in); err != nil {
200 return nil, err
201 }
202 owned := "You are Reasonix running under the fullsidecar demo strategy.\n\n" +
203 "Workspace: " + in.WorkspaceRoot + "\n\nBase prompt:\n" + in.Prompt
204 return extension.Replace(map[string]string{"prompt": owned, "workspaceRoot": in.WorkspaceRoot})
205 }
206
207 // ---------------------------------------------------------------------------
208 // Observation and UI
209 // ---------------------------------------------------------------------------
210
211 // observe publishes the extension's status line and demo card when the
212 // session starts.
213 func (p *plugin) observe(ctx context.Context, event string, _ json.RawMessage) {
214 if event != "session.start" {
215 return
216 }
217 session := p.session.Load()
218 if session == nil {
219 return
220 }
221 if err := p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
222 Label: "fullsidecar online",
223 Detail: "intercepts, provider, and UI are live",
224 Severity: extension.UISeverityInfo,
225 }); err != nil {
226 p.log.Printf("publish status: %v", err)
227 }
228 if err := p.ui.PublishCard(ctx, session.SessionID, session.Generation, "fullsidecar-card", extension.UICardPayload{
229 Title: "fullsidecar",
230 Markdown: "Reference extension: try the **demo** action or the `/fs ` input trigger.",
231 Fields: []extension.UIKeyValue{{Key: "plugin", Value: p.id}, {Key: "provider", Value: fakeRef(p.id)}},
232 Actions: []extension.UIActionRef{{ActionID: "demo", Label: "Run demo"}},
233 }); err != nil {
234 p.log.Printf("publish card: %v", err)
235 }
236 }
237
238 // action runs the declared "demo" action: a blocking form prompt, then a
239 // notification built from the answers. A dismissed prompt is not a failure.
240 func (p *plugin) action(ctx context.Context, actionID string, _ map[string]string) error {
241 if actionID != "demo" {
242 return fmt.Errorf("fullsidecar: unknown action %q", actionID)
243 }
244 session := p.session.Load()
245 if session == nil {
246 return errors.New("fullsidecar: no session yet")
247 }
248 values, err := p.ui.RequestForm(ctx, session.SessionID, session.Generation, "fullsidecar-demo-form", extension.UIFormPayload{
249 Title: "fullsidecar demo",
250 Message: "Whom should the demo greet?",
251 Fields: []extension.UIFormField{
252 {Key: "name", Label: "Your name", Kind: extension.UIFieldInput, Required: true},
253 {Key: "loud", Label: "Shout the greeting", Kind: extension.UIFieldConfirm},
254 },
255 })
256 if errors.Is(err, extension.ErrUICancelled) {
257 return nil
258 }
259 if err != nil {
260 return err
261 }
262 name, _ := values["name"].(string)
263 if strings.TrimSpace(name) == "" {
264 name = "world"
265 }
266 greeting := "Hello, " + name + "!"
267 if loud, _ := values["loud"].(bool); loud {
268 greeting = strings.ToUpper(greeting)
269 }
270 return p.ui.PublishNotification(ctx, session.SessionID, session.Generation, "fullsidecar-greeting", extension.UINotificationPayload{
271 Title: greeting,
272 Severity: extension.UISeverityInfo,
273 })
274 }
275
276 // submit acknowledges published-form submissions with a status update.
277 func (p *plugin) submit(ctx context.Context, surfaceID string, values map[string]any) error {
278 p.log.Printf("form %q submitted: %v", surfaceID, values)
279 session := p.session.Load()
280 if session == nil {
281 return nil
282 }
283 return p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
284 Label: "fullsidecar: form " + surfaceID + " submitted",
285 Severity: extension.UISeverityInfo,
286 })
287 }
288
289 // ---------------------------------------------------------------------------
290 // Fake streaming provider
291 // ---------------------------------------------------------------------------
292
293 func fakeRef(pluginID string) string { return "plugin/" + pluginID + "/fake/" + fakeModel }
294
295 func fakeDescriptor(pluginID string) extension.ProviderDescriptor {
296 return extension.ProviderDescriptor{
297 Ref: fakeRef(pluginID),
298 DisplayName: "fullsidecar fake",
299 Model: fakeModel,
300 ContextWindow: 64000,
301 Tools: true,
302 Reasoning: true,
303 Efforts: []string{"low", "high"},
304 DefaultEffort: "low",
305 }
306 }
307
308 // fakeProvider streams a fixed scripted completion: two text chunks, one tool
309 // call, final usage, done. Chunks are paced so hosts can exercise mid-stream
310 // cancel; a cancelled context stops production immediately, and the SDK ends
311 // the stream interrupted.
312 type fakeProvider struct {
313 id string
314 interval time.Duration
315 log *log.Logger
316 }
317
318 func (p *fakeProvider) Catalog(context.Context) ([]extension.ProviderDescriptor, error) {
319 return []extension.ProviderDescriptor{fakeDescriptor(p.id)}, nil
320 }
321
322 func (p *fakeProvider) Stream(ctx context.Context, req extension.StreamRequest) (<-chan extension.StreamChunk, error) {
323 if req.ProviderRef != fakeRef(p.id) {
324 return nil, fmt.Errorf("fullsidecar: unknown provider ref %q", req.ProviderRef)
325 }
326 p.log.Printf("stream %s opened for %s (model %s)", req.StreamID, req.ProviderRef, req.Model)
327 chunks := make(chan extension.StreamChunk)
328 go func() {
329 defer close(chunks)
330 script := []extension.StreamChunk{
331 extension.TextChunk("fake-hello "),
332 extension.TextChunk("fake-world"),
333 {Type: extension.ChunkToolCall, ToolCall: &extension.ProviderToolCall{
334 ID: "call-1", Name: "lookup", Arguments: `{"query":"reasonix"}`,
335 }},
336 extension.UsageChunk(extension.ProviderUsage{
337 PromptTokens: 5, CompletionTokens: 7, TotalTokens: 12,
338 CacheHitTokens: 2, CacheMissTokens: 3, ReasoningTokens: 4,
339 FinishReason: "stop",
340 }),
341 extension.DoneChunk(),
342 }
343 for _, chunk := range script {
344 select {
345 case <-ctx.Done():
346 return
347 case <-time.After(p.interval):
348 }
349 select {
350 case <-ctx.Done():
351 return
352 case chunks <- chunk:
353 }
354 }
355 }()
356 return chunks, nil
357 }
358
358 lines GO