返回 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 v2 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 Provides: append([]extension.CapabilityWire(nil), params.Manifest.Provides...),
130 }, nil
131 }
132
133 // Interceptors
134
135 // interceptInput rewrites any input that starts with the "/fs " trigger.
136 func (p *plugin) interceptInput(ctx context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
137 var in struct {
138 Text string `json:"text"`
139 }
140 if err := json.Unmarshal(payload, &in); err != nil {
141 return extension.Continue(), nil
142 }
143 // Conformance hooks (see the package comment); inert when unset.
144 if crash := os.Getenv("FULLSIDECAR_CRASH_ON_INPUT"); crash != "" && in.Text == crash {
145 p.log.Printf("crash hook triggered by input %q", in.Text)
146 os.Exit(3)
147 }
148 if stall := os.Getenv("FULLSIDECAR_STALL_ON_INPUT"); stall != "" && in.Text == stall {
149 <-ctx.Done()
150 return nil, ctx.Err()
151 }
152 if !strings.HasPrefix(in.Text, rewritePrefix) {
153 return extension.Continue(), nil
154 }
155 rewritten := strings.TrimPrefix(in.Text, rewritePrefix) + rewriteSuffix
156 p.log.Printf("input.receive: rewrote %q → %q", in.Text, rewritten)
157 return extension.Replace(map[string]string{"text": rewritten})
158 }
159
160 // interceptTool blocks the denied tool outright and rewrites the arguments of
161 // the rewritten tool; every other tool continues untouched.
162 func (p *plugin) interceptTool(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
163 var call struct {
164 Name string `json:"name"`
165 Arguments string `json:"arguments"`
166 }
167 if err := json.Unmarshal(payload, &call); err != nil {
168 return extension.Continue(), nil
169 }
170 switch call.Name {
171 case deniedTool:
172 return extension.Block("fullsidecar: tool " + deniedTool + " is denied by the demo policy"), nil
173 case rewrittenTool:
174 args := map[string]any{}
175 if strings.TrimSpace(call.Arguments) != "" {
176 if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil {
177 return extension.Continue(), nil
178 }
179 }
180 args["sandbox"] = true
181 encoded, err := json.Marshal(args)
182 if err != nil {
183 return extension.Continue(), nil
184 }
185 return extension.Replace(map[string]string{"name": call.Name, "arguments": string(encoded)})
186 default:
187 return extension.Continue(), nil
188 }
189 }
190
191 // interceptSystemPrompt owns the system_prompt strategy slot: it wraps the
192 // base prompt instead of letting the default assembler render it.
193 func (p *plugin) interceptSystemPrompt(_ context.Context, payload json.RawMessage) (*extension.InterceptResult, error) {
194 var in struct {
195 Prompt string `json:"prompt"`
196 WorkspaceRoot string `json:"workspaceRoot"`
197 }
198 if err := json.Unmarshal(payload, &in); err != nil {
199 return nil, err
200 }
201 owned := "You are Reasonix running under the fullsidecar demo strategy.\n\n" +
202 "Workspace: " + in.WorkspaceRoot + "\n\nBase prompt:\n" + in.Prompt
203 return extension.Replace(map[string]string{"prompt": owned, "workspaceRoot": in.WorkspaceRoot})
204 }
205
206 // Observation and UI
207
208 // observe publishes the extension's status line and demo card when the
209 // session starts.
210 func (p *plugin) observe(ctx context.Context, event string, _ json.RawMessage) {
211 if event != "session.start" {
212 return
213 }
214 session := p.session.Load()
215 if session == nil {
216 return
217 }
218 if err := p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
219 Label: "fullsidecar online",
220 Detail: "intercepts, provider, and UI are live",
221 Severity: extension.UISeverityInfo,
222 }); err != nil {
223 p.log.Printf("publish status: %v", err)
224 }
225 if err := p.ui.PublishCard(ctx, session.SessionID, session.Generation, "fullsidecar-card", extension.UICardPayload{
226 Title: "fullsidecar",
227 Markdown: "Reference extension: try the **demo** action or the `/fs ` input trigger.",
228 Fields: []extension.UIKeyValue{{Key: "plugin", Value: p.id}, {Key: "provider", Value: fakeRef(p.id)}},
229 Actions: []extension.UIActionRef{{ActionID: "demo", Label: "Run demo"}},
230 }); err != nil {
231 p.log.Printf("publish card: %v", err)
232 }
233 }
234
235 // action runs the declared "demo" action: a blocking form prompt, then a
236 // notification built from the answers. A dismissed prompt is not a failure.
237 func (p *plugin) action(ctx context.Context, actionID string, _ map[string]string) error {
238 if actionID != "demo" {
239 return fmt.Errorf("fullsidecar: unknown action %q", actionID)
240 }
241 session := p.session.Load()
242 if session == nil {
243 return errors.New("fullsidecar: no session yet")
244 }
245 values, err := p.ui.RequestForm(ctx, session.SessionID, session.Generation, "fullsidecar-demo-form", extension.UIFormPayload{
246 Title: "fullsidecar demo",
247 Message: "Whom should the demo greet?",
248 Fields: []extension.UIFormField{
249 {Key: "name", Label: "Your name", Kind: extension.UIFieldInput, Required: true},
250 {Key: "loud", Label: "Shout the greeting", Kind: extension.UIFieldConfirm},
251 },
252 })
253 if errors.Is(err, extension.ErrUICancelled) {
254 return nil
255 }
256 if err != nil {
257 return err
258 }
259 name, _ := values["name"].(string)
260 if strings.TrimSpace(name) == "" {
261 name = "world"
262 }
263 greeting := "Hello, " + name + "!"
264 if loud, _ := values["loud"].(bool); loud {
265 greeting = strings.ToUpper(greeting)
266 }
267 return p.ui.PublishNotification(ctx, session.SessionID, session.Generation, "fullsidecar-greeting", extension.UINotificationPayload{
268 Title: greeting,
269 Severity: extension.UISeverityInfo,
270 })
271 }
272
273 // submit acknowledges published-form submissions with a status update.
274 func (p *plugin) submit(ctx context.Context, surfaceID string, values map[string]any) error {
275 p.log.Printf("form %q submitted: %v", surfaceID, values)
276 session := p.session.Load()
277 if session == nil {
278 return nil
279 }
280 return p.ui.PublishStatus(ctx, session.SessionID, session.Generation, "fullsidecar-status", extension.UIStatusPayload{
281 Label: "fullsidecar: form " + surfaceID + " submitted",
282 Severity: extension.UISeverityInfo,
283 })
284 }
285
286 // Fake streaming provider
287
288 func fakeRef(pluginID string) string { return "plugin/" + pluginID + "/fake/" + fakeModel }
289
290 func fakeDescriptor(pluginID string) extension.ProviderDescriptor {
291 return extension.ProviderDescriptor{
292 Ref: fakeRef(pluginID),
293 DisplayName: "fullsidecar fake",
294 Model: fakeModel,
295 ContextWindow: 64000,
296 Tools: true,
297 Reasoning: true,
298 Efforts: []string{"low", "high"},
299 DefaultEffort: "low",
300 }
301 }
302
303 // fakeProvider streams a fixed scripted completion: two text chunks, one tool
304 // call, final usage, done. Chunks are paced so hosts can exercise mid-stream
305 // cancel; a cancelled context stops production immediately, and the SDK ends
306 // the stream interrupted.
307 type fakeProvider struct {
308 id string
309 interval time.Duration
310 log *log.Logger
311 }
312
313 func (p *fakeProvider) Catalog(context.Context) ([]extension.ProviderDescriptor, error) {
314 return []extension.ProviderDescriptor{fakeDescriptor(p.id)}, nil
315 }
316
317 func (p *fakeProvider) Stream(ctx context.Context, req extension.StreamRequest) (<-chan extension.StreamChunk, error) {
318 if req.ProviderRef != fakeRef(p.id) {
319 return nil, fmt.Errorf("fullsidecar: unknown provider ref %q", req.ProviderRef)
320 }
321 p.log.Printf("stream %s opened for %s (model %s)", req.StreamID, req.ProviderRef, req.Model)
322 chunks := make(chan extension.StreamChunk)
323 go func() {
324 defer close(chunks)
325 script := []extension.StreamChunk{
326 extension.TextChunk("fake-hello "),
327 extension.TextChunk("fake-world"),
328 {Type: extension.ChunkToolCall, ToolCall: &extension.ProviderToolCall{
329 ID: "call-1", Name: "lookup", Arguments: `{"query":"reasonix"}`,
330 }},
331 extension.UsageChunk(extension.ProviderUsage{
332 PromptTokens: 5, CompletionTokens: 7, TotalTokens: 12,
333 CacheHitTokens: 2, CacheMissTokens: 3, ReasoningTokens: 4,
334 FinishReason: "stop",
335 }),
336 extension.DoneChunk(),
337 }
338 for _, chunk := range script {
339 select {
340 case <-ctx.Done():
341 return
342 case <-time.After(p.interval):
343 }
344 select {
345 case <-ctx.Done():
346 return
347 case chunks <- chunk:
348 }
349 }
350 }()
351 return chunks, nil
352 }
353
353 lines GO