merge: candidate intent-plan-i2-shadow-allowlist into local_dev
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|---|---|---|---|---|---|
|
||||
| Cursor | intent-plan-i2-impl-2026-09-24 | feature/intent-plan-i2-shadow-allowlist-2026-09-24 | .worktrees/intent-plan-i2-shadow-allowlist-2026-09-24 | intent-plan | in_progress |
|
||||
| Cursor | closeout-claim-removed-2026-09-23 | feature/closeout-claim-removed-2026-09-23 | (removed) | git:dev+worktree-cleanup | done |
|
||||
| Cursor | module-batches-ag-status-fix-2026-09-23 | feature/module-batches-ag-status-fix-2026-09-23 | .worktrees/module-batches-ag-status-fix-2026-09-23 | module-decoupling-spec | done |
|
||||
| Cursor | fav-constraint-current-2026-09-23 | feature/fav-constraint-current-2026-09-23 | (removed) | git:dev+docs | done |
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Track I2:shadow 层 + 项目 allowlist + 冻结阈值"
|
||||
task_id: intent-plan-i2-shadow-allowlist-2026-09-24
|
||||
status: in_progress
|
||||
created: 2026-09-24
|
||||
updated: 2026-09-24
|
||||
owner: Cursor
|
||||
task_type: implementation
|
||||
task_tier: small
|
||||
policy_revision: "AGENTS+CURRENT @ 9d8332ad"
|
||||
branch: feature/intent-plan-i2-shadow-allowlist-2026-09-24
|
||||
worktree: .worktrees/intent-plan-i2-shadow-allowlist-2026-09-24
|
||||
base_dev_sha: "9d8332ad0892adf147cb31da358ed01ad3e503fa"
|
||||
write_scope: ["internal/app", "internal/app/intentflow", "internal/chat", "internal/sysadmin", "docker-compose.*.yml", ".doc/ops/short-term", ".doc/test-reports"]
|
||||
allowed_environments: ["dev"]
|
||||
mutex: intent-plan
|
||||
review_state: pending
|
||||
integration_state: pending
|
||||
deployment: pending
|
||||
cleanup: pending
|
||||
cleanup_owner: 独立维护者
|
||||
remote_sync: not_requested
|
||||
---
|
||||
|
||||
# Track I2:shadow 层 + 项目 allowlist + 冻结阈值
|
||||
|
||||
## 用户目标与授权
|
||||
|
||||
- 关闭 FEATURE-MATRIX `intent-plan-u1u8`:实现 I2 代码门禁后在 dev 逐级放量验收。
|
||||
- 本任务(T1)只交付代码:shadow 持久不可执行计划、`INTENT_PLAN_PROJECT_ALLOWLIST`、进程内对账指标、冻结阈值登记。
|
||||
- 禁止:升 compose 默认值、升 full、push、操作 test/main。
|
||||
|
||||
## 冻结阈值(Foundation 0 / I2 进入 auto_read 前)
|
||||
|
||||
对 U2 `plan-proposal-eval-v1.json` counterfactual 基线:
|
||||
|
||||
| 指标 | 阈值 |
|
||||
|---|---|
|
||||
| hard-negative 错误自动执行(would_execute) | **0** |
|
||||
| 安全红线 / 越权副作用 | **0** |
|
||||
| 写意图漏判为 auto_read | **0** |
|
||||
| read_only would_execute 覆盖(非 hard-negative) | ≥ 基线分母内全部命中 |
|
||||
| 确认放弃率 / 主动拆单(放量观察) | 记录;不达不升 confirm 写扩面 |
|
||||
|
||||
放量观察窗以标注数据集 + 手工样本为主证据;不以事后结果下调阈值。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] `TierShadow`:持久 `proposed` + `validated_only`,零执行;不占 active-write
|
||||
2. [x] `INTENT_PLAN_PROJECT_ALLOWLIST`(空拒绝 / `*` / id 列表)
|
||||
3. [x] 进程指标 + chat 对 shadow 不抢 legacy 路由
|
||||
4. [x] 单测 + arch-check
|
||||
5. [ ] 合入 local_dev;T2 做真实放量验收
|
||||
|
||||
## 相关链接
|
||||
|
||||
- 方案:多意图两行收口推进方案
|
||||
- 上位:[capability-enablement Track I](../../remediation/2026-09-20-capability-enablement-impact-plan.md)
|
||||
- 设计:[multi-intent-hitl-plan](../../system-design/2026-09-15-multi-intent-hitl-plan.md)
|
||||
@@ -128,6 +128,7 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy}
|
||||
INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-}
|
||||
MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false}
|
||||
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
|
||||
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
|
||||
|
||||
@@ -126,6 +126,7 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy}
|
||||
INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-}
|
||||
MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false}
|
||||
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
|
||||
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
|
||||
|
||||
@@ -123,6 +123,7 @@ services:
|
||||
WORKFLOW_INTENTS_ENABLED: ${WORKFLOW_INTENTS_ENABLED:-true}
|
||||
MULTI_INTENT_ENABLED: ${MULTI_INTENT_ENABLED:-true}
|
||||
MULTI_INTENT_PLAN_TIER: ${MULTI_INTENT_PLAN_TIER:-legacy}
|
||||
INTENT_PLAN_PROJECT_ALLOWLIST: ${INTENT_PLAN_PROJECT_ALLOWLIST:-}
|
||||
MULTI_INTENT_U9_SEGMENT: ${MULTI_INTENT_U9_SEGMENT:-false}
|
||||
MULTI_INTENT_U10_MUTATE: ${MULTI_INTENT_U10_MUTATE:-false}
|
||||
MULTI_INTENT_U11_DOCGOV: ${MULTI_INTENT_U11_DOCGOV:-false}
|
||||
|
||||
@@ -338,6 +338,7 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
|
||||
"WORKFLOW_INTENTS_ENABLED": strconv.FormatBool(iflags.WorkflowIntentsEnabled),
|
||||
"MULTI_INTENT_ENABLED": strconv.FormatBool(iflags.MultiIntentEnabled),
|
||||
"MULTI_INTENT_PLAN_TIER": string(pflags.PlanTier),
|
||||
"INTENT_PLAN_PROJECT_ALLOWLIST": formatProjectAllowlist(pflags.ProjectAllowlist),
|
||||
"MULTI_INTENT_U9_SEGMENT": strconv.FormatBool(pflags.U9Segment),
|
||||
"MULTI_INTENT_U10_MUTATE": strconv.FormatBool(pflags.U10Mutate),
|
||||
"MULTI_INTENT_U11_DOCGOV": strconv.FormatBool(pflags.U11DocGov),
|
||||
@@ -361,6 +362,20 @@ func sysadminFeatureValues(cfg *config.Config) func() map[string]string {
|
||||
}
|
||||
}
|
||||
|
||||
func formatProjectAllowlist(ids []int64) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == intentflow.AllowAllProjects {
|
||||
return "*"
|
||||
}
|
||||
parts = append(parts, strconv.FormatInt(id, 10))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// WriterLive reports whether the durable consumer already owns the visible write.
|
||||
func (r *Runtime) WriterLive(ctx context.Context, consumer string) bool {
|
||||
if r == nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package intentflow
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// AllowAllProjects is the wildcard sentinel for INTENT_PLAN_PROJECT_ALLOWLIST=*.
|
||||
const AllowAllProjects int64 = -1
|
||||
|
||||
// ParseProjectAllowlist parses a comma-separated project ID list.
|
||||
// Empty string → deny elevated tiers. "*" → allow all. Invalid tokens are skipped.
|
||||
func ParseProjectAllowlist(raw string) []int64 {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if raw == "*" {
|
||||
return []int64{AllowAllProjects}
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]int64, 0, len(parts))
|
||||
seen := map[int64]struct{}{}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "*" {
|
||||
if p == "*" {
|
||||
return []int64{AllowAllProjects}
|
||||
}
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseInt(p, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ProjectAllowed reports whether elevated plan tiers may act for projectID.
|
||||
// Legacy tier always returns true (no allowlist gate). Empty allowlist denies.
|
||||
func (f FeatureFlags) ProjectAllowed(projectID int64) bool {
|
||||
if f.PlanTier == TierLegacy || f.PlanTier == "" {
|
||||
return true
|
||||
}
|
||||
if len(f.ProjectAllowlist) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, id := range f.ProjectAllowlist {
|
||||
if id == AllowAllProjects {
|
||||
return true
|
||||
}
|
||||
if id == projectID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,6 +20,10 @@ const (
|
||||
|
||||
type FeatureFlags struct {
|
||||
PlanTier PlanTier
|
||||
// ProjectAllowlist gates elevated tiers (shadow/auto_read/confirm/full).
|
||||
// Empty denies; AllowAllProjects (-1) allows every project.
|
||||
// Env: INTENT_PLAN_PROJECT_ALLOWLIST (*|id,id,...)
|
||||
ProjectAllowlist []int64
|
||||
// Independent PU8 expansion gates. Default OFF; do not couple to PlanTier.
|
||||
U9Segment bool // MULTI_INTENT_U9_SEGMENT — LLM segment proposal
|
||||
U10Mutate bool // MULTI_INTENT_U10_MUTATE — low/medium mutate adapters
|
||||
@@ -34,11 +38,12 @@ var (
|
||||
|
||||
func defaultFeatureFlags() FeatureFlags {
|
||||
return FeatureFlags{
|
||||
PlanTier: parsePlanTier(os.Getenv("MULTI_INTENT_PLAN_TIER")),
|
||||
U9Segment: envBool("MULTI_INTENT_U9_SEGMENT", false),
|
||||
U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false),
|
||||
U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false),
|
||||
U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false),
|
||||
PlanTier: parsePlanTier(os.Getenv("MULTI_INTENT_PLAN_TIER")),
|
||||
ProjectAllowlist: ParseProjectAllowlist(os.Getenv("INTENT_PLAN_PROJECT_ALLOWLIST")),
|
||||
U9Segment: envBool("MULTI_INTENT_U9_SEGMENT", false),
|
||||
U10Mutate: envBool("MULTI_INTENT_U10_MUTATE", false),
|
||||
U11DocGov: envBool("MULTI_INTENT_U11_DOCGOV", false),
|
||||
U12RunControl: envBool("MULTI_INTENT_U12_RUN_CONTROL", false),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,48 @@ func TestPlanTierEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectAllowlistParseAndGate(t *testing.T) {
|
||||
if got := ParseProjectAllowlist(""); len(got) != 0 {
|
||||
t.Fatalf("empty = %+v", got)
|
||||
}
|
||||
if got := ParseProjectAllowlist("*"); len(got) != 1 || got[0] != AllowAllProjects {
|
||||
t.Fatalf("star = %+v", got)
|
||||
}
|
||||
got := ParseProjectAllowlist("42, 100,42,bad")
|
||||
if len(got) != 2 || got[0] != 42 || got[1] != 100 {
|
||||
t.Fatalf("list = %+v", got)
|
||||
}
|
||||
legacy := FeatureFlags{PlanTier: TierLegacy}
|
||||
if !legacy.ProjectAllowed(99) {
|
||||
t.Fatal("legacy must not require allowlist")
|
||||
}
|
||||
denied := FeatureFlags{PlanTier: TierShadow}
|
||||
if denied.ProjectAllowed(42) {
|
||||
t.Fatal("empty allowlist must deny elevated")
|
||||
}
|
||||
ok := FeatureFlags{PlanTier: TierAutoRead, ProjectAllowlist: []int64{42}}
|
||||
if !ok.ProjectAllowed(42) || ok.ProjectAllowed(7) {
|
||||
t.Fatal("id allowlist")
|
||||
}
|
||||
all := FeatureFlags{PlanTier: TierConfirm, ProjectAllowlist: []int64{AllowAllProjects}}
|
||||
if !all.ProjectAllowed(1) {
|
||||
t.Fatal("wildcard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAllowlistEmpty(t *testing.T) {
|
||||
t.Setenv("MULTI_INTENT_PLAN_TIER", "shadow")
|
||||
t.Setenv("INTENT_PLAN_PROJECT_ALLOWLIST", "")
|
||||
ResetFeatureFlagsForTest()
|
||||
f := CurrentFeatureFlags()
|
||||
if f.PlanTier != TierShadow {
|
||||
t.Fatal(f.PlanTier)
|
||||
}
|
||||
if len(f.ProjectAllowlist) != 0 {
|
||||
t.Fatalf("default allowlist must be empty deny, got %+v", f.ProjectAllowlist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCodes(t *testing.T) {
|
||||
if ErrorCode(ErrPlanNotFoundOrHidden) != "PLAN_NOT_FOUND_OR_HIDDEN" {
|
||||
t.Fatal(ErrorCode(ErrPlanNotFoundOrHidden))
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package intentflow
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// RolloutMetrics is the in-process I2 counter set used for shadow reconciliation
|
||||
// and allowlist gates. Durable plan facts still flow through intent.plan.* events.
|
||||
type RolloutMetrics struct {
|
||||
PlansCreated atomic.Int64
|
||||
ShadowPlans atomic.Int64
|
||||
AutoReadPlans atomic.Int64
|
||||
ConfirmPlans atomic.Int64
|
||||
AllowlistDenied atomic.Int64
|
||||
CounterfactualWouldExecute atomic.Int64
|
||||
CounterfactualWouldConfirm atomic.Int64
|
||||
CounterfactualWouldClarify atomic.Int64
|
||||
CounterfactualWouldReject atomic.Int64
|
||||
CounterfactualPassthrough atomic.Int64
|
||||
ReconciliationDiffs atomic.Int64
|
||||
}
|
||||
|
||||
var globalMetrics RolloutMetrics
|
||||
|
||||
// Metrics returns the process-wide rollout counters.
|
||||
func Metrics() *RolloutMetrics { return &globalMetrics }
|
||||
|
||||
// Snapshot is a plain copy for tests and admin surfaces.
|
||||
type MetricsSnapshot struct {
|
||||
PlansCreated int64 `json:"plans_created"`
|
||||
ShadowPlans int64 `json:"shadow_plans"`
|
||||
AutoReadPlans int64 `json:"auto_read_plans"`
|
||||
ConfirmPlans int64 `json:"confirm_plans"`
|
||||
AllowlistDenied int64 `json:"allowlist_denied"`
|
||||
CounterfactualWouldExecute int64 `json:"cf_would_execute"`
|
||||
CounterfactualWouldConfirm int64 `json:"cf_would_confirm"`
|
||||
CounterfactualWouldClarify int64 `json:"cf_would_clarify"`
|
||||
CounterfactualWouldReject int64 `json:"cf_would_reject"`
|
||||
CounterfactualPassthrough int64 `json:"cf_passthrough"`
|
||||
ReconciliationDiffs int64 `json:"reconciliation_diffs"`
|
||||
}
|
||||
|
||||
func (m *RolloutMetrics) Snapshot() MetricsSnapshot {
|
||||
if m == nil {
|
||||
return MetricsSnapshot{}
|
||||
}
|
||||
return MetricsSnapshot{
|
||||
PlansCreated: m.PlansCreated.Load(),
|
||||
ShadowPlans: m.ShadowPlans.Load(),
|
||||
AutoReadPlans: m.AutoReadPlans.Load(),
|
||||
ConfirmPlans: m.ConfirmPlans.Load(),
|
||||
AllowlistDenied: m.AllowlistDenied.Load(),
|
||||
CounterfactualWouldExecute: m.CounterfactualWouldExecute.Load(),
|
||||
CounterfactualWouldConfirm: m.CounterfactualWouldConfirm.Load(),
|
||||
CounterfactualWouldClarify: m.CounterfactualWouldClarify.Load(),
|
||||
CounterfactualWouldReject: m.CounterfactualWouldReject.Load(),
|
||||
CounterfactualPassthrough: m.CounterfactualPassthrough.Load(),
|
||||
ReconciliationDiffs: m.ReconciliationDiffs.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func ResetMetricsForTest() {
|
||||
globalMetrics = RolloutMetrics{}
|
||||
}
|
||||
|
||||
const (
|
||||
cfWouldExecute = "would_execute"
|
||||
cfWouldConfirm = "would_confirm"
|
||||
cfWouldClarify = "would_clarify"
|
||||
cfWouldReject = "would_reject"
|
||||
cfPassthrough = "would_passthrough"
|
||||
)
|
||||
|
||||
func recordCounterfactual(route string) {
|
||||
m := Metrics()
|
||||
switch route {
|
||||
case cfWouldExecute:
|
||||
m.CounterfactualWouldExecute.Add(1)
|
||||
case cfWouldConfirm:
|
||||
m.CounterfactualWouldConfirm.Add(1)
|
||||
case cfWouldClarify:
|
||||
m.CounterfactualWouldClarify.Add(1)
|
||||
case cfWouldReject:
|
||||
m.CounterfactualWouldReject.Add(1)
|
||||
default:
|
||||
m.CounterfactualPassthrough.Add(1)
|
||||
}
|
||||
}
|
||||
@@ -98,16 +98,6 @@ func visibleTo(plan *Plan, p Principal) bool {
|
||||
return plan != nil && sameScope(plan.Principal, p)
|
||||
}
|
||||
|
||||
// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default).
|
||||
// auto_read executes independent queries; confirm persists write/generate plans
|
||||
// awaiting confirmation and does not execute them.
|
||||
func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) {
|
||||
if err := requirePrincipal(in.Principal); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.handleTurnAutoRead(ctx, in)
|
||||
}
|
||||
|
||||
func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) {
|
||||
if err := requirePrincipal(principal); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,9 +10,25 @@ import (
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResult, error) {
|
||||
tier := CurrentFeatureFlags().PlanTier
|
||||
if !tier.Allows(TierAutoRead) {
|
||||
// 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)
|
||||
@@ -22,14 +38,24 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
rec := intent.NewRecognizer()
|
||||
res, err := rec.Recognize(ctx, text, nil)
|
||||
if err != nil || res == nil || res.PlanProposal == nil {
|
||||
recordCounterfactual(cfPassthrough)
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
proposal := res.PlanProposal
|
||||
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)
|
||||
if (!confirmWrites && !readOnly) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 {
|
||||
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 {
|
||||
@@ -41,21 +67,29 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
row, err := policy.Lookup(key)
|
||||
if err != nil || row.RejectPlan || !tier.Allows(row.OpenFromTier) {
|
||||
if err != nil || row.RejectPlan {
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
if row.RequiresBatchConfirm && !confirmWrites {
|
||||
// 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
|
||||
}
|
||||
drafts = append(drafts, StepDraft{
|
||||
ActionKey: key,
|
||||
ActionDigest: fmt.Sprintf("%s:%d:%s", proposal.Digest, i, key),
|
||||
SafeSummary: summary,
|
||||
ExecutionMode: ModeLive,
|
||||
ExecutionMode: mode,
|
||||
})
|
||||
}
|
||||
turnID := in.ClientTurnID
|
||||
@@ -66,23 +100,40 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
if digest == "" {
|
||||
digest = 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: PlanAwaitingConfirmation,
|
||||
ExecutionMode: ModeLive,
|
||||
WritePlan: confirmWrites,
|
||||
Status: status,
|
||||
ExecutionMode: mode,
|
||||
WritePlan: writePlan,
|
||||
SafeSummary: text,
|
||||
Steps: drafts,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if confirmWrites {
|
||||
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,
|
||||
@@ -101,6 +152,27 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
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 shortDigest(text string) string {
|
||||
sum := sha256.Sum256([]byte(text))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
|
||||
@@ -8,8 +8,11 @@ import (
|
||||
)
|
||||
|
||||
func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
intentflow.ResetMetricsForTest()
|
||||
p, _, owner := testPlans()
|
||||
ctx := context.Background()
|
||||
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
@@ -56,7 +59,9 @@ func TestHandleTurnAutoReadPersistsAndExecutesQueries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierAutoRead, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, _ := testPlans()
|
||||
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
|
||||
@@ -80,7 +85,9 @@ func TestQueryGuardRejectsGenerate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, owner := testPlans()
|
||||
ctx := context.Background()
|
||||
@@ -132,7 +139,9 @@ func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, _ := testPlans()
|
||||
ctx := context.Background()
|
||||
@@ -155,3 +164,88 @@ func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) {
|
||||
t.Fatalf("second write plan must conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTurnShadowPersistsValidatedOnlyWithoutExecute(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
intentflow.ResetMetricsForTest()
|
||||
p, _, owner := testPlans()
|
||||
ctx := context.Background()
|
||||
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
|
||||
ClientTurnID: "turn-sh-1", RequestDigest: "d-sh-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res == nil || res.Plan == nil {
|
||||
t.Fatal("shadow must persist a plan")
|
||||
}
|
||||
if res.Plan.Status != intentflow.PlanProposed {
|
||||
t.Fatalf("shadow status = %s, want proposed", res.Plan.Status)
|
||||
}
|
||||
if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly {
|
||||
t.Fatalf("shadow mode = %s", res.Plan.ExecutionMode)
|
||||
}
|
||||
for _, s := range res.Plan.Steps {
|
||||
if s.Status != intentflow.StepPending {
|
||||
t.Fatalf("shadow must not execute, step=%+v", s)
|
||||
}
|
||||
if s.ExecutionMode != intentflow.ModeValidatedOnly {
|
||||
t.Fatalf("shadow step mode = %s", s.ExecutionMode)
|
||||
}
|
||||
}
|
||||
if owner.ExecCount("") != 0 {
|
||||
t.Fatal("shadow must not call action owner")
|
||||
}
|
||||
snap := intentflow.Metrics().Snapshot()
|
||||
if snap.ShadowPlans < 1 || snap.CounterfactualWouldExecute < 1 {
|
||||
t.Fatalf("metrics = %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTurnElevatedDeniedWithoutAllowlist(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierAutoRead})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
intentflow.ResetMetricsForTest()
|
||||
p, _, _ := testPlans()
|
||||
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
|
||||
Principal: principal(), Text: "查一下项目风险,然后查相关法规",
|
||||
ClientTurnID: "turn-deny", RequestDigest: "d-deny",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Plan != nil {
|
||||
t.Fatalf("empty allowlist must deny elevated tier: %+v", res.Plan)
|
||||
}
|
||||
if intentflow.Metrics().Snapshot().AllowlistDenied < 1 {
|
||||
t.Fatal("expected allowlist_denied metric")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTurnShadowObservesConfirmClassWithoutExecuting(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierShadow, ProjectAllowlist: []int64{intentflow.AllowAllProjects},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, owner := testPlans()
|
||||
res, err := p.HandleTurn(context.Background(), intentflow.TurnInput{
|
||||
Principal: principal(), Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-sh-cf", RequestDigest: "d-sh-cf",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res == nil || res.Plan == nil {
|
||||
t.Fatal("shadow must observe confirm-class proposal")
|
||||
}
|
||||
if res.Plan.ExecutionMode != intentflow.ModeValidatedOnly || res.Plan.Status != intentflow.PlanProposed {
|
||||
t.Fatalf("plan=%+v", res.Plan)
|
||||
}
|
||||
if owner.ExecCount("") != 0 {
|
||||
t.Fatal("shadow confirm observe must not execute")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ func TestTaskRunAdapterDeniedAfterRevoke(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfirmThenMuxExecutesGenerateOnce(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
query := intentflow.NewFakeActionOwner()
|
||||
task := NewTaskRunAdapter()
|
||||
|
||||
@@ -82,7 +82,9 @@ func TestQueryOwnerReauthAndVersionCheck(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConfirmZeroWritesBeforeDispatch(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{
|
||||
PlanTier: intentflow.TierConfirm, ProjectAllowlist: []int64{42},
|
||||
})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
query := NewQueryOwner(func(context.Context, int64, int64) error { return nil }, nil)
|
||||
task := NewTaskRunAdapter()
|
||||
|
||||
@@ -400,7 +400,10 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
})
|
||||
if planErr == nil && tr != nil && tr.Plan != nil {
|
||||
persistedPlan = tr.Plan
|
||||
recRes.IntentPlan = nil
|
||||
// Shadow (validated_only) must not steal the legacy coordinator route.
|
||||
if tr.Plan.ExecutionMode != "validated_only" {
|
||||
recRes.IntentPlan = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ var featureMaturityCatalog = map[string]featureMaturity{
|
||||
},
|
||||
"MULTI_INTENT_PLAN_TIER": {
|
||||
state: implLive, adapter: adapterDegraded,
|
||||
reason: "真实 query owner 与 confirm 后 DispatchEligible 已接线;默认仍 legacy,升 auto_read/confirm 需 I2 阈值与项目 allowlist",
|
||||
reason: "I2 接线就绪:shadow 持久不可执行计划 + 项目 allowlist + 指标;默认仍 legacy,升档需 allowlist 与阈值验收",
|
||||
},
|
||||
"INTENT_PLAN_PROJECT_ALLOWLIST": {
|
||||
state: implLive, adapter: adapterReady,
|
||||
reason: "空值拒绝所有 elevated tier;* 放开全部;逗号分隔项目 ID",
|
||||
},
|
||||
"MULTI_INTENT_U9_SEGMENT": {
|
||||
state: implDryRun, adapter: adapterUnavailable,
|
||||
@@ -109,6 +113,7 @@ var featureCatalog = []featureSpec{
|
||||
{key: "WORKFLOW_INTENTS_ENABLED", label: "工作流意图", group: "intent", fallback: "true", description: "识别工作流类意图"},
|
||||
{key: "MULTI_INTENT_ENABLED", label: "多意图识别", group: "intent", fallback: "true", description: "单条消息多意图拆解"},
|
||||
{key: "MULTI_INTENT_PLAN_TIER", label: "多意图计划档位", group: "intentflow", fallback: "legacy", description: "意图计划灰度:legacy/shadow/auto_read/confirm/full"},
|
||||
{key: "INTENT_PLAN_PROJECT_ALLOWLIST", label: "多意图计划项目白名单", group: "intentflow", fallback: "", description: "elevated tier 项目 allowlist:空拒绝;* 全部;逗号分隔项目 ID"},
|
||||
{key: "MULTI_INTENT_U9_SEGMENT", label: "U9 LLM 分段", group: "intentflow", fallback: "false", description: "LLM 意图分段提议"},
|
||||
{key: "MULTI_INTENT_U10_MUTATE", label: "U10 变更适配器", group: "intentflow", fallback: "false", description: "低/中风险 mutate 动作适配器"},
|
||||
{key: "MULTI_INTENT_U11_DOCGOV", label: "U11 文档治理适配器", group: "intentflow", fallback: "false", description: "docgov 动作适配器"},
|
||||
|
||||
Reference in New Issue
Block a user