Files
picoclaw/pkg/evolution/runtime.go
T
lxowalle b3a7b7ad64 feat: agent self evolution (#2847)
* feat: add agent self-evolution

* fix ci

* delete unused doc

* fix lint

* fix evolution review issues
2026-05-11 16:13:27 +08:00

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
}