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.
253 lines
6.9 KiB
Go
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)
|
|
}
|