| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "reflect" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/extension" |
| 12 | "reasonix/internal/extension/dispatch" |
| 13 | "reasonix/internal/extension/providerconv" |
| 14 | "reasonix/internal/provider" |
| 15 | ) |
| 16 | |
| 17 | // Extension Protocol v1 agent-side wiring (stage 6b2). The agent consults the |
| 18 | // frozen dispatcher at the nine agent-loop intercept points: |
| 19 | // |
| 20 | // agent.before_start Run, before the turn is appended (block aborts the run) |
| 21 | // context.prepare stream, on the request message copy (never the session) |
| 22 | // provider.request stream, after the request is fully assembled |
| 23 | // provider.response stream, after a successful stream, before persisting |
| 24 | // tool.before executeOne, right after the call parses |
| 25 | // permission.decision executeOne, at the permission gate (allow/deny rulings) |
| 26 | // tool.after executeOne, after Execute returns (success or error) |
| 27 | // compaction.prepare compact, before the fold is archived and summarized |
| 28 | // compaction.complete compact, after the summary is produced, before persist |
| 29 | // |
| 30 | // Decision semantics are uniform: continue passes the payload through; |
| 31 | // replace substitutes it (strictly re-decoded and revalidated by the |
| 32 | // dispatcher, then checked against host invariants here); block fails the |
| 33 | // local operation — the run (before_start), the provider request (context/ |
| 34 | // provider.request), the turn (provider.response), the tool call |
| 35 | // (tool.before/after, permission.decision), or the compaction pass — with the |
| 36 | // redacted reason. allow/deny is terminal at permission.decision only, where |
| 37 | // the host verdict is computed FIRST and combined: an extension allow |
| 38 | // overrides a host deny (full-trust contract, audited), an extension deny |
| 39 | // overrides a host allow, and continue leaves the host decision standing. |
| 40 | // |
| 41 | // Error policy follows the dispatcher: a required extension's failure fails |
| 42 | // the local operation; an optional extension's failure is warned about once |
| 43 | // and skipped. A nil dispatcher (no v1 runtime packages installed) passes |
| 44 | // every point through untouched, so behavior stays byte-identical to the |
| 45 | // pre-dispatch path. |
| 46 | // |
| 47 | // Two-phase ruling at slot-mapped points: points that map to a replacement |
| 48 | // slot (context.prepare → context, provider.request → provider_request, |
| 49 | // provider.response → provider_response, compaction.prepare/complete → |
| 50 | // compaction, permission.decision → permission) first walk the intercept |
| 51 | // chain, then give the slot's OWNER the final say through RunStrategy over |
| 52 | // the (possibly interceptor-modified) payload. An owner that also declared |
| 53 | // the point under intercepts participates in both phases, in exactly this |
| 54 | // order — chain interceptor first, slot strategy last — so its strategy |
| 55 | // ruling is always the final replacement phase. A chain block short-circuits |
| 56 | // the strategy phase (the operation is already stopped). The owner is |
| 57 | // required-class by definition: its block, timeout, error, or contract |
| 58 | // violation is fatal to the local operation. Replaced values are adopted only |
| 59 | // when a replace ruling actually changed the payload, so a no-replacement |
| 60 | // walk (including an unowned slot) keeps the original values byte-identically. |
| 61 | // |
| 62 | // Ephemerality is the cache contract: context.prepare and provider.request |
| 63 | // replacements shape only the request being assembled — a.session.Messages is |
| 64 | // never mutated — while a provider.response replacement is persisted as the |
| 65 | // visible assistant turn (that IS the user's transcript), and tool.after |
| 66 | // replacements become the tool result the model reads. |
| 67 | // |
| 68 | // Observer model: after every completed intercept walk (blocked or not) the |
| 69 | // agent fires the point's fire-and-forget Event with the final payload, so |
| 70 | // observation-only extensions see exactly what the host acted on. A |
| 71 | // required-extension failure skips the event — the operation itself failed. |
| 72 | |
| 73 | // extensionBlockedError reports an extension's block ruling as an operation |
| 74 | // failure. The reason is already credential-redacted by the dispatcher. |
| 75 | func extensionBlockedError(point extension.InterceptorPoint, reason string) error { |
| 76 | reason = strings.TrimSpace(reason) |
| 77 | if reason == "" { |
| 78 | reason = "no reason given" |
| 79 | } |
| 80 | return fmt.Errorf("extension blocked %s: %s", point, reason) |
| 81 | } |
| 82 | |
| 83 | // extensionBlockReason normalizes a block reason for tool-result surfaces. |
| 84 | func extensionBlockReason(reason string) string { |
| 85 | reason = strings.TrimSpace(reason) |
| 86 | if reason == "" { |
| 87 | return "blocked by extension" |
| 88 | } |
| 89 | return reason |
| 90 | } |
| 91 | |
| 92 | // strategyReplaced runs the replacement-slot owner's strategy for the point |
| 93 | // and reports whether a replace ruling actually changed the payload (the |
| 94 | // adoption signal for the caller's converted values). An unowned slot no-ops |
| 95 | // inside RunStrategy, so the fast path costs one comparison. The owner is |
| 96 | // required-class: a block, timeout, error, or contract violation is returned |
| 97 | // as a fatal error for the local operation. |
| 98 | func strategyReplaced(ctx context.Context, d *dispatch.Dispatcher, slot extension.Slot, point extension.InterceptorPoint, payloadPtr any) (bool, error) { |
| 99 | before := reflect.ValueOf(payloadPtr).Elem().Interface() |
| 100 | if err := d.RunStrategy(ctx, slot, point, payloadPtr); err != nil { |
| 101 | return false, err |
| 102 | } |
| 103 | return !reflect.DeepEqual(before, reflect.ValueOf(payloadPtr).Elem().Interface()), nil |
| 104 | } |
| 105 | |
| 106 | // interceptAgentStart runs agent.before_start at the top of Run. A block (or |
| 107 | // a required extension's failure) aborts the run before the user turn is |
| 108 | // appended; the error surfaces like a normal run error. |
| 109 | func (a *Agent) interceptAgentStart(ctx context.Context) error { |
| 110 | d := a.extensions |
| 111 | if d == nil { |
| 112 | return nil |
| 113 | } |
| 114 | payload := dispatch.AgentStartPayload{ |
| 115 | Model: a.prov.Name(), |
| 116 | ToolCount: len(a.tools.Schemas()), |
| 117 | SessionID: ParentSession(ctx), |
| 118 | } |
| 119 | result, err := d.Intercept(ctx, extension.PointAgentBeforeStart, &payload) |
| 120 | if err != nil { |
| 121 | return err |
| 122 | } |
| 123 | d.Event(extension.PointAgentBeforeStart, payload) |
| 124 | if result.Blocked { |
| 125 | return extensionBlockedError(extension.PointAgentBeforeStart, result.BlockReason) |
| 126 | } |
| 127 | return nil |
| 128 | } |
| 129 | |
| 130 | // interceptContextPrepare runs context.prepare on the request message copy. |
| 131 | // The returned slice feeds only this provider request: the session log is |
| 132 | // never touched, so a replacement is invisible to the next turn (and to the |
| 133 | // prompt-cache prefix) — ephemerality is the cache contract. |
| 134 | func (a *Agent) interceptContextPrepare(ctx context.Context, messages []provider.Message) ([]provider.Message, error) { |
| 135 | d := a.extensions |
| 136 | if d == nil { |
| 137 | return messages, nil |
| 138 | } |
| 139 | payload := dispatch.ContextPayload{Messages: providerconv.MessagesToProtocol(messages)} |
| 140 | result, err := d.Intercept(ctx, extension.PointContextPrepare, &payload) |
| 141 | if err != nil { |
| 142 | return nil, err |
| 143 | } |
| 144 | if result.Blocked { |
| 145 | d.Event(extension.PointContextPrepare, payload) |
| 146 | return nil, extensionBlockedError(extension.PointContextPrepare, result.BlockReason) |
| 147 | } |
| 148 | // The context slot owner gets the final say over the chain-walked payload. |
| 149 | replaced, err := strategyReplaced(ctx, d, extension.SlotContext, extension.PointContextPrepare, &payload) |
| 150 | if err != nil { |
| 151 | return nil, err |
| 152 | } |
| 153 | d.Event(extension.PointContextPrepare, payload) |
| 154 | if len(result.Applied) > 0 || replaced { |
| 155 | return providerconv.MessagesFromProtocol(payload.Messages), nil |
| 156 | } |
| 157 | return messages, nil |
| 158 | } |
| 159 | |
| 160 | // interceptProviderRequest runs provider.request on the fully assembled |
| 161 | // request (post CreatedAt-strip). A replacement is revalidated by the payload |
| 162 | // registry (tool parameter schemas must be JSON objects, messages/tools must |
| 163 | // be arrays) before it may substitute the request being sent. |
| 164 | func (a *Agent) interceptProviderRequest(ctx context.Context, req provider.Request) (provider.Request, error) { |
| 165 | d := a.extensions |
| 166 | if d == nil { |
| 167 | return req, nil |
| 168 | } |
| 169 | payload := dispatch.ProviderRequestPayload{Request: providerconv.RequestToProtocol(req)} |
| 170 | result, err := d.Intercept(ctx, extension.PointProviderRequest, &payload) |
| 171 | if err != nil { |
| 172 | return provider.Request{}, err |
| 173 | } |
| 174 | if result.Blocked { |
| 175 | d.Event(extension.PointProviderRequest, payload) |
| 176 | return provider.Request{}, extensionBlockedError(extension.PointProviderRequest, result.BlockReason) |
| 177 | } |
| 178 | // The provider_request slot owner gets the final say over the |
| 179 | // chain-walked payload. |
| 180 | replaced, err := strategyReplaced(ctx, d, extension.SlotProviderRequest, extension.PointProviderRequest, &payload) |
| 181 | if err != nil { |
| 182 | return provider.Request{}, err |
| 183 | } |
| 184 | d.Event(extension.PointProviderRequest, payload) |
| 185 | if len(result.Applied) > 0 || replaced { |
| 186 | return providerconv.RequestFromProtocol(payload.Request), nil |
| 187 | } |
| 188 | return req, nil |
| 189 | } |
| 190 | |
| 191 | // interceptProviderResponse runs provider.response after the stream completed |
| 192 | // successfully, before the assistant turn is persisted. A replacement is |
| 193 | // persisted as the visible turn — the user's transcript and the model's own |
| 194 | // history on the next request. The live text/reasoning deltas already |
| 195 | // streamed to the frontend are not retroactively changed; the closing Message |
| 196 | // event and the session carry the replaced values. Session-level cache |
| 197 | // counters keep the provider's real usage (they were accumulated while |
| 198 | // streaming); a replaced Usage drives only this turn's Usage event and |
| 199 | // compaction decision. A block fails the turn with the redacted reason. |
| 200 | func (a *Agent) interceptProviderResponse(ctx context.Context, text, reasoning, signature string, calls []provider.ToolCall, usage *provider.Usage) (string, string, string, []provider.ToolCall, *provider.Usage, error) { |
| 201 | d := a.extensions |
| 202 | if d == nil { |
| 203 | return text, reasoning, signature, calls, usage, nil |
| 204 | } |
| 205 | payload := dispatch.ProviderResponsePayload{ |
| 206 | Text: text, |
| 207 | Reasoning: reasoning, |
| 208 | Signature: signature, |
| 209 | Calls: providerconv.ToolCallsToProtocol(calls), |
| 210 | Usage: providerconv.UsageToProtocol(usage), |
| 211 | } |
| 212 | result, err := d.Intercept(ctx, extension.PointProviderResponse, &payload) |
| 213 | if err != nil { |
| 214 | return "", "", "", nil, nil, err |
| 215 | } |
| 216 | if result.Blocked { |
| 217 | d.Event(extension.PointProviderResponse, payload) |
| 218 | return "", "", "", nil, nil, extensionBlockedError(extension.PointProviderResponse, result.BlockReason) |
| 219 | } |
| 220 | // The provider_response slot owner gets the final say over the |
| 221 | // chain-walked payload. |
| 222 | replaced, err := strategyReplaced(ctx, d, extension.SlotProviderResponse, extension.PointProviderResponse, &payload) |
| 223 | if err != nil { |
| 224 | return "", "", "", nil, nil, err |
| 225 | } |
| 226 | d.Event(extension.PointProviderResponse, payload) |
| 227 | if len(result.Applied) > 0 || replaced { |
| 228 | return payload.Text, payload.Reasoning, payload.Signature, |
| 229 | providerconv.ToolCallsFromProtocol(payload.Calls), providerconv.UsageFromProtocol(payload.Usage), nil |
| 230 | } |
| 231 | return text, reasoning, signature, calls, usage, nil |
| 232 | } |
| 233 | |
| 234 | // interceptToolBefore runs tool.before right after the call parsed. A block |
| 235 | // fails the call with the reason as the tool-result error (mirroring a |
| 236 | // PreToolUse hook block). A replacement substitutes the provider-visible name |
| 237 | // and arguments, but only after host revalidation — the arguments must decode |
| 238 | // as a JSON object and the name must still resolve in the registry — and the |
| 239 | // substituted call is then re-parsed so policy, permission, and evidence all |
| 240 | // see the call that will actually execute. An invalid replacement fails the |
| 241 | // call with a contract-violation error result. |
| 242 | func (a *Agent) interceptToolBefore(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) { |
| 243 | d := a.extensions |
| 244 | if d == nil { |
| 245 | return toolOutcome{}, false |
| 246 | } |
| 247 | payload := dispatch.ToolBeforePayload{Name: plan.call.Name, Arguments: plan.call.Arguments} |
| 248 | result, err := d.Intercept(ctx, extension.PointToolBefore, &payload) |
| 249 | if err != nil { |
| 250 | msg := fmt.Sprintf("error: %v", err) |
| 251 | return toolOutcome{output: msg, errMsg: firstLine(err.Error())}, true |
| 252 | } |
| 253 | d.Event(extension.PointToolBefore, payload) |
| 254 | if result.Blocked { |
| 255 | reason := extensionBlockReason(result.BlockReason) |
| 256 | return toolOutcome{output: "blocked: " + reason, blocked: true, errMsg: "blocked by extension"}, true |
| 257 | } |
| 258 | if len(result.Applied) == 0 { |
| 259 | return toolOutcome{}, false |
| 260 | } |
| 261 | plugin := result.Applied[len(result.Applied)-1] |
| 262 | violation := func(detail string) (toolOutcome, bool) { |
| 263 | msg := fmt.Sprintf("extension %s violated the intercept contract at %s: %s", plugin, extension.PointToolBefore, detail) |
| 264 | return toolOutcome{output: "error: " + msg, errMsg: msg}, true |
| 265 | } |
| 266 | trimmed := strings.TrimSpace(payload.Arguments) |
| 267 | if trimmed == "" || trimmed[0] != '{' { |
| 268 | return violation("arguments must decode as a JSON object") |
| 269 | } |
| 270 | t, _, ambiguous := a.tools.ResolveCall(payload.Name) |
| 271 | if t == nil || len(ambiguous) > 0 { |
| 272 | return violation(fmt.Sprintf("substituted tool name %q does not resolve in the registry", payload.Name)) |
| 273 | } |
| 274 | plan.call.Name = payload.Name |
| 275 | plan.call.Arguments = payload.Arguments |
| 276 | if blocked, early := a.parseToolCall(plan); early { |
| 277 | return blocked, true |
| 278 | } |
| 279 | return toolOutcome{}, false |
| 280 | } |
| 281 | |
| 282 | // interceptExtensionPermission runs permission.decision at the gate point. |
| 283 | // The host decision is computed first and rides the payload; the extension |
| 284 | // ruling combines with it: allow overrides a host deny (the full-trust |
| 285 | // contract — the dispatcher records the audit note, surfaced here as a |
| 286 | // warning notice), deny or block overrides a host allow, continue leaves the |
| 287 | // host decision standing. allow is updated in place; early=true carries the |
| 288 | // blocked outcome. |
| 289 | func (a *Agent) interceptExtensionPermission(ctx context.Context, plan *toolCallPlan, allow *bool) (toolOutcome, bool) { |
| 290 | d := a.extensions |
| 291 | if d == nil { |
| 292 | return toolOutcome{}, false |
| 293 | } |
| 294 | hostDecision := "deny" |
| 295 | if *allow { |
| 296 | hostDecision = "allow" |
| 297 | } |
| 298 | payload := dispatch.PermissionPayload{ |
| 299 | Name: plan.permName, |
| 300 | Arguments: string(plan.permArgs), |
| 301 | ReadOnly: plan.readOnly, |
| 302 | HostDecision: hostDecision, |
| 303 | } |
| 304 | result, err := d.Intercept(ctx, extension.PointPermissionDecision, &payload) |
| 305 | if err != nil { |
| 306 | return toolOutcome{ |
| 307 | output: fmt.Sprintf("blocked: %v", err), |
| 308 | blocked: true, |
| 309 | errMsg: "blocked by extension permission policy", |
| 310 | }, true |
| 311 | } |
| 312 | // The permission slot owner gets the final say after the chain walk. Its |
| 313 | // effective rulings here are continue (the chain/host combination stands) |
| 314 | // and block (veto); a replace adjusts only the payload observers see — |
| 315 | // allow/deny remains the chain's terminal mechanism. |
| 316 | if !result.Blocked { |
| 317 | if serr := d.RunStrategy(ctx, extension.SlotPermission, extension.PointPermissionDecision, &payload); serr != nil { |
| 318 | reason := serr.Error() |
| 319 | var blockErr *dispatch.BlockError |
| 320 | if errors.As(serr, &blockErr) { |
| 321 | reason = extensionBlockReason(blockErr.Reason) |
| 322 | } |
| 323 | return toolOutcome{ |
| 324 | output: "blocked: " + reason, |
| 325 | blocked: true, |
| 326 | errMsg: "blocked by extension permission policy", |
| 327 | }, true |
| 328 | } |
| 329 | } |
| 330 | d.Event(extension.PointPermissionDecision, payload) |
| 331 | for _, note := range result.Audit { |
| 332 | a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: note}) |
| 333 | } |
| 334 | switch { |
| 335 | case result.Blocked: |
| 336 | reason := extensionBlockReason(result.BlockReason) |
| 337 | return toolOutcome{ |
| 338 | output: "blocked: " + reason, |
| 339 | blocked: true, |
| 340 | errMsg: "blocked by extension permission policy", |
| 341 | }, true |
| 342 | case result.Permission != nil && !*result.Permission: |
| 343 | return toolOutcome{ |
| 344 | output: "blocked: denied by extension permission policy", |
| 345 | blocked: true, |
| 346 | errMsg: "blocked by extension permission policy", |
| 347 | }, true |
| 348 | case result.Permission != nil && *result.Permission: |
| 349 | *allow = true |
| 350 | } |
| 351 | return toolOutcome{}, false |
| 352 | } |
| 353 | |
| 354 | // interceptToolAfter runs tool.after on the executed result. A replacement |
| 355 | // substitutes the visible result string and the error flag — clearing IsError |
| 356 | // converts a failed call into a success with the replaced text, setting it |
| 357 | // converts a success into an error result carrying the replaced text. A block |
| 358 | // (or a required extension's failure) converts the call to an error tool |
| 359 | // result with the reason; the tool itself already ran. |
| 360 | func (a *Agent) interceptToolAfter(ctx context.Context, call provider.ToolCall, result string, err error) (string, error) { |
| 361 | d := a.extensions |
| 362 | if d == nil { |
| 363 | return result, err |
| 364 | } |
| 365 | payload := dispatch.ToolAfterPayload{ |
| 366 | Name: call.Name, |
| 367 | Arguments: call.Arguments, |
| 368 | Result: result, |
| 369 | IsError: err != nil, |
| 370 | } |
| 371 | res, ierr := d.Intercept(ctx, extension.PointToolAfter, &payload) |
| 372 | if ierr != nil { |
| 373 | return "", ierr |
| 374 | } |
| 375 | d.Event(extension.PointToolAfter, payload) |
| 376 | if res.Blocked { |
| 377 | return "", errors.New(extensionBlockReason(res.BlockReason)) |
| 378 | } |
| 379 | if len(res.Applied) > 0 { |
| 380 | result = payload.Result |
| 381 | switch { |
| 382 | case payload.IsError && err == nil: |
| 383 | err = errors.New("extension replaced this tool result with an error") |
| 384 | case !payload.IsError: |
| 385 | err = nil |
| 386 | } |
| 387 | } |
| 388 | return result, err |
| 389 | } |
| 390 | |
| 391 | // interceptCompactionPrepare runs compaction.prepare before the fold is |
| 392 | // archived and summarized, colocated with the PreCompact hook so the payload's |
| 393 | // Guidance is the hook-contributed guidance (plus any /compact focus text). A |
| 394 | // replacement's messages and guidance drive only this compaction pass; a |
| 395 | // block skips the pass with the reason surfaced through the caller's notice. |
| 396 | func (a *Agent) interceptCompactionPrepare(ctx context.Context, fold []provider.Message, guidance string) ([]provider.Message, string, error) { |
| 397 | d := a.extensions |
| 398 | if d == nil { |
| 399 | return fold, guidance, nil |
| 400 | } |
| 401 | payload := dispatch.CompactionPreparePayload{ |
| 402 | Messages: providerconv.MessagesToProtocol(fold), |
| 403 | Guidance: guidance, |
| 404 | } |
| 405 | result, err := d.Intercept(ctx, extension.PointCompactionPrepare, &payload) |
| 406 | if err != nil { |
| 407 | return nil, "", err |
| 408 | } |
| 409 | if result.Blocked { |
| 410 | d.Event(extension.PointCompactionPrepare, payload) |
| 411 | return nil, "", extensionBlockedError(extension.PointCompactionPrepare, result.BlockReason) |
| 412 | } |
| 413 | // The compaction slot owner gets the final say over the chain-walked fold |
| 414 | // and guidance. |
| 415 | replaced, err := strategyReplaced(ctx, d, extension.SlotCompaction, extension.PointCompactionPrepare, &payload) |
| 416 | if err != nil { |
| 417 | return nil, "", err |
| 418 | } |
| 419 | d.Event(extension.PointCompactionPrepare, payload) |
| 420 | if len(result.Applied) > 0 || replaced { |
| 421 | return providerconv.MessagesFromProtocol(payload.Messages), payload.Guidance, nil |
| 422 | } |
| 423 | return fold, guidance, nil |
| 424 | } |
| 425 | |
| 426 | // interceptCompactionComplete runs compaction.complete after the summary is |
| 427 | // produced (including the mechanical-fold fallback), before it is written |
| 428 | // into the session. A replacement is persisted as the summary; a block skips |
| 429 | // the pass. |
| 430 | func (a *Agent) interceptCompactionComplete(ctx context.Context, summary string) (string, error) { |
| 431 | d := a.extensions |
| 432 | if d == nil { |
| 433 | return summary, nil |
| 434 | } |
| 435 | payload := dispatch.CompactionCompletePayload{Summary: summary} |
| 436 | result, err := d.Intercept(ctx, extension.PointCompactionComplete, &payload) |
| 437 | if err != nil { |
| 438 | return "", err |
| 439 | } |
| 440 | if result.Blocked { |
| 441 | d.Event(extension.PointCompactionComplete, payload) |
| 442 | return "", extensionBlockedError(extension.PointCompactionComplete, result.BlockReason) |
| 443 | } |
| 444 | // The compaction slot owner gets the final say over the chain-walked |
| 445 | // summary. |
| 446 | replaced, err := strategyReplaced(ctx, d, extension.SlotCompaction, extension.PointCompactionComplete, &payload) |
| 447 | if err != nil { |
| 448 | return "", err |
| 449 | } |
| 450 | d.Event(extension.PointCompactionComplete, payload) |
| 451 | if len(result.Applied) > 0 || replaced { |
| 452 | return payload.Summary, nil |
| 453 | } |
| 454 | return summary, nil |
| 455 | } |
| 456 |