返回 DeepSeek-Reasonix
prompt_identity.go
根目录 / internal / control / prompt_identity.go
1 package control
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "sort"
8 "sync"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 )
13
14 type PendingPromptOwner struct {
15 mu sync.Mutex
16 pending map[string]PendingPrompt
17 resolved map[string]PromptResolution
18 next uint64
19 revision uint64
20 }
21
22 type PendingPromptState string
23
24 const (
25 PromptPending PendingPromptState = "pending"
26 PromptResolving PendingPromptState = "resolving"
27 )
28
29 type PromptTerminalState string
30
31 const (
32 PromptAnswered PromptTerminalState = "answered"
33 PromptRejected PromptTerminalState = "rejected"
34 PromptCancelled PromptTerminalState = "cancelled"
35 PromptUnavailable PromptTerminalState = "unavailable"
36 )
37
38 type PromptResolution struct {
39 Identity PromptIdentity
40 State PromptTerminalState
41 AnswerDigest string
42 }
43
44 type PendingPrompt struct {
45 Identity PromptIdentity
46 State PendingPromptState
47 Order uint64
48 // AnswerDigest is filled only after the exact resolver wins the one-shot
49 // transition. It lets an identical retry succeed idempotently while a
50 // conflicting late answer is rejected.
51 AnswerDigest string
52 Done chan struct{}
53 Resolve func(PromptAnswer) error
54 Cancel func() error
55 }
56
57 func (o *PendingPromptOwner) RegisterPrompt(prompt PendingPrompt) error {
58 identity := prompt.Identity
59 if identity.PromptID == "" {
60 return ErrPromptNotPending
61 }
62 o.mu.Lock()
63 defer o.mu.Unlock()
64 if o.pending == nil {
65 o.pending = make(map[string]PendingPrompt)
66 }
67 if o.resolved == nil {
68 o.resolved = make(map[string]PromptResolution)
69 }
70 if _, exists := o.resolved[identity.PromptID]; exists {
71 return fmt.Errorf("prompt %q was already terminal", identity.PromptID)
72 }
73 if _, exists := o.pending[identity.PromptID]; exists {
74 return fmt.Errorf("prompt %q already registered", identity.PromptID)
75 }
76 if prompt.State == "" {
77 prompt.State = PromptPending
78 }
79 if prompt.Done == nil {
80 prompt.Done = make(chan struct{})
81 }
82 o.next++
83 prompt.Order = o.next
84 o.pending[identity.PromptID] = prompt
85 o.revision++
86 return nil
87 }
88
89 func (o *PendingPromptOwner) Register(identity PromptIdentity) error {
90 return o.RegisterPrompt(PendingPrompt{Identity: identity, State: PromptPending})
91 }
92 func (o *PendingPromptOwner) Identity(id string) (PromptIdentity, bool) {
93 o.mu.Lock()
94 defer o.mu.Unlock()
95 v, ok := o.pending[id]
96 return v.Identity, ok
97 }
98 func (o *PendingPromptOwner) Prompt(id string) (PendingPrompt, bool) {
99 o.mu.Lock()
100 defer o.mu.Unlock()
101 p, ok := o.pending[id]
102 return p, ok
103 }
104 func (o *PendingPromptOwner) Remove(id string) {
105 o.mu.Lock()
106 if prompt, ok := o.pending[id]; ok {
107 delete(o.pending, id)
108 close(prompt.Done)
109 o.revision++
110 }
111 o.mu.Unlock()
112 }
113 func (o *PendingPromptOwner) RemoveKind(kind PromptKind) {
114 o.mu.Lock()
115 defer o.mu.Unlock()
116 changed := false
117 for id, prompt := range o.pending {
118 if prompt.Identity.Kind == kind {
119 delete(o.pending, id)
120 close(prompt.Done)
121 changed = true
122 }
123 }
124 if changed {
125 o.revision++
126 }
127 }
128
129 func (o *PendingPromptOwner) MarkKindTerminal(kind PromptKind, state PromptTerminalState) {
130 o.mu.Lock()
131 defer o.mu.Unlock()
132 if o.resolved == nil {
133 o.resolved = make(map[string]PromptResolution)
134 }
135 changed := false
136 for id, prompt := range o.pending {
137 if prompt.Identity.Kind != kind {
138 continue
139 }
140 delete(o.pending, id)
141 close(prompt.Done)
142 changed = true
143 if _, exists := o.resolved[id]; !exists {
144 o.resolved[id] = PromptResolution{Identity: prompt.Identity, State: state}
145 }
146 }
147 if changed {
148 o.revision++
149 }
150 }
151 func (o *PendingPromptOwner) MarkResolved(identity PromptIdentity) {
152 o.MarkTerminal(identity, PromptAnswered)
153 }
154 func (o *PendingPromptOwner) MarkTerminal(identity PromptIdentity, state PromptTerminalState) {
155 o.mu.Lock()
156 defer o.mu.Unlock()
157 prompt, pending := o.pending[identity.PromptID]
158 if pending {
159 identity = normalizePromptIdentity(identity, prompt.Identity)
160 }
161 delete(o.pending, identity.PromptID)
162 if pending {
163 close(prompt.Done)
164 }
165 if o.resolved == nil {
166 o.resolved = make(map[string]PromptResolution)
167 }
168 if _, exists := o.resolved[identity.PromptID]; !exists {
169 o.resolved[identity.PromptID] = PromptResolution{Identity: identity, State: state, AnswerDigest: prompt.AnswerDigest}
170 pending = true
171 }
172 if pending {
173 o.revision++
174 }
175 }
176 func (o *PendingPromptOwner) BeginResolve(identity PromptIdentity) error {
177 o.mu.Lock()
178 defer o.mu.Unlock()
179 p, ok := o.pending[identity.PromptID]
180 if !ok {
181 if _, resolved := o.resolved[identity.PromptID]; resolved {
182 return ErrPromptAlreadyResolved
183 }
184 return ErrPromptNotPending
185 }
186 identity = normalizePromptIdentity(identity, p.Identity)
187 if p.Identity != identity {
188 return ErrPromptStaleTurn
189 }
190 if p.State == PromptResolving {
191 return ErrPromptAlreadyResolved
192 }
193 p.State = PromptResolving
194 if p.Done == nil {
195 p.Done = make(chan struct{})
196 }
197 o.pending[identity.PromptID] = p
198 o.revision++
199 return nil
200 }
201
202 // BindRouting fills routing fields that were not available when a prompt was
203 // queued behind another prompt. It never rewrites a captured identity: once a
204 // turn or runtime epoch is known, a later event must use that same value.
205 func (o *PendingPromptOwner) BindRouting(id, turnID, runtimeEpoch string) (PromptIdentity, bool) {
206 o.mu.Lock()
207 defer o.mu.Unlock()
208 prompt, ok := o.pending[id]
209 if !ok {
210 return PromptIdentity{}, false
211 }
212 changed := false
213 if prompt.Identity.TurnID == "" && turnID != "" {
214 prompt.Identity.TurnID = turnID
215 changed = true
216 }
217 if prompt.Identity.RuntimeEpoch == "" && runtimeEpoch != "" {
218 prompt.Identity.RuntimeEpoch = runtimeEpoch
219 changed = true
220 }
221 o.pending[id] = prompt
222 if changed {
223 o.revision++
224 }
225 return prompt.Identity, true
226 }
227 func (o *PendingPromptOwner) Resolve(identity PromptIdentity, answer PromptAnswer) error {
228 digest := promptAnswerDigest(answer)
229 wantState := promptAnswerTerminal(identity, answer)
230 for {
231 o.mu.Lock()
232 if resolution, ok := o.resolved[identity.PromptID]; ok {
233 identity = normalizePromptIdentity(identity, resolution.Identity)
234 o.mu.Unlock()
235 if resolution.Identity == identity && resolution.State == wantState && resolution.AnswerDigest == digest {
236 return nil
237 }
238 return ErrPromptAlreadyResolved
239 }
240 prompt, ok := o.pending[identity.PromptID]
241 if !ok {
242 o.mu.Unlock()
243 return ErrPromptNotPending
244 }
245 identity = normalizePromptIdentity(identity, prompt.Identity)
246 if prompt.Identity != identity {
247 o.mu.Unlock()
248 return ErrPromptStaleTurn
249 }
250 if prompt.State == PromptResolving {
251 if prompt.AnswerDigest != digest {
252 o.mu.Unlock()
253 return ErrPromptAlreadyResolved
254 }
255 done := prompt.Done
256 o.mu.Unlock()
257 <-done
258 continue
259 }
260 prompt.State = PromptResolving
261 prompt.AnswerDigest = digest
262 if prompt.Done == nil {
263 prompt.Done = make(chan struct{})
264 }
265 o.pending[identity.PromptID] = prompt
266 o.revision++
267 o.mu.Unlock()
268
269 if prompt.Resolve == nil {
270 // A published request without a live answerer is terminal. Leaving it
271 // pending would recreate the permanent-wait failure this registry owns.
272 o.MarkTerminal(identity, PromptUnavailable)
273 return ErrPromptUnavailable
274 }
275 if err := prompt.Resolve(answer); err != nil {
276 // The typed answerer failed after this registry awarded it the
277 // one-shot transition. It is no longer safe to advertise the request
278 // as answerable: terminalize it and detach typed cleanup so neither a
279 // dead connection nor a throwing adapter can create an infinite wait.
280 o.MarkTerminal(identity, PromptUnavailable)
281 if prompt.Cancel != nil {
282 go func(cancel func() error) { _ = cancel() }(prompt.Cancel)
283 }
284 return errors.Join(ErrPromptUnavailable, err)
285 }
286 o.MarkTerminal(identity, wantState)
287 resolution, ok := o.Resolution(identity.PromptID)
288 if !ok || resolution.State != wantState || resolution.AnswerDigest != digest {
289 return ErrPromptAlreadyResolved
290 }
291 return nil
292 }
293 }
294
295 // normalizePromptIdentity preserves compatibility with transports that were
296 // shipped before toolCallId became part of the shared interaction snapshot.
297 // The owner remains authoritative: an omitted field adopts the captured value,
298 // while a conflicting non-empty value still fails the exact identity check.
299 func normalizePromptIdentity(candidate, owned PromptIdentity) PromptIdentity {
300 if candidate.ToolCallID == "" {
301 candidate.ToolCallID = owned.ToolCallID
302 }
303 return candidate
304 }
305
306 func promptAnswerDigest(answer PromptAnswer) string {
307 b, _ := json.Marshal(answer)
308 return string(b)
309 }
310
311 func promptAnswerTerminal(identity PromptIdentity, answer PromptAnswer) PromptTerminalState {
312 if identity.Kind == PromptMCP && answer.Action == "cancel" {
313 return PromptCancelled
314 }
315 if (identity.Kind == PromptApproval && !answer.Allow) ||
316 (identity.Kind == PromptMCP && answer.Action == "decline") ||
317 (identity.Kind == PromptPlan && answer.Action != "start") ||
318 (identity.Kind == PromptRecovery && answer.Action != string(agent.RecoveryActionContinue)) {
319 return PromptRejected
320 }
321 return PromptAnswered
322 }
323
324 func (o *PendingPromptOwner) MarkIDTerminal(id string, state PromptTerminalState) {
325 if prompt, ok := o.Prompt(id); ok {
326 o.MarkTerminal(prompt.Identity, state)
327 }
328 }
329 func (o *PendingPromptOwner) WasResolved(id string) bool {
330 o.mu.Lock()
331 defer o.mu.Unlock()
332 _, ok := o.resolved[id]
333 return ok
334 }
335 func (o *PendingPromptOwner) Resolution(id string) (PromptResolution, bool) {
336 o.mu.Lock()
337 defer o.mu.Unlock()
338 resolution, ok := o.resolved[id]
339 return resolution, ok
340 }
341 func (o *PendingPromptOwner) Clear() {
342 o.mu.Lock()
343 for _, prompt := range o.pending {
344 close(prompt.Done)
345 }
346 o.pending = make(map[string]PendingPrompt)
347 o.resolved = make(map[string]PromptResolution)
348 o.revision++
349 o.mu.Unlock()
350 }
351 func (o *PendingPromptOwner) CancelAll() {
352 o.cancelMatching(func(PromptIdentity) bool { return true })
353 }
354
355 // CancelTurn cannot close prompts registered by a successor while an older
356 // Stop request was waiting on its asynchronous publication lane.
357 func (o *PendingPromptOwner) CancelTurn(turnID string) {
358 o.cancelMatching(func(identity PromptIdentity) bool { return identity.TurnID == turnID })
359 }
360
361 func (o *PendingPromptOwner) cancelMatching(matches func(PromptIdentity) bool) {
362 o.mu.Lock()
363 cancels := make([]func() error, 0, len(o.pending))
364 identities := make([]PromptIdentity, 0, len(o.pending))
365 prompts := make([]PendingPrompt, 0, len(o.pending))
366 for _, prompt := range o.pending {
367 if !matches(prompt.Identity) {
368 continue
369 }
370 prompts = append(prompts, prompt)
371 identities = append(identities, prompt.Identity)
372 if prompt.Cancel != nil {
373 cancels = append(cancels, prompt.Cancel)
374 }
375 }
376 for _, identity := range identities {
377 delete(o.pending, identity.PromptID)
378 }
379 if o.resolved == nil {
380 o.resolved = make(map[string]PromptResolution)
381 }
382 for _, identity := range identities {
383 if _, exists := o.resolved[identity.PromptID]; !exists {
384 o.resolved[identity.PromptID] = PromptResolution{Identity: identity, State: PromptCancelled}
385 }
386 }
387 for _, prompt := range prompts {
388 close(prompt.Done)
389 }
390 if len(identities) > 0 {
391 o.revision++
392 }
393 o.mu.Unlock()
394 for _, cancel := range cancels {
395 // A connector or legacy adapter may provide a cancellation callback that
396 // blocks. Registry state is already terminal, so cleanup runs detached and
397 // can never delay the session's Stop path.
398 go func(cancel func() error) { _ = cancel() }(cancel)
399 }
400 }
401 func (o *PendingPromptOwner) Identities() []PromptIdentity {
402 identities, _ := o.IdentitiesRevision()
403 return identities
404 }
405
406 // IdentitiesRevision returns one registry projection boundary. Runtime-state
407 // assembly verifies the revision after sampling its other owners so a prompt
408 // transition cannot be published with an older todo/turn snapshot.
409 func (o *PendingPromptOwner) IdentitiesRevision() ([]PromptIdentity, uint64) {
410 o.mu.Lock()
411 defer o.mu.Unlock()
412 type orderedIdentity struct {
413 identity PromptIdentity
414 order uint64
415 }
416 ordered := make([]orderedIdentity, 0, len(o.pending))
417 for _, prompt := range o.pending {
418 ordered = append(ordered, orderedIdentity{identity: prompt.Identity, order: prompt.Order})
419 }
420 sort.Slice(ordered, func(i, j int) bool { return ordered[i].order < ordered[j].order })
421 out := make([]PromptIdentity, len(ordered))
422 for i := range ordered {
423 out[i] = ordered[i].identity
424 }
425 return out, o.revision
426 }
427
428 func (o *PendingPromptOwner) Revision() uint64 {
429 o.mu.Lock()
430 defer o.mu.Unlock()
431 return o.revision
432 }
433
434 // PromptKind identifies the interactive surface that owns a pending decision.
435 type PromptKind string
436
437 const (
438 PromptAsk PromptKind = "ask"
439 PromptApproval PromptKind = "approval"
440 PromptPlan PromptKind = "plan"
441 PromptRecovery PromptKind = "recovery"
442 PromptMCP PromptKind = "mcp"
443 )
444
445 // PromptIdentity is the immutable identity captured when a decision card is
446 // emitted. Turn and runtime fences prevent a delayed UI action crossing a
447 // controller replacement.
448 type PromptIdentity struct {
449 PromptID string
450 ToolCallID string
451 TurnID string
452 RuntimeEpoch string
453 Kind PromptKind
454 }
455
456 func (c *Controller) promptIdentitySnapshot() (string, string) {
457 turnID, _, _, _ := c.turnEventRuntimeStatus()
458 c.promptEpochMu.RLock()
459 epoch := c.promptRuntimeEpoch
460 c.promptEpochMu.RUnlock()
461 return turnID, epoch
462 }
463
464 func (c *Controller) registerOwnedPrompt(id string, kind PromptKind) {
465 turn, epoch := c.promptIdentitySnapshot()
466 identity := PromptIdentity{PromptID: id, ToolCallID: id, TurnID: turn, RuntimeEpoch: epoch, Kind: kind}
467 resolve := func(answer PromptAnswer) error {
468 switch kind {
469 case PromptAsk:
470 return c.answerQuestionCheckedLocked(id, answer.Questions)
471 case PromptApproval:
472 return c.resolveApprovalLocked(id, answer.Allow, scopeFromApprove(answer.Allow, answer.Session, answer.Persist))
473 case PromptPlan:
474 return c.resolvePlanDecisionWithFeedbackLocked(id, PlanDecisionAction(answer.Action), answer.Feedback)
475 case PromptRecovery:
476 return c.resolveRecoveryLocked(id, agent.RecoveryAction(answer.Action), answer.Feedback)
477 case PromptMCP:
478 return c.answerMCPInteractionCheckedLocked(id, answer.Action, answer.Content)
479 default:
480 return ErrPromptNotPending
481 }
482 }
483 cancel := func() error {
484 switch kind {
485 case PromptAsk:
486 c.approval.cancelAsk(id)
487 case PromptApproval, PromptPlan, PromptRecovery:
488 c.approval.cancel(id)
489 case PromptMCP:
490 c.approval.cancelMCPInteraction(id)
491 }
492 return nil
493 }
494 _ = c.promptOwner.RegisterPrompt(PendingPrompt{Identity: identity, State: PromptPending, Resolve: resolve, Cancel: cancel})
495 }
496
497 // bindOwnedPromptRouting completes an identity immediately before its request
498 // event is emitted. This closes the startup window where the prompt is queued
499 // before the turn ledger has published its active turn or runtime epoch.
500 func (c *Controller) bindOwnedPromptRouting(id, turnID, runtimeEpoch string) PromptIdentity {
501 if c == nil {
502 return PromptIdentity{}
503 }
504 identity, _ := c.promptOwner.BindRouting(id, turnID, runtimeEpoch)
505 return identity
506 }
507
508 func (c *Controller) PendingPromptIdentities() []PromptIdentity { return c.promptOwner.Identities() }
509
510 func (c *Controller) cancelOwnedPrompt(id string) {
511 c.cancelOwnedPromptLocked(id)
512 }
513
514 func (c *Controller) cancelOwnedPromptLocked(id string) {
515 if prompt, ok := c.promptOwner.Prompt(id); ok && prompt.Cancel != nil {
516 _ = prompt.Cancel()
517 } else {
518 c.approval.cancel(id)
519 c.approval.cancelAsk(id)
520 c.approval.cancelMCPInteraction(id)
521 }
522 c.promptOwner.MarkIDTerminal(id, PromptCancelled)
523 }
524
525 var (
526 ErrPromptStaleTurn = errors.New("prompt belongs to a stale turn")
527 ErrPromptStaleRuntime = errors.New("prompt belongs to a stale runtime")
528 ErrPromptAlreadyResolved = errors.New("prompt is already resolved")
529 ErrPromptNotPending = errors.New("prompt is not pending")
530 ErrPromptUnavailable = errors.New("prompt answerer is unavailable")
531 )
532
533 // PromptAnswer is the transport-neutral union used by exact prompt resolve.
534 type PromptAnswer struct {
535 Questions []event.AskAnswer
536 Allow bool
537 Session bool
538 Persist bool
539 Action string
540 Feedback string
541 Content map[string]any
542 Generation uint64
543 PermissionRevision uint64
544 }
545
546 // ResolvePromptExact is the single controller-owned decision boundary. The
547 // specialized resolvers retain their validation and durable receipts. The
548 // owner claims the one-shot transition before invoking an answerer, without
549 // holding its registry lock or any controller-wide lock.
550 func (c *Controller) ResolvePromptExact(identity PromptIdentity, answer PromptAnswer) error {
551 defer c.refreshRuntimeState(event.Event{})
552 if c == nil {
553 return ErrPromptNotPending
554 }
555 if identity.PromptID == "" || identity.TurnID == "" {
556 return ErrPromptNotPending
557 }
558 c.mu.Lock()
559 closed := c.closed
560 c.mu.Unlock()
561 if closed {
562 return ErrPromptNotPending
563 }
564 c.promptEpochMu.RLock()
565 epoch := c.promptRuntimeEpoch
566 c.promptEpochMu.RUnlock()
567 if epoch != "" && (identity.RuntimeEpoch == "" || identity.RuntimeEpoch != epoch) {
568 return ErrPromptStaleRuntime
569 }
570 turnID, _, _, _ := c.turnEventRuntimeStatus()
571 if turnID != identity.TurnID {
572 return ErrPromptStaleTurn
573 }
574 owned, ok := c.promptOwner.Identity(identity.PromptID)
575 if !ok {
576 // Let the owner compare the terminal state and answer digest. An
577 // identical transport retry is idempotent; a different late answer is
578 // rejected as a conflict.
579 return c.promptOwner.Resolve(identity, answer)
580 }
581 identity = normalizePromptIdentity(identity, owned)
582 if owned.TurnID != identity.TurnID || owned.Kind != identity.Kind {
583 return ErrPromptStaleTurn
584 }
585 if owned.RuntimeEpoch != identity.RuntimeEpoch {
586 return ErrPromptStaleRuntime
587 }
588 if identity.Kind == PromptApproval {
589 if answer.Generation != 0 && answer.Generation != c.runtimeGeneration {
590 return ErrPromptStaleRuntime
591 }
592 if answer.PermissionRevision != 0 && answer.PermissionRevision != c.permissionRevision.Load() {
593 return ErrPromptStaleRuntime
594 }
595 }
596 return c.promptOwner.Resolve(identity, answer)
597 }
598
598 lines GO