From 5e51d5c9a4401d44ac40f84fefb73befd083aa6d Mon Sep 17 00:00:00 2001 From: lxowalle Date: Thu, 30 Apr 2026 11:20:40 +0800 Subject: [PATCH] feat: integrate automatic skill evolution runtime --- cmd/picoclaw/internal/evolution/apply.go | 78 -- cmd/picoclaw/internal/evolution/command.go | 21 - .../internal/evolution/command_test.go | 969 ------------------ .../internal/evolution/draft_helpers.go | 56 - cmd/picoclaw/internal/evolution/drafts.go | 58 -- cmd/picoclaw/internal/evolution/helpers.go | 18 - cmd/picoclaw/internal/evolution/prune.go | 54 - cmd/picoclaw/internal/evolution/review.go | 127 --- cmd/picoclaw/internal/evolution/rollback.go | 160 --- cmd/picoclaw/internal/evolution/run_once.go | 55 - cmd/picoclaw/internal/evolution/status.go | 173 ---- .../internal/evolution/workspace_scope.go | 37 - cmd/picoclaw/main.go | 2 - pkg/agent/events.go | 10 + pkg/agent/evolution_bridge.go | 106 +- pkg/agent/evolution_bridge_test.go | 90 +- pkg/agent/pipeline_execute.go | 100 +- pkg/agent/pipeline_execute_test.go | 50 + pkg/agent/turn_coord.go | 3 + pkg/agent/turn_state.go | 44 + pkg/config/config.go | 29 +- pkg/config/config_test.go | 69 +- pkg/config/defaults.go | 10 +- pkg/evolution/apply.go | 11 + pkg/evolution/apply_test.go | 33 + pkg/evolution/drafts.go | 131 +++ pkg/evolution/drafts_test.go | 39 +- pkg/evolution/lifecycle.go | 62 ++ pkg/evolution/llm_draft_generator.go | 12 + pkg/evolution/organizer.go | 68 +- pkg/evolution/organizer_test.go | 65 ++ pkg/evolution/runtime.go | 890 +++++++++++++--- pkg/evolution/runtime_apply_test.go | 186 +++- pkg/evolution/runtime_cold_path_test.go | 163 ++- pkg/evolution/runtime_test.go | 223 +++- pkg/evolution/success_judge.go | 168 +++ pkg/evolution/types.go | 60 +- 37 files changed, 2340 insertions(+), 2090 deletions(-) delete mode 100644 cmd/picoclaw/internal/evolution/apply.go delete mode 100644 cmd/picoclaw/internal/evolution/command.go delete mode 100644 cmd/picoclaw/internal/evolution/command_test.go delete mode 100644 cmd/picoclaw/internal/evolution/draft_helpers.go delete mode 100644 cmd/picoclaw/internal/evolution/drafts.go delete mode 100644 cmd/picoclaw/internal/evolution/helpers.go delete mode 100644 cmd/picoclaw/internal/evolution/prune.go delete mode 100644 cmd/picoclaw/internal/evolution/review.go delete mode 100644 cmd/picoclaw/internal/evolution/rollback.go delete mode 100644 cmd/picoclaw/internal/evolution/run_once.go delete mode 100644 cmd/picoclaw/internal/evolution/status.go delete mode 100644 cmd/picoclaw/internal/evolution/workspace_scope.go create mode 100644 pkg/agent/pipeline_execute_test.go create mode 100644 pkg/evolution/success_judge.go diff --git a/cmd/picoclaw/internal/evolution/apply.go b/cmd/picoclaw/internal/evolution/apply.go deleted file mode 100644 index 31bfa8db0..000000000 --- a/cmd/picoclaw/internal/evolution/apply.go +++ /dev/null @@ -1,78 +0,0 @@ -package evolutioncmd - -import ( - "errors" - "fmt" - "time" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func newApplyCommand() *cobra.Command { - return &cobra.Command{ - Use: "apply ", - Short: "Apply one draft to the current workspace", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - drafts, err := store.LoadDrafts() - if err != nil { - return err - } - - paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir) - index, draft, err := findWorkspaceDraft(drafts, paths, workspace, args[0]) - if err != nil { - return err - } - - now := time.Now().UTC() - applier := evolution.NewApplier(paths, nil) - if err := applier.ApplyDraft(cmd.Context(), workspace, draft); err != nil { - drafts[index].Status = evolution.DraftStatusQuarantined - drafts[index].UpdatedAt = timePtr(now) - drafts[index].ScanFindings = appendUniqueStrings(drafts[index].ScanFindings, fmt.Sprintf("manual apply failed: %v", err)) - if saveErr := store.SaveDrafts(drafts); saveErr != nil { - return errors.Join(err, saveErr) - } - return err - } - - drafts[index].Status = evolution.DraftStatusAccepted - drafts[index].UpdatedAt = timePtr(now) - drafts[index].ReviewNotes = appendUniqueStrings(drafts[index].ReviewNotes, "manually applied via CLI") - if err := store.SaveDrafts(drafts); err != nil { - return err - } - if err := evolution.SaveAppliedProfile(store, workspace, draft, now); err != nil { - return err - } - if err := annotateManualApplyProfile(store, draft.TargetSkillName, draft.ChangeKind); err != nil { - return err - } - - _, err = fmt.Fprintf(cmd.OutOrStdout(), "applied draft=%s target=%s\n", draft.ID, draft.TargetSkillName) - return err - }, - } -} - -func annotateManualApplyProfile(store *evolution.Store, skillName string, changeKind evolution.ChangeKind) error { - profile, err := store.LoadProfile(skillName) - if err != nil { - return err - } - if len(profile.VersionHistory) == 0 { - return nil - } - last := len(profile.VersionHistory) - 1 - profile.VersionHistory[last].Action = "manual_apply:" + string(changeKind) - profile.VersionHistory[last].Summary = "manual CLI apply: " + profile.VersionHistory[last].Summary - return store.SaveProfile(profile) -} diff --git a/cmd/picoclaw/internal/evolution/command.go b/cmd/picoclaw/internal/evolution/command.go deleted file mode 100644 index 10f0398a8..000000000 --- a/cmd/picoclaw/internal/evolution/command.go +++ /dev/null @@ -1,21 +0,0 @@ -package evolutioncmd - -import "github.com/spf13/cobra" - -func NewEvolutionCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "evolution", - Short: "Manage self-evolving skills", - } - - cmd.AddCommand( - newDraftsCommand(), - newReviewCommand(), - newApplyCommand(), - newRollbackCommand(), - newStatusCommand(), - newRunOnceCommand(), - newPruneCommand(), - ) - return cmd -} diff --git a/cmd/picoclaw/internal/evolution/command_test.go b/cmd/picoclaw/internal/evolution/command_test.go deleted file mode 100644 index 6a44d1e40..000000000 --- a/cmd/picoclaw/internal/evolution/command_test.go +++ /dev/null @@ -1,969 +0,0 @@ -package evolutioncmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "os" - "path/filepath" - "reflect" - "sort" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/evolution" - "github.com/sipeed/picoclaw/pkg/providers" -) - -func TestNewEvolutionCommand(t *testing.T) { - cmd := NewEvolutionCommand() - if cmd.Use != "evolution" { - t.Fatalf("Use = %q, want evolution", cmd.Use) - } - - names := make([]string, 0, len(cmd.Commands())) - for _, sub := range cmd.Commands() { - names = append(names, sub.Name()) - } - sort.Strings(names) - - want := []string{"apply", "drafts", "prune", "review", "rollback", "run-once", "status"} - if !reflect.DeepEqual(names, want) { - t.Fatalf("commands = %v, want %v", names, want) - } -} - -func TestDraftsListsWorkspaceDraftDetails(t *testing.T) { - sharedState := filepath.Join(t.TempDir(), "shared-evolution") - workspace := t.TempDir() - otherWorkspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, sharedState) - store := evolution.NewStore(evolution.NewPaths(workspace, sharedState)) - - drafts := []evolution.SkillDraft{ - { - ID: "draft-1", - WorkspaceID: workspace, - SourceRecordID: "pattern-1", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeShortcut, - ChangeKind: evolution.ChangeKindAppend, - HumanSummary: "Prefer native-name query first", - Status: evolution.DraftStatusCandidate, - }, - { - ID: "draft-2", - WorkspaceID: otherWorkspace, - SourceRecordID: "pattern-2", - TargetSkillName: "maps", - DraftType: evolution.DraftTypeWorkflow, - ChangeKind: evolution.ChangeKindReplace, - HumanSummary: "Other workspace draft", - Status: evolution.DraftStatusAccepted, - }, - } - if err := store.SaveDrafts(drafts); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - cmd := newDraftsCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - output := out.String() - for _, want := range []string{ - "id=draft-1", - "status=candidate", - "target=weather", - "change=append", - "summary=Prefer native-name query first", - } { - if !strings.Contains(output, want) { - t.Fatalf("output missing %q:\n%s", want, output) - } - } - if strings.Contains(output, "draft-2") { - t.Fatalf("output should exclude other workspace draft:\n%s", output) - } -} - -func TestReviewShowsDraftDetails(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - now := time.Unix(1700000000, 0).UTC() - if err := store.SaveDrafts([]evolution.SkillDraft{ - { - ID: "draft-review", - WorkspaceID: workspace, - CreatedAt: now, - SourceRecordID: "pattern-1", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeShortcut, - ChangeKind: evolution.ChangeKindAppend, - HumanSummary: "Prefer native-name query first", - IntendedUseCases: []string{ - "weather native-name path", - }, - PreferredEntryPath: []string{"weather"}, - AvoidPatterns: []string{"avoid starting with geocode before using weather"}, - BodyOrPatch: "## Start Here\nUse native-name query first.\n", - Status: evolution.DraftStatusCandidate, - ReviewNotes: []string{"local structural validation completed"}, - }, - }); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - if err := os.MkdirAll(filepath.Join(workspace, "skills", "weather"), 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile( - filepath.Join(workspace, "skills", "weather", "SKILL.md"), - []byte("---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names first.\n"), - 0o644, - ); err != nil { - t.Fatalf("WriteFile: %v", err) - } - if err := store.SaveProfile(evolution.SkillProfile{ - SkillName: "weather", - WorkspaceID: workspace, - CurrentVersion: "draft-stable", - Status: evolution.SkillStatusActive, - Origin: "evolved", - HumanSummary: "Weather helper", - ChangeReason: "stable weather path", - IntendedUseCases: []string{"basic weather lookup"}, - PreferredEntryPath: []string{"geocode", "weather"}, - AvoidPatterns: []string{"avoid translating city names twice"}, - LastUsedAt: now.Add(-time.Hour), - UseCount: 5, - VersionHistory: []evolution.SkillVersionEntry{ - { - Version: "draft-old", - Action: "create", - Timestamp: now.Add(-48 * time.Hour), - DraftID: "draft-old", - Summary: "initial stable version", - }, - { - Version: "draft-stable", - Action: "manual_apply:append", - Timestamp: now.Add(-2 * time.Hour), - DraftID: "draft-stable", - Summary: "manual CLI apply: stable weather path", - }, - }, - }); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - - cmd := newReviewCommand() - cmd.SetArgs([]string{"draft-review"}) - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - output := out.String() - for _, want := range []string{ - "id=draft-review", - "source=pattern-1", - "target=weather", - "type=shortcut", - "change=append", - "status=candidate", - "review_notes=local structural validation completed", - "intended_use_cases=weather native-name path", - "preferred_entry_path=weather", - "avoid_patterns=avoid starting with geocode before using weather", - "profile:", - "skill=weather status=active version=draft-stable uses=5 reason=stable weather path", - "current_preferred_entry_path=geocode -> weather", - "recent_history:", - "version=draft-stable action=manual_apply:append draft_id=draft-stable summary=manual CLI apply: stable weather path", - "impact_preview:", - "will_update_existing_skill=true", - "expected_effect=append a new section onto the current skill", - "current_body:", - "Use city names first.", - "rendered_body:", - "## Start Here", - "diff_preview:", - "--- current", - "+++ rendered", - "@@", - "+## Start Here", - "+Use native-name query first.", - "body:", - "Use native-name query first.", - } { - if !strings.Contains(output, want) { - t.Fatalf("output missing %q:\n%s", want, output) - } - } -} - -func TestApplyCommandAppliesDraftAndMarksAccepted(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - if err := store.SaveDrafts([]evolution.SkillDraft{ - { - ID: "draft-apply", - WorkspaceID: workspace, - SourceRecordID: "pattern-1", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeShortcut, - ChangeKind: evolution.ChangeKindCreate, - HumanSummary: "Create weather shortcut", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", - Status: evolution.DraftStatusCandidate, - }, - }); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - cmd := newApplyCommand() - cmd.SetArgs([]string{"draft-apply"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - drafts, err := store.LoadDrafts() - if err != nil { - t.Fatalf("LoadDrafts: %v", err) - } - if len(drafts) != 1 { - t.Fatalf("len(drafts) = %d, want 1", len(drafts)) - } - if drafts[0].Status != evolution.DraftStatusAccepted { - t.Fatalf("draft status = %q, want accepted", drafts[0].Status) - } - if drafts[0].UpdatedAt == nil { - t.Fatal("expected UpdatedAt to be set after apply") - } - - skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") - data, err := os.ReadFile(skillPath) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if !strings.Contains(string(data), "Use native-name query first.") { - t.Fatalf("unexpected skill body:\n%s", string(data)) - } - - profile, err := store.LoadProfile("weather") - if err != nil { - t.Fatalf("LoadProfile: %v", err) - } - if profile.CurrentVersion != "draft-apply" { - t.Fatalf("CurrentVersion = %q, want draft-apply", profile.CurrentVersion) - } - if profile.Status != evolution.SkillStatusActive { - t.Fatalf("Status = %q, want active", profile.Status) - } -} - -func TestRollbackCommandRestoresLatestBackupAndUpdatesProfile(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - skillDir := filepath.Join(workspace, "skills", "weather") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - skillPath := filepath.Join(skillDir, "SKILL.md") - original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n" - if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { - return time.Unix(1700000000, 0).UTC() - }) - if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ - ID: "draft-new", - WorkspaceID: workspace, - SourceRecordID: "pattern-2", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeWorkflow, - ChangeKind: evolution.ChangeKindReplace, - HumanSummary: "Replace weather body", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n", - Status: evolution.DraftStatusAccepted, - }); err != nil { - t.Fatalf("ApplyDraft: %v", err) - } - - if err := store.SaveDrafts([]evolution.SkillDraft{ - { - ID: "draft-new", - WorkspaceID: workspace, - SourceRecordID: "pattern-2", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeWorkflow, - ChangeKind: evolution.ChangeKindReplace, - HumanSummary: "Replace weather body", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n", - Status: evolution.DraftStatusAccepted, - }, - }); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - if err := store.SaveProfile(evolution.SkillProfile{ - SkillName: "weather", - WorkspaceID: workspace, - CurrentVersion: "draft-new", - Status: evolution.SkillStatusActive, - Origin: "evolved", - HumanSummary: "Weather skill", - LastUsedAt: time.Unix(1700000000, 0).UTC(), - UseCount: 3, - RetentionScore: 0.8, - VersionHistory: []evolution.SkillVersionEntry{ - { - Version: "draft-old", - Action: "create", - Timestamp: time.Unix(1699990000, 0).UTC(), - DraftID: "draft-old", - Summary: "old stable", - }, - { - Version: "draft-new", - Action: "replace", - Timestamp: time.Unix(1700000000, 0).UTC(), - DraftID: "draft-new", - Summary: "new risky", - }, - }, - }); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - - cmd := newRollbackCommand() - cmd.SetArgs([]string{"weather"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - data, err := os.ReadFile(skillPath) - if err != nil { - t.Fatalf("ReadFile: %v", err) - } - if string(data) != original { - t.Fatalf("rollback did not restore original body:\n%s", string(data)) - } - - profile, err := store.LoadProfile("weather") - if err != nil { - t.Fatalf("LoadProfile: %v", err) - } - if profile.CurrentVersion != "draft-old" { - t.Fatalf("CurrentVersion = %q, want draft-old", profile.CurrentVersion) - } - if len(profile.VersionHistory) != 3 { - t.Fatalf("len(VersionHistory) = %d, want 3", len(profile.VersionHistory)) - } - last := profile.VersionHistory[len(profile.VersionHistory)-1] - if last.Action != "manual_rollback" { - t.Fatalf("Action = %q, want manual_rollback", last.Action) - } - if !last.Rollback { - t.Fatal("expected rollback history entry") - } - - drafts, err := store.LoadDrafts() - if err != nil { - t.Fatalf("LoadDrafts: %v", err) - } - if len(drafts) != 1 { - t.Fatalf("len(drafts) = %d, want 1", len(drafts)) - } - if drafts[0].Status != evolution.DraftStatusQuarantined { - t.Fatalf("draft status = %q, want quarantined after rollback", drafts[0].Status) - } - if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "rolled back") { - t.Fatalf("ReviewNotes = %v, want rollback note", drafts[0].ReviewNotes) - } -} - -func TestDraftGeneratorForRunOnce_FallsBackWhenProviderCreationFails(t *testing.T) { - originalFactory := createEvolutionProvider - createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) { - return nil, "", errors.New("provider init failed") - } - defer func() { - createEvolutionProvider = originalFactory - }() - - workspace := t.TempDir() - generator := draftGeneratorForRunOnce(&config.Config{}, workspace) - - draft, err := generator.GenerateDraft(t.Context(), evolution.LearningRecord{ - ID: "rule-1", - Summary: "weather native-name path", - EventCount: 2, - SuccessRate: 1, - WinningPath: []string{"weather"}, - }, nil) - if err != nil { - t.Fatalf("GenerateDraft: %v", err) - } - if draft.TargetSkillName != "weather" { - t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName) - } - if draft.BodyOrPatch == "" { - t.Fatal("expected fallback template draft body") - } -} - -func TestDraftGeneratorForRunOnce_UsesExplicitModelIDFromProviderFactory(t *testing.T) { - originalFactory := createEvolutionProvider - provider := &runOnceProvider{ - defaultModel: "", - response: &providers.LLMResponse{ - Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name query first","body_or_patch":"## Start Here\nUse native-name query first."}`, - }, - } - createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) { - return provider, "configured-model-id", nil - } - defer func() { - createEvolutionProvider = originalFactory - }() - - workspace := t.TempDir() - generator := draftGeneratorForRunOnce(&config.Config{}, workspace) - - draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ - ID: "rule-1", - Summary: "weather native-name path", - EventCount: 2, - SuccessRate: 1, - WinningPath: []string{"weather"}, - }, nil) - if err != nil { - t.Fatalf("GenerateDraft: %v", err) - } - if provider.lastModel != "configured-model-id" { - t.Fatalf("lastModel = %q, want configured-model-id", provider.lastModel) - } - if draft.TargetSkillName != "weather" { - t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName) - } - if draft.HumanSummary != "Prefer native-name query first" { - t.Fatalf("HumanSummary = %q, want %q", draft.HumanSummary, "Prefer native-name query first") - } -} - -func TestPruneAppendsLifecycleHistory(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - skillDir := filepath.Join(workspace, "skills", "weather") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# weather\n"), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - if err := store.SaveProfile(evolution.SkillProfile{ - SkillName: "weather", - WorkspaceID: workspace, - Status: evolution.SkillStatusArchived, - Origin: "evolved", - HumanSummary: "weather helper", - LastUsedAt: time.Now().Add(-366 * 24 * time.Hour), - RetentionScore: 0.05, - VersionHistory: []evolution.SkillVersionEntry{{ - Version: "v1", - Action: "create", - Timestamp: time.Now().Add(-400 * 24 * time.Hour), - Summary: "initial version", - }}, - }); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - - cmd := newPruneCommand() - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - profile, err := store.LoadProfile("weather") - if err != nil { - t.Fatalf("LoadProfile: %v", err) - } - if profile.Status != evolution.SkillStatusDeleted { - t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusDeleted) - } - if len(profile.VersionHistory) != 2 { - t.Fatalf("len(VersionHistory) = %d, want 2", len(profile.VersionHistory)) - } - last := profile.VersionHistory[len(profile.VersionHistory)-1] - if last.Action != "lifecycle:deleted" { - t.Fatalf("Action = %q, want lifecycle:deleted", last.Action) - } - if !strings.Contains(last.Summary, "archived -> deleted") { - t.Fatalf("Summary = %q, want lifecycle transition summary", last.Summary) - } -} - -func TestPruneScopesProfilesToCurrentWorkspaceInSharedState(t *testing.T) { - sharedState := filepath.Join(t.TempDir(), "shared-evolution") - workspace := t.TempDir() - otherWorkspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, sharedState) - store := evolution.NewStore(evolution.NewPaths(workspace, sharedState)) - - if err := os.MkdirAll(filepath.Join(workspace, "skills", "mine"), 0o755); err != nil { - t.Fatalf("MkdirAll current skill: %v", err) - } - if err := os.WriteFile(filepath.Join(workspace, "skills", "mine", "SKILL.md"), []byte("# mine\n"), 0o644); err != nil { - t.Fatalf("WriteFile current skill: %v", err) - } - if err := os.MkdirAll(filepath.Join(otherWorkspace, "skills", "theirs"), 0o755); err != nil { - t.Fatalf("MkdirAll other skill: %v", err) - } - if err := os.WriteFile(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md"), []byte("# theirs\n"), 0o644); err != nil { - t.Fatalf("WriteFile other skill: %v", err) - } - - profiles := []evolution.SkillProfile{ - { - SkillName: "mine", - WorkspaceID: workspace, - Status: evolution.SkillStatusArchived, - Origin: "evolved", - LastUsedAt: time.Now().Add(-366 * 24 * time.Hour), - RetentionScore: 0.05, - }, - { - SkillName: "theirs", - WorkspaceID: otherWorkspace, - Status: evolution.SkillStatusArchived, - Origin: "evolved", - LastUsedAt: time.Now().Add(-366 * 24 * time.Hour), - RetentionScore: 0.05, - }, - { - SkillName: "legacy", - Status: evolution.SkillStatusArchived, - Origin: "evolved", - LastUsedAt: time.Now().Add(-366 * 24 * time.Hour), - RetentionScore: 0.05, - }, - } - for _, profile := range profiles { - if err := store.SaveProfile(profile); err != nil { - t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err) - } - } - - cmd := newPruneCommand() - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - mine, err := store.LoadProfile("mine") - if err != nil { - t.Fatalf("LoadProfile(mine): %v", err) - } - if mine.Status != evolution.SkillStatusDeleted { - t.Fatalf("mine.Status = %q, want %q", mine.Status, evolution.SkillStatusDeleted) - } - if len(mine.VersionHistory) != 1 || mine.VersionHistory[0].Action != "lifecycle:deleted" { - t.Fatalf("mine.VersionHistory = %+v, want lifecycle delete entry", mine.VersionHistory) - } - - theirs, err := store.LoadProfile("theirs") - if err != nil { - t.Fatalf("LoadProfile(theirs): %v", err) - } - if theirs.Status != evolution.SkillStatusArchived { - t.Fatalf("theirs.Status = %q, want archived", theirs.Status) - } - if len(theirs.VersionHistory) != 0 { - t.Fatalf("theirs.VersionHistory = %+v, want unchanged", theirs.VersionHistory) - } - if _, statErr := os.Stat(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md")); statErr != nil { - t.Fatalf("other workspace skill should remain, stat err = %v", statErr) - } - - legacy, err := store.LoadProfile("legacy") - if err != nil { - t.Fatalf("LoadProfile(legacy): %v", err) - } - if legacy.Status != evolution.SkillStatusArchived { - t.Fatalf("legacy.Status = %q, want archived", legacy.Status) - } - if len(legacy.VersionHistory) != 0 { - t.Fatalf("legacy.VersionHistory = %+v, want unchanged", legacy.VersionHistory) - } -} - -func TestPruneDoesNotMutateProfileWhenDeleteActionFails(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - paths := evolution.NewPaths(workspace, "") - store := evolution.NewStore(paths) - - legacyProfile := evolution.SkillProfile{ - SkillName: "../escape", - WorkspaceID: workspace, - Status: evolution.SkillStatusArchived, - Origin: "evolved", - LastUsedAt: time.Now().Add(-366 * 24 * time.Hour), - RetentionScore: 0.05, - VersionHistory: []evolution.SkillVersionEntry{{ - Version: "v1", - Action: "create", - Timestamp: time.Now().Add(-400 * 24 * time.Hour), - Summary: "initial version", - }}, - } - if err := os.MkdirAll(paths.ProfilesDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - data, err := json.MarshalIndent(legacyProfile, "", " ") - if err != nil { - t.Fatalf("MarshalIndent: %v", err) - } - if err := os.WriteFile(filepath.Join(paths.ProfilesDir, "weather.json"), data, 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - cmd := newPruneCommand() - err = cmd.Execute() - if err == nil { - t.Fatal("expected prune to fail when delete action cannot resolve skill path") - } - - profile, loadErr := store.LoadProfile("weather") - if loadErr != nil { - t.Fatalf("LoadProfile: %v", loadErr) - } - if profile.Status != evolution.SkillStatusArchived { - t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusArchived) - } - if len(profile.VersionHistory) != 1 { - t.Fatalf("len(VersionHistory) = %d, want 1", len(profile.VersionHistory)) - } -} - -func TestStatusShowsDraftAndProfileDistributions(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - now := time.Unix(1700000000, 0).UTC() - drafts := []evolution.SkillDraft{ - {ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", HumanSummary: "first candidate", ChangeKind: evolution.ChangeKindAppend, Status: evolution.DraftStatusCandidate, UpdatedAt: timePtr(now.Add(-2 * time.Hour))}, - {ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", HumanSummary: "middle quarantine", ChangeKind: evolution.ChangeKindReplace, Status: evolution.DraftStatusQuarantined, UpdatedAt: timePtr(now.Add(-1 * time.Hour))}, - {ID: "d3", WorkspaceID: workspace, TargetSkillName: "skill-c", HumanSummary: "latest accepted", ChangeKind: evolution.ChangeKindCreate, Status: evolution.DraftStatusAccepted, UpdatedAt: timePtr(now)}, - } - if err := store.SaveDrafts(drafts); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - profiles := []evolution.SkillProfile{ - {SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive, CurrentVersion: "v-a", UseCount: 5, ChangeReason: "active reason", LastUsedAt: now.Add(-3 * time.Hour)}, - {SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold, CurrentVersion: "v-b", UseCount: 2, ChangeReason: "cold reason", LastUsedAt: now.Add(-2 * time.Hour)}, - {SkillName: "skill-c", WorkspaceID: workspace, Status: evolution.SkillStatusArchived, CurrentVersion: "v-c", UseCount: 1, ChangeReason: "archived reason", LastUsedAt: now}, - {SkillName: "skill-d", WorkspaceID: workspace, Status: evolution.SkillStatusDeleted, CurrentVersion: "v-d", UseCount: 0, ChangeReason: "deleted reason", LastUsedAt: now.Add(-4 * time.Hour)}, - } - for _, profile := range profiles { - if err := store.SaveProfile(profile); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - } - - cmd := newStatusCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - output := out.String() - for _, want := range []string{ - "drafts=3", - "drafts_by_status=candidate:1 quarantined:1 accepted:1", - "profiles=4", - "profiles_by_status=active:1 cold:1 archived:1 deleted:1", - "draft_items:", - "id=d3 status=accepted target=skill-c change=create summary=latest accepted", - "profile_items:", - "skill=skill-c status=archived version=v-c uses=1 reason=archived reason", - } { - if !strings.Contains(output, want) { - t.Fatalf("output missing %q:\n%s", want, output) - } - } -} - -func TestStatusScopesCountsToCurrentWorkspaceInSharedState(t *testing.T) { - sharedState := filepath.Join(t.TempDir(), "shared-evolution") - workspace := t.TempDir() - otherWorkspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, sharedState) - store := evolution.NewStore(evolution.NewPaths(workspace, sharedState)) - - drafts := []evolution.SkillDraft{ - {ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", Status: evolution.DraftStatusCandidate}, - {ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", Status: evolution.DraftStatusAccepted}, - {ID: "d3", WorkspaceID: otherWorkspace, TargetSkillName: "skill-c", Status: evolution.DraftStatusQuarantined}, - {ID: "d4", TargetSkillName: "legacy-draft", Status: evolution.DraftStatusCandidate}, - } - if err := store.SaveDrafts(drafts); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - profiles := []evolution.SkillProfile{ - {SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive}, - {SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold}, - {SkillName: "skill-c", WorkspaceID: otherWorkspace, Status: evolution.SkillStatusDeleted}, - {SkillName: "legacy", Status: evolution.SkillStatusArchived}, - } - for _, profile := range profiles { - if err := store.SaveProfile(profile); err != nil { - t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err) - } - } - - cmd := newStatusCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - output := out.String() - for _, want := range []string{ - "drafts=2", - "drafts_by_status=candidate:1 quarantined:0 accepted:1", - "profiles=2", - "profiles_by_status=active:1 cold:1 archived:0 deleted:0", - } { - if !strings.Contains(output, want) { - t.Fatalf("output missing %q:\n%s", want, output) - } - } -} - -func TestApplyCommandAddsExplicitManualAuditInfo(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - if err := store.SaveDrafts([]evolution.SkillDraft{ - { - ID: "draft-apply-audit", - WorkspaceID: workspace, - SourceRecordID: "pattern-1", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeShortcut, - ChangeKind: evolution.ChangeKindCreate, - HumanSummary: "Create weather shortcut", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", - Status: evolution.DraftStatusCandidate, - }, - }); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - - cmd := newApplyCommand() - cmd.SetArgs([]string{"draft-apply-audit"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - drafts, err := store.LoadDrafts() - if err != nil { - t.Fatalf("LoadDrafts: %v", err) - } - if len(drafts) != 1 { - t.Fatalf("len(drafts) = %d, want 1", len(drafts)) - } - if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "manually applied via CLI") { - t.Fatalf("ReviewNotes = %v, want manual apply note", drafts[0].ReviewNotes) - } - - profile, err := store.LoadProfile("weather") - if err != nil { - t.Fatalf("LoadProfile: %v", err) - } - last := profile.VersionHistory[len(profile.VersionHistory)-1] - if last.Action != "manual_apply:create" { - t.Fatalf("Action = %q, want manual_apply:create", last.Action) - } - if !strings.Contains(last.Summary, "manual CLI apply") { - t.Fatalf("Summary = %q, want manual CLI apply summary", last.Summary) - } -} - -func TestRollbackCommandAddsExplicitManualAuditInfo(t *testing.T) { - workspace := t.TempDir() - configureEvolutionCommandTest(t, workspace, "") - store := evolution.NewStore(evolution.NewPaths(workspace, "")) - - skillDir := filepath.Join(workspace, "skills", "weather") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatalf("MkdirAll: %v", err) - } - skillPath := filepath.Join(skillDir, "SKILL.md") - original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n" - if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { - t.Fatalf("WriteFile: %v", err) - } - - applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { - return time.Unix(1700000000, 0).UTC() - }) - if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ - ID: "draft-risky", - WorkspaceID: workspace, - SourceRecordID: "pattern-2", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeWorkflow, - ChangeKind: evolution.ChangeKindReplace, - HumanSummary: "Replace weather body", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n", - Status: evolution.DraftStatusAccepted, - }); err != nil { - t.Fatalf("ApplyDraft: %v", err) - } - - if err := store.SaveDrafts([]evolution.SkillDraft{{ - ID: "draft-risky", - WorkspaceID: workspace, - SourceRecordID: "pattern-2", - TargetSkillName: "weather", - DraftType: evolution.DraftTypeWorkflow, - ChangeKind: evolution.ChangeKindReplace, - HumanSummary: "Replace weather body", - BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n", - Status: evolution.DraftStatusAccepted, - }}); err != nil { - t.Fatalf("SaveDrafts: %v", err) - } - if err := store.SaveProfile(evolution.SkillProfile{ - SkillName: "weather", - WorkspaceID: workspace, - CurrentVersion: "draft-risky", - Status: evolution.SkillStatusActive, - Origin: "evolved", - HumanSummary: "Weather skill", - LastUsedAt: time.Unix(1700000000, 0).UTC(), - VersionHistory: []evolution.SkillVersionEntry{ - {Version: "draft-old", Action: "create", Timestamp: time.Unix(1699990000, 0).UTC(), DraftID: "draft-old", Summary: "old stable"}, - {Version: "draft-risky", Action: "replace", Timestamp: time.Unix(1700000000, 0).UTC(), DraftID: "draft-risky", Summary: "new risky"}, - }, - }); err != nil { - t.Fatalf("SaveProfile: %v", err) - } - - cmd := newRollbackCommand() - cmd.SetArgs([]string{"weather"}) - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - if err := cmd.Execute(); err != nil { - t.Fatalf("Execute: %v", err) - } - - profile, err := store.LoadProfile("weather") - if err != nil { - t.Fatalf("LoadProfile: %v", err) - } - last := profile.VersionHistory[len(profile.VersionHistory)-1] - if last.Action != "manual_rollback" { - t.Fatalf("Action = %q, want manual_rollback", last.Action) - } - if !strings.Contains(last.Summary, "manual CLI rollback") { - t.Fatalf("Summary = %q, want manual rollback summary", last.Summary) - } - if last.DraftID != "draft-risky" { - t.Fatalf("DraftID = %q, want draft-risky", last.DraftID) - } - - drafts, err := store.LoadDrafts() - if err != nil { - t.Fatalf("LoadDrafts: %v", err) - } - if len(drafts) != 1 { - t.Fatalf("len(drafts) = %d, want 1", len(drafts)) - } - if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "backup=") { - t.Fatalf("ReviewNotes = %v, want backup path note", drafts[0].ReviewNotes) - } -} - -func configureEvolutionCommandTest(t *testing.T, workspace, stateDir string) { - t.Helper() - - configPath := filepath.Join(t.TempDir(), "config.json") - t.Setenv(config.EnvConfig, configPath) - t.Setenv(config.EnvHome, t.TempDir()) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: workspace, - }, - }, - Evolution: config.EvolutionConfig{ - Enabled: true, - Mode: "observe", - StateDir: stateDir, - }, - } - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("SaveConfig: %v", err) - } -} - -type runOnceProvider struct { - response *providers.LLMResponse - err error - defaultModel string - lastModel string -} - -func (p *runOnceProvider) Chat( - _ context.Context, - _ []providers.Message, - _ []providers.ToolDefinition, - model string, - _ map[string]any, -) (*providers.LLMResponse, error) { - p.lastModel = model - return p.response, p.err -} - -func (p *runOnceProvider) GetDefaultModel() string { - return p.defaultModel -} diff --git a/cmd/picoclaw/internal/evolution/draft_helpers.go b/cmd/picoclaw/internal/evolution/draft_helpers.go deleted file mode 100644 index ea0dc7e48..000000000 --- a/cmd/picoclaw/internal/evolution/draft_helpers.go +++ /dev/null @@ -1,56 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "time" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func loadWorkspaceDraft(store *evolution.Store, workspace, stateDir, id string) (evolution.SkillDraft, error) { - drafts, err := store.LoadDrafts() - if err != nil { - return evolution.SkillDraft{}, err - } - paths := evolution.NewPaths(workspace, stateDir) - _, draft, err := findWorkspaceDraft(drafts, paths, workspace, id) - return draft, err -} - -func findWorkspaceDraft( - drafts []evolution.SkillDraft, - paths evolution.Paths, - workspace, id string, -) (int, evolution.SkillDraft, error) { - for i, draft := range drafts { - if !draftBelongsToWorkspace(paths, workspace, draft) { - continue - } - if draft.ID == id { - return i, draft, nil - } - } - return -1, evolution.SkillDraft{}, fmt.Errorf("draft %q not found for workspace", id) -} - -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 value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - existing = append(existing, value) - seen[value] = struct{}{} - } - return existing -} - -func timePtr(v time.Time) *time.Time { - return &v -} diff --git a/cmd/picoclaw/internal/evolution/drafts.go b/cmd/picoclaw/internal/evolution/drafts.go deleted file mode 100644 index 4f4297da4..000000000 --- a/cmd/picoclaw/internal/evolution/drafts.go +++ /dev/null @@ -1,58 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "sort" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func newDraftsCommand() *cobra.Command { - return &cobra.Command{ - Use: "drafts", - Short: "List skill drafts for the current workspace", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir) - drafts, err := store.LoadDrafts() - if err != nil { - return err - } - - filtered := make([]evolution.SkillDraft, 0, len(drafts)) - for _, draft := range drafts { - if draftBelongsToWorkspace(paths, workspace, draft) { - filtered = append(filtered, draft) - } - } - sort.Slice(filtered, func(i, j int) bool { - if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) { - return filtered[i].ID < filtered[j].ID - } - return filtered[i].CreatedAt.Before(filtered[j].CreatedAt) - }) - - for _, draft := range filtered { - if _, err := fmt.Fprintf( - cmd.OutOrStdout(), - "id=%s status=%s target=%s type=%s change=%s summary=%s\n", - draft.ID, - draft.Status, - draft.TargetSkillName, - draft.DraftType, - draft.ChangeKind, - draft.HumanSummary, - ); err != nil { - return err - } - } - return nil - }, - } -} diff --git a/cmd/picoclaw/internal/evolution/helpers.go b/cmd/picoclaw/internal/evolution/helpers.go deleted file mode 100644 index 27d4933a6..000000000 --- a/cmd/picoclaw/internal/evolution/helpers.go +++ /dev/null @@ -1,18 +0,0 @@ -package evolutioncmd - -import ( - "github.com/sipeed/picoclaw/cmd/picoclaw/internal" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func loadEvolutionDeps() (*config.Config, *evolution.Store, string, error) { - cfg, err := internal.LoadConfig() - if err != nil { - return nil, nil, "", err - } - - workspace := cfg.WorkspacePath() - store := evolution.NewStore(evolution.NewPaths(workspace, cfg.Evolution.StateDir)) - return cfg, store, workspace, nil -} diff --git a/cmd/picoclaw/internal/evolution/prune.go b/cmd/picoclaw/internal/evolution/prune.go deleted file mode 100644 index 7616859a1..000000000 --- a/cmd/picoclaw/internal/evolution/prune.go +++ /dev/null @@ -1,54 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "time" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func newPruneCommand() *cobra.Command { - return &cobra.Command{ - Use: "prune", - Short: "Recompute lifecycle states for learned skills", - RunE: func(_ *cobra.Command, _ []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - profiles, err := store.LoadProfiles() - if err != nil { - return err - } - - now := time.Now() - paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir) - for _, profile := range profiles { - if !profileBelongsToWorkspace(paths, workspace, profile) { - continue - } - - next := evolution.NextLifecycleState(profile, now) - if next != profile.Status { - if err := evolution.ApplyLifecycleState(paths, profile, next); err != nil { - return err - } - profile.VersionHistory = append(profile.VersionHistory, evolution.SkillVersionEntry{ - Version: profile.CurrentVersion, - Action: "lifecycle:" + string(next), - Timestamp: now, - Summary: fmt.Sprintf("lifecycle transition: %s -> %s", profile.Status, next), - }) - profile.Status = next - } - if err := store.SaveProfile(profile); err != nil { - return err - } - } - return nil - }, - } -} diff --git a/cmd/picoclaw/internal/evolution/review.go b/cmd/picoclaw/internal/evolution/review.go deleted file mode 100644 index 5fb4c654f..000000000 --- a/cmd/picoclaw/internal/evolution/review.go +++ /dev/null @@ -1,127 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "os" - "strings" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func newReviewCommand() *cobra.Command { - return &cobra.Command{ - Use: "review ", - Short: "Show detailed information for one draft", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - draft, err := loadWorkspaceDraft(store, workspace, cfg.Evolution.StateDir, args[0]) - if err != nil { - return err - } - profile, profileErr := store.LoadProfile(draft.TargetSkillName) - hasProfile := profileErr == nil - if profileErr != nil && !os.IsNotExist(profileErr) { - return profileErr - } - preview, err := evolution.BuildDraftPreview(workspace, draft) - if err != nil { - return err - } - - reviewNotes := strings.Join(draft.ReviewNotes, ", ") - scanFindings := strings.Join(draft.ScanFindings, ", ") - intendedUseCases := strings.Join(draft.IntendedUseCases, ", ") - preferredEntryPath := strings.Join(draft.PreferredEntryPath, " -> ") - avoidPatterns := strings.Join(draft.AvoidPatterns, ", ") - if _, err := fmt.Fprintf( - cmd.OutOrStdout(), - "id=%s\nsource=%s\ntarget=%s\ntype=%s\nchange=%s\nstatus=%s\nsummary=%s\nreview_notes=%s\nscan_findings=%s\nintended_use_cases=%s\npreferred_entry_path=%s\navoid_patterns=%s\n%s%scurrent_body:\n%s\nrendered_body:\n%s\ndiff_preview:\n%s\nbody:\n%s\n", - draft.ID, - draft.SourceRecordID, - draft.TargetSkillName, - draft.DraftType, - draft.ChangeKind, - draft.Status, - draft.HumanSummary, - reviewNotes, - scanFindings, - intendedUseCases, - preferredEntryPath, - avoidPatterns, - formatReviewProfileSection(profile, hasProfile), - formatImpactPreview(draft, hasProfile), - preview.CurrentBody, - preview.RenderedBody, - preview.DiffPreview, - draft.BodyOrPatch, - ); err != nil { - return err - } - return nil - }, - } -} - -func formatReviewProfileSection(profile evolution.SkillProfile, hasProfile bool) string { - if !hasProfile { - return "profile:\nmissing=true\nrecent_history:\n" - } - - lines := []string{ - "profile:", - fmt.Sprintf( - "skill=%s status=%s version=%s uses=%d reason=%s", - profile.SkillName, - profile.Status, - profile.CurrentVersion, - profile.UseCount, - profile.ChangeReason, - ), - "current_preferred_entry_path=" + strings.Join(profile.PreferredEntryPath, " -> "), - "recent_history:", - } - - history := profile.VersionHistory - for i := len(history) - 1; i >= 0 && i >= len(history)-3; i-- { - entry := history[i] - lines = append(lines, fmt.Sprintf( - "version=%s action=%s draft_id=%s summary=%s", - entry.Version, - entry.Action, - entry.DraftID, - entry.Summary, - )) - } - return strings.Join(lines, "\n") + "\n" -} - -func formatImpactPreview(draft evolution.SkillDraft, hasProfile bool) string { - lines := []string{ - "impact_preview:", - fmt.Sprintf("will_update_existing_skill=%t", hasProfile), - "expected_effect=" + expectedDraftEffect(draft.ChangeKind), - } - return strings.Join(lines, "\n") + "\n" -} - -func expectedDraftEffect(changeKind evolution.ChangeKind) string { - switch changeKind { - case evolution.ChangeKindCreate: - return "create a brand-new skill file" - case evolution.ChangeKindAppend: - return "append a new section onto the current skill" - case evolution.ChangeKindReplace: - return "replace the current skill body with the drafted body" - case evolution.ChangeKindMerge: - return "merge the draft into the current skill with an extra merged section" - default: - return "apply the drafted change to the target skill" - } -} diff --git a/cmd/picoclaw/internal/evolution/rollback.go b/cmd/picoclaw/internal/evolution/rollback.go deleted file mode 100644 index 7ff18f60c..000000000 --- a/cmd/picoclaw/internal/evolution/rollback.go +++ /dev/null @@ -1,160 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "time" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" - "github.com/sipeed/picoclaw/pkg/fileutil" -) - -func newRollbackCommand() *cobra.Command { - return &cobra.Command{ - Use: "rollback ", - Short: "Restore the latest backup for one skill", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir) - backupPath, err := latestBackupPath(paths, args[0]) - if err != nil { - return err - } - - data, err := os.ReadFile(backupPath) - if err != nil { - return err - } - - skillPath := filepath.Join(workspace, "skills", args[0], "SKILL.md") - if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { - return err - } - if err := fileutil.WriteFileAtomic(skillPath, data, 0o644); err != nil { - return err - } - - now := time.Now().UTC() - rolledBackDraftID, err := saveRollbackProfile(store, workspace, args[0], backupPath, now) - if err != nil { - return err - } - if err := markDraftRolledBack(store, workspace, cfg.Evolution.StateDir, rolledBackDraftID, backupPath, now); err != nil { - return err - } - - _, err = fmt.Fprintf(cmd.OutOrStdout(), "rolled back skill=%s backup=%s\n", args[0], backupPath) - return err - }, - } -} - -func latestBackupPath(paths evolution.Paths, skillName string) (string, error) { - backupRoot := filepath.Join(paths.BackupsDir, skillName) - entries, err := os.ReadDir(backupRoot) - if err != nil { - return "", err - } - - names := make([]string, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - names = append(names, entry.Name()) - } - } - sort.Sort(sort.Reverse(sort.StringSlice(names))) - for _, name := range names { - candidate := filepath.Join(backupRoot, name, "SKILL.md") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - return "", fmt.Errorf("no backup found for skill %q", skillName) -} - -func saveRollbackProfile(store *evolution.Store, workspace, skillName, backupPath string, now time.Time) (string, error) { - profile, err := store.LoadProfile(skillName) - if err != nil { - return "", err - } - - rolledBackDraftID := profile.CurrentVersion - previousVersion := previousStableVersion(profile.VersionHistory, profile.CurrentVersion) - profile.CurrentVersion = previousVersion - profile.Status = evolution.SkillStatusActive - profile.LastUsedAt = now - profile.VersionHistory = append(profile.VersionHistory, evolution.SkillVersionEntry{ - Version: previousVersion, - Action: "manual_rollback", - Timestamp: now, - DraftID: rolledBackDraftID, - Summary: "manual CLI rollback to latest backup: " + backupPath, - Rollback: true, - RollbackReason: "manual CLI rollback", - }) - return rolledBackDraftID, store.SaveProfile(profile) -} - -func previousStableVersion(history []evolution.SkillVersionEntry, currentVersion string) string { - if len(history) == 0 { - return currentVersion - } - - currentIndex := -1 - for i := len(history) - 1; i >= 0; i-- { - if history[i].Version == currentVersion { - currentIndex = i - break - } - } - if currentIndex <= 0 { - return currentVersion - } - for i := currentIndex - 1; i >= 0; i-- { - if history[i].Rollback { - continue - } - if history[i].Version != "" { - return history[i].Version - } - } - return currentVersion -} - -func markDraftRolledBack(store *evolution.Store, workspace, stateDir, draftID, backupPath string, now time.Time) error { - if draftID == "" { - return nil - } - - drafts, err := store.LoadDrafts() - if err != nil { - return err - } - paths := evolution.NewPaths(workspace, stateDir) - for i, draft := range drafts { - if !draftBelongsToWorkspace(paths, workspace, draft) { - continue - } - if draft.ID != draftID { - continue - } - drafts[i].Status = evolution.DraftStatusQuarantined - drafts[i].UpdatedAt = timePtr(now) - drafts[i].ReviewNotes = appendUniqueStrings( - drafts[i].ReviewNotes, - "manually rolled back after apply", - "backup="+backupPath, - ) - return store.SaveDrafts(drafts) - } - return nil -} diff --git a/cmd/picoclaw/internal/evolution/run_once.go b/cmd/picoclaw/internal/evolution/run_once.go deleted file mode 100644 index 5826cc375..000000000 --- a/cmd/picoclaw/internal/evolution/run_once.go +++ /dev/null @@ -1,55 +0,0 @@ -package evolutioncmd - -import ( - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/evolution" - "github.com/sipeed/picoclaw/pkg/providers" -) - -var createEvolutionProvider = providers.CreateProvider - -func newRunOnceCommand() *cobra.Command { - return &cobra.Command{ - Use: "run-once", - Short: "Run evolution cold path once", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - - rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: cfg.Evolution, - Store: store, - Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{ - MinCaseCount: cfg.Evolution.MinCaseCount, - MinSuccessRate: cfg.Evolution.MinSuccessRate, - }), - SkillsRecaller: evolution.NewSkillsRecaller(workspace), - GeneratorFactory: func(workspace string) evolution.DraftGenerator { - return draftGeneratorForRunOnce(cfg, workspace) - }, - Applier: evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil), - }) - if err != nil { - return err - } - - return rt.RunColdPathOnce(cmd.Context(), workspace) - }, - } -} - -func draftGeneratorForRunOnce(cfg *config.Config, workspace string) evolution.DraftGenerator { - if cfg == nil { - return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "") - } - - provider, modelID, err := createEvolutionProvider(cfg) - if err != nil { - return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "") - } - return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID) -} diff --git a/cmd/picoclaw/internal/evolution/status.go b/cmd/picoclaw/internal/evolution/status.go deleted file mode 100644 index 1dc46eb8d..000000000 --- a/cmd/picoclaw/internal/evolution/status.go +++ /dev/null @@ -1,173 +0,0 @@ -package evolutioncmd - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func newStatusCommand() *cobra.Command { - return &cobra.Command{ - Use: "status", - Short: "Show evolution status", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, store, workspace, err := loadEvolutionDeps() - if err != nil { - return err - } - paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir) - - drafts, err := store.LoadDrafts() - if err != nil { - return err - } - profiles, err := store.LoadProfiles() - if err != nil { - return err - } - drafts = filterDraftsForWorkspace(paths, workspace, drafts) - profiles = filterProfilesForWorkspace(paths, workspace, profiles) - - _, err = fmt.Fprintf( - cmd.OutOrStdout(), - "workspace=%s\nenabled=%v\nmode=%s\ndrafts=%d\ndrafts_by_status=%s\nprofiles=%d\nprofiles_by_status=%s\n%s%s", - workspace, - cfg.Evolution.Enabled, - cfg.Evolution.EffectiveMode(), - len(drafts), - formatDraftStatusCounts(drafts), - len(profiles), - formatProfileStatusCounts(profiles), - formatDraftItems(drafts), - formatProfileItems(profiles), - ) - return err - }, - } -} - -func filterDraftsForWorkspace(paths evolution.Paths, workspace string, drafts []evolution.SkillDraft) []evolution.SkillDraft { - filtered := make([]evolution.SkillDraft, 0, len(drafts)) - for _, draft := range drafts { - if draftBelongsToWorkspace(paths, workspace, draft) { - filtered = append(filtered, draft) - } - } - return filtered -} - -func filterProfilesForWorkspace(paths evolution.Paths, workspace string, profiles []evolution.SkillProfile) []evolution.SkillProfile { - filtered := make([]evolution.SkillProfile, 0, len(profiles)) - for _, profile := range profiles { - if profileBelongsToWorkspace(paths, workspace, profile) { - filtered = append(filtered, profile) - } - } - return filtered -} - -func formatDraftStatusCounts(drafts []evolution.SkillDraft) string { - counts := map[evolution.DraftStatus]int{ - evolution.DraftStatusCandidate: 0, - evolution.DraftStatusQuarantined: 0, - evolution.DraftStatusAccepted: 0, - } - for _, draft := range drafts { - counts[draft.Status]++ - } - - parts := []string{ - fmt.Sprintf("%s:%d", evolution.DraftStatusCandidate, counts[evolution.DraftStatusCandidate]), - fmt.Sprintf("%s:%d", evolution.DraftStatusQuarantined, counts[evolution.DraftStatusQuarantined]), - fmt.Sprintf("%s:%d", evolution.DraftStatusAccepted, counts[evolution.DraftStatusAccepted]), - } - return strings.Join(parts, " ") -} - -func formatProfileStatusCounts(profiles []evolution.SkillProfile) string { - counts := map[evolution.SkillStatus]int{ - evolution.SkillStatusActive: 0, - evolution.SkillStatusCold: 0, - evolution.SkillStatusArchived: 0, - evolution.SkillStatusDeleted: 0, - } - for _, profile := range profiles { - counts[profile.Status]++ - } - - parts := []string{ - fmt.Sprintf("%s:%d", evolution.SkillStatusActive, counts[evolution.SkillStatusActive]), - fmt.Sprintf("%s:%d", evolution.SkillStatusCold, counts[evolution.SkillStatusCold]), - fmt.Sprintf("%s:%d", evolution.SkillStatusArchived, counts[evolution.SkillStatusArchived]), - fmt.Sprintf("%s:%d", evolution.SkillStatusDeleted, counts[evolution.SkillStatusDeleted]), - } - return strings.Join(parts, " ") -} - -func formatDraftItems(drafts []evolution.SkillDraft) string { - if len(drafts) == 0 { - return "draft_items:\n" - } - - items := append([]evolution.SkillDraft(nil), drafts...) - sort.Slice(items, func(i, j int) bool { - left := draftSortTime(items[i]) - right := draftSortTime(items[j]) - if !left.Equal(right) { - return left.After(right) - } - return items[i].ID < items[j].ID - }) - - lines := []string{"draft_items:"} - for _, draft := range items { - lines = append(lines, fmt.Sprintf( - "id=%s status=%s target=%s change=%s summary=%s", - draft.ID, - draft.Status, - draft.TargetSkillName, - draft.ChangeKind, - draft.HumanSummary, - )) - } - return strings.Join(lines, "\n") + "\n" -} - -func formatProfileItems(profiles []evolution.SkillProfile) string { - if len(profiles) == 0 { - return "profile_items:\n" - } - - items := append([]evolution.SkillProfile(nil), profiles...) - sort.Slice(items, func(i, j int) bool { - if !items[i].LastUsedAt.Equal(items[j].LastUsedAt) { - return items[i].LastUsedAt.After(items[j].LastUsedAt) - } - return items[i].SkillName < items[j].SkillName - }) - - lines := []string{"profile_items:"} - for _, profile := range items { - lines = append(lines, fmt.Sprintf( - "skill=%s status=%s version=%s uses=%d reason=%s", - profile.SkillName, - profile.Status, - profile.CurrentVersion, - profile.UseCount, - profile.ChangeReason, - )) - } - return strings.Join(lines, "\n") + "\n" -} - -func draftSortTime(draft evolution.SkillDraft) time.Time { - if draft.UpdatedAt != nil { - return *draft.UpdatedAt - } - return draft.CreatedAt -} diff --git a/cmd/picoclaw/internal/evolution/workspace_scope.go b/cmd/picoclaw/internal/evolution/workspace_scope.go deleted file mode 100644 index 83647e221..000000000 --- a/cmd/picoclaw/internal/evolution/workspace_scope.go +++ /dev/null @@ -1,37 +0,0 @@ -package evolutioncmd - -import ( - "path/filepath" - - "github.com/sipeed/picoclaw/pkg/evolution" -) - -func draftBelongsToWorkspace(paths evolution.Paths, workspace string, draft evolution.SkillDraft) bool { - if draft.WorkspaceID == workspace { - return true - } - return draft.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace) -} - -func profileBelongsToWorkspace(paths evolution.Paths, workspace string, profile evolution.SkillProfile) bool { - if profile.WorkspaceID == workspace { - return true - } - return profile.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace) -} - -func usesDefaultWorkspaceState(paths evolution.Paths, workspace string) bool { - return paths.RootDir == evolution.NewPaths(workspace, "").RootDir -} - -func inferWorkspaceFromPaths(paths evolution.Paths) string { - root := filepath.Clean(paths.RootDir) - if filepath.Base(root) != "evolution" { - return "" - } - stateDir := filepath.Dir(root) - if filepath.Base(stateDir) != "state" { - return "" - } - return filepath.Dir(stateDir) -} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 240da657d..0867203a6 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -18,7 +18,6 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" - evolutioncmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/evolution" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" @@ -90,7 +89,6 @@ picoclaw --no-color status`, cron.NewCronCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), - evolutioncmd.NewEvolutionCommand(), model.NewModelCommand(), updater.NewUpdateCommand("picoclaw"), version.NewVersionCommand(), diff --git a/pkg/agent/events.go b/pkg/agent/events.go index 670765625..d36d00d8d 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -131,6 +131,13 @@ type SkillContextSnapshot struct { SkillNames []string `json:"skill_names,omitempty"` } +type ToolExecutionRecord struct { + Name string `json:"name"` + Success bool `json:"success"` + ErrorSummary string `json:"error_summary,omitempty"` + SkillNames []string `json:"skill_names,omitempty"` +} + // TurnEndPayload describes the completion of a turn. type TurnEndPayload struct { Status TurnEndStatus @@ -138,11 +145,14 @@ type TurnEndPayload struct { Iterations int Duration time.Duration FinalContentLen int + UserMessage string + FinalContent string ActiveSkills []string AttemptedSkills []string FinalSuccessfulPath []string SkillContextSnapshots []SkillContextSnapshot ToolKinds []string + ToolExecutions []ToolExecutionRecord } // LLMRequestPayload describes an outbound LLM request. diff --git a/pkg/agent/evolution_bridge.go b/pkg/agent/evolution_bridge.go index 1f4951b21..0479bfa03 100644 --- a/pkg/agent/evolution_bridge.go +++ b/pkg/agent/evolution_bridge.go @@ -2,6 +2,7 @@ package agent import ( "context" + "sync" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/evolution" @@ -16,6 +17,9 @@ type evolutionBridge struct { registry *AgentRegistry runtime *evolution.Runtime coldPathRunner *evolution.ColdPathRunner + bgCtx context.Context + cancel context.CancelFunc + wg sync.WaitGroup } func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider providers.LLMProvider) (*evolutionBridge, error) { @@ -32,6 +36,9 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr GeneratorFactory: func(workspace string) evolution.DraftGenerator { return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID) }, + SuccessJudgeFactory: func(workspace string) evolution.SuccessJudge { + return evolution.NewLLMTaskSuccessJudge(provider, modelID, &evolution.HeuristicSuccessJudge{}) + }, ApplierFactory: func(workspace string) *evolution.Applier { return evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil) }, @@ -39,13 +46,16 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr if err != nil { return nil, err } + bgCtx, cancel := context.WithCancel(context.Background()) bridge := &evolutionBridge{ cfg: cfg.Evolution, registry: registry, runtime: runtime, + bgCtx: bgCtx, + cancel: cancel, } - if cfg.Evolution.AutoRunColdPath { + if cfg.Evolution.RunsColdPathAutomatically() { bridge.coldPathRunner = evolution.NewColdPathRunnerWithErrorHandler(runtime, func(err error) { logger.WarnCF("agent", "Cold path run failed", map[string]any{ "error": err.Error(), @@ -57,13 +67,21 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr } func (b *evolutionBridge) Close() error { - if b == nil || b.coldPathRunner == nil { + if b == nil { return nil } - return b.coldPathRunner.Close() + if b.cancel != nil { + b.cancel() + } + var closeErr error + if b.coldPathRunner != nil { + closeErr = b.coldPathRunner.Close() + } + b.wg.Wait() + return closeErr } -func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error { +func (b *evolutionBridge) OnEvent(_ context.Context, evt Event) error { if b == nil || !b.cfg.Enabled || b.runtime == nil { return nil } @@ -74,30 +92,57 @@ func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error { if !ok { return nil } - if err := b.runtime.FinalizeTurn(ctx, evolution.TurnCaseInput{ - Workspace: payload.Workspace, - WorkspaceID: payload.Workspace, - TurnID: evt.Meta.TurnID, - SessionKey: evt.Meta.SessionKey, - AgentID: evt.Meta.AgentID, - Status: string(payload.Status), - ToolKinds: append([]string(nil), payload.ToolKinds...), - ActiveSkillNames: append([]string(nil), payload.ActiveSkills...), - AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...), - FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...), - SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots), - }); err != nil { - return err - } - if b.coldPathRunner != nil { - b.coldPathRunner.Trigger(payload.Workspace) - } + b.handleTurnEndAsync(evt.Meta, payload) return nil } return nil } +func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayload) { + if b == nil || b.runtime == nil { + return + } + + input := evolution.TurnCaseInput{ + Workspace: payload.Workspace, + WorkspaceID: payload.Workspace, + TurnID: meta.TurnID, + SessionKey: meta.SessionKey, + AgentID: meta.AgentID, + Status: string(payload.Status), + UserMessage: payload.UserMessage, + FinalContent: payload.FinalContent, + ToolKinds: append([]string(nil), payload.ToolKinds...), + ToolExecutions: toEvolutionToolExecutions(payload.ToolExecutions), + ActiveSkillNames: append([]string(nil), payload.ActiveSkills...), + AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...), + FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...), + SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots), + } + + ctx := b.bgCtx + if ctx == nil { + ctx = context.Background() + } + + b.wg.Add(1) + go func() { + defer b.wg.Done() + if err := b.runtime.FinalizeTurn(ctx, input); err != nil { + logger.WarnCF("agent", "Evolution finalize turn failed", map[string]any{ + "error": err.Error(), + "turn_id": input.TurnID, + "workspace": input.Workspace, + }) + return + } + if b.coldPathRunner != nil { + b.coldPathRunner.Trigger(input.Workspace) + } + }() +} + func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.SkillContextSnapshot { if len(input) == 0 { return nil @@ -113,3 +158,20 @@ func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution. } return out } + +func toEvolutionToolExecutions(input []ToolExecutionRecord) []evolution.ToolExecutionRecord { + if len(input) == 0 { + return nil + } + + out := make([]evolution.ToolExecutionRecord, 0, len(input)) + for _, record := range input { + out = append(out, evolution.ToolExecutionRecord{ + Name: record.Name, + Success: record.Success, + ErrorSummary: record.ErrorSummary, + SkillNames: append([]string(nil), record.SkillNames...), + }) + } + return out +} diff --git a/pkg/agent/evolution_bridge_test.go b/pkg/agent/evolution_bridge_test.go index 6bca35afd..8138d942e 100644 --- a/pkg/agent/evolution_bridge_test.go +++ b/pkg/agent/evolution_bridge_test.go @@ -84,16 +84,24 @@ func TestEvolutionBridge_ObserveWritesCaseRecord(t *testing.T) { t.Fatalf("tool_kinds = %#v, want [echo_text]", toolKinds) } - activeSkillsRaw, exists := record["active_skill_names"] + activeSkillsRaw, exists := record["initial_skill_names"] if !exists { - t.Fatal("active_skill_names field missing") + t.Fatal("initial_skill_names field missing") } activeSkills, ok := activeSkillsRaw.([]any) if !ok { - t.Fatalf("active_skill_names wrong type: %#v", activeSkillsRaw) + t.Fatalf("initial_skill_names wrong type: %#v", activeSkillsRaw) } if len(activeSkills) != 1 || activeSkills[0] != "observe-skill" { - t.Fatalf("active_skill_names = %#v, want [observe-skill]", activeSkills) + t.Fatalf("initial_skill_names = %#v, want [observe-skill]", activeSkills) + } + toolExecsRaw, exists := record["tool_executions"] + if !exists { + t.Fatal("tool_executions field missing") + } + toolExecs, ok := toolExecsRaw.([]any) + if !ok || len(toolExecs) != 1 { + t.Fatalf("tool_executions wrong type: %#v", toolExecsRaw) } } @@ -238,14 +246,13 @@ func TestEvolutionBridge_ObserveDoesNotCreateDraftFile(t *testing.T) { assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json")) } -func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) { +func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "review", - AutoRunColdPath: true, + Enabled: true, + Mode: "draft", }, &simpleMockProvider{response: "ok"}) defer al.Close() @@ -261,14 +268,13 @@ func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) { waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) } -func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *testing.T) { +func TestEvolutionBridge_DraftModeUsesProviderBackedDraftGenerator(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "review", - AutoRunColdPath: true, + Enabled: true, + Mode: "draft", }, &simpleMockProvider{ response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`, }) @@ -289,7 +295,7 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *test } } -func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) { +func TestEvolutionBridge_DraftModeUsesProviderDefaultModel(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) @@ -299,9 +305,8 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) { } al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "review", - AutoRunColdPath: true, + Enabled: true, + Mode: "draft", }, provider) defer al.Close() @@ -316,15 +321,13 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) { } } -func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) { +func TestEvolutionBridge_DraftModeKeepsCandidateDraft(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "apply", - AutoRunColdPath: true, - AutoApply: false, + Enabled: true, + Mode: "draft", }, &simpleMockProvider{ response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"create","human_summary":"Create weather helper","body_or_patch":"---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n"}`, }) @@ -344,7 +347,7 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateD assertProfileNotExists(t, tmpDir, "weather") } -func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testing.T) { +func TestEvolutionBridge_ApplyModeAutomaticallyRunsColdPathAndAppliesMergeDraft(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) @@ -359,10 +362,8 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testin } al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "apply", - AutoRunColdPath: true, - AutoApply: true, + Enabled: true, + Mode: "apply", }, &simpleMockProvider{ response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"merge","human_summary":"Merge native-name path","body_or_patch":"Prefer native-name query first."}`, }) @@ -398,14 +399,13 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testin } } -func TestEvolutionBridge_AutoRunColdPathDisabledDoesNotCreateDraftFile(t *testing.T) { +func TestEvolutionBridge_ObserveModeDoesNotRunColdPathOrCreateDraftFile(t *testing.T) { tmpDir := t.TempDir() seedReadyRule(t, tmpDir) al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ - Enabled: true, - Mode: "review", - AutoRunColdPath: false, + Enabled: true, + Mode: "observe", }, &simpleMockProvider{response: "ok"}) defer al.Close() @@ -516,21 +516,24 @@ func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) { if len(finalPath) != 2 || finalPath[0] != "geocode" || finalPath[1] != "weather" { t.Fatalf("final_successful_path = %#v, want [geocode weather]", finalPath) } - skillSnapshots, ok := attemptTrailRaw["skill_context_snapshots"].([]any) - if !ok { - t.Fatalf("skill_context_snapshots wrong type: %#v", attemptTrailRaw["skill_context_snapshots"]) + if _, exists := attemptTrailRaw["skill_context_snapshots"]; exists { + t.Fatalf("skill_context_snapshots should not be persisted: %#v", attemptTrailRaw["skill_context_snapshots"]) } - if len(skillSnapshots) != 2 { - t.Fatalf("len(skill_context_snapshots) = %d, want 2", len(skillSnapshots)) + initialSkills, ok := record["initial_skill_names"].([]any) + if !ok || len(initialSkills) != 1 || initialSkills[0] != "weather" { + t.Fatalf("initial_skill_names = %#v, want [weather]", record["initial_skill_names"]) + } + addedSkills, ok := record["added_skill_names"].([]any) + if !ok || len(addedSkills) != 1 || addedSkills[0] != "geocode" { + t.Fatalf("added_skill_names = %#v, want [geocode]", record["added_skill_names"]) } } func TestEvolutionBridge_CloseStopsColdPathRunnerIdempotently(t *testing.T) { cfg := &config.Config{ Evolution: config.EvolutionConfig{ - Enabled: true, - Mode: "review", - AutoRunColdPath: true, + Enabled: true, + Mode: "draft", }, } @@ -660,12 +663,17 @@ func waitForEvolutionRecord(t *testing.T, path string) map[string]any { data, err := os.ReadFile(path) if err == nil { lines := strings.Split(strings.TrimSpace(string(data)), "\n") - if len(lines) == 1 && lines[0] != "" { + for i := len(lines) - 1; i >= 0; i-- { + if strings.TrimSpace(lines[i]) == "" { + continue + } var record map[string]any - if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + if err := json.Unmarshal([]byte(lines[i]), &record); err != nil { t.Fatalf("json.Unmarshal(%s): %v", path, err) } - return record + if kind, _ := record["kind"].(string); kind == string(evolution.RecordKindTask) { + return record + } } } time.Sleep(10 * time.Millisecond) diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 2a4a947c9..50fea955f 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -6,6 +6,9 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" + "sort" + "strings" "time" "github.com/sipeed/picoclaw/pkg/bus" @@ -16,6 +19,89 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +func toolErrorSummary(result *tools.ToolResult) string { + if result == nil || !result.IsError { + return "" + } + content := strings.TrimSpace(result.ContentForLLM()) + if content == "" && result.Err != nil { + content = strings.TrimSpace(result.Err.Error()) + } + return utils.Truncate(content, 200) +} + +func inferSkillNamesFromToolCall(ts *turnState, toolName string, toolArgs map[string]any) []string { + if ts == nil || toolName != "read_file" { + return nil + } + + rawPath, ok := toolArgs["path"].(string) + if !ok { + return nil + } + path := strings.TrimSpace(rawPath) + if path == "" { + return nil + } + + cleanPath := filepath.Clean(path) + if !filepath.IsAbs(cleanPath) { + cleanPath = filepath.Join(ts.workspace, cleanPath) + } + if filepath.Base(cleanPath) != "SKILL.md" { + return nil + } + + var roots []string + if ts.agent != nil && ts.agent.ContextBuilder != nil { + roots = ts.agent.ContextBuilder.skillRoots() + } + if len(roots) == 0 && strings.TrimSpace(ts.workspace) != "" { + roots = []string{filepath.Join(ts.workspace, "skills")} + } + + found := make(map[string]struct{}) + for _, root := range roots { + root = strings.TrimSpace(root) + if root == "" { + continue + } + rel, err := filepath.Rel(filepath.Clean(root), cleanPath) + if err != nil { + continue + } + if rel == "." || rel == "" || strings.HasPrefix(rel, "..") { + continue + } + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) != 2 || parts[1] != "SKILL.md" { + continue + } + + skillName := strings.TrimSpace(parts[0]) + if skillName == "" { + continue + } + if ts.agent != nil && ts.agent.ContextBuilder != nil { + if canonical, ok := ts.agent.ContextBuilder.ResolveSkillName(skillName); ok { + skillName = canonical + } + } + found[skillName] = struct{}{} + } + + if len(found) == 0 { + return nil + } + + names := make([]string, 0, len(found)) + for skillName := range found { + names = append(names, skillName) + } + sort.Strings(names) + return names +} + // ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks, // tool execution with async callbacks, media delivery, and steering injection. // Returns ToolControl indicating what the coordinator should do next: @@ -198,7 +284,12 @@ toolLoop: Async: hookResult.Async, }, ) - ts.recordToolKind(toolName) + ts.recordToolExecution( + toolName, + !hookResult.IsError, + toolErrorSummary(hookResult), + inferSkillNamesFromToolCall(ts, toolName, toolArgs), + ) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { @@ -571,7 +662,12 @@ toolLoop: Async: toolResult.Async, }, ) - ts.recordToolKind(toolName) + ts.recordToolExecution( + toolName, + !toolResult.IsError, + toolErrorSummary(toolResult), + inferSkillNamesFromToolCall(ts, toolName, toolArgs), + ) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) diff --git a/pkg/agent/pipeline_execute_test.go b/pkg/agent/pipeline_execute_test.go new file mode 100644 index 000000000..404da320c --- /dev/null +++ b/pkg/agent/pipeline_execute_test.go @@ -0,0 +1,50 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestInferSkillNamesFromToolCall_ReadFileSkillMarkdown(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "three-one") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: three-one\ndescription: test\n---\n# Three One\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + cb := NewContextBuilder(workspace) + ts := &turnState{ + workspace: workspace, + agent: &AgentInstance{ + Workspace: workspace, + ContextBuilder: cb, + }, + } + + got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{ + "path": filepath.Join(workspace, "skills", "three-one", "SKILL.md"), + }) + if len(got) != 1 || got[0] != "three-one" { + t.Fatalf("inferSkillNamesFromToolCall = %v, want [three-one]", got) + } +} + +func TestInferSkillNamesFromToolCall_NonSkillFileIgnored(t *testing.T) { + workspace := t.TempDir() + ts := &turnState{workspace: workspace} + + got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{ + "path": filepath.Join(workspace, "README.md"), + }) + if len(got) != 0 { + t.Fatalf("inferSkillNamesFromToolCall = %v, want empty", got) + } +} diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index cd5038ceb..32e59c913 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -46,11 +46,14 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel Iterations: ts.currentIteration(), Duration: time.Since(ts.startedAt), FinalContentLen: ts.finalContentLen(), + UserMessage: ts.userMessage, + FinalContent: ts.finalContentSnapshot(), ActiveSkills: append([]string(nil), ts.activeSkills...), AttemptedSkills: attemptedSkills, FinalSuccessfulPath: finalSuccessfulPath, SkillContextSnapshots: skillContextSnapshots, ToolKinds: ts.toolKindsSnapshot(), + ToolExecutions: ts.toolExecutionsSnapshot(), }, ) }() diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 320463f9c..e238f88bd 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -184,6 +184,7 @@ type turnState struct { attemptedSkills []string skillContextTrace []SkillContextSnapshot toolKinds []string + toolExecutions []ToolExecutionRecord turnCtx *TurnContext channel string @@ -383,6 +384,12 @@ func (ts *turnState) finalContentLen() int { return len(ts.finalContent) } +func (ts *turnState) finalContentSnapshot() string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return ts.finalContent +} + func (ts *turnState) recordToolKind(tool string) { tool = strings.TrimSpace(tool) if tool == "" { @@ -406,6 +413,43 @@ func (ts *turnState) toolKindsSnapshot() []string { return append([]string(nil), ts.toolKinds...) } +func (ts *turnState) recordToolExecution(tool string, success bool, errorSummary string, skillNames []string) { + tool = strings.TrimSpace(tool) + if tool == "" { + return + } + + ts.recordToolKind(tool) + + ts.mu.Lock() + defer ts.mu.Unlock() + ts.toolExecutions = append(ts.toolExecutions, ToolExecutionRecord{ + Name: tool, + Success: success, + ErrorSummary: strings.TrimSpace(errorSummary), + SkillNames: append([]string(nil), skillNames...), + }) +} + +func (ts *turnState) toolExecutionsSnapshot() []ToolExecutionRecord { + ts.mu.RLock() + defer ts.mu.RUnlock() + if len(ts.toolExecutions) == 0 { + return nil + } + + out := make([]ToolExecutionRecord, 0, len(ts.toolExecutions)) + for _, exec := range ts.toolExecutions { + out = append(out, ToolExecutionRecord{ + Name: exec.Name, + Success: exec.Success, + ErrorSummary: exec.ErrorSummary, + SkillNames: append([]string(nil), exec.SkillNames...), + }) + } + return out +} + func (ts *turnState) recordAttemptedSkills(skillNames []string) { if len(skillNames) == 0 { return diff --git a/pkg/config/config.go b/pkg/config/config.go index c8b42ad2c..4d2ec119d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -53,13 +53,11 @@ type Config struct { } type EvolutionConfig struct { - Enabled bool `json:"enabled,omitempty"` - Mode string `json:"mode,omitempty"` - StateDir string `json:"state_dir,omitempty"` - MinCaseCount int `json:"min_case_count,omitempty"` - MinSuccessRate float64 `json:"min_success_rate,omitempty"` - AutoRunColdPath bool `json:"auto_run_cold_path,omitempty"` - AutoApply bool `json:"auto_apply,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Mode string `json:"mode,omitempty"` + StateDir string `json:"state_dir,omitempty"` + MinCaseCount int `json:"min_case_count,omitempty"` + MinSuccessRate float64 `json:"min_success_rate,omitempty"` } func (c EvolutionConfig) EffectiveMode() string { @@ -67,8 +65,8 @@ func (c EvolutionConfig) EffectiveMode() string { return "" } switch strings.ToLower(strings.TrimSpace(c.Mode)) { - case "review": - return "review" + case "draft": + return "draft" case "apply": return "apply" case "", "observe": @@ -78,6 +76,19 @@ func (c EvolutionConfig) EffectiveMode() string { } } +func (c EvolutionConfig) RunsColdPathAutomatically() bool { + switch c.EffectiveMode() { + case "draft", "apply": + return true + default: + return false + } +} + +func (c EvolutionConfig) AutoAppliesDrafts() bool { + return c.EffectiveMode() == "apply" +} + // IsolationConfig controls subprocess isolation for commands started by PicoClaw. // It is applied by the isolation package rather than by sandboxing the main process. type IsolationConfig struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c0e1e4934..6a0c8bda2 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -174,13 +174,13 @@ func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) { func TestDefaultConfig_EvolutionDefaults(t *testing.T) { cfg := DefaultConfig() - assert.False(t, cfg.Evolution.Enabled) - assert.Equal(t, "observe", cfg.Evolution.Mode) + assert.True(t, cfg.Evolution.Enabled) + assert.Equal(t, "apply", cfg.Evolution.Mode) assert.Equal(t, "", cfg.Evolution.StateDir) assert.Equal(t, 3, cfg.Evolution.MinCaseCount) assert.Equal(t, 0.7, cfg.Evolution.MinSuccessRate) - assert.False(t, cfg.Evolution.AutoRunColdPath) - assert.False(t, cfg.Evolution.AutoApply) + assert.True(t, cfg.Evolution.RunsColdPathAutomatically()) + assert.True(t, cfg.Evolution.AutoAppliesDrafts()) } func TestEvolutionConfig_EffectiveMode(t *testing.T) { @@ -216,17 +216,17 @@ func TestEvolutionConfig_EffectiveMode(t *testing.T) { name: "enabled returns configured mode", cfg: EvolutionConfig{ Enabled: true, - Mode: "review", + Mode: "draft", }, - want: "review", + want: "draft", }, { name: "enabled trims and normalizes mode", cfg: EvolutionConfig{ Enabled: true, - Mode: " Review ", + Mode: " Draft ", }, - want: "review", + want: "draft", }, { name: "enabled returns apply mode", @@ -261,6 +261,59 @@ func TestEvolutionConfig_EffectiveMode(t *testing.T) { } } +func TestEvolutionConfig_ModeSemantics(t *testing.T) { + tests := []struct { + name string + cfg EvolutionConfig + wantRunsCold bool + wantAutoApply bool + }{ + { + name: "disabled does not run cold path", + cfg: EvolutionConfig{ + Enabled: false, + Mode: "apply", + }, + wantRunsCold: false, + wantAutoApply: false, + }, + { + name: "observe only records hot path", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, + wantRunsCold: false, + wantAutoApply: false, + }, + { + name: "draft runs cold path without applying", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "draft", + }, + wantRunsCold: true, + wantAutoApply: false, + }, + { + name: "apply runs cold path and auto applies", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "apply", + }, + wantRunsCold: true, + wantAutoApply: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantRunsCold, tt.cfg.RunsColdPathAutomatically()) + assert.Equal(t, tt.wantAutoApply, tt.cfg.AutoAppliesDrafts()) + }) + } +} + func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index c7bf151ce..2b9e59037 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -45,12 +45,10 @@ func DefaultConfig() *Config { Dimensions: []string{"chat"}, }, Evolution: EvolutionConfig{ - Enabled: false, - Mode: "observe", - MinCaseCount: 3, - MinSuccessRate: 0.7, - AutoRunColdPath: false, - AutoApply: false, + Enabled: false, + Mode: "apply", + MinCaseCount: 2, + MinSuccessRate: 0.4, }, Channels: defaultChannels(), Hooks: HooksConfig{ diff --git a/pkg/evolution/apply.go b/pkg/evolution/apply.go index 9ea44558d..0836ac9a9 100644 --- a/pkg/evolution/apply.go +++ b/pkg/evolution/apply.go @@ -115,9 +115,20 @@ func (a *Applier) rollbackSkill(skillPath, backupPath string, hadOriginal bool) if err := os.Remove(skillPath); err != nil && !os.IsNotExist(err) { return err } + skillDir := filepath.Dir(skillPath) + if err := os.Remove(skillDir); err != nil && !os.IsNotExist(err) && !isDirNotEmptyError(err) { + return err + } return nil } +func isDirNotEmptyError(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "directory not empty") +} + func validateAppliedSkillBody(body string) error { body = strings.TrimSpace(body) if !strings.HasPrefix(body, "---\n") { diff --git a/pkg/evolution/apply_test.go b/pkg/evolution/apply_test.go index 5ce32ee90..5a76a63c2 100644 --- a/pkg/evolution/apply_test.go +++ b/pkg/evolution/apply_test.go @@ -129,6 +129,39 @@ func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) { } } +func TestApplier_FailedNewSkillDoesNotLeaveEmptyDirectory(t *testing.T) { + workspace := t.TempDir() + applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { + return time.Unix(1700000000, 0).UTC() + }) + + draft := evolution.SkillDraft{ + ID: "draft-invalid-new-skill", + WorkspaceID: workspace, + SourceRecordID: "rule-invalid-new-skill", + TargetSkillName: "calculate-100-via-theorems", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "broken new skill", + BodyOrPatch: "invalid-frontmatter", + Status: evolution.DraftStatusAccepted, + } + + err := applier.ApplyDraft(context.Background(), workspace, draft) + if err == nil { + t.Fatal("expected ApplyDraft to fail") + } + + skillPath := filepath.Join(workspace, "skills", "calculate-100-via-theorems", "SKILL.md") + if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { + t.Fatalf("expected no skill file, got err=%v", statErr) + } + skillDir := filepath.Dir(skillPath) + if _, statErr := os.Stat(skillDir); !os.IsNotExist(statErr) { + t.Fatalf("expected no leftover skill dir, got err=%v", statErr) + } +} + func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { diff --git a/pkg/evolution/drafts.go b/pkg/evolution/drafts.go index d8aaf8348..dd80f829f 100644 --- a/pkg/evolution/drafts.go +++ b/pkg/evolution/drafts.go @@ -98,6 +98,9 @@ func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRe } func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string { + if target := inferCombinedSkillName(rule); target != "" { + return target + } if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" { return strings.TrimSpace(matches[0].Name) } @@ -118,6 +121,134 @@ func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) strin return "" } +func inferCombinedSkillName(rule LearningRecord) string { + path := normalizePath(rule.WinningPath) + if len(path) < 2 { + return "" + } + + tokens := tokenizeForEvolution(rule.Summary) + suffix := commonWinningPathSuffix(path) + if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" { + if candidate := validSkillNameOrEmpty("calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix)); candidate != "" { + return candidate + } + } + if len(tokens) >= 2 { + prefix := strings.Join(tokens[:minInt(len(tokens), 4)], "-") + if suffix != "" { + if candidate := validSkillNameOrEmpty(prefix + "-via-" + pluralizeSuffix(suffix)); candidate != "" { + return candidate + } + } + if candidate := validSkillNameOrEmpty(prefix + "-shortcut"); candidate != "" { + return candidate + } + } + + compressedPath := compressedWinningPathName(path) + if candidate := validSkillNameOrEmpty("combined-" + compressedPath); candidate != "" { + return candidate + } + if candidate := validSkillNameOrEmpty(path[0] + "-to-" + path[len(path)-1] + "-shortcut"); candidate != "" { + return candidate + } + return "" +} + +func commonWinningPathSuffix(path []string) string { + if len(path) < 2 { + return "" + } + + var suffix string + for i, name := range path { + parts := strings.Split(strings.TrimSpace(name), "-") + if len(parts) == 0 { + return "" + } + last := strings.TrimSpace(parts[len(parts)-1]) + if last == "" { + return "" + } + if i == 0 { + suffix = last + continue + } + if suffix != last { + return "" + } + } + return suffix +} + +func compressedWinningPathName(path []string) string { + suffix := commonWinningPathSuffix(path) + fragments := make([]string, 0, len(path)+1) + for _, name := range path { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if suffix != "" { + trimmed = strings.TrimSuffix(trimmed, "-"+suffix) + trimmed = strings.TrimSuffix(trimmed, suffix) + trimmed = strings.Trim(trimmed, "-") + } + if trimmed != "" { + fragments = append(fragments, trimmed) + } + } + if suffix != "" { + fragments = append(fragments, pluralizeSuffix(suffix)) + } + if len(fragments) == 0 { + return strings.Join(path, "-") + } + return strings.Join(fragments, "-") +} + +func pluralizeSuffix(suffix string) string { + suffix = strings.TrimSpace(strings.ToLower(suffix)) + if suffix == "" { + return "" + } + if strings.HasSuffix(suffix, "s") { + return suffix + } + return suffix + "s" +} + +func isNumericToken(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func validSkillNameOrEmpty(candidate string) string { + candidate = strings.Trim(candidate, "-") + candidate = strings.Join(strings.FieldsFunc(candidate, func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') + }), "-") + candidate = strings.ToLower(strings.Trim(candidate, "-")) + if candidate == "" { + return "" + } + if len(candidate) > skills.MaxNameLength { + return "" + } + if err := skills.ValidateSkillName(candidate); err != nil { + return "" + } + return candidate +} + func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) { for _, match := range matches { if match.Name != target || strings.TrimSpace(match.Path) == "" { diff --git a/pkg/evolution/drafts_test.go b/pkg/evolution/drafts_test.go index 586967530..31e374c73 100644 --- a/pkg/evolution/drafts_test.go +++ b/pkg/evolution/drafts_test.go @@ -16,8 +16,8 @@ func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *tes generator := evolution.NewDefaultDraftGenerator(t.TempDir()) draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ - Summary: "weather native-name path", - WinningPath: []string{"geocode", "weather"}, + Summary: "weather lookup", + WinningPath: []string{"weather"}, LateAddedSkills: []string{"weather"}, FinalSnapshotTrigger: "context_retry_rebuild", EventCount: 4, @@ -34,6 +34,35 @@ func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *tes } } +func TestDefaultDraftGenerator_PrefersCombinedSkillForStableMultiSkillPath(t *testing.T) { + workspace := t.TempDir() + generator := evolution.NewDefaultDraftGenerator(workspace) + + draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ + Summary: "调用三一定理计算100", + WinningPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, + LateAddedSkills: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, + EventCount: 3, + SuccessRate: 1, + }, []skills.SkillInfo{ + {Name: "three-one-theorem", Path: filepath.Join(workspace, "skills", "three-one-theorem", "SKILL.md"), Source: "workspace"}, + {Name: "four-two-theorem", Path: filepath.Join(workspace, "skills", "four-two-theorem", "SKILL.md"), Source: "workspace"}, + {Name: "five-three-theorem", Path: filepath.Join(workspace, "skills", "five-three-theorem", "SKILL.md"), Source: "workspace"}, + }) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + if draft.TargetSkillName != "calculate-100-via-theorems" { + t.Fatalf("TargetSkillName = %q, want calculate-100-via-theorems", draft.TargetSkillName) + } + if draft.ChangeKind != evolution.ChangeKindCreate { + t.Fatalf("ChangeKind = %q, want create", draft.ChangeKind) + } + if !strings.Contains(draft.BodyOrPatch, "---\nname: calculate-100-via-theorems") { + t.Fatalf("BodyOrPatch should contain full skill document:\n%s", draft.BodyOrPatch) + } +} + func TestDefaultDraftGenerator_UsesAppendWhenExtendingExistingSkill(t *testing.T) { workspace := t.TempDir() generator := evolution.NewDefaultDraftGenerator(workspace) @@ -107,4 +136,10 @@ func TestLLMDraftGenerator_BuildPromptIncludesLateAddedSkillHint(t *testing.T) { if !strings.Contains(prompt, "Final snapshot trigger: context_retry_rebuild") { t.Fatalf("prompt missing final snapshot trigger:\n%s", prompt) } + if !strings.Contains(prompt, "Prefer creating a new combined shortcut skill") { + t.Fatalf("prompt missing combined skill guidance:\n%s", prompt) + } + if !strings.Contains(prompt, "Suggested target skill name:") { + t.Fatalf("prompt missing suggested target skill name:\n%s", prompt) + } } diff --git a/pkg/evolution/lifecycle.go b/pkg/evolution/lifecycle.go index 87628c02c..f9cad26bd 100644 --- a/pkg/evolution/lifecycle.go +++ b/pkg/evolution/lifecycle.go @@ -10,6 +10,12 @@ import ( "github.com/sipeed/picoclaw/pkg/skills" ) +type LifecycleRunSummary struct { + EvaluatedProfiles int + TransitionedProfiles int + DeletedSkills int +} + func NextLifecycleState(profile SkillProfile, now time.Time) SkillStatus { if profile.Origin == "manual" || profile.LastUsedAt.IsZero() { return profile.Status @@ -58,6 +64,51 @@ func ApplyLifecycleState(paths Paths, profile SkillProfile, next SkillStatus) er return err } +func RunLifecycleOnce(store *Store, paths Paths, workspace string, now time.Time) (LifecycleRunSummary, error) { + if store == nil { + return LifecycleRunSummary{}, nil + } + + profiles, err := store.LoadProfiles() + if err != nil { + return LifecycleRunSummary{}, err + } + + summary := LifecycleRunSummary{} + for _, profile := range profiles { + if !profileBelongsToWorkspace(paths, workspace, profile) { + continue + } + + summary.EvaluatedProfiles++ + next := NextLifecycleState(profile, now) + if next == profile.Status { + continue + } + + if err := ApplyLifecycleState(paths, profile, next); err != nil { + return summary, err + } + profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{ + Version: profile.CurrentVersion, + Action: "lifecycle:" + string(next), + Timestamp: now, + Summary: fmt.Sprintf("lifecycle transition: %s -> %s", profile.Status, next), + }) + profile.Status = next + if err := store.SaveProfile(profile); err != nil { + return summary, err + } + + summary.TransitionedProfiles++ + if next == SkillStatusDeleted { + summary.DeletedSkills++ + } + } + + return summary, nil +} + func inferWorkspaceFromPaths(paths Paths) string { root := filepath.Clean(paths.RootDir) if filepath.Base(root) != "evolution" { @@ -69,3 +120,14 @@ func inferWorkspaceFromPaths(paths Paths) string { } return filepath.Dir(stateDir) } + +func profileBelongsToWorkspace(paths Paths, workspace string, profile SkillProfile) bool { + if profile.WorkspaceID == workspace { + return true + } + return profile.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace) +} + +func usesDefaultWorkspaceState(paths Paths, workspace string) bool { + return paths.RootDir == NewPaths(workspace, "").RootDir +} diff --git a/pkg/evolution/llm_draft_generator.go b/pkg/evolution/llm_draft_generator.go index d04cacacb..40e937475 100644 --- a/pkg/evolution/llm_draft_generator.go +++ b/pkg/evolution/llm_draft_generator.go @@ -105,10 +105,22 @@ func (g *LLMDraftGenerator) buildPrompt(rule LearningRecord, matches []skills.Sk "Matched skill refs: " + summarizeSkillMatches(matches), "Matched skill names: " + joinOrFallback(rule.MatchedSkillNames, "none"), "", + combinedSkillGuidance(rule), "body_or_patch should contain the full draft body or patch content as plain text.", }, "\n") } +func combinedSkillGuidance(rule LearningRecord) string { + if target := inferCombinedSkillName(rule); target != "" { + return strings.Join([]string{ + "This rule represents a stable multi-step successful path.", + "Prefer creating a new combined shortcut skill instead of modifying one component skill.", + "Suggested target skill name: " + target, + }, "\n") + } + return "Prefer updating an existing skill only when the learned pattern clearly belongs inside that single skill." +} + func parseLLMDraft(content string) (SkillDraft, bool) { normalized := strings.TrimSpace(content) normalized = strings.TrimPrefix(normalized, "```json") diff --git a/pkg/evolution/organizer.go b/pkg/evolution/organizer.go index 6fea31b36..d8b8a2e67 100644 --- a/pkg/evolution/organizer.go +++ b/pkg/evolution/organizer.go @@ -90,7 +90,7 @@ func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, erro Kind: RecordKindPattern, WorkspaceID: cluster[0].WorkspaceID, CreatedAt: o.now(), - Summary: buildRuleSummary(ruleKey, winningPath), + Summary: buildRuleSummary(cluster, ruleKey, winningPath), Source: map[string]any{"cluster_key": ruleKey}, Status: RecordStatus("ready"), SourceRecordIDs: collectRecordIDs(cluster), @@ -108,16 +108,7 @@ func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, erro } func normalizeRuleKey(record LearningRecord) string { - if path := normalizeFinalSuccessfulPath(record); len(path) > 0 { - return strings.Join(path, " ") - } - if path := normalizeAttemptedSkills(record); len(path) > 0 { - return strings.Join(path, " ") - } - if path := normalizePath(record.ActiveSkillNames); len(path) > 0 { - return strings.Join(path, " ") - } - if path := normalizePath(record.MatchedSkillNames); len(path) > 0 { + if path := preferredRulePath(record); len(path) > 0 { return strings.Join(path, " ") } if path := normalizePath(record.ToolKinds); len(path) > 0 { @@ -134,6 +125,28 @@ func normalizeRuleKey(record LearningRecord) string { return strings.Join(tokens, " ") } +func preferredRulePath(record LearningRecord) []string { + if path := normalizeFinalSuccessfulPath(record); len(path) > 0 { + return path + } + if path := normalizePath(record.UsedSkillNames); len(path) > 0 { + return path + } + if path := normalizePath(record.AddedSkillNames); len(path) > 0 { + return path + } + if path := normalizeAttemptedSkills(record); len(path) > 0 { + return path + } + if path := normalizePath(record.ActiveSkillNames); len(path) > 0 { + return path + } + if path := normalizePath(record.MatchedSkillNames); len(path) > 0 { + return path + } + return nil +} + func normalizePath(values []string) []string { if len(values) == 0 { return nil @@ -202,13 +215,7 @@ func clusterWinningPath(cluster []LearningRecord) []string { order := make([]string, 0) for _, record := range cluster { - path := normalizeFinalSuccessfulPath(record) - if len(path) == 0 { - path = normalizeAttemptedSkills(record) - } - if len(path) == 0 { - path = normalizePath(record.ActiveSkillNames) - } + path := preferredRulePath(record) if len(path) == 0 { path = normalizePath(record.ToolKinds) } @@ -287,6 +294,9 @@ func clusterLateAddedSkills(cluster []LearningRecord, winningPath []string) ([]s } func lateAddedSkillsFromRecord(record LearningRecord) ([]string, string) { + if skills := normalizePath(record.AddedSkillNames); len(skills) > 0 { + return skills, "loaded_during_task" + } if record.AttemptTrail == nil || len(record.AttemptTrail.SkillContextSnapshots) == 0 { return nil, "" } @@ -359,9 +369,29 @@ func stableRuleID(workspaceID, key string) string { return "rule-" + hex.EncodeToString(sum[:6]) } -func buildRuleSummary(key string, winningPath []string) string { +func buildRuleSummary(cluster []LearningRecord, key string, winningPath []string) string { + if goal := representativeGoal(cluster); goal != "" && len(winningPath) > 0 { + return goal + " via " + strings.Join(winningPath, " -> ") + } + if goal := representativeGoal(cluster); goal != "" { + return goal + } if len(winningPath) > 0 { return strings.Join(winningPath, " -> ") } return key } + +func representativeGoal(cluster []LearningRecord) string { + for _, record := range cluster { + if goal := strings.TrimSpace(record.UserGoal); goal != "" { + return goal + } + } + for _, record := range cluster { + if summary := strings.TrimSpace(record.Summary); summary != "" { + return summary + } + } + return "" +} diff --git a/pkg/evolution/organizer_test.go b/pkg/evolution/organizer_test.go index 35c7d98fe..397bfa5be 100644 --- a/pkg/evolution/organizer_test.go +++ b/pkg/evolution/organizer_test.go @@ -243,3 +243,68 @@ func TestOrganizer_BuildRulesCapturesLateAddedSkillHintFromSnapshots(t *testing. t.Fatalf("FinalSnapshotTrigger = %q, want context_retry_rebuild", got) } } + +func TestOrganizer_BuildRulesUsesAddedSkillNamesWithoutSnapshots(t *testing.T) { + ok := true + cases := []evolution.LearningRecord{ + { + ID: "case-1", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather shanghai", + UserGoal: "check weather in shanghai", + Status: evolution.RecordStatus("new"), + Success: &ok, + UsedSkillNames: []string{"geocode", "weather"}, + AddedSkillNames: []string{"weather"}, + }, + { + ID: "case-2", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "weather beijing", + UserGoal: "check weather in beijing", + Status: evolution.RecordStatus("new"), + Success: &ok, + UsedSkillNames: []string{"geocode", "weather"}, + AddedSkillNames: []string{"weather"}, + }, + { + ID: "case-3", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000200, 0).UTC(), + Summary: "weather hangzhou", + UserGoal: "check weather in hangzhou", + Status: evolution.RecordStatus("new"), + Success: &ok, + UsedSkillNames: []string{"geocode", "weather"}, + AddedSkillNames: []string{"weather"}, + }, + } + + org := evolution.NewOrganizer(evolution.OrganizerOptions{ + MinCaseCount: 3, + MinSuccessRate: 0.7, + Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, + }) + + rules, err := org.BuildRules(cases) + if err != nil { + t.Fatalf("BuildRules: %v", err) + } + if len(rules) != 1 { + t.Fatalf("len(rules) = %d, want 1", len(rules)) + } + if got := rules[0].WinningPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" { + t.Fatalf("WinningPath = %v, want [geocode weather]", got) + } + if got := rules[0].LateAddedSkills; len(got) != 1 || got[0] != "weather" { + t.Fatalf("LateAddedSkills = %v, want [weather]", got) + } + if got := rules[0].FinalSnapshotTrigger; got != "loaded_during_task" { + t.Fatalf("FinalSnapshotTrigger = %q, want loaded_during_task", got) + } +} diff --git a/pkg/evolution/runtime.go b/pkg/evolution/runtime.go index ae300c2fd..179686680 100644 --- a/pkg/evolution/runtime.go +++ b/pkg/evolution/runtime.go @@ -7,41 +7,48 @@ import ( "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 - SkillsRecaller *SkillsRecaller - DraftGenerator DraftGenerator - GeneratorFactory func(workspace string) DraftGenerator - Applier *Applier - ApplierFactory func(workspace string) *Applier + Config config.EvolutionConfig + Now func() time.Time + Store *Store + Organizer *Organizer + 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 - skillsRecaller *SkillsRecaller - draftGenerator DraftGenerator - generatorFactory func(workspace string) DraftGenerator - applier *Applier - applierFactory func(workspace string) *Applier + cfg config.EvolutionConfig + mu sync.Mutex + now func() time.Time + writer *CaseWriter + store *Store + organizer *Organizer + 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 { @@ -51,7 +58,10 @@ type TurnCaseInput struct { SessionKey string AgentID string Status string + UserMessage string + FinalContent string ToolKinds []string + ToolExecutions []ToolExecutionRecord ActiveSkillNames []string AttemptedSkillNames []string FinalSuccessfulPath []string @@ -74,44 +84,55 @@ func NewRuntime(opts RuntimeOptions) (*Runtime, error) { } 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, + cfg: opts.Config, + now: now, + store: opts.Store, + organizer: organizer, + 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 == "" { + if rt == nil || !rt.cfg.Enabled || input.Workspace == "" || shouldSkipLearningRecord(input) { return nil } success := input.Status == "completed" + skillUsage := buildSkillUsage(input) workspaceID := input.WorkspaceID if workspaceID == "" { workspaceID = input.Workspace } + toolKinds := cloneToolKinds(input) 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), + ID: input.TurnID, + Kind: RecordKindTask, + WorkspaceID: workspaceID, + CreatedAt: rt.now(), + SessionKey: input.SessionKey, + TaskHash: buildTaskHash(input, skillUsage, toolKinds), + Summary: buildRecordSummary(input), + UserGoal: summarizeText(input.UserMessage, 240), + FinalOutput: summarizeText(input.FinalContent, 240), + Source: map[string]any{"turn_id": input.TurnID, "session_key": input.SessionKey, "agent_id": input.AgentID}, + Status: RecordStatus("new"), + Success: &success, + ToolKinds: toolKinds, + ToolExecutions: cloneToolExecutions(input.ToolExecutions), + InitialSkillNames: append([]string(nil), skillUsage.Initial...), + AddedSkillNames: append([]string(nil), skillUsage.Added...), + UsedSkillNames: append([]string(nil), skillUsage.Used...), + AllLoadedSkillNames: append([]string(nil), skillUsage.All...), + ActiveSkillNames: append([]string(nil), skillUsage.Initial...), + AttemptTrail: buildAttemptTrail(skillUsage, success), + Signals: buildLearningSignals(skillUsage, success), } paths := NewPaths(input.Workspace, rt.cfg.StateDir) @@ -127,76 +148,55 @@ func (rt *Runtime) FinalizeTurn(ctx context.Context, input TurnCaseInput) error return err } - return rt.recordSkillUsage(input, success) + if err := rt.recordSkillUsage(input, success); err != nil { + return err + } + + logger.InfoCF("evolution", "Recorded hot path learning record", map[string]any{ + "workspace": input.Workspace, + "turn_id": input.TurnID, + "success": success, + "tool_count": len(record.ToolExecutions), + "used_skills": len(record.UsedSkillNames), + "all_skills": len(record.AllLoadedSkillNames), + }) + return nil } -func buildAttemptTrail(input TurnCaseInput, success bool) *AttemptTrail { - attemptedInput := input.AttemptedSkillNames - if len(attemptedInput) == 0 { - attemptedInput = input.ActiveSkillNames - } +type skillUsageRecord struct { + Initial []string + Added []string + Used []string + All []string + Attempted []string + Final []string +} - 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 { +func buildAttemptTrail(usage skillUsageRecord, success bool) *AttemptTrail { + if len(usage.All) == 0 { return nil } trail := &AttemptTrail{ - AttemptedSkills: attempted, - } - if len(input.SkillContextSnapshots) > 0 { - trail.SkillContextSnapshots = cloneSkillContextSnapshots(input.SkillContextSnapshots) + AttemptedSkills: append([]string(nil), usage.Attempted...), } if success { - finalPathInput := input.FinalSuccessfulPath - if len(finalPathInput) == 0 { - finalPathInput = attempted + finalPath := usage.Final + if len(finalPath) == 0 { + finalPath = usage.All } - finalPath := make([]string, 0, len(finalPathInput)) - for _, skillName := range finalPathInput { - skillName = strings.TrimSpace(skillName) - if skillName == "" { - continue - } - finalPath = append(finalPath, skillName) - } - trail.FinalSuccessfulPath = finalPath + trail.FinalSuccessfulPath = append([]string(nil), finalPath...) } return trail } -func cloneSkillContextSnapshots(input []SkillContextSnapshot) []SkillContextSnapshot { - if len(input) == 0 { - return nil +func buildTaskHash(input TurnCaseInput, usage skillUsageRecord, toolKinds []string) string { + parts := make([]string, 0, len(usage.All)+len(toolKinds)+3) + parts = append(parts, normalizedValues(usage.All)...) + parts = append(parts, normalizedValues(toolKinds)...) + if goal := summarizeText(input.UserMessage, 120); goal != "" { + parts = append(parts, strings.ToLower(goal)) } - - 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))) } @@ -208,18 +208,11 @@ func buildTaskHash(input TurnCaseInput) string { return hex.EncodeToString(sum[:8]) } -func buildLearningSignals(input TurnCaseInput, success bool) []string { - if !success { +func buildLearningSignals(usage skillUsageRecord, success bool) []string { + if !success || len(usage.Used) == 0 { return nil } - skillValues := input.AttemptedSkillNames - if len(skillValues) == 0 { - skillValues = input.ActiveSkillNames - } - if len(normalizedValues(skillValues)) > 1 { - return []string{"potentially_learnable"} - } - return nil + return []string{"potentially_learnable"} } func normalizedValues(values []string) []string { @@ -234,28 +227,216 @@ func normalizedValues(values []string) []string { return out } +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 cloneToolKinds(input TurnCaseInput) []string { + if len(input.ToolKinds) > 0 { + return append([]string(nil), input.ToolKinds...) + } + if len(input.ToolExecutions) == 0 { + return nil + } + out := make([]string, 0, len(input.ToolExecutions)) + seen := make(map[string]struct{}, len(input.ToolExecutions)) + for _, exec := range input.ToolExecutions { + name := strings.TrimSpace(exec.Name) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + out = append(out, name) + } + return out +} + +func cloneToolExecutions(input []ToolExecutionRecord) []ToolExecutionRecord { + if len(input) == 0 { + return nil + } + out := make([]ToolExecutionRecord, 0, len(input)) + for _, exec := range input { + name := strings.TrimSpace(exec.Name) + if name == "" { + continue + } + out = append(out, ToolExecutionRecord{ + Name: name, + Success: exec.Success, + ErrorSummary: strings.TrimSpace(exec.ErrorSummary), + SkillNames: uniqueTrimmedNames(exec.SkillNames), + }) + } + return out +} + +func buildSkillUsage(input TurnCaseInput) skillUsageRecord { + initial := uniqueTrimmedNames(input.ActiveSkillNames) + if len(input.SkillContextSnapshots) > 0 { + initial = uniqueTrimmedNames(input.SkillContextSnapshots[0].SkillNames) + } + all := append([]string(nil), initial...) + added := make([]string, 0) + seen := make(map[string]struct{}, len(all)) + for _, skillName := range all { + seen[strings.ToLower(skillName)] = struct{}{} + } + + appendNew := func(skillNames []string) { + for _, skillName := range uniqueTrimmedNames(skillNames) { + key := strings.ToLower(skillName) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + added = append(added, skillName) + all = append(all, skillName) + } + } + + for _, snapshot := range input.SkillContextSnapshots { + appendNew(snapshot.SkillNames) + } + for _, exec := range input.ToolExecutions { + appendNew(exec.SkillNames) + } + attempted := uniqueTrimmedNames(input.AttemptedSkillNames) + if len(attempted) == 0 { + attempted = append([]string(nil), all...) + } else { + appendNew(attempted) + } + final := uniqueTrimmedNames(input.FinalSuccessfulPath) + if len(final) > 0 { + appendNew(final) + } + if len(attempted) == 0 { + attempted = append([]string(nil), all...) + } + if len(final) == 0 { + final = append([]string(nil), added...) + } + + return skillUsageRecord{ + Initial: initial, + Added: append([]string(nil), added...), + Used: append([]string(nil), added...), + All: all, + Attempted: append([]string(nil), attempted...), + Final: append([]string(nil), final...), + } +} + +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.InfoCF("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) records, err := store.LoadLearningRecords() if err != nil { return err } + logger.InfoCF("evolution", "Loaded evolution records", map[string]any{ + "workspace": workspace, + "record_count": len(records), + "run_id": runID, + }) + admittedCount := 0 + newRuleCount := 0 if rt.organizer != nil { - rules, err := rt.organizer.BuildRules(records) + recordsForOrganizer, err := rt.recordsForColdPath(ctx, workspace, records) + if err != nil { + return err + } + admittedCount = countTaskLearningRecords(recordsForOrganizer) + logger.InfoCF("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, + }) + rules, err := rt.organizer.BuildRules(recordsForOrganizer) if err != nil { return err } newRules := filterNewRules(records, rules, workspace) + newRuleCount = len(newRules) + logger.InfoCF("evolution", "Built learning patterns", map[string]any{ + "workspace": workspace, + "pattern_count": len(rules), + "new_patterns": len(newRules), + "admitted_tasks": admittedCount, + "patterns": summarizePatternRecords(rules), + "run_id": runID, + }) if len(newRules) > 0 { if err := store.AppendLearningRecords(newRules); err != nil { return err @@ -266,14 +447,25 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error generator := rt.draftGeneratorForWorkspace(workspace) if generator == nil { - return nil + logger.InfoCF("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(records, workspace) if len(readyRules) == 0 { - return nil + logger.InfoCF("evolution", "Finished cold path run without ready patterns", map[string]any{ + "workspace": workspace, + "record_count": len(records), + "new_patterns": newRuleCount, + "admitted_tasks": admittedCount, + "run_id": runID, + }) + return rt.runLifecycleMaintenance(workspace, store, runID) } existingDrafts, err := store.LoadDrafts() @@ -281,7 +473,16 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error return err } existingBySource := existingDraftSourceSet(existingDrafts, workspace) + logger.InfoCF("evolution", "Selected ready patterns for drafting", map[string]any{ + "workspace": workspace, + "ready_patterns": len(readyRules), + "existing_draft_count": len(existingBySource), + "ready_pattern_ids": joinRecordIDs(readyRules), + "ready_patterns_info": summarizePatternRecords(readyRules), + "run_id": runID, + }) + processedRules := 0 for _, rule := range readyRules { select { case <-ctx.Done(): @@ -290,6 +491,12 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error } if _, exists := existingBySource[rule.ID]; exists { + logger.InfoCF("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 } @@ -297,6 +504,13 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error if err != nil { return err } + logger.InfoCF("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 := generator.GenerateDraft(ctx, rule, matches) if err != nil { @@ -304,9 +518,32 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error } draft = rt.finalizeDraft(workspace, rule, matches, draft) - if mode == "apply" && rt.cfg.AutoApply && applier != nil && draft.Status == DraftStatusCandidate { + logger.InfoCF("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 { + 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 { @@ -323,6 +560,13 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error } draft.Status = DraftStatusAccepted 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 { @@ -337,15 +581,103 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error } return 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, + }) } if err := store.SaveDrafts([]SkillDraft{draft}); err != nil { return err } + logger.InfoCF("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++ } - return nil + 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) recordsForColdPath( + ctx context.Context, + workspace string, + records []LearningRecord, +) ([]LearningRecord, error) { + out := make([]LearningRecord, 0, len(records)) + judge := rt.successJudgeForWorkspace(workspace) + + for _, record := range records { + if !isTaskRecordKind(record.Kind) { + out = append(out, record) + continue + } + if record.WorkspaceID != workspace { + continue + } + if !passesColdPathRuleFilter(record) { + continue + } + if judge != nil { + decision, err := judge.JudgeTaskRecord(ctx, record) + if err != nil { + return nil, err + } + if !decision.Success { + continue + } + } + out = append(out, record) + } + return out, nil +} + +func passesColdPathRuleFilter(record LearningRecord) bool { + if !isTaskRecordKind(record.Kind) { + return false + } + if record.Success == nil || !*record.Success { + return false + } + if strings.TrimSpace(record.UserGoal) == "" { + return false + } + if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") { + return false + } + if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") { + return false + } + if len(record.ToolExecutions) > 0 { + allFailed := true + for _, exec := range record.ToolExecutions { + if exec.Success { + allFailed = false + break + } + } + if allFailed { + return false + } + } + if len(record.ToolKinds) == 0 && len(record.UsedSkillNames) == 0 && strings.TrimSpace(record.FinalOutput) == "" { + return false + } + return true } func (rt *Runtime) storeForWorkspace(workspace string) *Store { @@ -382,6 +714,18 @@ func (rt *Runtime) draftGeneratorForWorkspace(workspace string) 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 { @@ -404,9 +748,11 @@ func (rt *Runtime) finalizeDraft(workspace string, rule LearningRecord, matches draft.MatchedSkillRefs = collectSkillRefs(matches) } + draft, normalizationNotes := rt.normalizeDraftForWorkspace(workspace, rule, 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 @@ -415,6 +761,232 @@ func (rt *Runtime) finalizeDraft(workspace string, rule LearningRecord, matches return draft } +func (rt *Runtime) normalizeDraftForWorkspace( + workspace string, + rule LearningRecord, + 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) + notes = append(notes, "normalized combined shortcut draft to append onto the existing combined skill") + } else { + draft.ChangeKind = ChangeKindCreate + draft.BodyOrPatch = synthesizeCombinedSkillDocument(target, draft, rule) + 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) + 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) 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, + "", + }, "\n") + return buildSkillDocument(target, description, body) +} + +func synthesizeCombinedSkillDocument(target string, draft SkillDraft, rule LearningRecord) string { + description := strings.TrimSpace(draft.HumanSummary) + if description == "" { + description = buildCombinedSkillHumanSummary(target, rule, false) + } + + body := strings.Join([]string{ + "# " + titleCaseSkillName(target), + "", + "## Start Here", + synthesizedCombinedStartHereLine(rule, target), + "", + "## When To Use", + synthesizedCombinedWhenToUseLine(rule, target), + "", + "## Wrapped Path", + synthesizedWrappedPathLine(rule), + "", + "## Learned Shortcut", + synthesizedCombinedLearnedContent(draft.BodyOrPatch), + "", + }, "\n") + return buildSkillDocument(target, description, body) +} + +func synthesizeCombinedSkillAppendBody(target string, draft SkillDraft, rule LearningRecord) 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.", + "", + synthesizedCombinedLearnedContent(draft.BodyOrPatch), + "", + } + 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 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) string { + content := strings.TrimSpace(stripSkillFrontmatter(body)) + if content == "" { + return "Use this shortcut directly when the same task pattern appears again." + } + return demoteMarkdownHeadings(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 demoteMarkdownHeadings(content string) string { + lines := strings.Split(content, "\n") + for i, line := range lines { + trimmed := strings.TrimLeft(line, " \t") + if !strings.HasPrefix(trimmed, "#") { + continue + } + prefixLen := len(line) - len(trimmed) + lines[i] = line[:prefixLen] + "##" + trimmed + } + return strings.TrimSpace(strings.Join(lines, "\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 { + action := "Create" + if hasExisting { + action = "Refresh" + } + return fmt.Sprintf("%s combined shortcut %s from learned pattern: %s", action, target, fallbackEvolutionSummary(rule)) +} + +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 @@ -431,6 +1003,89 @@ func collectSkillRefs(matches []skills.SkillInfo) []string { 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.InfoCF("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.InfoCF("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 filterNewRules(records []LearningRecord, rules []LearningRecord, workspace string) []LearningRecord { existing := make(map[string]struct{}, len(records)) for _, record := range records { @@ -544,13 +1199,14 @@ func appendUniqueStrings(existing []string, values ...string) []string { } func (rt *Runtime) recordSkillUsage(input TurnCaseInput, success bool) error { - if len(input.ActiveSkillNames) == 0 { + usage := buildSkillUsage(input) + if len(usage.All) == 0 { return nil } store := rt.storeForWorkspace(input.Workspace) - seen := make(map[string]struct{}, len(input.ActiveSkillNames)) - for _, skillName := range input.ActiveSkillNames { + seen := make(map[string]struct{}, len(usage.All)) + for _, skillName := range usage.All { skillName = strings.TrimSpace(skillName) if skillName == "" { continue diff --git a/pkg/evolution/runtime_apply_test.go b/pkg/evolution/runtime_apply_test.go index 9950a66c2..255adba5e 100644 --- a/pkg/evolution/runtime_apply_test.go +++ b/pkg/evolution/runtime_apply_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -30,7 +31,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) { } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true}, + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Store: store, Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { @@ -104,7 +105,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) { } } -func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) { +func TestRuntime_RunColdPathOnce_DraftModeKeepsCandidateDraft(t *testing.T) { root := t.TempDir() store := evolution.NewStore(evolution.NewPaths(root, "")) @@ -122,7 +123,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: false}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Store: store, Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { @@ -170,6 +171,98 @@ func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t } } +func TestRuntime_RunColdPathOnce_ApplyModeRetargetsStableMultiSkillPathIntoCombinedShortcut(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "calculate 100", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, + Store: store, + Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { + return time.Unix(1700001000, 0).UTC() + }), + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "five-three-theorem", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "combine the theorem chain into one shortcut skill", + BodyOrPatch: "Prefer the full theorem chain directly.", + }, + }, + Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}), + SkillsRecaller: evolution.NewSkillsRecaller(root), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %v", err) + } + + skillPath := filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md") + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + content := string(data) + if !strings.Contains(content, "name: calculate-100-via-theorems") { + t.Fatalf("unexpected content:\n%s", content) + } + if !strings.Contains(content, "# Calculate 100 Via Theorems") { + t.Fatalf("missing synthesized heading:\n%s", content) + } + if !strings.Contains(content, "Prefer the full theorem chain directly.") { + t.Fatalf("missing learned content:\n%s", content) + } + if !strings.Contains(content, "Use `calculate-100-via-theorems` directly") { + t.Fatalf("missing direct shortcut guidance:\n%s", content) + } + + drafts, err := store.LoadDrafts() + if err != nil { + t.Fatalf("LoadDrafts: %v", err) + } + if len(drafts) != 1 { + t.Fatalf("len(drafts) = %d, want 1", len(drafts)) + } + if drafts[0].Status != evolution.DraftStatusAccepted { + t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted) + } + if drafts[0].ChangeKind != evolution.ChangeKindCreate { + t.Fatalf("ChangeKind = %q, want %q", drafts[0].ChangeKind, evolution.ChangeKindCreate) + } + if drafts[0].TargetSkillName != "calculate-100-via-theorems" { + t.Fatalf("TargetSkillName = %q, want calculate-100-via-theorems", drafts[0].TargetSkillName) + } + if len(drafts[0].PreferredEntryPath) != 1 || drafts[0].PreferredEntryPath[0] != "calculate-100-via-theorems" { + t.Fatalf("PreferredEntryPath = %v, want [calculate-100-via-theorems]", drafts[0].PreferredEntryPath) + } + if len(drafts[0].ReviewNotes) == 0 { + t.Fatal("expected normalization review notes") + } +} + func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAudit(t *testing.T) { root := t.TempDir() store := evolution.NewStore(evolution.NewPaths(root, "")) @@ -221,7 +314,7 @@ func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAu } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true}, + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Store: store, Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { @@ -292,6 +385,89 @@ func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAu } } +func TestRuntime_RunColdPathOnce_AutoRunsLifecycleMaintenance(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + now := time.Unix(1700001000, 0).UTC() + + if err := store.SaveProfile(evolution.SkillProfile{ + SkillName: "stale-active-skill", + WorkspaceID: root, + Status: evolution.SkillStatusActive, + Origin: "evolved", + HumanSummary: "stale active skill", + LastUsedAt: now.Add(-91 * 24 * time.Hour), + RetentionScore: 0.1, + }); err != nil { + t.Fatalf("SaveProfile(active): %v", err) + } + + skillDir := filepath.Join(root, "skills", "stale-archived-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, []byte("---\nname: stale-archived-skill\ndescription: stale\n---\n# Stale Archived Skill\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := store.SaveProfile(evolution.SkillProfile{ + SkillName: "stale-archived-skill", + WorkspaceID: root, + Status: evolution.SkillStatusArchived, + Origin: "evolved", + HumanSummary: "stale archived skill", + LastUsedAt: now.Add(-366 * 24 * time.Hour), + RetentionScore: 0.05, + }); err != nil { + t.Fatalf("SaveProfile(archived): %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + Now: func() time.Time { return now }, + Store: store, + Applier: evolution.NewApplier(paths, func() time.Time { + return now + }), + Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}), + SkillsRecaller: evolution.NewSkillsRecaller(root), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %v", err) + } + + activeProfile, err := store.LoadProfile("stale-active-skill") + if err != nil { + t.Fatalf("LoadProfile(active): %v", err) + } + if activeProfile.Status != evolution.SkillStatusCold { + t.Fatalf("active profile Status = %q, want %q", activeProfile.Status, evolution.SkillStatusCold) + } + if len(activeProfile.VersionHistory) != 1 || activeProfile.VersionHistory[0].Action != "lifecycle:cold" { + t.Fatalf("active profile VersionHistory = %+v, want lifecycle:cold entry", activeProfile.VersionHistory) + } + + archivedProfile, err := store.LoadProfile("stale-archived-skill") + if err != nil { + t.Fatalf("LoadProfile(archived): %v", err) + } + if archivedProfile.Status != evolution.SkillStatusDeleted { + t.Fatalf("archived profile Status = %q, want %q", archivedProfile.Status, evolution.SkillStatusDeleted) + } + if len(archivedProfile.VersionHistory) != 1 || archivedProfile.VersionHistory[0].Action != "lifecycle:deleted" { + t.Fatalf("archived profile VersionHistory = %+v, want lifecycle:deleted entry", archivedProfile.VersionHistory) + } + + if _, err := os.Stat(skillPath); !os.IsNotExist(err) { + t.Fatalf("expected lifecycle delete to remove skill file, stat err = %v", err) + } +} + func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesDraft(t *testing.T) { root := t.TempDir() paths := evolution.NewPaths(root, "") @@ -318,7 +494,7 @@ func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesD } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true}, + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Store: store, Applier: evolution.NewApplier(paths, func() time.Time { diff --git a/pkg/evolution/runtime_cold_path_test.go b/pkg/evolution/runtime_cold_path_test.go index 5e346a477..052705bae 100644 --- a/pkg/evolution/runtime_cold_path_test.go +++ b/pkg/evolution/runtime_cold_path_test.go @@ -38,6 +38,22 @@ type draftGenerationResult struct { err error } +type stubSuccessJudge struct { + decisions map[string]evolution.TaskSuccessDecision + calls []string +} + +func (j *stubSuccessJudge) JudgeTaskRecord( + _ context.Context, + record evolution.LearningRecord, +) (evolution.TaskSuccessDecision, error) { + j.calls = append(j.calls, record.ID) + if decision, ok := j.decisions[record.ID]; ok { + return decision, nil + } + return evolution.TaskSuccessDecision{Success: true, Reason: "default success"}, nil +} + func (g *sequenceDraftGenerator) GenerateDraft( _ context.Context, _ evolution.LearningRecord, @@ -70,7 +86,7 @@ func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) { } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, DraftGenerator: stubDraftGenerator{ draft: evolution.SkillDraft{ @@ -107,6 +123,137 @@ func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) { } } +func TestRuntime_RunColdPathOnce_AdmitsOnlyRecordsApprovedBySuccessJudge(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + ok := true + failed := false + + records := []evolution.LearningRecord{ + { + ID: "task-failed", + Kind: evolution.RecordKindTask, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "failed weather attempt", + UserGoal: "check weather in shanghai", + FinalOutput: "tool failed", + Status: evolution.RecordStatus("new"), + Success: &failed, + UsedSkillNames: []string{"weather"}, + ToolKinds: []string{"read_file"}, + }, + { + ID: "task-rejected", + Kind: evolution.RecordKindTask, + WorkspaceID: root, + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "partial weather answer", + UserGoal: "check weather in shanghai", + FinalOutput: "I will check it next", + Status: evolution.RecordStatus("new"), + Success: &ok, + UsedSkillNames: []string{"weather"}, + ToolKinds: []string{"read_file"}, + ToolExecutions: []evolution.ToolExecutionRecord{{Name: "read_file", Success: true}}, + }, + { + ID: "task-admitted", + Kind: evolution.RecordKindTask, + WorkspaceID: root, + CreatedAt: time.Unix(1700000200, 0).UTC(), + Summary: "weather answer delivered", + UserGoal: "check weather in shanghai", + FinalOutput: "sunny, 26C", + Status: evolution.RecordStatus("new"), + Success: &ok, + UsedSkillNames: []string{"weather"}, + AddedSkillNames: []string{"weather"}, + ToolKinds: []string{"read_file"}, + ToolExecutions: []evolution.ToolExecutionRecord{{Name: "read_file", Success: true}}, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"weather"}, + FinalSuccessfulPath: []string{"weather"}, + }, + }, + } + if err := store.AppendLearningRecords(records); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + judge := &stubSuccessJudge{ + decisions: map[string]evolution.TaskSuccessDecision{ + "task-rejected": {Success: false, Reason: "only partial reasoning"}, + "task-admitted": {Success: true, Reason: "goal achieved"}, + }, + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, + Store: store, + SuccessJudge: judge, + Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 1, MinSuccessRate: 1}), + SkillsRecaller: evolution.NewSkillsRecaller(root), + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-weather", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "prefer the proven weather path", + BodyOrPatch: "## Start Here\nUse the weather path directly.", + }, + }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %v", err) + } + + if len(judge.calls) != 2 || judge.calls[0] != "task-rejected" || judge.calls[1] != "task-admitted" { + t.Fatalf("judge calls = %v, want [task-rejected task-admitted]", judge.calls) + } + + allRecords, err := store.LoadLearningRecords() + if err != nil { + t.Fatalf("LoadLearningRecords: %v", err) + } + + var pattern evolution.LearningRecord + foundPattern := false + for _, record := range allRecords { + if record.Kind != evolution.RecordKindPattern { + continue + } + pattern = record + foundPattern = true + break + } + if !foundPattern { + t.Fatal("expected generated pattern record") + } + if len(pattern.SourceRecordIDs) != 1 || pattern.SourceRecordIDs[0] != "task-admitted" { + t.Fatalf("SourceRecordIDs = %v, want [task-admitted]", pattern.SourceRecordIDs) + } + if got := pattern.WinningPath; len(got) != 1 || got[0] != "weather" { + t.Fatalf("WinningPath = %v, want [weather]", got) + } + + drafts, err := store.LoadDrafts() + if err != nil { + t.Fatalf("LoadDrafts: %v", err) + } + if len(drafts) != 1 { + t.Fatalf("len(drafts) = %d, want 1", len(drafts)) + } + if drafts[0].SourceRecordID != pattern.ID { + t.Fatalf("draft SourceRecordID = %q, want %q", drafts[0].SourceRecordID, pattern.ID) + } +} + func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) { root := t.TempDir() store := evolution.NewStore(evolution.NewPaths(root, "")) @@ -125,7 +272,7 @@ func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) { } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, DraftGenerator: stubDraftGenerator{ draft: evolution.SkillDraft{ ID: "draft-1", @@ -247,7 +394,7 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(t *testing.T) { } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, }) if err != nil { @@ -301,7 +448,7 @@ func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(t *t }, } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, provider, "runtime-explicit-model"), }) @@ -348,7 +495,7 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvid } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, nil, ""), }) @@ -408,7 +555,7 @@ func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *tes } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, GeneratorFactory: func(workspace string) evolution.DraftGenerator { return evolution.NewDraftGeneratorForWorkspace(workspace, provider, "runtime-explicit-model") @@ -484,7 +631,7 @@ func TestRuntime_RunColdPathOnce_PersistsEarlierDraftWhenLaterRuleFails(t *testi } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, DraftGenerator: generator, SkillsRecaller: evolution.NewSkillsRecaller(root), @@ -543,7 +690,7 @@ func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(t *testing.T) } rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ - Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Config: config.EvolutionConfig{Enabled: true, Mode: "draft"}, Store: store, DraftGenerator: stubDraftGenerator{ draft: evolution.SkillDraft{ diff --git a/pkg/evolution/runtime_test.go b/pkg/evolution/runtime_test.go index 22a831122..52b4c8cfe 100644 --- a/pkg/evolution/runtime_test.go +++ b/pkg/evolution/runtime_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/evolution" @@ -53,6 +54,32 @@ func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) { } } +func TestRuntime_FinalizeTurnSkipsHeartbeat(t *testing.T) { + workspace := t.TempDir() + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "heartbeat-turn", + SessionKey: "heartbeat", + Status: "completed", + UserMessage: "# Heartbeat Check", + FinalContent: "HEARTBEAT_OK", + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + paths := evolution.NewPaths(workspace, "") + if _, err := os.Stat(paths.LearningRecords); !os.IsNotExist(err) { + t.Fatalf("heartbeat should not create learning records, stat err = %v", err) + } +} + func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) { workspace := t.TempDir() override := filepath.Join(t.TempDir(), "custom-state") @@ -71,25 +98,36 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) { } if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ - Workspace: workspace, - TurnID: "turn-1", - SessionKey: "session-1", - AgentID: "agent-1", - Status: "completed", - ToolKinds: []string{"web", "read_file"}, + Workspace: workspace, + TurnID: "turn-1", + SessionKey: "session-1", + AgentID: "agent-1", + Status: "completed", + UserMessage: "summarize the release notes", + FinalContent: "Here is the summary.", + ToolKinds: []string{"web", "read_file"}, + ToolExecutions: []evolution.ToolExecutionRecord{ + {Name: "web", Success: true}, + {Name: "read_file", Success: true}, + }, ActiveSkillNames: []string{"skill-a"}, }); err != nil { t.Fatalf("FinalizeTurn first call: %v", err) } if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ - Workspace: workspace, - WorkspaceID: "ws-explicit", - TurnID: "turn-2", - SessionKey: "session-2", - AgentID: "agent-2", - Status: "error", - ToolKinds: []string{"bash"}, + Workspace: workspace, + WorkspaceID: "ws-explicit", + TurnID: "turn-2", + SessionKey: "session-2", + AgentID: "agent-2", + Status: "error", + UserMessage: "run the bash command", + FinalContent: "bash failed", + ToolKinds: []string{"bash"}, + ToolExecutions: []evolution.ToolExecutionRecord{ + {Name: "bash", Success: false, ErrorSummary: "exit status 1"}, + }, ActiveSkillNames: []string{"skill-b"}, }); err != nil { t.Fatalf("FinalizeTurn second call: %v", err) @@ -119,15 +157,36 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) { if first.SessionKey != "session-1" { t.Fatalf("first SessionKey = %q, want %q", first.SessionKey, "session-1") } - if first.Summary != "turn turn-1 finished with status=completed" { + if first.Summary != "summarize the release notes" { t.Fatalf("first Summary = %q", first.Summary) } + if first.UserGoal != "summarize the release notes" { + t.Fatalf("first UserGoal = %q", first.UserGoal) + } + if first.FinalOutput != "Here is the summary." { + t.Fatalf("first FinalOutput = %q", first.FinalOutput) + } if first.Success == nil || !*first.Success { t.Fatalf("first Success = %v, want true", first.Success) } if len(first.ToolKinds) != 2 || first.ToolKinds[0] != "web" || first.ToolKinds[1] != "read_file" { t.Fatalf("first ToolKinds = %v", first.ToolKinds) } + if len(first.ToolExecutions) != 2 || !first.ToolExecutions[0].Success || first.ToolExecutions[0].Name != "web" { + t.Fatalf("first ToolExecutions = %+v", first.ToolExecutions) + } + if len(first.InitialSkillNames) != 1 || first.InitialSkillNames[0] != "skill-a" { + t.Fatalf("first InitialSkillNames = %v", first.InitialSkillNames) + } + if len(first.AddedSkillNames) != 0 { + t.Fatalf("first AddedSkillNames = %v, want empty", first.AddedSkillNames) + } + if len(first.UsedSkillNames) != 0 { + t.Fatalf("first UsedSkillNames = %v, want empty", first.UsedSkillNames) + } + if len(first.AllLoadedSkillNames) != 1 || first.AllLoadedSkillNames[0] != "skill-a" { + t.Fatalf("first AllLoadedSkillNames = %v", first.AllLoadedSkillNames) + } if len(first.ActiveSkillNames) != 1 || first.ActiveSkillNames[0] != "skill-a" { t.Fatalf("first ActiveSkillNames = %v", first.ActiveSkillNames) } @@ -169,9 +228,15 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) { if second.SessionKey != "session-2" { t.Fatalf("second SessionKey = %q, want %q", second.SessionKey, "session-2") } + if second.UserGoal != "run the bash command" { + t.Fatalf("second UserGoal = %q", second.UserGoal) + } if second.Success == nil || *second.Success { t.Fatalf("second Success = %v, want false", second.Success) } + if len(second.ToolExecutions) != 1 || second.ToolExecutions[0].ErrorSummary != "exit status 1" { + t.Fatalf("second ToolExecutions = %+v", second.ToolExecutions) + } if second.AttemptTrail == nil { t.Fatal("second AttemptTrail should not be nil") } @@ -241,14 +306,126 @@ func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) { if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" { t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals) } + if got := record.InitialSkillNames; len(got) != 1 || got[0] != "geocode" { + t.Fatalf("InitialSkillNames = %v, want [geocode]", got) + } + if got := record.AddedSkillNames; len(got) != 1 || got[0] != "weather" { + t.Fatalf("AddedSkillNames = %v, want [weather]", got) + } + if got := record.UsedSkillNames; len(got) != 1 || got[0] != "weather" { + t.Fatalf("UsedSkillNames = %v, want [weather]", got) + } + if got := record.AllLoadedSkillNames; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" { + t.Fatalf("AllLoadedSkillNames = %v, want [geocode weather]", got) + } if record.AttemptTrail == nil { t.Fatal("AttemptTrail should not be nil") } - if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" { - t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got) + if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 1 || got[0] != "weather" { + t.Fatalf("FinalSuccessfulPath = %v, want [weather]", got) } - if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 { - t.Fatalf("SkillContextSnapshots = %v, want 2 snapshots", got) + if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 0 { + t.Fatalf("SkillContextSnapshots = %v, want empty", got) + } +} + +func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) { + workspace := t.TempDir() + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-skill-chain", + SessionKey: "session-skill-chain", + AgentID: "main", + Status: "completed", + UserMessage: "调用三一定理计算100", + FinalContent: "done", + ToolExecutions: []evolution.ToolExecutionRecord{ + {Name: "read_file", Success: true, SkillNames: []string{"three-one"}}, + {Name: "read_file", Success: true, SkillNames: []string{"four-two"}}, + {Name: "read_file", Success: true, SkillNames: []string{"five-three"}}, + }, + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + paths := evolution.NewPaths(workspace, "") + data, err := os.ReadFile(paths.LearningRecords) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("record file line count = %d, want 1", len(lines)) + } + + var record evolution.LearningRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("Unmarshal record: %v", err) + } + if got := record.AddedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" { + t.Fatalf("AddedSkillNames = %v, want [three-one four-two five-three]", got) + } + if got := record.UsedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" { + t.Fatalf("UsedSkillNames = %v, want [three-one four-two five-three]", got) + } + if got := record.AllLoadedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" { + t.Fatalf("AllLoadedSkillNames = %v, want [three-one four-two five-three]", got) + } +} + +func TestRuntime_FinalizeTurnPreservesUTF8WhenTruncatingChineseOutput(t *testing.T) { + workspace := t.TempDir() + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + longChinese := strings.Repeat("中文输出", 80) + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-utf8", + SessionKey: "session-utf8", + AgentID: "main", + Status: "completed", + UserMessage: "请处理这段中文输出", + FinalContent: longChinese, + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + paths := evolution.NewPaths(workspace, "") + data, err := os.ReadFile(paths.LearningRecords) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("record file line count = %d, want 1", len(lines)) + } + + var record evolution.LearningRecord + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("Unmarshal record: %v", err) + } + if !utf8.ValidString(record.FinalOutput) { + t.Fatalf("FinalOutput is not valid UTF-8: %q", record.FinalOutput) + } + if strings.ContainsRune(record.FinalOutput, '\uFFFD') { + t.Fatalf("FinalOutput contains replacement rune: %q", record.FinalOutput) + } + if !strings.HasSuffix(record.FinalOutput, "...") { + t.Fatalf("FinalOutput = %q, want truncated suffix ...", record.FinalOutput) } } @@ -306,8 +483,14 @@ func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) { if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" { t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got) } - if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 || got[1].Trigger != "context_retry_rebuild" { - t.Fatalf("SkillContextSnapshots = %+v, want explicit snapshots preserved", got) + if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 0 { + t.Fatalf("SkillContextSnapshots = %+v, want empty", got) + } + if got := record.InitialSkillNames; len(got) != 1 || got[0] != "weather" { + t.Fatalf("InitialSkillNames = %v, want [weather]", got) + } + if got := record.AddedSkillNames; len(got) != 1 || got[0] != "geocode" { + t.Fatalf("AddedSkillNames = %v, want [geocode]", got) } if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" { t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals) diff --git a/pkg/evolution/success_judge.go b/pkg/evolution/success_judge.go new file mode 100644 index 000000000..e08f5f609 --- /dev/null +++ b/pkg/evolution/success_judge.go @@ -0,0 +1,168 @@ +package evolution + +import ( + "context" + "encoding/json" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +type TaskSuccessDecision struct { + Success bool + Reason string +} + +type SuccessJudge interface { + JudgeTaskRecord(ctx context.Context, record LearningRecord) (TaskSuccessDecision, error) +} + +type HeuristicSuccessJudge struct{} + +func (j *HeuristicSuccessJudge) JudgeTaskRecord( + _ context.Context, + record LearningRecord, +) (TaskSuccessDecision, error) { + if record.Success == nil || !*record.Success { + return TaskSuccessDecision{Success: false, Reason: "task not completed"}, nil + } + if strings.TrimSpace(record.UserGoal) == "" { + return TaskSuccessDecision{Success: false, Reason: "missing user goal"}, nil + } + if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") { + return TaskSuccessDecision{Success: false, Reason: "heartbeat session"}, nil + } + if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") { + return TaskSuccessDecision{Success: false, Reason: "heartbeat output"}, nil + } + if len(record.ToolExecutions) > 0 { + allFailed := true + for _, exec := range record.ToolExecutions { + if exec.Success { + allFailed = false + break + } + } + if allFailed { + return TaskSuccessDecision{Success: false, Reason: "all tools failed"}, nil + } + } + if strings.TrimSpace(record.FinalOutput) == "" && len(record.ToolExecutions) == 0 { + return TaskSuccessDecision{Success: false, Reason: "missing final output"}, nil + } + return TaskSuccessDecision{Success: true, Reason: "heuristic success"}, nil +} + +type LLMTaskSuccessJudge struct { + provider providers.LLMProvider + model string + fallback SuccessJudge +} + +type llmTaskSuccessResponse struct { + Success bool `json:"success"` + Reason string `json:"reason"` +} + +func NewLLMTaskSuccessJudge(provider providers.LLMProvider, model string, fallback SuccessJudge) *LLMTaskSuccessJudge { + if fallback == nil { + fallback = &HeuristicSuccessJudge{} + } + return &LLMTaskSuccessJudge{ + provider: provider, + model: strings.TrimSpace(model), + fallback: fallback, + } +} + +func (j *LLMTaskSuccessJudge) JudgeTaskRecord( + ctx context.Context, + record LearningRecord, +) (TaskSuccessDecision, error) { + if j == nil || j.provider == nil { + return j.fallbackDecision(ctx, record) + } + + model := strings.TrimSpace(j.model) + if model == "" { + model = strings.TrimSpace(j.provider.GetDefaultModel()) + } + if model == "" { + return j.fallbackDecision(ctx, record) + } + + resp, err := j.provider.Chat(ctx, []providers.Message{ + { + Role: "system", + Content: "Return exactly one JSON object with fields success:boolean and reason:string. No markdown fences.", + }, + { + Role: "user", + Content: buildTaskSuccessJudgePrompt(record), + }, + }, nil, model, map[string]any{"temperature": 0}) + if err != nil || resp == nil { + return j.fallbackDecision(ctx, record) + } + + content := strings.TrimSpace(resp.Content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(content, "```") + content = strings.TrimSpace(content) + if content == "" { + return j.fallbackDecision(ctx, record) + } + + var payload llmTaskSuccessResponse + if err := json.Unmarshal([]byte(content), &payload); err != nil { + return j.fallbackDecision(ctx, record) + } + return TaskSuccessDecision{ + Success: payload.Success, + Reason: strings.TrimSpace(payload.Reason), + }, nil +} + +func (j *LLMTaskSuccessJudge) fallbackDecision( + ctx context.Context, + record LearningRecord, +) (TaskSuccessDecision, error) { + if j == nil || j.fallback == nil { + return TaskSuccessDecision{Success: false, Reason: "no success judge available"}, nil + } + return j.fallback.JudgeTaskRecord(ctx, record) +} + +func buildTaskSuccessJudgePrompt(record LearningRecord) string { + lines := []string{ + "Decide whether this agent task truly achieved the user's goal.", + "Reject tasks that are only partial reasoning, only describe future steps, or obviously did not complete the requested outcome.", + "", + "User goal: " + fallbackString(record.UserGoal, "none"), + "Summary: " + fallbackString(record.Summary, "none"), + "Final output: " + fallbackString(record.FinalOutput, "none"), + "Used skills: " + joinOrFallback(record.UsedSkillNames, "none"), + "Tool kinds: " + joinOrFallback(record.ToolKinds, "none"), + "Tool executions:", + } + if len(record.ToolExecutions) == 0 { + lines = append(lines, "- none") + } else { + for _, exec := range record.ToolExecutions { + status := "failed" + if exec.Success { + status = "succeeded" + } + line := "- " + exec.Name + ": " + status + if errSummary := strings.TrimSpace(exec.ErrorSummary); errSummary != "" { + line += " (" + errSummary + ")" + } + if len(exec.SkillNames) > 0 { + line += " [skills: " + strings.Join(exec.SkillNames, ", ") + "]" + } + lines = append(lines, line) + } + } + return strings.Join(lines, "\n") +} diff --git a/pkg/evolution/types.go b/pkg/evolution/types.go index 5e0b936fe..3d5976155 100644 --- a/pkg/evolution/types.go +++ b/pkg/evolution/types.go @@ -62,30 +62,44 @@ type SkillContextSnapshot struct { SkillNames []string `json:"skill_names,omitempty"` } +type ToolExecutionRecord struct { + Name string `json:"name"` + Success bool `json:"success"` + ErrorSummary string `json:"error_summary,omitempty"` + SkillNames []string `json:"skill_names,omitempty"` +} + type LearningRecord struct { - ID string `json:"id"` - Kind RecordKind `json:"kind"` - WorkspaceID string `json:"workspace_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` - SessionKey string `json:"session_key,omitempty"` - TaskHash string `json:"task_hash,omitempty"` - Summary string `json:"summary"` - Source map[string]any `json:"source,omitempty"` - Status RecordStatus `json:"status"` - Success *bool `json:"success,omitempty"` - ToolKinds []string `json:"tool_kinds,omitempty"` - ActiveSkillNames []string `json:"active_skill_names,omitempty"` - AttemptTrail *AttemptTrail `json:"attempt_trail,omitempty"` - Signals []string `json:"signals,omitempty"` - SourceRecordIDs []string `json:"source_record_ids,omitempty"` - EventCount int `json:"event_count,omitempty"` - SuccessRate float64 `json:"success_rate,omitempty"` - MaturityScore float64 `json:"maturity_score,omitempty"` - WinningPath []string `json:"winning_path,omitempty"` - LateAddedSkills []string `json:"late_added_skills,omitempty"` - FinalSnapshotTrigger string `json:"final_snapshot_trigger,omitempty"` - MatchedSkillNames []string `json:"matched_skill_names,omitempty"` + ID string `json:"id"` + Kind RecordKind `json:"kind"` + WorkspaceID string `json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SessionKey string `json:"session_key,omitempty"` + TaskHash string `json:"task_hash,omitempty"` + Summary string `json:"summary"` + UserGoal string `json:"user_goal,omitempty"` + FinalOutput string `json:"final_output,omitempty"` + Source map[string]any `json:"source,omitempty"` + Status RecordStatus `json:"status"` + Success *bool `json:"success,omitempty"` + ToolKinds []string `json:"tool_kinds,omitempty"` + ToolExecutions []ToolExecutionRecord `json:"tool_executions,omitempty"` + InitialSkillNames []string `json:"initial_skill_names,omitempty"` + AddedSkillNames []string `json:"added_skill_names,omitempty"` + UsedSkillNames []string `json:"used_skill_names,omitempty"` + AllLoadedSkillNames []string `json:"all_loaded_skill_names,omitempty"` + ActiveSkillNames []string `json:"active_skill_names,omitempty"` + AttemptTrail *AttemptTrail `json:"attempt_trail,omitempty"` + Signals []string `json:"signals,omitempty"` + SourceRecordIDs []string `json:"source_record_ids,omitempty"` + EventCount int `json:"event_count,omitempty"` + SuccessRate float64 `json:"success_rate,omitempty"` + MaturityScore float64 `json:"maturity_score,omitempty"` + WinningPath []string `json:"winning_path,omitempty"` + LateAddedSkills []string `json:"late_added_skills,omitempty"` + FinalSnapshotTrigger string `json:"final_snapshot_trigger,omitempty"` + MatchedSkillNames []string `json:"matched_skill_names,omitempty"` } type SkillDraft struct {