615 lines
16 KiB
Go
615 lines
16 KiB
Go
package evolution
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"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
|
|
SkillsRecaller *SkillsRecaller
|
|
DraftGenerator DraftGenerator
|
|
GeneratorFactory func(workspace string) DraftGenerator
|
|
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
|
|
skillsRecaller *SkillsRecaller
|
|
draftGenerator DraftGenerator
|
|
generatorFactory func(workspace string) DraftGenerator
|
|
applier *Applier
|
|
applierFactory func(workspace string) *Applier
|
|
}
|
|
|
|
type TurnCaseInput struct {
|
|
Workspace string
|
|
WorkspaceID string
|
|
TurnID string
|
|
SessionKey string
|
|
AgentID string
|
|
Status string
|
|
ToolKinds []string
|
|
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.MinCaseCount,
|
|
MinSuccessRate: opts.Config.MinSuccessRate,
|
|
Now: now,
|
|
})
|
|
}
|
|
|
|
return &Runtime{
|
|
cfg: opts.Config,
|
|
now: now,
|
|
store: opts.Store,
|
|
organizer: organizer,
|
|
skillsRecaller: opts.SkillsRecaller,
|
|
draftGenerator: opts.DraftGenerator,
|
|
generatorFactory: opts.GeneratorFactory,
|
|
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 == "" {
|
|
return nil
|
|
}
|
|
|
|
success := input.Status == "completed"
|
|
workspaceID := input.WorkspaceID
|
|
if workspaceID == "" {
|
|
workspaceID = input.Workspace
|
|
}
|
|
|
|
record := LearningRecord{
|
|
ID: input.TurnID,
|
|
Kind: RecordKindTask,
|
|
WorkspaceID: workspaceID,
|
|
CreatedAt: rt.now(),
|
|
SessionKey: input.SessionKey,
|
|
TaskHash: buildTaskHash(input),
|
|
Summary: fmt.Sprintf("turn %s finished with status=%s", input.TurnID, input.Status),
|
|
Source: map[string]any{"turn_id": input.TurnID, "session_key": input.SessionKey, "agent_id": input.AgentID},
|
|
Status: RecordStatus("new"),
|
|
Success: &success,
|
|
ToolKinds: append([]string(nil), input.ToolKinds...),
|
|
ActiveSkillNames: append([]string(nil), input.ActiveSkillNames...),
|
|
AttemptTrail: buildAttemptTrail(input, success),
|
|
Signals: buildLearningSignals(input, success),
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return rt.recordSkillUsage(input, success)
|
|
}
|
|
|
|
func buildAttemptTrail(input TurnCaseInput, success bool) *AttemptTrail {
|
|
attemptedInput := input.AttemptedSkillNames
|
|
if len(attemptedInput) == 0 {
|
|
attemptedInput = input.ActiveSkillNames
|
|
}
|
|
|
|
attempted := make([]string, 0, len(attemptedInput))
|
|
for _, skillName := range attemptedInput {
|
|
skillName = strings.TrimSpace(skillName)
|
|
if skillName == "" {
|
|
continue
|
|
}
|
|
attempted = append(attempted, skillName)
|
|
}
|
|
if len(attempted) == 0 {
|
|
return nil
|
|
}
|
|
|
|
trail := &AttemptTrail{
|
|
AttemptedSkills: attempted,
|
|
}
|
|
if len(input.SkillContextSnapshots) > 0 {
|
|
trail.SkillContextSnapshots = cloneSkillContextSnapshots(input.SkillContextSnapshots)
|
|
}
|
|
if success {
|
|
finalPathInput := input.FinalSuccessfulPath
|
|
if len(finalPathInput) == 0 {
|
|
finalPathInput = attempted
|
|
}
|
|
finalPath := make([]string, 0, len(finalPathInput))
|
|
for _, skillName := range finalPathInput {
|
|
skillName = strings.TrimSpace(skillName)
|
|
if skillName == "" {
|
|
continue
|
|
}
|
|
finalPath = append(finalPath, skillName)
|
|
}
|
|
trail.FinalSuccessfulPath = finalPath
|
|
}
|
|
return trail
|
|
}
|
|
|
|
func cloneSkillContextSnapshots(input []SkillContextSnapshot) []SkillContextSnapshot {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]SkillContextSnapshot, 0, len(input))
|
|
for _, snapshot := range input {
|
|
out = append(out, SkillContextSnapshot{
|
|
Sequence: snapshot.Sequence,
|
|
Trigger: snapshot.Trigger,
|
|
SkillNames: append([]string(nil), snapshot.SkillNames...),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildTaskHash(input TurnCaseInput) string {
|
|
skillValues := input.AttemptedSkillNames
|
|
if len(skillValues) == 0 {
|
|
skillValues = input.ActiveSkillNames
|
|
}
|
|
|
|
parts := make([]string, 0, len(skillValues)+len(input.ToolKinds)+2)
|
|
parts = append(parts, normalizedValues(skillValues)...)
|
|
parts = append(parts, normalizedValues(input.ToolKinds)...)
|
|
if strings.TrimSpace(input.Status) != "" {
|
|
parts = append(parts, strings.ToLower(strings.TrimSpace(input.Status)))
|
|
}
|
|
if len(parts) == 0 {
|
|
parts = append(parts, "empty")
|
|
}
|
|
|
|
sum := sha1.Sum([]byte(strings.Join(parts, "\x00")))
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
func buildLearningSignals(input TurnCaseInput, success bool) []string {
|
|
if !success {
|
|
return nil
|
|
}
|
|
skillValues := input.AttemptedSkillNames
|
|
if len(skillValues) == 0 {
|
|
skillValues = input.ActiveSkillNames
|
|
}
|
|
if len(normalizedValues(skillValues)) > 1 {
|
|
return []string{"potentially_learnable"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizedValues(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
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()
|
|
if mode == "" || mode == "observe" {
|
|
return nil
|
|
}
|
|
|
|
store := rt.storeForWorkspace(workspace)
|
|
records, err := store.LoadLearningRecords()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if rt.organizer != nil {
|
|
rules, err := rt.organizer.BuildRules(records)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newRules := filterNewRules(records, rules, workspace)
|
|
if len(newRules) > 0 {
|
|
if err := store.AppendLearningRecords(newRules); err != nil {
|
|
return err
|
|
}
|
|
records = append(records, newRules...)
|
|
}
|
|
}
|
|
|
|
generator := rt.draftGeneratorForWorkspace(workspace)
|
|
if generator == nil {
|
|
return nil
|
|
}
|
|
|
|
recaller := rt.skillsRecallerForWorkspace(workspace)
|
|
applier := rt.applierForWorkspace(workspace)
|
|
readyRules := filterReadyRules(records, workspace)
|
|
if len(readyRules) == 0 {
|
|
return nil
|
|
}
|
|
|
|
existingDrafts, err := store.LoadDrafts()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
existingBySource := existingDraftSourceSet(existingDrafts, workspace)
|
|
|
|
for _, rule := range readyRules {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if _, exists := existingBySource[rule.ID]; exists {
|
|
continue
|
|
}
|
|
|
|
matches, err := recaller.RecallSimilarSkills(rule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
draft, err := generator.GenerateDraft(ctx, rule, matches)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
draft = rt.finalizeDraft(workspace, rule, matches, draft)
|
|
if mode == "apply" && rt.cfg.AutoApply && applier != nil && draft.Status == DraftStatusCandidate {
|
|
rollbackApply, err := applier.applyDraftWithRollback(ctx, workspace, draft)
|
|
if err != nil {
|
|
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 errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr)
|
|
}
|
|
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr)
|
|
}
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
|
}
|
|
return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
|
}
|
|
draft.Status = DraftStatusAccepted
|
|
if err := rt.saveAppliedProfile(store, workspace, draft); err != nil {
|
|
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 errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr)
|
|
}
|
|
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr)
|
|
}
|
|
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
|
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
|
}
|
|
return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
|
}
|
|
}
|
|
|
|
if err := store.SaveDrafts([]SkillDraft{draft}); err != nil {
|
|
return err
|
|
}
|
|
existingBySource[rule.ID] = struct{}{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rt *Runtime) storeForWorkspace(workspace string) *Store {
|
|
paths := NewPaths(workspace, rt.cfg.StateDir)
|
|
|
|
rt.mu.Lock()
|
|
defer rt.mu.Unlock()
|
|
|
|
if rt.store == nil || rt.store.paths.RootDir != paths.RootDir {
|
|
rt.store = NewStore(paths)
|
|
}
|
|
return rt.store
|
|
}
|
|
|
|
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) 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, 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
|
|
if len(draft.MatchedSkillRefs) == 0 {
|
|
draft.MatchedSkillRefs = collectSkillRefs(matches)
|
|
}
|
|
|
|
review := ReviewDraft(draft)
|
|
draft.Status = review.Status
|
|
draft.ReviewNotes = append([]string(nil), review.ReviewNotes...)
|
|
if len(review.Findings) == 0 {
|
|
draft.ScanFindings = nil
|
|
return draft
|
|
}
|
|
draft.ScanFindings = append([]string(nil), review.Findings...)
|
|
return draft
|
|
}
|
|
|
|
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 filterNewRules(records []LearningRecord, rules []LearningRecord, workspace string) []LearningRecord {
|
|
existing := make(map[string]struct{}, len(records))
|
|
for _, record := range records {
|
|
if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
existing[record.ID] = struct{}{}
|
|
}
|
|
|
|
out := make([]LearningRecord, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if rule.WorkspaceID != workspace {
|
|
continue
|
|
}
|
|
if _, ok := existing[rule.ID]; ok {
|
|
continue
|
|
}
|
|
existing[rule.ID] = struct{}{}
|
|
out = append(out, rule)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error {
|
|
profile, err := store.LoadProfile(draft.TargetSkillName)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
now := rt.now()
|
|
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 store.SaveProfile(profile)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (rt *Runtime) recordSkillUsage(input TurnCaseInput, success bool) error {
|
|
if len(input.ActiveSkillNames) == 0 {
|
|
return nil
|
|
}
|
|
|
|
store := rt.storeForWorkspace(input.Workspace)
|
|
seen := make(map[string]struct{}, len(input.ActiveSkillNames))
|
|
for _, skillName := range input.ActiveSkillNames {
|
|
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()
|
|
|
|
profile, err := store.LoadProfile(skillName)
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
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 store.SaveProfile(profile)
|
|
}
|
|
|
|
func nextRetentionScore(current float64, success bool) float64 {
|
|
increment := 0.05
|
|
if success {
|
|
increment = 0.1
|
|
}
|
|
current += increment
|
|
if current > 1 {
|
|
return 1
|
|
}
|
|
return current
|
|
}
|