picoclaw/pkg/evolution/runtime.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
}