mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-09 12:23:57 +00:00
- 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
231 lines
7.5 KiB
Go
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, ¶ms); 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
|
|
}
|