返回 DeepSeek-Reasonix
mcp_interaction.go
根目录 / internal / control / mcp_interaction.go
1 package control
2
3 import (
4 "context"
5 "fmt"
6 "strconv"
7
8 "reasonix/internal/event"
9 "reasonix/internal/mcpinteraction"
10 "reasonix/internal/provider"
11 )
12
13 // pendingMCPInteraction is one server-initiated elicitation awaiting the user's
14 // accept/decline/cancel.
15 type pendingMCPInteraction struct {
16 request event.MCPInteraction
17 reply chan mcpinteraction.Result
18 queued bool
19 }
20
21 // mcpInteractionState groups the elicitation bookkeeping so the mutex-guarded
22 // approvalManager ratchets by one field, not one per map.
23 type mcpInteractionState struct {
24 pending map[string]pendingMCPInteraction
25 resolutions map[string]*promptResolution
26 }
27
28 func (a *approvalManager) registerMCPInteraction(req mcpinteraction.Request) (string, chan mcpinteraction.Result) {
29 a.mu.Lock()
30 defer a.mu.Unlock()
31 if a.mcpInteractions.pending == nil {
32 a.mcpInteractions.pending = map[string]pendingMCPInteraction{}
33 a.mcpInteractions.resolutions = map[string]*promptResolution{}
34 }
35 a.nextID++
36 id := strconv.Itoa(a.nextID)
37 reply := make(chan mcpinteraction.Result, 1)
38 a.mcpInteractions.pending[id] = pendingMCPInteraction{
39 request: event.MCPInteraction{
40 ID: id, Server: req.Server, Mode: req.Mode, Message: req.Message,
41 RequestedSchema: append([]byte(nil), req.RequestedSchema...),
42 URL: req.URL, ElicitationID: req.ElicitationID,
43 },
44 reply: reply,
45 queued: true,
46 }
47 return id, reply
48 }
49
50 func (a *approvalManager) markMCPInteractionEmitted(id string) {
51 a.mu.Lock()
52 defer a.mu.Unlock()
53 if p, ok := a.mcpInteractions.pending[id]; ok {
54 p.queued = false
55 a.mcpInteractions.pending[id] = p
56 }
57 }
58
59 func (a *approvalManager) cancelMCPInteraction(id string) {
60 a.mu.Lock()
61 delete(a.mcpInteractions.pending, id)
62 if attempt := a.mcpInteractions.resolutions[id]; attempt != nil {
63 a.finishMCPInteractionResolutionLocked(id, attempt, context.Canceled)
64 }
65 a.mu.Unlock()
66 }
67
68 func (a *approvalManager) finishMCPInteractionResolutionLocked(id string, attempt *promptResolution, err error) {
69 if attempt == nil || a.mcpInteractions.resolutions[id] != attempt {
70 return
71 }
72 delete(a.mcpInteractions.resolutions, id)
73 attempt.err = err
74 close(attempt.done)
75 }
76
77 // resolveMCPInteractionAfter mirrors resolveAskAfter: the durable
78 // PromptAnswered transition is persisted before the waiter is released.
79 func (a *approvalManager) resolveMCPInteractionAfter(id string, persist func(pendingMCPInteraction) error) (pendingMCPInteraction, bool, error) {
80 a.mu.Lock()
81 p, ok := a.mcpInteractions.pending[id]
82 if !ok {
83 a.mu.Unlock()
84 return pendingMCPInteraction{}, false, nil
85 }
86 if inFlight := a.mcpInteractions.resolutions[id]; inFlight != nil {
87 a.mu.Unlock()
88 return pendingMCPInteraction{}, false, inFlight.wait()
89 }
90 attempt := newPromptResolution()
91 a.mcpInteractions.resolutions[id] = attempt
92 a.mu.Unlock()
93 if persist != nil {
94 if err := persist(p); err != nil {
95 a.mu.Lock()
96 a.finishMCPInteractionResolutionLocked(id, attempt, err)
97 a.mu.Unlock()
98 return pendingMCPInteraction{}, false, err
99 }
100 }
101 a.mu.Lock()
102 defer a.mu.Unlock()
103 current, ok := a.mcpInteractions.pending[id]
104 if !ok || a.mcpInteractions.resolutions[id] != attempt || current.reply != p.reply {
105 if a.mcpInteractions.resolutions[id] == attempt {
106 a.finishMCPInteractionResolutionLocked(id, attempt, context.Canceled)
107 }
108 return pendingMCPInteraction{}, false, attempt.err
109 }
110 delete(a.mcpInteractions.pending, id)
111 a.finishMCPInteractionResolutionLocked(id, attempt, nil)
112 return p, true, nil
113 }
114
115 // snapshotMCPInteractions copies emitted-but-unanswered elicitations for
116 // frontend replay; queued ones have never been shown and stay hidden.
117 func (a *approvalManager) snapshotMCPInteractions() []event.MCPInteraction {
118 a.mu.Lock()
119 defer a.mu.Unlock()
120 out := make([]event.MCPInteraction, 0, len(a.mcpInteractions.pending))
121 for id, p := range a.mcpInteractions.pending {
122 if p.queued {
123 continue
124 }
125 interaction := p.request
126 interaction.ID = id
127 out = append(out, interaction)
128 }
129 return out
130 }
131
132 // Interact implements mcpinteraction.Broker: it surfaces a server-initiated
133 // elicitation and blocks on that request's own cancellable reply channel. Form
134 // values and URL targets ride the resolve call only — nothing is logged here.
135 func (c *Controller) Interact(ctx context.Context, req mcpinteraction.Request) (mcpinteraction.Result, error) {
136 c.approval.promptEmitMu.Lock()
137 id, reply := c.approval.registerMCPInteraction(req)
138 c.registerOwnedPrompt(id, PromptMCP)
139 payload := event.MCPInteraction{
140 ID: id, Server: req.Server, Mode: req.Mode, Message: req.Message,
141 RequestedSchema: append([]byte(nil), req.RequestedSchema...),
142 URL: req.URL, ElicitationID: req.ElicitationID,
143 }
144 payload.TurnID, _, _, _ = c.turnEventRuntimeStatus()
145 _, runtimeEpoch := c.promptIdentitySnapshot()
146 if identity := c.bindOwnedPromptRouting(id, payload.TurnID, runtimeEpoch); identity.TurnID != "" {
147 payload.TurnID = identity.TurnID
148 }
149 if err := event.EmitChecked(c.sink, event.Event{Kind: event.MCPInteractionRequest, TurnID: payload.TurnID, ItemID: id, MCPInteraction: payload}); err != nil {
150 c.approval.promptEmitMu.Unlock()
151 c.cancelOwnedPrompt(id)
152 return mcpinteraction.Result{Action: mcpinteraction.ActionCancel}, fmt.Errorf("persist elicitation request: %w", err)
153 }
154 c.approval.markMCPInteractionEmitted(id)
155 c.approval.promptEmitMu.Unlock()
156
157 waitCtx, cancelWait := c.approval.waitContext(ctx)
158 defer cancelWait()
159
160 select {
161 case res := <-reply:
162 return res, nil
163 case <-waitCtx.Done():
164 c.cancelOwnedPrompt(id)
165 return mcpinteraction.Result{Action: mcpinteraction.ActionCancel}, waitCtx.Err()
166 }
167 }
168
169 // AnswerMCPInteraction resolves a pending elicitation with the user's action
170 // (accept/decline/cancel) and, for accept, the submitted form values.
171 func (c *Controller) AnswerMCPInteraction(id, action string, content map[string]any) {
172 _ = c.AnswerMCPInteractionChecked(id, action, content)
173 }
174
175 // AnswerMCPInteractionChecked persists the prompt transition before releasing
176 // the blocked MCP call, so a crashed frontend cannot lose an answered decision.
177 func (c *Controller) AnswerMCPInteractionChecked(id, action string, content map[string]any) error {
178 defer c.refreshRuntimeState(event.Event{})
179 return c.answerMCPInteractionCheckedLocked(id, action, content)
180 }
181
182 func (c *Controller) answerMCPInteractionCheckedLocked(id, action string, content map[string]any) error {
183 switch action {
184 case mcpinteraction.ActionAccept, mcpinteraction.ActionDecline, mcpinteraction.ActionCancel:
185 default:
186 return fmt.Errorf("invalid elicitation action %q", action)
187 }
188 if action != mcpinteraction.ActionAccept {
189 content = nil
190 }
191 pending, ok, err := c.approval.resolveMCPInteractionAfter(id, func(p pendingMCPInteraction) error {
192 state := PromptAnswered
193 switch action {
194 case mcpinteraction.ActionDecline:
195 state = PromptRejected
196 case mcpinteraction.ActionCancel:
197 state = PromptCancelled
198 }
199 return c.emitTurnEventChecked(event.Event{Kind: event.PromptAnswered, ItemID: id, InteractionState: string(state), Status: event.TurnInProgress})
200 })
201 if err != nil {
202 return err
203 }
204 if ok {
205 terminal := PromptAnswered
206 switch action {
207 case mcpinteraction.ActionDecline:
208 terminal = PromptRejected
209 case mcpinteraction.ActionCancel:
210 terminal = PromptCancelled
211 }
212 c.promptOwner.MarkIDTerminal(id, terminal)
213 c.recordMCPInteractionReceipt(id, pending, action)
214 pending.reply <- mcpinteraction.Result{Action: action, Content: content}
215 }
216 return nil
217 }
218
219 func (c *Controller) recordMCPInteractionReceipt(id string, pending pendingMCPInteraction, action string) {
220 if c == nil || c.executor == nil {
221 return
222 }
223 receipt := &provider.DecisionReceipt{
224 ID: id,
225 Kind: "mcp_elicitation",
226 Subject: clipUTF8(pending.request.Server+" elicitation ("+pending.request.Mode+")", 240),
227 Outcome: action,
228 }
229 c.executor.Session().AddDecisionReceipt(receipt)
230 }
231
231 lines GO