package intentflow import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "chat-agent-system/internal/intent" ) // HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy (default). // shadow persists a validated_only plan without executing; auto_read executes // independent queries; confirm persists write/generate plans awaiting confirmation. // Elevated tiers require INTENT_PLAN_PROJECT_ALLOWLIST membership. func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) { if err := requirePrincipal(in.Principal); err != nil { return nil, err } return p.handleTurnElevated(ctx, in) } func (p *Plans) handleTurnElevated(ctx context.Context, in TurnInput) (*TurnResult, error) { flags := CurrentFeatureFlags() tier := flags.PlanTier if !tier.Allows(TierShadow) { return &TurnResult{}, nil } if !flags.ProjectAllowed(in.Principal.ProjectID) { Metrics().AllowlistDenied.Add(1) return &TurnResult{}, nil } text := strings.TrimSpace(in.Text) if text == "" { return &TurnResult{}, nil } digest := in.RequestDigest if digest == "" { digest = shortDigest(text) } rec := intent.NewRecognizer() res, err := rec.Recognize(ctx, text, nil) if err != nil || res == nil { recordCounterfactual(cfPassthrough) return &TurnResult{}, nil } var proposal *intent.PlanProposal // U9 live segment proposals require elevated tier ≥ auto_read (plan Track I3). if flags.U9Segment && !RulesCanDecide(text) && tier.Allows(TierAutoRead) { sp, propErr := ActiveSegmentProposer().Propose(ctx, SegmentProposalRequest{ Text: text, RequestDigest: digest, MaxSegments: 4, }) if propErr != nil { return &TurnResult{}, nil } if sp == nil || len(sp.Spans) < 2 || len(sp.Spans) > 4 { return &TurnResult{}, nil } if err := ValidateProposedSpans(text, sp.Spans); err != nil { return &TurnResult{}, nil } proposal = buildPlanFromLLMSpans(ctx, text, res, sp.Spans) if proposal == nil || len(proposal.Segments) < 2 { return &TurnResult{}, nil } } else { proposal = res.PlanProposal } if proposal == nil { recordCounterfactual(cfPassthrough) return &TurnResult{}, nil } route := counterfactualRoute(proposal) recordCounterfactual(route) shadowConfirm := tier == TierShadow && proposal.ComboHint == intent.ComboNeedsConfirm confirmWrites := proposal.ComboHint == intent.ComboNeedsConfirm && tier.Allows(TierConfirm) readOnly := proposal.ComboHint == intent.ComboReadOnly && (tier.Allows(TierAutoRead) || tier == TierShadow) if (!confirmWrites && !readOnly && !shadowConfirm) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 { return &TurnResult{}, nil } if shadowConfirm { confirmWrites = true readOnly = false } policy := DefaultBatchActionPolicy() drafts := make([]StepDraft, 0, len(proposal.Segments)) for i, seg := range proposal.Segments { key := "" if seg.Frame != nil { key = seg.Frame.ActionKey } if key == "" { return &TurnResult{}, nil } row, err := policy.Lookup(key) if err != nil || row.RejectPlan { return &TurnResult{}, nil } // Shadow ignores OpenFromTier so confirm-class keys can be observed. if tier != TierShadow && !tier.Allows(row.OpenFromTier) { return &TurnResult{}, nil } if row.RequiresBatchConfirm && !confirmWrites && tier != TierShadow { return &TurnResult{}, nil } summary := strings.TrimSpace(seg.Normalized) if summary == "" { summary = strings.TrimSpace(seg.Text) } mode := ModeLive if tier == TierShadow { mode = ModeValidatedOnly } slots := slotsJSONFromSegment(seg) expectedRev := "" if p != nil && p.bind != nil && mode == ModeLive { bound, bindErr := p.bind(ctx, ResourceVersionBindInput{ Principal: in.Principal, ActionKey: key, SafeSummary: summary, Slots: slots, }) if bindErr != nil { return &TurnResult{}, nil } expectedRev = bound } drafts = append(drafts, StepDraft{ ActionKey: key, ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key), SafeSummary: summary, ExecutionMode: mode, Slots: slots, ExpectedResourceVersion: expectedRev, }) } turnID := in.ClientTurnID if turnID == "" { turnID = "turn-" + shortDigest(text) } status := PlanAwaitingConfirmation mode := ModeLive writePlan := confirmWrites if tier == TierShadow { status = PlanProposed mode = ModeValidatedOnly writePlan = false // shadow never owns the active-write slot } created, err := p.CreatePlan(ctx, CreateCommand{ Principal: in.Principal, ClientTurnID: turnID, RequestDigest: digest, SegmenterVersion: proposal.SegmenterVersion, Status: status, ExecutionMode: mode, WritePlan: writePlan, SafeSummary: text, Steps: drafts, }) if err != nil { return nil, err } Metrics().PlansCreated.Add(1) if tier == TierShadow { Metrics().ShadowPlans.Add(1) return &TurnResult{Plan: created}, nil } if confirmWrites { Metrics().ConfirmPlans.Add(1) return &TurnResult{Plan: created}, nil } Metrics().AutoReadPlans.Add(1) confirmed, err := p.DecidePlan(ctx, DecisionCommand{ Principal: in.Principal, PlanID: created.PlanID, ExpectedRevision: created.Revision, DecisionNonce: "auto-read:" + created.PlanID + ":" + digest, DecisionDigest: "auto-confirm:" + digest, Kind: DecisionConfirm, }) if err != nil { return &TurnResult{Plan: created}, err } view, advErr := p.DispatchEligible(ctx, "auto-read", confirmed.PlanID) if view == nil { view = confirmed } return &TurnResult{Plan: view}, advErr } func counterfactualRoute(proposal *intent.PlanProposal) string { if proposal == nil { return cfPassthrough } switch proposal.ComboHint { case intent.ComboReadOnly: if len(proposal.Segments) >= 2 && len(proposal.Segments) <= 4 { return cfWouldExecute } return cfPassthrough case intent.ComboNeedsConfirm: return cfWouldConfirm case intent.ComboNeedsClarify: return cfWouldClarify case intent.ComboRejected, intent.ComboRejectedBatchSize: return cfWouldReject default: return cfPassthrough } } func slotsJSONFromSegment(seg intent.ProposedSegment) []byte { if seg.Frame == nil || len(seg.Frame.Slots) == 0 { return nil } out := make(map[string]string, len(seg.Frame.Slots)) for k, sv := range seg.Frame.Slots { v := strings.TrimSpace(sv.Value) if v != "" { out[k] = v } } if len(out) == 0 { return nil } b, err := json.Marshal(out) if err != nil { return nil } return b } func shortDigest(text string) string { sum := sha256.Sum256([]byte(text)) return hex.EncodeToString(sum[:8]) } // AdvanceEligible is retained for callers/tests; it delegates to the production // dispatcher so recovery and re-auth stay on one path. func (p *Plans) AdvanceEligible(ctx context.Context, workerID, planID string) (*PlanView, error) { return p.DispatchEligible(ctx, workerID, planID) }