Files
Agent-System/internal/app/intentflow/policy.go
weichenandCursor 9364da4974 feat(intentflow): freeze plan lifecycle contracts and batch policy
Add the app/intentflow skeleton so HITL state, error codes, REST shapes, and BatchActionPolicy live outside intent and default to the legacy tier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 16:22:52 +00:00

138 lines
3.7 KiB
Go

package intentflow
import (
"fmt"
"chat-agent-system/internal/intent"
)
const PolicyVersion = "batch-action-policy.v1"
// ActionBatchPolicy is the intentflow-owned batch row. It must not copy
// Risk, RequiredSlots, CapabilityKey, or AllowedStates from ActionRegistry.
type ActionBatchPolicy struct {
ActionKey string
RequiresBatchConfirm bool
OpenFromTier PlanTier
AllowedModes []ExecutionMode
RejectPlan bool
}
// BatchActionPolicy is the versioned fail-closed matrix keyed by ActionSpec.Key.
type BatchActionPolicy struct {
Version string
byKey map[string]ActionBatchPolicy
}
func DefaultBatchActionPolicy() *BatchActionPolicy {
p, err := NewBatchActionPolicy(defaultBatchRows(), intent.DefaultRegistry())
if err != nil {
panic(err)
}
return p
}
func NewBatchActionPolicy(rows []ActionBatchPolicy, registry *intent.ActionRegistry) (*BatchActionPolicy, error) {
if registry == nil {
return nil, fmt.Errorf("intentflow: action registry is required")
}
byKey := make(map[string]ActionBatchPolicy, len(rows))
for _, row := range rows {
if row.ActionKey == "" {
return nil, fmt.Errorf("intentflow: empty policy action key")
}
if _, exists := byKey[row.ActionKey]; exists {
return nil, fmt.Errorf("intentflow: duplicate policy key %q", row.ActionKey)
}
if _, ok := registry.Get(row.ActionKey); !ok {
return nil, fmt.Errorf("intentflow: unknown policy action key %q", row.ActionKey)
}
byKey[row.ActionKey] = row
}
for _, spec := range registry.All() {
if spec == nil {
continue
}
if _, ok := byKey[spec.Key]; !ok {
return nil, fmt.Errorf("intentflow: action %q missing batch policy", spec.Key)
}
}
return &BatchActionPolicy{Version: PolicyVersion, byKey: byKey}, nil
}
func (p *BatchActionPolicy) Lookup(key string) (ActionBatchPolicy, error) {
if p == nil {
return ActionBatchPolicy{}, fmt.Errorf("intentflow: policy is nil")
}
row, ok := p.byKey[key]
if !ok {
return ActionBatchPolicy{}, fmt.Errorf("intentflow: unknown action key %q", key)
}
return row, nil
}
func (p *BatchActionPolicy) RequiresBatchConfirm(key string) bool {
row, err := p.Lookup(key)
if err != nil {
return true
}
return row.RequiresBatchConfirm || row.RejectPlan
}
func defaultBatchRows() []ActionBatchPolicy {
autoRead := []string{
"regulation.query",
"case.query",
"permission.query",
"document.reject_reason.query",
"workflow.status.query",
"document.status.query",
"run.status.query",
"project.advisory",
"conversation.help",
}
confirmGenerate := []string{"task.run", "task.run.adhoc"}
rows := make([]ActionBatchPolicy, 0, len(intent.DefaultRegistry().All()))
seen := map[string]struct{}{}
add := func(row ActionBatchPolicy) {
rows = append(rows, row)
seen[row.ActionKey] = struct{}{}
}
for _, key := range autoRead {
add(ActionBatchPolicy{
ActionKey: key,
RequiresBatchConfirm: false,
OpenFromTier: TierAutoRead,
AllowedModes: []ExecutionMode{ModeLive, ModeValidatedOnly},
})
}
for _, key := range confirmGenerate {
add(ActionBatchPolicy{
ActionKey: key,
RequiresBatchConfirm: true,
OpenFromTier: TierConfirm,
AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive},
})
}
add(ActionBatchPolicy{
ActionKey: "security.forbidden",
RejectPlan: true,
OpenFromTier: TierFull,
})
for _, spec := range intent.DefaultRegistry().All() {
if spec == nil {
continue
}
if _, ok := seen[spec.Key]; ok {
continue
}
add(ActionBatchPolicy{
ActionKey: spec.Key,
RequiresBatchConfirm: true,
OpenFromTier: TierFull,
AllowedModes: []ExecutionMode{ModeValidatedOnly},
})
}
return rows
}