Replace FakeActionOwner with QueryOwner+QueryGuard at the composition root; DispatchEligible reuses claim/execute/recover so confirm stays zero-write before dispatch and default tier remains legacy.
114 lines
3.2 KiB
Go
114 lines
3.2 KiB
Go
package intentflow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"chat-agent-system/internal/intent"
|
|
)
|
|
|
|
func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResult, error) {
|
|
tier := CurrentFeatureFlags().PlanTier
|
|
if !tier.Allows(TierAutoRead) {
|
|
return &TurnResult{}, nil
|
|
}
|
|
text := strings.TrimSpace(in.Text)
|
|
if text == "" {
|
|
return &TurnResult{}, nil
|
|
}
|
|
rec := intent.NewRecognizer()
|
|
res, err := rec.Recognize(ctx, text, nil)
|
|
if err != nil || res == nil || res.PlanProposal == nil {
|
|
return &TurnResult{}, nil
|
|
}
|
|
proposal := res.PlanProposal
|
|
confirmWrites := proposal.ComboHint == intent.ComboNeedsConfirm && tier.Allows(TierConfirm)
|
|
readOnly := proposal.ComboHint == intent.ComboReadOnly && tier.Allows(TierAutoRead)
|
|
if (!confirmWrites && !readOnly) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 {
|
|
return &TurnResult{}, nil
|
|
}
|
|
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 || !tier.Allows(row.OpenFromTier) {
|
|
return &TurnResult{}, nil
|
|
}
|
|
if row.RequiresBatchConfirm && !confirmWrites {
|
|
return &TurnResult{}, nil
|
|
}
|
|
summary := strings.TrimSpace(seg.Normalized)
|
|
if summary == "" {
|
|
summary = strings.TrimSpace(seg.Text)
|
|
}
|
|
drafts = append(drafts, StepDraft{
|
|
ActionKey: key,
|
|
ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key),
|
|
SafeSummary: summary,
|
|
ExecutionMode: ModeLive,
|
|
})
|
|
}
|
|
turnID := in.ClientTurnID
|
|
if turnID == "" {
|
|
turnID = "turn-" + shortDigest(text)
|
|
}
|
|
digest := in.RequestDigest
|
|
if digest == "" {
|
|
digest = shortDigest(text)
|
|
}
|
|
created, err := p.CreatePlan(ctx, CreateCommand{
|
|
Principal: in.Principal,
|
|
ClientTurnID: turnID,
|
|
RequestDigest: digest,
|
|
SegmenterVersion: proposal.SegmenterVersion,
|
|
Status: PlanAwaitingConfirmation,
|
|
ExecutionMode: ModeLive,
|
|
WritePlan: confirmWrites,
|
|
SafeSummary: text,
|
|
Steps: drafts,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if confirmWrites {
|
|
return &TurnResult{Plan: created}, nil
|
|
}
|
|
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 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)
|
|
}
|