Files
Agent-System/internal/app/intentflow/turn.go
T
WeiCHEN d019388f37 feat(intent): wire production QueryOwner and confirm dispatcher
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.
2026-09-22 21:44:56 +08:00

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