Files
Agent-Intent/intent/registry.go
T
weichenandCursor 05e74d8e32
ci / vet-and-test (pull_request) Successful in 18s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Fold NFKC input and stop catalog metadata leaks
Why: fullwidth text missed case-folded terms, SpecByID shared maps, the catalog prompt printed handlers and private metadata, overlapping slots kept both values, and negation was easy to mistake for semantics.
Evidence: GOWORK=off go vet ./... && go test -count=1 -race ./... passed, including TestR2NFKCFoldsFullwidthButNotTraditional, TestR2SpecByIDDeepCopiesMetadata, TestR2CatalogHidesPrivateMetadataAndHandlerInMarkdown, TestR2EvalRulesDocumentsMaxSpecs, TestR2OverlappingSlotsAreAmbiguous, and TestR2NegationIsOnlyExcludesAny.
Impact: NFKC is opt-in and does not convert traditional Chinese; catalog markdown omits handlers and non-allowlisted metadata; overlapping slot spans are invalid.
Refs: I4, I7, I8, I10, I11, I12
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 12:56:55 +00:00

659 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package intent
import (
"context"
"fmt"
"io/fs"
"math"
"regexp"
"sort"
"strings"
"sync"
"sync/atomic"
"logging-system/logsys"
)
// Options 注册表与识别器的共享选项。零值字段在构建时填入默认值;
// GuardDomains / ProtectedDomains 决定快照的阶段归属与 overlay 保护范围。
type Options struct {
// ProtectedDomains 保护域:overlay 覆盖或禁用这些域内的 spec 会整批失败(fail-closed)。
ProtectedDomains []string
// GuardDomains 守卫域:这些域内的 spec 在 Stage 0 演练,命中即短路返回 GuardHit。
GuardDomains []string
// TopN 结果候选数上限(默认 5)。
TopN int
// AmbiguityMargin 前两名分差小于该值判 Ambiguous(默认 0.1)。
AmbiguityMargin float64
// MaxInputLen 输入字节长度上限(默认 4096),超过返回 InvalidArgument。
MaxInputLen int
// MaxSpecs 快照内 spec 总数上限,含禁用(默认 2048)。
MaxSpecs int
// MaxPatternsPerSpec 单 spec 正则数量上限(默认 32)。
MaxPatternsPerSpec int
// MaxSlotsPerSpec 单 spec 槽位数量上限(默认 16)。
MaxSlotsPerSpec int
// Log 可选日志器;nil 时使用 Nop。识别日志不记录原始输入。
Log *logsys.Logger
// SlotValidators 作用域槽位校验器。构建与校验时先查此表,查不到再回落
// 已弃用的全局表(RegisterSlotValidator)。不同 Registry 的同名校验器互不影响。
SlotValidators map[string]SlotValidator
// NFKC 为真时,词条与输入先做兼容等价归一(全角 ASCII、组合字符),再按
// case_sensitive 决定是否小写。NFKC 不做繁简转换。
NFKC bool
}
// Options 字段默认值。
const (
DefaultTopN = 5
DefaultAmbiguityMargin = 0.1
DefaultMaxInputLen = 4096
DefaultMaxSpecs = 2048
DefaultMaxPatterns = 32
DefaultMaxSlots = 16
)
func (o Options) normalized() Options {
n := o
if n.TopN <= 0 {
n.TopN = DefaultTopN
}
if n.AmbiguityMargin <= 0 {
n.AmbiguityMargin = DefaultAmbiguityMargin
}
if n.MaxInputLen <= 0 {
n.MaxInputLen = DefaultMaxInputLen
}
if n.MaxSpecs <= 0 {
n.MaxSpecs = DefaultMaxSpecs
}
if n.MaxPatternsPerSpec <= 0 {
n.MaxPatternsPerSpec = DefaultMaxPatterns
}
if n.MaxSlotsPerSpec <= 0 {
n.MaxSlotsPerSpec = DefaultMaxSlots
}
if n.Log == nil {
n.Log = logsys.Nop()
}
if len(n.SlotValidators) > 0 {
cp := make(map[string]SlotValidator, len(n.SlotValidators))
for k, v := range n.SlotValidators {
cp[k] = v
}
n.SlotValidators = cp
}
return n
}
func (o Options) protectedSet() map[string]bool {
return domainSet(o.ProtectedDomains)
}
func (o Options) guardSet() map[string]bool {
return domainSet(o.GuardDomains)
}
func domainSet(domains []string) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
m[d] = true
}
return m
}
func (o Options) limits() limits {
return limits{maxSpecs: o.MaxSpecs, maxPatternsPerSpec: o.MaxPatternsPerSpec, maxSlotsPerSpec: o.MaxSlotsPerSpec}
}
// ---- 编译产物 ----
// 槽位的编译产物与抽取/校验逻辑见 slots.go。
type compiledSpec struct {
spec IntentSpec
termsAny []string // 已按 case_sensitive 折叠
termsAll []string
termsEx []string
patterns []*regexp.Regexp
slots []compiledSlot
guard bool
index int // 合并序,确定性次序裁决用
base float64
bonus float64
threshold float64
}
// matchOutcome 单个 spec 对一次输入的评估结果。
type matchOutcome struct {
score float64
passed bool
hitTerms []string
hitPat []string
reject string // 非 passed 时的排除原因
}
// evaluate 按文档化的计分规则评估。input 为原文,folded 为小写折叠后文本。
func (cs *compiledSpec) evaluate(input, folded string) matchOutcome {
hay := input
if !cs.spec.Matcher.CaseSensitive {
hay = folded
}
for _, t := range cs.termsEx {
if strings.Contains(hay, t) {
return matchOutcome{reject: "excluded_term:" + t}
}
}
for _, t := range cs.termsAll {
if !strings.Contains(hay, t) {
return matchOutcome{reject: "missing_includes_all:" + t}
}
}
var hitTerms, hitPat []string
positive := false
for _, t := range cs.termsAny {
if strings.Contains(hay, t) {
positive = true
hitTerms = append(hitTerms, t)
}
}
for i, p := range cs.patterns {
if p.MatchString(input) {
positive = true
hitPat = append(hitPat, cs.spec.Matcher.Patterns[i])
}
}
if !positive && (len(cs.termsAny) > 0 || len(cs.patterns) > 0) {
return matchOutcome{reject: "no_positive_match"}
}
score := cs.base
if len(hitPat) > 0 {
score += cs.bonus
}
if score > 1 {
score = 1
}
// 固定精度舍入:避免浮点噪声进入结果与 margin 比较。
return matchOutcome{score: math.Round(score*10000) / 10000, passed: score >= cs.threshold, hitTerms: hitTerms, hitPat: hitPat}
}
func compileSpec(spec IntentSpec, guard bool, index int, scoped map[string]SlotValidator, nfkc bool) (*compiledSpec, error) {
m := spec.Matcher
fold := func(terms []string) []string {
out := make([]string, len(terms))
for i, t := range terms {
out[i] = foldMatchText(t, nfkc, m.CaseSensitive)
}
return out
}
cs := &compiledSpec{
spec: spec,
termsAny: fold(m.IncludesAny),
termsAll: fold(m.IncludesAll),
termsEx: fold(m.ExcludesAny),
patterns: make([]*regexp.Regexp, len(m.Patterns)),
guard: guard,
index: index,
base: spec.Confidence.Base,
bonus: spec.Confidence.PatternBonus,
threshold: spec.Confidence.Threshold,
}
if cs.base == 0 {
cs.base = DefaultBase
}
if cs.bonus == 0 {
cs.bonus = DefaultPatternBonus
}
if cs.threshold == 0 {
cs.threshold = DefaultThreshold
}
for i, p := range m.Patterns {
re, err := regexp.Compile(p)
if err != nil {
return nil, newError(InvalidSpec, "pattern 编译失败", "field", fmt.Sprintf("matcher.patterns[%d]", i), "id", spec.ID, "error", err.Error())
}
cs.patterns[i] = re
}
for _, s := range spec.Slots {
cs2, err := compileSlot(s, scoped)
if err != nil {
return nil, err.(*Error).with("id", spec.ID)
}
cs.slots = append(cs.slots, cs2)
}
return cs, nil
}
// ---- 快照 ----
// Snapshot 是合并产物的不可变快照:全部 matcher 已编译,识别全程无锁读。
// 同一快照对同样输入给出同样结果;revision 单调递增。
type Snapshot struct {
rev int64
margin float64
topN int
maxInput int
specs []*compiledSpec // 仅启用 spec,按合并序
byID map[string]int
guardIdx []int
disabled []string // 已禁用 id(overlay disable 与 spec.disabled 两个入口)
sources map[string]string
total int // 含禁用
nfkc bool
}
// Revision 返回快照版本号,随每次成功 Reload 递增。
func (s *Snapshot) Revision() int64 { return s.rev }
// SpecIDs 返回启用中的 spec id,按合并序(确定性次序裁决的注册序)。
func (s *Snapshot) SpecIDs() []string {
ids := make([]string, len(s.specs))
for i, cs := range s.specs {
ids[i] = cs.spec.ID
}
return ids
}
// DisabledIDs 返回被禁用的 spec id,字典序。
func (s *Snapshot) DisabledIDs() []string {
out := make([]string, len(s.disabled))
copy(out, s.disabled)
sort.Strings(out)
return out
}
// Source 返回 spec 的来源描述("builtin" 或 "<file>#intents[i]")。
func (s *Snapshot) Source(id string) (string, bool) {
src, ok := s.sources[id]
return src, ok
}
// SpecByID 返回指定 id 的启用 spec 定义副本。切片与 Metadata 深拷贝,
// 调用方改返回值不会改到快照。不存在或已禁用时第二个返回值为 false。
func (s *Snapshot) SpecByID(id string) (IntentSpec, bool) {
if idx, ok := s.byID[id]; ok {
return cloneIntentSpec(s.specs[idx].spec), true
}
return IntentSpec{}, false
}
// Conflict 诊断信息:两个启用 spec 共享同一 includes_any 词条。
// 共享不一定是错误(计分与确定性次序会裁决),用于配置评审提示。
type Conflict struct {
Kind string
A string
B string
Term string
}
// Conflicts 报告非守卫 spec 之间共享的 includes_any 词条,确定性排序。
func (s *Snapshot) Conflicts() []Conflict {
termOwners := make(map[string][]string)
for _, cs := range s.specs {
if cs.guard {
continue
}
for _, t := range cs.spec.Matcher.IncludesAny {
termOwners[t] = append(termOwners[t], cs.spec.ID)
}
}
terms := make([]string, 0, len(termOwners))
for t, owners := range termOwners {
if len(owners) > 1 {
terms = append(terms, t)
}
}
sort.Strings(terms)
var out []Conflict
for _, t := range terms {
owners := append([]string(nil), termOwners[t]...)
sort.Strings(owners)
for i := 0; i < len(owners); i++ {
for j := i + 1; j < len(owners); j++ {
out = append(out, Conflict{Kind: "shared_term", A: owners[i], B: owners[j], Term: t})
}
}
}
return out
}
// specHit 规则阶段的内部命中记录。
type specHit struct {
cs *compiledSpec
score float64
terms []string
pats []string
}
func lessHit(a, b specHit) bool {
if a.score != b.score {
return a.score > b.score
}
if a.cs.spec.Priority != b.cs.spec.Priority {
return a.cs.spec.Priority > b.cs.spec.Priority
}
return a.cs.index < b.cs.index
}
func sortHits(hits []specHit) {
sort.SliceStable(hits, func(i, j int) bool { return lessHit(hits[i], hits[j]) })
}
// evalGuard 评估守卫域 spec,返回过阈命中(已排序)。
func (s *Snapshot) evalGuard(input, folded string) []specHit {
var hits []specHit
for _, idx := range s.guardIdx {
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalIDs 按给定 id 列表评估(挂起预期阶段用),返回过阈命中(已排序)。
func (s *Snapshot) evalIDs(ids []string, input, folded string) []specHit {
var hits []specHit
for _, id := range ids {
idx, ok := s.byID[id]
if !ok {
continue
}
cs := s.specs[idx]
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
// evalRules 对全部非守卫 spec 做线性扫描,返回过阈命中(已排序)。
// 规模上限是 Options.MaxSpecs(默认 DefaultMaxSpecs = 2048)。倒排索引留到
// 超过该上限仍不够用时再做;构建期已经拒绝超过上限的快照。
func (s *Snapshot) evalRules(input, folded string) []specHit {
var hits []specHit
for _, cs := range s.specs {
if cs.guard {
continue
}
if o := cs.evaluate(input, folded); o.passed {
hits = append(hits, specHit{cs: cs, score: o.score, terms: o.hitTerms, pats: o.hitPat})
}
}
sortHits(hits)
return hits
}
func (h specHit) reason() string {
var parts []string
if len(h.terms) > 0 {
parts = append(parts, "terms="+strings.Join(h.terms, ","))
}
if len(h.pats) > 0 {
parts = append(parts, "patterns="+strings.Join(h.pats, ","))
}
return strings.Join(parts, " ")
}
func (h specHit) candidate(input string, withSlots bool) Candidate {
cs := h.cs
c := Candidate{
ID: cs.spec.ID,
Domain: cs.spec.Domain,
Version: cs.spec.Version,
Confidence: h.score,
Priority: cs.spec.Priority,
Risk: cs.spec.Risk,
RequiresConfirm: cs.spec.RequiresConfirm,
Handler: cs.spec.Handler,
Reason: h.reason(),
}
if c.Risk == "" {
c.Risk = RiskLow
}
if len(cs.spec.Metadata) > 0 {
c.Metadata = make(map[string]string, len(cs.spec.Metadata))
for k, v := range cs.spec.Metadata {
c.Metadata[k] = v
}
}
if withSlots {
c.Slots, c.MissingRequiredSlots, c.InvalidSlots = cs.extractSlots(input)
}
return c
}
// fillTopSlots 给裁决后的 Top 补抽槽位。Ambiguous 路径建候选时不抽槽位,
// 分类器选定 Top 之后才知道该抽哪一条。
func (s *Snapshot) fillTopSlots(c *Candidate, input string) {
idx, ok := s.byID[c.ID]
if !ok {
return
}
slots, missing, invalid := s.specs[idx].extractSlots(input)
c.Slots = slots
c.MissingRequiredSlots = missing
c.InvalidSlots = invalid
}
func candidatesOf(hits []specHit, input string, topSlots bool) []Candidate {
out := make([]Candidate, 0, len(hits))
for i, h := range hits {
out = append(out, h.candidate(input, topSlots && i == 0))
}
return out
}
// ---- 构建与合并 ----
// RegistryBuilder 收集 Go 内置基线 spec 并构建 Registry。
type RegistryBuilder struct {
opts Options
builtin []IntentSpec
seen map[string]bool
}
// NewRegistryBuilder 创建构建器。opts 零值字段填默认。
func NewRegistryBuilder(opts Options) *RegistryBuilder {
return &RegistryBuilder{opts: opts.normalized(), seen: make(map[string]bool)}
}
// Register 登记内置基线 spec,立即校验;重复 id 报 InvalidSpec。
func (b *RegistryBuilder) Register(specs ...IntentSpec) error {
lim := b.opts.limits()
for _, spec := range specs {
if b.seen[spec.ID] {
return newError(InvalidSpec, "内置 spec id 重复", "id", spec.ID)
}
if err := validateSpec(spec, lim, b.opts.SlotValidators); err != nil {
return err
}
b.seen[spec.ID] = true
b.builtin = append(b.builtin, spec)
}
return nil
}
// Build 仅用内置基线构建(rev 1)。此后 Reload 会报错(无配置来源)。
func (b *RegistryBuilder) Build() (*Registry, error) {
return b.buildWithSource(nil, nil)
}
// BuildFS 用内置基线 + JSON overlay 来源构建,并记住来源供 Reload 重读。
// roots 按给定顺序应用;目录 root 内按文件名字典序加载 *.json。
func (b *RegistryBuilder) BuildFS(ctx context.Context, fsys fs.FS, roots ...string) (*Registry, error) {
if fsys == nil {
return nil, newError(InvalidArgument, "fsys 为 nil")
}
if len(roots) == 0 {
return nil, newError(InvalidArgument, "至少提供一个配置 root")
}
return b.buildWithSource(fsys, roots)
}
func (b *RegistryBuilder) buildWithSource(fsys fs.FS, roots []string) (*Registry, error) {
var entries []overlayEntry
if fsys != nil {
var err error
entries, err = loadOverlays(fsys, roots)
if err != nil {
return nil, err
}
}
snap, err := buildSnapshot(b.opts, b.builtin, entries, 1)
if err != nil {
return nil, err
}
r := &Registry{
opts: b.opts,
builtin: append([]IntentSpec(nil), b.builtin...),
fsys: fsys,
roots: append([]string(nil), roots...),
hasSource: fsys != nil,
log: b.opts.Log,
}
r.snap.Store(snap)
return r, nil
}
// Registry 持有当前快照与配置来源,支持原子 Reload。
type Registry struct {
opts Options
builtin []IntentSpec
fsys fs.FS
roots []string
hasSource bool
mu sync.Mutex // 串行化 Reload
snap atomic.Pointer[Snapshot]
log *logsys.Logger
}
// Snapshot 返回当前生效快照。永不为 nil。
func (r *Registry) Snapshot() *Snapshot { return r.snap.Load() }
// Options 返回生效选项(已填默认值)。
func (r *Registry) Options() Options { return r.opts }
// Reload 重读配置来源并构建新快照;成功后原子替换并返回新快照。
// 任一步失败保留旧快照,revision 不变,返回带来源与原因的错误。
func (r *Registry) Reload(ctx context.Context) (*Snapshot, error) {
r.mu.Lock()
defer r.mu.Unlock()
old := r.snap.Load()
if !r.hasSource {
err := newError(LoadFailed, "没有登记配置来源;用 BuildFS 构建后才能 Reload", "revision", old.rev)
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
entries, err := loadOverlays(r.fsys, r.roots)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
ns, err := buildSnapshot(r.opts, r.builtin, entries, old.rev+1)
if err != nil {
r.log.Module("intent").Error("intent config rejected", err)
return nil, err
}
r.snap.Store(ns)
r.log.Module("intent").Info("intent snapshot reloaded",
"revision", ns.rev, "specs", len(ns.specs), "disabled", len(ns.disabled))
return ns, nil
}
// buildSnapshot 执行双源合并与编译。任一错误整批失败,不产生半套快照。
func buildSnapshot(opts Options, baseline []IntentSpec, entries []overlayEntry, rev int64) (*Snapshot, error) {
opts = opts.normalized()
protected := opts.protectedSet()
guards := opts.guardSet()
lim := opts.limits()
ordered := append([]IntentSpec(nil), baseline...)
pos := make(map[string]int, len(ordered))
sources := make(map[string]string, len(ordered))
for i, spec := range ordered {
pos[spec.ID] = i
sources[spec.ID] = "builtin"
}
var disabledList []string
for _, e := range entries {
if e.disable {
idx, ok := pos[e.id]
if !ok {
return nil, newError(InvalidSpec, "disable 目标不存在", "id", e.id, "source", e.source)
}
if protected[ordered[idx].Domain] {
return nil, newError(SpecProtected, "overlay 试图禁用保护域 spec",
"id", e.id, "domain", ordered[idx].Domain, "source", e.source)
}
ordered = append(ordered[:idx], ordered[idx+1:]...)
for id, p := range pos {
if p > idx {
pos[id] = p - 1
}
}
delete(pos, e.id)
disabledList = append(disabledList, e.id)
sources[e.id] = e.source + " (disabled)"
continue
}
spec := e.spec
if err := validateSpec(spec, lim, opts.SlotValidators); err != nil {
return nil, err.(*Error).with("source", e.source)
}
if idx, ok := pos[spec.ID]; ok {
old := ordered[idx]
if protected[old.Domain] {
return nil, newError(SpecProtected, "overlay 试图覆盖保护域 spec",
"id", spec.ID, "domain", old.Domain, "source", e.source)
}
if spec.Version <= old.Version {
return nil, newError(InvalidSpec, "覆盖必须递增 version",
"id", spec.ID, "old_version", old.Version, "new_version", spec.Version, "source", e.source)
}
ordered[idx] = spec
} else {
ordered = append(ordered, spec)
pos[spec.ID] = len(ordered) - 1
}
sources[spec.ID] = e.source
}
total := len(ordered) + len(disabledList)
if total > lim.maxSpecs {
return nil, newError(InvalidSpec, "spec 总数超过上限", "total", total, "max", lim.maxSpecs)
}
snap := &Snapshot{
rev: rev,
margin: opts.AmbiguityMargin,
topN: opts.TopN,
maxInput: opts.MaxInputLen,
byID: make(map[string]int, len(ordered)),
disabled: disabledList,
sources: sources,
total: total,
nfkc: opts.NFKC,
}
nextIndex := 0
for _, spec := range ordered {
if spec.Disabled {
snap.disabled = append(snap.disabled, spec.ID)
continue
}
cs, err := compileSpec(spec, guards[spec.Domain], nextIndex, opts.SlotValidators, opts.NFKC)
if err != nil {
return nil, err
}
snap.specs = append(snap.specs, cs)
snap.byID[spec.ID] = len(snap.specs) - 1
if cs.guard {
snap.guardIdx = append(snap.guardIdx, len(snap.specs)-1)
}
nextIndex++
}
return snap, nil
}