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