iop/packages/go/agentpolicy/evaluator.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

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,
}},
},
}
}