From f58459e762cadd6d14586815a28b267c4104be56 Mon Sep 17 00:00:00 2001 From: lxowalle Date: Tue, 28 Apr 2026 14:34:30 +0800 Subject: [PATCH] feat: add modular agent self-evolution foundation --- cmd/picoclaw/internal/agent/helpers.go | 1 + 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 + ...on-implementation-status-and-pr-summary.md | 409 ++++++++ pkg/agent/agent.go | 31 + pkg/agent/agent_init.go | 14 + pkg/agent/context.go | 34 +- pkg/agent/events.go | 25 +- pkg/agent/evolution_bridge.go | 115 +++ pkg/agent/evolution_bridge_test.go | 802 +++++++++++++++ pkg/agent/pipeline_execute.go | 2 + pkg/agent/pipeline_llm.go | 7 +- pkg/agent/pipeline_setup.go | 10 +- pkg/agent/turn_coord.go | 24 +- pkg/agent/turn_coord_test.go | 26 + pkg/agent/turn_state.go | 153 ++- pkg/config/config.go | 27 + pkg/config/config_test.go | 90 ++ pkg/config/defaults.go | 8 + pkg/evolution/apply.go | 178 ++++ pkg/evolution/apply_test.go | 275 +++++ pkg/evolution/case_writer.go | 21 + pkg/evolution/case_writer_test.go | 77 ++ pkg/evolution/cold_path_runner.go | 121 +++ pkg/evolution/cold_path_runner_test.go | 142 +++ pkg/evolution/draft_review.go | 38 + pkg/evolution/draft_review_test.go | 67 ++ pkg/evolution/drafts.go | 249 +++++ pkg/evolution/drafts_test.go | 110 ++ pkg/evolution/generator_factory.go | 11 + pkg/evolution/lifecycle.go | 71 ++ pkg/evolution/lifecycle_actions_test.go | 72 ++ pkg/evolution/lifecycle_test.go | 170 +++ pkg/evolution/llm_draft_generator.go | 174 ++++ pkg/evolution/llm_draft_generator_test.go | 203 ++++ pkg/evolution/organizer.go | 367 +++++++ pkg/evolution/organizer_test.go | 245 +++++ pkg/evolution/paths.go | 31 + pkg/evolution/paths_test.go | 74 ++ pkg/evolution/preview.go | 142 +++ pkg/evolution/preview_test.go | 111 ++ pkg/evolution/profile_sync.go | 73 ++ pkg/evolution/record_kinds.go | 9 + pkg/evolution/runtime.go | 615 +++++++++++ pkg/evolution/runtime_apply_test.go | 372 +++++++ pkg/evolution/runtime_cold_path_test.go | 603 +++++++++++ pkg/evolution/runtime_test.go | 450 ++++++++ pkg/evolution/skills_recall.go | 179 ++++ pkg/evolution/skills_recall_test.go | 49 + pkg/evolution/store.go | 384 +++++++ pkg/evolution/store_test.go | 178 ++++ pkg/evolution/types.go | 136 +++ pkg/gateway/gateway.go | 6 +- pkg/providers/fixed_model_provider.go | 70 ++ pkg/skills/loader.go | 11 +- pkg/skills/validation.go | 29 + 67 files changed, 9652 insertions(+), 47 deletions(-) create mode 100644 cmd/picoclaw/internal/evolution/apply.go create mode 100644 cmd/picoclaw/internal/evolution/command.go create mode 100644 cmd/picoclaw/internal/evolution/command_test.go create mode 100644 cmd/picoclaw/internal/evolution/draft_helpers.go create mode 100644 cmd/picoclaw/internal/evolution/drafts.go create mode 100644 cmd/picoclaw/internal/evolution/helpers.go create mode 100644 cmd/picoclaw/internal/evolution/prune.go create mode 100644 cmd/picoclaw/internal/evolution/review.go create mode 100644 cmd/picoclaw/internal/evolution/rollback.go create mode 100644 cmd/picoclaw/internal/evolution/run_once.go create mode 100644 cmd/picoclaw/internal/evolution/status.go create mode 100644 cmd/picoclaw/internal/evolution/workspace_scope.go create mode 100644 docs/design/2026-04-28-agent-self-evolution-implementation-status-and-pr-summary.md create mode 100644 pkg/agent/evolution_bridge.go create mode 100644 pkg/agent/evolution_bridge_test.go create mode 100644 pkg/evolution/apply.go create mode 100644 pkg/evolution/apply_test.go create mode 100644 pkg/evolution/case_writer.go create mode 100644 pkg/evolution/case_writer_test.go create mode 100644 pkg/evolution/cold_path_runner.go create mode 100644 pkg/evolution/cold_path_runner_test.go create mode 100644 pkg/evolution/draft_review.go create mode 100644 pkg/evolution/draft_review_test.go create mode 100644 pkg/evolution/drafts.go create mode 100644 pkg/evolution/drafts_test.go create mode 100644 pkg/evolution/generator_factory.go create mode 100644 pkg/evolution/lifecycle.go create mode 100644 pkg/evolution/lifecycle_actions_test.go create mode 100644 pkg/evolution/lifecycle_test.go create mode 100644 pkg/evolution/llm_draft_generator.go create mode 100644 pkg/evolution/llm_draft_generator_test.go create mode 100644 pkg/evolution/organizer.go create mode 100644 pkg/evolution/organizer_test.go create mode 100644 pkg/evolution/paths.go create mode 100644 pkg/evolution/paths_test.go create mode 100644 pkg/evolution/preview.go create mode 100644 pkg/evolution/preview_test.go create mode 100644 pkg/evolution/profile_sync.go create mode 100644 pkg/evolution/record_kinds.go create mode 100644 pkg/evolution/runtime.go create mode 100644 pkg/evolution/runtime_apply_test.go create mode 100644 pkg/evolution/runtime_cold_path_test.go create mode 100644 pkg/evolution/runtime_test.go create mode 100644 pkg/evolution/skills_recall.go create mode 100644 pkg/evolution/skills_recall_test.go create mode 100644 pkg/evolution/store.go create mode 100644 pkg/evolution/store_test.go create mode 100644 pkg/evolution/types.go create mode 100644 pkg/providers/fixed_model_provider.go create mode 100644 pkg/skills/validation.go diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 23227d56a..59080b3a3 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -43,6 +43,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error { if err != nil { return fmt.Errorf("error creating provider: %w", err) } + provider = providers.WithDefaultModel(provider, modelID) // Use the resolved model ID from provider creation if modelID != "" { diff --git a/cmd/picoclaw/internal/evolution/apply.go b/cmd/picoclaw/internal/evolution/apply.go new file mode 100644 index 000000000..31bfa8db0 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/apply.go @@ -0,0 +1,78 @@ +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 new file mode 100644 index 000000000..10f0398a8 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/command.go @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..6a44d1e40 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/command_test.go @@ -0,0 +1,969 @@ +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 new file mode 100644 index 000000000..ea0dc7e48 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/draft_helpers.go @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..4f4297da4 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/drafts.go @@ -0,0 +1,58 @@ +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 new file mode 100644 index 000000000..27d4933a6 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/helpers.go @@ -0,0 +1,18 @@ +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 new file mode 100644 index 000000000..7616859a1 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/prune.go @@ -0,0 +1,54 @@ +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 new file mode 100644 index 000000000..5fb4c654f --- /dev/null +++ b/cmd/picoclaw/internal/evolution/review.go @@ -0,0 +1,127 @@ +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 new file mode 100644 index 000000000..7ff18f60c --- /dev/null +++ b/cmd/picoclaw/internal/evolution/rollback.go @@ -0,0 +1,160 @@ +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 new file mode 100644 index 000000000..5826cc375 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/run_once.go @@ -0,0 +1,55 @@ +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 new file mode 100644 index 000000000..1dc46eb8d --- /dev/null +++ b/cmd/picoclaw/internal/evolution/status.go @@ -0,0 +1,173 @@ +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 new file mode 100644 index 000000000..83647e221 --- /dev/null +++ b/cmd/picoclaw/internal/evolution/workspace_scope.go @@ -0,0 +1,37 @@ +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 0867203a6..240da657d 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -18,6 +18,7 @@ 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" @@ -89,6 +90,7 @@ picoclaw --no-color status`, cron.NewCronCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), + evolutioncmd.NewEvolutionCommand(), model.NewModelCommand(), updater.NewUpdateCommand("picoclaw"), version.NewVersionCommand(), diff --git a/docs/design/2026-04-28-agent-self-evolution-implementation-status-and-pr-summary.md b/docs/design/2026-04-28-agent-self-evolution-implementation-status-and-pr-summary.md new file mode 100644 index 000000000..8830028cd --- /dev/null +++ b/docs/design/2026-04-28-agent-self-evolution-implementation-status-and-pr-summary.md @@ -0,0 +1,409 @@ +# Agent Self-Evolution Implementation Status and PR Summary + +## 1. Goal + +This implementation connects the self-evolving skill design into PicoClaw and focuses on a minimal, safe, and modular loop: + +1. the agent records learnable signals after a task finishes +2. the system organizes those signals in the background or on demand +3. the system generates candidate skill drafts +4. a human reviews and decides whether to apply them +5. applied skills keep version history, support rollback, and can cool down over time + +The goal of this batch is to complete the data flow, lifecycle, and operational entry points, not to jump straight to a fully automatic self-updating system. + +## 2. Scope Implemented in This Batch + +The current code covers the main objects and execution paths defined by the design: + +1. `Learning Record` +2. `Skill Draft` +3. `Skill Profile` +4. hot-path `task` record writing +5. cold-path `pattern` aggregation +6. draft generation +7. draft review and quarantine +8. skill apply, backup, and rollback +9. learned skill lifecycle maintenance +10. CLI operations + +Against the design document's implementation scope, this batch completes the core self-evolution loop. Remaining items are mostly automation or UX improvements. + +## 3. How the System Connects to PicoClaw + +### 3.1 Integration Points + +The feature is integrated as a separate module with three layers: + +1. an event bridge in `pkg/agent` +2. the self-evolution core in `pkg/evolution` +3. CLI management commands in `cmd/picoclaw/internal/evolution` + +This keeps the integration modular: + +1. runtime switches are centralized in `config.EvolutionConfig` +2. evolution state is stored separately and does not directly pollute the regular skill layout +3. drafts, rollback, and lifecycle actions can be managed independently through the CLI +4. the whole feature can be disabled with `enabled: false` + +### 3.2 Runtime Flow + +The current runtime flow is: + +1. the agent finishes a task turn +2. `pkg/agent/evolution_bridge.go` listens for the `TurnEnd` event +3. the hot path writes a `Learning Record(kind=task)` +4. if `auto_run_cold_path` is enabled, it schedules one cold-path run +5. the cold path aggregates multiple `task` records into a `pattern` +6. the draft generator creates a `Skill Draft` +7. draft review performs structural validation and sensitive-content scanning +8. valid drafts move to `candidate`, invalid drafts move to `quarantined` +9. a human uses CLI `review` and `apply` to decide whether to publish the skill change +10. after apply, the system updates the `Skill Profile`, keeps backups, and supports later `rollback` or `prune` + +### 3.3 Disable Path + +The feature now uses a single `enabled` switch. There is no separate `off` mode. + +When `evolution.enabled = false`: + +1. the agent does not write evolution records +2. the cold path does not run +3. normal skill loading and normal task execution remain unaffected + +## 4. Core Capabilities Already Completed + +### 4.1 Hot-Path Recording + +Completed: + +1. a `Learning Record(kind=task)` is written after each task turn +2. it records workspace, turn, session, tool kinds, active skills, attempted skills, and related context +3. it records skill attempt trajectory signals: + `AttemptedSkills` + `FinalSuccessfulPath` + `SkillContextSnapshots` +4. it can capture the pattern where many skills were tried and the final successful path came from a later-added skill +5. the hot path only writes records and does not modify formal skills + +This gives the system the basic ability to remember which skill path actually solved the task. + +### 4.2 Cold-Path Organization + +Completed: + +1. multiple `task` records can be aggregated into `pattern` records +2. the system uses a minimum sample count and success-rate threshold before learning +3. it prefers the final successful path when extracting a stable winning path +4. it captures late-added skill hints and the final snapshot trigger + +The cold path is responsible for turning task-level observations into reusable patterns without slowing down the user's live interaction. + +### 4.3 Draft Generation + +Completed: + +1. LLM-based draft generation +2. provider-backed generation is used first when a provider is available +3. local fallback generation is used automatically when the provider is unavailable, errors, or returns invalid content +4. support for `create`, `append`, `replace`, and `merge` +5. fallback generation now prefers `append` when extending an existing skill + +`merge` is used to merge newly learned stable knowledge into an existing skill instead of replacing it too aggressively. + +### 4.4 Draft Review and Quarantine + +Completed: + +1. draft states: + `candidate` + `quarantined` + `accepted` +2. structurally invalid drafts are quarantined +3. secret-like or sensitive content is quarantined +4. invalid skill names are quarantined +5. quarantined drafts never go directly into formal skills + +This keeps generation separate from publication and prevents direct promotion of bad drafts. + +### 4.5 Skill Apply and Rollback + +Completed: + +1. backups are saved before formal apply +2. failed applies can roll back automatically +3. `Skill Profile.version_history` records version changes +4. `rollback ` restores the latest backup +5. if apply fails or profile persistence fails, the skill file is restored and audit information is recorded + +Current rollback is structure-level and file-level rollback, with the primary goal of preventing broken skill files from being left behind. + +### 4.6 Lifecycle Maintenance + +Completed: + +1. lifecycle states: + `active` + `cold` + `archived` + `deleted` +2. usage count, last-used time, and retention score tracking +3. `prune` recomputes lifecycle states +4. recently reused cold skills can become active again +5. profiles and statistics remain workspace-scoped even when using a shared `state_dir` + +### 4.7 Human Review CLI + +Completed CLI commands: + +1. `picoclaw evolution drafts` +2. `picoclaw evolution review ` +3. `picoclaw evolution apply ` +4. `picoclaw evolution rollback ` +5. `picoclaw evolution status` +6. `picoclaw evolution run-once` +7. `picoclaw evolution prune` + +`review` now shows: + +1. draft metadata +2. target skill profile summary +3. recent version history +4. impact preview +5. current body +6. rendered body +7. `diff_preview` + +`diff_preview` uses a unified diff style so human review is easier. + +## 5. Current Configuration + +The feature is configured through `EvolutionConfig` in `pkg/config/config.go`: + +```json +{ + "evolution": { + "enabled": false, + "mode": "observe", + "state_dir": "", + "min_case_count": 3, + "min_success_rate": 0.7, + "auto_run_cold_path": false, + "auto_apply": false + } +} +``` + +Field meanings: + +1. `enabled` + The master switch. When false, the whole self-evolution system is off. +2. `mode` + Currently supports `observe`, `review`, and `apply`. + `observe` focuses on recording and candidate generation. + `review` keeps the flow in the human review path. + `apply` allows the cold path to enter apply logic, while actual automatic apply is still controlled by `auto_apply`. +3. `state_dir` + Stores self-evolution state separately from the skill working tree. +4. `min_case_count` + Minimum task sample count before a reusable pattern is considered mature enough. +5. `min_success_rate` + Minimum success rate required before a pattern is considered stable enough to learn from. +6. `auto_run_cold_path` + Whether to trigger cold-path processing automatically after each task turn. +7. `auto_apply` + Whether qualified drafts should be written into formal skills automatically when apply mode is active. + +## 6. How “Human Confirmation Before Apply” Works Today + +There is currently no interactive approval popup inside the agent runtime. + +Human confirmation is implemented through the CLI flow: + +1. the cold path first generates a `candidate` draft +2. a human runs `picoclaw evolution review ` to inspect it +3. a human runs `picoclaw evolution apply ` to publish it + +In other words, manual confirmation exists today as an explicit CLI review flow, not as a live turn-time prompt. + +## 7. What Is Done vs. What Is Still Deferred + +### 7.1 Design Goals Already Covered + +The current implementation already covers: + +1. the full `Learning Record -> Skill Draft -> Skill Profile -> formal Skill` chain +2. hot path writes only `task` +3. cold path aggregates `pattern` +4. cold-path LLM draft generation +5. shortcut and winning-path learning +6. structural validation +7. sensitive-content scanning +8. backup +9. rollback +10. candidate and quarantined states +11. lifecycle state maintenance + +### 7.2 Items Not Included in This PR + +The following are still future improvements and are not blockers for this PR: + +1. cross-workspace self-evolution +2. fully automatic merge into formal skills without human review +3. behavior-level rollback +4. full UI +5. hot-path LLM rerank + +Clarifications: + +1. “fully automatic merge” + means the system would directly merge a qualified draft into the formal skill without a human running `review` and `apply`. + The current implementation still prioritizes human review. +2. “behavior-level rollback” + means rollback would be triggered by later evidence of degraded real task behavior, reduced hit rate, or regression, not only by structural apply failures. + The current implementation mainly handles structure-level rollback and apply-failure rollback. +3. “hot-path LLM rerank” + means using an LLM before execution to rerank recalled skills and choose the most likely successful one first. + This is intentionally not part of the current hot path, so it does not add that extra inference cost yet. + +## 8. Best PR Boundary for the Current State + +This PR is best framed as the first complete batch of: + +1. self-evolution infrastructure +2. hot-path task learning loop +3. cold-path draft generation +4. lifecycle and operations CLI + +That boundary works well because: + +1. the feature is runnable, testable, and rollback-safe +2. the master switch is clear and easy to disable +3. evolution state and formal skill ownership stay clearly separated +4. the human review path is already complete +5. future automation improvements can land in later PRs independently + +## 9. Verification + +The following test commands passed: + +```bash +/usr/local/go/bin/go test ./cmd/picoclaw/internal/evolution -count=1 +/usr/local/go/bin/go test ./pkg/evolution -count=1 +/usr/local/go/bin/go test ./pkg/agent -count=1 +``` + +This batch also includes test coverage for: + +1. hot-path record writing +2. attempted-skill and final-success-path extraction +3. cold-path aggregation and draft generation +4. LLM generator vs. fallback generator selection +5. draft quarantine +6. apply and rollback +7. lifecycle and prune +8. workspace isolation +9. CLI output and audit details + +## 10. Main Change Areas + +### 10.1 Core Implementation + +1. `pkg/evolution/` + Runtime, storage, aggregation, drafts, review, rollback, lifecycle, and preview logic. + +### 10.2 Agent Integration + +1. `pkg/agent/evolution_bridge.go` + Connects agent turn-end events to the self-evolution runtime. +2. `pkg/agent/events.go` +3. `pkg/agent/turn_state.go` +4. `pkg/agent/turn_coord.go` + +### 10.3 CLI + +1. `cmd/picoclaw/internal/evolution/` + Operational commands for self-evolution. + +### 10.4 Config and Supporting Integration + +1. `pkg/config/config.go` +2. `pkg/config/defaults.go` +3. `pkg/config/config_test.go` +4. `cmd/picoclaw/main.go` +5. `pkg/skills/loader.go` +6. `pkg/gateway/gateway.go` + +## 11. Suggested PR Title + +Recommended title: + +`feat: add modular agent self-evolution foundation, drafts, lifecycle, and CLI` + +Alternative if you want a shorter “first loop” framing: + +`feat: add first self-evolution loop for learned skills` + +## 12. Suggested PR Description + +The following text can be used directly as the PR body: + +~~~md +## Summary + +This PR adds the first modular self-evolution loop for PicoClaw skills. + +It introduces: + +1. hot-path task learning records +2. cold-path pattern aggregation +3. LLM-backed and fallback draft generation +4. draft review states (`candidate`, `quarantined`, `accepted`) +5. skill apply / backup / rollback +6. skill lifecycle maintenance (`active`, `cold`, `archived`, `deleted`) +7. workspace-scoped evolution state isolation +8. CLI commands for review and operations + +## What is included + +1. `Learning Record`, `Skill Draft`, and `Skill Profile` +2. `task -> pattern -> draft -> skill` main pipeline +3. learning from attempted skills and final successful paths +4. diff-based review output for human inspection +5. modular config gate via `evolution.enabled` +6. cold-path auto-run and optional auto-apply controls + +## CLI + +1. `picoclaw evolution drafts` +2. `picoclaw evolution review ` +3. `picoclaw evolution apply ` +4. `picoclaw evolution rollback ` +5. `picoclaw evolution status` +6. `picoclaw evolution run-once` +7. `picoclaw evolution prune` + +## Safety + +1. no skill file is modified on the hot path +2. drafts are validated before acceptance +3. suspicious or invalid drafts are quarantined +4. apply keeps backups and supports rollback +5. evolution can be fully disabled with `evolution.enabled = false` + +## Verification + +```bash +/usr/local/go/bin/go test ./cmd/picoclaw/internal/evolution -count=1 +/usr/local/go/bin/go test ./pkg/evolution -count=1 +/usr/local/go/bin/go test ./pkg/agent -count=1 +``` + +## Not included in this PR + +1. cross-workspace evolution +2. behavior-level rollback +3. fully automatic merge without human review +4. full UI +5. hot-path LLM rerank +~~~ diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 3e9bd845e..53454ee1d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -50,6 +50,7 @@ type AgentLoop struct { transcriber asr.Transcriber cmdRegistry *commands.Registry mcp mcpRuntime + evolution *evolutionBridge hookRuntime hookRuntime steering *steeringQueue pendingSkills sync.Map @@ -278,6 +279,14 @@ func (al *AgentLoop) Close() { }) } } + if al.evolution != nil { + if err := al.evolution.Close(); err != nil { + logger.ErrorCF("agent", "Failed to close evolution bridge", + map[string]any{ + "error": err.Error(), + }) + } + } al.GetRegistry().Close() if al.hooks != nil { @@ -362,14 +371,22 @@ func (al *AgentLoop) ReloadProviderAndConfig( // Ensure shared tools are re-registered on the new registry registerSharedTools(al, cfg, al.bus, registry, provider) + newEvolution, evolutionErr := newEvolutionBridge(registry, cfg, provider) + if evolutionErr != nil { + logger.WarnCF("agent", "Failed to reinitialize evolution bridge during reload", + map[string]any{"error": evolutionErr.Error()}) + } + // Atomically swap the config and registry under write lock // This ensures readers see a consistent pair al.mu.Lock() oldRegistry := al.registry + oldEvolution := al.evolution // Store new values al.cfg = cfg al.registry = registry + al.evolution = newEvolution // Also update fallback chain with new config; rebuild rate limiter registry. newRL := providers.NewRateLimiterRegistry() @@ -386,6 +403,14 @@ func (al *AgentLoop) ReloadProviderAndConfig( oldMCPManager := al.mcp.reset() al.hookRuntime.reset(al) configureHookManagerFromConfig(al.hooks, cfg) + if newEvolution != nil { + if err := al.MountHook(NamedHook(evolutionObserverHookName, newEvolution)); err != nil { + logger.WarnCF("agent", "Failed to remount evolution observer during reload", + map[string]any{"error": err.Error()}) + } + } else { + al.UnmountHook(evolutionObserverHookName) + } if err := al.ensureHooksInitialized(ctx); err != nil { logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload", map[string]any{"error": err.Error()}) @@ -396,6 +421,12 @@ func (al *AgentLoop) ReloadProviderAndConfig( map[string]any{"error": err.Error()}) } } + if oldEvolution != nil { + if err := oldEvolution.Close(); err != nil { + logger.WarnCF("agent", "Failed to close previous evolution bridge during reload", + map[string]any{"error": err.Error()}) + } + } if err := al.ensureMCPInitialized(ctx); err != nil { logger.WarnCF("agent", "MCP failed to reinitialize after reload", map[string]any{"error": err.Error()}) diff --git a/pkg/agent/agent_init.go b/pkg/agent/agent_init.go index 611d634e8..1ab0e6e2b 100644 --- a/pkg/agent/agent_init.go +++ b/pkg/agent/agent_init.go @@ -48,6 +48,12 @@ func NewAgentLoop( } eventBus := NewEventBus() + bridge, err := newEvolutionBridge(registry, cfg, provider) + if err != nil { + logger.WarnCF("agent", "Failed to initialize evolution bridge", map[string]any{ + "error": err.Error(), + }) + } // Determine worker pool size from config (default: 1 = sequential) workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns @@ -63,6 +69,7 @@ func NewAgentLoop( eventBus: eventBus, fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + evolution: bridge, steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), workerSem: make(chan struct{}, workerPoolSize), } @@ -70,6 +77,13 @@ func NewAgentLoop( al.hooks = NewHookManager(eventBus) configureHookManagerFromConfig(al.hooks, cfg) al.contextManager = al.resolveContextManager() + if bridge != nil { + if err := al.MountHook(NamedHook(evolutionObserverHookName, bridge)); err != nil { + logger.WarnCF("agent", "Failed to mount evolution observer", map[string]any{ + "error": err.Error(), + }) + } + } // Register shared tools to all agents (now that al is created) registerSharedTools(al, cfg, msgBus, registry, provider) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 1e5a75d92..982ff6827 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -837,10 +837,28 @@ func (cb *ContextBuilder) AddAssistantMessage( } func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string { - if cb.skillsLoader == nil || len(skillNames) == 0 { + ordered := cb.ResolveActiveSkillsForContext(skillNames) + if len(ordered) == 0 { return "" } + content := cb.skillsLoader.LoadSkillsForContext(ordered) + if strings.TrimSpace(content) == "" { + return "" + } + + return fmt.Sprintf(`# Active Skills + +The following skills are active for this request. Follow them when relevant. + +%s`, content) +} + +func (cb *ContextBuilder) ResolveActiveSkillsForContext(skillNames []string) []string { + if cb.skillsLoader == nil || len(skillNames) == 0 { + return nil + } + var ordered []string seen := make(map[string]struct{}, len(skillNames)) for _, name := range skillNames { @@ -855,19 +873,9 @@ func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string { ordered = append(ordered, canonical) } if len(ordered) == 0 { - return "" + return nil } - - content := cb.skillsLoader.LoadSkillsForContext(ordered) - if strings.TrimSpace(content) == "" { - return "" - } - - return fmt.Sprintf(`# Active Skills - -The following skills are active for this request. Follow them when relevant. - -%s`, content) + return ordered } func (cb *ContextBuilder) ListSkillNames() []string { diff --git a/pkg/agent/events.go b/pkg/agent/events.go index f68d3eab5..670765625 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -120,12 +120,29 @@ type TurnStartPayload struct { MediaCount int } +const ( + skillContextTriggerInitialBuild = "initial_build" + skillContextTriggerContextRetryRebuild = "context_retry_rebuild" +) + +type SkillContextSnapshot struct { + Sequence int `json:"sequence"` + Trigger string `json:"trigger"` + SkillNames []string `json:"skill_names,omitempty"` +} + // TurnEndPayload describes the completion of a turn. type TurnEndPayload struct { - Status TurnEndStatus - Iterations int - Duration time.Duration - FinalContentLen int + Status TurnEndStatus + Workspace string + Iterations int + Duration time.Duration + FinalContentLen int + ActiveSkills []string + AttemptedSkills []string + FinalSuccessfulPath []string + SkillContextSnapshots []SkillContextSnapshot + ToolKinds []string } // LLMRequestPayload describes an outbound LLM request. diff --git a/pkg/agent/evolution_bridge.go b/pkg/agent/evolution_bridge.go new file mode 100644 index 000000000..1f4951b21 --- /dev/null +++ b/pkg/agent/evolution_bridge.go @@ -0,0 +1,115 @@ +package agent + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/evolution" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const evolutionObserverHookName = "evolution-observer" + +type evolutionBridge struct { + cfg config.EvolutionConfig + registry *AgentRegistry + runtime *evolution.Runtime + coldPathRunner *evolution.ColdPathRunner +} + +func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider providers.LLMProvider) (*evolutionBridge, error) { + if cfg == nil { + return nil, nil + } + + modelID := "" + if provider != nil { + modelID = provider.GetDefaultModel() + } + runtime, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: cfg.Evolution, + GeneratorFactory: func(workspace string) evolution.DraftGenerator { + return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID) + }, + ApplierFactory: func(workspace string) *evolution.Applier { + return evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil) + }, + }) + if err != nil { + return nil, err + } + + bridge := &evolutionBridge{ + cfg: cfg.Evolution, + registry: registry, + runtime: runtime, + } + if cfg.Evolution.AutoRunColdPath { + bridge.coldPathRunner = evolution.NewColdPathRunnerWithErrorHandler(runtime, func(err error) { + logger.WarnCF("agent", "Cold path run failed", map[string]any{ + "error": err.Error(), + }) + }) + } + + return bridge, nil +} + +func (b *evolutionBridge) Close() error { + if b == nil || b.coldPathRunner == nil { + return nil + } + return b.coldPathRunner.Close() +} + +func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error { + if b == nil || !b.cfg.Enabled || b.runtime == nil { + return nil + } + + switch evt.Kind { + case EventKindTurnEnd: + payload, ok := evt.Payload.(TurnEndPayload) + 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) + } + return nil + } + + return nil +} + +func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.SkillContextSnapshot { + if len(input) == 0 { + return nil + } + + out := make([]evolution.SkillContextSnapshot, 0, len(input)) + for _, snapshot := range input { + out = append(out, evolution.SkillContextSnapshot{ + Sequence: snapshot.Sequence, + Trigger: snapshot.Trigger, + SkillNames: append([]string(nil), snapshot.SkillNames...), + }) + } + return out +} diff --git a/pkg/agent/evolution_bridge_test.go b/pkg/agent/evolution_bridge_test.go new file mode 100644 index 000000000..6bca35afd --- /dev/null +++ b/pkg/agent/evolution_bridge_test.go @@ -0,0 +1,802 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/evolution" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestEvolutionBridge_DisabledWritesNothing(t *testing.T) { + tmpDir := t.TempDir() + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: false, + Mode: "observe", + }, &simpleMockProvider{response: "ok"}) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-disabled", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + + assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json")) +} + +func TestEvolutionBridge_ObserveWritesCaseRecord(t *testing.T) { + tmpDir := t.TempDir() + provider := &toolCallRespProvider{ + toolName: "echo_text", + toolArgs: map[string]any{"text": "bridge"}, + response: "done", + } + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, provider) + defer al.Close() + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + defaultAgent.SkillsFilter = []string{"observe-skill"} + al.RegisterTool(&echoTextTool{}) + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "done" { + t.Fatalf("response = %q, want %q", resp, "done") + } + + record := waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + + if got := record["kind"]; got != string(evolution.RecordKindCase) { + t.Fatalf("kind = %v, want %q", got, evolution.RecordKindCase) + } + if got := record["workspace_id"]; got != tmpDir { + t.Fatalf("workspace_id = %v, want %q", got, tmpDir) + } + if got := record["status"]; got != "new" { + t.Fatalf("status = %v, want %q", got, "new") + } + + toolKinds, ok := record["tool_kinds"].([]any) + if !ok { + t.Fatalf("tool_kinds missing or wrong type: %#v", record["tool_kinds"]) + } + if len(toolKinds) != 1 || toolKinds[0] != "echo_text" { + t.Fatalf("tool_kinds = %#v, want [echo_text]", toolKinds) + } + + activeSkillsRaw, exists := record["active_skill_names"] + if !exists { + t.Fatal("active_skill_names field missing") + } + activeSkills, ok := activeSkillsRaw.([]any) + if !ok { + t.Fatalf("active_skill_names wrong type: %#v", activeSkillsRaw) + } + if len(activeSkills) != 1 || activeSkills[0] != "observe-skill" { + t.Fatalf("active_skill_names = %#v, want [observe-skill]", activeSkills) + } +} + +func TestEvolutionBridge_ObserveTurnEndPayloadIncludesResolvedAttemptTrail(t *testing.T) { + tmpDir := t.TempDir() + skillDir := filepath.Join(tmpDir, "skills", "observe-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: observe-skill\ndescription: observe test skill\n---\n# Observe Skill\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, &simpleMockProvider{response: "ok"}) + defer al.Close() + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + defaultAgent.SkillsFilter = []string{"missing-skill", "observe-skill", "observe-skill"} + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-attempt-trail", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + + turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool { + return evt.Kind == EventKindTurnEnd + }) + turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload) + if !ok { + t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload) + } + if got := turnEndPayload.AttemptedSkills; len(got) != 1 || got[0] != "observe-skill" { + t.Fatalf("AttemptedSkills = %v, want [observe-skill]", got) + } + if got := turnEndPayload.FinalSuccessfulPath; len(got) != 1 || got[0] != "observe-skill" { + t.Fatalf("FinalSuccessfulPath = %v, want [observe-skill]", got) + } + if got := turnEndPayload.SkillContextSnapshots; len(got) != 1 || got[0].Trigger != skillContextTriggerInitialBuild { + t.Fatalf("SkillContextSnapshots = %+v, want single initial_build snapshot", got) + } +} + +func TestEvolutionBridge_ObserveTurnEndUsesLatestSkillSnapshotAfterRetry(t *testing.T) { + tmpDir := t.TempDir() + baseSkillDir := filepath.Join(tmpDir, "skills", "base-skill") + if err := os.MkdirAll(baseSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(baseSkillDir): %v", err) + } + if err := os.WriteFile( + filepath.Join(baseSkillDir, "SKILL.md"), + []byte("---\nname: base-skill\ndescription: base test skill\n---\n# Base Skill\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(base-skill): %v", err) + } + + lateSkillPath := filepath.Join(tmpDir, "skills", "late-skill", "SKILL.md") + provider := &lateSkillOnRetryProvider{lateSkillPath: lateSkillPath} + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, provider) + defer al.Close() + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + defaultAgent.SkillsFilter = []string{"base-skill", "late-skill"} + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-retry-snapshot", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "Recovered after retry" { + t.Fatalf("response = %q, want %q", resp, "Recovered after retry") + } + + turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool { + return evt.Kind == EventKindTurnEnd + }) + turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload) + if !ok { + t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload) + } + if got := turnEndPayload.AttemptedSkills; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" { + t.Fatalf("AttemptedSkills = %v, want [base-skill late-skill]", got) + } + if got := turnEndPayload.FinalSuccessfulPath; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" { + t.Fatalf("FinalSuccessfulPath = %v, want [base-skill late-skill]", got) + } + if got := turnEndPayload.SkillContextSnapshots; len(got) != 2 { + t.Fatalf("len(SkillContextSnapshots) = %d, want 2", len(got)) + } + if turnEndPayload.SkillContextSnapshots[0].Trigger != skillContextTriggerInitialBuild { + t.Fatalf("SkillContextSnapshots[0].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[0].Trigger, skillContextTriggerInitialBuild) + } + if turnEndPayload.SkillContextSnapshots[1].Trigger != skillContextTriggerContextRetryRebuild { + t.Fatalf("SkillContextSnapshots[1].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[1].Trigger, skillContextTriggerContextRetryRebuild) + } + if got := turnEndPayload.SkillContextSnapshots[1].SkillNames; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" { + t.Fatalf("SkillContextSnapshots[1].SkillNames = %v, want [base-skill late-skill]", got) + } +} + +func TestEvolutionBridge_ObserveDoesNotCreateDraftFile(t *testing.T) { + tmpDir := t.TempDir() + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, &simpleMockProvider{response: "ok"}) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-no-draft", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json")) +} + +func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "review", + AutoRunColdPath: true, + }, &simpleMockProvider{response: "ok"}) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) +} + +func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "review", + AutoRunColdPath: true, + }, &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."}`, + }) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-llm", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp == "" { + t.Fatal("expected non-empty response") + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) + if drafts[0].HumanSummary != "Prefer native-name path first" { + t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first") + } +} + +func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + provider := &capturingEvolutionDraftProvider{ + defaultModel: "provider-explicit-model", + 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."}`, + } + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "review", + AutoRunColdPath: true, + }, provider) + defer al.Close() + + if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-model", "cli", "direct"); err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) + if provider.lastModel != "provider-explicit-model" { + t.Fatalf("lastModel = %q, want provider-explicit-model", provider.lastModel) + } +} + +func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "apply", + AutoRunColdPath: true, + AutoApply: false, + }, &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"}`, + }) + defer al.Close() + + if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-no-auto-apply", "cli", "direct"); err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) + if drafts[0].Status != evolution.DraftStatusCandidate { + t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate) + } + + assertNotExists(t, filepath.Join(tmpDir, "skills", "weather", "SKILL.md")) + assertProfileNotExists(t, tmpDir, "weather") +} + +func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + skillDir := filepath.Join(tmpDir, "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\n## Start Here\nUse city names.\n" + if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "apply", + AutoRunColdPath: true, + AutoApply: true, + }, &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."}`, + }) + defer al.Close() + + if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-merge", "cli", "direct"); err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1) + if drafts[0].Status != evolution.DraftStatusAccepted { + t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted) + } + + merged := waitForSkillBody(t, skillPath) + if !strings.Contains(merged, "Use city names.") { + t.Fatalf("merged skill lost original content:\n%s", merged) + } + if !strings.Contains(merged, "## Merged Knowledge") { + t.Fatalf("merged skill missing merged section:\n%s", merged) + } + if !strings.Contains(merged, "Prefer native-name query first.") { + t.Fatalf("merged skill missing learned knowledge:\n%s", merged) + } + + profile := waitForProfile(t, tmpDir, "weather") + if profile.Status != evolution.SkillStatusActive { + t.Fatalf("profile status = %q, want %q", profile.Status, evolution.SkillStatusActive) + } + if profile.CurrentVersion == "" { + t.Fatal("expected applied profile current version") + } +} + +func TestEvolutionBridge_AutoRunColdPathDisabledDoesNotCreateDraftFile(t *testing.T) { + tmpDir := t.TempDir() + seedReadyRule(t, tmpDir) + + al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{ + Enabled: true, + Mode: "review", + AutoRunColdPath: false, + }, &simpleMockProvider{response: "ok"}) + defer al.Close() + + resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-no-auto-cold-path", "cli", "direct") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + if resp != "ok" { + t.Fatalf("response = %q, want %q", resp, "ok") + } + + waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl")) + assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json")) +} + +func TestEvolutionBridge_TurnEndUsesPayloadWorkspace(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Evolution: config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, + } + + bridge, err := newEvolutionBridge(nil, cfg, nil) + if err != nil { + t.Fatalf("newEvolutionBridge: %v", err) + } + + err = bridge.OnEvent(context.Background(), Event{ + Kind: EventKindTurnEnd, + Meta: EventMeta{ + AgentID: "main", + TurnID: "turn-1", + SessionKey: "session-1", + }, + Payload: TurnEndPayload{ + Status: TurnEndStatusCompleted, + Workspace: workspace, + ActiveSkills: []string{"observe-skill"}, + ToolKinds: []string{"echo_text"}, + }, + }) + if err != nil { + t.Fatalf("OnEvent: %v", err) + } + + record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl")) + if got := record["workspace_id"]; got != workspace { + t.Fatalf("workspace_id = %v, want %q", got, workspace) + } +} + +func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Evolution: config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, + } + + bridge, err := newEvolutionBridge(nil, cfg, nil) + if err != nil { + t.Fatalf("newEvolutionBridge: %v", err) + } + + err = bridge.OnEvent(context.Background(), Event{ + Kind: EventKindTurnEnd, + Meta: EventMeta{ + AgentID: "main", + TurnID: "turn-1", + SessionKey: "session-1", + }, + Payload: TurnEndPayload{ + Status: TurnEndStatusCompleted, + Workspace: workspace, + ActiveSkills: []string{"weather"}, + AttemptedSkills: []string{"geocode", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + SkillContextSnapshots: []SkillContextSnapshot{ + {Sequence: 1, Trigger: skillContextTriggerInitialBuild, SkillNames: []string{"weather"}}, + {Sequence: 2, Trigger: skillContextTriggerContextRetryRebuild, SkillNames: []string{"geocode", "weather"}}, + }, + ToolKinds: []string{"echo_text"}, + }, + }) + if err != nil { + t.Fatalf("OnEvent: %v", err) + } + + record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl")) + attemptTrailRaw, ok := record["attempt_trail"].(map[string]any) + if !ok { + t.Fatalf("attempt_trail missing or wrong type: %#v", record["attempt_trail"]) + } + attemptedSkills, ok := attemptTrailRaw["attempted_skills"].([]any) + if !ok { + t.Fatalf("attempted_skills wrong type: %#v", attemptTrailRaw["attempted_skills"]) + } + if len(attemptedSkills) != 2 || attemptedSkills[0] != "geocode" || attemptedSkills[1] != "weather" { + t.Fatalf("attempted_skills = %#v, want [geocode weather]", attemptedSkills) + } + finalPath, ok := attemptTrailRaw["final_successful_path"].([]any) + if !ok { + t.Fatalf("final_successful_path wrong type: %#v", attemptTrailRaw["final_successful_path"]) + } + 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 len(skillSnapshots) != 2 { + t.Fatalf("len(skill_context_snapshots) = %d, want 2", len(skillSnapshots)) + } +} + +func TestEvolutionBridge_CloseStopsColdPathRunnerIdempotently(t *testing.T) { + cfg := &config.Config{ + Evolution: config.EvolutionConfig{ + Enabled: true, + Mode: "review", + AutoRunColdPath: true, + }, + } + + bridge, err := newEvolutionBridge(nil, cfg, nil) + if err != nil { + t.Fatalf("newEvolutionBridge: %v", err) + } + if bridge.coldPathRunner == nil { + t.Fatal("expected cold path runner") + } + + if err := bridge.Close(); err != nil { + t.Fatalf("first Close() error = %v", err) + } + if err := bridge.Close(); err != nil { + t.Fatalf("second Close() error = %v", err) + } + if bridge.coldPathRunner.Trigger(t.TempDir()) { + t.Fatal("expected closed bridge runner to reject new work") + } +} + +func TestAgentLoop_ReloadProviderAndConfig_RebuildsEvolutionBridge(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + Evolution: config.EvolutionConfig{ + Enabled: false, + Mode: "observe", + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + defer al.Close() + + oldBridge := al.evolution + if oldBridge == nil { + t.Fatal("expected initial evolution bridge") + } + + reloadCfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + Evolution: config.EvolutionConfig{ + Enabled: true, + Mode: "apply", + StateDir: filepath.Join(t.TempDir(), "evolution-state"), + }, + } + + if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloadCfg); err != nil { + t.Fatalf("ReloadProviderAndConfig failed: %v", err) + } + + if al.evolution == nil { + t.Fatal("expected evolution bridge after reload") + } + if al.evolution == oldBridge { + t.Fatal("expected evolution bridge to be rebuilt on reload") + } + if al.evolution.cfg.Enabled != reloadCfg.Evolution.Enabled { + t.Fatalf("reloaded evolution enabled = %v, want %v", al.evolution.cfg.Enabled, reloadCfg.Evolution.Enabled) + } + if al.evolution.cfg.Mode != reloadCfg.Evolution.Mode { + t.Fatalf("reloaded evolution mode = %q, want %q", al.evolution.cfg.Mode, reloadCfg.Evolution.Mode) + } + if al.evolution.cfg.StateDir != reloadCfg.Evolution.StateDir { + t.Fatalf("reloaded evolution state_dir = %q, want %q", al.evolution.cfg.StateDir, reloadCfg.Evolution.StateDir) + } +} + +func seedReadyRule(t *testing.T, workspace string) { + t.Helper() + + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: workspace, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"weather"}, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } +} + +func newEvolutionTestLoop(t *testing.T, workspace string, evo config.EvolutionConfig, provider providers.LLMProvider) *AgentLoop { + t.Helper() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 3, + }, + }, + Evolution: evo, + } + + return NewAgentLoop(cfg, bus.NewMessageBus(), provider) +} + +func waitForEvolutionRecord(t *testing.T, path string) map[string]any { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) == 1 && lines[0] != "" { + var record map[string]any + if err := json.Unmarshal([]byte(lines[0]), &record); err != nil { + t.Fatalf("json.Unmarshal(%s): %v", path, err) + } + return record + } + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("timed out waiting for evolution record at %s", path) + return nil +} + +func waitForDrafts(t *testing.T, path string, want int) []evolution.SkillDraft { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + var drafts []evolution.SkillDraft + if err := json.Unmarshal(data, &drafts); err != nil { + t.Fatalf("json.Unmarshal(%s): %v", path, err) + } + if len(drafts) == want { + return drafts + } + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("timed out waiting for %d drafts at %s", want, path) + return nil +} + +func waitForSkillBody(t *testing.T, path string) string { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + return string(data) + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("timed out waiting for skill file at %s", path) + return "" +} + +func waitForProfile(t *testing.T, workspace, skillName string) evolution.SkillProfile { + t.Helper() + + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + profile, err := store.LoadProfile(skillName) + if err == nil { + return profile + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("timed out waiting for profile %q in %s", skillName, workspace) + return evolution.SkillProfile{} +} + +func assertProfileNotExists(t *testing.T, workspace, skillName string) { + t.Helper() + + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + if _, err := store.LoadProfile(skillName); !os.IsNotExist(err) { + t.Fatalf("profile %q should not exist, got err = %v", skillName, err) + } +} + +func assertNotExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("%s should not exist, stat err = %v", path, err) + } +} + +type capturingEvolutionDraftProvider struct { + response string + defaultModel string + lastModel string +} + +type lateSkillOnRetryProvider struct { + calls int + lateSkillPath string +} + +func (p *lateSkillOnRetryProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + if p.calls == 1 { + if err := os.MkdirAll(filepath.Dir(p.lateSkillPath), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile( + p.lateSkillPath, + []byte("---\nname: late-skill\ndescription: late test skill\n---\n# Late Skill\n"), + 0o644, + ); err != nil { + return nil, err + } + return nil, errors.New("context_window_exceeded") + } + + return &providers.LLMResponse{Content: "Recovered after retry"}, nil +} + +func (p *lateSkillOnRetryProvider) GetDefaultModel() string { + return "mock-model" +} + +func (p *capturingEvolutionDraftProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + p.lastModel = model + return &providers.LLMResponse{Content: p.response}, nil +} + +func (p *capturingEvolutionDraftProvider) GetDefaultModel() string { + return p.defaultModel +} diff --git a/pkg/agent/pipeline_execute.go b/pkg/agent/pipeline_execute.go index 48e72e096..2a4a947c9 100644 --- a/pkg/agent/pipeline_execute.go +++ b/pkg/agent/pipeline_execute.go @@ -198,6 +198,7 @@ toolLoop: Async: hookResult.Async, }, ) + ts.recordToolKind(toolName) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { @@ -570,6 +571,7 @@ toolLoop: Async: toolResult.Async, }, ) + ts.recordToolKind(toolName) messages = append(messages, toolResultMsg) if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) diff --git a/pkg/agent/pipeline_llm.go b/pkg/agent/pipeline_llm.go index 7b3fee208..34b36de1a 100644 --- a/pkg/agent/pipeline_llm.go +++ b/pkg/agent/pipeline_llm.go @@ -319,10 +319,15 @@ func (p *Pipeline) CallLLM( exec.history = asmResp.History exec.summary = asmResp.Summary } + contextualSkills := ts.activeSkills + if ts.agent.ContextBuilder != nil { + contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) + } + ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills) exec.messages = ts.agent.ContextBuilder.BuildMessages( exec.history, exec.summary, "", nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., + contextualSkills..., ) exec.callMessages = exec.messages if exec.gracefulTerminal { diff --git a/pkg/agent/pipeline_setup.go b/pkg/agent/pipeline_setup.go index e6ead1012..5317a8990 100644 --- a/pkg/agent/pipeline_setup.go +++ b/pkg/agent/pipeline_setup.go @@ -31,6 +31,12 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution } ts.captureRestorePoint(history, summary) + contextualSkills := ts.activeSkills + if ts.agent.ContextBuilder != nil { + contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills) + } + ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills) + messages := ts.agent.ContextBuilder.BuildMessages( history, summary, @@ -40,7 +46,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., + contextualSkills..., ) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) @@ -73,7 +79,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution history, summary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName, - activeSkillNames(ts.agent, ts.opts)..., + contextualSkills..., ) messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize) } diff --git a/pkg/agent/turn_coord.go b/pkg/agent/turn_coord.go index 4c8335933..cd5038ceb 100644 --- a/pkg/agent/turn_coord.go +++ b/pkg/agent/turn_coord.go @@ -27,14 +27,30 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel turnStatus := TurnEndStatusCompleted defer func() { + attemptedSkills := ts.attemptedSkillsSnapshot() + skillContextSnapshots := ts.skillContextSnapshotsSnapshot() + finalSuccessfulPath := []string(nil) + if turnStatus == TurnEndStatusCompleted { + if latest := ts.latestSkillContextSnapshot(); len(latest) > 0 { + finalSuccessfulPath = latest + } else { + finalSuccessfulPath = append([]string(nil), attemptedSkills...) + } + } al.emitEvent( EventKindTurnEnd, ts.eventMeta("runTurn", "turn.end"), TurnEndPayload{ - Status: turnStatus, - Iterations: ts.currentIteration(), - Duration: time.Since(ts.startedAt), - FinalContentLen: ts.finalContentLen(), + Status: turnStatus, + Workspace: ts.workspace, + Iterations: ts.currentIteration(), + Duration: time.Since(ts.startedAt), + FinalContentLen: ts.finalContentLen(), + ActiveSkills: append([]string(nil), ts.activeSkills...), + AttemptedSkills: attemptedSkills, + FinalSuccessfulPath: finalSuccessfulPath, + SkillContextSnapshots: skillContextSnapshots, + ToolKinds: ts.toolKindsSnapshot(), }, ) }() diff --git a/pkg/agent/turn_coord_test.go b/pkg/agent/turn_coord_test.go index c059d0a39..56bc759f8 100644 --- a/pkg/agent/turn_coord_test.go +++ b/pkg/agent/turn_coord_test.go @@ -613,3 +613,29 @@ func TestTurnState_HardAbortRequested(t *testing.T) { t.Error("expected hard abort to be requested") } } + +func TestTurnState_SkillContextSnapshotsTrackLatestSuccessfulPath(t *testing.T) { + ts := &turnState{} + + ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, []string{"skill-a"}) + ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, []string{"skill-b", "skill-c"}) + + if got := ts.attemptedSkillsSnapshot(); len(got) != 3 || got[0] != "skill-a" || got[1] != "skill-b" || got[2] != "skill-c" { + t.Fatalf("attemptedSkillsSnapshot = %v, want [skill-a skill-b skill-c]", got) + } + + if got := ts.latestSkillContextSnapshot(); len(got) != 2 || got[0] != "skill-b" || got[1] != "skill-c" { + t.Fatalf("latestSkillContextSnapshot = %v, want [skill-b skill-c]", got) + } + + snapshots := ts.skillContextSnapshotsSnapshot() + if len(snapshots) != 2 { + t.Fatalf("len(skillContextSnapshotsSnapshot()) = %d, want 2", len(snapshots)) + } + if snapshots[0].Sequence != 1 || snapshots[0].Trigger != skillContextTriggerInitialBuild { + t.Fatalf("snapshots[0] = %+v, want sequence=1 trigger=%q", snapshots[0], skillContextTriggerInitialBuild) + } + if snapshots[1].Sequence != 2 || snapshots[1].Trigger != skillContextTriggerContextRetryRebuild { + t.Fatalf("snapshots[1] = %+v, want sequence=2 trigger=%q", snapshots[1], skillContextTriggerContextRetryRebuild) + } +} diff --git a/pkg/agent/turn_state.go b/pkg/agent/turn_state.go index 8b5fd4e2c..320463f9c 100644 --- a/pkg/agent/turn_state.go +++ b/pkg/agent/turn_state.go @@ -5,6 +5,7 @@ package agent import ( "context" "reflect" + "strings" "sync" "sync/atomic" "time" @@ -176,13 +177,18 @@ type turnState struct { opts processOptions scope turnEventScope - turnID string - agentID string - sessionKey string - turnCtx *TurnContext + turnID string + agentID string + sessionKey string + activeSkills []string + attemptedSkills []string + skillContextTrace []SkillContextSnapshot + toolKinds []string + turnCtx *TurnContext channel string chatID string + workspace string userMessage string media []string @@ -238,19 +244,21 @@ type turnState struct { func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState { ts := &turnState{ - agent: agent, - opts: opts, - scope: scope, - turnID: scope.turnID, - agentID: agent.ID, - sessionKey: opts.Dispatch.SessionKey, - turnCtx: cloneTurnContext(scope.context), - channel: opts.Dispatch.Channel(), - chatID: opts.Dispatch.ChatID(), - userMessage: opts.Dispatch.UserMessage, - media: append([]string(nil), opts.Dispatch.Media...), - phase: TurnPhaseSetup, - startedAt: time.Now(), + agent: agent, + opts: opts, + scope: scope, + turnID: scope.turnID, + agentID: agent.ID, + sessionKey: opts.Dispatch.SessionKey, + activeSkills: activeSkillNames(agent, opts), + turnCtx: cloneTurnContext(scope.context), + channel: opts.Dispatch.Channel(), + chatID: opts.Dispatch.ChatID(), + workspace: agent.Workspace, + userMessage: opts.Dispatch.UserMessage, + media: append([]string(nil), opts.Dispatch.Media...), + phase: TurnPhaseSetup, + startedAt: time.Now(), } // Bind session store and capture initial history length for rollback logic @@ -375,6 +383,117 @@ func (ts *turnState) finalContentLen() int { return len(ts.finalContent) } +func (ts *turnState) recordToolKind(tool string) { + tool = strings.TrimSpace(tool) + if tool == "" { + return + } + + ts.mu.Lock() + defer ts.mu.Unlock() + + for _, existing := range ts.toolKinds { + if existing == tool { + return + } + } + ts.toolKinds = append(ts.toolKinds, tool) +} + +func (ts *turnState) toolKindsSnapshot() []string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return append([]string(nil), ts.toolKinds...) +} + +func (ts *turnState) recordAttemptedSkills(skillNames []string) { + if len(skillNames) == 0 { + return + } + + ts.mu.Lock() + defer ts.mu.Unlock() + + for _, skillName := range skillNames { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + continue + } + seen := false + for _, existing := range ts.attemptedSkills { + if existing == skillName { + seen = true + break + } + } + if seen { + continue + } + ts.attemptedSkills = append(ts.attemptedSkills, skillName) + } +} + +func (ts *turnState) attemptedSkillsSnapshot() []string { + ts.mu.RLock() + defer ts.mu.RUnlock() + return append([]string(nil), ts.attemptedSkills...) +} + +func (ts *turnState) recordSkillContextSnapshot(trigger string, skillNames []string) { + if len(skillNames) == 0 { + return + } + + filtered := make([]string, 0, len(skillNames)) + for _, skillName := range skillNames { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + continue + } + filtered = append(filtered, skillName) + } + if len(filtered) == 0 { + return + } + + ts.recordAttemptedSkills(filtered) + + ts.mu.Lock() + defer ts.mu.Unlock() + ts.skillContextTrace = append(ts.skillContextTrace, SkillContextSnapshot{ + Sequence: len(ts.skillContextTrace) + 1, + Trigger: trigger, + SkillNames: append([]string(nil), filtered...), + }) +} + +func (ts *turnState) latestSkillContextSnapshot() []string { + ts.mu.RLock() + defer ts.mu.RUnlock() + if len(ts.skillContextTrace) == 0 { + return nil + } + return append([]string(nil), ts.skillContextTrace[len(ts.skillContextTrace)-1].SkillNames...) +} + +func (ts *turnState) skillContextSnapshotsSnapshot() []SkillContextSnapshot { + ts.mu.RLock() + defer ts.mu.RUnlock() + if len(ts.skillContextTrace) == 0 { + return nil + } + + snapshots := make([]SkillContextSnapshot, 0, len(ts.skillContextTrace)) + for _, snapshot := range ts.skillContextTrace { + snapshots = append(snapshots, SkillContextSnapshot{ + Sequence: snapshot.Sequence, + Trigger: snapshot.Trigger, + SkillNames: append([]string(nil), snapshot.SkillNames...), + }) + } + return snapshots +} + func (ts *turnState) setTurnCancel(cancel context.CancelFunc) { ts.mu.Lock() defer ts.mu.Unlock() diff --git a/pkg/config/config.go b/pkg/config/config.go index 161108638..c8b42ad2c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,6 +36,7 @@ type Config struct { Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"` Agents AgentsConfig `json:"agents" yaml:"-"` Session SessionConfig `json:"session,omitempty" yaml:"-"` + Evolution EvolutionConfig `json:"evolution,omitempty" yaml:"-"` Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"` ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway" yaml:"-"` @@ -51,6 +52,32 @@ type Config struct { sensitiveCache *SensitiveDataCache } +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"` +} + +func (c EvolutionConfig) EffectiveMode() string { + if !c.Enabled { + return "" + } + switch strings.ToLower(strings.TrimSpace(c.Mode)) { + case "review": + return "review" + case "apply": + return "apply" + case "", "observe": + return "observe" + default: + return "observe" + } +} + // 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 624cc7305..c0e1e4934 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -171,6 +171,96 @@ 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.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) +} + +func TestEvolutionConfig_EffectiveMode(t *testing.T) { + tests := []struct { + name string + cfg EvolutionConfig + want string + }{ + { + name: "disabled returns empty", + cfg: EvolutionConfig{ + Enabled: false, + Mode: "apply", + }, + want: "", + }, + { + name: "enabled empty mode defaults to observe", + cfg: EvolutionConfig{ + Enabled: true, + }, + want: "observe", + }, + { + name: "enabled whitespace mode defaults to observe", + cfg: EvolutionConfig{ + Enabled: true, + Mode: " \t\n ", + }, + want: "observe", + }, + { + name: "enabled returns configured mode", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "review", + }, + want: "review", + }, + { + name: "enabled trims and normalizes mode", + cfg: EvolutionConfig{ + Enabled: true, + Mode: " Review ", + }, + want: "review", + }, + { + name: "enabled returns apply mode", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "apply", + }, + want: "apply", + }, + { + name: "enabled normalizes uppercase apply", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "APPLY", + }, + want: "apply", + }, + { + name: "enabled unknown mode falls back to observe", + cfg: EvolutionConfig{ + Enabled: true, + Mode: "propose", + }, + want: "observe", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.cfg.EffectiveMode()) + }) + } +} + 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 35ef7cdd8..c7bf151ce 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -44,6 +44,14 @@ func DefaultConfig() *Config { Session: SessionConfig{ Dimensions: []string{"chat"}, }, + Evolution: EvolutionConfig{ + Enabled: false, + Mode: "observe", + MinCaseCount: 3, + MinSuccessRate: 0.7, + AutoRunColdPath: false, + AutoApply: false, + }, Channels: defaultChannels(), Hooks: HooksConfig{ Enabled: true, diff --git a/pkg/evolution/apply.go b/pkg/evolution/apply.go new file mode 100644 index 000000000..9ea44558d --- /dev/null +++ b/pkg/evolution/apply.go @@ -0,0 +1,178 @@ +package evolution + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type Applier struct { + paths Paths + now func() time.Time +} + +func NewApplier(paths Paths, now func() time.Time) *Applier { + if now == nil { + now = time.Now + } + return &Applier{ + paths: paths, + now: now, + } +} + +func (a *Applier) ApplyDraft(ctx context.Context, workspace string, draft SkillDraft) error { + rollback, err := a.applyDraftWithRollback(ctx, workspace, draft) + if err != nil { + return err + } + _ = rollback + return nil +} + +func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string, draft SkillDraft) (func() error, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil { + return nil, err + } + + existingBody, backupPath, hadOriginal, err := a.backupCurrentSkill(workspace, draft.TargetSkillName) + if err != nil { + return nil, err + } + + skillDir := filepath.Join(workspace, "skills", draft.TargetSkillName) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return nil, err + } + + renderedBody, err := renderAppliedBody(draft, existingBody, hadOriginal) + if err != nil { + return nil, err + } + + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := fileutil.WriteFileAtomic(skillPath, []byte(renderedBody), 0o644); err != nil { + return nil, err + } + + if err := validateAppliedSkillBody(renderedBody); err != nil { + if rollbackErr := a.rollbackSkill(skillPath, backupPath, hadOriginal); rollbackErr != nil { + return nil, errorsJoin(err, rollbackErr) + } + return nil, err + } + + return func() error { + return a.rollbackSkill(skillPath, backupPath, hadOriginal) + }, nil +} + +func (a *Applier) backupCurrentSkill(workspace, skillName string) (currentBody, backupPath string, hadOriginal bool, err error) { + if err := skills.ValidateSkillName(skillName); err != nil { + return "", "", false, err + } + + skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md") + data, err := os.ReadFile(skillPath) + if os.IsNotExist(err) { + return "", "", false, nil + } + if err != nil { + return "", "", false, err + } + + backupDir := filepath.Join(a.paths.BackupsDir, skillName, a.now().Format("20060102-150405")) + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return "", "", false, err + } + + backupPath = filepath.Join(backupDir, "SKILL.md") + if err := fileutil.WriteFileAtomic(backupPath, data, 0o644); err != nil { + return "", "", false, err + } + return string(data), backupPath, true, nil +} + +func (a *Applier) rollbackSkill(skillPath, backupPath string, hadOriginal bool) error { + if hadOriginal { + data, err := os.ReadFile(backupPath) + if err != nil { + return err + } + return fileutil.WriteFileAtomic(skillPath, data, 0o644) + } + if err := os.Remove(skillPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func validateAppliedSkillBody(body string) error { + body = strings.TrimSpace(body) + if !strings.HasPrefix(body, "---\n") { + return fmt.Errorf("skill frontmatter is required") + } + if !strings.Contains(body, "\n# ") { + return fmt.Errorf("skill heading is required") + } + return nil +} + +func renderAppliedBody(draft SkillDraft, existingBody string, hadOriginal bool) (string, error) { + switch draft.ChangeKind { + case ChangeKindCreate: + if hadOriginal { + return "", fmt.Errorf("cannot create skill %q: skill already exists", draft.TargetSkillName) + } + return draft.BodyOrPatch, nil + case ChangeKindReplace: + if !hadOriginal { + return "", fmt.Errorf("cannot replace skill %q: skill does not exist", draft.TargetSkillName) + } + return draft.BodyOrPatch, nil + case ChangeKindAppend: + if !hadOriginal || strings.TrimSpace(existingBody) == "" { + return draft.BodyOrPatch, nil + } + return strings.TrimRight(existingBody, "\n") + "\n\n" + strings.TrimLeft(draft.BodyOrPatch, "\n"), nil + case ChangeKindMerge: + if !hadOriginal || strings.TrimSpace(existingBody) == "" { + return draft.BodyOrPatch, nil + } + mergedSection := strings.Join([]string{ + "", + "## Merged Knowledge", + strings.TrimSpace(draft.BodyOrPatch), + "", + }, "\n") + return strings.TrimRight(existingBody, "\n") + mergedSection, nil + default: + return "", fmt.Errorf("unsupported change_kind %q", draft.ChangeKind) + } +} + +func errorsJoin(errs ...error) error { + var first error + for _, err := range errs { + if err == nil { + continue + } + if first == nil { + first = err + continue + } + first = fmt.Errorf("%w; %v", first, err) + } + return first +} diff --git a/pkg/evolution/apply_test.go b/pkg/evolution/apply_test.go new file mode 100644 index 000000000..5ce32ee90 --- /dev/null +++ b/pkg/evolution/apply_test.go @@ -0,0 +1,275 @@ +package evolution_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestApplier_CreateDraftWritesSkillFile(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-1", + WorkspaceID: workspace, + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "weather helper", + BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", + Status: evolution.DraftStatusAccepted, + } + + if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { + t.Fatalf("ApplyDraft: %v", err) + } + + 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), "# Weather") { + t.Fatalf("unexpected content: %s", string(data)) + } +} + +func TestApplier_CreateDraftFailsWhenSkillAlreadyExists(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "weather") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n" + skillPath := filepath.Join(skillDir, "SKILL.md") + 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() + }) + + draft := evolution.SkillDraft{ + ID: "draft-create-existing", + WorkspaceID: workspace, + SourceRecordID: "rule-create-existing", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "weather helper", + BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n", + } + + err := applier.ApplyDraft(context.Background(), workspace, draft) + if err == nil { + t.Fatal("expected ApplyDraft to fail") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("error = %v, want already exists", err) + } + + got, readErr := os.ReadFile(skillPath) + if readErr != nil { + t.Fatalf("ReadFile: %v", readErr) + } + if string(got) != original { + t.Fatalf("skill content changed unexpectedly:\n%s", string(got)) + } +} + +func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "weather") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n" + skillPath := filepath.Join(skillDir, "SKILL.md") + 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() + }) + + draft := evolution.SkillDraft{ + ID: "draft-2", + WorkspaceID: workspace, + SourceRecordID: "rule-2", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindReplace, + HumanSummary: "broken draft", + BodyOrPatch: "invalid-frontmatter", + Status: evolution.DraftStatusAccepted, + } + + err := applier.ApplyDraft(context.Background(), workspace, draft) + if err == nil { + t.Fatal("expected ApplyDraft to fail") + } + + got, readErr := os.ReadFile(skillPath) + if readErr != nil { + t.Fatalf("ReadFile: %v", readErr) + } + if string(got) != original { + t.Fatalf("skill content changed after rollback:\n%s", string(got)) + } +} + +func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(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-replace-missing", + WorkspaceID: workspace, + SourceRecordID: "rule-replace-missing", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindReplace, + HumanSummary: "replace missing skill", + BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n", + } + + err := applier.ApplyDraft(context.Background(), workspace, draft) + if err == nil { + t.Fatal("expected ApplyDraft to fail") + } + if !strings.Contains(err.Error(), "does not exist") { + t.Fatalf("error = %v, want does not exist", err) + } + + skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") + if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { + t.Fatalf("expected no skill file, got err=%v", statErr) + } +} + +func TestApplier_AppendDraftPreservesOriginalBody(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "weather") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" + skillPath := filepath.Join(skillDir, "SKILL.md") + 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() + }) + + draft := evolution.SkillDraft{ + ID: "draft-append", + WorkspaceID: workspace, + SourceRecordID: "rule-append", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "append draft", + BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n", + } + + if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { + t.Fatalf("ApplyDraft: %v", err) + } + + got, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + content := string(got) + if !strings.Contains(content, "Use city names.") { + t.Fatalf("appended content lost original body:\n%s", content) + } + if !strings.Contains(content, "Prefer native-name query first.") { + t.Fatalf("appended content missing new body:\n%s", content) + } +} + +func TestApplier_MergeDraftAddsMergedKnowledgeSection(t *testing.T) { + workspace := t.TempDir() + skillDir := filepath.Join(workspace, "skills", "weather") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" + skillPath := filepath.Join(skillDir, "SKILL.md") + 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() + }) + + draft := evolution.SkillDraft{ + ID: "draft-merge", + WorkspaceID: workspace, + SourceRecordID: "rule-merge", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindMerge, + HumanSummary: "merge draft", + BodyOrPatch: "Prefer native-name query first.", + } + + if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { + t.Fatalf("ApplyDraft: %v", err) + } + + got, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + content := string(got) + if !strings.Contains(content, "Use city names.") { + t.Fatalf("merged content lost original body:\n%s", content) + } + if !strings.Contains(content, "## Merged Knowledge") { + t.Fatalf("merged content missing merged section:\n%s", content) + } + if !strings.Contains(content, "Prefer native-name query first.") { + t.Fatalf("merged content missing new knowledge:\n%s", content) + } +} + +func TestApplier_RejectsInvalidSkillName(t *testing.T) { + workspace := t.TempDir() + applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { + return time.Unix(1700000000, 0).UTC() + }) + + for _, name := range []string{"../escape", "/tmp/escape"} { + err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ + ID: "draft-invalid-name", + WorkspaceID: workspace, + SourceRecordID: "rule-invalid-name", + TargetSkillName: name, + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "bad name", + BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nbody\n", + }) + if err == nil { + t.Fatalf("TargetSkillName %q expected error", name) + } + } +} diff --git a/pkg/evolution/case_writer.go b/pkg/evolution/case_writer.go new file mode 100644 index 000000000..f684c2523 --- /dev/null +++ b/pkg/evolution/case_writer.go @@ -0,0 +1,21 @@ +package evolution + +import ( + "context" +) + +type CaseWriter struct { + paths Paths + store *Store +} + +func NewCaseWriter(paths Paths) *CaseWriter { + return &CaseWriter{ + paths: paths, + store: NewStore(paths), + } +} + +func (w *CaseWriter) AppendCase(ctx context.Context, record LearningRecord) error { + return w.store.AppendLearningRecord(ctx, record) +} diff --git a/pkg/evolution/case_writer_test.go b/pkg/evolution/case_writer_test.go new file mode 100644 index 000000000..8b8704f94 --- /dev/null +++ b/pkg/evolution/case_writer_test.go @@ -0,0 +1,77 @@ +package evolution_test + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestCaseWriter_AppendsOneRecord(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + writer := evolution.NewCaseWriter(paths) + + record1 := testRecord("rec-1", "ws-1", true) + record2 := testRecord("rec-2", "ws-2", false) + + if err := writer.AppendCase(context.Background(), record1); err != nil { + t.Fatalf("AppendCase: %v", err) + } + if err := writer.AppendCase(context.Background(), record2); err != nil { + t.Fatalf("AppendCase second record: %v", err) + } + + data, err := os.ReadFile(paths.LearningRecords) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + + text := string(data) + if !strings.HasSuffix(text, "\n") { + t.Fatalf("record file should end with newline, got %q", text) + } + + lines := strings.Split(strings.TrimSpace(text), "\n") + if len(lines) != 2 { + t.Fatalf("record file line count = %d, want 2", len(lines)) + } + + records := []evolution.LearningRecord{record1, record2} + for i, line := range lines { + var got evolution.LearningRecord + if err := json.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("Unmarshal line %d: %v", i, err) + } + + want := records[i] + if got.ID != want.ID { + t.Fatalf("record %d ID = %q, want %q", i, got.ID, want.ID) + } + if got.Kind != evolution.RecordKindCase { + t.Fatalf("record %d kind = %q, want %q", i, got.Kind, evolution.RecordKindCase) + } + if got.Summary != want.Summary { + t.Fatalf("record %d summary = %q, want %q", i, got.Summary, want.Summary) + } + if got.Success == nil || *got.Success != *want.Success { + t.Fatalf("record %d success = %v, want %v", i, got.Success, want.Success) + } + } +} + +func testRecord(id, workspaceID string, success bool) evolution.LearningRecord { + return evolution.LearningRecord{ + ID: id, + Kind: evolution.RecordKindCase, + WorkspaceID: workspaceID, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "cli turn completed", + Status: evolution.RecordStatus("new"), + Success: &success, + } +} diff --git a/pkg/evolution/cold_path_runner.go b/pkg/evolution/cold_path_runner.go new file mode 100644 index 000000000..696c706fb --- /dev/null +++ b/pkg/evolution/cold_path_runner.go @@ -0,0 +1,121 @@ +package evolution + +import ( + "context" + "errors" + "sync" +) + +type coldPathRuntime interface { + RunColdPathOnce(ctx context.Context, workspace string) error +} + +type ColdPathRunner struct { + runtime coldPathRuntime + async func(func()) + onError func(error) + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + wg sync.WaitGroup + closeOnce sync.Once + closed bool + running map[string]workspaceRunState +} + +func NewColdPathRunner(runtime coldPathRuntime) *ColdPathRunner { + return NewColdPathRunnerWithErrorHandler(runtime, nil) +} + +func NewColdPathRunnerWithErrorHandler(runtime coldPathRuntime, onError func(error)) *ColdPathRunner { + if onError == nil { + onError = func(error) {} + } + ctx, cancel := context.WithCancel(context.Background()) + + return &ColdPathRunner{ + runtime: runtime, + async: func(run func()) { + go run() + }, + onError: onError, + ctx: ctx, + cancel: cancel, + running: make(map[string]workspaceRunState), + } +} + +type workspaceRunState struct { + running bool + pending bool +} + +func (r *ColdPathRunner) Trigger(workspace string) bool { + if r == nil || r.runtime == nil || workspace == "" { + return false + } + + r.mu.Lock() + if r.closed { + r.mu.Unlock() + return false + } + state, exists := r.running[workspace] + if exists && state.running { + state.pending = true + r.running[workspace] = state + r.mu.Unlock() + return true + } + r.running[workspace] = workspaceRunState{running: true} + r.wg.Add(1) + r.mu.Unlock() + + r.async(func() { + defer r.wg.Done() + r.runWorkspace(workspace) + }) + + return true +} + +func (r *ColdPathRunner) runWorkspace(workspace string) { + for { + if err := r.runtime.RunColdPathOnce(r.ctx, workspace); err != nil && !errors.Is(err, context.Canceled) { + r.onError(err) + } + + r.mu.Lock() + state, exists := r.running[workspace] + if !exists || r.closed { + delete(r.running, workspace) + r.mu.Unlock() + return + } + if state.pending { + state.pending = false + r.running[workspace] = state + r.mu.Unlock() + continue + } + delete(r.running, workspace) + r.mu.Unlock() + return + } +} + +func (r *ColdPathRunner) Close() error { + if r == nil { + return nil + } + + r.closeOnce.Do(func() { + r.mu.Lock() + r.closed = true + r.mu.Unlock() + r.cancel() + }) + r.wg.Wait() + return nil +} diff --git a/pkg/evolution/cold_path_runner_test.go b/pkg/evolution/cold_path_runner_test.go new file mode 100644 index 000000000..3582fa6eb --- /dev/null +++ b/pkg/evolution/cold_path_runner_test.go @@ -0,0 +1,142 @@ +package evolution + +import ( + "context" + "sync/atomic" + "testing" + "time" +) + +type blockingColdPathRuntime struct { + runCount atomic.Int32 + cancelCount atomic.Int32 + started chan string + release chan struct{} +} + +func (r *blockingColdPathRuntime) RunColdPathOnce(ctx context.Context, workspace string) error { + r.runCount.Add(1) + r.started <- workspace + select { + case <-r.release: + return nil + case <-ctx.Done(): + r.cancelCount.Add(1) + return ctx.Err() + } +} + +func TestColdPathRunner_QueuesPendingRunForWorkspace(t *testing.T) { + runtime := &blockingColdPathRuntime{ + started: make(chan string, 4), + release: make(chan struct{}, 4), + } + runner := NewColdPathRunner(runtime) + defer runner.Close() + + if scheduled := runner.Trigger("workspace-a"); !scheduled { + t.Fatal("expected first trigger to be scheduled") + } + + select { + case workspace := <-runtime.started: + if workspace != "workspace-a" { + t.Fatalf("workspace = %q, want workspace-a", workspace) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for first cold path run") + } + + if scheduled := runner.Trigger("workspace-a"); !scheduled { + t.Fatal("expected second trigger to queue a pending run") + } + + select { + case workspace := <-runtime.started: + t.Fatalf("unexpected early pending cold path run for %q", workspace) + case <-time.After(150 * time.Millisecond): + } + + runtime.release <- struct{}{} + + select { + case workspace := <-runtime.started: + if workspace != "workspace-a" { + t.Fatalf("workspace = %q, want workspace-a", workspace) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for pending cold path run") + } + + runtime.release <- struct{}{} + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if runtime.runCount.Load() == 2 { + return + } + time.Sleep(10 * time.Millisecond) + } + + t.Fatalf("runCount = %d, want 2", runtime.runCount.Load()) +} + +func TestColdPathRunner_CloseCancelsActiveRunAndDropsPendingWork(t *testing.T) { + runtime := &blockingColdPathRuntime{ + started: make(chan string, 4), + release: make(chan struct{}, 4), + } + runner := NewColdPathRunner(runtime) + + if scheduled := runner.Trigger("workspace-a"); !scheduled { + t.Fatal("expected first trigger to be scheduled") + } + + select { + case <-runtime.started: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for first cold path run") + } + + if scheduled := runner.Trigger("workspace-a"); !scheduled { + t.Fatal("expected second trigger to mark pending work") + } + + closeDone := make(chan struct{}) + go func() { + defer close(closeDone) + if err := runner.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } + }() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if !runner.Trigger("workspace-a") { + break + } + time.Sleep(10 * time.Millisecond) + } + if runner.Trigger("workspace-a") { + t.Fatal("expected Trigger to reject new work after Close") + } + + select { + case <-closeDone: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for Close to finish") + } + + select { + case workspace := <-runtime.started: + t.Fatalf("unexpected pending cold path run after Close for %q", workspace) + case <-time.After(150 * time.Millisecond): + } + + if got := runtime.runCount.Load(); got != 1 { + t.Fatalf("runCount = %d, want 1", got) + } + if got := runtime.cancelCount.Load(); got != 1 { + t.Fatalf("cancelCount = %d, want 1", got) + } +} diff --git a/pkg/evolution/draft_review.go b/pkg/evolution/draft_review.go new file mode 100644 index 000000000..da44d6365 --- /dev/null +++ b/pkg/evolution/draft_review.go @@ -0,0 +1,38 @@ +package evolution + +import "strings" + +type DraftReviewResult struct { + Status DraftStatus + Findings []string + ReviewNotes []string +} + +func ReviewDraft(draft SkillDraft) DraftReviewResult { + findings := append([]string(nil), ValidateDraft(draft)...) + findings = append(findings, scanDraftContent(draft)...) + + result := DraftReviewResult{ + Status: DraftStatusCandidate, + Findings: findings, + ReviewNotes: []string{"local structural validation completed"}, + } + if len(findings) > 0 { + result.Status = DraftStatusQuarantined + } + return result +} + +func scanDraftContent(draft SkillDraft) []string { + body := strings.ToLower(draft.BodyOrPatch) + findings := make([]string, 0, 2) + + if strings.Contains(body, "sk-live-") || strings.Contains(body, "sk_test_") || strings.Contains(body, "api_key=") { + findings = append(findings, "secret-like token detected in body_or_patch") + } + if strings.Contains(body, "-----begin private key-----") { + findings = append(findings, "private key material detected in body_or_patch") + } + + return findings +} diff --git a/pkg/evolution/draft_review_test.go b/pkg/evolution/draft_review_test.go new file mode 100644 index 000000000..70bcc6a6f --- /dev/null +++ b/pkg/evolution/draft_review_test.go @@ -0,0 +1,67 @@ +package evolution_test + +import ( + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestReviewDraft_QuarantinesInvalidDraft(t *testing.T) { + result := evolution.ReviewDraft(evolution.SkillDraft{ + ID: "draft-1", + TargetSkillName: "", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "broken", + BodyOrPatch: "", + }) + + if result.Status != evolution.DraftStatusQuarantined { + t.Fatalf("Status = %q, want %q", result.Status, evolution.DraftStatusQuarantined) + } + if len(result.Findings) == 0 { + t.Fatal("expected findings for invalid draft") + } +} + +func TestReviewDraft_QuarantinesSecretLikeContent(t *testing.T) { + result := evolution.ReviewDraft(evolution.SkillDraft{ + ID: "draft-2", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "contains credentials", + BodyOrPatch: "Use token sk-live-secret for direct calls.", + }) + + if result.Status != evolution.DraftStatusQuarantined { + t.Fatalf("Status = %q, want %q", result.Status, evolution.DraftStatusQuarantined) + } + if len(result.Findings) == 0 { + t.Fatal("expected findings for secret-like content") + } + if !strings.Contains(strings.Join(result.Findings, "\n"), "secret-like") { + t.Fatalf("findings = %v, want secret-like finding", result.Findings) + } +} + +func TestReviewDraft_QuarantinesInvalidTargetSkillName(t *testing.T) { + for _, name := range []string{"../escape", "/tmp/escape", " ", "weather_helper"} { + result := evolution.ReviewDraft(evolution.SkillDraft{ + ID: "draft-invalid-name", + TargetSkillName: name, + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "bad name", + BodyOrPatch: "body", + }) + + if result.Status != evolution.DraftStatusQuarantined { + t.Fatalf("TargetSkillName %q status = %q, want %q", name, result.Status, evolution.DraftStatusQuarantined) + } + if len(result.Findings) == 0 { + t.Fatalf("TargetSkillName %q expected findings", name) + } + } +} diff --git a/pkg/evolution/drafts.go b/pkg/evolution/drafts.go new file mode 100644 index 000000000..d8aaf8348 --- /dev/null +++ b/pkg/evolution/drafts.go @@ -0,0 +1,249 @@ +package evolution + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type DraftGenerator interface { + GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) +} + +func ValidateDraft(draft SkillDraft) []string { + findings := make([]string, 0, 5) + + if strings.TrimSpace(draft.TargetSkillName) == "" { + findings = append(findings, "target_skill_name is required") + } else if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil { + findings = append(findings, "target_skill_name is invalid: "+err.Error()) + } + if strings.TrimSpace(draft.HumanSummary) == "" { + findings = append(findings, "human_summary is required") + } + if strings.TrimSpace(draft.BodyOrPatch) == "" { + findings = append(findings, "body_or_patch is required") + } + + switch draft.DraftType { + case DraftTypeWorkflow, DraftTypeShortcut: + default: + findings = append(findings, "draft_type is invalid") + } + + switch draft.ChangeKind { + case ChangeKindCreate, ChangeKindAppend, ChangeKindReplace, ChangeKindMerge: + default: + findings = append(findings, "change_kind is invalid") + } + + return findings +} + +type DefaultDraftGenerator struct { + loader *skills.SkillsLoader +} + +func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator { + builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) + if builtinSkillsDir == "" { + wd, _ := os.Getwd() + builtinSkillsDir = filepath.Join(wd, "skills") + } + + globalSkillsDir := filepath.Join(config.GetHome(), "skills") + return &DefaultDraftGenerator{ + loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), + } +} + +func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) { + target := inferTargetSkillName(rule, matches) + if target == "" { + target = "learned-skill" + } + + _, hasExisting, err := g.loadBaseSkillContent(target, matches) + if err != nil { + return SkillDraft{}, err + } + + draftType := DraftTypeWorkflow + if len(rule.WinningPath) <= 1 { + draftType = DraftTypeShortcut + } + + changeKind := ChangeKindCreate + body := g.buildNewSkillBody(target, rule) + if hasExisting { + changeKind = ChangeKindAppend + body = g.buildAppendBody(rule) + } + + return SkillDraft{ + TargetSkillName: target, + DraftType: draftType, + ChangeKind: changeKind, + HumanSummary: g.buildHumanSummary(target, rule, hasExisting), + IntendedUseCases: inferIntendedUseCases(rule), + PreferredEntryPath: inferPreferredEntryPath(rule), + AvoidPatterns: inferAvoidPatterns(rule), + BodyOrPatch: body, + }, nil +} + +func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string { + if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" { + return strings.TrimSpace(matches[0].Name) + } + if len(rule.LateAddedSkills) > 0 && strings.TrimSpace(rule.LateAddedSkills[0]) != "" { + return strings.TrimSpace(rule.LateAddedSkills[0]) + } + if len(rule.WinningPath) > 0 && strings.TrimSpace(rule.WinningPath[0]) != "" { + return strings.TrimSpace(rule.WinningPath[0]) + } + if len(rule.MatchedSkillNames) > 0 && strings.TrimSpace(rule.MatchedSkillNames[0]) != "" { + return strings.TrimSpace(rule.MatchedSkillNames[0]) + } + + tokens := tokenizeForEvolution(rule.Summary) + if len(tokens) > 0 { + return tokens[0] + } + return "" +} + +func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) { + for _, match := range matches { + if match.Name != target || strings.TrimSpace(match.Path) == "" { + continue + } + data, err := os.ReadFile(match.Path) + if err != nil { + return "", false, err + } + return string(data), true, nil + } + + if g.loader == nil { + return "", false, nil + } + content, ok := g.loader.LoadSkill(target) + if !ok { + return "", false, nil + } + description := fmt.Sprintf("Learned workflow for %s.", target) + return buildSkillDocument(target, description, content), true, nil +} + +func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRecord, hasExisting bool) string { + if hasExisting { + return fmt.Sprintf("Refresh %s with learned pattern: %s", target, rule.Summary) + } + return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary) +} + +func (g *DefaultDraftGenerator) buildNewSkillBody(target string, rule LearningRecord) string { + description := fmt.Sprintf("Learned workflow for %s.", target) + body := strings.Join([]string{ + "# " + titleCaseSkillName(target), + "", + "## Start Here", + g.startHereLine(rule), + "", + "## When To Use", + fmt.Sprintf("Use this skill when the task matches `%s`.", strings.TrimSpace(rule.Summary)), + "", + "## Learned Pattern", + g.learnedPatternLine(rule), + "", + "## Winning Path", + g.winningPathLine(rule), + "", + "## Evidence", + g.evidenceLine(rule), + }, "\n") + return buildSkillDocument(target, description, body) +} + +func (g *DefaultDraftGenerator) buildAppendBody(rule LearningRecord) string { + return strings.Join([]string{ + "## Learned Evolution", + fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)), + fmt.Sprintf("- Learned pattern: %s", g.learnedPatternLine(rule)), + fmt.Sprintf("- Winning path: %s", g.winningPathLine(rule)), + fmt.Sprintf("- Evidence: %s", g.evidenceLine(rule)), + "", + }, "\n") +} + +func buildSkillDocument(name, description, body string) string { + return strings.Join([]string{ + "---", + "name: " + strings.TrimSpace(name), + "description: " + strings.TrimSpace(description), + "---", + "", + strings.TrimSpace(body), + "", + }, "\n") +} + +func titleCaseSkillName(name string) string { + parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == ' ' }) + for i, part := range parts { + if part == "" { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + if len(parts) == 0 { + return "Learned Skill" + } + return strings.Join(parts, " ") +} + +func (g *DefaultDraftGenerator) startHereLine(rule LearningRecord) string { + if len(rule.WinningPath) > 0 { + return fmt.Sprintf("Start with `%s` before trying other paths.", strings.Join(rule.WinningPath, " -> ")) + } + return fmt.Sprintf("Start from the learned path for `%s`.", strings.TrimSpace(rule.Summary)) +} + +func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string { + if len(rule.LateAddedSkills) > 0 { + return fmt.Sprintf( + "Late-added skill `%s` was repeatedly introduced immediately before success%s.", + strings.Join(rule.LateAddedSkills, " -> "), + triggerSuffix(rule.FinalSnapshotTrigger), + ) + } + if len(rule.WinningPath) > 0 { + return fmt.Sprintf("Prefer `%s` because it was the most reliable recent path.", strings.Join(rule.WinningPath, " -> ")) + } + return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary)) +} + +func (g *DefaultDraftGenerator) winningPathLine(rule LearningRecord) string { + if len(rule.WinningPath) == 0 { + return "No explicit winning path was recorded." + } + return strings.Join(rule.WinningPath, " -> ") +} + +func (g *DefaultDraftGenerator) evidenceLine(rule LearningRecord) string { + return fmt.Sprintf("%d cases, success rate %.2f", rule.EventCount, rule.SuccessRate) +} + +func triggerSuffix(trigger string) string { + trigger = strings.TrimSpace(trigger) + if trigger == "" { + return "" + } + return fmt.Sprintf(" during `%s`", trigger) +} diff --git a/pkg/evolution/drafts_test.go b/pkg/evolution/drafts_test.go new file mode 100644 index 000000000..586967530 --- /dev/null +++ b/pkg/evolution/drafts_test.go @@ -0,0 +1,110 @@ +package evolution_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/evolution" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" +) + +func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *testing.T) { + generator := evolution.NewDefaultDraftGenerator(t.TempDir()) + + draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ + Summary: "weather native-name path", + WinningPath: []string{"geocode", "weather"}, + LateAddedSkills: []string{"weather"}, + FinalSnapshotTrigger: "context_retry_rebuild", + EventCount: 4, + SuccessRate: 1, + }, nil) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + if draft.TargetSkillName != "weather" { + t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName) + } + if !strings.Contains(draft.BodyOrPatch, "Late-added skill") { + t.Fatalf("BodyOrPatch = %q, want late-added skill guidance", draft.BodyOrPatch) + } +} + +func TestDefaultDraftGenerator_UsesAppendWhenExtendingExistingSkill(t *testing.T) { + workspace := t.TempDir() + generator := evolution.NewDefaultDraftGenerator(workspace) + + existingPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(existingPath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + existing := "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names.\n" + if err := os.WriteFile(existingPath, []byte(existing), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ + Summary: "weather native-name path", + WinningPath: []string{"weather"}, + EventCount: 4, + SuccessRate: 1, + }, []skills.SkillInfo{ + {Name: "weather", Path: existingPath, Source: "workspace", Description: "Weather helper"}, + }) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + if draft.ChangeKind != evolution.ChangeKindAppend { + t.Fatalf("ChangeKind = %q, want append", draft.ChangeKind) + } + if strings.Contains(draft.BodyOrPatch, "---\nname: weather") { + t.Fatalf("BodyOrPatch should contain only appended section, got full document:\n%s", draft.BodyOrPatch) + } + if !strings.Contains(draft.BodyOrPatch, "## Learned Evolution") { + t.Fatalf("BodyOrPatch = %q, want learned evolution section", draft.BodyOrPatch) + } + if len(draft.IntendedUseCases) != 1 || draft.IntendedUseCases[0] != "weather native-name path" { + t.Fatalf("IntendedUseCases = %v, want [weather native-name path]", draft.IntendedUseCases) + } + if len(draft.PreferredEntryPath) != 1 || draft.PreferredEntryPath[0] != "weather" { + t.Fatalf("PreferredEntryPath = %v, want [weather]", draft.PreferredEntryPath) + } +} + +func TestLLMDraftGenerator_BuildPromptIncludesLateAddedSkillHint(t *testing.T) { + provider := &llmDraftTestProvider{ + defaultModel: "test-model", + response: &providers.LLMResponse{ + Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`, + }, + } + generator := evolution.NewLLMDraftGenerator(provider, "", &recordingDraftGenerator{}) + + _, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ + ID: "rule-1", + Summary: "weather native-name path", + EventCount: 7, + SuccessRate: 0.86, + WinningPath: []string{"geocode", "weather"}, + MatchedSkillNames: []string{"weather"}, + LateAddedSkills: []string{"weather"}, + FinalSnapshotTrigger: "context_retry_rebuild", + }, []skills.SkillInfo{ + {Name: "weather", Path: "/tmp/weather/SKILL.md", Source: "workspace", Description: "Find weather details."}, + }) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + + prompt := provider.lastMessages[1].Content + if !strings.Contains(prompt, "Late-added successful skills: weather") { + t.Fatalf("prompt missing late-added skill hint:\n%s", prompt) + } + if !strings.Contains(prompt, "Final snapshot trigger: context_retry_rebuild") { + t.Fatalf("prompt missing final snapshot trigger:\n%s", prompt) + } +} diff --git a/pkg/evolution/generator_factory.go b/pkg/evolution/generator_factory.go new file mode 100644 index 000000000..1baafae79 --- /dev/null +++ b/pkg/evolution/generator_factory.go @@ -0,0 +1,11 @@ +package evolution + +import "github.com/sipeed/picoclaw/pkg/providers" + +func NewDraftGeneratorForWorkspace(workspace string, provider providers.LLMProvider, modelID string) DraftGenerator { + fallback := NewDefaultDraftGenerator(workspace) + if provider == nil { + return fallback + } + return NewLLMDraftGenerator(provider, modelID, fallback) +} diff --git a/pkg/evolution/lifecycle.go b/pkg/evolution/lifecycle.go new file mode 100644 index 000000000..87628c02c --- /dev/null +++ b/pkg/evolution/lifecycle.go @@ -0,0 +1,71 @@ +package evolution + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/sipeed/picoclaw/pkg/skills" +) + +func NextLifecycleState(profile SkillProfile, now time.Time) SkillStatus { + if profile.Origin == "manual" || profile.LastUsedAt.IsZero() { + return profile.Status + } + + idle := now.Sub(profile.LastUsedAt) + switch profile.Status { + case SkillStatusActive: + if idle > 90*24*time.Hour && profile.RetentionScore < 0.3 { + return SkillStatusCold + } + case SkillStatusCold: + if idle > 180*24*time.Hour && profile.RetentionScore < 0.2 { + return SkillStatusArchived + } + case SkillStatusArchived: + if idle > 365*24*time.Hour && profile.RetentionScore < 0.1 { + return SkillStatusDeleted + } + } + + return profile.Status +} + +func ApplyLifecycleState(paths Paths, profile SkillProfile, next SkillStatus) error { + if next != SkillStatusDeleted { + return nil + } + + workspace := profile.WorkspaceID + if workspace == "" { + workspace = inferWorkspaceFromPaths(paths) + } + if workspace == "" { + return fmt.Errorf("resolve lifecycle delete workspace for skill %q: workspace is required", profile.SkillName) + } + if err := skills.ValidateSkillName(profile.SkillName); err != nil { + return fmt.Errorf("resolve lifecycle delete skill name: %w", err) + } + + skillPath := filepath.Join(workspace, "skills", profile.SkillName, "SKILL.md") + err := os.Remove(skillPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func inferWorkspaceFromPaths(paths 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/pkg/evolution/lifecycle_actions_test.go b/pkg/evolution/lifecycle_actions_test.go new file mode 100644 index 000000000..8bd65d87d --- /dev/null +++ b/pkg/evolution/lifecycle_actions_test.go @@ -0,0 +1,72 @@ +package evolution_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestApplyLifecycleStateDeletedRemovesSkillFile(t *testing.T) { + workspace := t.TempDir() + 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") + if err := os.WriteFile(skillPath, []byte("# weather\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + err := evolution.ApplyLifecycleState( + evolution.NewPaths(workspace, ""), + evolution.SkillProfile{SkillName: "weather"}, + evolution.SkillStatusDeleted, + ) + if err != nil { + t.Fatalf("ApplyLifecycleState: %v", err) + } + + if _, err := os.Stat(skillPath); !os.IsNotExist(err) { + t.Fatalf("skill file should be removed, stat err = %v", err) + } +} + +func TestApplyLifecycleStateDeletedRequiresResolvedWorkspace(t *testing.T) { + err := evolution.ApplyLifecycleState( + evolution.Paths{RootDir: filepath.Join(t.TempDir(), "shared-evolution")}, + evolution.SkillProfile{SkillName: "weather"}, + evolution.SkillStatusDeleted, + ) + if err == nil { + t.Fatal("expected error when workspace cannot be resolved") + } +} + +func TestApplyLifecycleStateDeletedRequiresSkillName(t *testing.T) { + workspace := t.TempDir() + + err := evolution.ApplyLifecycleState( + evolution.NewPaths(workspace, ""), + evolution.SkillProfile{WorkspaceID: workspace}, + evolution.SkillStatusDeleted, + ) + if err == nil { + t.Fatal("expected error when skill name is empty") + } +} + +func TestApplyLifecycleStateDeletedRejectsTraversalSkillName(t *testing.T) { + workspace := t.TempDir() + + err := evolution.ApplyLifecycleState( + evolution.NewPaths(workspace, ""), + evolution.SkillProfile{WorkspaceID: workspace, SkillName: "../escape"}, + evolution.SkillStatusDeleted, + ) + if err == nil { + t.Fatal("expected error for traversal skill name") + } +} diff --git a/pkg/evolution/lifecycle_test.go b/pkg/evolution/lifecycle_test.go new file mode 100644 index 000000000..e1c4f6615 --- /dev/null +++ b/pkg/evolution/lifecycle_test.go @@ -0,0 +1,170 @@ +package evolution_test + +import ( + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestStore_SaveAndLoadProfile(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + + profile := evolution.SkillProfile{ + SkillName: "weather", + WorkspaceID: root, + CurrentVersion: "v2", + Status: evolution.SkillStatusActive, + Origin: "evolved", + HumanSummary: "weather lookup helper", + LastUsedAt: time.Unix(1700000000, 0).UTC(), + UseCount: 3, + RetentionScore: 0.8, + VersionHistory: []evolution.SkillVersionEntry{ + { + Version: "v1", + Action: "create", + Timestamp: time.Unix(1699990000, 0).UTC(), + Summary: "initial learned version", + }, + }, + } + + if err := store.SaveProfile(profile); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + loaded, err := store.LoadProfile("weather") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if loaded.SkillName != "weather" { + t.Fatalf("SkillName = %q, want weather", loaded.SkillName) + } + if loaded.Status != evolution.SkillStatusActive { + t.Fatalf("Status = %q, want %q", loaded.Status, evolution.SkillStatusActive) + } + if len(loaded.VersionHistory) != 1 { + t.Fatalf("len(VersionHistory) = %d, want 1", len(loaded.VersionHistory)) + } +} + +func TestNextLifecycleState_ActiveToCold(t *testing.T) { + now := time.Now().UTC() + profile := evolution.SkillProfile{ + SkillName: "release-flow", + Status: evolution.SkillStatusActive, + Origin: "evolved", + LastUsedAt: now.AddDate(0, -6, 0), + RetentionScore: 0.1, + } + + got := evolution.NextLifecycleState(profile, now) + if got != evolution.SkillStatusCold { + t.Fatalf("NextLifecycleState = %q, want %q", got, evolution.SkillStatusCold) + } +} + +func TestNextLifecycleState_ManualSkillStaysActive(t *testing.T) { + now := time.Now().UTC() + profile := evolution.SkillProfile{ + SkillName: "manual-weather", + Status: evolution.SkillStatusActive, + Origin: "manual", + LastUsedAt: now.AddDate(-1, 0, 0), + RetentionScore: 0, + } + + got := evolution.NextLifecycleState(profile, now) + if got != evolution.SkillStatusActive { + t.Fatalf("NextLifecycleState = %q, want %q", got, evolution.SkillStatusActive) + } +} + +func TestStore_SaveProfileRejectsInvalidSkillName(t *testing.T) { + store := evolution.NewStore(evolution.NewPaths(t.TempDir(), "")) + + err := store.SaveProfile(evolution.SkillProfile{SkillName: "../escape"}) + if err == nil { + t.Fatal("expected SaveProfile to reject invalid skill name") + } +} + +func TestStore_LoadProfileRejectsInvalidSkillName(t *testing.T) { + store := evolution.NewStore(evolution.NewPaths(t.TempDir(), "")) + + _, err := store.LoadProfile("/tmp/escape") + if err == nil { + t.Fatal("expected LoadProfile to reject invalid skill name") + } +} + +func TestStore_SharedStateProfilesRemainIsolatedPerWorkspace(t *testing.T) { + sharedState := t.TempDir() + workspaceA := t.TempDir() + workspaceB := t.TempDir() + + storeA := evolution.NewStore(evolution.NewPaths(workspaceA, sharedState)) + storeB := evolution.NewStore(evolution.NewPaths(workspaceB, sharedState)) + + profileA := evolution.SkillProfile{ + SkillName: "weather", + WorkspaceID: workspaceA, + CurrentVersion: "v-a", + Status: evolution.SkillStatusActive, + Origin: "evolved", + HumanSummary: "workspace A weather helper", + LastUsedAt: time.Unix(1700000000, 0).UTC(), + UseCount: 2, + RetentionScore: 0.6, + } + profileB := evolution.SkillProfile{ + SkillName: "weather", + WorkspaceID: workspaceB, + CurrentVersion: "v-b", + Status: evolution.SkillStatusCold, + Origin: "manual", + HumanSummary: "workspace B weather helper", + LastUsedAt: time.Unix(1700000500, 0).UTC(), + UseCount: 9, + RetentionScore: 0.2, + } + + if err := storeA.SaveProfile(profileA); err != nil { + t.Fatalf("storeA.SaveProfile: %v", err) + } + if err := storeB.SaveProfile(profileB); err != nil { + t.Fatalf("storeB.SaveProfile: %v", err) + } + + loadedA, err := storeA.LoadProfile("weather") + if err != nil { + t.Fatalf("storeA.LoadProfile: %v", err) + } + if loadedA.WorkspaceID != workspaceA { + t.Fatalf("storeA workspace = %q, want %q", loadedA.WorkspaceID, workspaceA) + } + if loadedA.CurrentVersion != "v-a" { + t.Fatalf("storeA CurrentVersion = %q, want v-a", loadedA.CurrentVersion) + } + + loadedB, err := storeB.LoadProfile("weather") + if err != nil { + t.Fatalf("storeB.LoadProfile: %v", err) + } + if loadedB.WorkspaceID != workspaceB { + t.Fatalf("storeB workspace = %q, want %q", loadedB.WorkspaceID, workspaceB) + } + if loadedB.CurrentVersion != "v-b" { + t.Fatalf("storeB CurrentVersion = %q, want v-b", loadedB.CurrentVersion) + } + + allProfiles, err := storeA.LoadProfiles() + if err != nil { + t.Fatalf("LoadProfiles: %v", err) + } + if len(allProfiles) != 2 { + t.Fatalf("len(LoadProfiles()) = %d, want 2", len(allProfiles)) + } +} diff --git a/pkg/evolution/llm_draft_generator.go b/pkg/evolution/llm_draft_generator.go new file mode 100644 index 000000000..d04cacacb --- /dev/null +++ b/pkg/evolution/llm_draft_generator.go @@ -0,0 +1,174 @@ +package evolution + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type LLMDraftGenerator struct { + provider providers.LLMProvider + model string + fallback DraftGenerator +} + +type llmDraftResponse struct { + TargetSkillName string `json:"target_skill_name"` + DraftType string `json:"draft_type"` + ChangeKind string `json:"change_kind"` + HumanSummary string `json:"human_summary"` + IntendedUseCases []string `json:"intended_use_cases"` + PreferredEntryPath []string `json:"preferred_entry_path"` + AvoidPatterns []string `json:"avoid_patterns"` + BodyOrPatch string `json:"body_or_patch"` +} + +func NewLLMDraftGenerator(provider providers.LLMProvider, model string, fallback DraftGenerator) *LLMDraftGenerator { + return &LLMDraftGenerator{ + provider: provider, + model: strings.TrimSpace(model), + fallback: fallback, + } +} + +func (g *LLMDraftGenerator) GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) { + if g == nil || g.provider == nil { + return g.generateFallback(ctx, rule, matches) + } + + model := g.model + if model == "" { + model = strings.TrimSpace(g.provider.GetDefaultModel()) + } + if model == "" { + return g.generateFallback(ctx, rule, matches) + } + + resp, err := g.provider.Chat(ctx, []providers.Message{ + { + Role: "system", + Content: "Return exactly one JSON object for a skill draft. Do not use markdown fences.", + }, + { + Role: "user", + Content: g.buildPrompt(rule, matches), + }, + }, nil, model, map[string]any{"temperature": 0.2}) + if err != nil || resp == nil { + return g.generateFallback(ctx, rule, matches) + } + + content := strings.TrimSpace(resp.Content) + if content == "" { + return g.generateFallback(ctx, rule, matches) + } + + draft, ok := parseLLMDraft(content) + if !ok || len(ValidateDraft(draft)) > 0 { + return g.generateFallback(ctx, rule, matches) + } + + return draft, nil +} + +func (g *LLMDraftGenerator) generateFallback( + ctx context.Context, + rule LearningRecord, + matches []skills.SkillInfo, +) (SkillDraft, error) { + if g == nil || g.fallback == nil { + return SkillDraft{}, nil + } + return g.fallback.GenerateDraft(ctx, rule, matches) +} + +func (g *LLMDraftGenerator) buildPrompt(rule LearningRecord, matches []skills.SkillInfo) string { + return strings.Join([]string{ + "Generate a skill draft JSON object with these required string fields:", + `target_skill_name, draft_type, change_kind, human_summary, body_or_patch.`, + "Optional array fields: intended_use_cases, preferred_entry_path, avoid_patterns.", + "", + "Allowed values:", + "- draft_type: workflow | shortcut", + "- change_kind: create | append | replace | merge", + "", + "Rule summary: " + strings.TrimSpace(rule.Summary), + "Winning path: " + joinOrFallback(rule.WinningPath, "none"), + "Late-added successful skills: " + joinOrFallback(rule.LateAddedSkills, "none"), + "Final snapshot trigger: " + fallbackString(rule.FinalSnapshotTrigger, "none"), + fmt.Sprintf("Event count: %d", rule.EventCount), + fmt.Sprintf("Success rate: %.2f", rule.SuccessRate), + "Matched skill refs: " + summarizeSkillMatches(matches), + "Matched skill names: " + joinOrFallback(rule.MatchedSkillNames, "none"), + "", + "body_or_patch should contain the full draft body or patch content as plain text.", + }, "\n") +} + +func parseLLMDraft(content string) (SkillDraft, bool) { + normalized := strings.TrimSpace(content) + normalized = strings.TrimPrefix(normalized, "```json") + normalized = strings.TrimPrefix(normalized, "```") + normalized = strings.TrimSuffix(normalized, "```") + normalized = strings.TrimSpace(normalized) + + var payload llmDraftResponse + if err := json.Unmarshal([]byte(normalized), &payload); err != nil { + return SkillDraft{}, false + } + + draft := SkillDraft{ + TargetSkillName: strings.TrimSpace(payload.TargetSkillName), + DraftType: DraftType(strings.TrimSpace(payload.DraftType)), + ChangeKind: ChangeKind(strings.TrimSpace(payload.ChangeKind)), + HumanSummary: strings.TrimSpace(payload.HumanSummary), + IntendedUseCases: append([]string(nil), payload.IntendedUseCases...), + PreferredEntryPath: append([]string(nil), payload.PreferredEntryPath...), + AvoidPatterns: append([]string(nil), payload.AvoidPatterns...), + BodyOrPatch: strings.TrimSpace(payload.BodyOrPatch), + } + return draft, true +} + +func summarizeSkillMatches(matches []skills.SkillInfo) string { + if len(matches) == 0 { + return "none" + } + + parts := make([]string, 0, len(matches)) + for _, match := range matches { + part := strings.TrimSpace(match.Name) + if desc := strings.TrimSpace(match.Description); desc != "" { + part += ": " + desc + } + if path := strings.TrimSpace(match.Path); path != "" { + part += " (" + path + ")" + } + if part != "" { + parts = append(parts, part) + } + } + if len(parts) == 0 { + return "none" + } + return strings.Join(parts, "; ") +} + +func joinOrFallback(parts []string, fallback string) string { + if len(parts) == 0 { + return fallback + } + return strings.Join(parts, " -> ") +} + +func fallbackString(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} diff --git a/pkg/evolution/llm_draft_generator_test.go b/pkg/evolution/llm_draft_generator_test.go new file mode 100644 index 000000000..fde769453 --- /dev/null +++ b/pkg/evolution/llm_draft_generator_test.go @@ -0,0 +1,203 @@ +package evolution_test + +import ( + "context" + "errors" + "testing" + + "github.com/sipeed/picoclaw/pkg/evolution" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type recordingDraftGenerator struct { + draft evolution.SkillDraft + err error + calls int +} + +func (g *recordingDraftGenerator) GenerateDraft( + _ context.Context, + _ evolution.LearningRecord, + _ []skills.SkillInfo, +) (evolution.SkillDraft, error) { + g.calls++ + return g.draft, g.err +} + +type llmDraftTestProvider struct { + response *providers.LLMResponse + err error + defaultModel string + lastModel string + lastMessages []providers.Message + chatCallCount int +} + +func (p *llmDraftTestProvider) Chat( + _ context.Context, + messages []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + p.chatCallCount++ + p.lastModel = model + p.lastMessages = append([]providers.Message(nil), messages...) + return p.response, p.err +} + +func (p *llmDraftTestProvider) GetDefaultModel() string { + return p.defaultModel +} + +func testLearningRule() evolution.LearningRecord { + return evolution.LearningRecord{ + ID: "rule-1", + Summary: "weather native-name path", + EventCount: 7, + SuccessRate: 0.86, + WinningPath: []string{"weather", "native-name"}, + MatchedSkillNames: []string{"weather"}, + } +} + +func testSkillMatches() []skills.SkillInfo { + return []skills.SkillInfo{ + { + Name: "weather", + Path: "/tmp/weather/SKILL.md", + Source: "workspace", + Description: "Find weather details.", + }, + } +} + +func TestLLMDraftGenerator_GenerateDraft_ParsesJSONResponse(t *testing.T) { + provider := &llmDraftTestProvider{ + defaultModel: "test-model", + response: &providers.LLMResponse{ + Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`, + }, + } + fallback := &recordingDraftGenerator{ + draft: evolution.SkillDraft{TargetSkillName: "fallback"}, + } + generator := evolution.NewLLMDraftGenerator(provider, "", fallback) + + draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches()) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + + if provider.chatCallCount != 1 { + t.Fatalf("chatCallCount = %d, want 1", provider.chatCallCount) + } + if provider.lastModel != "test-model" { + t.Fatalf("lastModel = %q, want test-model", provider.lastModel) + } + if len(provider.lastMessages) == 0 { + t.Fatal("expected prompt messages") + } + if fallback.calls != 0 { + t.Fatalf("fallback.calls = %d, want 0", fallback.calls) + } + if draft.TargetSkillName != "weather" { + t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName) + } + if draft.DraftType != evolution.DraftTypeShortcut { + t.Fatalf("DraftType = %q, want %q", draft.DraftType, evolution.DraftTypeShortcut) + } + if draft.ChangeKind != evolution.ChangeKindAppend { + t.Fatalf("ChangeKind = %q, want %q", draft.ChangeKind, evolution.ChangeKindAppend) + } + if draft.HumanSummary == "" || draft.BodyOrPatch == "" { + t.Fatal("expected non-empty draft content") + } +} + +func TestLLMDraftGenerator_GenerateDraft_PrefersExplicitModelIDOverProviderDefault(t *testing.T) { + provider := &llmDraftTestProvider{ + defaultModel: "provider-default-model", + response: &providers.LLMResponse{ + Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`, + }, + } + generator := evolution.NewLLMDraftGenerator(provider, "explicit-model-id", &recordingDraftGenerator{}) + + _, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches()) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + if provider.lastModel != "explicit-model-id" { + t.Fatalf("lastModel = %q, want explicit-model-id", provider.lastModel) + } +} + +func TestLLMDraftGenerator_GenerateDraft_FallsBackOnProviderError(t *testing.T) { + fallback := &recordingDraftGenerator{ + draft: evolution.SkillDraft{ + TargetSkillName: "weather-fallback", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "fallback summary", + BodyOrPatch: "fallback body", + }, + } + generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{ + defaultModel: "test-model", + err: errors.New("provider unavailable"), + }, "", fallback) + + draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches()) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + + if fallback.calls != 1 { + t.Fatalf("fallback.calls = %d, want 1", fallback.calls) + } + if draft.TargetSkillName != "weather-fallback" { + t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName) + } +} + +func TestLLMDraftGenerator_GenerateDraft_FallsBackOnInvalidOrEmptyContent(t *testing.T) { + testCases := []struct { + name string + content string + }{ + {name: "invalid json", content: `not-json`}, + {name: "empty content", content: ``}, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + fallback := &recordingDraftGenerator{ + draft: evolution.SkillDraft{ + TargetSkillName: "weather-fallback", + DraftType: evolution.DraftTypeWorkflow, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "fallback summary", + BodyOrPatch: "fallback body", + }, + } + generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{ + defaultModel: "test-model", + response: &providers.LLMResponse{Content: tt.content}, + }, "", fallback) + + draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches()) + if err != nil { + t.Fatalf("GenerateDraft: %v", err) + } + + if fallback.calls != 1 { + t.Fatalf("fallback.calls = %d, want 1", fallback.calls) + } + if draft.TargetSkillName != "weather-fallback" { + t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName) + } + }) + } +} diff --git a/pkg/evolution/organizer.go b/pkg/evolution/organizer.go new file mode 100644 index 000000000..6fea31b36 --- /dev/null +++ b/pkg/evolution/organizer.go @@ -0,0 +1,367 @@ +package evolution + +import ( + "crypto/sha1" + "encoding/hex" + "sort" + "strings" + "time" +) + +type OrganizerOptions struct { + MinCaseCount int + MinSuccessRate float64 + Now func() time.Time +} + +type Organizer struct { + minCaseCount int + minSuccessRate float64 + now func() time.Time +} + +func NewOrganizer(opts OrganizerOptions) *Organizer { + now := opts.Now + if now == nil { + now = time.Now + } + + minCaseCount := opts.MinCaseCount + if minCaseCount <= 0 { + minCaseCount = 3 + } + + minSuccessRate := opts.MinSuccessRate + if minSuccessRate <= 0 { + minSuccessRate = 0.7 + } + + return &Organizer{ + minCaseCount: minCaseCount, + minSuccessRate: minSuccessRate, + now: now, + } +} + +func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, error) { + clusters := make(map[string][]LearningRecord) + keys := make([]string, 0) + + for _, record := range records { + if !isTaskRecordKind(record.Kind) { + continue + } + + key := normalizeRuleKey(record) + if key == "" { + continue + } + + clusterKey := record.WorkspaceID + "\x00" + key + if _, ok := clusters[clusterKey]; !ok { + keys = append(keys, clusterKey) + } + clusters[clusterKey] = append(clusters[clusterKey], record) + } + + sort.Strings(keys) + + rules := make([]LearningRecord, 0, len(keys)) + for _, clusterKey := range keys { + cluster := append([]LearningRecord(nil), clusters[clusterKey]...) + sortCaseCluster(cluster) + + if len(cluster) < o.minCaseCount { + continue + } + + successRate := clusterSuccessRate(cluster) + if successRate < o.minSuccessRate { + continue + } + + ruleKey := clusterKey[strings.Index(clusterKey, "\x00")+1:] + winningPath := clusterWinningPath(cluster) + lateAddedSkills, finalSnapshotTrigger := clusterLateAddedSkills(cluster, winningPath) + matchedSkillNames := append([]string(nil), winningPath...) + + rules = append(rules, LearningRecord{ + ID: stableRuleID(cluster[0].WorkspaceID, ruleKey), + Kind: RecordKindPattern, + WorkspaceID: cluster[0].WorkspaceID, + CreatedAt: o.now(), + Summary: buildRuleSummary(ruleKey, winningPath), + Source: map[string]any{"cluster_key": ruleKey}, + Status: RecordStatus("ready"), + SourceRecordIDs: collectRecordIDs(cluster), + EventCount: len(cluster), + SuccessRate: successRate, + MaturityScore: computeMaturityScore(len(cluster), successRate), + WinningPath: winningPath, + LateAddedSkills: lateAddedSkills, + FinalSnapshotTrigger: finalSnapshotTrigger, + MatchedSkillNames: matchedSkillNames, + }) + } + + return rules, nil +} + +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 { + return strings.Join(path, " ") + } + if path := normalizePath(record.ToolKinds); len(path) > 0 { + return strings.Join(path, " ") + } + + tokens := tokenizeForEvolution(record.Summary) + if len(tokens) == 0 { + return "" + } + if len(tokens) > 6 { + tokens = tokens[:6] + } + return strings.Join(tokens, " ") +} + +func normalizePath(values []string) []string { + if len(values) == 0 { + return nil + } + + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + out = append(out, value) + } + if len(out) == 0 { + return nil + } + return out +} + +func normalizeFinalSuccessfulPath(record LearningRecord) []string { + if record.AttemptTrail == nil { + return nil + } + return normalizePath(record.AttemptTrail.FinalSuccessfulPath) +} + +func normalizeAttemptedSkills(record LearningRecord) []string { + if record.AttemptTrail == nil { + return nil + } + return normalizePath(record.AttemptTrail.AttemptedSkills) +} + +func sortCaseCluster(cluster []LearningRecord) { + sort.Slice(cluster, func(i, j int) bool { + if !cluster[i].CreatedAt.Equal(cluster[j].CreatedAt) { + return cluster[i].CreatedAt.Before(cluster[j].CreatedAt) + } + return cluster[i].ID < cluster[j].ID + }) +} + +func clusterSuccessRate(cluster []LearningRecord) float64 { + if len(cluster) == 0 { + return 0 + } + + successes := 0 + for _, record := range cluster { + if record.Success != nil && *record.Success { + successes++ + } + } + return float64(successes) / float64(len(cluster)) +} + +func clusterWinningPath(cluster []LearningRecord) []string { + type pathScore struct { + path []string + count int + } + + bestKey := "" + best := pathScore{} + paths := make(map[string]pathScore) + 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) + } + if len(path) == 0 { + path = normalizePath(record.ToolKinds) + } + if len(path) == 0 { + continue + } + + key := strings.Join(path, "\x00") + score := paths[key] + if score.path == nil { + score.path = append([]string(nil), path...) + order = append(order, key) + } + score.count++ + paths[key] = score + } + + for _, key := range order { + score := paths[key] + if score.count > best.count { + best = score + bestKey = key + } + } + + if bestKey == "" { + return nil + } + return best.path +} + +func clusterLateAddedSkills(cluster []LearningRecord, winningPath []string) ([]string, string) { + type lateAddedScore struct { + skills []string + trigger string + count int + } + + bestKey := "" + best := lateAddedScore{} + scores := make(map[string]lateAddedScore) + order := make([]string, 0) + + for _, record := range cluster { + skills, trigger := lateAddedSkillsFromRecord(record) + if len(skills) == 0 { + continue + } + if len(winningPath) > 0 && !pathsEqual(skills, tailAddedWithinWinningPath(winningPath, skills)) { + continue + } + + key := trigger + "\x00" + strings.Join(skills, "\x00") + score := scores[key] + if score.skills == nil { + score.skills = append([]string(nil), skills...) + score.trigger = trigger + order = append(order, key) + } + score.count++ + scores[key] = score + } + + for _, key := range order { + score := scores[key] + if score.count > best.count { + bestKey = key + best = score + } + } + + if bestKey == "" { + return nil, "" + } + return best.skills, best.trigger +} + +func lateAddedSkillsFromRecord(record LearningRecord) ([]string, string) { + if record.AttemptTrail == nil || len(record.AttemptTrail.SkillContextSnapshots) == 0 { + return nil, "" + } + + snapshots := record.AttemptTrail.SkillContextSnapshots + last := snapshots[len(snapshots)-1] + if len(last.SkillNames) == 0 { + return nil, "" + } + if len(snapshots) == 1 { + return nil, strings.TrimSpace(last.Trigger) + } + + prev := snapshots[len(snapshots)-2] + prevSet := make(map[string]struct{}, len(prev.SkillNames)) + for _, skill := range normalizePath(prev.SkillNames) { + prevSet[skill] = struct{}{} + } + + added := make([]string, 0, len(last.SkillNames)) + for _, skill := range normalizePath(last.SkillNames) { + if _, ok := prevSet[skill]; ok { + continue + } + added = append(added, skill) + } + if len(added) == 0 { + return nil, strings.TrimSpace(last.Trigger) + } + return added, strings.TrimSpace(last.Trigger) +} + +func tailAddedWithinWinningPath(winningPath, lateAdded []string) []string { + if len(winningPath) == 0 || len(lateAdded) == 0 || len(lateAdded) > len(winningPath) { + return nil + } + tail := winningPath[len(winningPath)-len(lateAdded):] + if !pathsEqual(tail, lateAdded) { + return nil + } + return tail +} + +func pathsEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func collectRecordIDs(cluster []LearningRecord) []string { + ids := make([]string, 0, len(cluster)) + for _, record := range cluster { + ids = append(ids, record.ID) + } + return ids +} + +func computeMaturityScore(caseCount int, successRate float64) float64 { + return float64(caseCount) * successRate +} + +func stableRuleID(workspaceID, key string) string { + sum := sha1.Sum([]byte(workspaceID + "\x00" + key)) + return "rule-" + hex.EncodeToString(sum[:6]) +} + +func buildRuleSummary(key string, winningPath []string) string { + if len(winningPath) > 0 { + return strings.Join(winningPath, " -> ") + } + return key +} diff --git a/pkg/evolution/organizer_test.go b/pkg/evolution/organizer_test.go new file mode 100644 index 000000000..35c7d98fe --- /dev/null +++ b/pkg/evolution/organizer_test.go @@ -0,0 +1,245 @@ +package evolution_test + +import ( + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestOrganizer_BuildRulesCreatesRuleRecord(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", + Status: evolution.RecordStatus("new"), + Success: &ok, + ActiveSkillNames: []string{"weather"}, + }, + { + ID: "case-2", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "weather beijing", + Status: evolution.RecordStatus("new"), + Success: &ok, + ActiveSkillNames: []string{"weather"}, + }, + { + ID: "case-3", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000200, 0).UTC(), + Summary: "weather hangzhou", + Status: evolution.RecordStatus("new"), + Success: &ok, + ActiveSkillNames: []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)) + } + + rule := rules[0] + if rule.Kind != evolution.RecordKindRule { + t.Fatalf("Kind = %q, want %q", rule.Kind, evolution.RecordKindRule) + } + if rule.EventCount != 3 { + t.Fatalf("EventCount = %d, want 3", rule.EventCount) + } + if len(rule.SourceRecordIDs) != 3 { + t.Fatalf("SourceRecordIDs = %v", rule.SourceRecordIDs) + } + if rule.MaturityScore <= 0 { + t.Fatalf("MaturityScore = %v, want > 0", rule.MaturityScore) + } + if len(rule.WinningPath) != 1 || rule.WinningPath[0] != "weather" { + t.Fatalf("WinningPath = %v, want [weather]", rule.WinningPath) + } +} + +func TestOrganizer_BuildRulesSkipsImmatureCluster(t *testing.T) { + ok := true + cases := []evolution.LearningRecord{ + { + ID: "case-1", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "release build linux", + Status: evolution.RecordStatus("new"), + Success: &ok, + }, + } + + org := evolution.NewOrganizer(evolution.OrganizerOptions{ + MinCaseCount: 3, + MinSuccessRate: 0.7, + }) + + rules, err := org.BuildRules(cases) + if err != nil { + t.Fatalf("BuildRules: %v", err) + } + if len(rules) != 0 { + t.Fatalf("len(rules) = %d, want 0", len(rules)) + } +} + +func TestOrganizer_BuildRulesPrefersFinalSuccessfulPathFromAttemptTrail(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", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"geocode", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + }, + ActiveSkillNames: []string{"geocode", "weather"}, + }, + { + ID: "case-2", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "weather beijing", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"browser", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + }, + ActiveSkillNames: []string{"browser", "weather"}, + }, + { + ID: "case-3", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000200, 0).UTC(), + Summary: "weather hangzhou", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"maps", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + }, + ActiveSkillNames: []string{"maps", "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) + } +} + +func TestOrganizer_BuildRulesCapturesLateAddedSkillHintFromSnapshots(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", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"geocode", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + SkillContextSnapshots: []evolution.SkillContextSnapshot{ + {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}}, + {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, + }, + }, + }, + { + ID: "case-2", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "weather beijing", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"browser", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + SkillContextSnapshots: []evolution.SkillContextSnapshot{ + {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}}, + {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, + }, + }, + }, + { + ID: "case-3", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000200, 0).UTC(), + Summary: "weather hangzhou", + Status: evolution.RecordStatus("new"), + Success: &ok, + AttemptTrail: &evolution.AttemptTrail{ + AttemptedSkills: []string{"maps", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + SkillContextSnapshots: []evolution.SkillContextSnapshot{ + {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}}, + {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "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].LateAddedSkills; len(got) != 1 || got[0] != "weather" { + t.Fatalf("LateAddedSkills = %v, want [weather]", got) + } + if got := rules[0].FinalSnapshotTrigger; got != "context_retry_rebuild" { + t.Fatalf("FinalSnapshotTrigger = %q, want context_retry_rebuild", got) + } +} diff --git a/pkg/evolution/paths.go b/pkg/evolution/paths.go new file mode 100644 index 000000000..64f294a00 --- /dev/null +++ b/pkg/evolution/paths.go @@ -0,0 +1,31 @@ +package evolution + +import ( + "path/filepath" + "strings" +) + +type Paths struct { + Workspace string + RootDir string + LearningRecords string + SkillDrafts string + ProfilesDir string + BackupsDir string +} + +func NewPaths(workspace, override string) Paths { + root := strings.TrimSpace(override) + if root == "" { + root = filepath.Join(workspace, "state", "evolution") + } + + return Paths{ + Workspace: workspace, + RootDir: root, + LearningRecords: filepath.Join(root, "learning-records.jsonl"), + SkillDrafts: filepath.Join(root, "skill-drafts.json"), + ProfilesDir: filepath.Join(root, "profiles"), + BackupsDir: filepath.Join(root, "backups"), + } +} diff --git a/pkg/evolution/paths_test.go b/pkg/evolution/paths_test.go new file mode 100644 index 000000000..17f11f11c --- /dev/null +++ b/pkg/evolution/paths_test.go @@ -0,0 +1,74 @@ +package evolution + +import ( + "path/filepath" + "testing" +) + +func TestNewPaths_DefaultRoot(t *testing.T) { + workspace := "/tmp/workspace" + + paths := NewPaths(workspace, "") + + wantRoot := filepath.Join(workspace, "state", "evolution") + if paths.RootDir != wantRoot { + t.Fatalf("RootDir = %q, want %q", paths.RootDir, wantRoot) + } + if paths.LearningRecords != filepath.Join(wantRoot, "learning-records.jsonl") { + t.Fatalf("LearningRecords = %q", paths.LearningRecords) + } + if paths.SkillDrafts != filepath.Join(wantRoot, "skill-drafts.json") { + t.Fatalf("SkillDrafts = %q", paths.SkillDrafts) + } + if paths.ProfilesDir != filepath.Join(wantRoot, "profiles") { + t.Fatalf("ProfilesDir = %q", paths.ProfilesDir) + } + if paths.BackupsDir != filepath.Join(wantRoot, "backups") { + t.Fatalf("BackupsDir = %q", paths.BackupsDir) + } +} + +func TestNewPaths_UsesOverride(t *testing.T) { + workspace := "/tmp/workspace" + override := "/tmp/custom-evolution" + + paths := NewPaths(workspace, override) + + if paths.RootDir != override { + t.Fatalf("RootDir = %q, want %q", paths.RootDir, override) + } + if paths.LearningRecords != filepath.Join(override, "learning-records.jsonl") { + t.Fatalf("LearningRecords = %q", paths.LearningRecords) + } + if paths.SkillDrafts != filepath.Join(override, "skill-drafts.json") { + t.Fatalf("SkillDrafts = %q", paths.SkillDrafts) + } + if paths.ProfilesDir != filepath.Join(override, "profiles") { + t.Fatalf("ProfilesDir = %q", paths.ProfilesDir) + } + if paths.BackupsDir != filepath.Join(override, "backups") { + t.Fatalf("BackupsDir = %q", paths.BackupsDir) + } +} + +func TestNewPaths_BlankOverrideFallsBackToDefaultRoot(t *testing.T) { + workspace := "/tmp/workspace" + + paths := NewPaths(workspace, " \t\n ") + + wantRoot := filepath.Join(workspace, "state", "evolution") + if paths.RootDir != wantRoot { + t.Fatalf("RootDir = %q, want %q", paths.RootDir, wantRoot) + } +} + +func TestNewPaths_TrimmedOverrideIsUsed(t *testing.T) { + workspace := "/tmp/workspace" + override := " /tmp/custom-evolution " + + paths := NewPaths(workspace, override) + + if paths.RootDir != "/tmp/custom-evolution" { + t.Fatalf("RootDir = %q, want %q", paths.RootDir, "/tmp/custom-evolution") + } +} diff --git a/pkg/evolution/preview.go b/pkg/evolution/preview.go new file mode 100644 index 000000000..db14202ab --- /dev/null +++ b/pkg/evolution/preview.go @@ -0,0 +1,142 @@ +package evolution + +import ( + "os" + "path/filepath" + "strconv" + "strings" +) + +type DraftPreview struct { + CurrentBody string + RenderedBody string + DiffPreview string +} + +func BuildDraftPreview(workspace string, draft SkillDraft) (DraftPreview, error) { + currentBody, hadOriginal, err := loadCurrentSkillBody(workspace, draft.TargetSkillName) + if err != nil { + return DraftPreview{}, err + } + + renderedBody, err := renderAppliedBody(draft, currentBody, hadOriginal) + if err != nil { + return DraftPreview{}, err + } + + return DraftPreview{ + CurrentBody: currentBody, + RenderedBody: renderedBody, + DiffPreview: buildLineDiffPreview(currentBody, renderedBody), + }, nil +} + +func loadCurrentSkillBody(workspace, skillName string) (string, bool, error) { + skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md") + data, err := os.ReadFile(skillPath) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + return string(data), true, nil +} + +func buildLineDiffPreview(currentBody, renderedBody string) string { + before := strings.Split(strings.TrimRight(currentBody, "\n"), "\n") + after := strings.Split(strings.TrimRight(renderedBody, "\n"), "\n") + + if len(before) == 1 && before[0] == "" { + before = nil + } + if len(after) == 1 && after[0] == "" { + after = nil + } + + prefixLen := sharedPrefixLen(before, after) + suffixLen := sharedSuffixLen(before[prefixLen:], after[prefixLen:]) + const contextRadius = 2 + + beforeChangeStart := prefixLen + beforeChangeEnd := len(before) - suffixLen + afterChangeStart := prefixLen + afterChangeEnd := len(after) - suffixLen + + hunkBeforeStart := previewMaxInt(0, beforeChangeStart-contextRadius) + hunkAfterStart := previewMaxInt(0, afterChangeStart-contextRadius) + hunkBeforeEnd := previewMinInt(len(before), beforeChangeEnd+contextRadius) + hunkAfterEnd := previewMinInt(len(after), afterChangeEnd+contextRadius) + + removed := before[prefixLen : len(before)-suffixLen] + added := after[prefixLen : len(after)-suffixLen] + if len(removed) == 0 && len(added) == 0 { + return "(no content change)" + } + + lines := make([]string, 0, (hunkBeforeEnd-hunkBeforeStart)+(hunkAfterEnd-hunkAfterStart)) + header := []string{ + "--- current", + "+++ rendered", + formatUnifiedHunkHeader(hunkBeforeStart, hunkBeforeEnd-hunkBeforeStart, hunkAfterStart, hunkAfterEnd-hunkAfterStart), + } + for _, line := range before[hunkBeforeStart:beforeChangeStart] { + lines = append(lines, " "+line) + } + for _, line := range removed { + lines = append(lines, "-"+line) + } + for _, line := range added { + lines = append(lines, "+"+line) + } + for _, line := range after[afterChangeEnd:hunkAfterEnd] { + lines = append(lines, " "+line) + } + return strings.Join(append(header, lines...), "\n") +} + +func formatUnifiedHunkHeader(beforeStart, beforeCount, afterStart, afterCount int) string { + return "@@ -" + formatUnifiedRange(beforeStart+1, beforeCount) + " +" + formatUnifiedRange(afterStart+1, afterCount) + " @@" +} + +func formatUnifiedRange(start, count int) string { + return strconv.Itoa(start) + "," + strconv.Itoa(count) +} + +func sharedPrefixLen(left, right []string) int { + limit := len(left) + if len(right) < limit { + limit = len(right) + } + n := 0 + for n < limit && left[n] == right[n] { + n++ + } + return n +} + +func sharedSuffixLen(left, right []string) int { + limit := len(left) + if len(right) < limit { + limit = len(right) + } + n := 0 + for n < limit && left[len(left)-1-n] == right[len(right)-1-n] { + n++ + } + return n +} + +func previewMinInt(a, b int) int { + if a < b { + return a + } + return b +} + +func previewMaxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/evolution/preview_test.go b/pkg/evolution/preview_test.go new file mode 100644 index 000000000..e6f0a9ce5 --- /dev/null +++ b/pkg/evolution/preview_test.go @@ -0,0 +1,111 @@ +package evolution + +import ( + "strings" + "testing" +) + +func TestBuildLineDiffPreview_UsesUnifiedDiffStyle(t *testing.T) { + current := strings.Join([]string{ + "---", + "name: weather", + "description: weather helper", + "---", + "# Weather", + "## Start Here", + "Use city names first.", + "", + }, "\n") + rendered := strings.Join([]string{ + "---", + "name: weather", + "description: weather helper", + "---", + "# Weather", + "## Start Here", + "Use city names first.", + "", + "## Start Here", + "Use native-name query first.", + "", + }, "\n") + + diff := buildLineDiffPreview(current, rendered) + + for _, want := range []string{ + "--- current", + "+++ rendered", + "@@", + "+## Start Here", + "+Use native-name query first.", + } { + if !strings.Contains(diff, want) { + t.Fatalf("diff missing %q:\n%s", want, diff) + } + } +} + +func TestBuildLineDiffPreview_NoContentChange(t *testing.T) { + body := "---\nname: weather\n---\n# Weather\n" + diff := buildLineDiffPreview(body, body) + if diff != "(no content change)" { + t.Fatalf("diff = %q, want no-content marker", diff) + } +} + +func TestBuildLineDiffPreview_LimitsContextAroundChanges(t *testing.T) { + current := strings.Join([]string{ + "line-01", + "line-02", + "line-03", + "line-04", + "line-05", + "line-06", + "line-07", + "line-08", + "line-09", + "line-10", + "", + }, "\n") + rendered := strings.Join([]string{ + "line-01", + "line-02", + "line-03", + "line-04", + "line-05", + "line-06", + "inserted-a", + "inserted-b", + "line-07", + "line-08", + "line-09", + "line-10", + "", + }, "\n") + + diff := buildLineDiffPreview(current, rendered) + + for _, want := range []string{ + "@@", + " line-05", + " line-06", + "+inserted-a", + "+inserted-b", + " line-07", + " line-08", + } { + if !strings.Contains(diff, want) { + t.Fatalf("diff missing %q:\n%s", want, diff) + } + } + for _, unwanted := range []string{ + "line-01", + "line-02", + "line-09", + "line-10", + } { + if strings.Contains(diff, unwanted) { + t.Fatalf("diff should omit distant context %q:\n%s", unwanted, diff) + } + } +} diff --git a/pkg/evolution/profile_sync.go b/pkg/evolution/profile_sync.go new file mode 100644 index 000000000..cfc7e4235 --- /dev/null +++ b/pkg/evolution/profile_sync.go @@ -0,0 +1,73 @@ +package evolution + +import ( + "errors" + "os" + "strings" + "time" +) + +func SaveAppliedProfile(store *Store, workspace string, draft SkillDraft, now time.Time) error { + profile, err := store.LoadProfile(draft.TargetSkillName) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if errors.Is(err, os.ErrNotExist) { + profile = SkillProfile{ + SkillName: draft.TargetSkillName, + WorkspaceID: workspace, + Origin: "evolved", + } + } + + profile.SkillName = draft.TargetSkillName + profile.WorkspaceID = workspace + profile.CurrentVersion = draft.ID + profile.Status = SkillStatusActive + profile.Origin = profileOrigin(profile.Origin) + profile.HumanSummary = draft.HumanSummary + profile.ChangeReason = draft.HumanSummary + profile.IntendedUseCases = append([]string(nil), draft.IntendedUseCases...) + profile.PreferredEntryPath = append([]string(nil), draft.PreferredEntryPath...) + profile.AvoidPatterns = append([]string(nil), draft.AvoidPatterns...) + profile.LastUsedAt = now + if profile.RetentionScore <= 0 { + profile.RetentionScore = 1 + } + profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{ + Version: draft.ID, + Action: string(draft.ChangeKind), + Timestamp: now, + DraftID: draft.ID, + Summary: draft.HumanSummary, + }) + return store.SaveProfile(profile) +} + +func inferIntendedUseCases(rule LearningRecord) []string { + summary := strings.TrimSpace(rule.Summary) + if summary == "" { + return nil + } + return []string{summary} +} + +func inferPreferredEntryPath(rule LearningRecord) []string { + if len(rule.WinningPath) == 0 { + return nil + } + return append([]string(nil), rule.WinningPath...) +} + +func inferAvoidPatterns(rule LearningRecord) []string { + if len(rule.LateAddedSkills) == 0 || len(rule.WinningPath) <= len(rule.LateAddedSkills) { + return nil + } + prefix := rule.WinningPath[:len(rule.WinningPath)-len(rule.LateAddedSkills)] + if len(prefix) == 0 { + return nil + } + return []string{ + "avoid starting with " + strings.Join(prefix, " -> ") + " before using " + strings.Join(rule.LateAddedSkills, " -> "), + } +} diff --git a/pkg/evolution/record_kinds.go b/pkg/evolution/record_kinds.go new file mode 100644 index 000000000..db9af93a3 --- /dev/null +++ b/pkg/evolution/record_kinds.go @@ -0,0 +1,9 @@ +package evolution + +func isTaskRecordKind(kind RecordKind) bool { + return kind == RecordKindTask || kind == legacyRecordKindCase +} + +func isPatternRecordKind(kind RecordKind) bool { + return kind == RecordKindPattern || kind == legacyRecordKindRule +} diff --git a/pkg/evolution/runtime.go b/pkg/evolution/runtime.go new file mode 100644 index 000000000..ae300c2fd --- /dev/null +++ b/pkg/evolution/runtime.go @@ -0,0 +1,615 @@ +package evolution + +import ( + "context" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "os" + "sort" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +var ErrApplyDraftFailed = errors.New("apply draft failed") + +type RuntimeOptions struct { + Config config.EvolutionConfig + Now func() time.Time + Store *Store + Organizer *Organizer + SkillsRecaller *SkillsRecaller + DraftGenerator DraftGenerator + GeneratorFactory func(workspace string) DraftGenerator + Applier *Applier + ApplierFactory func(workspace string) *Applier +} + +type Runtime struct { + cfg config.EvolutionConfig + mu sync.Mutex + now func() time.Time + writer *CaseWriter + store *Store + organizer *Organizer + skillsRecaller *SkillsRecaller + draftGenerator DraftGenerator + generatorFactory func(workspace string) DraftGenerator + applier *Applier + applierFactory func(workspace string) *Applier +} + +type TurnCaseInput struct { + Workspace string + WorkspaceID string + TurnID string + SessionKey string + AgentID string + Status string + ToolKinds []string + ActiveSkillNames []string + AttemptedSkillNames []string + FinalSuccessfulPath []string + SkillContextSnapshots []SkillContextSnapshot +} + +func NewRuntime(opts RuntimeOptions) (*Runtime, error) { + now := opts.Now + if now == nil { + now = time.Now + } + + organizer := opts.Organizer + if organizer == nil { + organizer = NewOrganizer(OrganizerOptions{ + MinCaseCount: opts.Config.MinCaseCount, + MinSuccessRate: opts.Config.MinSuccessRate, + Now: now, + }) + } + + return &Runtime{ + cfg: opts.Config, + now: now, + store: opts.Store, + organizer: organizer, + skillsRecaller: opts.SkillsRecaller, + draftGenerator: opts.DraftGenerator, + generatorFactory: opts.GeneratorFactory, + applier: opts.Applier, + applierFactory: opts.ApplierFactory, + }, nil +} + +func (rt *Runtime) FinalizeTurn(ctx context.Context, input TurnCaseInput) error { + if rt == nil || !rt.cfg.Enabled || input.Workspace == "" { + return nil + } + + success := input.Status == "completed" + workspaceID := input.WorkspaceID + if workspaceID == "" { + workspaceID = input.Workspace + } + + record := LearningRecord{ + ID: input.TurnID, + Kind: RecordKindTask, + WorkspaceID: workspaceID, + CreatedAt: rt.now(), + SessionKey: input.SessionKey, + TaskHash: buildTaskHash(input), + Summary: fmt.Sprintf("turn %s finished with status=%s", input.TurnID, input.Status), + Source: map[string]any{"turn_id": input.TurnID, "session_key": input.SessionKey, "agent_id": input.AgentID}, + Status: RecordStatus("new"), + Success: &success, + ToolKinds: append([]string(nil), input.ToolKinds...), + ActiveSkillNames: append([]string(nil), input.ActiveSkillNames...), + AttemptTrail: buildAttemptTrail(input, success), + Signals: buildLearningSignals(input, success), + } + + paths := NewPaths(input.Workspace, rt.cfg.StateDir) + + rt.mu.Lock() + if rt.writer == nil || rt.writer.paths.RootDir != paths.RootDir { + rt.writer = NewCaseWriter(paths) + } + writer := rt.writer + rt.mu.Unlock() + + if err := writer.AppendCase(ctx, record); err != nil { + return err + } + + return rt.recordSkillUsage(input, success) +} + +func buildAttemptTrail(input TurnCaseInput, success bool) *AttemptTrail { + attemptedInput := input.AttemptedSkillNames + if len(attemptedInput) == 0 { + attemptedInput = input.ActiveSkillNames + } + + attempted := make([]string, 0, len(attemptedInput)) + for _, skillName := range attemptedInput { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + continue + } + attempted = append(attempted, skillName) + } + if len(attempted) == 0 { + return nil + } + + trail := &AttemptTrail{ + AttemptedSkills: attempted, + } + if len(input.SkillContextSnapshots) > 0 { + trail.SkillContextSnapshots = cloneSkillContextSnapshots(input.SkillContextSnapshots) + } + if success { + finalPathInput := input.FinalSuccessfulPath + if len(finalPathInput) == 0 { + finalPathInput = attempted + } + finalPath := make([]string, 0, len(finalPathInput)) + for _, skillName := range finalPathInput { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + continue + } + finalPath = append(finalPath, skillName) + } + trail.FinalSuccessfulPath = finalPath + } + return trail +} + +func cloneSkillContextSnapshots(input []SkillContextSnapshot) []SkillContextSnapshot { + if len(input) == 0 { + return nil + } + + out := make([]SkillContextSnapshot, 0, len(input)) + for _, snapshot := range input { + out = append(out, SkillContextSnapshot{ + Sequence: snapshot.Sequence, + Trigger: snapshot.Trigger, + SkillNames: append([]string(nil), snapshot.SkillNames...), + }) + } + return out +} + +func buildTaskHash(input TurnCaseInput) string { + skillValues := input.AttemptedSkillNames + if len(skillValues) == 0 { + skillValues = input.ActiveSkillNames + } + + parts := make([]string, 0, len(skillValues)+len(input.ToolKinds)+2) + parts = append(parts, normalizedValues(skillValues)...) + parts = append(parts, normalizedValues(input.ToolKinds)...) + if strings.TrimSpace(input.Status) != "" { + parts = append(parts, strings.ToLower(strings.TrimSpace(input.Status))) + } + if len(parts) == 0 { + parts = append(parts, "empty") + } + + sum := sha1.Sum([]byte(strings.Join(parts, "\x00"))) + return hex.EncodeToString(sum[:8]) +} + +func buildLearningSignals(input TurnCaseInput, success bool) []string { + if !success { + return nil + } + skillValues := input.AttemptedSkillNames + if len(skillValues) == 0 { + skillValues = input.ActiveSkillNames + } + if len(normalizedValues(skillValues)) > 1 { + return []string{"potentially_learnable"} + } + return nil +} + +func normalizedValues(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + continue + } + out = append(out, value) + } + return out +} + +func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error { + if rt == nil || !rt.cfg.Enabled || workspace == "" { + return nil + } + + mode := rt.cfg.EffectiveMode() + if mode == "" || mode == "observe" { + return nil + } + + store := rt.storeForWorkspace(workspace) + records, err := store.LoadLearningRecords() + if err != nil { + return err + } + + if rt.organizer != nil { + rules, err := rt.organizer.BuildRules(records) + if err != nil { + return err + } + newRules := filterNewRules(records, rules, workspace) + if len(newRules) > 0 { + if err := store.AppendLearningRecords(newRules); err != nil { + return err + } + records = append(records, newRules...) + } + } + + generator := rt.draftGeneratorForWorkspace(workspace) + if generator == nil { + return nil + } + + recaller := rt.skillsRecallerForWorkspace(workspace) + applier := rt.applierForWorkspace(workspace) + readyRules := filterReadyRules(records, workspace) + if len(readyRules) == 0 { + return nil + } + + existingDrafts, err := store.LoadDrafts() + if err != nil { + return err + } + existingBySource := existingDraftSourceSet(existingDrafts, workspace) + + for _, rule := range readyRules { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if _, exists := existingBySource[rule.ID]; exists { + continue + } + + matches, err := recaller.RecallSimilarSkills(rule) + if err != nil { + return err + } + + draft, err := generator.GenerateDraft(ctx, rule, matches) + if err != nil { + return err + } + + draft = rt.finalizeDraft(workspace, rule, matches, draft) + if mode == "apply" && rt.cfg.AutoApply && applier != nil && draft.Status == DraftStatusCandidate { + rollbackApply, err := applier.applyDraftWithRollback(ctx, workspace, draft) + if err != nil { + draft.Status = DraftStatusQuarantined + draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply failed: %v", err)) + if auditErr := rt.recordRollbackAudit(store, draft, err); auditErr != nil { + draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("rollback audit failed: %v", auditErr)) + if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr) + } + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr) + } + if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr) + } + return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err) + } + draft.Status = DraftStatusAccepted + if err := rt.saveAppliedProfile(store, workspace, draft); err != nil { + draft.Status = DraftStatusQuarantined + draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("profile save failed: %v", err)) + if rollbackErr := rollbackApply(); rollbackErr != nil { + draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply rollback failed: %v", rollbackErr)) + if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr) + } + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr) + } + if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { + return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr) + } + return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err) + } + } + + if err := store.SaveDrafts([]SkillDraft{draft}); err != nil { + return err + } + existingBySource[rule.ID] = struct{}{} + } + + return nil +} + +func (rt *Runtime) storeForWorkspace(workspace string) *Store { + paths := NewPaths(workspace, rt.cfg.StateDir) + + rt.mu.Lock() + defer rt.mu.Unlock() + + if rt.store == nil || rt.store.paths.RootDir != paths.RootDir { + rt.store = NewStore(paths) + } + return rt.store +} + +func (rt *Runtime) skillsRecallerForWorkspace(workspace string) *SkillsRecaller { + rt.mu.Lock() + defer rt.mu.Unlock() + + if rt.skillsRecaller == nil || rt.skillsRecaller.workspace != workspace { + rt.skillsRecaller = NewSkillsRecaller(workspace) + } + return rt.skillsRecaller +} + +func (rt *Runtime) draftGeneratorForWorkspace(workspace string) DraftGenerator { + if rt.generatorFactory != nil { + if generator := rt.generatorFactory(workspace); generator != nil { + return generator + } + } + if rt.draftGenerator != nil { + return rt.draftGenerator + } + return NewDefaultDraftGenerator(workspace) +} + +func (rt *Runtime) applierForWorkspace(workspace string) *Applier { + if rt.applierFactory != nil { + if applier := rt.applierFactory(workspace); applier != nil { + return applier + } + } + return rt.applier +} + +func (rt *Runtime) finalizeDraft(workspace string, rule LearningRecord, matches []skills.SkillInfo, draft SkillDraft) SkillDraft { + if draft.ID == "" { + draft.ID = "draft-" + rule.ID + } + if draft.CreatedAt.IsZero() { + draft.CreatedAt = rt.now() + } + draft.WorkspaceID = workspace + draft.SourceRecordID = rule.ID + if len(draft.MatchedSkillRefs) == 0 { + draft.MatchedSkillRefs = collectSkillRefs(matches) + } + + review := ReviewDraft(draft) + draft.Status = review.Status + draft.ReviewNotes = append([]string(nil), review.ReviewNotes...) + if len(review.Findings) == 0 { + draft.ScanFindings = nil + return draft + } + draft.ScanFindings = append([]string(nil), review.Findings...) + return draft +} + +func collectSkillRefs(matches []skills.SkillInfo) []string { + if len(matches) == 0 { + return nil + } + + refs := make([]string, 0, len(matches)) + for _, match := range matches { + if strings := match.Path; strings != "" { + refs = append(refs, strings) + continue + } + refs = append(refs, match.Source+":"+match.Name) + } + return refs +} + +func filterNewRules(records []LearningRecord, rules []LearningRecord, workspace string) []LearningRecord { + existing := make(map[string]struct{}, len(records)) + for _, record := range records { + if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace { + continue + } + existing[record.ID] = struct{}{} + } + + out := make([]LearningRecord, 0, len(rules)) + for _, rule := range rules { + if rule.WorkspaceID != workspace { + continue + } + if _, ok := existing[rule.ID]; ok { + continue + } + existing[rule.ID] = struct{}{} + out = append(out, rule) + } + return out +} + +func filterReadyRules(records []LearningRecord, workspace string) []LearningRecord { + seen := make(map[string]LearningRecord) + for _, record := range records { + if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace || record.Status != RecordStatus("ready") { + continue + } + seen[record.ID] = record + } + + out := make([]LearningRecord, 0, len(seen)) + for _, record := range seen { + out = append(out, record) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.Before(out[j].CreatedAt) + } + return out[i].ID < out[j].ID + }) + return out +} + +func existingDraftSourceSet(drafts []SkillDraft, workspace string) map[string]struct{} { + out := make(map[string]struct{}, len(drafts)) + for _, draft := range drafts { + if draft.WorkspaceID != workspace || draft.SourceRecordID == "" { + continue + } + if draft.Status == DraftStatusQuarantined { + continue + } + out[draft.SourceRecordID] = struct{}{} + } + return out +} + +func (rt *Runtime) saveAppliedProfile(store *Store, workspace string, draft SkillDraft) error { + now := rt.now() + + return SaveAppliedProfile(store, workspace, draft, now) +} + +func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error { + profile, err := store.LoadProfile(draft.TargetSkillName) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + + now := rt.now() + profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{ + Version: profile.CurrentVersion, + Action: "rollback", + Timestamp: now, + DraftID: draft.ID, + Summary: fmt.Sprintf("Rolled back failed draft apply: %s", draft.HumanSummary), + Rollback: true, + RollbackReason: applyErr.Error(), + }) + return store.SaveProfile(profile) +} + +func profileOrigin(origin string) string { + if origin == "manual" { + return origin + } + return "evolved" +} + +func appendUniqueStrings(existing []string, values ...string) []string { + seen := make(map[string]struct{}, len(existing)) + for _, value := range existing { + seen[value] = struct{}{} + } + for _, value := range values { + if strings.TrimSpace(value) == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + existing = append(existing, value) + seen[value] = struct{}{} + } + return existing +} + +func (rt *Runtime) recordSkillUsage(input TurnCaseInput, success bool) error { + if len(input.ActiveSkillNames) == 0 { + return nil + } + + store := rt.storeForWorkspace(input.Workspace) + seen := make(map[string]struct{}, len(input.ActiveSkillNames)) + for _, skillName := range input.ActiveSkillNames { + skillName = strings.TrimSpace(skillName) + if skillName == "" { + continue + } + if _, ok := seen[skillName]; ok { + continue + } + seen[skillName] = struct{}{} + + if err := rt.touchSkillProfile(store, input, skillName, success); err != nil { + return err + } + } + return nil +} + +func (rt *Runtime) touchSkillProfile(store *Store, input TurnCaseInput, skillName string, success bool) error { + now := rt.now() + + profile, err := store.LoadProfile(skillName) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if errors.Is(err, os.ErrNotExist) { + profile = SkillProfile{ + SkillName: skillName, + WorkspaceID: input.Workspace, + Status: SkillStatusActive, + Origin: "manual", + HumanSummary: skillName, + RetentionScore: 0.2, + } + } + + profile.SkillName = skillName + profile.WorkspaceID = input.Workspace + if profile.Status == SkillStatusCold || profile.Status == SkillStatusArchived || profile.Status == "" { + profile.Status = SkillStatusActive + } + if profile.Origin == "" { + profile.Origin = "manual" + } + if strings.TrimSpace(profile.HumanSummary) == "" { + profile.HumanSummary = skillName + } + profile.LastUsedAt = now + profile.UseCount++ + profile.RetentionScore = nextRetentionScore(profile.RetentionScore, success) + return store.SaveProfile(profile) +} + +func nextRetentionScore(current float64, success bool) float64 { + increment := 0.05 + if success { + increment = 0.1 + } + current += increment + if current > 1 { + return 1 + } + return current +} diff --git a/pkg/evolution/runtime_apply_test.go b/pkg/evolution/runtime_apply_test.go new file mode 100644 index 000000000..9950a66c2 --- /dev/null +++ b/pkg/evolution/runtime_apply_test.go @@ -0,0 +1,372 @@ +package evolution_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + 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", AutoApply: true}, + 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: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "weather helper", + IntendedUseCases: []string{ + "weather native-name path", + }, + PreferredEntryPath: []string{"weather"}, + AvoidPatterns: []string{"avoid translating city names before querying weather"}, + BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", + }, + }, + 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", "weather", "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + t.Fatalf("expected skill file: %v", err) + } + + profile, err := store.LoadProfile("weather") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if profile.Status != evolution.SkillStatusActive { + t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive) + } + if profile.CurrentVersion == "" { + t.Fatal("CurrentVersion should not be empty") + } + if profile.ChangeReason != "weather helper" { + t.Fatalf("ChangeReason = %q, want weather helper", profile.ChangeReason) + } + if len(profile.IntendedUseCases) != 1 || profile.IntendedUseCases[0] != "weather native-name path" { + t.Fatalf("IntendedUseCases = %v, want [weather native-name path]", profile.IntendedUseCases) + } + if len(profile.PreferredEntryPath) != 1 || profile.PreferredEntryPath[0] != "weather" { + t.Fatalf("PreferredEntryPath = %v, want [weather]", profile.PreferredEntryPath) + } + if len(profile.AvoidPatterns) != 1 || profile.AvoidPatterns[0] != "avoid translating city names before querying weather" { + t.Fatalf("AvoidPatterns = %v, want populated metadata", profile.AvoidPatterns) + } + + 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) + } +} + +func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + 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", AutoApply: false}, + 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: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "weather helper", + BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", + }, + }, + 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) + } + + if _, err := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); !os.IsNotExist(err) { + t.Fatalf("expected no applied skill file, got err=%v", err) + } + if _, err := store.LoadProfile("weather"); !os.IsNotExist(err) { + t.Fatalf("expected no profile, got err=%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.DraftStatusCandidate { + t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate) + } +} + +func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAudit(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + + profile := evolution.SkillProfile{ + SkillName: "weather", + WorkspaceID: root, + CurrentVersion: "v1", + Status: evolution.SkillStatusActive, + Origin: "evolved", + HumanSummary: "weather helper", + LastUsedAt: time.Unix(1700000000, 0).UTC(), + RetentionScore: 1, + VersionHistory: []evolution.SkillVersionEntry{ + { + Version: "v1", + Action: "create", + Timestamp: time.Unix(1700000000, 0).UTC(), + DraftID: "draft-old", + Summary: "initial", + }, + }, + } + if err := store.SaveProfile(profile); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + skillDir := filepath.Join(root, "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: valid\n---\n# Weather\nold body\n" + if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true}, + 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-rollback", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindReplace, + HumanSummary: "broken weather helper", + BodyOrPatch: "invalid-frontmatter", + }, + }, + Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}), + SkillsRecaller: evolution.NewSkillsRecaller(root), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + err = rt.RunColdPathOnce(context.Background(), root) + if err == nil { + t.Fatal("expected RunColdPathOnce to fail") + } + if !errors.Is(err, evolution.ErrApplyDraftFailed) { + t.Fatalf("error = %v, want ErrApplyDraftFailed", 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.DraftStatusQuarantined { + t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined) + } + if len(drafts[0].ScanFindings) == 0 { + t.Fatal("expected apply error in ScanFindings") + } + + loadedProfile, err := store.LoadProfile("weather") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if len(loadedProfile.VersionHistory) != 2 { + t.Fatalf("len(VersionHistory) = %d, want 2", len(loadedProfile.VersionHistory)) + } + last := loadedProfile.VersionHistory[len(loadedProfile.VersionHistory)-1] + if !last.Rollback { + t.Fatal("expected rollback audit entry") + } + if last.DraftID != "draft-rollback" { + t.Fatalf("DraftID = %q, want draft-rollback", last.DraftID) + } + + got, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != original { + t.Fatalf("skill content changed after runtime rollback:\n%s", string(got)) + } +} + +func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesDraft(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(paths.ProfilesDir), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(paths.ProfilesDir, []byte("not-a-directory"), 0o644); err != nil { + t.Fatalf("WriteFile(profiles): %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true}, + Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, + Store: store, + Applier: evolution.NewApplier(paths, func() time.Time { + return time.Unix(1700001000, 0).UTC() + }), + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-profile-fail", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindCreate, + HumanSummary: "weather helper", + BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", + }, + }, + Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}), + SkillsRecaller: evolution.NewSkillsRecaller(root), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + err = rt.RunColdPathOnce(context.Background(), root) + if err == nil { + t.Fatal("expected RunColdPathOnce to fail") + } + if !errors.Is(err, evolution.ErrApplyDraftFailed) { + t.Fatalf("error = %v, want ErrApplyDraftFailed", err) + } + + skillPath := filepath.Join(root, "skills", "weather", "SKILL.md") + if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { + t.Fatalf("expected rolled back skill file, got err=%v", statErr) + } + + 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 %q", drafts[0].Status, evolution.DraftStatusQuarantined) + } + if len(drafts[0].ScanFindings) == 0 { + t.Fatal("expected scan findings for profile save failure") + } +} diff --git a/pkg/evolution/runtime_cold_path_test.go b/pkg/evolution/runtime_cold_path_test.go new file mode 100644 index 000000000..5e346a477 --- /dev/null +++ b/pkg/evolution/runtime_cold_path_test.go @@ -0,0 +1,603 @@ +package evolution_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/evolution" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type stubDraftGenerator struct { + draft evolution.SkillDraft + err error +} + +func (g stubDraftGenerator) GenerateDraft( + _ context.Context, + _ evolution.LearningRecord, + _ []skills.SkillInfo, +) (evolution.SkillDraft, error) { + return g.draft, g.err +} + +type sequenceDraftGenerator struct { + results []draftGenerationResult + index int +} + +type draftGenerationResult struct { + draft evolution.SkillDraft + err error +} + +func (g *sequenceDraftGenerator) GenerateDraft( + _ context.Context, + _ evolution.LearningRecord, + _ []skills.SkillInfo, +) (evolution.SkillDraft, error) { + if g.index >= len(g.results) { + return evolution.SkillDraft{}, nil + } + result := g.results[g.index] + g.index++ + return result.draft, result.err +} + +func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + 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: "review"}, + Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "prefer native-name path first", + BodyOrPatch: "## Start Here\nUse native-name query first.", + }, + }, + Store: store, + 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) + } + + 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.DraftStatusCandidate { + t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate) + } +} + +func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(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: "release path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + 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: "review"}, + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "broken", + BodyOrPatch: "", + }, + }, + Store: store, + 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) + } + + 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("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined) + } + if len(drafts[0].ScanFindings) == 0 { + t.Fatal("expected scan findings for invalid draft") + } +} + +func TestRuntime_RunColdPathOnce_DoesNotWriteSkillFile(t *testing.T) { + root := t.TempDir() + skillPath := filepath.Join(root, "skills", "weather", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(skillPath, []byte("---\nname: weather\ndescription: test\n---\n# Weather"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + store := evolution.NewStore(evolution.NewPaths(root, "")) + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + original, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile(original): %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: root, + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "prefer native-name path first", + BodyOrPatch: "## Start Here\nUse native-name query first.", + }, + }, + Store: store, + 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) + } + + got, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile(after): %v", err) + } + if string(got) != string(original) { + t.Fatalf("skill file changed unexpectedly:\n%s", string(got)) + } +} + +func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"weather"}, + } + 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: "review"}, + Store: store, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %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].TargetSkillName != "weather" { + t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName) + } + if drafts[0].Status != evolution.DraftStatusCandidate { + t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate) + } + if drafts[0].BodyOrPatch == "" { + t.Fatal("expected generated draft body") + } +} + +func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"weather"}, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + provider := &llmDraftRuntimeProvider{ + response: &providers.LLMResponse{ + Content: `{"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."}`, + }, + } + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Store: store, + DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, provider, "runtime-explicit-model"), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %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 provider.calls != 1 { + t.Fatalf("provider.calls = %d, want 1", provider.calls) + } + if drafts[0].HumanSummary != "Prefer native-name path first" { + t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first") + } +} + +func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvider(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"weather"}, + } + 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: "review"}, + Store: store, + DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, nil, ""), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %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].TargetSkillName != "weather" { + t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName) + } + if drafts[0].BodyOrPatch == "" { + t.Fatal("expected generated draft body") + } +} + +func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + + if err := os.MkdirAll(filepath.Join(root, "skills", "weather"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + skillBody := "---\nname: weather\ndescription: workspace weather helper\n---\n# Weather\n## Start Here\nUse the workspace-specific path.\n" + if err := os.WriteFile(filepath.Join(root, "skills", "weather", "SKILL.md"), []byte(skillBody), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + rule := evolution.LearningRecord{ + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + SuccessRate: 1, + WinningPath: []string{"weather"}, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + provider := &llmDraftRuntimeProvider{ + response: &providers.LLMResponse{Content: `not-json`}, + defaultModel: "runtime-test-model", + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Store: store, + GeneratorFactory: func(workspace string) evolution.DraftGenerator { + return evolution.NewDraftGeneratorForWorkspace(workspace, provider, "runtime-explicit-model") + }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.RunColdPathOnce(context.Background(), root); err != nil { + t.Fatalf("RunColdPathOnce: %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].ChangeKind != evolution.ChangeKindAppend { + t.Fatalf("ChangeKind = %q, want %q", drafts[0].ChangeKind, evolution.ChangeKindAppend) + } + if !strings.Contains(drafts[0].BodyOrPatch, "## Learned Evolution") { + t.Fatalf("BodyOrPatch = %q, want appended learned evolution section", drafts[0].BodyOrPatch) + } +} + +func TestRuntime_RunColdPathOnce_PersistsEarlierDraftWhenLaterRuleFails(t *testing.T) { + root := t.TempDir() + store := evolution.NewStore(evolution.NewPaths(root, "")) + + rules := []evolution.LearningRecord{ + { + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + }, + { + ID: "rule-2", + Kind: evolution.RecordKindRule, + WorkspaceID: root, + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "release path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + }, + } + if err := store.AppendLearningRecords(rules); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + generator := &sequenceDraftGenerator{ + results: []draftGenerationResult{ + { + draft: evolution.SkillDraft{ + ID: "draft-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "prefer native-name path first", + BodyOrPatch: "## Start Here\nUse native-name query first.", + }, + }, + { + err: context.DeadlineExceeded, + }, + }, + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Store: store, + DraftGenerator: generator, + SkillsRecaller: evolution.NewSkillsRecaller(root), + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + err = rt.RunColdPathOnce(context.Background(), root) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RunColdPathOnce error = %v, want %v", err, context.DeadlineExceeded) + } + + drafts, loadErr := store.LoadDrafts() + if loadErr != nil { + t.Fatalf("LoadDrafts: %v", loadErr) + } + if len(drafts) != 1 { + t.Fatalf("len(drafts) = %d, want 1", len(drafts)) + } + if drafts[0].SourceRecordID != "rule-1" { + t.Fatalf("SourceRecordID = %q, want rule-1", drafts[0].SourceRecordID) + } +} + +func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(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: "weather native-name path", + Status: evolution.RecordStatus("ready"), + EventCount: 4, + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + if err := store.SaveDrafts([]evolution.SkillDraft{{ + ID: "draft-old", + WorkspaceID: root, + CreatedAt: time.Unix(1700000100, 0).UTC(), + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "broken attempt", + BodyOrPatch: "## Start Here\nBroken content.", + Status: evolution.DraftStatusQuarantined, + ScanFindings: []string{"apply failed"}, + }}); err != nil { + t.Fatalf("SaveDrafts: %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "review"}, + Store: store, + DraftGenerator: stubDraftGenerator{ + draft: evolution.SkillDraft{ + ID: "draft-new", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "fixed attempt", + BodyOrPatch: "## Start Here\nUse native-name query first.", + }, + }, + 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) + } + + drafts, err := store.LoadDrafts() + if err != nil { + t.Fatalf("LoadDrafts: %v", err) + } + if len(drafts) != 2 { + t.Fatalf("len(drafts) = %d, want 2", len(drafts)) + } + if drafts[1].ID != "draft-new" { + t.Fatalf("drafts[1].ID = %q, want draft-new", drafts[1].ID) + } +} + +type llmDraftRuntimeProvider struct { + response *providers.LLMResponse + err error + calls int + defaultModel string +} + +func (p *llmDraftRuntimeProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + p.calls++ + return p.response, p.err +} + +func (p *llmDraftRuntimeProvider) GetDefaultModel() string { + if p.defaultModel != "" { + return p.defaultModel + } + return "runtime-test-model" +} diff --git a/pkg/evolution/runtime_test.go b/pkg/evolution/runtime_test.go new file mode 100644 index 000000000..22a831122 --- /dev/null +++ b/pkg/evolution/runtime_test.go @@ -0,0 +1,450 @@ +package evolution_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestRuntime_FinalizeTurnDisabledDoesNothing(t *testing.T) { + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: false, Mode: "observe"}, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + workspace := t.TempDir() + err = rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-1", + Status: "completed", + }) + if err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + paths := evolution.NewPaths(workspace, "") + if _, err := os.Stat(paths.LearningRecords); !os.IsNotExist(err) { + t.Fatalf("learning records file should not exist, stat err = %v", err) + } +} + +func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) { + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "observe"}, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + TurnID: "turn-1", + Status: "completed", + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } +} + +func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) { + workspace := t.TempDir() + override := filepath.Join(t.TempDir(), "custom-state") + now := time.Unix(1700000000, 0).UTC() + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + StateDir: override, + }, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + 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"}, + 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"}, + ActiveSkillNames: []string{"skill-b"}, + }); err != nil { + t.Fatalf("FinalizeTurn second call: %v", err) + } + + paths := evolution.NewPaths(workspace, override) + 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) != 2 { + t.Fatalf("record file line count = %d, want 2", len(lines)) + } + + var first evolution.LearningRecord + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("Unmarshal first record: %v", err) + } + if first.WorkspaceID != workspace { + t.Fatalf("first WorkspaceID = %q, want %q", first.WorkspaceID, workspace) + } + if first.CreatedAt != now { + t.Fatalf("first CreatedAt = %v, want %v", first.CreatedAt, now) + } + 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" { + t.Fatalf("first Summary = %q", first.Summary) + } + 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.ActiveSkillNames) != 1 || first.ActiveSkillNames[0] != "skill-a" { + t.Fatalf("first ActiveSkillNames = %v", first.ActiveSkillNames) + } + if first.AttemptTrail == nil { + t.Fatal("first AttemptTrail should not be nil") + } + if len(first.AttemptTrail.AttemptedSkills) != 1 || first.AttemptTrail.AttemptedSkills[0] != "skill-a" { + t.Fatalf("first AttemptTrail.AttemptedSkills = %v, want [skill-a]", first.AttemptTrail.AttemptedSkills) + } + if len(first.AttemptTrail.FinalSuccessfulPath) != 1 || first.AttemptTrail.FinalSuccessfulPath[0] != "skill-a" { + t.Fatalf( + "first AttemptTrail.FinalSuccessfulPath = %v, want [skill-a]", + first.AttemptTrail.FinalSuccessfulPath, + ) + } + if got := first.Source["turn_id"]; got != "turn-1" { + t.Fatalf("first Source.turn_id = %v", got) + } + if got := first.Source["session_key"]; got != "session-1" { + t.Fatalf("first Source.session_key = %v", got) + } + if got := first.Source["agent_id"]; got != "agent-1" { + t.Fatalf("first Source.agent_id = %v", got) + } + if first.TaskHash == "" { + t.Fatal("first TaskHash should not be empty") + } + if len(first.Signals) != 0 { + t.Fatalf("first Signals = %v, want empty for single-skill success", first.Signals) + } + + var second evolution.LearningRecord + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("Unmarshal second record: %v", err) + } + if second.WorkspaceID != "ws-explicit" { + t.Fatalf("second WorkspaceID = %q, want %q", second.WorkspaceID, "ws-explicit") + } + if second.SessionKey != "session-2" { + t.Fatalf("second SessionKey = %q, want %q", second.SessionKey, "session-2") + } + if second.Success == nil || *second.Success { + t.Fatalf("second Success = %v, want false", second.Success) + } + if second.AttemptTrail == nil { + t.Fatal("second AttemptTrail should not be nil") + } + if len(second.AttemptTrail.AttemptedSkills) != 1 || second.AttemptTrail.AttemptedSkills[0] != "skill-b" { + t.Fatalf("second AttemptTrail.AttemptedSkills = %v, want [skill-b]", second.AttemptTrail.AttemptedSkills) + } + if len(second.AttemptTrail.FinalSuccessfulPath) != 0 { + t.Fatalf( + "second AttemptTrail.FinalSuccessfulPath = %v, want empty for failed turn", + second.AttemptTrail.FinalSuccessfulPath, + ) + } + if second.TaskHash == "" { + t.Fatal("second TaskHash should not be empty") + } + if second.TaskHash == first.TaskHash { + t.Fatal("TaskHash should differ for different skill/tool signatures") + } + if len(second.Signals) != 0 { + t.Fatalf("second Signals = %v, want empty for failed turn", second.Signals) + } +} + +func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) { + workspace := t.TempDir() + now := time.Unix(1700003000, 0).UTC() + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "observe"}, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-learnable", + SessionKey: "session-learnable", + AgentID: "agent-1", + Status: "completed", + ToolKinds: []string{"web", "bash"}, + ActiveSkillNames: []string{"geocode", "weather"}, + SkillContextSnapshots: []evolution.SkillContextSnapshot{ + {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}}, + {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, + }, + }); 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 len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" { + t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals) + } + 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.SkillContextSnapshots; len(got) != 2 { + t.Fatalf("SkillContextSnapshots = %v, want 2 snapshots", got) + } +} + +func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) { + workspace := t.TempDir() + now := time.Unix(1700003500, 0).UTC() + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "observe"}, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-explicit-trail", + SessionKey: "session-explicit-trail", + AgentID: "agent-1", + Status: "completed", + ToolKinds: []string{"web"}, + ActiveSkillNames: []string{"weather"}, + AttemptedSkillNames: []string{"geocode", "weather"}, + FinalSuccessfulPath: []string{"geocode", "weather"}, + SkillContextSnapshots: []evolution.SkillContextSnapshot{ + {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"weather"}}, + {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, + }, + }); 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 record.AttemptTrail == nil { + t.Fatal("AttemptTrail should not be nil") + } + if got := record.AttemptTrail.AttemptedSkills; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" { + t.Fatalf("AttemptedSkills = %v, want [geocode weather]", got) + } + 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 len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" { + t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals) + } +} + +func TestRuntime_FinalizeTurnUpdatesSkillProfileUsage(t *testing.T) { + workspace := t.TempDir() + now := time.Unix(1700000000, 0).UTC() + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{ + Enabled: true, + Mode: "observe", + }, + Now: func() time.Time { return now }, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-1", + SessionKey: "session-1", + AgentID: "agent-1", + Status: "completed", + ActiveSkillNames: []string{"skill-a", "skill-a"}, + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + profile, err := store.LoadProfile("skill-a") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if profile.Origin != "manual" { + t.Fatalf("Origin = %q, want manual", profile.Origin) + } + if profile.UseCount != 1 { + t.Fatalf("UseCount = %d, want 1", profile.UseCount) + } + if profile.LastUsedAt != now { + t.Fatalf("LastUsedAt = %v, want %v", profile.LastUsedAt, now) + } + if profile.RetentionScore <= 0.2 { + t.Fatalf("RetentionScore = %v, want > 0.2", profile.RetentionScore) + } +} + +func TestRuntime_FinalizeTurnReactivatesColdSkill(t *testing.T) { + workspace := t.TempDir() + now := time.Unix(1700001000, 0).UTC() + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + + if err := store.SaveProfile(evolution.SkillProfile{ + SkillName: "skill-cold", + WorkspaceID: workspace, + Status: evolution.SkillStatusCold, + Origin: "evolved", + HumanSummary: "cold skill", + LastUsedAt: now.Add(-24 * time.Hour), + UseCount: 2, + RetentionScore: 0.2, + }); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "observe"}, + Now: func() time.Time { return now }, + Store: store, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-cold", + Status: "completed", + ActiveSkillNames: []string{"skill-cold"}, + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + profile, err := store.LoadProfile("skill-cold") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if profile.Status != evolution.SkillStatusActive { + t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive) + } +} + +func TestRuntime_FinalizeTurnReactivatesArchivedSkill(t *testing.T) { + workspace := t.TempDir() + now := time.Unix(1700002000, 0).UTC() + store := evolution.NewStore(evolution.NewPaths(workspace, "")) + + if err := store.SaveProfile(evolution.SkillProfile{ + SkillName: "skill-archived", + WorkspaceID: workspace, + Status: evolution.SkillStatusArchived, + Origin: "evolved", + HumanSummary: "archived skill", + LastUsedAt: now.Add(-48 * time.Hour), + UseCount: 5, + RetentionScore: 0.1, + }); err != nil { + t.Fatalf("SaveProfile: %v", err) + } + + rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ + Config: config.EvolutionConfig{Enabled: true, Mode: "observe"}, + Now: func() time.Time { return now }, + Store: store, + }) + if err != nil { + t.Fatalf("NewRuntime: %v", err) + } + + if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ + Workspace: workspace, + TurnID: "turn-archived", + Status: "completed", + ActiveSkillNames: []string{"skill-archived"}, + }); err != nil { + t.Fatalf("FinalizeTurn: %v", err) + } + + profile, err := store.LoadProfile("skill-archived") + if err != nil { + t.Fatalf("LoadProfile: %v", err) + } + if profile.Status != evolution.SkillStatusActive { + t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive) + } +} diff --git a/pkg/evolution/skills_recall.go b/pkg/evolution/skills_recall.go new file mode 100644 index 000000000..159034827 --- /dev/null +++ b/pkg/evolution/skills_recall.go @@ -0,0 +1,179 @@ +package evolution + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type SkillsRecaller struct { + workspace string + loader *skills.SkillsLoader +} + +func NewSkillsRecaller(workspace string) *SkillsRecaller { + builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills)) + if builtinSkillsDir == "" { + wd, _ := os.Getwd() + builtinSkillsDir = filepath.Join(wd, "skills") + } + + globalSkillsDir := filepath.Join(config.GetHome(), "skills") + return &SkillsRecaller{ + workspace: workspace, + loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), + } +} + +func (r *SkillsRecaller) RecallSimilarSkills(rule LearningRecord) ([]skills.SkillInfo, error) { + if r == nil || r.loader == nil { + return nil, nil + } + + all := r.loader.ListSkills() + type scored struct { + info skills.SkillInfo + score int + sourceRank int + } + + scoredList := make([]scored, 0, len(all)) + for _, skill := range all { + score := scoreSkillMatch(rule, skill) + if score <= 0 { + continue + } + + if body, ok := r.loader.LoadSkill(skill.Name); ok { + score += scoreSkillBody(rule, body) + } + + scoredList = append(scoredList, scored{ + info: skill, + score: score, + sourceRank: skillSourceRank(skill.Source), + }) + } + + sort.Slice(scoredList, func(i, j int) bool { + if scoredList[i].score != scoredList[j].score { + return scoredList[i].score > scoredList[j].score + } + if scoredList[i].sourceRank != scoredList[j].sourceRank { + return scoredList[i].sourceRank < scoredList[j].sourceRank + } + return scoredList[i].info.Name < scoredList[j].info.Name + }) + + out := make([]skills.SkillInfo, 0, len(scoredList)) + for _, item := range scoredList { + out = append(out, item.info) + } + return out, nil +} + +func scoreSkillMatch(rule LearningRecord, skill skills.SkillInfo) int { + score := 0 + skillName := strings.ToLower(strings.TrimSpace(skill.Name)) + ruleSummary := strings.ToLower(rule.Summary) + + if skillName != "" { + if containsNormalized(rule.WinningPath, skillName) { + score += 8 + } + if containsNormalized(rule.MatchedSkillNames, skillName) { + score += 6 + } + if strings.Contains(ruleSummary, skillName) { + score += 4 + } + } + + score += 2 * tokenOverlap(ruleTokens(rule), tokenizeForEvolution(skill.Name+" "+skill.Description)) + return score +} + +func scoreSkillBody(rule LearningRecord, body string) int { + return minInt(tokenOverlap(ruleTokens(rule), tokenizeForEvolution(body)), 3) +} + +func skillSourceRank(source string) int { + switch source { + case "workspace": + return 0 + case "global": + return 1 + case "builtin": + return 2 + default: + return 3 + } +} + +func ruleTokens(rule LearningRecord) []string { + parts := make([]string, 0, len(rule.WinningPath)+len(rule.MatchedSkillNames)+4) + parts = append(parts, normalizePath(rule.WinningPath)...) + parts = append(parts, normalizePath(rule.MatchedSkillNames)...) + parts = append(parts, tokenizeForEvolution(rule.Summary)...) + return parts +} + +func containsNormalized(values []string, target string) bool { + target = strings.ToLower(strings.TrimSpace(target)) + for _, value := range values { + if strings.ToLower(strings.TrimSpace(value)) == target { + return true + } + } + return false +} + +func tokenOverlap(left, right []string) int { + if len(left) == 0 || len(right) == 0 { + return 0 + } + + leftSet := make(map[string]struct{}, len(left)) + for _, token := range left { + leftSet[token] = struct{}{} + } + + seen := make(map[string]struct{}, len(right)) + count := 0 + for _, token := range right { + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + if _, ok := leftSet[token]; ok { + count++ + } + } + return count +} + +func tokenizeForEvolution(text string) []string { + fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') + }) + + out := make([]string, 0, len(fields)) + for _, field := range fields { + if field == "" { + continue + } + out = append(out, field) + } + return out +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/evolution/skills_recall_test.go b/pkg/evolution/skills_recall_test.go new file mode 100644 index 000000000..561ce6c8e --- /dev/null +++ b/pkg/evolution/skills_recall_test.go @@ -0,0 +1,49 @@ +package evolution_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestRecallSimilarSkills_ReturnsWorkspaceSkillFirst(t *testing.T) { + workspace := t.TempDir() + globalHome := t.TempDir() + builtinRoot := t.TempDir() + + t.Setenv("HOME", globalHome) + t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot) + + mustWriteSkill := func(root, name, content string) { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s): %v", dir, err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", name, err) + } + } + + mustWriteSkill(filepath.Join(workspace, "skills"), "weather", "---\nname: weather\ndescription: weather lookup\n---\n# Weather\nUse weather queries.\n") + mustWriteSkill(filepath.Join(globalHome, ".picoclaw", "skills"), "release", "---\nname: release\ndescription: release flow\n---\n# Release\nRelease build.\n") + mustWriteSkill(builtinRoot, "weather-fallback", "---\nname: weather-fallback\ndescription: weather backup\n---\n# Weather Fallback\nBackup weather path.\n") + + recaller := evolution.NewSkillsRecaller(workspace) + matches, err := recaller.RecallSimilarSkills(evolution.LearningRecord{ + Kind: evolution.RecordKindRule, + Summary: "weather native-name path", + EventCount: 4, + }) + if err != nil { + t.Fatalf("RecallSimilarSkills: %v", err) + } + if len(matches) == 0 { + t.Fatal("expected at least one match") + } + if matches[0].Name != "weather" { + t.Fatalf("first match = %q, want weather", matches[0].Name) + } +} diff --git a/pkg/evolution/store.go b/pkg/evolution/store.go new file mode 100644 index 000000000..025d4d7fd --- /dev/null +++ b/pkg/evolution/store.go @@ -0,0 +1,384 @@ +package evolution + +import ( + "bufio" + "bytes" + "context" + "crypto/sha1" + "encoding/json" + "encoding/hex" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type Store struct { + paths Paths +} + +func NewStore(paths Paths) *Store { + return &Store{paths: paths} +} + +var storeFileLocks sync.Map + +func (s *Store) AppendLearningRecord(ctx context.Context, record LearningRecord) error { + return s.appendLearningRecords(ctx, []LearningRecord{record}) +} + +func (s *Store) AppendLearningRecords(records []LearningRecord) error { + return s.appendLearningRecords(context.Background(), records) +} + +func (s *Store) appendLearningRecords(ctx context.Context, records []LearningRecord) error { + if len(records) == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + unlock := lockStoreFile(s.paths.LearningRecords) + defer unlock() + + if err := os.MkdirAll(filepath.Dir(s.paths.LearningRecords), 0o755); err != nil { + return err + } + + f, err := os.OpenFile(s.paths.LearningRecords, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer f.Close() + + enc := json.NewEncoder(f) + for _, record := range records { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if err := enc.Encode(record); err != nil { + return err + } + } + return nil +} + +func (s *Store) LoadLearningRecords() ([]LearningRecord, error) { + var records []LearningRecord + if err := decodeJSONLLines(s.paths.LearningRecords, func(line []byte) error { + var record LearningRecord + if err := json.Unmarshal(line, &record); err != nil { + return err + } + records = append(records, record) + return nil + }); err != nil { + return nil, err + } + return records, nil +} + +func (s *Store) SaveDrafts(drafts []SkillDraft) error { + unlock := lockStoreFile(s.paths.SkillDrafts) + defer unlock() + + existing, err := s.LoadDrafts() + if err != nil { + return err + } + + indexByKey := make(map[string]int, len(existing)) + for i, draft := range existing { + indexByKey[draftKey(draft.WorkspaceID, draft.ID)] = i + } + + for _, draft := range drafts { + key := draftKey(draft.WorkspaceID, draft.ID) + if idx, ok := indexByKey[key]; ok { + existing[idx] = draft + continue + } + indexByKey[key] = len(existing) + existing = append(existing, draft) + } + + data, err := json.MarshalIndent(existing, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(s.paths.SkillDrafts, data, 0o644) +} + +func (s *Store) LoadDrafts() ([]SkillDraft, error) { + data, err := os.ReadFile(s.paths.SkillDrafts) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, nil + } + + var drafts []SkillDraft + if err := json.Unmarshal(data, &drafts); err != nil { + return nil, err + } + return drafts, nil +} + +func (s *Store) SaveProfile(profile SkillProfile) error { + path, err := s.profilePath(profile.WorkspaceID, profile.SkillName) + if err != nil { + return err + } + unlock := lockStoreFile(path) + defer unlock() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(profile, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o644) +} + +func (s *Store) LoadProfile(skillName string) (SkillProfile, error) { + paths, err := s.profileLookupPaths(skillName) + if err != nil { + return SkillProfile{}, err + } + for _, path := range paths { + profile, loadErr := s.loadProfileFromPath(path) + if errors.Is(loadErr, os.ErrNotExist) { + continue + } + if loadErr != nil { + return SkillProfile{}, loadErr + } + return profile, nil + } + return SkillProfile{}, os.ErrNotExist +} + +func (s *Store) LoadProfiles() ([]SkillProfile, error) { + entries, err := os.ReadDir(s.paths.ProfilesDir) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + + profiles := make([]SkillProfile, 0, len(entries)) + for _, entry := range entries { + entryPath := filepath.Join(s.paths.ProfilesDir, entry.Name()) + if entry.IsDir() { + nestedProfiles, loadErr := s.loadProfilesFromDir(entryPath) + if loadErr != nil { + return nil, loadErr + } + profiles = append(profiles, nestedProfiles...) + continue + } + if filepath.Ext(entry.Name()) != ".json" { + continue + } + profile, err := s.loadProfileFromPath(entryPath) + if err != nil { + return nil, err + } + profiles = append(profiles, profile) + } + + sort.Slice(profiles, func(i, j int) bool { + if profiles[i].SkillName != profiles[j].SkillName { + return profiles[i].SkillName < profiles[j].SkillName + } + return profiles[i].WorkspaceID < profiles[j].WorkspaceID + }) + return profiles, nil +} + +func decodeJSONLLines(path string, decode func(line []byte) error) error { + f, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var lines [][]byte + for scanner.Scan() { + line := bytes.TrimSpace(scanner.Bytes()) + if len(line) == 0 { + continue + } + lines = append(lines, append([]byte(nil), line...)) + } + if err := scanner.Err(); err != nil { + return err + } + + for i, line := range lines { + if err := decode(line); err != nil { + if i == len(lines)-1 && isInvalidJSON(err) { + return nil + } + return err + } + } + return nil +} + +func draftKey(workspaceID, id string) string { + return workspaceID + "\x00" + id +} + +func isInvalidJSON(err error) bool { + var syntaxErr *json.SyntaxError + return errors.As(err, &syntaxErr) +} + +func lockStoreFile(path string) func() { + actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{}) + mu := actual.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func (s *Store) profilePath(workspaceID, skillName string) (string, error) { + if err := skills.ValidateSkillName(skillName); err != nil { + return "", err + } + workspaceID = strings.TrimSpace(workspaceID) + if workspaceID == "" { + return filepath.Join(s.paths.ProfilesDir, skillName+".json"), nil + } + return filepath.Join(s.paths.ProfilesDir, workspaceScopeDir(workspaceID), skillName+".json"), nil +} + +func (s *Store) loadProfilesFromDir(dir string) ([]SkillProfile, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + profiles := make([]SkillProfile, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + profile, err := s.loadProfileFromPath(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, err + } + profiles = append(profiles, profile) + } + return profiles, nil +} + +func (s *Store) loadProfileFromPath(path string) (SkillProfile, error) { + data, err := os.ReadFile(path) + if err != nil { + return SkillProfile{}, err + } + + var profile SkillProfile + if err := json.Unmarshal(data, &profile); err != nil { + return SkillProfile{}, err + } + return profile, nil +} + +func (s *Store) profileLookupPaths(skillName string) ([]string, error) { + if err := skills.ValidateSkillName(skillName); err != nil { + return nil, err + } + + paths := make([]string, 0, 4) + seen := make(map[string]struct{}, 4) + appendPath := func(path string) { + if path == "" { + return + } + if _, ok := seen[path]; ok { + return + } + paths = append(paths, path) + seen[path] = struct{}{} + } + + if workspaceID := strings.TrimSpace(s.paths.Workspace); workspaceID != "" { + path, err := s.profilePath(workspaceID, skillName) + if err != nil { + return nil, err + } + appendPath(path) + } + + legacyPath, err := s.profilePath("", skillName) + if err != nil { + return nil, err + } + appendPath(legacyPath) + + matches, err := filepath.Glob(filepath.Join(s.paths.ProfilesDir, "*", skillName+".json")) + if err != nil { + return nil, err + } + sort.Strings(matches) + for _, match := range matches { + appendPath(match) + } + + return paths, nil +} + +func workspaceScopeDir(workspaceID string) string { + sum := sha1.Sum([]byte(workspaceID)) + base := filepath.Base(filepath.Clean(workspaceID)) + base = sanitizeWorkspaceComponent(base) + if base == "" || base == "." { + base = "workspace" + } + return base + "-" + hex.EncodeToString(sum[:6]) +} + +func sanitizeWorkspaceComponent(value string) string { + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= 'A' && r <= 'Z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_' || r == '.': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return strings.Trim(b.String(), "-") +} diff --git a/pkg/evolution/store_test.go b/pkg/evolution/store_test.go new file mode 100644 index 000000000..22343f62d --- /dev/null +++ b/pkg/evolution/store_test.go @@ -0,0 +1,178 @@ +package evolution_test + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/evolution" +) + +func TestStore_AppendLearningRecordsPersistsCaseAndRule(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + records := []evolution.LearningRecord{ + { + ID: "case-1", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather task completed", + Status: evolution.RecordStatus("new"), + }, + { + ID: "rule-1", + Kind: evolution.RecordKindRule, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000100, 0).UTC(), + Summary: "prefer native-name weather path", + Status: evolution.RecordStatus("ready"), + }, + } + + if err := store.AppendLearningRecords(records); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + loaded, err := store.LoadLearningRecords() + if err != nil { + t.Fatalf("LoadLearningRecords: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("len(loaded) = %d, want 2", len(loaded)) + } + if loaded[1].Kind != evolution.RecordKindRule { + t.Fatalf("loaded[1].Kind = %q, want %q", loaded[1].Kind, evolution.RecordKindRule) + } +} + +func TestStore_SaveDraftsOverwritesByID(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + first := evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "prefer native-name path first", + BodyOrPatch: "## Start Here", + Status: evolution.DraftStatusCandidate, + } + second := first + second.HumanSummary = "updated summary" + + if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil { + t.Fatalf("SaveDrafts(first): %v", err) + } + if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil { + t.Fatalf("SaveDrafts(second): %v", err) + } + + loaded, err := store.LoadDrafts() + if err != nil { + t.Fatalf("LoadDrafts: %v", err) + } + if len(loaded) != 1 { + t.Fatalf("len(loaded) = %d, want 1", len(loaded)) + } + if loaded[0].HumanSummary != "updated summary" { + t.Fatalf("HumanSummary = %q, want %q", loaded[0].HumanSummary, "updated summary") + } +} + +func TestStore_SaveDraftsKeepsSameIDDifferentWorkspace(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + first := evolution.SkillDraft{ + ID: "draft-1", + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + SourceRecordID: "rule-1", + TargetSkillName: "weather", + DraftType: evolution.DraftTypeShortcut, + ChangeKind: evolution.ChangeKindAppend, + HumanSummary: "workspace one", + BodyOrPatch: "## Start Here", + Status: evolution.DraftStatusCandidate, + } + second := first + second.WorkspaceID = "ws-2" + second.HumanSummary = "workspace two" + + if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil { + t.Fatalf("SaveDrafts(first): %v", err) + } + if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil { + t.Fatalf("SaveDrafts(second): %v", err) + } + + loaded, err := store.LoadDrafts() + if err != nil { + t.Fatalf("LoadDrafts: %v", err) + } + if len(loaded) != 2 { + t.Fatalf("len(loaded) = %d, want 2", len(loaded)) + } + if loaded[0].WorkspaceID == loaded[1].WorkspaceID { + t.Fatalf("loaded drafts should keep distinct workspace IDs: %+v", loaded) + } +} + +func TestStore_LoadLearningRecordsIgnoresTruncatedTrailingLine(t *testing.T) { + root := t.TempDir() + paths := evolution.NewPaths(root, "") + store := evolution.NewStore(paths) + + record := evolution.LearningRecord{ + ID: "case-1", + Kind: evolution.RecordKindCase, + WorkspaceID: "ws-1", + CreatedAt: time.Unix(1700000000, 0).UTC(), + Summary: "weather task completed", + Status: evolution.RecordStatus("new"), + } + if err := store.AppendLearningRecords([]evolution.LearningRecord{record}); err != nil { + t.Fatalf("AppendLearningRecords: %v", err) + } + + f, err := os.OpenFile(paths.LearningRecords, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("OpenFile: %v", err) + } + if _, err := f.WriteString("{\"id\":\"broken\""); err != nil { + f.Close() + t.Fatalf("WriteString: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + loaded, err := store.LoadLearningRecords() + if err != nil { + t.Fatalf("LoadLearningRecords: %v", err) + } + if len(loaded) != 1 { + t.Fatalf("len(loaded) = %d, want 1", len(loaded)) + } + if loaded[0].ID != "case-1" { + t.Fatalf("loaded[0].ID = %q, want %q", loaded[0].ID, "case-1") + } + + data, err := os.ReadFile(paths.LearningRecords) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(data), "\"broken\"") { + t.Fatalf("expected test fixture to include broken trailing line") + } +} diff --git a/pkg/evolution/types.go b/pkg/evolution/types.go new file mode 100644 index 000000000..5e0b936fe --- /dev/null +++ b/pkg/evolution/types.go @@ -0,0 +1,136 @@ +package evolution + +import "time" + +type RecordKind string + +const ( + RecordKindTask RecordKind = "task" + RecordKindPattern RecordKind = "pattern" + legacyRecordKindCase RecordKind = "case" + legacyRecordKindRule RecordKind = "rule" + // Deprecated: use RecordKindTask. + RecordKindCase = RecordKindTask + // Deprecated: use RecordKindPattern. + RecordKindRule = RecordKindPattern +) + +type RecordStatus string + +type DraftType string + +const ( + DraftTypeWorkflow DraftType = "workflow" + DraftTypeShortcut DraftType = "shortcut" +) + +type ChangeKind string + +const ( + ChangeKindCreate ChangeKind = "create" + ChangeKindAppend ChangeKind = "append" + ChangeKindReplace ChangeKind = "replace" + ChangeKindMerge ChangeKind = "merge" +) + +type DraftStatus string + +const ( + DraftStatusCandidate DraftStatus = "candidate" + DraftStatusQuarantined DraftStatus = "quarantined" + DraftStatusAccepted DraftStatus = "accepted" +) + +type SkillStatus string + +const ( + SkillStatusActive SkillStatus = "active" + SkillStatusCold SkillStatus = "cold" + SkillStatusArchived SkillStatus = "archived" + SkillStatusDeleted SkillStatus = "deleted" +) + +type AttemptTrail struct { + AttemptedSkills []string `json:"attempted_skills,omitempty"` + FinalSuccessfulPath []string `json:"final_successful_path,omitempty"` + SkillContextSnapshots []SkillContextSnapshot `json:"skill_context_snapshots,omitempty"` +} + +type SkillContextSnapshot struct { + Sequence int `json:"sequence"` + Trigger string `json:"trigger"` + 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"` +} + +type SkillDraft struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SourceRecordID string `json:"source_record_id"` + TargetSkillName string `json:"target_skill_name"` + MatchedSkillRefs []string `json:"matched_skill_refs,omitempty"` + DraftType DraftType `json:"draft_type"` + ChangeKind ChangeKind `json:"change_kind"` + HumanSummary string `json:"human_summary"` + IntendedUseCases []string `json:"intended_use_cases,omitempty"` + PreferredEntryPath []string `json:"preferred_entry_path,omitempty"` + AvoidPatterns []string `json:"avoid_patterns,omitempty"` + BodyOrPatch string `json:"body_or_patch"` + Status DraftStatus `json:"status"` + ReviewNotes []string `json:"review_notes,omitempty"` + ScanFindings []string `json:"scan_findings,omitempty"` +} + +type SkillVersionEntry struct { + Version string `json:"version"` + Action string `json:"action"` + Timestamp time.Time `json:"timestamp"` + DraftID string `json:"draft_id,omitempty"` + Summary string `json:"summary"` + Rollback bool `json:"rollback,omitempty"` + RollbackReason string `json:"rollback_reason,omitempty"` +} + +type SkillProfile struct { + SkillName string `json:"skill_name"` + WorkspaceID string `json:"workspace_id"` + CurrentVersion string `json:"current_version"` + Status SkillStatus `json:"status"` + Origin string `json:"origin"` + HumanSummary string `json:"human_summary"` + ChangeReason string `json:"change_reason,omitempty"` + IntendedUseCases []string `json:"intended_use_cases,omitempty"` + PreferredEntryPath []string `json:"preferred_entry_path,omitempty"` + AvoidPatterns []string `json:"avoid_patterns,omitempty"` + LastUsedAt time.Time `json:"last_used_at"` + UseCount int `json:"use_count"` + RetentionScore float64 `json:"retention_score"` + VersionHistory []SkillVersionEntry `json:"version_history"` +} diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index f58590d5b..60dcef04e 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -332,7 +332,11 @@ func createStartupProvider( return &startupBlockedProvider{reason: reason}, "", nil } - return providers.CreateProvider(cfg) + provider, modelID, err := providers.CreateProvider(cfg) + if err != nil { + return nil, "", err + } + return providers.WithDefaultModel(provider, modelID), modelID, nil } func setupAndStartServices( diff --git a/pkg/providers/fixed_model_provider.go b/pkg/providers/fixed_model_provider.go new file mode 100644 index 000000000..18fce3e9c --- /dev/null +++ b/pkg/providers/fixed_model_provider.go @@ -0,0 +1,70 @@ +package providers + +import ( + "context" + "strings" +) + +type fixedModelProvider struct { + inner LLMProvider + model string +} + +func WithDefaultModel(provider LLMProvider, model string) LLMProvider { + model = strings.TrimSpace(model) + if provider == nil || model == "" { + return provider + } + return &fixedModelProvider{ + inner: provider, + model: model, + } +} + +func (p *fixedModelProvider) Chat( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, +) (*LLMResponse, error) { + return p.inner.Chat(ctx, messages, tools, model, options) +} + +func (p *fixedModelProvider) GetDefaultModel() string { + if p == nil { + return "" + } + return p.model +} + +func (p *fixedModelProvider) Close() { + if inner, ok := p.inner.(StatefulProvider); ok { + inner.Close() + } +} + +func (p *fixedModelProvider) ChatStream( + ctx context.Context, + messages []Message, + tools []ToolDefinition, + model string, + options map[string]any, + onChunk func(accumulated string), +) (*LLMResponse, error) { + streaming, ok := p.inner.(StreamingProvider) + if !ok { + return p.inner.Chat(ctx, messages, tools, model, options) + } + return streaming.ChatStream(ctx, messages, tools, model, options, onChunk) +} + +func (p *fixedModelProvider) SupportsThinking() bool { + thinking, ok := p.inner.(ThinkingCapable) + return ok && thinking.SupportsThinking() +} + +func (p *fixedModelProvider) SupportsNativeSearch() bool { + nativeSearch, ok := p.inner.(NativeSearchCapable) + return ok && nativeSearch.SupportsNativeSearch() +} diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index f5985a662..e7a82329c 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -42,11 +42,8 @@ func (info SkillInfo) validate() error { if info.Name == "" { errs = errors.Join(errs, errors.New("name is required")) } else { - if len(info.Name) > MaxNameLength { - errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength)) - } - if !namePattern.MatchString(info.Name) { - errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens")) + if err := ValidateSkillName(info.Name); err != nil { + errs = errors.Join(errs, err) } } @@ -148,6 +145,10 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo { } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { + if err := ValidateSkillName(name); err != nil { + return "", false + } + // 1. load from workspace skills first (project-level) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") diff --git a/pkg/skills/validation.go b/pkg/skills/validation.go new file mode 100644 index 000000000..504992b4a --- /dev/null +++ b/pkg/skills/validation.go @@ -0,0 +1,29 @@ +package skills + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/utils" +) + +func ValidateSkillName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return fmt.Errorf("skill name is required") + } + if filepath.IsAbs(trimmed) { + return fmt.Errorf("skill name must not be an absolute path") + } + if err := utils.ValidateSkillIdentifier(trimmed); err != nil { + return fmt.Errorf("skill name is invalid: %w", err) + } + if len(trimmed) > MaxNameLength { + return fmt.Errorf("skill name exceeds %d characters", MaxNameLength) + } + if !namePattern.MatchString(trimmed) { + return fmt.Errorf("skill name must be alphanumeric with hyphens") + } + return nil +}