독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
539 lines
14 KiB
Go
539 lines
14 KiB
Go
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,
|
|
}},
|
|
},
|
|
}
|
|
}
|