240 lines
6.4 KiB
Go
240 lines
6.4 KiB
Go
// Package clarification provides types and services for agent clarification requests.
|
|
package clarification
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Sentinel errors for Respond.
|
|
var (
|
|
ErrNotFound = errors.New("clarification request not found")
|
|
ErrAlreadyResponded = errors.New("response already submitted")
|
|
)
|
|
|
|
// Store manages pending clarification requests.
|
|
// It provides thread-safe storage and notification when responses arrive.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
pending map[string]*PendingClarification
|
|
timeout time.Duration
|
|
|
|
// onWaitEntered, if non-nil, is invoked inside WaitForResponse after the
|
|
// initial pending lookup and before the select blocks. Tests use it to
|
|
// coordinate multi-waiter scenarios deterministically; always nil in
|
|
// production.
|
|
onWaitEntered func(pendingID string)
|
|
}
|
|
|
|
// NewStore creates a new clarification store.
|
|
func NewStore(timeout time.Duration) *Store {
|
|
if timeout == 0 {
|
|
timeout = 2 * time.Hour // Default timeout — long enough for user to respond to clarification
|
|
}
|
|
return &Store{
|
|
pending: make(map[string]*PendingClarification),
|
|
timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// CreateRequest creates a new clarification request and returns its pending ID
|
|
// plus a boolean indicating whether a new entry was created (true) or an
|
|
// existing one was reused (false). If a pending entry for the same session
|
|
// with identical normalised questions already exists, the existing pending ID
|
|
// is returned and isNew is false.
|
|
func (s *Store) CreateRequest(req *Request) (string, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Normalise in-place so dedup keys are stable even when the caller
|
|
// hasn't assigned IDs yet.
|
|
_ = NormalizeAndValidateQuestions(req.Questions)
|
|
|
|
// Deduplicate: if a pending entry for the same session with identical
|
|
// normalised questions already exists, return the existing pending ID.
|
|
for _, existing := range s.pending {
|
|
if existing.Request.SessionID == req.SessionID && questionsEqual(existing.Request.Questions, req.Questions) {
|
|
return existing.Request.PendingID, false
|
|
}
|
|
}
|
|
|
|
if req.PendingID == "" {
|
|
req.PendingID = uuid.New().String()
|
|
}
|
|
req.CreatedAt = time.Now()
|
|
|
|
s.pending[req.PendingID] = &PendingClarification{
|
|
Request: req,
|
|
done: make(chan struct{}),
|
|
CancelCh: make(chan struct{}),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
return req.PendingID, true
|
|
}
|
|
|
|
// GetRequest returns a pending clarification request by ID.
|
|
func (s *Store) GetRequest(pendingID string) (*Request, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
pending, ok := s.pending[pendingID]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return pending.Request, true
|
|
}
|
|
|
|
// WaitForResponse blocks until a response is received or the context is cancelled.
|
|
// Returns the response or an error if cancelled/timed out.
|
|
func (s *Store) WaitForResponse(ctx context.Context, pendingID string) (*Response, error) {
|
|
s.mu.RLock()
|
|
pending, ok := s.pending[pendingID]
|
|
hook := s.onWaitEntered
|
|
s.mu.RUnlock()
|
|
|
|
if hook != nil {
|
|
hook(pendingID)
|
|
}
|
|
|
|
if !ok {
|
|
return nil, fmt.Errorf("clarification request not found: %s", pendingID)
|
|
}
|
|
|
|
// Create timeout context
|
|
timeoutCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
|
defer cancel()
|
|
|
|
select {
|
|
case <-pending.done:
|
|
// Clean up after receiving response
|
|
s.mu.Lock()
|
|
delete(s.pending, pendingID)
|
|
s.mu.Unlock()
|
|
return pending.resp, nil
|
|
case <-pending.CancelCh:
|
|
// Agent's turn completed — cancel the blocking wait
|
|
s.mu.Lock()
|
|
delete(s.pending, pendingID)
|
|
s.mu.Unlock()
|
|
return nil, fmt.Errorf("clarification cancelled (agent moved on): %s", pendingID)
|
|
case <-timeoutCtx.Done():
|
|
if ctx.Err() != nil {
|
|
// Parent context cancelled — do not delete the shared entry
|
|
// because another waiter may still be blocked on it.
|
|
return nil, ctx.Err()
|
|
}
|
|
// Store-level timeout — safe to clean up.
|
|
s.mu.Lock()
|
|
delete(s.pending, pendingID)
|
|
s.mu.Unlock()
|
|
return nil, fmt.Errorf("clarification request timed out: %s", pendingID)
|
|
}
|
|
}
|
|
|
|
// Respond submits a response to a pending clarification request.
|
|
// Returns an error if the request is not found.
|
|
func (s *Store) Respond(pendingID string, resp *Response) error {
|
|
s.mu.RLock()
|
|
pending, ok := s.pending[pendingID]
|
|
s.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return fmt.Errorf("%w: %s", ErrNotFound, pendingID)
|
|
}
|
|
|
|
pending.mu.Lock()
|
|
defer pending.mu.Unlock()
|
|
|
|
if pending.resolved {
|
|
return fmt.Errorf("%w: %s", ErrAlreadyResponded, pendingID)
|
|
}
|
|
|
|
resp.PendingID = pendingID
|
|
resp.RespondedAt = time.Now()
|
|
pending.resp = resp
|
|
pending.resolved = true
|
|
close(pending.done)
|
|
return nil
|
|
}
|
|
|
|
// CancelRequest cancels a single pending clarification by id, unblocking any
|
|
// WaitForResponse caller that is currently parked on it. Returns true if the
|
|
// entry existed (and was removed), false otherwise. Used by callers that need
|
|
// to surface a creation-side failure immediately rather than wait for the
|
|
// 2-hour MCP timeout.
|
|
func (s *Store) CancelRequest(pendingID string) bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
pending, ok := s.pending[pendingID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
close(pending.CancelCh)
|
|
delete(s.pending, pendingID)
|
|
return true
|
|
}
|
|
|
|
// ListPending returns a snapshot of all pending clarification requests.
|
|
// The caller should not modify the returned requests.
|
|
func (s *Store) ListPending() []*Request {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
out := make([]*Request, 0, len(s.pending))
|
|
for _, p := range s.pending {
|
|
out = append(out, p.Request)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CancelSession cancels all pending clarification requests for a given session.
|
|
// It closes the CancelCh to unblock any WaitForResponse callers and removes entries.
|
|
// Returns the list of cancelled pending IDs.
|
|
func (s *Store) CancelSession(sessionID string) []string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
var cancelled []string
|
|
for id, pending := range s.pending {
|
|
if pending.Request.SessionID == sessionID {
|
|
close(pending.CancelCh)
|
|
delete(s.pending, id)
|
|
cancelled = append(cancelled, id)
|
|
}
|
|
}
|
|
return cancelled
|
|
}
|
|
|
|
func questionsEqual(a, b []Question) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].Prompt != b[i].Prompt {
|
|
return false
|
|
}
|
|
if !optionsEqual(a[i].Options, b[i].Options) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func optionsEqual(a, b []Option) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i].ID != b[i].ID || a[i].Label != b[i].Label || a[i].Description != b[i].Description {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|