Files
goclaw/internal/agent/evolution_guardrails.go
viettranx 6d43de4169 fix(evolution): fix 7 bugs in evolution flow — guardrails, cron, dedup, dead types
- Remove broken delta comparison in CheckGuardrails (compared usage rate vs max delta)
- Replace hardcoded +0.05 threshold bump with configurable MaxDeltaPerCycle, cap at 0.95
- Remove dead MetricFeedback and SuggestMemoryPrune constants + UI references
- Make SuggestToolOrder approval actionable — disables tool via BuiltinToolTenantConfigStore
- Replace time.Ticker(24h) with wall-clock scheduling (3 AM daily, Sundays weekly eval)
- Add PG advisory lock on pinned connection for multi-instance safety
- Add EvolutionSuggest flag check in cron (metrics-only agents skip suggestion analysis)
- Change suggestion dedup from type-only to (type, metric_key) composite key
2026-04-10 10:13:58 +07:00

231 lines
7.5 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"slices"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// AdaptationGuardrails controls auto-adaptation safety limits.
// Stored in agent other_config JSONB under "evolution_guardrails".
type AdaptationGuardrails struct {
MaxDeltaPerCycle float64 `json:"max_delta_per_cycle"` // max parameter change per cycle (default 0.1)
MinDataPoints int `json:"min_data_points"` // min metrics before applying (default 100)
RollbackOnDrop float64 `json:"rollback_on_drop_pct"` // quality drop % triggering rollback (default 20.0)
LockedParams []string `json:"locked_params,omitempty"` // params that cannot be auto-changed
}
// DefaultGuardrails returns conservative defaults.
func DefaultGuardrails() AdaptationGuardrails {
return AdaptationGuardrails{
MaxDeltaPerCycle: 0.1,
MinDataPoints: 100,
RollbackOnDrop: 20.0,
}
}
// CheckGuardrails validates a suggestion against guardrail constraints.
// Returns an error describing the violation, or nil if safe to apply.
func CheckGuardrails(g AdaptationGuardrails, sg store.EvolutionSuggestion, dataPoints int) error {
// Check minimum data points.
minDP := g.MinDataPoints
if minDP <= 0 {
minDP = 100
}
if dataPoints < minDP {
return fmt.Errorf("insufficient data: %d points, need %d", dataPoints, minDP)
}
// Check locked parameters.
if len(g.LockedParams) > 0 {
var params map[string]any
if err := json.Unmarshal(sg.Parameters, &params); err == nil {
for key := range params {
if slices.Contains(g.LockedParams, key) {
return fmt.Errorf("parameter %q is locked", key)
}
}
}
}
// Threshold suggestions: the applied delta is capped at MaxDeltaPerCycle in ApplySuggestion,
// so no additional delta check is needed here. MinDataPoints and LockedParams already guard safety.
return nil
}
// ApplySuggestion applies an approved suggestion's parameters to the agent's other_config JSONB.
// Stores the previous values in the suggestion's parameters for rollback.
// Scope: only retrieval-related parameters. Never security or core settings.
func ApplySuggestion(ctx context.Context, agentStore store.AgentStore, sugStore store.EvolutionSuggestionStore, sg store.EvolutionSuggestion, guardrails AdaptationGuardrails) error {
// Load current agent config.
agent, err := agentStore.GetByID(ctx, sg.AgentID)
if err != nil {
return fmt.Errorf("load agent: %w", err)
}
var otherConfig map[string]any
if len(agent.OtherConfig) > 0 {
_ = json.Unmarshal(agent.OtherConfig, &otherConfig)
}
if otherConfig == nil {
otherConfig = make(map[string]any)
}
// Store baseline for rollback: snapshot current retrieval params.
baseline := make(map[string]any)
if v, ok := otherConfig["retrieval_threshold"]; ok {
baseline["retrieval_threshold"] = v
}
// Apply suggestion-specific changes based on type.
switch sg.SuggestionType {
case store.SuggestThreshold:
// Raise retrieval threshold by MaxDeltaPerCycle (bounded by guardrails).
current, _ := otherConfig["retrieval_threshold"].(float64)
if current == 0 {
current = 0.3 // default threshold
}
delta := guardrails.MaxDeltaPerCycle
if delta <= 0 {
delta = 0.05 // fallback
}
newThreshold := current + delta
if newThreshold > 0.95 {
newThreshold = 0.95
}
otherConfig["retrieval_threshold"] = newThreshold
default:
// Non-threshold suggestions are informational only, no auto-apply.
return fmt.Errorf("suggestion type %q does not support auto-apply", sg.SuggestionType)
}
// Save updated config.
configJSON, _ := json.Marshal(otherConfig)
if err := agentStore.Update(ctx, sg.AgentID, map[string]any{
"other_config": json.RawMessage(configJSON),
}); err != nil {
return fmt.Errorf("update agent config: %w", err)
}
// Persist baseline into suggestion parameters for future rollback.
var sgParams map[string]any
_ = json.Unmarshal(sg.Parameters, &sgParams)
if sgParams == nil {
sgParams = make(map[string]any)
}
sgParams["_baseline"] = baseline
updatedParams, _ := json.Marshal(sgParams)
if err := sugStore.UpdateSuggestionParameters(ctx, sg.ID, updatedParams); err != nil {
return fmt.Errorf("save baseline: %w", err)
}
if err := sugStore.UpdateSuggestionStatus(ctx, sg.ID, "applied", "auto-adapt"); err != nil {
return fmt.Errorf("update suggestion status: %w", err)
}
slog.Info("evolution.auto_adapt.applied", "agent", sg.AgentID, "type", sg.SuggestionType)
return nil
}
// RollbackSuggestion reverts an applied suggestion by restoring baseline values.
func RollbackSuggestion(ctx context.Context, agentStore store.AgentStore, sugStore store.EvolutionSuggestionStore, sg store.EvolutionSuggestion) error {
// Extract baseline from suggestion params.
var sgParams map[string]any
if err := json.Unmarshal(sg.Parameters, &sgParams); err != nil {
return fmt.Errorf("parse suggestion params: %w", err)
}
baseline, _ := sgParams["_baseline"].(map[string]any)
if baseline == nil {
return fmt.Errorf("no baseline data for rollback")
}
// Load current config and restore baseline values.
agent, err := agentStore.GetByID(ctx, sg.AgentID)
if err != nil {
return fmt.Errorf("load agent: %w", err)
}
var otherConfig map[string]any
if len(agent.OtherConfig) > 0 {
_ = json.Unmarshal(agent.OtherConfig, &otherConfig)
}
if otherConfig == nil {
otherConfig = make(map[string]any)
}
// Restore each baseline parameter.
maps.Copy(otherConfig, baseline)
configJSON, _ := json.Marshal(otherConfig)
if err := agentStore.Update(ctx, sg.AgentID, map[string]any{
"other_config": json.RawMessage(configJSON),
}); err != nil {
return fmt.Errorf("rollback agent config: %w", err)
}
if err := sugStore.UpdateSuggestionStatus(ctx, sg.ID, "rolled_back", "auto-adapt"); err != nil {
return fmt.Errorf("update suggestion status: %w", err)
}
slog.Info("evolution.auto_adapt.rolled_back", "agent", sg.AgentID, "type", sg.SuggestionType)
return nil
}
// EvaluateApplied checks if applied suggestions improved or degraded quality.
// Rolls back suggestions where quality dropped more than the guardrail threshold.
func EvaluateApplied(ctx context.Context, agentID uuid.UUID, guardrails AdaptationGuardrails,
metricsStore store.EvolutionMetricsStore, sugStore store.EvolutionSuggestionStore,
agentStore store.AgentStore) error {
// Find applied suggestions for this agent.
applied, err := sugStore.ListSuggestions(ctx, agentID, "applied", 20)
if err != nil {
return err
}
rollbackPct := guardrails.RollbackOnDrop
if rollbackPct <= 0 {
rollbackPct = 20.0
}
for _, sg := range applied {
// Only evaluate threshold suggestions (the only auto-apply type).
if sg.SuggestionType != store.SuggestThreshold {
continue
}
// Compare current retrieval usage to baseline.
var sgParams map[string]any
_ = json.Unmarshal(sg.Parameters, &sgParams)
baselineRate, _ := sgParams["current_usage_rate"].(float64)
if baselineRate == 0 {
continue
}
// Get current retrieval metrics.
currentAggs, err := metricsStore.AggregateRetrievalMetrics(ctx, agentID, sg.CreatedAt)
if err != nil || len(currentAggs) == 0 {
continue
}
// Check if usage rate dropped significantly.
for _, agg := range currentAggs {
drop := (baselineRate - agg.UsageRate) / baselineRate * 100
if drop > rollbackPct {
slog.Warn("evolution.auto_adapt.quality_drop",
"agent", agentID, "source", agg.Source,
"baseline", baselineRate, "current", agg.UsageRate, "drop_pct", drop)
_ = RollbackSuggestion(ctx, agentStore, sugStore, sg)
break
}
}
}
return nil
}