feat: add modular agent self-evolution foundation

This commit is contained in:
lxowalle 2026-04-28 14:34:30 +08:00
parent f38debf0d8
commit f58459e762
67 changed files with 9652 additions and 47 deletions

View file

@ -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 != "" {

View file

@ -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 <draft-id>",
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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
},
}
}

View file

@ -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
}

View file

@ -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
},
}
}

View file

@ -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 <draft-id>",
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"
}
}

View file

@ -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 <skill-name>",
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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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(),

View file

@ -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 <skill-name>` 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 <draft-id>`
3. `picoclaw evolution apply <draft-id>`
4. `picoclaw evolution rollback <skill-name>`
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 <draft-id>` to inspect it
3. a human runs `picoclaw evolution apply <draft-id>` 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 <draft-id>`
3. `picoclaw evolution apply <draft-id>`
4. `picoclaw evolution rollback <skill-name>`
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
~~~

View file

@ -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()})

View file

@ -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)

View file

@ -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 {

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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 {

View file

@ -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)
}

View file

@ -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(),
},
)
}()

View file

@ -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)
}
}

View file

@ -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()

View file

@ -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 {

View file

@ -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")

View file

@ -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,

178
pkg/evolution/apply.go Normal file
View file

@ -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
}

275
pkg/evolution/apply_test.go Normal file
View file

@ -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)
}
}
}

View file

@ -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)
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}
}

249
pkg/evolution/drafts.go Normal file
View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

367
pkg/evolution/organizer.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

31
pkg/evolution/paths.go Normal file
View file

@ -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"),
}
}

View file

@ -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")
}
}

142
pkg/evolution/preview.go Normal file
View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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, " -> "),
}
}

View file

@ -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
}

615
pkg/evolution/runtime.go Normal file
View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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"
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}

384
pkg/evolution/store.go Normal file
View file

@ -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(), "-")
}

178
pkg/evolution/store_test.go Normal file
View file

@ -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")
}
}

136
pkg/evolution/types.go Normal file
View file

@ -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"`
}

View file

@ -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(

View file

@ -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()
}

View file

@ -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")

29
pkg/skills/validation.go Normal file
View file

@ -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
}