Files
weichen dbae19e80d fix(intentflow): bind resource versions for U10/U11 at plan create
Mutate/docgov live Authorize requires ExpectedResourceVersion; populate it
(and frame slots) when HandleTurn drafts confirm-class steps so confirm
no longer fails closed with PLAN_ACTION_VERSION_CONFLICT.
2026-09-23 15:44:48 +00:00

253 lines
6.9 KiB
Go

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)
}