返回 DeepSeek-Reasonix
payloads.go
根目录 / internal / extension / dispatch / payloads.go
1 package dispatch
2
3 import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9
10 "reasonix/internal/extension"
11 "reasonix/internal/extension/protocol"
12 "reasonix/internal/extension/providerconv"
13 "reasonix/internal/provider"
14 )
15
16 // Host payload DTOs: one struct per intercept point. These are the host-side
17 // shapes the dispatcher marshals into extension/intercept params and — more
18 // importantly — the shapes an extension's "replace" answer is strictly
19 // re-decoded against before it may substitute the live value. JSON field
20 // names are camelCase, matching the protocol package's DTO convention.
21
22 // InputPayload is the input.receive payload: one user input line.
23 type InputPayload struct {
24 Text string `json:"text,omitempty"`
25 }
26
27 // Point returns the intercept point this payload serves.
28 func (InputPayload) Point() extension.InterceptorPoint { return extension.PointInputReceive }
29
30 // Validate enforces the required fields: text must be non-empty (an
31 // extension emptying the input should block instead).
32 func (p *InputPayload) Validate() error {
33 if p.Text == "" {
34 return errors.New("text must be non-empty")
35 }
36 return nil
37 }
38
39 // AgentStartPayload is the agent.before_start payload.
40 type AgentStartPayload struct {
41 Model string `json:"model,omitempty"`
42 ToolCount int `json:"toolCount,omitempty"`
43 SessionID string `json:"sessionId,omitempty"`
44 }
45
46 // Point returns the intercept point this payload serves.
47 func (AgentStartPayload) Point() extension.InterceptorPoint { return extension.PointAgentBeforeStart }
48
49 // Validate enforces the required fields.
50 func (p *AgentStartPayload) Validate() error {
51 if p.SessionID == "" {
52 return errors.New("sessionId must be non-empty")
53 }
54 return nil
55 }
56
57 // SystemPromptPayload is the system_prompt.build payload.
58 type SystemPromptPayload struct {
59 Prompt string `json:"prompt,omitempty"`
60 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
61 }
62
63 // Point returns the intercept point this payload serves.
64 func (SystemPromptPayload) Point() extension.InterceptorPoint {
65 return extension.PointSystemPromptBuild
66 }
67
68 // Validate enforces the required fields. The prompt itself may be empty: a
69 // strategy owner intentionally blanking the prompt is a policy question, not
70 // a shape violation.
71 func (p *SystemPromptPayload) Validate() error {
72 if p.WorkspaceRoot == "" {
73 return errors.New("workspaceRoot must be non-empty")
74 }
75 return nil
76 }
77
78 // ContextPayload is the context.prepare payload.
79 type ContextPayload struct {
80 Messages []protocol.ProviderMessage `json:"messages,omitempty"`
81 }
82
83 // Point returns the intercept point this payload serves.
84 func (ContextPayload) Point() extension.InterceptorPoint { return extension.PointContextPrepare }
85
86 // Validate enforces the required fields: a replacement must carry the
87 // messages array explicitly, even when empty.
88 func (p *ContextPayload) Validate() error {
89 if p.Messages == nil {
90 return errors.New("messages must be an array")
91 }
92 // Validate before adopting the replacement so an optional extension's bad
93 // transcript follows its normal warn-and-skip policy, not a fatal late gate.
94 return provider.ValidateModelTranscript(providerconv.MessagesFromProtocol(p.Messages))
95 }
96
97 // ProviderRequestPayload is the provider.request payload.
98 type ProviderRequestPayload struct {
99 Request protocol.ProviderRequest `json:"request"`
100 }
101
102 // Point returns the intercept point this payload serves.
103 func (ProviderRequestPayload) Point() extension.InterceptorPoint {
104 return extension.PointProviderRequest
105 }
106
107 // Validate enforces the request invariants, including the JSON-Schema shape
108 // of every tool's parameters (protocol.ProviderRequest.Validate).
109 func (p *ProviderRequestPayload) Validate() error {
110 if err := p.Request.Validate(); err != nil {
111 return err
112 }
113 return provider.ValidateModelTranscript(providerconv.MessagesFromProtocol(p.Request.Messages))
114 }
115
116 // ProviderResponsePayload is the provider.response payload: the assembled
117 // terminal response of one provider stream.
118 type ProviderResponsePayload struct {
119 Text string `json:"text,omitempty"`
120 Reasoning string `json:"reasoning,omitempty"`
121 Signature string `json:"signature,omitempty"`
122 Calls []protocol.ProviderToolCall `json:"calls,omitempty"`
123 Usage *protocol.ProviderUsage `json:"usage,omitempty"`
124 }
125
126 // Point returns the intercept point this payload serves.
127 func (ProviderResponsePayload) Point() extension.InterceptorPoint {
128 return extension.PointProviderResponse
129 }
130
131 // Validate enforces the required fields: every tool call must carry its
132 // provider-visible identity.
133 func (p *ProviderResponsePayload) Validate() error {
134 for i, call := range p.Calls {
135 if call.ID == "" || call.Name == "" {
136 return fmt.Errorf("calls[%d]: id and name must be non-empty", i)
137 }
138 }
139 return nil
140 }
141
142 // ToolBeforePayload is the tool.before payload. Arguments is the tool's JSON
143 // argument object in text form.
144 type ToolBeforePayload struct {
145 Name string `json:"name,omitempty"`
146 Arguments string `json:"arguments,omitempty"`
147 }
148
149 // Point returns the intercept point this payload serves.
150 func (ToolBeforePayload) Point() extension.InterceptorPoint { return extension.PointToolBefore }
151
152 // Validate enforces the required fields plus the JSON shape of the tool
153 // arguments.
154 func (p *ToolBeforePayload) Validate() error {
155 if p.Name == "" {
156 return errors.New("name must be non-empty")
157 }
158 return validateArguments(p.Arguments)
159 }
160
161 // ToolAfterPayload is the tool.after payload.
162 type ToolAfterPayload struct {
163 Name string `json:"name,omitempty"`
164 Arguments string `json:"arguments,omitempty"`
165 Result string `json:"result,omitempty"`
166 IsError bool `json:"isError,omitempty"`
167 }
168
169 // Point returns the intercept point this payload serves.
170 func (ToolAfterPayload) Point() extension.InterceptorPoint { return extension.PointToolAfter }
171
172 // Validate enforces the required fields plus the JSON shape of the tool
173 // arguments.
174 func (p *ToolAfterPayload) Validate() error {
175 if p.Name == "" {
176 return errors.New("name must be non-empty")
177 }
178 return validateArguments(p.Arguments)
179 }
180
181 // PermissionPayload is the permission.decision payload. HostDecision is the
182 // verdict the host reached on its own ("allow" or "deny"); an extension's
183 // allow may override a host deny (the dispatcher records an audit note),
184 // never the reverse without the caller's combination rule.
185 type PermissionPayload struct {
186 Name string `json:"name,omitempty"`
187 Arguments string `json:"arguments,omitempty"`
188 ReadOnly bool `json:"readOnly,omitempty"`
189 HostDecision string `json:"hostDecision,omitempty"`
190 }
191
192 // Point returns the intercept point this payload serves.
193 func (PermissionPayload) Point() extension.InterceptorPoint { return extension.PointPermissionDecision }
194
195 // Validate enforces the required fields, the host-decision enum, and the
196 // JSON shape of the tool arguments.
197 func (p *PermissionPayload) Validate() error {
198 if p.Name == "" {
199 return errors.New("name must be non-empty")
200 }
201 if p.HostDecision != "allow" && p.HostDecision != "deny" {
202 return fmt.Errorf("hostDecision must be %q or %q", "allow", "deny")
203 }
204 return validateArguments(p.Arguments)
205 }
206
207 // CompactionPreparePayload is the compaction.prepare payload.
208 type CompactionPreparePayload struct {
209 Messages []protocol.ProviderMessage `json:"messages,omitempty"`
210 Guidance string `json:"guidance,omitempty"`
211 }
212
213 // Point returns the intercept point this payload serves.
214 func (CompactionPreparePayload) Point() extension.InterceptorPoint {
215 return extension.PointCompactionPrepare
216 }
217
218 // Validate enforces the required fields: a replacement must carry the
219 // messages array explicitly, even when empty.
220 func (p *CompactionPreparePayload) Validate() error {
221 if p.Messages == nil {
222 return errors.New("messages must be an array")
223 }
224 return nil
225 }
226
227 // CompactionCompletePayload is the compaction.complete payload.
228 type CompactionCompletePayload struct {
229 Summary string `json:"summary,omitempty"`
230 }
231
232 // Point returns the intercept point this payload serves.
233 func (CompactionCompletePayload) Point() extension.InterceptorPoint {
234 return extension.PointCompactionComplete
235 }
236
237 // Validate enforces the required fields.
238 func (p *CompactionCompletePayload) Validate() error {
239 if p.Summary == "" {
240 return errors.New("summary must be non-empty")
241 }
242 return nil
243 }
244
245 // Session phases: the SessionPayload.Phase values, one per session.* point.
246 const (
247 PhaseStart = "start"
248 PhaseEnd = "end"
249 PhaseLoad = "load"
250 PhaseSave = "save"
251 PhaseRotate = "rotate"
252 )
253
254 // SessionPayload serves all five session.* points; Phase distinguishes them
255 // and must agree with the point being dispatched.
256 type SessionPayload struct {
257 SessionPath string `json:"sessionPath,omitempty"`
258 Phase string `json:"phase,omitempty"`
259 }
260
261 // Point returns the family representative; the registry maps this payload to
262 // all five session.* points.
263 func (SessionPayload) Point() extension.InterceptorPoint { return extension.PointSessionStart }
264
265 // Validate enforces the required fields and the phase enum.
266 func (p *SessionPayload) Validate() error {
267 switch p.Phase {
268 case PhaseStart, PhaseEnd, PhaseLoad, PhaseSave, PhaseRotate:
269 return nil
270 default:
271 return fmt.Errorf("phase must be one of %q, %q, %q, %q, %q",
272 PhaseStart, PhaseEnd, PhaseLoad, PhaseSave, PhaseRotate)
273 }
274 }
275
276 // FrontendEventPayload is the frontend.event payload.
277 type FrontendEventPayload struct {
278 Kind string `json:"kind,omitempty"`
279 Text string `json:"text,omitempty"`
280 Detail string `json:"detail,omitempty"`
281 }
282
283 // Point returns the intercept point this payload serves.
284 func (FrontendEventPayload) Point() extension.InterceptorPoint { return extension.PointFrontendEvent }
285
286 // Validate enforces the required fields.
287 func (p *FrontendEventPayload) Validate() error {
288 if p.Kind == "" {
289 return errors.New("kind must be non-empty")
290 }
291 return nil
292 }
293
294 // validateArguments enforces the tool-arguments shape: empty (no arguments)
295 // or a valid JSON object.
296 func validateArguments(arguments string) error {
297 if arguments == "" {
298 return nil
299 }
300 trimmed := bytes.TrimSpace([]byte(arguments))
301 if len(trimmed) == 0 || trimmed[0] != '{' || !json.Valid(trimmed) {
302 return errors.New("arguments must be a JSON object")
303 }
304 return nil
305 }
306
307 // payloadFactory returns a fresh pointer to one point's payload struct.
308 type payloadFactory func() any
309
310 // payloadRegistry maps each of the 17 intercept points to the factory for
311 // its payload DTO, so replace answers decode strictly into a fresh value of
312 // the right type.
313 var payloadRegistry = map[extension.InterceptorPoint]payloadFactory{
314 extension.PointInputReceive: func() any { return &InputPayload{} },
315 extension.PointAgentBeforeStart: func() any { return &AgentStartPayload{} },
316 extension.PointSystemPromptBuild: func() any { return &SystemPromptPayload{} },
317 extension.PointContextPrepare: func() any { return &ContextPayload{} },
318 extension.PointProviderRequest: func() any { return &ProviderRequestPayload{} },
319 extension.PointProviderResponse: func() any { return &ProviderResponsePayload{} },
320 extension.PointToolBefore: func() any { return &ToolBeforePayload{} },
321 extension.PointToolAfter: func() any { return &ToolAfterPayload{} },
322 extension.PointPermissionDecision: func() any { return &PermissionPayload{} },
323 extension.PointCompactionPrepare: func() any { return &CompactionPreparePayload{} },
324 extension.PointCompactionComplete: func() any { return &CompactionCompletePayload{} },
325 extension.PointSessionStart: func() any { return &SessionPayload{} },
326 extension.PointSessionEnd: func() any { return &SessionPayload{} },
327 extension.PointSessionLoad: func() any { return &SessionPayload{} },
328 extension.PointSessionSave: func() any { return &SessionPayload{} },
329 extension.PointSessionRotate: func() any { return &SessionPayload{} },
330 extension.PointFrontendEvent: func() any { return &FrontendEventPayload{} },
331 }
332
333 // decodePayload strictly decodes a replacement payload for point: unknown
334 // fields are rejected, trailing JSON is rejected, and the DTO's Validate runs
335 // before the value may substitute the live payload. Session payloads must
336 // also agree with the point being dispatched (a "start" payload cannot
337 // replace session.save).
338 func decodePayload(point extension.InterceptorPoint, raw json.RawMessage) (any, error) {
339 factory, ok := payloadRegistry[point]
340 if !ok {
341 return nil, fmt.Errorf("no payload DTO registered for %s", point)
342 }
343 if len(bytes.TrimSpace(raw)) == 0 {
344 return nil, errors.New("replacement is empty")
345 }
346 fresh := factory()
347 decoder := json.NewDecoder(bytes.NewReader(raw))
348 decoder.DisallowUnknownFields()
349 if err := decoder.Decode(fresh); err != nil {
350 return nil, fmt.Errorf("replacement does not match the %s payload: %w", point, err)
351 }
352 var extra any
353 if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
354 return nil, errors.New("replacement contains trailing JSON")
355 }
356 validatable, ok := fresh.(interface{ Validate() error })
357 if !ok {
358 return nil, fmt.Errorf("payload DTO for %s has no Validate method", point)
359 }
360 if err := validatable.Validate(); err != nil {
361 return nil, err
362 }
363 if session, ok := fresh.(*SessionPayload); ok {
364 if want := extension.InterceptorPoint("session." + session.Phase); want != point {
365 return nil, fmt.Errorf("phase %q does not match point %s", session.Phase, point)
366 }
367 }
368 return fresh, nil
369 }
370
370 lines GO