返回 DeepSeek-Reasonix
extensions.go
根目录 / internal / agent / extensions.go
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
456 lines GO