package agentpolicy import ( "context" "fmt" "strings" "time" "iop/packages/go/agentconfig" ) // SelectionContext provides the runtime values that the evaluator matches // against the predicates in each SelectionRule. type SelectionContext struct { // ProjectID selects an effective project policy. An empty value uses the // global policy. ProjectID string // Now is the current wall-clock time, interpreted in the policy timezone. Now time.Time // Stage is the current workflow stage (e.g. "worker", "selfcheck"). Stage string // Grade is the current agent/work-unit grade. Grade int // Agent is the current agent identity. Agent string // Lane is the current execution lane. Lane string // QuotaState is the current provider quota state. QuotaState string // RemainingTokens is the current remaining token budget. RemainingTokens int64 // FailureCode is the current failure code, if any. FailureCode string // Capabilities is the set of capabilities the selected provider/profile // can consume. Capabilities []string } // Evaluator is a stateless policy evaluator. It implements the // agenttask.PolicySelector interface. type Evaluator struct{} // NewEvaluator creates a stateless policy evaluator. func NewEvaluator() *Evaluator { return &Evaluator{} } // SelectPolicy evaluates the effective ordered selection policy and returns // exactly one catalog-backed RouteDecision. Rules after the first match are // retained as explicitly unevaluated evidence. func (e *Evaluator) SelectPolicy( _ context.Context, snapshot agentconfig.RuntimeSnapshot, selCtx SelectionContext, ) (RouteDecision, error) { config, selection, err := effectivePolicy(snapshot, selCtx.ProjectID) if err != nil { return RouteDecision{}, err } if selCtx.Now.IsZero() { return RouteDecision{}, fmt.Errorf("%w: current time is required", ErrInvalidSelectionContext) } now := selCtx.Now if selection.Timezone != "" { loc, err := time.LoadLocation(selection.Timezone) if err != nil { return RouteDecision{}, fmt.Errorf( "agentpolicy: invalid timezone %q: %w", selection.Timezone, err, ) } now = now.In(loc) } candidates := make([]CandidateEvaluation, 0, len(selection.Rules)+1) var selected resolvedTarget selectedRuleID := "" selectedReason := "" for _, rule := range selection.Rules { target, err := resolveTarget(config, rule.Target) if err != nil { return RouteDecision{}, err } candidate := target.candidate(CandidateSourceRule, rule.ID) if selectedReason != "" { candidate.Reason = CandidateReasonNotEvaluatedAfterFirstHit candidates = append(candidates, candidate) continue } matched, reason := matchRule(rule, selCtx, now) candidate.Evaluated = true candidate.Reason = reason if matched { candidate.Eligible = true candidate.Used = true candidate.Reason = CandidateReasonMatched selected = target selectedRuleID = rule.ID selectedReason = CandidateReasonMatched } candidates = append(candidates, candidate) } if selection.Default.Profile != "" { target, err := resolveTarget(config, selection.Default) if err != nil { return RouteDecision{}, err } candidate := target.candidate(CandidateSourceDefault, "") if selectedReason == "" { candidate.Evaluated = true candidate.Eligible = true candidate.Used = true candidate.Reason = CandidateReasonDefault selected = target selectedReason = CandidateReasonDefault } else { candidate.Reason = CandidateReasonNotEvaluatedAfterFirstHit } candidates = append(candidates, candidate) } if selectedReason == "" { return RouteDecision{}, ErrNoMatch } return buildDecision( selected, selectedRuleID, selectedReason, config.Revision, selection.Revision, candidates, now, ), nil } // ResumePolicy validates a persisted route against the exact effective // project policy and catalog identities, then returns it without evaluating // current rule predicates. func (e *Evaluator) ResumePolicy( _ context.Context, snapshot agentconfig.RuntimeSnapshot, selCtx SelectionContext, persisted []byte, ) (RouteDecision, error) { config, selection, err := effectivePolicy(snapshot, selCtx.ProjectID) if err != nil { return RouteDecision{}, err } decision, err := DecodeDecision(persisted, DecisionExpectation{ ConfigRevision: config.Revision, SelectionRevision: selection.Revision, }) if err != nil { return RouteDecision{}, err } if err := validateCatalogEvidence(config, selection, decision); err != nil { return RouteDecision{}, err } return decision, nil } func effectivePolicy( snapshot agentconfig.RuntimeSnapshot, projectID string, ) (agentconfig.RuntimeConfig, agentconfig.SelectionPolicy, error) { config := snapshot.Config() if projectID == "" { return config, config.Selection, nil } project, ok := snapshot.Project(projectID) if !ok { return agentconfig.RuntimeConfig{}, agentconfig.SelectionPolicy{}, fmt.Errorf( "%w: %s", ErrUnknownProject, projectID, ) } if project.ConfigRevision != config.Revision || project.Selection.Revision == "" { return agentconfig.RuntimeConfig{}, agentconfig.SelectionPolicy{}, fmt.Errorf( "%w: project %q has inconsistent revisions", ErrRevisionMismatch, projectID, ) } return config, project.Selection, nil } type resolvedTarget struct { providerID string modelID string profileID string profileRevision string } func resolveTarget( config agentconfig.RuntimeConfig, target agentconfig.TargetRef, ) (resolvedTarget, error) { resolved, ok := config.Catalog.ResolveProfile(target.Profile) if !ok { return resolvedTarget{}, fmt.Errorf("%w: %s", ErrUnknownProfile, target.Profile) } if target.Provider != "" && target.Provider != resolved.Provider.ID { return resolvedTarget{}, fmt.Errorf( "%w: provider %q does not match profile provider %q", ErrTargetIdentityMismatch, target.Provider, resolved.Provider.ID, ) } if target.Model != "" && target.Model != resolved.Model.ID { return resolvedTarget{}, fmt.Errorf( "%w: model %q does not match profile model %q", ErrTargetIdentityMismatch, target.Model, resolved.Model.ID, ) } return resolvedTarget{ providerID: resolved.Provider.ID, modelID: resolved.Model.ID, profileID: resolved.Profile.ID, profileRevision: digestParts( "agentpolicy-profile", config.Revision, resolved.Provider.ID, resolved.Model.ID, resolved.Profile.ID, ), }, nil } func (target resolvedTarget) candidate(source, ruleID string) CandidateEvaluation { return CandidateEvaluation{ Source: source, RuleID: ruleID, ProviderID: target.providerID, ModelID: target.modelID, ProfileID: target.profileID, ProfileRevision: target.profileRevision, Evaluated: false, Eligible: false, Used: false, } } func validateCatalogEvidence( config agentconfig.RuntimeConfig, selection agentconfig.SelectionPolicy, decision RouteDecision, ) error { expectedCandidateCount := len(selection.Rules) if selection.Default.Profile != "" { expectedCandidateCount++ } if len(decision.Candidates) != expectedCandidateCount { return fmt.Errorf( "%w: candidate count %d does not match policy count %d", ErrCorruptDecision, len(decision.Candidates), expectedCandidateCount, ) } for index, rule := range selection.Rules { expected, err := resolveTarget(config, rule.Target) if err != nil { return fmt.Errorf("%w: policy rule %q: %v", ErrCorruptDecision, rule.ID, err) } candidate := decision.Candidates[index] if candidate.Source != CandidateSourceRule || candidate.RuleID != rule.ID || !candidateMatchesTarget(candidate, expected) { return fmt.Errorf( "%w: candidate %d does not match policy rule %q", ErrCorruptDecision, index, rule.ID, ) } } if selection.Default.Profile != "" { expected, err := resolveTarget(config, selection.Default) if err != nil { return fmt.Errorf("%w: policy default: %v", ErrCorruptDecision, err) } candidate := decision.Candidates[len(decision.Candidates)-1] if candidate.Source != CandidateSourceDefault || candidate.RuleID != "" || !candidateMatchesTarget(candidate, expected) { return fmt.Errorf("%w: default candidate does not match policy", ErrCorruptDecision) } } selected, err := resolveTarget(config, agentconfig.TargetRef{ Provider: decision.ProviderID, Model: decision.ModelID, Profile: decision.ProfileID, }) if err != nil { return fmt.Errorf("%w: selected target: %v", ErrCorruptDecision, err) } if selected.profileRevision != decision.ProfileRevision { return fmt.Errorf("%w: selected profile revision mismatch", ErrCorruptDecision) } for index, candidate := range decision.Candidates { target, err := resolveTarget(config, agentconfig.TargetRef{ Provider: candidate.ProviderID, Model: candidate.ModelID, Profile: candidate.ProfileID, }) if err != nil { return fmt.Errorf( "%w: candidate %d: %v", ErrCorruptDecision, index, err, ) } if target.profileRevision != candidate.ProfileRevision { return fmt.Errorf( "%w: candidate %d profile revision mismatch", ErrCorruptDecision, index, ) } } for index, used := range decision.History.UsedRoutes { target, err := resolveTarget(config, agentconfig.TargetRef{ Provider: used.ProviderID, Model: used.ModelID, Profile: used.ProfileID, }) if err != nil { return fmt.Errorf( "%w: used route %d: %v", ErrCorruptDecision, index, err, ) } if target.profileRevision != used.ProfileRevision { return fmt.Errorf( "%w: used route %d profile revision mismatch", ErrCorruptDecision, index, ) } } return nil } func candidateMatchesTarget( candidate CandidateEvaluation, target resolvedTarget, ) bool { return candidate.ProviderID == target.providerID && candidate.ModelID == target.modelID && candidate.ProfileID == target.profileID && candidate.ProfileRevision == target.profileRevision } // matchRule evaluates all predicates of a rule against the selection context. func matchRule(rule agentconfig.SelectionRule, selCtx SelectionContext, now time.Time) (bool, string) { if !matchTimeWindows(rule.Match.TimeWindows, now) { return false, "time window not matched" } if !matchStringList(rule.Match.QuotaStates, selCtx.QuotaState) { return false, "quota state not matched" } if !matchMinRemainingToken(rule.Match.MinRemainingToken, selCtx.RemainingTokens) { return false, "remaining tokens below minimum" } if !matchStringList(rule.Match.Agents, selCtx.Agent) { return false, "agent not matched" } if !matchStringList(rule.Match.Stages, selCtx.Stage) { return false, "stage not matched" } if !matchStringList(rule.Match.Lanes, selCtx.Lane) { return false, "lane not matched" } if !matchGrade(rule.Match.MinGrade, rule.Match.MaxGrade, selCtx.Grade) { return false, "grade not matched" } if !matchCapabilities(rule.Match.Capabilities, selCtx.Capabilities) { return false, "capability not satisfied" } if !matchStringList(rule.Match.FailureCodes, selCtx.FailureCode) { return false, "failure code not matched" } return true, CandidateReasonMatched } func matchTimeWindows(windows []agentconfig.SelectionTimeWindow, now time.Time) bool { if len(windows) == 0 { return true } for _, window := range windows { if matchTimeWindowEntry(window, now) { return true } } return false } func matchTimeWindowEntry(window agentconfig.SelectionTimeWindow, now time.Time) bool { if len(window.Days) > 0 { dayName := strings.ToLower(now.Weekday().String()) matched := false for _, day := range window.Days { if strings.ToLower(day) == dayName { matched = true break } } if !matched { return false } } start, err := time.Parse("15:04", strings.TrimSpace(window.Start)) if err != nil { return false } end, err := time.Parse("15:04", strings.TrimSpace(window.End)) if err != nil { return false } nowTime := time.Date(0, 0, 0, now.Hour(), now.Minute(), now.Second(), 0, time.UTC) startTime := time.Date(0, 0, 0, start.Hour(), start.Minute(), 0, 0, time.UTC) endTime := time.Date(0, 0, 0, end.Hour(), end.Minute(), 0, 0, time.UTC) if startTime.After(endTime) { return !nowTime.Before(startTime) || !nowTime.After(endTime) } return !nowTime.Before(startTime) && !nowTime.After(endTime) } func matchStringList(list []string, current string) bool { if len(list) == 0 { return true } for _, value := range list { if value == current { return true } } return false } func matchMinRemainingToken(min *int64, remaining int64) bool { if min == nil { return true } return remaining >= *min } func matchGrade(minGrade, maxGrade, grade int) bool { if minGrade != 0 && grade < minGrade { return false } if maxGrade != 0 && grade > maxGrade { return false } return true } func matchCapabilities(required, available []string) bool { if len(required) == 0 { return true } availableSet := make(map[string]struct{}, len(available)) for _, capability := range available { availableSet[capability] = struct{}{} } for _, requiredCapability := range required { if _, ok := availableSet[requiredCapability]; !ok { return false } } return true } func buildDecision( target resolvedTarget, ruleID, reason, configRevision, selectionRevision string, candidates []CandidateEvaluation, now time.Time, ) RouteDecision { decisionID := digestParts( "route-decision", configRevision, selectionRevision, target.providerID, target.modelID, target.profileID, target.profileRevision, ruleID, reason, now.Format(time.RFC3339Nano), ) return RouteDecision{ ProviderID: target.providerID, ModelID: target.modelID, ProfileID: target.profileID, ProfileRevision: target.profileRevision, ConfigRevision: configRevision, SelectionRevision: selectionRevision, SelectedRuleID: ruleID, SelectedReason: reason, Candidates: append([]CandidateEvaluation(nil), candidates...), History: RouteHistory{ DecisionID: decisionID, SelectedAt: now, UsedRoutes: []UsedRoute{{ ProviderID: target.providerID, ModelID: target.modelID, ProfileID: target.profileID, ProfileRevision: target.profileRevision, RuleID: ruleID, Reason: reason, UsedAt: now, }}, }, } }