返回 DeepSeek-Reasonix
extensions.go
根目录 / internal / control / extensions.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "log/slog"
7 "sync"
8
9 "reasonix/internal/event"
10 "reasonix/internal/eventwire"
11 "reasonix/internal/extension"
12 "reasonix/internal/extension/dispatch"
13 )
14
15 // Extension dispatch wiring (Extension Protocol v1, stage 6b1). The
16 // dispatcher is the frozen, immutable view of the installed extensions for
17 // one controller generation; every wiring point here is nil-safe: with no
18 // dispatcher installed (no v1 runtime packages, or a degraded snapshot) each
19 // helper returns immediately and the controller behaves byte-identically to
20 // the pre-dispatch path.
21 //
22 // Session payload surface: SessionPayload carries only the session transcript
23 // path and the phase. Transcript path and metadata decisions stay host-side —
24 // a session_policy owner's replace ruling adjusts the payload observing
25 // extensions receive, never which file the host writes, loads, or rotates to.
26
27 // extensionSessionEvent broadcasts one session.* point to observing
28 // extensions, fire-and-forget. session.start and session.end are
29 // observation-only: no strategy runs at them.
30 func (c *Controller) extensionSessionEvent(point extension.InterceptorPoint, phase, path string) {
31 c.extensionSessionPayloadEvent(point, dispatch.SessionPayload{SessionPath: path, Phase: phase})
32 }
33
34 // interceptInputReceive runs one composed input text through the extension
35 // chain at input.receive before it enters the session. The returned text is
36 // what the turn must use (a replace ruling rewrites it); blocked reports an
37 // extension's block ruling, with the redacted reason already surfaced as a
38 // user-visible notice by this helper. A required-class extension failure is
39 // returned as the error. With no dispatcher installed the input passes
40 // through untouched.
41 func (c *Controller) interceptInputReceive(ctx context.Context, input string) (text string, blocked bool, err error) {
42 if c.extensions == nil {
43 return input, false, nil
44 }
45 payload := dispatch.InputPayload{Text: input}
46 result, err := c.extensions.Intercept(ctx, extension.PointInputReceive, &payload)
47 if err != nil {
48 return input, false, err
49 }
50 if result.Blocked {
51 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Turn blocked by an extension.", Detail: result.BlockReason})
52 return input, true, nil
53 }
54 return payload.Text, false, nil
55 }
56
57 // extensionSessionPayloadEvent broadcasts one session.* point with an
58 // already-settled (possibly owner-adjusted) payload.
59 func (c *Controller) extensionSessionPayloadEvent(point extension.InterceptorPoint, payload dispatch.SessionPayload) {
60 if c.extensions == nil {
61 return
62 }
63 c.extensions.Event(point, payload)
64 }
65
66 // extensionSessionStrategy runs only the strategy half of a session.* point
67 // and returns the final payload for a later event broadcast. Callers that
68 // must separate the ruling from the observation (session.save rules on the
69 // impending write but observes the completed one) use this half directly.
70 func (c *Controller) extensionSessionStrategy(ctx context.Context, point extension.InterceptorPoint, phase, path string) (dispatch.SessionPayload, error) {
71 payload := dispatch.SessionPayload{SessionPath: path, Phase: phase}
72 if c.extensions == nil {
73 return payload, nil
74 }
75 if _, owned := c.extensions.Strategy(extension.SlotSessionPolicy); owned {
76 if err := c.extensions.RunStrategy(ctx, extension.SlotSessionPolicy, point, &payload); err != nil {
77 return payload, err
78 }
79 }
80 return payload, nil
81 }
82
83 // extensionSessionPhase runs the session_policy strategy at one session.*
84 // point (load/save/rotate) when the slot has an owner, then broadcasts the
85 // event with the final (possibly owner-adjusted) payload. The strategy error
86 // is returned to the caller: at session.save and session.rotate it is fatal
87 // to the operation (the owner is required-class by definition); at
88 // session.load the caller degrades to a warning because Controller.Resume has
89 // no failure channel this stage.
90 func (c *Controller) extensionSessionPhase(ctx context.Context, point extension.InterceptorPoint, phase, path string) error {
91 if c.extensions == nil {
92 return nil
93 }
94 payload, err := c.extensionSessionStrategy(ctx, point, phase, path)
95 if err != nil {
96 return err
97 }
98 c.extensionSessionPayloadEvent(point, payload)
99 return nil
100 }
101
102 // extensionWarn surfaces a required-class extension failure to the user and
103 // the log. It goes through the ordinary sink (the failure itself is a
104 // frontend event like any other).
105 func (c *Controller) extensionWarn(what string, err error) {
106 slog.Warn("controller: extension "+what, "err", err)
107 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "Extension " + what + ": " + err.Error()})
108 }
109
110 // frontendEventSink wraps the controller's sink when a dispatcher is
111 // installed: every event is observed at frontend.event (fire-and-forget)
112 // and, when the frontend_events slot has an owner, ruled on first — a
113 // replacement may rewrite Text/Detail but never the Kind.
114 type frontendEventSink struct {
115 inner event.Sink
116 d *dispatch.Dispatcher
117
118 warnMu sync.Mutex
119 warned map[string]bool
120 }
121
122 func newFrontendEventSink(inner event.Sink, d *dispatch.Dispatcher) *frontendEventSink {
123 return &frontendEventSink{inner: inner, d: d, warned: map[string]bool{}}
124 }
125
126 // Emit observes and rules on one controller event before forwarding it. The
127 // strategy runs synchronously (the frontend_events owner is required-class):
128 // an explicit block ruling suppresses the event, but an owner malfunction
129 // (timeout, crash, contract violation) emits the original event with a
130 // warning — dropping ApprovalRequest/TurnDone-class events would hang the
131 // frontend's state machine, so only an affirmative block ruling may.
132 func (s *frontendEventSink) Emit(ev event.Event) {
133 name, ok := eventwire.KindName(ev.Kind)
134 if !ok {
135 s.inner.Emit(ev)
136 return
137 }
138 payload := dispatch.FrontendEventPayload{Kind: name, Text: ev.Text, Detail: ev.Detail}
139 if _, owned := s.d.Strategy(extension.SlotFrontendEvents); owned {
140 before := payload
141 if err := s.d.RunStrategy(context.Background(), extension.SlotFrontendEvents, extension.PointFrontendEvent, &payload); err != nil {
142 var blockErr *dispatch.BlockError
143 if errors.As(err, &blockErr) {
144 s.warnOnce("block|"+blockErr.Plugin, "extension frontend event suppressed: "+blockErr.Error())
145 return
146 }
147 s.warnOnce("failure|"+extensionFailurePlugin(err), "extension frontend event strategy failed; emitting the original event: "+err.Error())
148 payload = before
149 } else if payload.Kind != before.Kind {
150 s.warnOnce("kind", "extension frontend event strategy tried to change the event kind; emitting the original event")
151 payload = before
152 }
153 }
154 // Observers see exactly what the frontend is about to receive.
155 s.d.Event(extension.PointFrontendEvent, payload)
156 ev.Text, ev.Detail = payload.Text, payload.Detail
157 s.inner.Emit(ev)
158 }
159
160 // warnOnce logs msg at most once per key for the life of the sink. Warnings
161 // stay on the log: routing them back through the sink would re-enter the
162 // strategy path they describe.
163 func (s *frontendEventSink) warnOnce(key, msg string) {
164 s.warnMu.Lock()
165 if s.warned[key] {
166 s.warnMu.Unlock()
167 return
168 }
169 s.warned[key] = true
170 s.warnMu.Unlock()
171 slog.Warn("controller: " + msg)
172 }
173
174 // extensionFailurePlugin extracts the plugin ID from a dispatch error for
175 // warn-once keying.
176 func extensionFailurePlugin(err error) string {
177 var failureErr *dispatch.FailureError
178 if errors.As(err, &failureErr) {
179 return failureErr.Plugin
180 }
181 var violationErr *dispatch.ViolationError
182 if errors.As(err, &violationErr) {
183 return violationErr.Plugin
184 }
185 return "unknown"
186 }
187
187 lines GO