| 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 |