mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-08-11 00:24:45 +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
146 lines
4.3 KiB
Go
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
|
|
}
|