mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b3a7b7ad64
* feat: add agent self-evolution * fix ci * delete unused doc * fix lint * fix evolution review issues
1580 lines
45 KiB
Go
1580 lines
45 KiB
Go
package evolution
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
)
|
|
|
|
var ErrApplyDraftFailed = errors.New("apply draft failed")
|
|
|
|
type RuntimeOptions struct {
|
|
Config config.EvolutionConfig
|
|
Now func() time.Time
|
|
Store *Store
|
|
Organizer *Organizer
|
|
PatternClusterer PatternClusterer
|
|
SuccessJudge SuccessJudge
|
|
SkillsRecaller *SkillsRecaller
|
|
DraftGenerator DraftGenerator
|
|
GeneratorFactory func(workspace string) DraftGenerator
|
|
SuccessJudgeFactory func(workspace string) SuccessJudge
|
|
Applier *Applier
|
|
ApplierFactory func(workspace string) *Applier
|
|
}
|
|
|
|
type Runtime struct {
|
|
cfg config.EvolutionConfig
|
|
mu sync.Mutex
|
|
now func() time.Time
|
|
writer *CaseWriter
|
|
store *Store
|
|
organizer *Organizer
|
|
patternClusterer PatternClusterer
|
|
successJudge SuccessJudge
|
|
skillsRecaller *SkillsRecaller
|
|
draftGenerator DraftGenerator
|
|
generatorFactory func(workspace string) DraftGenerator
|
|
successJudgeFactory func(workspace string) SuccessJudge
|
|
applier *Applier
|
|
applierFactory func(workspace string) *Applier
|
|
}
|
|
|
|
type TurnCaseInput struct {
|
|
Workspace string
|
|
WorkspaceID string
|
|
TurnID string
|
|
SessionKey string
|
|
AgentID string
|
|
Status string
|
|
UserMessage string
|
|
FinalContent string
|
|
ToolKinds []string
|
|
ToolExecutions []ToolExecutionRecord
|
|
ActiveSkillNames []string
|
|
AttemptedSkillNames []string
|
|
FinalSuccessfulPath []string
|
|
SkillContextSnapshots []SkillContextSnapshot
|
|
}
|
|
|
|
func NewRuntime(opts RuntimeOptions) (*Runtime, error) {
|
|
now := opts.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
|
|
organizer := opts.Organizer
|
|
if organizer == nil {
|
|
organizer = NewOrganizer(OrganizerOptions{
|
|
MinCaseCount: opts.Config.EffectiveMinTaskCount(),
|
|
MinSuccessRate: opts.Config.EffectiveMinSuccessRatio(),
|
|
Now: now,
|
|
})
|
|
}
|
|
|
|
patternClusterer := opts.PatternClusterer
|
|
if patternClusterer == nil {
|
|
patternClusterer = NewHeuristicPatternClusterer(opts.Config.EffectiveMinTaskCount(), now)
|
|
}
|
|
|
|
return &Runtime{
|
|
cfg: opts.Config,
|
|
now: now,
|
|
store: opts.Store,
|
|
organizer: organizer,
|
|
patternClusterer: patternClusterer,
|
|
successJudge: opts.SuccessJudge,
|
|
skillsRecaller: opts.SkillsRecaller,
|
|
draftGenerator: opts.DraftGenerator,
|
|
generatorFactory: opts.GeneratorFactory,
|
|
successJudgeFactory: opts.SuccessJudgeFactory,
|
|
applier: opts.Applier,
|
|
applierFactory: opts.ApplierFactory,
|
|
}, nil
|
|
}
|
|
|
|
func (rt *Runtime) FinalizeTurn(ctx context.Context, input TurnCaseInput) error {
|
|
if rt == nil || !rt.cfg.Enabled || input.Workspace == "" || shouldSkipLearningRecord(input) {
|
|
return nil
|
|
}
|
|
|
|
success := input.Status == "completed"
|
|
usedSkillNames := buildUsedSkillNames(input)
|
|
workspaceID := input.Workspace
|
|
createdAt := rt.now()
|
|
|
|
record := LearningRecord{
|
|
ID: buildTaskRecordID(input, createdAt),
|
|
Kind: RecordKindTask,
|
|
WorkspaceID: workspaceID,
|
|
CreatedAt: createdAt,
|
|
SessionKey: input.SessionKey,
|
|
Summary: buildRecordSummary(input),
|
|
FinalOutput: summarizeText(input.FinalContent, 1200),
|
|
Status: RecordStatus("new"),
|
|
Success: &success,
|
|
UsedSkillNames: append([]string(nil), usedSkillNames...),
|
|
}
|
|
|
|
paths := NewPaths(input.Workspace, rt.cfg.StateDir)
|
|
|
|
rt.mu.Lock()
|
|
if rt.writer == nil || rt.writer.paths.RootDir != paths.RootDir {
|
|
rt.writer = NewCaseWriter(paths)
|
|
}
|
|
writer := rt.writer
|
|
rt.mu.Unlock()
|
|
|
|
if err := writer.AppendCase(ctx, record); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := rt.recordSkillUsage(input, success); err != nil {
|
|
return err
|
|
}
|
|
|
|
logger.DebugCF("evolution", "Recorded hot path learning record", map[string]any{
|
|
"workspace": input.Workspace,
|
|
"turn_id": input.TurnID,
|
|
"success": success,
|
|
"used_skills": len(record.UsedSkillNames),
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func buildTaskRecordID(input TurnCaseInput, createdAt time.Time) string {
|
|
base := strings.TrimSpace(input.TurnID)
|
|
if base == "" {
|
|
base = "turn"
|
|
}
|
|
base = validSkillNameOrEmpty(base)
|
|
if base == "" {
|
|
base = "turn"
|
|
}
|
|
seed := strings.Join([]string{
|
|
input.Workspace,
|
|
input.SessionKey,
|
|
input.AgentID,
|
|
input.TurnID,
|
|
createdAt.UTC().Format(time.RFC3339Nano),
|
|
}, "\x00")
|
|
sum := sha1.Sum([]byte(seed))
|
|
return base + "-" + hex.EncodeToString(sum[:6])
|
|
}
|
|
|
|
func buildRecordSummary(input TurnCaseInput) string {
|
|
if goal := summarizeText(input.UserMessage, 160); goal != "" {
|
|
return goal
|
|
}
|
|
return fmt.Sprintf("turn %s finished with status=%s", input.TurnID, input.Status)
|
|
}
|
|
|
|
func summarizeText(text string, maxLen int) string {
|
|
text = strings.TrimSpace(text)
|
|
if text == "" || maxLen <= 0 {
|
|
return text
|
|
}
|
|
if utf8.RuneCountInString(text) <= maxLen {
|
|
return text
|
|
}
|
|
if maxLen <= 3 {
|
|
runes := []rune(text)
|
|
return string(runes[:maxLen])
|
|
}
|
|
runes := []rune(text)
|
|
return string(runes[:maxLen-3]) + "..."
|
|
}
|
|
|
|
func buildUsedSkillNames(input TurnCaseInput) []string {
|
|
if final := uniqueTrimmedNames(input.FinalSuccessfulPath); len(final) > 0 {
|
|
return final
|
|
}
|
|
out := make([]string, 0)
|
|
for _, exec := range input.ToolExecutions {
|
|
if !exec.Success {
|
|
continue
|
|
}
|
|
out = append(out, exec.SkillNames...)
|
|
}
|
|
return uniqueTrimmedNames(out)
|
|
}
|
|
|
|
func shouldSkipLearningRecord(input TurnCaseInput) bool {
|
|
if strings.EqualFold(strings.TrimSpace(input.SessionKey), "heartbeat") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func uniqueTrimmedNames(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(value)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error {
|
|
if rt == nil || !rt.cfg.Enabled || workspace == "" {
|
|
return nil
|
|
}
|
|
|
|
mode := rt.cfg.EffectiveMode()
|
|
runID := fmt.Sprintf("%d", rt.now().UnixNano())
|
|
if mode == "" || mode == "observe" {
|
|
logger.DebugCF("evolution", "Skipped cold path run", map[string]any{
|
|
"workspace": workspace,
|
|
"mode": mode,
|
|
"run_id": runID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
logger.InfoCF("evolution", "Started cold path run", map[string]any{
|
|
"workspace": workspace,
|
|
"mode": mode,
|
|
"run_id": runID,
|
|
})
|
|
|
|
store := rt.storeForWorkspace(workspace)
|
|
taskRecords, err := store.LoadTaskRecords()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
patternRecords, err := store.LoadPatternRecords()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.DebugCF("evolution", "Loaded evolution records", map[string]any{
|
|
"workspace": workspace,
|
|
"task_count": len(taskRecords),
|
|
"pattern_count": len(patternRecords),
|
|
"run_id": runID,
|
|
})
|
|
|
|
admittedCount := 0
|
|
newRuleCount := 0
|
|
if rt.patternClusterer != nil {
|
|
recordsForOrganizer, evidenceRecordsForOrganizer, inputErr := rt.recordsForColdPathInputs(
|
|
ctx,
|
|
workspace,
|
|
taskRecords,
|
|
)
|
|
if inputErr != nil {
|
|
return inputErr
|
|
}
|
|
recordsForOrganizer = rt.filterRecordsByMinSuccessRatio(
|
|
workspace,
|
|
evidenceRecordsForOrganizer,
|
|
recordsForOrganizer,
|
|
)
|
|
admittedCount = countTaskLearningRecords(recordsForOrganizer)
|
|
logger.DebugCF("evolution", "Admitted task records for cold path", map[string]any{
|
|
"workspace": workspace,
|
|
"admitted_tasks": admittedCount,
|
|
"organizer_input": len(recordsForOrganizer),
|
|
"task_ids": joinRecordIDs(recordsForOrganizer),
|
|
"run_id": runID,
|
|
})
|
|
var rules []LearningRecord
|
|
var clusteredTaskIDs []string
|
|
if clusterer, ok := rt.patternClusterer.(evidencePatternClusterer); ok {
|
|
rules, clusteredTaskIDs, err = clusterer.BuildPatternsWithEvidence(
|
|
ctx,
|
|
workspace,
|
|
recordsForOrganizer,
|
|
evidenceRecordsForOrganizer,
|
|
patternRecords,
|
|
rt.cfg.EffectiveMinSuccessRatio(),
|
|
)
|
|
} else {
|
|
rules, clusteredTaskIDs, err = rt.patternClusterer.BuildPatterns(
|
|
ctx,
|
|
workspace,
|
|
recordsForOrganizer,
|
|
patternRecords,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newRuleCount = countNewPatterns(patternRecords, rules, workspace)
|
|
logger.DebugCF("evolution", "Built learning patterns", map[string]any{
|
|
"workspace": workspace,
|
|
"pattern_count": len(rules),
|
|
"new_patterns": newRuleCount,
|
|
"admitted_tasks": admittedCount,
|
|
"patterns": summarizePatternRecords(rules),
|
|
"run_id": runID,
|
|
})
|
|
if len(rules) > 0 {
|
|
merged := mergePatternRecords(patternRecords, rules, workspace)
|
|
if mergeErr := store.MergePatternRecords(rules); mergeErr != nil {
|
|
return mergeErr
|
|
}
|
|
patternRecords = merged
|
|
}
|
|
if len(clusteredTaskIDs) > 0 {
|
|
if markErr := markTaskRecordsClustered(store, clusteredTaskIDs); markErr != nil {
|
|
return markErr
|
|
}
|
|
}
|
|
}
|
|
|
|
generator := rt.draftGeneratorForWorkspace(workspace)
|
|
if generator == nil {
|
|
logger.DebugCF("evolution", "Skipped drafting because no draft generator is available", map[string]any{
|
|
"workspace": workspace,
|
|
"run_id": runID,
|
|
})
|
|
return rt.runLifecycleMaintenance(workspace, store, runID)
|
|
}
|
|
|
|
recaller := rt.skillsRecallerForWorkspace(workspace)
|
|
applier := rt.applierForWorkspace(workspace)
|
|
readyRules := filterReadyRules(patternRecords, workspace)
|
|
readyRules = enrichReadyRulesForDrafts(readyRules, taskRecords)
|
|
if len(readyRules) == 0 {
|
|
logger.DebugCF("evolution", "Finished cold path run without ready patterns", map[string]any{
|
|
"workspace": workspace,
|
|
"record_count": len(taskRecords),
|
|
"new_patterns": newRuleCount,
|
|
"admitted_tasks": admittedCount,
|
|
"run_id": runID,
|
|
})
|
|
return rt.runLifecycleMaintenance(workspace, store, runID)
|
|
}
|
|
|
|
existingDrafts, err := store.LoadDrafts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
readyRuleByID := make(map[string]LearningRecord, len(readyRules))
|
|
for _, rule := range readyRules {
|
|
readyRuleByID[rule.ID] = rule
|
|
}
|
|
appliedExistingDrafts := 0
|
|
changedExistingDrafts := false
|
|
for _, draft := range existingDrafts {
|
|
if draft.WorkspaceID != workspace || draft.Status != DraftStatusCandidate {
|
|
continue
|
|
}
|
|
rule, ok := readyRuleByID[draft.SourceRecordID]
|
|
if !ok {
|
|
logger.DebugCF(
|
|
"evolution",
|
|
"Skipped existing candidate draft because its source pattern is not ready",
|
|
map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"source_record_id": draft.SourceRecordID,
|
|
"run_id": runID,
|
|
},
|
|
)
|
|
continue
|
|
}
|
|
matches, recallErr := recaller.RecallSimilarSkills(rule)
|
|
if recallErr != nil {
|
|
return recallErr
|
|
}
|
|
draft.MatchedSkillRefs = collectSkillRefs(matches)
|
|
var normalizationNotes []string
|
|
evidence := draftEvidenceForRule(rule, taskRecords)
|
|
draft, normalizationNotes = rt.normalizeDraftForWorkspace(workspace, rule, matches, evidence, draft)
|
|
review := ReviewDraft(draft)
|
|
draft.Status = review.Status
|
|
draft.ReviewNotes = appendUniqueStrings(draft.ReviewNotes, append(review.ReviewNotes, normalizationNotes...)...)
|
|
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, review.Findings...)
|
|
changedExistingDrafts = true
|
|
if draft.Status != DraftStatusCandidate || mode != "apply" || applier == nil {
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
continue
|
|
}
|
|
updatedDraft, applyErr := rt.applyCandidateDraft(ctx, workspace, store, applier, draft, runID)
|
|
if applyErr != nil {
|
|
return applyErr
|
|
}
|
|
if updatedDraft.Status == DraftStatusAccepted {
|
|
appliedExistingDrafts++
|
|
changedExistingDrafts = true
|
|
}
|
|
}
|
|
if changedExistingDrafts {
|
|
existingDrafts, err = store.LoadDrafts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
existingBySource := existingDraftSourceSet(existingDrafts, workspace)
|
|
logger.DebugCF("evolution", "Selected ready patterns for drafting", map[string]any{
|
|
"workspace": workspace,
|
|
"ready_patterns": len(readyRules),
|
|
"existing_draft_count": len(existingBySource),
|
|
"applied_existing": appliedExistingDrafts,
|
|
"ready_pattern_ids": joinRecordIDs(readyRules),
|
|
"ready_patterns_info": summarizePatternRecords(readyRules),
|
|
"run_id": runID,
|
|
})
|
|
|
|
processedRules := 0
|
|
for _, rule := range readyRules {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if _, exists := existingBySource[rule.ID]; exists {
|
|
logger.DebugCF(
|
|
"evolution",
|
|
"Skipped pattern because a non-quarantined draft already exists",
|
|
map[string]any{
|
|
"workspace": workspace,
|
|
"pattern_id": rule.ID,
|
|
"pattern_info": summarizePatternRecord(rule),
|
|
"run_id": runID,
|
|
},
|
|
)
|
|
continue
|
|
}
|
|
|
|
evidence := draftEvidenceForRule(rule, taskRecords)
|
|
rule = enrichRuleWithDraftEvidence(rule, evidence)
|
|
matches, err := recaller.RecallSimilarSkills(rule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.DebugCF("evolution", "Generating skill draft", map[string]any{
|
|
"workspace": workspace,
|
|
"pattern_id": rule.ID,
|
|
"matched_skill_count": len(matches),
|
|
"pattern_info": summarizePatternRecord(rule),
|
|
"run_id": runID,
|
|
})
|
|
|
|
draft, err := generateDraftWithEvidence(ctx, generator, rule, matches, evidence)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
draft = rt.finalizeDraft(workspace, rule, matches, evidence, draft)
|
|
draftSaved := false
|
|
logger.DebugCF("evolution", "Finalized skill draft", map[string]any{
|
|
"workspace": workspace,
|
|
"pattern_id": rule.ID,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"change_kind": string(draft.ChangeKind),
|
|
"status": string(draft.Status),
|
|
"run_id": runID,
|
|
})
|
|
if mode == "apply" && applier != nil && draft.Status == DraftStatusCandidate {
|
|
var err error
|
|
draft, err = rt.applyCandidateDraft(ctx, workspace, store, applier, draft, runID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
draftSaved = true
|
|
}
|
|
|
|
if !draftSaved {
|
|
if err := store.SaveDrafts([]SkillDraft{draft}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
logger.DebugCF("evolution", "Saved skill draft", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"status": string(draft.Status),
|
|
"run_id": runID,
|
|
})
|
|
existingBySource[rule.ID] = struct{}{}
|
|
processedRules++
|
|
}
|
|
|
|
logger.InfoCF("evolution", "Finished cold path run", map[string]any{
|
|
"workspace": workspace,
|
|
"ready_patterns": len(readyRules),
|
|
"processed_patterns": processedRules,
|
|
"new_patterns": newRuleCount,
|
|
"run_id": runID,
|
|
})
|
|
return rt.runLifecycleMaintenance(workspace, store, runID)
|
|
}
|
|
|
|
func (rt *Runtime) recordsForColdPathInputs(
|
|
ctx context.Context,
|
|
workspace string,
|
|
records []LearningRecord,
|
|
) ([]LearningRecord, []LearningRecord, error) {
|
|
admitted := make([]LearningRecord, 0, len(records))
|
|
evidence := make([]LearningRecord, 0, len(records))
|
|
judge := rt.successJudgeForWorkspace(workspace)
|
|
|
|
for _, record := range records {
|
|
if !isTaskRecordKind(record.Kind) || record.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
if reason := coldPathEvidenceRejectReason(record); reason != "" {
|
|
logger.DebugCF("evolution", "Rejected task record for cold path", map[string]any{
|
|
"workspace": workspace,
|
|
"record_id": record.ID,
|
|
"reason": reason,
|
|
})
|
|
continue
|
|
}
|
|
|
|
evidenceRecord := record
|
|
if record.Success != nil && *record.Success && judge != nil {
|
|
decision, err := judge.JudgeTaskRecord(ctx, record)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
judgedSuccess := decision.Success
|
|
evidenceRecord.Success = &judgedSuccess
|
|
if !decision.Success {
|
|
logger.DebugCF("evolution", "Rejected task record by success judge", map[string]any{
|
|
"workspace": workspace,
|
|
"record_id": record.ID,
|
|
"reason": strings.TrimSpace(decision.Reason),
|
|
})
|
|
}
|
|
}
|
|
evidence = append(evidence, evidenceRecord)
|
|
if evidenceRecord.Success == nil || !*evidenceRecord.Success {
|
|
continue
|
|
}
|
|
admitted = append(admitted, evidenceRecord)
|
|
}
|
|
return admitted, evidence, nil
|
|
}
|
|
|
|
func (rt *Runtime) filterRecordsByMinSuccessRatio(
|
|
workspace string,
|
|
allRecords []LearningRecord,
|
|
admittedRecords []LearningRecord,
|
|
) []LearningRecord {
|
|
minRatio := rt.cfg.EffectiveMinSuccessRatio()
|
|
if minRatio <= 0 {
|
|
return admittedRecords
|
|
}
|
|
|
|
type successStats struct {
|
|
success int
|
|
total int
|
|
}
|
|
statsByKey := make(map[string]successStats)
|
|
for _, record := range allRecords {
|
|
key, ok := coldPathSuccessRatioKey(workspace, record)
|
|
if !ok {
|
|
continue
|
|
}
|
|
stats := statsByKey[key]
|
|
stats.total++
|
|
if record.Success != nil && *record.Success {
|
|
stats.success++
|
|
}
|
|
statsByKey[key] = stats
|
|
}
|
|
|
|
out := make([]LearningRecord, 0, len(admittedRecords))
|
|
for _, record := range admittedRecords {
|
|
if !isTaskRecordKind(record.Kind) {
|
|
out = append(out, record)
|
|
continue
|
|
}
|
|
key, ok := coldPathSuccessRatioKey(workspace, record)
|
|
if !ok {
|
|
continue
|
|
}
|
|
stats := statsByKey[key]
|
|
if stats.total == 0 {
|
|
continue
|
|
}
|
|
ratio := float64(stats.success) / float64(stats.total)
|
|
if ratio < minRatio {
|
|
logger.DebugCF("evolution", "Rejected task record below cold path success ratio", map[string]any{
|
|
"workspace": workspace,
|
|
"record_id": record.ID,
|
|
"success_ratio": ratio,
|
|
"min_success_ratio": minRatio,
|
|
"success_count": stats.success,
|
|
"total_count": stats.total,
|
|
})
|
|
continue
|
|
}
|
|
out = append(out, record)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func coldPathSuccessRatioKey(workspace string, record LearningRecord) (string, bool) {
|
|
if !isTaskRecordKind(record.Kind) || record.WorkspaceID != workspace {
|
|
return "", false
|
|
}
|
|
if record.Status != "" && record.Status != RecordStatus("new") {
|
|
return "", false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") {
|
|
return "", false
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") {
|
|
return "", false
|
|
}
|
|
if strings.TrimSpace(record.Summary) == "" {
|
|
return "", false
|
|
}
|
|
key := heuristicClusterKey(record)
|
|
if key == "" {
|
|
return "", false
|
|
}
|
|
return key, true
|
|
}
|
|
|
|
func coldPathEvidenceRejectReason(record LearningRecord) string {
|
|
if !isTaskRecordKind(record.Kind) {
|
|
return "not a task record"
|
|
}
|
|
if record.Success == nil {
|
|
return "task success unknown"
|
|
}
|
|
if record.Status != "" && record.Status != RecordStatus("new") {
|
|
return "task already processed"
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") {
|
|
return "heartbeat session"
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") {
|
|
return "heartbeat output"
|
|
}
|
|
if strings.TrimSpace(record.Summary) == "" {
|
|
return "missing summary"
|
|
}
|
|
if strings.TrimSpace(record.FinalOutput) == "" {
|
|
return "missing final output"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (rt *Runtime) storeForWorkspace(workspace string) *Store {
|
|
paths := NewPaths(workspace, rt.cfg.StateDir)
|
|
if rt.store != nil && rt.store.paths.RootDir == paths.RootDir && rt.store.paths.Workspace == paths.Workspace {
|
|
return rt.store
|
|
}
|
|
return NewStore(paths)
|
|
}
|
|
|
|
func (rt *Runtime) skillsRecallerForWorkspace(workspace string) *SkillsRecaller {
|
|
rt.mu.Lock()
|
|
defer rt.mu.Unlock()
|
|
|
|
if rt.skillsRecaller == nil || rt.skillsRecaller.workspace != workspace {
|
|
rt.skillsRecaller = NewSkillsRecaller(workspace)
|
|
}
|
|
return rt.skillsRecaller
|
|
}
|
|
|
|
func (rt *Runtime) draftGeneratorForWorkspace(workspace string) DraftGenerator {
|
|
if rt.generatorFactory != nil {
|
|
if generator := rt.generatorFactory(workspace); generator != nil {
|
|
return generator
|
|
}
|
|
}
|
|
if rt.draftGenerator != nil {
|
|
return rt.draftGenerator
|
|
}
|
|
return NewDefaultDraftGenerator(workspace)
|
|
}
|
|
|
|
func (rt *Runtime) successJudgeForWorkspace(workspace string) SuccessJudge {
|
|
if rt.successJudgeFactory != nil {
|
|
if judge := rt.successJudgeFactory(workspace); judge != nil {
|
|
return judge
|
|
}
|
|
}
|
|
if rt.successJudge != nil {
|
|
return rt.successJudge
|
|
}
|
|
return &HeuristicSuccessJudge{}
|
|
}
|
|
|
|
func (rt *Runtime) applierForWorkspace(workspace string) *Applier {
|
|
if rt.applierFactory != nil {
|
|
if applier := rt.applierFactory(workspace); applier != nil {
|
|
return applier
|
|
}
|
|
}
|
|
return rt.applier
|
|
}
|
|
|
|
func (rt *Runtime) finalizeDraft(
|
|
workspace string,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
draft SkillDraft,
|
|
) SkillDraft {
|
|
if draft.ID == "" {
|
|
draft.ID = "draft-" + rule.ID
|
|
}
|
|
if draft.CreatedAt.IsZero() {
|
|
draft.CreatedAt = rt.now()
|
|
}
|
|
draft.WorkspaceID = workspace
|
|
draft.SourceRecordID = rule.ID
|
|
draft.MatchedSkillRefs = collectSkillRefs(matches)
|
|
|
|
draft, normalizationNotes := rt.normalizeDraftForWorkspace(workspace, rule, matches, evidence, draft)
|
|
review := ReviewDraft(draft)
|
|
draft.Status = review.Status
|
|
draft.ReviewNotes = append([]string(nil), review.ReviewNotes...)
|
|
draft.ReviewNotes = append(draft.ReviewNotes, normalizationNotes...)
|
|
if len(review.Findings) == 0 {
|
|
draft.ScanFindings = nil
|
|
return draft
|
|
}
|
|
draft.ScanFindings = append([]string(nil), review.Findings...)
|
|
return draft
|
|
}
|
|
|
|
func (rt *Runtime) normalizeDraftForWorkspace(
|
|
workspace string,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
draft SkillDraft,
|
|
) (SkillDraft, []string) {
|
|
target := strings.TrimSpace(draft.TargetSkillName)
|
|
if workspace == "" || target == "" {
|
|
return draft, nil
|
|
}
|
|
|
|
notes := make([]string, 0, 4)
|
|
if combinedTarget := inferCombinedSkillName(rule); combinedTarget != "" && combinedTarget != target {
|
|
originalTarget := target
|
|
draft.TargetSkillName = combinedTarget
|
|
target = combinedTarget
|
|
notes = append(notes, fmt.Sprintf(
|
|
"retargeted draft from %q to combined shortcut skill %q because the winning path was a stable multi-skill chain",
|
|
originalTarget,
|
|
combinedTarget,
|
|
))
|
|
}
|
|
|
|
skillPath := filepath.Join(workspace, "skills", target, "SKILL.md")
|
|
_, err := os.Stat(skillPath)
|
|
hasExisting := err == nil
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return draft, notes
|
|
}
|
|
|
|
if combinedTarget := inferCombinedSkillName(rule); combinedTarget != "" && combinedTarget == target {
|
|
draft.HumanSummary = buildCombinedSkillHumanSummary(target, rule, hasExisting)
|
|
draft.PreferredEntryPath = []string{target}
|
|
draft.AvoidPatterns = appendUniqueStrings(
|
|
draft.AvoidPatterns,
|
|
buildCombinedSkillAvoidPattern(target, rule),
|
|
)
|
|
if hasExisting {
|
|
draft.ChangeKind = ChangeKindAppend
|
|
draft.BodyOrPatch = synthesizeCombinedSkillAppendBody(target, draft, rule, matches, evidence)
|
|
notes = append(notes, "normalized combined shortcut draft to append onto the existing combined skill")
|
|
} else {
|
|
draft.ChangeKind = ChangeKindCreate
|
|
draft.BodyOrPatch = synthesizeCombinedSkillDocument(target, draft, rule, matches, evidence)
|
|
notes = append(notes, "normalized combined shortcut draft to create a new standalone shortcut skill")
|
|
}
|
|
return draft, notes
|
|
}
|
|
|
|
if !hasExisting {
|
|
switch draft.ChangeKind {
|
|
case ChangeKindAppend, ChangeKindMerge, ChangeKindReplace:
|
|
draft.ChangeKind = ChangeKindCreate
|
|
notes = append(notes, "normalized change_kind to create because target skill did not exist")
|
|
if !looksLikeSkillDocument(draft.BodyOrPatch) {
|
|
draft.BodyOrPatch = synthesizeSkillDocumentFromPartialDraft(target, draft, rule, evidence)
|
|
notes = append(notes, "synthesized full skill document because draft body was partial")
|
|
}
|
|
}
|
|
return draft, notes
|
|
}
|
|
|
|
if draft.ChangeKind == ChangeKindCreate && !looksLikeSkillDocument(draft.BodyOrPatch) {
|
|
draft.ChangeKind = ChangeKindAppend
|
|
notes = append(notes, "normalized change_kind to append because target skill already existed")
|
|
}
|
|
return draft, notes
|
|
}
|
|
|
|
func looksLikeSkillDocument(body string) bool {
|
|
body = strings.TrimSpace(body)
|
|
return strings.HasPrefix(body, "---\n") && strings.Contains(body, "\n# ")
|
|
}
|
|
|
|
func synthesizeSkillDocumentFromPartialDraft(
|
|
target string,
|
|
draft SkillDraft,
|
|
rule LearningRecord,
|
|
evidence DraftEvidence,
|
|
) string {
|
|
description := strings.TrimSpace(draft.HumanSummary)
|
|
if description == "" {
|
|
description = fmt.Sprintf("Learned workflow for %s.", target)
|
|
}
|
|
|
|
bodyContent := strings.TrimSpace(draft.BodyOrPatch)
|
|
if bodyContent == "" {
|
|
bodyContent = "No learned content was generated."
|
|
}
|
|
if strings.HasPrefix(bodyContent, "# ") {
|
|
return buildSkillDocument(target, description, bodyContent)
|
|
}
|
|
|
|
body := strings.Join([]string{
|
|
"# " + titleCaseSkillName(target),
|
|
"",
|
|
"## Start Here",
|
|
synthesizedStartHereLine(rule, target),
|
|
"",
|
|
"## Learned Evolution",
|
|
bodyContent,
|
|
"",
|
|
"## Expected Result",
|
|
synthesizedExpectedResultLine(evidence),
|
|
"",
|
|
"## Source Evidence",
|
|
synthesizedEvidenceLine(rule, evidence),
|
|
"",
|
|
}, "\n")
|
|
return buildSkillDocument(target, description, body)
|
|
}
|
|
|
|
func synthesizeCombinedSkillDocument(
|
|
target string,
|
|
draft SkillDraft,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) string {
|
|
description := strings.TrimSpace(draft.HumanSummary)
|
|
if description == "" {
|
|
description = buildCombinedSkillHumanSummary(target, rule, false)
|
|
}
|
|
|
|
body := strings.Join([]string{
|
|
"# " + titleCaseSkillName(target),
|
|
"",
|
|
"## When To Use",
|
|
synthesizedCombinedWhenToUseLine(rule, target),
|
|
"",
|
|
"## Procedure",
|
|
synthesizedCombinedStartHereLine(rule, target),
|
|
synthesizedCombinedProcedure(matches, rule),
|
|
"",
|
|
"## Source Skills",
|
|
synthesizedComponentBreakdown(matches),
|
|
"",
|
|
"## Learned Context",
|
|
synthesizedCombinedLearnedContent(draft.BodyOrPatch, rule),
|
|
"",
|
|
"## Expected Result",
|
|
synthesizedExpectedResultLine(evidence),
|
|
"",
|
|
"## Source Evidence",
|
|
synthesizedEvidenceLine(rule, evidence),
|
|
"",
|
|
}, "\n")
|
|
return buildSkillDocument(target, description, body)
|
|
}
|
|
|
|
func synthesizeCombinedSkillAppendBody(
|
|
target string,
|
|
draft SkillDraft,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) string {
|
|
lines := []string{
|
|
"## Learned Shortcut Update",
|
|
fmt.Sprintf("- Shortcut skill: `%s`", target),
|
|
fmt.Sprintf("- Task summary: %s", fallbackEvolutionSummary(rule)),
|
|
fmt.Sprintf("- Wrapped path: %s", synthesizedWrappedPathLine(rule)),
|
|
"- Guidance: prefer this shortcut directly instead of replaying the whole path when the task matches.",
|
|
fmt.Sprintf("- Expected result: %s", synthesizedExpectedResultLine(evidence)),
|
|
fmt.Sprintf("- Evidence: %s", synthesizedEvidenceLine(rule, evidence)),
|
|
"",
|
|
"### Source Skills",
|
|
synthesizedComponentBreakdown(matches),
|
|
"",
|
|
synthesizedCombinedLearnedContent(draft.BodyOrPatch, rule),
|
|
"",
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func synthesizedStartHereLine(rule LearningRecord, target string) string {
|
|
if len(rule.WinningPath) > 0 {
|
|
return fmt.Sprintf(
|
|
"Start with `%s` for tasks like `%s`.",
|
|
strings.Join(rule.WinningPath, " -> "),
|
|
strings.TrimSpace(rule.Summary),
|
|
)
|
|
}
|
|
if summary := strings.TrimSpace(rule.Summary); summary != "" {
|
|
return fmt.Sprintf("Use `%s` when the task matches `%s`.", target, summary)
|
|
}
|
|
return fmt.Sprintf("Use `%s` for the learned task pattern.", target)
|
|
}
|
|
|
|
func synthesizedCombinedStartHereLine(rule LearningRecord, target string) string {
|
|
return fmt.Sprintf("Use `%s` directly when the task matches `%s`.", target, fallbackEvolutionSummary(rule))
|
|
}
|
|
|
|
func synthesizedCombinedWhenToUseLine(rule LearningRecord, target string) string {
|
|
if len(rule.WinningPath) == 0 {
|
|
return fmt.Sprintf("Use `%s` when the learned task pattern appears again.", target)
|
|
}
|
|
return fmt.Sprintf(
|
|
"Use `%s` as a direct shortcut instead of replaying `%s` step by step.",
|
|
target,
|
|
strings.Join(rule.WinningPath, " -> "),
|
|
)
|
|
}
|
|
|
|
func synthesizedCombinedProcedure(matches []skills.SkillInfo, rule LearningRecord) string {
|
|
components := synthesizedComponentBreakdown(matches)
|
|
if !strings.HasPrefix(strings.TrimSpace(components), "- `") {
|
|
if len(rule.WinningPath) == 0 {
|
|
return "Use the learned shortcut directly and keep the response focused on the requested result."
|
|
}
|
|
return fmt.Sprintf(
|
|
"Apply the recorded path `%s`, then return the final result with only the necessary explanation.",
|
|
strings.Join(rule.WinningPath, " -> "),
|
|
)
|
|
}
|
|
return "Follow the source skill guidance below as one compact procedure, then return the final result without replaying unnecessary discovery steps."
|
|
}
|
|
|
|
func synthesizedExpectedResultLine(evidence DraftEvidence) string {
|
|
if excerpt := firstFinalOutputExcerpt(evidence, 360); excerpt != "" {
|
|
return excerpt
|
|
}
|
|
return "Return the completed result for the matched task without restating unrelated discovery steps."
|
|
}
|
|
|
|
func synthesizedEvidenceLine(rule LearningRecord, evidence DraftEvidence) string {
|
|
if len(evidence.TaskRecords) > 0 {
|
|
ids := make([]string, 0, len(evidence.TaskRecords))
|
|
for _, task := range evidence.TaskRecords {
|
|
if id := strings.TrimSpace(task.ID); id != "" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
if len(ids) > 0 {
|
|
return "learned from task records: " + strings.Join(ids, ", ")
|
|
}
|
|
}
|
|
if len(rule.TaskRecordIDs) > 0 {
|
|
return "learned from task records: " + strings.Join(rule.TaskRecordIDs, ", ")
|
|
}
|
|
return "learned from the pattern record."
|
|
}
|
|
|
|
func synthesizedWrappedPathLine(rule LearningRecord) string {
|
|
if len(rule.WinningPath) == 0 {
|
|
return "No explicit wrapped path was recorded."
|
|
}
|
|
return strings.Join(rule.WinningPath, " -> ")
|
|
}
|
|
|
|
func synthesizedCombinedLearnedContent(body string, rule LearningRecord) string {
|
|
content := strings.TrimSpace(stripSkillFrontmatter(body))
|
|
if content == "" {
|
|
return fmt.Sprintf(
|
|
"Learned from `%s`; use this shortcut directly when the same task pattern appears again.",
|
|
fallbackEvolutionSummary(rule),
|
|
)
|
|
}
|
|
content = removeVerboseCombinedSections(content)
|
|
content = strings.Join(strings.Fields(content), " ")
|
|
if content == "" {
|
|
return fmt.Sprintf(
|
|
"Learned from `%s`; use this shortcut directly when the same task pattern appears again.",
|
|
fallbackEvolutionSummary(rule),
|
|
)
|
|
}
|
|
content = trimAtReadableBoundary(content, 1200)
|
|
return "- Learned task: " + fallbackEvolutionSummary(rule) + "\n- Reusable guidance: " + content
|
|
}
|
|
|
|
func stripSkillFrontmatter(body string) string {
|
|
trimmed := strings.TrimSpace(body)
|
|
if !strings.HasPrefix(trimmed, "---\n") {
|
|
return trimmed
|
|
}
|
|
rest := strings.TrimPrefix(trimmed, "---\n")
|
|
end := strings.Index(rest, "\n---\n")
|
|
if end < 0 {
|
|
return trimmed
|
|
}
|
|
return strings.TrimSpace(rest[end+5:])
|
|
}
|
|
|
|
func removeVerboseCombinedSections(content string) string {
|
|
lines := strings.Split(content, "\n")
|
|
out := make([]string, 0, len(lines))
|
|
skip := false
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "#") {
|
|
title := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
|
|
normalized := strings.ToLower(title)
|
|
switch normalized {
|
|
case "component skill breakdown", "source skills", "wrapped path", "start here", "when to use", "procedure":
|
|
skip = true
|
|
continue
|
|
default:
|
|
skip = false
|
|
}
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
|
}
|
|
|
|
func fallbackEvolutionSummary(rule LearningRecord) string {
|
|
if summary := strings.TrimSpace(rule.Summary); summary != "" {
|
|
return summary
|
|
}
|
|
if len(rule.WinningPath) > 0 {
|
|
return strings.Join(rule.WinningPath, " -> ")
|
|
}
|
|
return "the learned task pattern"
|
|
}
|
|
|
|
func buildCombinedSkillHumanSummary(target string, rule LearningRecord, hasExisting bool) string {
|
|
_ = hasExisting
|
|
summary := fallbackEvolutionSummary(rule)
|
|
if strings.TrimSpace(summary) == "" || summary == "the learned task pattern" {
|
|
summary = titleCaseSkillName(target)
|
|
}
|
|
return fmt.Sprintf("Use this skill to %s when the task requires this workflow.", sentenceFragment(summary))
|
|
}
|
|
|
|
func buildCombinedSkillAvoidPattern(target string, rule LearningRecord) string {
|
|
if len(rule.WinningPath) == 0 {
|
|
return fmt.Sprintf("avoid bypassing `%s` when the same learned task pattern appears again", target)
|
|
}
|
|
return fmt.Sprintf("avoid replaying %s before trying `%s` directly", strings.Join(rule.WinningPath, " -> "), target)
|
|
}
|
|
|
|
func collectSkillRefs(matches []skills.SkillInfo) []string {
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
|
|
refs := make([]string, 0, len(matches))
|
|
for _, match := range matches {
|
|
if strings := match.Path; strings != "" {
|
|
refs = append(refs, strings)
|
|
continue
|
|
}
|
|
refs = append(refs, match.Source+":"+match.Name)
|
|
}
|
|
return refs
|
|
}
|
|
|
|
func countTaskLearningRecords(records []LearningRecord) int {
|
|
count := 0
|
|
for _, record := range records {
|
|
if isTaskRecordKind(record.Kind) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (rt *Runtime) runLifecycleMaintenance(workspace string, store *Store, runID string) error {
|
|
if rt == nil || store == nil || workspace == "" {
|
|
return nil
|
|
}
|
|
|
|
paths := NewPaths(workspace, rt.cfg.StateDir)
|
|
logger.DebugCF("evolution", "Started lifecycle maintenance", map[string]any{
|
|
"workspace": workspace,
|
|
"run_id": runID,
|
|
})
|
|
|
|
summary, err := RunLifecycleOnce(store, paths, workspace, rt.now())
|
|
if err != nil {
|
|
logger.WarnCF("evolution", "Lifecycle maintenance failed", map[string]any{
|
|
"workspace": workspace,
|
|
"run_id": runID,
|
|
"error": err.Error(),
|
|
})
|
|
return err
|
|
}
|
|
|
|
logger.DebugCF("evolution", "Finished lifecycle maintenance", map[string]any{
|
|
"workspace": workspace,
|
|
"run_id": runID,
|
|
"evaluated_profiles": summary.EvaluatedProfiles,
|
|
"transitioned_profiles": summary.TransitionedProfiles,
|
|
"deleted_skills": summary.DeletedSkills,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func joinRecordIDs(records []LearningRecord) string {
|
|
if len(records) == 0 {
|
|
return ""
|
|
}
|
|
ids := make([]string, 0, len(records))
|
|
for _, record := range records {
|
|
if strings.TrimSpace(record.ID) == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, record.ID)
|
|
}
|
|
return strings.Join(ids, ",")
|
|
}
|
|
|
|
func summarizePatternRecords(records []LearningRecord) string {
|
|
if len(records) == 0 {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(records))
|
|
for _, record := range records {
|
|
parts = append(parts, summarizePatternRecord(record))
|
|
}
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
func summarizePatternRecord(record LearningRecord) string {
|
|
label := strings.TrimSpace(record.ID)
|
|
if label == "" {
|
|
label = "unknown-pattern"
|
|
}
|
|
|
|
path := strings.Join(record.WinningPath, " -> ")
|
|
if path == "" {
|
|
path = strings.TrimSpace(record.Summary)
|
|
}
|
|
if path == "" {
|
|
path = "no-summary"
|
|
}
|
|
|
|
return fmt.Sprintf("%s[%s]", label, path)
|
|
}
|
|
|
|
func enrichReadyRulesForDrafts(rules, taskRecords []LearningRecord) []LearningRecord {
|
|
if len(rules) == 0 || len(taskRecords) == 0 {
|
|
return rules
|
|
}
|
|
out := make([]LearningRecord, 0, len(rules))
|
|
for _, rule := range rules {
|
|
evidence := draftEvidenceForRule(rule, taskRecords)
|
|
out = append(out, enrichRuleWithDraftEvidence(rule, evidence))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func draftEvidenceForRule(rule LearningRecord, taskRecords []LearningRecord) DraftEvidence {
|
|
if len(rule.TaskRecordIDs) == 0 || len(taskRecords) == 0 {
|
|
return DraftEvidence{}
|
|
}
|
|
idSet := make(map[string]struct{}, len(rule.TaskRecordIDs))
|
|
for _, id := range rule.TaskRecordIDs {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
idSet[id] = struct{}{}
|
|
}
|
|
if len(idSet) == 0 {
|
|
return DraftEvidence{}
|
|
}
|
|
tasks := make([]LearningRecord, 0, len(idSet))
|
|
for _, task := range taskRecords {
|
|
if rule.WorkspaceID != "" && task.WorkspaceID != rule.WorkspaceID {
|
|
continue
|
|
}
|
|
if _, ok := idSet[task.ID]; !ok {
|
|
continue
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
return DraftEvidence{TaskRecords: tasks}
|
|
}
|
|
|
|
func generateDraftWithEvidence(
|
|
ctx context.Context,
|
|
generator DraftGenerator,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) (SkillDraft, error) {
|
|
if generator == nil {
|
|
return SkillDraft{}, nil
|
|
}
|
|
if evidenceAware, ok := generator.(EvidenceAwareDraftGenerator); ok {
|
|
return evidenceAware.GenerateDraftWithEvidence(ctx, rule, matches, evidence)
|
|
}
|
|
return generator.GenerateDraft(ctx, rule, matches)
|
|
}
|
|
|
|
func countNewPatterns(existing, patterns []LearningRecord, workspace string) int {
|
|
existingIDs := make(map[string]struct{}, len(existing))
|
|
for _, pattern := range existing {
|
|
if !isPatternRecordKind(pattern.Kind) || pattern.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
existingIDs[pattern.ID] = struct{}{}
|
|
}
|
|
count := 0
|
|
for _, pattern := range patterns {
|
|
if pattern.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
if _, ok := existingIDs[pattern.ID]; ok {
|
|
continue
|
|
}
|
|
count++
|
|
}
|
|
return count
|
|
}
|
|
|
|
func mergePatternRecords(existing, updates []LearningRecord, workspace string) []LearningRecord {
|
|
out := append([]LearningRecord(nil), existing...)
|
|
indexByID := make(map[string]int, len(out))
|
|
for i, pattern := range out {
|
|
indexByID[pattern.ID] = i
|
|
}
|
|
for _, update := range updates {
|
|
if update.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
if idx, ok := indexByID[update.ID]; ok {
|
|
out[idx] = update
|
|
continue
|
|
}
|
|
indexByID[update.ID] = len(out)
|
|
out = append(out, update)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func markTaskRecordsClustered(store *Store, ids []string) error {
|
|
if store == nil || len(ids) == 0 {
|
|
return nil
|
|
}
|
|
return store.MarkTaskRecordsClustered(ids)
|
|
}
|
|
|
|
func filterReadyRules(records []LearningRecord, workspace string) []LearningRecord {
|
|
seen := make(map[string]LearningRecord)
|
|
for _, record := range records {
|
|
if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace ||
|
|
record.Status != RecordStatus("ready") {
|
|
continue
|
|
}
|
|
seen[record.ID] = record
|
|
}
|
|
|
|
out := make([]LearningRecord, 0, len(seen))
|
|
for _, record := range seen {
|
|
out = append(out, record)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
|
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
|
}
|
|
return out[i].ID < out[j].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
func existingDraftSourceSet(drafts []SkillDraft, workspace string) map[string]struct{} {
|
|
out := make(map[string]struct{}, len(drafts))
|
|
for _, draft := range drafts {
|
|
if draft.WorkspaceID != workspace || draft.SourceRecordID == "" {
|
|
continue
|
|
}
|
|
if draft.Status == DraftStatusQuarantined {
|
|
continue
|
|
}
|
|
out[draft.SourceRecordID] = struct{}{}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (rt *Runtime) saveAppliedProfile(store *Store, workspace string, draft SkillDraft) error {
|
|
now := rt.now()
|
|
|
|
return SaveAppliedProfile(store, workspace, draft, now)
|
|
}
|
|
|
|
func (rt *Runtime) applyCandidateDraft(
|
|
ctx context.Context,
|
|
workspace string,
|
|
store *Store,
|
|
applier *Applier,
|
|
draft SkillDraft,
|
|
runID string,
|
|
) (SkillDraft, error) {
|
|
logger.InfoCF("evolution", "Applying skill draft", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"change_kind": string(draft.ChangeKind),
|
|
"run_id": runID,
|
|
})
|
|
rollbackApply, err := applier.applyDraftWithRollback(ctx, workspace, draft)
|
|
if err != nil {
|
|
logger.WarnCF("evolution", "Skill draft apply failed", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"error": err.Error(),
|
|
"run_id": runID,
|
|
})
|
|
draft.Status = DraftStatusQuarantined
|
|
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply failed: %v", err))
|
|
if auditErr := rt.recordRollbackAudit(store, draft, err); auditErr != nil {
|
|
draft.ScanFindings = appendUniqueStrings(
|
|
draft.ScanFindings,
|
|
fmt.Sprintf("rollback audit failed: %v", auditErr),
|
|
)
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr)
|
|
}
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr)
|
|
}
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
|
}
|
|
return draft, fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
|
}
|
|
|
|
draft.Status = DraftStatusAccepted
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
logger.WarnCF("evolution", "Skill draft save failed after apply", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"error": saveErr.Error(),
|
|
"run_id": runID,
|
|
})
|
|
if rollbackErr := rollbackApply(); rollbackErr != nil {
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, saveErr), rollbackErr)
|
|
}
|
|
return draft, fmt.Errorf("%w: %v", ErrApplyDraftFailed, saveErr)
|
|
}
|
|
|
|
if err := rt.saveAppliedProfile(store, workspace, draft); err != nil {
|
|
logger.WarnCF("evolution", "Skill profile save failed after apply", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"error": err.Error(),
|
|
"run_id": runID,
|
|
})
|
|
draft.Status = DraftStatusQuarantined
|
|
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("profile save failed: %v", err))
|
|
if rollbackErr := rollbackApply(); rollbackErr != nil {
|
|
draft.ScanFindings = appendUniqueStrings(
|
|
draft.ScanFindings,
|
|
fmt.Sprintf("apply rollback failed: %v", rollbackErr),
|
|
)
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr)
|
|
}
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr)
|
|
}
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
|
}
|
|
return draft, fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
|
}
|
|
logger.InfoCF("evolution", "Applied skill draft successfully", map[string]any{
|
|
"workspace": workspace,
|
|
"draft_id": draft.ID,
|
|
"target_skill": draft.TargetSkillName,
|
|
"run_id": runID,
|
|
})
|
|
return draft, nil
|
|
}
|
|
|
|
func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error {
|
|
now := rt.now()
|
|
return store.UpdateProfile(
|
|
draft.WorkspaceID,
|
|
draft.TargetSkillName,
|
|
func(profile *SkillProfile, exists bool) error {
|
|
if !exists {
|
|
return nil
|
|
}
|
|
profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{
|
|
Version: profile.CurrentVersion,
|
|
Action: "rollback",
|
|
Timestamp: now,
|
|
DraftID: draft.ID,
|
|
Summary: fmt.Sprintf("Rolled back failed draft apply: %s", draft.HumanSummary),
|
|
Rollback: true,
|
|
RollbackReason: applyErr.Error(),
|
|
})
|
|
return nil
|
|
},
|
|
)
|
|
}
|
|
|
|
func profileOrigin(origin string) string {
|
|
if origin == "manual" {
|
|
return origin
|
|
}
|
|
return "evolved"
|
|
}
|
|
|
|
func appendUniqueStrings(existing []string, values ...string) []string {
|
|
seen := make(map[string]struct{}, len(existing))
|
|
for _, value := range existing {
|
|
seen[value] = struct{}{}
|
|
}
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
existing = append(existing, value)
|
|
seen[value] = struct{}{}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
type skillUsageSummary struct {
|
|
All []string
|
|
}
|
|
|
|
func buildSkillUsage(input TurnCaseInput) skillUsageSummary {
|
|
capacity := len(input.ActiveSkillNames) + len(input.AttemptedSkillNames) + len(input.FinalSuccessfulPath)
|
|
for _, snapshot := range input.SkillContextSnapshots {
|
|
capacity += len(snapshot.SkillNames)
|
|
}
|
|
for _, exec := range input.ToolExecutions {
|
|
capacity += len(exec.SkillNames)
|
|
}
|
|
|
|
all := make([]string, 0, capacity)
|
|
all = append(all, input.ActiveSkillNames...)
|
|
all = append(all, input.AttemptedSkillNames...)
|
|
all = append(all, input.FinalSuccessfulPath...)
|
|
for _, snapshot := range input.SkillContextSnapshots {
|
|
all = append(all, snapshot.SkillNames...)
|
|
}
|
|
for _, exec := range input.ToolExecutions {
|
|
all = append(all, exec.SkillNames...)
|
|
}
|
|
return skillUsageSummary{All: uniqueTrimmedNames(all)}
|
|
}
|
|
|
|
func (rt *Runtime) recordSkillUsage(input TurnCaseInput, success bool) error {
|
|
usage := buildSkillUsage(input)
|
|
if len(usage.All) == 0 {
|
|
return nil
|
|
}
|
|
|
|
store := rt.storeForWorkspace(input.Workspace)
|
|
seen := make(map[string]struct{}, len(usage.All))
|
|
for _, skillName := range usage.All {
|
|
skillName = strings.TrimSpace(skillName)
|
|
if skillName == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[skillName]; ok {
|
|
continue
|
|
}
|
|
seen[skillName] = struct{}{}
|
|
|
|
if err := rt.touchSkillProfile(store, input, skillName, success); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (rt *Runtime) touchSkillProfile(store *Store, input TurnCaseInput, skillName string, success bool) error {
|
|
now := rt.now()
|
|
return store.UpdateProfile(input.Workspace, skillName, func(profile *SkillProfile, exists bool) error {
|
|
if !exists {
|
|
*profile = SkillProfile{
|
|
SkillName: skillName,
|
|
WorkspaceID: input.Workspace,
|
|
Status: SkillStatusActive,
|
|
Origin: "manual",
|
|
HumanSummary: skillName,
|
|
RetentionScore: 0.2,
|
|
}
|
|
}
|
|
|
|
profile.SkillName = skillName
|
|
profile.WorkspaceID = input.Workspace
|
|
if profile.Status == SkillStatusCold || profile.Status == SkillStatusArchived || profile.Status == "" {
|
|
profile.Status = SkillStatusActive
|
|
}
|
|
if profile.Origin == "" {
|
|
profile.Origin = "manual"
|
|
}
|
|
if strings.TrimSpace(profile.HumanSummary) == "" {
|
|
profile.HumanSummary = skillName
|
|
}
|
|
profile.LastUsedAt = now
|
|
profile.UseCount++
|
|
profile.RetentionScore = nextRetentionScore(profile.RetentionScore, success)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func nextRetentionScore(current float64, success bool) float64 {
|
|
increment := 0.05
|
|
if success {
|
|
increment = 0.1
|
|
}
|
|
current += increment
|
|
if current > 1 {
|
|
return 1
|
|
}
|
|
return current
|
|
}
|