Files
goclaw/internal/agent/suggestion_engine.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

146 lines
4.3 KiB
Go

package agent
import (
"context"
"encoding/json"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// AnalysisInput bundles aggregated metrics for rule evaluation.
type AnalysisInput struct {
ToolAggs []store.ToolAggregate
RetrievalAggs []store.RetrievalAggregate
Since time.Time
}
// AnalysisRule evaluates aggregated metrics and optionally returns a suggestion.
// Returns nil when no suggestion is warranted.
type AnalysisRule interface {
Name() string
Evaluate(ctx context.Context, agentID uuid.UUID, input AnalysisInput) (*store.EvolutionSuggestion, error)
}
// SuggestionEngine analyzes agent metrics and generates actionable suggestions.
// Runs as a periodic cron job. Suggestions require admin review before application.
type SuggestionEngine struct {
metrics store.EvolutionMetricsStore
suggestions store.EvolutionSuggestionStore
rules []AnalysisRule
}
// NewSuggestionEngine creates a suggestion engine with default rules.
func NewSuggestionEngine(metrics store.EvolutionMetricsStore, suggestions store.EvolutionSuggestionStore) *SuggestionEngine {
return &SuggestionEngine{
metrics: metrics,
suggestions: suggestions,
rules: []AnalysisRule{
&LowRetrievalUsageRule{},
&ToolFailureRule{},
&RepeatedToolRule{},
},
}
}
// dedupKey uniquely identifies a suggestion by type + metric key (e.g., tool name or source).
type dedupKey struct {
Type store.SuggestionType
Key string
}
// extractMetricKey returns the distinguishing key from suggestion parameters.
func extractMetricKey(params json.RawMessage, st store.SuggestionType) string {
var p map[string]any
if json.Unmarshal(params, &p) != nil {
return ""
}
switch st {
case store.SuggestThreshold:
s, _ := p["source"].(string)
return s
case store.SuggestToolOrder, store.SuggestSkillAdd:
s, _ := p["tool"].(string)
return s
default:
return ""
}
}
// Analyze runs all rules against a single agent's metrics (7-day window).
// Returns newly created suggestions. Skips rules that produce duplicates.
func (e *SuggestionEngine) Analyze(ctx context.Context, agentID uuid.UUID) ([]store.EvolutionSuggestion, error) {
since := time.Now().Add(-7 * 24 * time.Hour)
toolAggs, err := e.metrics.AggregateToolMetrics(ctx, agentID, since)
if err != nil {
return nil, err
}
retrievalAggs, err := e.metrics.AggregateRetrievalMetrics(ctx, agentID, since)
if err != nil {
return nil, err
}
input := AnalysisInput{
ToolAggs: toolAggs,
RetrievalAggs: retrievalAggs,
Since: since,
}
// Load existing pending suggestions to avoid duplicates (composite key: type + metric key).
existing, _ := e.suggestions.ListSuggestions(ctx, agentID, "pending", 100)
existingKeys := make(map[dedupKey]bool, len(existing))
for _, sg := range existing {
mk := extractMetricKey(sg.Parameters, sg.SuggestionType)
existingKeys[dedupKey{sg.SuggestionType, mk}] = true
}
var created []store.EvolutionSuggestion
for _, rule := range e.rules {
sg, err := rule.Evaluate(ctx, agentID, input)
if err != nil {
slog.Debug("evolution.rule.error", "rule", rule.Name(), "agent", agentID, "error", err)
continue
}
if sg == nil {
continue
}
// Skip if pending suggestion with same type + metric key already exists.
mk := extractMetricKey(sg.Parameters, sg.SuggestionType)
if existingKeys[dedupKey{sg.SuggestionType, mk}] {
continue
}
sg.ID = uuid.New()
sg.AgentID = agentID
sg.Status = "pending"
if err := e.suggestions.CreateSuggestion(ctx, *sg); err != nil {
slog.Warn("evolution.suggestion.create_failed", "rule", rule.Name(), "agent", agentID, "error", err)
continue
}
created = append(created, *sg)
existingKeys[dedupKey{sg.SuggestionType, mk}] = true
}
return created, nil
}
// AnalyzeAll runs analysis for all agents with evolution metrics in a tenant.
// Intended to be called from a daily cron job.
func (e *SuggestionEngine) AnalyzeAll(ctx context.Context, agentIDs []uuid.UUID) error {
for _, agentID := range agentIDs {
if _, err := e.Analyze(ctx, agentID); err != nil {
slog.Warn("evolution.analyze_failed", "agent", agentID, "error", err)
}
}
return nil
}
// marshalParams marshals suggestion parameters to JSON, returning nil on error.
func marshalParams(params map[string]any) json.RawMessage {
data, _ := json.Marshal(params)
return data
}