feat: integrate automatic skill evolution runtime

This commit is contained in:
lxowalle 2026-04-30 11:20:40 +08:00
parent f58459e762
commit 5e51d5c9a4
37 changed files with 2340 additions and 2090 deletions

View file

@ -1,78 +0,0 @@
package evolutioncmd
import (
"errors"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func newApplyCommand() *cobra.Command {
return &cobra.Command{
Use: "apply <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

@ -1,21 +0,0 @@
package evolutioncmd
import "github.com/spf13/cobra"
func NewEvolutionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "evolution",
Short: "Manage self-evolving skills",
}
cmd.AddCommand(
newDraftsCommand(),
newReviewCommand(),
newApplyCommand(),
newRollbackCommand(),
newStatusCommand(),
newRunOnceCommand(),
newPruneCommand(),
)
return cmd
}

View file

@ -1,969 +0,0 @@
package evolutioncmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
"github.com/sipeed/picoclaw/pkg/providers"
)
func TestNewEvolutionCommand(t *testing.T) {
cmd := NewEvolutionCommand()
if cmd.Use != "evolution" {
t.Fatalf("Use = %q, want evolution", cmd.Use)
}
names := make([]string, 0, len(cmd.Commands()))
for _, sub := range cmd.Commands() {
names = append(names, sub.Name())
}
sort.Strings(names)
want := []string{"apply", "drafts", "prune", "review", "rollback", "run-once", "status"}
if !reflect.DeepEqual(names, want) {
t.Fatalf("commands = %v, want %v", names, want)
}
}
func TestDraftsListsWorkspaceDraftDetails(t *testing.T) {
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
workspace := t.TempDir()
otherWorkspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, sharedState)
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
drafts := []evolution.SkillDraft{
{
ID: "draft-1",
WorkspaceID: workspace,
SourceRecordID: "pattern-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "Prefer native-name query first",
Status: evolution.DraftStatusCandidate,
},
{
ID: "draft-2",
WorkspaceID: otherWorkspace,
SourceRecordID: "pattern-2",
TargetSkillName: "maps",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "Other workspace draft",
Status: evolution.DraftStatusAccepted,
},
}
if err := store.SaveDrafts(drafts); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
cmd := newDraftsCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := out.String()
for _, want := range []string{
"id=draft-1",
"status=candidate",
"target=weather",
"change=append",
"summary=Prefer native-name query first",
} {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
if strings.Contains(output, "draft-2") {
t.Fatalf("output should exclude other workspace draft:\n%s", output)
}
}
func TestReviewShowsDraftDetails(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
now := time.Unix(1700000000, 0).UTC()
if err := store.SaveDrafts([]evolution.SkillDraft{
{
ID: "draft-review",
WorkspaceID: workspace,
CreatedAt: now,
SourceRecordID: "pattern-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "Prefer native-name query first",
IntendedUseCases: []string{
"weather native-name path",
},
PreferredEntryPath: []string{"weather"},
AvoidPatterns: []string{"avoid starting with geocode before using weather"},
BodyOrPatch: "## Start Here\nUse native-name query first.\n",
Status: evolution.DraftStatusCandidate,
ReviewNotes: []string{"local structural validation completed"},
},
}); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
if err := os.MkdirAll(filepath.Join(workspace, "skills", "weather"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(
filepath.Join(workspace, "skills", "weather", "SKILL.md"),
[]byte("---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names first.\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "weather",
WorkspaceID: workspace,
CurrentVersion: "draft-stable",
Status: evolution.SkillStatusActive,
Origin: "evolved",
HumanSummary: "Weather helper",
ChangeReason: "stable weather path",
IntendedUseCases: []string{"basic weather lookup"},
PreferredEntryPath: []string{"geocode", "weather"},
AvoidPatterns: []string{"avoid translating city names twice"},
LastUsedAt: now.Add(-time.Hour),
UseCount: 5,
VersionHistory: []evolution.SkillVersionEntry{
{
Version: "draft-old",
Action: "create",
Timestamp: now.Add(-48 * time.Hour),
DraftID: "draft-old",
Summary: "initial stable version",
},
{
Version: "draft-stable",
Action: "manual_apply:append",
Timestamp: now.Add(-2 * time.Hour),
DraftID: "draft-stable",
Summary: "manual CLI apply: stable weather path",
},
},
}); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
cmd := newReviewCommand()
cmd.SetArgs([]string{"draft-review"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := out.String()
for _, want := range []string{
"id=draft-review",
"source=pattern-1",
"target=weather",
"type=shortcut",
"change=append",
"status=candidate",
"review_notes=local structural validation completed",
"intended_use_cases=weather native-name path",
"preferred_entry_path=weather",
"avoid_patterns=avoid starting with geocode before using weather",
"profile:",
"skill=weather status=active version=draft-stable uses=5 reason=stable weather path",
"current_preferred_entry_path=geocode -> weather",
"recent_history:",
"version=draft-stable action=manual_apply:append draft_id=draft-stable summary=manual CLI apply: stable weather path",
"impact_preview:",
"will_update_existing_skill=true",
"expected_effect=append a new section onto the current skill",
"current_body:",
"Use city names first.",
"rendered_body:",
"## Start Here",
"diff_preview:",
"--- current",
"+++ rendered",
"@@",
"+## Start Here",
"+Use native-name query first.",
"body:",
"Use native-name query first.",
} {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
}
func TestApplyCommandAppliesDraftAndMarksAccepted(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if err := store.SaveDrafts([]evolution.SkillDraft{
{
ID: "draft-apply",
WorkspaceID: workspace,
SourceRecordID: "pattern-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "Create weather shortcut",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
Status: evolution.DraftStatusCandidate,
},
}); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
cmd := newApplyCommand()
cmd.SetArgs([]string{"draft-apply"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if drafts[0].Status != evolution.DraftStatusAccepted {
t.Fatalf("draft status = %q, want accepted", drafts[0].Status)
}
if drafts[0].UpdatedAt == nil {
t.Fatal("expected UpdatedAt to be set after apply")
}
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !strings.Contains(string(data), "Use native-name query first.") {
t.Fatalf("unexpected skill body:\n%s", string(data))
}
profile, err := store.LoadProfile("weather")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
if profile.CurrentVersion != "draft-apply" {
t.Fatalf("CurrentVersion = %q, want draft-apply", profile.CurrentVersion)
}
if profile.Status != evolution.SkillStatusActive {
t.Fatalf("Status = %q, want active", profile.Status)
}
}
func TestRollbackCommandRestoresLatestBackupAndUpdatesProfile(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n"
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
ID: "draft-new",
WorkspaceID: workspace,
SourceRecordID: "pattern-2",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "Replace weather body",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n",
Status: evolution.DraftStatusAccepted,
}); err != nil {
t.Fatalf("ApplyDraft: %v", err)
}
if err := store.SaveDrafts([]evolution.SkillDraft{
{
ID: "draft-new",
WorkspaceID: workspace,
SourceRecordID: "pattern-2",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "Replace weather body",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n",
Status: evolution.DraftStatusAccepted,
},
}); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "weather",
WorkspaceID: workspace,
CurrentVersion: "draft-new",
Status: evolution.SkillStatusActive,
Origin: "evolved",
HumanSummary: "Weather skill",
LastUsedAt: time.Unix(1700000000, 0).UTC(),
UseCount: 3,
RetentionScore: 0.8,
VersionHistory: []evolution.SkillVersionEntry{
{
Version: "draft-old",
Action: "create",
Timestamp: time.Unix(1699990000, 0).UTC(),
DraftID: "draft-old",
Summary: "old stable",
},
{
Version: "draft-new",
Action: "replace",
Timestamp: time.Unix(1700000000, 0).UTC(),
DraftID: "draft-new",
Summary: "new risky",
},
},
}); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
cmd := newRollbackCommand()
cmd.SetArgs([]string{"weather"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
data, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(data) != original {
t.Fatalf("rollback did not restore original body:\n%s", string(data))
}
profile, err := store.LoadProfile("weather")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
if profile.CurrentVersion != "draft-old" {
t.Fatalf("CurrentVersion = %q, want draft-old", profile.CurrentVersion)
}
if len(profile.VersionHistory) != 3 {
t.Fatalf("len(VersionHistory) = %d, want 3", len(profile.VersionHistory))
}
last := profile.VersionHistory[len(profile.VersionHistory)-1]
if last.Action != "manual_rollback" {
t.Fatalf("Action = %q, want manual_rollback", last.Action)
}
if !last.Rollback {
t.Fatal("expected rollback history entry")
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if drafts[0].Status != evolution.DraftStatusQuarantined {
t.Fatalf("draft status = %q, want quarantined after rollback", drafts[0].Status)
}
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "rolled back") {
t.Fatalf("ReviewNotes = %v, want rollback note", drafts[0].ReviewNotes)
}
}
func TestDraftGeneratorForRunOnce_FallsBackWhenProviderCreationFails(t *testing.T) {
originalFactory := createEvolutionProvider
createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) {
return nil, "", errors.New("provider init failed")
}
defer func() {
createEvolutionProvider = originalFactory
}()
workspace := t.TempDir()
generator := draftGeneratorForRunOnce(&config.Config{}, workspace)
draft, err := generator.GenerateDraft(t.Context(), evolution.LearningRecord{
ID: "rule-1",
Summary: "weather native-name path",
EventCount: 2,
SuccessRate: 1,
WinningPath: []string{"weather"},
}, nil)
if err != nil {
t.Fatalf("GenerateDraft: %v", err)
}
if draft.TargetSkillName != "weather" {
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
}
if draft.BodyOrPatch == "" {
t.Fatal("expected fallback template draft body")
}
}
func TestDraftGeneratorForRunOnce_UsesExplicitModelIDFromProviderFactory(t *testing.T) {
originalFactory := createEvolutionProvider
provider := &runOnceProvider{
defaultModel: "",
response: &providers.LLMResponse{
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name query first","body_or_patch":"## Start Here\nUse native-name query first."}`,
},
}
createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) {
return provider, "configured-model-id", nil
}
defer func() {
createEvolutionProvider = originalFactory
}()
workspace := t.TempDir()
generator := draftGeneratorForRunOnce(&config.Config{}, workspace)
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
ID: "rule-1",
Summary: "weather native-name path",
EventCount: 2,
SuccessRate: 1,
WinningPath: []string{"weather"},
}, nil)
if err != nil {
t.Fatalf("GenerateDraft: %v", err)
}
if provider.lastModel != "configured-model-id" {
t.Fatalf("lastModel = %q, want configured-model-id", provider.lastModel)
}
if draft.TargetSkillName != "weather" {
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
}
if draft.HumanSummary != "Prefer native-name query first" {
t.Fatalf("HumanSummary = %q, want %q", draft.HumanSummary, "Prefer native-name query first")
}
}
func TestPruneAppendsLifecycleHistory(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# weather\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "weather",
WorkspaceID: workspace,
Status: evolution.SkillStatusArchived,
Origin: "evolved",
HumanSummary: "weather helper",
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
VersionHistory: []evolution.SkillVersionEntry{{
Version: "v1",
Action: "create",
Timestamp: time.Now().Add(-400 * 24 * time.Hour),
Summary: "initial version",
}},
}); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
cmd := newPruneCommand()
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
profile, err := store.LoadProfile("weather")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
if profile.Status != evolution.SkillStatusDeleted {
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusDeleted)
}
if len(profile.VersionHistory) != 2 {
t.Fatalf("len(VersionHistory) = %d, want 2", len(profile.VersionHistory))
}
last := profile.VersionHistory[len(profile.VersionHistory)-1]
if last.Action != "lifecycle:deleted" {
t.Fatalf("Action = %q, want lifecycle:deleted", last.Action)
}
if !strings.Contains(last.Summary, "archived -> deleted") {
t.Fatalf("Summary = %q, want lifecycle transition summary", last.Summary)
}
}
func TestPruneScopesProfilesToCurrentWorkspaceInSharedState(t *testing.T) {
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
workspace := t.TempDir()
otherWorkspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, sharedState)
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
if err := os.MkdirAll(filepath.Join(workspace, "skills", "mine"), 0o755); err != nil {
t.Fatalf("MkdirAll current skill: %v", err)
}
if err := os.WriteFile(filepath.Join(workspace, "skills", "mine", "SKILL.md"), []byte("# mine\n"), 0o644); err != nil {
t.Fatalf("WriteFile current skill: %v", err)
}
if err := os.MkdirAll(filepath.Join(otherWorkspace, "skills", "theirs"), 0o755); err != nil {
t.Fatalf("MkdirAll other skill: %v", err)
}
if err := os.WriteFile(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md"), []byte("# theirs\n"), 0o644); err != nil {
t.Fatalf("WriteFile other skill: %v", err)
}
profiles := []evolution.SkillProfile{
{
SkillName: "mine",
WorkspaceID: workspace,
Status: evolution.SkillStatusArchived,
Origin: "evolved",
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
},
{
SkillName: "theirs",
WorkspaceID: otherWorkspace,
Status: evolution.SkillStatusArchived,
Origin: "evolved",
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
},
{
SkillName: "legacy",
Status: evolution.SkillStatusArchived,
Origin: "evolved",
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
},
}
for _, profile := range profiles {
if err := store.SaveProfile(profile); err != nil {
t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err)
}
}
cmd := newPruneCommand()
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
mine, err := store.LoadProfile("mine")
if err != nil {
t.Fatalf("LoadProfile(mine): %v", err)
}
if mine.Status != evolution.SkillStatusDeleted {
t.Fatalf("mine.Status = %q, want %q", mine.Status, evolution.SkillStatusDeleted)
}
if len(mine.VersionHistory) != 1 || mine.VersionHistory[0].Action != "lifecycle:deleted" {
t.Fatalf("mine.VersionHistory = %+v, want lifecycle delete entry", mine.VersionHistory)
}
theirs, err := store.LoadProfile("theirs")
if err != nil {
t.Fatalf("LoadProfile(theirs): %v", err)
}
if theirs.Status != evolution.SkillStatusArchived {
t.Fatalf("theirs.Status = %q, want archived", theirs.Status)
}
if len(theirs.VersionHistory) != 0 {
t.Fatalf("theirs.VersionHistory = %+v, want unchanged", theirs.VersionHistory)
}
if _, statErr := os.Stat(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md")); statErr != nil {
t.Fatalf("other workspace skill should remain, stat err = %v", statErr)
}
legacy, err := store.LoadProfile("legacy")
if err != nil {
t.Fatalf("LoadProfile(legacy): %v", err)
}
if legacy.Status != evolution.SkillStatusArchived {
t.Fatalf("legacy.Status = %q, want archived", legacy.Status)
}
if len(legacy.VersionHistory) != 0 {
t.Fatalf("legacy.VersionHistory = %+v, want unchanged", legacy.VersionHistory)
}
}
func TestPruneDoesNotMutateProfileWhenDeleteActionFails(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
paths := evolution.NewPaths(workspace, "")
store := evolution.NewStore(paths)
legacyProfile := evolution.SkillProfile{
SkillName: "../escape",
WorkspaceID: workspace,
Status: evolution.SkillStatusArchived,
Origin: "evolved",
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
VersionHistory: []evolution.SkillVersionEntry{{
Version: "v1",
Action: "create",
Timestamp: time.Now().Add(-400 * 24 * time.Hour),
Summary: "initial version",
}},
}
if err := os.MkdirAll(paths.ProfilesDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
data, err := json.MarshalIndent(legacyProfile, "", " ")
if err != nil {
t.Fatalf("MarshalIndent: %v", err)
}
if err := os.WriteFile(filepath.Join(paths.ProfilesDir, "weather.json"), data, 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cmd := newPruneCommand()
err = cmd.Execute()
if err == nil {
t.Fatal("expected prune to fail when delete action cannot resolve skill path")
}
profile, loadErr := store.LoadProfile("weather")
if loadErr != nil {
t.Fatalf("LoadProfile: %v", loadErr)
}
if profile.Status != evolution.SkillStatusArchived {
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusArchived)
}
if len(profile.VersionHistory) != 1 {
t.Fatalf("len(VersionHistory) = %d, want 1", len(profile.VersionHistory))
}
}
func TestStatusShowsDraftAndProfileDistributions(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
now := time.Unix(1700000000, 0).UTC()
drafts := []evolution.SkillDraft{
{ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", HumanSummary: "first candidate", ChangeKind: evolution.ChangeKindAppend, Status: evolution.DraftStatusCandidate, UpdatedAt: timePtr(now.Add(-2 * time.Hour))},
{ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", HumanSummary: "middle quarantine", ChangeKind: evolution.ChangeKindReplace, Status: evolution.DraftStatusQuarantined, UpdatedAt: timePtr(now.Add(-1 * time.Hour))},
{ID: "d3", WorkspaceID: workspace, TargetSkillName: "skill-c", HumanSummary: "latest accepted", ChangeKind: evolution.ChangeKindCreate, Status: evolution.DraftStatusAccepted, UpdatedAt: timePtr(now)},
}
if err := store.SaveDrafts(drafts); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
profiles := []evolution.SkillProfile{
{SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive, CurrentVersion: "v-a", UseCount: 5, ChangeReason: "active reason", LastUsedAt: now.Add(-3 * time.Hour)},
{SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold, CurrentVersion: "v-b", UseCount: 2, ChangeReason: "cold reason", LastUsedAt: now.Add(-2 * time.Hour)},
{SkillName: "skill-c", WorkspaceID: workspace, Status: evolution.SkillStatusArchived, CurrentVersion: "v-c", UseCount: 1, ChangeReason: "archived reason", LastUsedAt: now},
{SkillName: "skill-d", WorkspaceID: workspace, Status: evolution.SkillStatusDeleted, CurrentVersion: "v-d", UseCount: 0, ChangeReason: "deleted reason", LastUsedAt: now.Add(-4 * time.Hour)},
}
for _, profile := range profiles {
if err := store.SaveProfile(profile); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
}
cmd := newStatusCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := out.String()
for _, want := range []string{
"drafts=3",
"drafts_by_status=candidate:1 quarantined:1 accepted:1",
"profiles=4",
"profiles_by_status=active:1 cold:1 archived:1 deleted:1",
"draft_items:",
"id=d3 status=accepted target=skill-c change=create summary=latest accepted",
"profile_items:",
"skill=skill-c status=archived version=v-c uses=1 reason=archived reason",
} {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
}
func TestStatusScopesCountsToCurrentWorkspaceInSharedState(t *testing.T) {
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
workspace := t.TempDir()
otherWorkspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, sharedState)
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
drafts := []evolution.SkillDraft{
{ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", Status: evolution.DraftStatusCandidate},
{ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", Status: evolution.DraftStatusAccepted},
{ID: "d3", WorkspaceID: otherWorkspace, TargetSkillName: "skill-c", Status: evolution.DraftStatusQuarantined},
{ID: "d4", TargetSkillName: "legacy-draft", Status: evolution.DraftStatusCandidate},
}
if err := store.SaveDrafts(drafts); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
profiles := []evolution.SkillProfile{
{SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive},
{SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold},
{SkillName: "skill-c", WorkspaceID: otherWorkspace, Status: evolution.SkillStatusDeleted},
{SkillName: "legacy", Status: evolution.SkillStatusArchived},
}
for _, profile := range profiles {
if err := store.SaveProfile(profile); err != nil {
t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err)
}
}
cmd := newStatusCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
output := out.String()
for _, want := range []string{
"drafts=2",
"drafts_by_status=candidate:1 quarantined:0 accepted:1",
"profiles=2",
"profiles_by_status=active:1 cold:1 archived:0 deleted:0",
} {
if !strings.Contains(output, want) {
t.Fatalf("output missing %q:\n%s", want, output)
}
}
}
func TestApplyCommandAddsExplicitManualAuditInfo(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if err := store.SaveDrafts([]evolution.SkillDraft{
{
ID: "draft-apply-audit",
WorkspaceID: workspace,
SourceRecordID: "pattern-1",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "Create weather shortcut",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
Status: evolution.DraftStatusCandidate,
},
}); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
cmd := newApplyCommand()
cmd.SetArgs([]string{"draft-apply-audit"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "manually applied via CLI") {
t.Fatalf("ReviewNotes = %v, want manual apply note", drafts[0].ReviewNotes)
}
profile, err := store.LoadProfile("weather")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
last := profile.VersionHistory[len(profile.VersionHistory)-1]
if last.Action != "manual_apply:create" {
t.Fatalf("Action = %q, want manual_apply:create", last.Action)
}
if !strings.Contains(last.Summary, "manual CLI apply") {
t.Fatalf("Summary = %q, want manual CLI apply summary", last.Summary)
}
}
func TestRollbackCommandAddsExplicitManualAuditInfo(t *testing.T) {
workspace := t.TempDir()
configureEvolutionCommandTest(t, workspace, "")
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
skillDir := filepath.Join(workspace, "skills", "weather")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n"
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
ID: "draft-risky",
WorkspaceID: workspace,
SourceRecordID: "pattern-2",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "Replace weather body",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n",
Status: evolution.DraftStatusAccepted,
}); err != nil {
t.Fatalf("ApplyDraft: %v", err)
}
if err := store.SaveDrafts([]evolution.SkillDraft{{
ID: "draft-risky",
WorkspaceID: workspace,
SourceRecordID: "pattern-2",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindReplace,
HumanSummary: "Replace weather body",
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n",
Status: evolution.DraftStatusAccepted,
}}); err != nil {
t.Fatalf("SaveDrafts: %v", err)
}
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "weather",
WorkspaceID: workspace,
CurrentVersion: "draft-risky",
Status: evolution.SkillStatusActive,
Origin: "evolved",
HumanSummary: "Weather skill",
LastUsedAt: time.Unix(1700000000, 0).UTC(),
VersionHistory: []evolution.SkillVersionEntry{
{Version: "draft-old", Action: "create", Timestamp: time.Unix(1699990000, 0).UTC(), DraftID: "draft-old", Summary: "old stable"},
{Version: "draft-risky", Action: "replace", Timestamp: time.Unix(1700000000, 0).UTC(), DraftID: "draft-risky", Summary: "new risky"},
},
}); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
cmd := newRollbackCommand()
cmd.SetArgs([]string{"weather"})
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
profile, err := store.LoadProfile("weather")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
last := profile.VersionHistory[len(profile.VersionHistory)-1]
if last.Action != "manual_rollback" {
t.Fatalf("Action = %q, want manual_rollback", last.Action)
}
if !strings.Contains(last.Summary, "manual CLI rollback") {
t.Fatalf("Summary = %q, want manual rollback summary", last.Summary)
}
if last.DraftID != "draft-risky" {
t.Fatalf("DraftID = %q, want draft-risky", last.DraftID)
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "backup=") {
t.Fatalf("ReviewNotes = %v, want backup path note", drafts[0].ReviewNotes)
}
}
func configureEvolutionCommandTest(t *testing.T, workspace, stateDir string) {
t.Helper()
configPath := filepath.Join(t.TempDir(), "config.json")
t.Setenv(config.EnvConfig, configPath)
t.Setenv(config.EnvHome, t.TempDir())
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
},
},
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "observe",
StateDir: stateDir,
},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig: %v", err)
}
}
type runOnceProvider struct {
response *providers.LLMResponse
err error
defaultModel string
lastModel string
}
func (p *runOnceProvider) Chat(
_ context.Context,
_ []providers.Message,
_ []providers.ToolDefinition,
model string,
_ map[string]any,
) (*providers.LLMResponse, error) {
p.lastModel = model
return p.response, p.err
}
func (p *runOnceProvider) GetDefaultModel() string {
return p.defaultModel
}

View file

@ -1,56 +0,0 @@
package evolutioncmd
import (
"fmt"
"time"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func loadWorkspaceDraft(store *evolution.Store, workspace, stateDir, id string) (evolution.SkillDraft, error) {
drafts, err := store.LoadDrafts()
if err != nil {
return evolution.SkillDraft{}, err
}
paths := evolution.NewPaths(workspace, stateDir)
_, draft, err := findWorkspaceDraft(drafts, paths, workspace, id)
return draft, err
}
func findWorkspaceDraft(
drafts []evolution.SkillDraft,
paths evolution.Paths,
workspace, id string,
) (int, evolution.SkillDraft, error) {
for i, draft := range drafts {
if !draftBelongsToWorkspace(paths, workspace, draft) {
continue
}
if draft.ID == id {
return i, draft, nil
}
}
return -1, evolution.SkillDraft{}, fmt.Errorf("draft %q not found for workspace", id)
}
func appendUniqueStrings(existing []string, values ...string) []string {
seen := make(map[string]struct{}, len(existing))
for _, value := range existing {
seen[value] = struct{}{}
}
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
existing = append(existing, value)
seen[value] = struct{}{}
}
return existing
}
func timePtr(v time.Time) *time.Time {
return &v
}

View file

@ -1,58 +0,0 @@
package evolutioncmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func newDraftsCommand() *cobra.Command {
return &cobra.Command{
Use: "drafts",
Short: "List skill drafts for the current workspace",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, store, workspace, err := loadEvolutionDeps()
if err != nil {
return err
}
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
drafts, err := store.LoadDrafts()
if err != nil {
return err
}
filtered := make([]evolution.SkillDraft, 0, len(drafts))
for _, draft := range drafts {
if draftBelongsToWorkspace(paths, workspace, draft) {
filtered = append(filtered, draft)
}
}
sort.Slice(filtered, func(i, j int) bool {
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
return filtered[i].ID < filtered[j].ID
}
return filtered[i].CreatedAt.Before(filtered[j].CreatedAt)
})
for _, draft := range filtered {
if _, err := fmt.Fprintf(
cmd.OutOrStdout(),
"id=%s status=%s target=%s type=%s change=%s summary=%s\n",
draft.ID,
draft.Status,
draft.TargetSkillName,
draft.DraftType,
draft.ChangeKind,
draft.HumanSummary,
); err != nil {
return err
}
}
return nil
},
}
}

View file

@ -1,18 +0,0 @@
package evolutioncmd
import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func loadEvolutionDeps() (*config.Config, *evolution.Store, string, error) {
cfg, err := internal.LoadConfig()
if err != nil {
return nil, nil, "", err
}
workspace := cfg.WorkspacePath()
store := evolution.NewStore(evolution.NewPaths(workspace, cfg.Evolution.StateDir))
return cfg, store, workspace, nil
}

View file

@ -1,54 +0,0 @@
package evolutioncmd
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func newPruneCommand() *cobra.Command {
return &cobra.Command{
Use: "prune",
Short: "Recompute lifecycle states for learned skills",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, store, workspace, err := loadEvolutionDeps()
if err != nil {
return err
}
profiles, err := store.LoadProfiles()
if err != nil {
return err
}
now := time.Now()
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
for _, profile := range profiles {
if !profileBelongsToWorkspace(paths, workspace, profile) {
continue
}
next := evolution.NextLifecycleState(profile, now)
if next != profile.Status {
if err := evolution.ApplyLifecycleState(paths, profile, next); err != nil {
return err
}
profile.VersionHistory = append(profile.VersionHistory, evolution.SkillVersionEntry{
Version: profile.CurrentVersion,
Action: "lifecycle:" + string(next),
Timestamp: now,
Summary: fmt.Sprintf("lifecycle transition: %s -> %s", profile.Status, next),
})
profile.Status = next
}
if err := store.SaveProfile(profile); err != nil {
return err
}
}
return nil
},
}
}

View file

@ -1,127 +0,0 @@
package evolutioncmd
import (
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func newReviewCommand() *cobra.Command {
return &cobra.Command{
Use: "review <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

@ -1,160 +0,0 @@
package evolutioncmd
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
func newRollbackCommand() *cobra.Command {
return &cobra.Command{
Use: "rollback <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

@ -1,55 +0,0 @@
package evolutioncmd
import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
"github.com/sipeed/picoclaw/pkg/providers"
)
var createEvolutionProvider = providers.CreateProvider
func newRunOnceCommand() *cobra.Command {
return &cobra.Command{
Use: "run-once",
Short: "Run evolution cold path once",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, store, workspace, err := loadEvolutionDeps()
if err != nil {
return err
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: cfg.Evolution,
Store: store,
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{
MinCaseCount: cfg.Evolution.MinCaseCount,
MinSuccessRate: cfg.Evolution.MinSuccessRate,
}),
SkillsRecaller: evolution.NewSkillsRecaller(workspace),
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
return draftGeneratorForRunOnce(cfg, workspace)
},
Applier: evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil),
})
if err != nil {
return err
}
return rt.RunColdPathOnce(cmd.Context(), workspace)
},
}
}
func draftGeneratorForRunOnce(cfg *config.Config, workspace string) evolution.DraftGenerator {
if cfg == nil {
return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "")
}
provider, modelID, err := createEvolutionProvider(cfg)
if err != nil {
return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "")
}
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID)
}

View file

@ -1,173 +0,0 @@
package evolutioncmd
import (
"fmt"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func newStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show evolution status",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, store, workspace, err := loadEvolutionDeps()
if err != nil {
return err
}
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
drafts, err := store.LoadDrafts()
if err != nil {
return err
}
profiles, err := store.LoadProfiles()
if err != nil {
return err
}
drafts = filterDraftsForWorkspace(paths, workspace, drafts)
profiles = filterProfilesForWorkspace(paths, workspace, profiles)
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"workspace=%s\nenabled=%v\nmode=%s\ndrafts=%d\ndrafts_by_status=%s\nprofiles=%d\nprofiles_by_status=%s\n%s%s",
workspace,
cfg.Evolution.Enabled,
cfg.Evolution.EffectiveMode(),
len(drafts),
formatDraftStatusCounts(drafts),
len(profiles),
formatProfileStatusCounts(profiles),
formatDraftItems(drafts),
formatProfileItems(profiles),
)
return err
},
}
}
func filterDraftsForWorkspace(paths evolution.Paths, workspace string, drafts []evolution.SkillDraft) []evolution.SkillDraft {
filtered := make([]evolution.SkillDraft, 0, len(drafts))
for _, draft := range drafts {
if draftBelongsToWorkspace(paths, workspace, draft) {
filtered = append(filtered, draft)
}
}
return filtered
}
func filterProfilesForWorkspace(paths evolution.Paths, workspace string, profiles []evolution.SkillProfile) []evolution.SkillProfile {
filtered := make([]evolution.SkillProfile, 0, len(profiles))
for _, profile := range profiles {
if profileBelongsToWorkspace(paths, workspace, profile) {
filtered = append(filtered, profile)
}
}
return filtered
}
func formatDraftStatusCounts(drafts []evolution.SkillDraft) string {
counts := map[evolution.DraftStatus]int{
evolution.DraftStatusCandidate: 0,
evolution.DraftStatusQuarantined: 0,
evolution.DraftStatusAccepted: 0,
}
for _, draft := range drafts {
counts[draft.Status]++
}
parts := []string{
fmt.Sprintf("%s:%d", evolution.DraftStatusCandidate, counts[evolution.DraftStatusCandidate]),
fmt.Sprintf("%s:%d", evolution.DraftStatusQuarantined, counts[evolution.DraftStatusQuarantined]),
fmt.Sprintf("%s:%d", evolution.DraftStatusAccepted, counts[evolution.DraftStatusAccepted]),
}
return strings.Join(parts, " ")
}
func formatProfileStatusCounts(profiles []evolution.SkillProfile) string {
counts := map[evolution.SkillStatus]int{
evolution.SkillStatusActive: 0,
evolution.SkillStatusCold: 0,
evolution.SkillStatusArchived: 0,
evolution.SkillStatusDeleted: 0,
}
for _, profile := range profiles {
counts[profile.Status]++
}
parts := []string{
fmt.Sprintf("%s:%d", evolution.SkillStatusActive, counts[evolution.SkillStatusActive]),
fmt.Sprintf("%s:%d", evolution.SkillStatusCold, counts[evolution.SkillStatusCold]),
fmt.Sprintf("%s:%d", evolution.SkillStatusArchived, counts[evolution.SkillStatusArchived]),
fmt.Sprintf("%s:%d", evolution.SkillStatusDeleted, counts[evolution.SkillStatusDeleted]),
}
return strings.Join(parts, " ")
}
func formatDraftItems(drafts []evolution.SkillDraft) string {
if len(drafts) == 0 {
return "draft_items:\n"
}
items := append([]evolution.SkillDraft(nil), drafts...)
sort.Slice(items, func(i, j int) bool {
left := draftSortTime(items[i])
right := draftSortTime(items[j])
if !left.Equal(right) {
return left.After(right)
}
return items[i].ID < items[j].ID
})
lines := []string{"draft_items:"}
for _, draft := range items {
lines = append(lines, fmt.Sprintf(
"id=%s status=%s target=%s change=%s summary=%s",
draft.ID,
draft.Status,
draft.TargetSkillName,
draft.ChangeKind,
draft.HumanSummary,
))
}
return strings.Join(lines, "\n") + "\n"
}
func formatProfileItems(profiles []evolution.SkillProfile) string {
if len(profiles) == 0 {
return "profile_items:\n"
}
items := append([]evolution.SkillProfile(nil), profiles...)
sort.Slice(items, func(i, j int) bool {
if !items[i].LastUsedAt.Equal(items[j].LastUsedAt) {
return items[i].LastUsedAt.After(items[j].LastUsedAt)
}
return items[i].SkillName < items[j].SkillName
})
lines := []string{"profile_items:"}
for _, profile := range items {
lines = append(lines, fmt.Sprintf(
"skill=%s status=%s version=%s uses=%d reason=%s",
profile.SkillName,
profile.Status,
profile.CurrentVersion,
profile.UseCount,
profile.ChangeReason,
))
}
return strings.Join(lines, "\n") + "\n"
}
func draftSortTime(draft evolution.SkillDraft) time.Time {
if draft.UpdatedAt != nil {
return *draft.UpdatedAt
}
return draft.CreatedAt
}

View file

@ -1,37 +0,0 @@
package evolutioncmd
import (
"path/filepath"
"github.com/sipeed/picoclaw/pkg/evolution"
)
func draftBelongsToWorkspace(paths evolution.Paths, workspace string, draft evolution.SkillDraft) bool {
if draft.WorkspaceID == workspace {
return true
}
return draft.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace)
}
func profileBelongsToWorkspace(paths evolution.Paths, workspace string, profile evolution.SkillProfile) bool {
if profile.WorkspaceID == workspace {
return true
}
return profile.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace)
}
func usesDefaultWorkspaceState(paths evolution.Paths, workspace string) bool {
return paths.RootDir == evolution.NewPaths(workspace, "").RootDir
}
func inferWorkspaceFromPaths(paths evolution.Paths) string {
root := filepath.Clean(paths.RootDir)
if filepath.Base(root) != "evolution" {
return ""
}
stateDir := filepath.Dir(root)
if filepath.Base(stateDir) != "state" {
return ""
}
return filepath.Dir(stateDir)
}

View file

@ -18,7 +18,6 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
evolutioncmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/evolution"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
@ -90,7 +89,6 @@ picoclaw --no-color status`,
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
evolutioncmd.NewEvolutionCommand(),
model.NewModelCommand(),
updater.NewUpdateCommand("picoclaw"),
version.NewVersionCommand(),

View file

@ -131,6 +131,13 @@ type SkillContextSnapshot struct {
SkillNames []string `json:"skill_names,omitempty"`
}
type ToolExecutionRecord struct {
Name string `json:"name"`
Success bool `json:"success"`
ErrorSummary string `json:"error_summary,omitempty"`
SkillNames []string `json:"skill_names,omitempty"`
}
// TurnEndPayload describes the completion of a turn.
type TurnEndPayload struct {
Status TurnEndStatus
@ -138,11 +145,14 @@ type TurnEndPayload struct {
Iterations int
Duration time.Duration
FinalContentLen int
UserMessage string
FinalContent string
ActiveSkills []string
AttemptedSkills []string
FinalSuccessfulPath []string
SkillContextSnapshots []SkillContextSnapshot
ToolKinds []string
ToolExecutions []ToolExecutionRecord
}
// LLMRequestPayload describes an outbound LLM request.

View file

@ -2,6 +2,7 @@ package agent
import (
"context"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
@ -16,6 +17,9 @@ type evolutionBridge struct {
registry *AgentRegistry
runtime *evolution.Runtime
coldPathRunner *evolution.ColdPathRunner
bgCtx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider providers.LLMProvider) (*evolutionBridge, error) {
@ -32,6 +36,9 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID)
},
SuccessJudgeFactory: func(workspace string) evolution.SuccessJudge {
return evolution.NewLLMTaskSuccessJudge(provider, modelID, &evolution.HeuristicSuccessJudge{})
},
ApplierFactory: func(workspace string) *evolution.Applier {
return evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil)
},
@ -39,13 +46,16 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr
if err != nil {
return nil, err
}
bgCtx, cancel := context.WithCancel(context.Background())
bridge := &evolutionBridge{
cfg: cfg.Evolution,
registry: registry,
runtime: runtime,
bgCtx: bgCtx,
cancel: cancel,
}
if cfg.Evolution.AutoRunColdPath {
if cfg.Evolution.RunsColdPathAutomatically() {
bridge.coldPathRunner = evolution.NewColdPathRunnerWithErrorHandler(runtime, func(err error) {
logger.WarnCF("agent", "Cold path run failed", map[string]any{
"error": err.Error(),
@ -57,13 +67,21 @@ func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider pr
}
func (b *evolutionBridge) Close() error {
if b == nil || b.coldPathRunner == nil {
if b == nil {
return nil
}
return b.coldPathRunner.Close()
if b.cancel != nil {
b.cancel()
}
var closeErr error
if b.coldPathRunner != nil {
closeErr = b.coldPathRunner.Close()
}
b.wg.Wait()
return closeErr
}
func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error {
func (b *evolutionBridge) OnEvent(_ context.Context, evt Event) error {
if b == nil || !b.cfg.Enabled || b.runtime == nil {
return nil
}
@ -74,30 +92,57 @@ func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error {
if !ok {
return nil
}
if err := b.runtime.FinalizeTurn(ctx, evolution.TurnCaseInput{
Workspace: payload.Workspace,
WorkspaceID: payload.Workspace,
TurnID: evt.Meta.TurnID,
SessionKey: evt.Meta.SessionKey,
AgentID: evt.Meta.AgentID,
Status: string(payload.Status),
ToolKinds: append([]string(nil), payload.ToolKinds...),
ActiveSkillNames: append([]string(nil), payload.ActiveSkills...),
AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...),
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
}); err != nil {
return err
}
if b.coldPathRunner != nil {
b.coldPathRunner.Trigger(payload.Workspace)
}
b.handleTurnEndAsync(evt.Meta, payload)
return nil
}
return nil
}
func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayload) {
if b == nil || b.runtime == nil {
return
}
input := evolution.TurnCaseInput{
Workspace: payload.Workspace,
WorkspaceID: payload.Workspace,
TurnID: meta.TurnID,
SessionKey: meta.SessionKey,
AgentID: meta.AgentID,
Status: string(payload.Status),
UserMessage: payload.UserMessage,
FinalContent: payload.FinalContent,
ToolKinds: append([]string(nil), payload.ToolKinds...),
ToolExecutions: toEvolutionToolExecutions(payload.ToolExecutions),
ActiveSkillNames: append([]string(nil), payload.ActiveSkills...),
AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...),
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
}
ctx := b.bgCtx
if ctx == nil {
ctx = context.Background()
}
b.wg.Add(1)
go func() {
defer b.wg.Done()
if err := b.runtime.FinalizeTurn(ctx, input); err != nil {
logger.WarnCF("agent", "Evolution finalize turn failed", map[string]any{
"error": err.Error(),
"turn_id": input.TurnID,
"workspace": input.Workspace,
})
return
}
if b.coldPathRunner != nil {
b.coldPathRunner.Trigger(input.Workspace)
}
}()
}
func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.SkillContextSnapshot {
if len(input) == 0 {
return nil
@ -113,3 +158,20 @@ func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.
}
return out
}
func toEvolutionToolExecutions(input []ToolExecutionRecord) []evolution.ToolExecutionRecord {
if len(input) == 0 {
return nil
}
out := make([]evolution.ToolExecutionRecord, 0, len(input))
for _, record := range input {
out = append(out, evolution.ToolExecutionRecord{
Name: record.Name,
Success: record.Success,
ErrorSummary: record.ErrorSummary,
SkillNames: append([]string(nil), record.SkillNames...),
})
}
return out
}

View file

@ -84,16 +84,24 @@ func TestEvolutionBridge_ObserveWritesCaseRecord(t *testing.T) {
t.Fatalf("tool_kinds = %#v, want [echo_text]", toolKinds)
}
activeSkillsRaw, exists := record["active_skill_names"]
activeSkillsRaw, exists := record["initial_skill_names"]
if !exists {
t.Fatal("active_skill_names field missing")
t.Fatal("initial_skill_names field missing")
}
activeSkills, ok := activeSkillsRaw.([]any)
if !ok {
t.Fatalf("active_skill_names wrong type: %#v", activeSkillsRaw)
t.Fatalf("initial_skill_names wrong type: %#v", activeSkillsRaw)
}
if len(activeSkills) != 1 || activeSkills[0] != "observe-skill" {
t.Fatalf("active_skill_names = %#v, want [observe-skill]", activeSkills)
t.Fatalf("initial_skill_names = %#v, want [observe-skill]", activeSkills)
}
toolExecsRaw, exists := record["tool_executions"]
if !exists {
t.Fatal("tool_executions field missing")
}
toolExecs, ok := toolExecsRaw.([]any)
if !ok || len(toolExecs) != 1 {
t.Fatalf("tool_executions wrong type: %#v", toolExecsRaw)
}
}
@ -238,14 +246,13 @@ func TestEvolutionBridge_ObserveDoesNotCreateDraftFile(t *testing.T) {
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
}
func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) {
func TestEvolutionBridge_DraftModeAutomaticallyRunsColdPathAndCreatesDraftFile(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "review",
AutoRunColdPath: true,
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
@ -261,14 +268,13 @@ func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) {
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
}
func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *testing.T) {
func TestEvolutionBridge_DraftModeUsesProviderBackedDraftGenerator(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "review",
AutoRunColdPath: true,
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`,
})
@ -289,7 +295,7 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *test
}
}
func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) {
func TestEvolutionBridge_DraftModeUsesProviderDefaultModel(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
@ -299,9 +305,8 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) {
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "review",
AutoRunColdPath: true,
Enabled: true,
Mode: "draft",
}, provider)
defer al.Close()
@ -316,15 +321,13 @@ func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) {
}
}
func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) {
func TestEvolutionBridge_DraftModeKeepsCandidateDraft(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "apply",
AutoRunColdPath: true,
AutoApply: false,
Enabled: true,
Mode: "draft",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"create","human_summary":"Create weather helper","body_or_patch":"---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n"}`,
})
@ -344,7 +347,7 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateD
assertProfileNotExists(t, tmpDir, "weather")
}
func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testing.T) {
func TestEvolutionBridge_ApplyModeAutomaticallyRunsColdPathAndAppliesMergeDraft(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
@ -359,10 +362,8 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testin
}
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "apply",
AutoRunColdPath: true,
AutoApply: true,
Enabled: true,
Mode: "apply",
}, &simpleMockProvider{
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"merge","human_summary":"Merge native-name path","body_or_patch":"Prefer native-name query first."}`,
})
@ -398,14 +399,13 @@ func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testin
}
}
func TestEvolutionBridge_AutoRunColdPathDisabledDoesNotCreateDraftFile(t *testing.T) {
func TestEvolutionBridge_ObserveModeDoesNotRunColdPathOrCreateDraftFile(t *testing.T) {
tmpDir := t.TempDir()
seedReadyRule(t, tmpDir)
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
Enabled: true,
Mode: "review",
AutoRunColdPath: false,
Enabled: true,
Mode: "observe",
}, &simpleMockProvider{response: "ok"})
defer al.Close()
@ -516,21 +516,24 @@ func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) {
if len(finalPath) != 2 || finalPath[0] != "geocode" || finalPath[1] != "weather" {
t.Fatalf("final_successful_path = %#v, want [geocode weather]", finalPath)
}
skillSnapshots, ok := attemptTrailRaw["skill_context_snapshots"].([]any)
if !ok {
t.Fatalf("skill_context_snapshots wrong type: %#v", attemptTrailRaw["skill_context_snapshots"])
if _, exists := attemptTrailRaw["skill_context_snapshots"]; exists {
t.Fatalf("skill_context_snapshots should not be persisted: %#v", attemptTrailRaw["skill_context_snapshots"])
}
if len(skillSnapshots) != 2 {
t.Fatalf("len(skill_context_snapshots) = %d, want 2", len(skillSnapshots))
initialSkills, ok := record["initial_skill_names"].([]any)
if !ok || len(initialSkills) != 1 || initialSkills[0] != "weather" {
t.Fatalf("initial_skill_names = %#v, want [weather]", record["initial_skill_names"])
}
addedSkills, ok := record["added_skill_names"].([]any)
if !ok || len(addedSkills) != 1 || addedSkills[0] != "geocode" {
t.Fatalf("added_skill_names = %#v, want [geocode]", record["added_skill_names"])
}
}
func TestEvolutionBridge_CloseStopsColdPathRunnerIdempotently(t *testing.T) {
cfg := &config.Config{
Evolution: config.EvolutionConfig{
Enabled: true,
Mode: "review",
AutoRunColdPath: true,
Enabled: true,
Mode: "draft",
},
}
@ -660,12 +663,17 @@ func waitForEvolutionRecord(t *testing.T, path string) map[string]any {
data, err := os.ReadFile(path)
if err == nil {
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) == 1 && lines[0] != "" {
for i := len(lines) - 1; i >= 0; i-- {
if strings.TrimSpace(lines[i]) == "" {
continue
}
var record map[string]any
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
if err := json.Unmarshal([]byte(lines[i]), &record); err != nil {
t.Fatalf("json.Unmarshal(%s): %v", path, err)
}
return record
if kind, _ := record["kind"].(string); kind == string(evolution.RecordKindTask) {
return record
}
}
}
time.Sleep(10 * time.Millisecond)

View file

@ -6,6 +6,9 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
@ -16,6 +19,89 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
func toolErrorSummary(result *tools.ToolResult) string {
if result == nil || !result.IsError {
return ""
}
content := strings.TrimSpace(result.ContentForLLM())
if content == "" && result.Err != nil {
content = strings.TrimSpace(result.Err.Error())
}
return utils.Truncate(content, 200)
}
func inferSkillNamesFromToolCall(ts *turnState, toolName string, toolArgs map[string]any) []string {
if ts == nil || toolName != "read_file" {
return nil
}
rawPath, ok := toolArgs["path"].(string)
if !ok {
return nil
}
path := strings.TrimSpace(rawPath)
if path == "" {
return nil
}
cleanPath := filepath.Clean(path)
if !filepath.IsAbs(cleanPath) {
cleanPath = filepath.Join(ts.workspace, cleanPath)
}
if filepath.Base(cleanPath) != "SKILL.md" {
return nil
}
var roots []string
if ts.agent != nil && ts.agent.ContextBuilder != nil {
roots = ts.agent.ContextBuilder.skillRoots()
}
if len(roots) == 0 && strings.TrimSpace(ts.workspace) != "" {
roots = []string{filepath.Join(ts.workspace, "skills")}
}
found := make(map[string]struct{})
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
rel, err := filepath.Rel(filepath.Clean(root), cleanPath)
if err != nil {
continue
}
if rel == "." || rel == "" || strings.HasPrefix(rel, "..") {
continue
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[1] != "SKILL.md" {
continue
}
skillName := strings.TrimSpace(parts[0])
if skillName == "" {
continue
}
if ts.agent != nil && ts.agent.ContextBuilder != nil {
if canonical, ok := ts.agent.ContextBuilder.ResolveSkillName(skillName); ok {
skillName = canonical
}
}
found[skillName] = struct{}{}
}
if len(found) == 0 {
return nil
}
names := make([]string, 0, len(found))
for skillName := range found {
names = append(names, skillName)
}
sort.Strings(names)
return names
}
// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks,
// tool execution with async callbacks, media delivery, and steering injection.
// Returns ToolControl indicating what the coordinator should do next:
@ -198,7 +284,12 @@ toolLoop:
Async: hookResult.Async,
},
)
ts.recordToolKind(toolName)
ts.recordToolExecution(
toolName,
!hookResult.IsError,
toolErrorSummary(hookResult),
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
)
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
@ -571,7 +662,12 @@ toolLoop:
Async: toolResult.Async,
},
)
ts.recordToolKind(toolName)
ts.recordToolExecution(
toolName,
!toolResult.IsError,
toolErrorSummary(toolResult),
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
)
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)

View file

@ -0,0 +1,50 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestInferSkillNamesFromToolCall_ReadFileSkillMarkdown(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "three-one")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: three-one\ndescription: test\n---\n# Three One\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cb := NewContextBuilder(workspace)
ts := &turnState{
workspace: workspace,
agent: &AgentInstance{
Workspace: workspace,
ContextBuilder: cb,
},
}
got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{
"path": filepath.Join(workspace, "skills", "three-one", "SKILL.md"),
})
if len(got) != 1 || got[0] != "three-one" {
t.Fatalf("inferSkillNamesFromToolCall = %v, want [three-one]", got)
}
}
func TestInferSkillNamesFromToolCall_NonSkillFileIgnored(t *testing.T) {
workspace := t.TempDir()
ts := &turnState{workspace: workspace}
got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{
"path": filepath.Join(workspace, "README.md"),
})
if len(got) != 0 {
t.Fatalf("inferSkillNamesFromToolCall = %v, want empty", got)
}
}

View file

@ -46,11 +46,14 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
Iterations: ts.currentIteration(),
Duration: time.Since(ts.startedAt),
FinalContentLen: ts.finalContentLen(),
UserMessage: ts.userMessage,
FinalContent: ts.finalContentSnapshot(),
ActiveSkills: append([]string(nil), ts.activeSkills...),
AttemptedSkills: attemptedSkills,
FinalSuccessfulPath: finalSuccessfulPath,
SkillContextSnapshots: skillContextSnapshots,
ToolKinds: ts.toolKindsSnapshot(),
ToolExecutions: ts.toolExecutionsSnapshot(),
},
)
}()

View file

@ -184,6 +184,7 @@ type turnState struct {
attemptedSkills []string
skillContextTrace []SkillContextSnapshot
toolKinds []string
toolExecutions []ToolExecutionRecord
turnCtx *TurnContext
channel string
@ -383,6 +384,12 @@ func (ts *turnState) finalContentLen() int {
return len(ts.finalContent)
}
func (ts *turnState) finalContentSnapshot() string {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.finalContent
}
func (ts *turnState) recordToolKind(tool string) {
tool = strings.TrimSpace(tool)
if tool == "" {
@ -406,6 +413,43 @@ func (ts *turnState) toolKindsSnapshot() []string {
return append([]string(nil), ts.toolKinds...)
}
func (ts *turnState) recordToolExecution(tool string, success bool, errorSummary string, skillNames []string) {
tool = strings.TrimSpace(tool)
if tool == "" {
return
}
ts.recordToolKind(tool)
ts.mu.Lock()
defer ts.mu.Unlock()
ts.toolExecutions = append(ts.toolExecutions, ToolExecutionRecord{
Name: tool,
Success: success,
ErrorSummary: strings.TrimSpace(errorSummary),
SkillNames: append([]string(nil), skillNames...),
})
}
func (ts *turnState) toolExecutionsSnapshot() []ToolExecutionRecord {
ts.mu.RLock()
defer ts.mu.RUnlock()
if len(ts.toolExecutions) == 0 {
return nil
}
out := make([]ToolExecutionRecord, 0, len(ts.toolExecutions))
for _, exec := range ts.toolExecutions {
out = append(out, ToolExecutionRecord{
Name: exec.Name,
Success: exec.Success,
ErrorSummary: exec.ErrorSummary,
SkillNames: append([]string(nil), exec.SkillNames...),
})
}
return out
}
func (ts *turnState) recordAttemptedSkills(skillNames []string) {
if len(skillNames) == 0 {
return

View file

@ -53,13 +53,11 @@ type Config struct {
}
type EvolutionConfig struct {
Enabled bool `json:"enabled,omitempty"`
Mode string `json:"mode,omitempty"`
StateDir string `json:"state_dir,omitempty"`
MinCaseCount int `json:"min_case_count,omitempty"`
MinSuccessRate float64 `json:"min_success_rate,omitempty"`
AutoRunColdPath bool `json:"auto_run_cold_path,omitempty"`
AutoApply bool `json:"auto_apply,omitempty"`
Enabled bool `json:"enabled,omitempty"`
Mode string `json:"mode,omitempty"`
StateDir string `json:"state_dir,omitempty"`
MinCaseCount int `json:"min_case_count,omitempty"`
MinSuccessRate float64 `json:"min_success_rate,omitempty"`
}
func (c EvolutionConfig) EffectiveMode() string {
@ -67,8 +65,8 @@ func (c EvolutionConfig) EffectiveMode() string {
return ""
}
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
case "review":
return "review"
case "draft":
return "draft"
case "apply":
return "apply"
case "", "observe":
@ -78,6 +76,19 @@ func (c EvolutionConfig) EffectiveMode() string {
}
}
func (c EvolutionConfig) RunsColdPathAutomatically() bool {
switch c.EffectiveMode() {
case "draft", "apply":
return true
default:
return false
}
}
func (c EvolutionConfig) AutoAppliesDrafts() bool {
return c.EffectiveMode() == "apply"
}
// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
// It is applied by the isolation package rather than by sandboxing the main process.
type IsolationConfig struct {

View file

@ -174,13 +174,13 @@ func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
func TestDefaultConfig_EvolutionDefaults(t *testing.T) {
cfg := DefaultConfig()
assert.False(t, cfg.Evolution.Enabled)
assert.Equal(t, "observe", cfg.Evolution.Mode)
assert.True(t, cfg.Evolution.Enabled)
assert.Equal(t, "apply", cfg.Evolution.Mode)
assert.Equal(t, "", cfg.Evolution.StateDir)
assert.Equal(t, 3, cfg.Evolution.MinCaseCount)
assert.Equal(t, 0.7, cfg.Evolution.MinSuccessRate)
assert.False(t, cfg.Evolution.AutoRunColdPath)
assert.False(t, cfg.Evolution.AutoApply)
assert.True(t, cfg.Evolution.RunsColdPathAutomatically())
assert.True(t, cfg.Evolution.AutoAppliesDrafts())
}
func TestEvolutionConfig_EffectiveMode(t *testing.T) {
@ -216,17 +216,17 @@ func TestEvolutionConfig_EffectiveMode(t *testing.T) {
name: "enabled returns configured mode",
cfg: EvolutionConfig{
Enabled: true,
Mode: "review",
Mode: "draft",
},
want: "review",
want: "draft",
},
{
name: "enabled trims and normalizes mode",
cfg: EvolutionConfig{
Enabled: true,
Mode: " Review ",
Mode: " Draft ",
},
want: "review",
want: "draft",
},
{
name: "enabled returns apply mode",
@ -261,6 +261,59 @@ func TestEvolutionConfig_EffectiveMode(t *testing.T) {
}
}
func TestEvolutionConfig_ModeSemantics(t *testing.T) {
tests := []struct {
name string
cfg EvolutionConfig
wantRunsCold bool
wantAutoApply bool
}{
{
name: "disabled does not run cold path",
cfg: EvolutionConfig{
Enabled: false,
Mode: "apply",
},
wantRunsCold: false,
wantAutoApply: false,
},
{
name: "observe only records hot path",
cfg: EvolutionConfig{
Enabled: true,
Mode: "observe",
},
wantRunsCold: false,
wantAutoApply: false,
},
{
name: "draft runs cold path without applying",
cfg: EvolutionConfig{
Enabled: true,
Mode: "draft",
},
wantRunsCold: true,
wantAutoApply: false,
},
{
name: "apply runs cold path and auto applies",
cfg: EvolutionConfig{
Enabled: true,
Mode: "apply",
},
wantRunsCold: true,
wantAutoApply: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.wantRunsCold, tt.cfg.RunsColdPathAutomatically())
assert.Equal(t, tt.wantAutoApply, tt.cfg.AutoAppliesDrafts())
})
}
}
func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")

View file

@ -45,12 +45,10 @@ func DefaultConfig() *Config {
Dimensions: []string{"chat"},
},
Evolution: EvolutionConfig{
Enabled: false,
Mode: "observe",
MinCaseCount: 3,
MinSuccessRate: 0.7,
AutoRunColdPath: false,
AutoApply: false,
Enabled: false,
Mode: "apply",
MinCaseCount: 2,
MinSuccessRate: 0.4,
},
Channels: defaultChannels(),
Hooks: HooksConfig{

View file

@ -115,9 +115,20 @@ func (a *Applier) rollbackSkill(skillPath, backupPath string, hadOriginal bool)
if err := os.Remove(skillPath); err != nil && !os.IsNotExist(err) {
return err
}
skillDir := filepath.Dir(skillPath)
if err := os.Remove(skillDir); err != nil && !os.IsNotExist(err) && !isDirNotEmptyError(err) {
return err
}
return nil
}
func isDirNotEmptyError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "directory not empty")
}
func validateAppliedSkillBody(body string) error {
body = strings.TrimSpace(body)
if !strings.HasPrefix(body, "---\n") {

View file

@ -129,6 +129,39 @@ func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) {
}
}
func TestApplier_FailedNewSkillDoesNotLeaveEmptyDirectory(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
return time.Unix(1700000000, 0).UTC()
})
draft := evolution.SkillDraft{
ID: "draft-invalid-new-skill",
WorkspaceID: workspace,
SourceRecordID: "rule-invalid-new-skill",
TargetSkillName: "calculate-100-via-theorems",
DraftType: evolution.DraftTypeWorkflow,
ChangeKind: evolution.ChangeKindCreate,
HumanSummary: "broken new skill",
BodyOrPatch: "invalid-frontmatter",
Status: evolution.DraftStatusAccepted,
}
err := applier.ApplyDraft(context.Background(), workspace, draft)
if err == nil {
t.Fatal("expected ApplyDraft to fail")
}
skillPath := filepath.Join(workspace, "skills", "calculate-100-via-theorems", "SKILL.md")
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
t.Fatalf("expected no skill file, got err=%v", statErr)
}
skillDir := filepath.Dir(skillPath)
if _, statErr := os.Stat(skillDir); !os.IsNotExist(statErr) {
t.Fatalf("expected no leftover skill dir, got err=%v", statErr)
}
}
func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(t *testing.T) {
workspace := t.TempDir()
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {

View file

@ -98,6 +98,9 @@ func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRe
}
func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string {
if target := inferCombinedSkillName(rule); target != "" {
return target
}
if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" {
return strings.TrimSpace(matches[0].Name)
}
@ -118,6 +121,134 @@ func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) strin
return ""
}
func inferCombinedSkillName(rule LearningRecord) string {
path := normalizePath(rule.WinningPath)
if len(path) < 2 {
return ""
}
tokens := tokenizeForEvolution(rule.Summary)
suffix := commonWinningPathSuffix(path)
if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" {
if candidate := validSkillNameOrEmpty("calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix)); candidate != "" {
return candidate
}
}
if len(tokens) >= 2 {
prefix := strings.Join(tokens[:minInt(len(tokens), 4)], "-")
if suffix != "" {
if candidate := validSkillNameOrEmpty(prefix + "-via-" + pluralizeSuffix(suffix)); candidate != "" {
return candidate
}
}
if candidate := validSkillNameOrEmpty(prefix + "-shortcut"); candidate != "" {
return candidate
}
}
compressedPath := compressedWinningPathName(path)
if candidate := validSkillNameOrEmpty("combined-" + compressedPath); candidate != "" {
return candidate
}
if candidate := validSkillNameOrEmpty(path[0] + "-to-" + path[len(path)-1] + "-shortcut"); candidate != "" {
return candidate
}
return ""
}
func commonWinningPathSuffix(path []string) string {
if len(path) < 2 {
return ""
}
var suffix string
for i, name := range path {
parts := strings.Split(strings.TrimSpace(name), "-")
if len(parts) == 0 {
return ""
}
last := strings.TrimSpace(parts[len(parts)-1])
if last == "" {
return ""
}
if i == 0 {
suffix = last
continue
}
if suffix != last {
return ""
}
}
return suffix
}
func compressedWinningPathName(path []string) string {
suffix := commonWinningPathSuffix(path)
fragments := make([]string, 0, len(path)+1)
for _, name := range path {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if suffix != "" {
trimmed = strings.TrimSuffix(trimmed, "-"+suffix)
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "-")
}
if trimmed != "" {
fragments = append(fragments, trimmed)
}
}
if suffix != "" {
fragments = append(fragments, pluralizeSuffix(suffix))
}
if len(fragments) == 0 {
return strings.Join(path, "-")
}
return strings.Join(fragments, "-")
}
func pluralizeSuffix(suffix string) string {
suffix = strings.TrimSpace(strings.ToLower(suffix))
if suffix == "" {
return ""
}
if strings.HasSuffix(suffix, "s") {
return suffix
}
return suffix + "s"
}
func isNumericToken(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func validSkillNameOrEmpty(candidate string) string {
candidate = strings.Trim(candidate, "-")
candidate = strings.Join(strings.FieldsFunc(candidate, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9')
}), "-")
candidate = strings.ToLower(strings.Trim(candidate, "-"))
if candidate == "" {
return ""
}
if len(candidate) > skills.MaxNameLength {
return ""
}
if err := skills.ValidateSkillName(candidate); err != nil {
return ""
}
return candidate
}
func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) {
for _, match := range matches {
if match.Name != target || strings.TrimSpace(match.Path) == "" {

View file

@ -16,8 +16,8 @@ func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *tes
generator := evolution.NewDefaultDraftGenerator(t.TempDir())
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
Summary: "weather native-name path",
WinningPath: []string{"geocode", "weather"},
Summary: "weather lookup",
WinningPath: []string{"weather"},
LateAddedSkills: []string{"weather"},
FinalSnapshotTrigger: "context_retry_rebuild",
EventCount: 4,
@ -34,6 +34,35 @@ func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *tes
}
}
func TestDefaultDraftGenerator_PrefersCombinedSkillForStableMultiSkillPath(t *testing.T) {
workspace := t.TempDir()
generator := evolution.NewDefaultDraftGenerator(workspace)
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
Summary: "调用三一定理计算100",
WinningPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
LateAddedSkills: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
EventCount: 3,
SuccessRate: 1,
}, []skills.SkillInfo{
{Name: "three-one-theorem", Path: filepath.Join(workspace, "skills", "three-one-theorem", "SKILL.md"), Source: "workspace"},
{Name: "four-two-theorem", Path: filepath.Join(workspace, "skills", "four-two-theorem", "SKILL.md"), Source: "workspace"},
{Name: "five-three-theorem", Path: filepath.Join(workspace, "skills", "five-three-theorem", "SKILL.md"), Source: "workspace"},
})
if err != nil {
t.Fatalf("GenerateDraft: %v", err)
}
if draft.TargetSkillName != "calculate-100-via-theorems" {
t.Fatalf("TargetSkillName = %q, want calculate-100-via-theorems", draft.TargetSkillName)
}
if draft.ChangeKind != evolution.ChangeKindCreate {
t.Fatalf("ChangeKind = %q, want create", draft.ChangeKind)
}
if !strings.Contains(draft.BodyOrPatch, "---\nname: calculate-100-via-theorems") {
t.Fatalf("BodyOrPatch should contain full skill document:\n%s", draft.BodyOrPatch)
}
}
func TestDefaultDraftGenerator_UsesAppendWhenExtendingExistingSkill(t *testing.T) {
workspace := t.TempDir()
generator := evolution.NewDefaultDraftGenerator(workspace)
@ -107,4 +136,10 @@ func TestLLMDraftGenerator_BuildPromptIncludesLateAddedSkillHint(t *testing.T) {
if !strings.Contains(prompt, "Final snapshot trigger: context_retry_rebuild") {
t.Fatalf("prompt missing final snapshot trigger:\n%s", prompt)
}
if !strings.Contains(prompt, "Prefer creating a new combined shortcut skill") {
t.Fatalf("prompt missing combined skill guidance:\n%s", prompt)
}
if !strings.Contains(prompt, "Suggested target skill name:") {
t.Fatalf("prompt missing suggested target skill name:\n%s", prompt)
}
}

View file

@ -10,6 +10,12 @@ import (
"github.com/sipeed/picoclaw/pkg/skills"
)
type LifecycleRunSummary struct {
EvaluatedProfiles int
TransitionedProfiles int
DeletedSkills int
}
func NextLifecycleState(profile SkillProfile, now time.Time) SkillStatus {
if profile.Origin == "manual" || profile.LastUsedAt.IsZero() {
return profile.Status
@ -58,6 +64,51 @@ func ApplyLifecycleState(paths Paths, profile SkillProfile, next SkillStatus) er
return err
}
func RunLifecycleOnce(store *Store, paths Paths, workspace string, now time.Time) (LifecycleRunSummary, error) {
if store == nil {
return LifecycleRunSummary{}, nil
}
profiles, err := store.LoadProfiles()
if err != nil {
return LifecycleRunSummary{}, err
}
summary := LifecycleRunSummary{}
for _, profile := range profiles {
if !profileBelongsToWorkspace(paths, workspace, profile) {
continue
}
summary.EvaluatedProfiles++
next := NextLifecycleState(profile, now)
if next == profile.Status {
continue
}
if err := ApplyLifecycleState(paths, profile, next); err != nil {
return summary, err
}
profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{
Version: profile.CurrentVersion,
Action: "lifecycle:" + string(next),
Timestamp: now,
Summary: fmt.Sprintf("lifecycle transition: %s -> %s", profile.Status, next),
})
profile.Status = next
if err := store.SaveProfile(profile); err != nil {
return summary, err
}
summary.TransitionedProfiles++
if next == SkillStatusDeleted {
summary.DeletedSkills++
}
}
return summary, nil
}
func inferWorkspaceFromPaths(paths Paths) string {
root := filepath.Clean(paths.RootDir)
if filepath.Base(root) != "evolution" {
@ -69,3 +120,14 @@ func inferWorkspaceFromPaths(paths Paths) string {
}
return filepath.Dir(stateDir)
}
func profileBelongsToWorkspace(paths Paths, workspace string, profile SkillProfile) bool {
if profile.WorkspaceID == workspace {
return true
}
return profile.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace)
}
func usesDefaultWorkspaceState(paths Paths, workspace string) bool {
return paths.RootDir == NewPaths(workspace, "").RootDir
}

View file

@ -105,10 +105,22 @@ func (g *LLMDraftGenerator) buildPrompt(rule LearningRecord, matches []skills.Sk
"Matched skill refs: " + summarizeSkillMatches(matches),
"Matched skill names: " + joinOrFallback(rule.MatchedSkillNames, "none"),
"",
combinedSkillGuidance(rule),
"body_or_patch should contain the full draft body or patch content as plain text.",
}, "\n")
}
func combinedSkillGuidance(rule LearningRecord) string {
if target := inferCombinedSkillName(rule); target != "" {
return strings.Join([]string{
"This rule represents a stable multi-step successful path.",
"Prefer creating a new combined shortcut skill instead of modifying one component skill.",
"Suggested target skill name: " + target,
}, "\n")
}
return "Prefer updating an existing skill only when the learned pattern clearly belongs inside that single skill."
}
func parseLLMDraft(content string) (SkillDraft, bool) {
normalized := strings.TrimSpace(content)
normalized = strings.TrimPrefix(normalized, "```json")

View file

@ -90,7 +90,7 @@ func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, erro
Kind: RecordKindPattern,
WorkspaceID: cluster[0].WorkspaceID,
CreatedAt: o.now(),
Summary: buildRuleSummary(ruleKey, winningPath),
Summary: buildRuleSummary(cluster, ruleKey, winningPath),
Source: map[string]any{"cluster_key": ruleKey},
Status: RecordStatus("ready"),
SourceRecordIDs: collectRecordIDs(cluster),
@ -108,16 +108,7 @@ func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, erro
}
func normalizeRuleKey(record LearningRecord) string {
if path := normalizeFinalSuccessfulPath(record); len(path) > 0 {
return strings.Join(path, " ")
}
if path := normalizeAttemptedSkills(record); len(path) > 0 {
return strings.Join(path, " ")
}
if path := normalizePath(record.ActiveSkillNames); len(path) > 0 {
return strings.Join(path, " ")
}
if path := normalizePath(record.MatchedSkillNames); len(path) > 0 {
if path := preferredRulePath(record); len(path) > 0 {
return strings.Join(path, " ")
}
if path := normalizePath(record.ToolKinds); len(path) > 0 {
@ -134,6 +125,28 @@ func normalizeRuleKey(record LearningRecord) string {
return strings.Join(tokens, " ")
}
func preferredRulePath(record LearningRecord) []string {
if path := normalizeFinalSuccessfulPath(record); len(path) > 0 {
return path
}
if path := normalizePath(record.UsedSkillNames); len(path) > 0 {
return path
}
if path := normalizePath(record.AddedSkillNames); len(path) > 0 {
return path
}
if path := normalizeAttemptedSkills(record); len(path) > 0 {
return path
}
if path := normalizePath(record.ActiveSkillNames); len(path) > 0 {
return path
}
if path := normalizePath(record.MatchedSkillNames); len(path) > 0 {
return path
}
return nil
}
func normalizePath(values []string) []string {
if len(values) == 0 {
return nil
@ -202,13 +215,7 @@ func clusterWinningPath(cluster []LearningRecord) []string {
order := make([]string, 0)
for _, record := range cluster {
path := normalizeFinalSuccessfulPath(record)
if len(path) == 0 {
path = normalizeAttemptedSkills(record)
}
if len(path) == 0 {
path = normalizePath(record.ActiveSkillNames)
}
path := preferredRulePath(record)
if len(path) == 0 {
path = normalizePath(record.ToolKinds)
}
@ -287,6 +294,9 @@ func clusterLateAddedSkills(cluster []LearningRecord, winningPath []string) ([]s
}
func lateAddedSkillsFromRecord(record LearningRecord) ([]string, string) {
if skills := normalizePath(record.AddedSkillNames); len(skills) > 0 {
return skills, "loaded_during_task"
}
if record.AttemptTrail == nil || len(record.AttemptTrail.SkillContextSnapshots) == 0 {
return nil, ""
}
@ -359,9 +369,29 @@ func stableRuleID(workspaceID, key string) string {
return "rule-" + hex.EncodeToString(sum[:6])
}
func buildRuleSummary(key string, winningPath []string) string {
func buildRuleSummary(cluster []LearningRecord, key string, winningPath []string) string {
if goal := representativeGoal(cluster); goal != "" && len(winningPath) > 0 {
return goal + " via " + strings.Join(winningPath, " -> ")
}
if goal := representativeGoal(cluster); goal != "" {
return goal
}
if len(winningPath) > 0 {
return strings.Join(winningPath, " -> ")
}
return key
}
func representativeGoal(cluster []LearningRecord) string {
for _, record := range cluster {
if goal := strings.TrimSpace(record.UserGoal); goal != "" {
return goal
}
}
for _, record := range cluster {
if summary := strings.TrimSpace(record.Summary); summary != "" {
return summary
}
}
return ""
}

View file

@ -243,3 +243,68 @@ func TestOrganizer_BuildRulesCapturesLateAddedSkillHintFromSnapshots(t *testing.
t.Fatalf("FinalSnapshotTrigger = %q, want context_retry_rebuild", got)
}
}
func TestOrganizer_BuildRulesUsesAddedSkillNamesWithoutSnapshots(t *testing.T) {
ok := true
cases := []evolution.LearningRecord{
{
ID: "case-1",
Kind: evolution.RecordKindCase,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "weather shanghai",
UserGoal: "check weather in shanghai",
Status: evolution.RecordStatus("new"),
Success: &ok,
UsedSkillNames: []string{"geocode", "weather"},
AddedSkillNames: []string{"weather"},
},
{
ID: "case-2",
Kind: evolution.RecordKindCase,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "weather beijing",
UserGoal: "check weather in beijing",
Status: evolution.RecordStatus("new"),
Success: &ok,
UsedSkillNames: []string{"geocode", "weather"},
AddedSkillNames: []string{"weather"},
},
{
ID: "case-3",
Kind: evolution.RecordKindCase,
WorkspaceID: "ws-1",
CreatedAt: time.Unix(1700000200, 0).UTC(),
Summary: "weather hangzhou",
UserGoal: "check weather in hangzhou",
Status: evolution.RecordStatus("new"),
Success: &ok,
UsedSkillNames: []string{"geocode", "weather"},
AddedSkillNames: []string{"weather"},
},
}
org := evolution.NewOrganizer(evolution.OrganizerOptions{
MinCaseCount: 3,
MinSuccessRate: 0.7,
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
})
rules, err := org.BuildRules(cases)
if err != nil {
t.Fatalf("BuildRules: %v", err)
}
if len(rules) != 1 {
t.Fatalf("len(rules) = %d, want 1", len(rules))
}
if got := rules[0].WinningPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
t.Fatalf("WinningPath = %v, want [geocode weather]", got)
}
if got := rules[0].LateAddedSkills; len(got) != 1 || got[0] != "weather" {
t.Fatalf("LateAddedSkills = %v, want [weather]", got)
}
if got := rules[0].FinalSnapshotTrigger; got != "loaded_during_task" {
t.Fatalf("FinalSnapshotTrigger = %q, want loaded_during_task", got)
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -30,7 +31,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) {
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
@ -104,7 +105,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) {
}
}
func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) {
func TestRuntime_RunColdPathOnce_DraftModeKeepsCandidateDraft(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
@ -122,7 +123,7 @@ func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: false},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
@ -170,6 +171,98 @@ func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t
}
}
func TestRuntime_RunColdPathOnce_ApplyModeRetargetsStableMultiSkillPathIntoCombinedShortcut(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
rule := evolution.LearningRecord{
ID: "rule-1",
Kind: evolution.RecordKindRule,
WorkspaceID: root,
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "calculate 100",
Status: evolution.RecordStatus("ready"),
EventCount: 4,
SuccessRate: 1,
WinningPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"},
}
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
t.Fatalf("AppendLearningRecords: %v", err)
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
return time.Unix(1700001000, 0).UTC()
}),
DraftGenerator: stubDraftGenerator{
draft: evolution.SkillDraft{
ID: "draft-1",
WorkspaceID: root,
SourceRecordID: "rule-1",
TargetSkillName: "five-three-theorem",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "combine the theorem chain into one shortcut skill",
BodyOrPatch: "Prefer the full theorem chain directly.",
},
},
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
SkillsRecaller: evolution.NewSkillsRecaller(root),
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
t.Fatalf("RunColdPathOnce: %v", err)
}
skillPath := filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md")
data, err := os.ReadFile(skillPath)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
content := string(data)
if !strings.Contains(content, "name: calculate-100-via-theorems") {
t.Fatalf("unexpected content:\n%s", content)
}
if !strings.Contains(content, "# Calculate 100 Via Theorems") {
t.Fatalf("missing synthesized heading:\n%s", content)
}
if !strings.Contains(content, "Prefer the full theorem chain directly.") {
t.Fatalf("missing learned content:\n%s", content)
}
if !strings.Contains(content, "Use `calculate-100-via-theorems` directly") {
t.Fatalf("missing direct shortcut guidance:\n%s", content)
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if drafts[0].Status != evolution.DraftStatusAccepted {
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted)
}
if drafts[0].ChangeKind != evolution.ChangeKindCreate {
t.Fatalf("ChangeKind = %q, want %q", drafts[0].ChangeKind, evolution.ChangeKindCreate)
}
if drafts[0].TargetSkillName != "calculate-100-via-theorems" {
t.Fatalf("TargetSkillName = %q, want calculate-100-via-theorems", drafts[0].TargetSkillName)
}
if len(drafts[0].PreferredEntryPath) != 1 || drafts[0].PreferredEntryPath[0] != "calculate-100-via-theorems" {
t.Fatalf("PreferredEntryPath = %v, want [calculate-100-via-theorems]", drafts[0].PreferredEntryPath)
}
if len(drafts[0].ReviewNotes) == 0 {
t.Fatal("expected normalization review notes")
}
}
func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAudit(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
@ -221,7 +314,7 @@ func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAu
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
@ -292,6 +385,89 @@ func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAu
}
}
func TestRuntime_RunColdPathOnce_AutoRunsLifecycleMaintenance(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
store := evolution.NewStore(paths)
now := time.Unix(1700001000, 0).UTC()
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "stale-active-skill",
WorkspaceID: root,
Status: evolution.SkillStatusActive,
Origin: "evolved",
HumanSummary: "stale active skill",
LastUsedAt: now.Add(-91 * 24 * time.Hour),
RetentionScore: 0.1,
}); err != nil {
t.Fatalf("SaveProfile(active): %v", err)
}
skillDir := filepath.Join(root, "skills", "stale-archived-skill")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, []byte("---\nname: stale-archived-skill\ndescription: stale\n---\n# Stale Archived Skill\n"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "stale-archived-skill",
WorkspaceID: root,
Status: evolution.SkillStatusArchived,
Origin: "evolved",
HumanSummary: "stale archived skill",
LastUsedAt: now.Add(-366 * 24 * time.Hour),
RetentionScore: 0.05,
}); err != nil {
t.Fatalf("SaveProfile(archived): %v", err)
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return now },
Store: store,
Applier: evolution.NewApplier(paths, func() time.Time {
return now
}),
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
SkillsRecaller: evolution.NewSkillsRecaller(root),
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
t.Fatalf("RunColdPathOnce: %v", err)
}
activeProfile, err := store.LoadProfile("stale-active-skill")
if err != nil {
t.Fatalf("LoadProfile(active): %v", err)
}
if activeProfile.Status != evolution.SkillStatusCold {
t.Fatalf("active profile Status = %q, want %q", activeProfile.Status, evolution.SkillStatusCold)
}
if len(activeProfile.VersionHistory) != 1 || activeProfile.VersionHistory[0].Action != "lifecycle:cold" {
t.Fatalf("active profile VersionHistory = %+v, want lifecycle:cold entry", activeProfile.VersionHistory)
}
archivedProfile, err := store.LoadProfile("stale-archived-skill")
if err != nil {
t.Fatalf("LoadProfile(archived): %v", err)
}
if archivedProfile.Status != evolution.SkillStatusDeleted {
t.Fatalf("archived profile Status = %q, want %q", archivedProfile.Status, evolution.SkillStatusDeleted)
}
if len(archivedProfile.VersionHistory) != 1 || archivedProfile.VersionHistory[0].Action != "lifecycle:deleted" {
t.Fatalf("archived profile VersionHistory = %+v, want lifecycle:deleted entry", archivedProfile.VersionHistory)
}
if _, err := os.Stat(skillPath); !os.IsNotExist(err) {
t.Fatalf("expected lifecycle delete to remove skill file, stat err = %v", err)
}
}
func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesDraft(t *testing.T) {
root := t.TempDir()
paths := evolution.NewPaths(root, "")
@ -318,7 +494,7 @@ func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesD
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store,
Applier: evolution.NewApplier(paths, func() time.Time {

View file

@ -38,6 +38,22 @@ type draftGenerationResult struct {
err error
}
type stubSuccessJudge struct {
decisions map[string]evolution.TaskSuccessDecision
calls []string
}
func (j *stubSuccessJudge) JudgeTaskRecord(
_ context.Context,
record evolution.LearningRecord,
) (evolution.TaskSuccessDecision, error) {
j.calls = append(j.calls, record.ID)
if decision, ok := j.decisions[record.ID]; ok {
return decision, nil
}
return evolution.TaskSuccessDecision{Success: true, Reason: "default success"}, nil
}
func (g *sequenceDraftGenerator) GenerateDraft(
_ context.Context,
_ evolution.LearningRecord,
@ -70,7 +86,7 @@ func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) {
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
DraftGenerator: stubDraftGenerator{
draft: evolution.SkillDraft{
@ -107,6 +123,137 @@ func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) {
}
}
func TestRuntime_RunColdPathOnce_AdmitsOnlyRecordsApprovedBySuccessJudge(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
ok := true
failed := false
records := []evolution.LearningRecord{
{
ID: "task-failed",
Kind: evolution.RecordKindTask,
WorkspaceID: root,
CreatedAt: time.Unix(1700000000, 0).UTC(),
Summary: "failed weather attempt",
UserGoal: "check weather in shanghai",
FinalOutput: "tool failed",
Status: evolution.RecordStatus("new"),
Success: &failed,
UsedSkillNames: []string{"weather"},
ToolKinds: []string{"read_file"},
},
{
ID: "task-rejected",
Kind: evolution.RecordKindTask,
WorkspaceID: root,
CreatedAt: time.Unix(1700000100, 0).UTC(),
Summary: "partial weather answer",
UserGoal: "check weather in shanghai",
FinalOutput: "I will check it next",
Status: evolution.RecordStatus("new"),
Success: &ok,
UsedSkillNames: []string{"weather"},
ToolKinds: []string{"read_file"},
ToolExecutions: []evolution.ToolExecutionRecord{{Name: "read_file", Success: true}},
},
{
ID: "task-admitted",
Kind: evolution.RecordKindTask,
WorkspaceID: root,
CreatedAt: time.Unix(1700000200, 0).UTC(),
Summary: "weather answer delivered",
UserGoal: "check weather in shanghai",
FinalOutput: "sunny, 26C",
Status: evolution.RecordStatus("new"),
Success: &ok,
UsedSkillNames: []string{"weather"},
AddedSkillNames: []string{"weather"},
ToolKinds: []string{"read_file"},
ToolExecutions: []evolution.ToolExecutionRecord{{Name: "read_file", Success: true}},
AttemptTrail: &evolution.AttemptTrail{
AttemptedSkills: []string{"weather"},
FinalSuccessfulPath: []string{"weather"},
},
},
}
if err := store.AppendLearningRecords(records); err != nil {
t.Fatalf("AppendLearningRecords: %v", err)
}
judge := &stubSuccessJudge{
decisions: map[string]evolution.TaskSuccessDecision{
"task-rejected": {Success: false, Reason: "only partial reasoning"},
"task-admitted": {Success: true, Reason: "goal achieved"},
},
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
SuccessJudge: judge,
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 1, MinSuccessRate: 1}),
SkillsRecaller: evolution.NewSkillsRecaller(root),
DraftGenerator: stubDraftGenerator{
draft: evolution.SkillDraft{
ID: "draft-weather",
TargetSkillName: "weather",
DraftType: evolution.DraftTypeShortcut,
ChangeKind: evolution.ChangeKindAppend,
HumanSummary: "prefer the proven weather path",
BodyOrPatch: "## Start Here\nUse the weather path directly.",
},
},
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
t.Fatalf("RunColdPathOnce: %v", err)
}
if len(judge.calls) != 2 || judge.calls[0] != "task-rejected" || judge.calls[1] != "task-admitted" {
t.Fatalf("judge calls = %v, want [task-rejected task-admitted]", judge.calls)
}
allRecords, err := store.LoadLearningRecords()
if err != nil {
t.Fatalf("LoadLearningRecords: %v", err)
}
var pattern evolution.LearningRecord
foundPattern := false
for _, record := range allRecords {
if record.Kind != evolution.RecordKindPattern {
continue
}
pattern = record
foundPattern = true
break
}
if !foundPattern {
t.Fatal("expected generated pattern record")
}
if len(pattern.SourceRecordIDs) != 1 || pattern.SourceRecordIDs[0] != "task-admitted" {
t.Fatalf("SourceRecordIDs = %v, want [task-admitted]", pattern.SourceRecordIDs)
}
if got := pattern.WinningPath; len(got) != 1 || got[0] != "weather" {
t.Fatalf("WinningPath = %v, want [weather]", got)
}
drafts, err := store.LoadDrafts()
if err != nil {
t.Fatalf("LoadDrafts: %v", err)
}
if len(drafts) != 1 {
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
}
if drafts[0].SourceRecordID != pattern.ID {
t.Fatalf("draft SourceRecordID = %q, want %q", drafts[0].SourceRecordID, pattern.ID)
}
}
func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) {
root := t.TempDir()
store := evolution.NewStore(evolution.NewPaths(root, ""))
@ -125,7 +272,7 @@ func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) {
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
DraftGenerator: stubDraftGenerator{
draft: evolution.SkillDraft{
ID: "draft-1",
@ -247,7 +394,7 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(t *testing.T) {
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
})
if err != nil {
@ -301,7 +448,7 @@ func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(t *t
},
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, provider, "runtime-explicit-model"),
})
@ -348,7 +495,7 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvid
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, nil, ""),
})
@ -408,7 +555,7 @@ func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *tes
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, "runtime-explicit-model")
@ -484,7 +631,7 @@ func TestRuntime_RunColdPathOnce_PersistsEarlierDraftWhenLaterRuleFails(t *testi
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
DraftGenerator: generator,
SkillsRecaller: evolution.NewSkillsRecaller(root),
@ -543,7 +690,7 @@ func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(t *testing.T)
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
Config: config.EvolutionConfig{Enabled: true, Mode: "draft"},
Store: store,
DraftGenerator: stubDraftGenerator{
draft: evolution.SkillDraft{

View file

@ -8,6 +8,7 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/evolution"
@ -53,6 +54,32 @@ func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) {
}
}
func TestRuntime_FinalizeTurnSkipsHeartbeat(t *testing.T) {
workspace := t.TempDir()
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
TurnID: "heartbeat-turn",
SessionKey: "heartbeat",
Status: "completed",
UserMessage: "# Heartbeat Check",
FinalContent: "HEARTBEAT_OK",
}); err != nil {
t.Fatalf("FinalizeTurn: %v", err)
}
paths := evolution.NewPaths(workspace, "")
if _, err := os.Stat(paths.LearningRecords); !os.IsNotExist(err) {
t.Fatalf("heartbeat should not create learning records, stat err = %v", err)
}
}
func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
workspace := t.TempDir()
override := filepath.Join(t.TempDir(), "custom-state")
@ -71,25 +98,36 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
}
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
TurnID: "turn-1",
SessionKey: "session-1",
AgentID: "agent-1",
Status: "completed",
ToolKinds: []string{"web", "read_file"},
Workspace: workspace,
TurnID: "turn-1",
SessionKey: "session-1",
AgentID: "agent-1",
Status: "completed",
UserMessage: "summarize the release notes",
FinalContent: "Here is the summary.",
ToolKinds: []string{"web", "read_file"},
ToolExecutions: []evolution.ToolExecutionRecord{
{Name: "web", Success: true},
{Name: "read_file", Success: true},
},
ActiveSkillNames: []string{"skill-a"},
}); err != nil {
t.Fatalf("FinalizeTurn first call: %v", err)
}
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
WorkspaceID: "ws-explicit",
TurnID: "turn-2",
SessionKey: "session-2",
AgentID: "agent-2",
Status: "error",
ToolKinds: []string{"bash"},
Workspace: workspace,
WorkspaceID: "ws-explicit",
TurnID: "turn-2",
SessionKey: "session-2",
AgentID: "agent-2",
Status: "error",
UserMessage: "run the bash command",
FinalContent: "bash failed",
ToolKinds: []string{"bash"},
ToolExecutions: []evolution.ToolExecutionRecord{
{Name: "bash", Success: false, ErrorSummary: "exit status 1"},
},
ActiveSkillNames: []string{"skill-b"},
}); err != nil {
t.Fatalf("FinalizeTurn second call: %v", err)
@ -119,15 +157,36 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
if first.SessionKey != "session-1" {
t.Fatalf("first SessionKey = %q, want %q", first.SessionKey, "session-1")
}
if first.Summary != "turn turn-1 finished with status=completed" {
if first.Summary != "summarize the release notes" {
t.Fatalf("first Summary = %q", first.Summary)
}
if first.UserGoal != "summarize the release notes" {
t.Fatalf("first UserGoal = %q", first.UserGoal)
}
if first.FinalOutput != "Here is the summary." {
t.Fatalf("first FinalOutput = %q", first.FinalOutput)
}
if first.Success == nil || !*first.Success {
t.Fatalf("first Success = %v, want true", first.Success)
}
if len(first.ToolKinds) != 2 || first.ToolKinds[0] != "web" || first.ToolKinds[1] != "read_file" {
t.Fatalf("first ToolKinds = %v", first.ToolKinds)
}
if len(first.ToolExecutions) != 2 || !first.ToolExecutions[0].Success || first.ToolExecutions[0].Name != "web" {
t.Fatalf("first ToolExecutions = %+v", first.ToolExecutions)
}
if len(first.InitialSkillNames) != 1 || first.InitialSkillNames[0] != "skill-a" {
t.Fatalf("first InitialSkillNames = %v", first.InitialSkillNames)
}
if len(first.AddedSkillNames) != 0 {
t.Fatalf("first AddedSkillNames = %v, want empty", first.AddedSkillNames)
}
if len(first.UsedSkillNames) != 0 {
t.Fatalf("first UsedSkillNames = %v, want empty", first.UsedSkillNames)
}
if len(first.AllLoadedSkillNames) != 1 || first.AllLoadedSkillNames[0] != "skill-a" {
t.Fatalf("first AllLoadedSkillNames = %v", first.AllLoadedSkillNames)
}
if len(first.ActiveSkillNames) != 1 || first.ActiveSkillNames[0] != "skill-a" {
t.Fatalf("first ActiveSkillNames = %v", first.ActiveSkillNames)
}
@ -169,9 +228,15 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
if second.SessionKey != "session-2" {
t.Fatalf("second SessionKey = %q, want %q", second.SessionKey, "session-2")
}
if second.UserGoal != "run the bash command" {
t.Fatalf("second UserGoal = %q", second.UserGoal)
}
if second.Success == nil || *second.Success {
t.Fatalf("second Success = %v, want false", second.Success)
}
if len(second.ToolExecutions) != 1 || second.ToolExecutions[0].ErrorSummary != "exit status 1" {
t.Fatalf("second ToolExecutions = %+v", second.ToolExecutions)
}
if second.AttemptTrail == nil {
t.Fatal("second AttemptTrail should not be nil")
}
@ -241,14 +306,126 @@ func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) {
if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" {
t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals)
}
if got := record.InitialSkillNames; len(got) != 1 || got[0] != "geocode" {
t.Fatalf("InitialSkillNames = %v, want [geocode]", got)
}
if got := record.AddedSkillNames; len(got) != 1 || got[0] != "weather" {
t.Fatalf("AddedSkillNames = %v, want [weather]", got)
}
if got := record.UsedSkillNames; len(got) != 1 || got[0] != "weather" {
t.Fatalf("UsedSkillNames = %v, want [weather]", got)
}
if got := record.AllLoadedSkillNames; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
t.Fatalf("AllLoadedSkillNames = %v, want [geocode weather]", got)
}
if record.AttemptTrail == nil {
t.Fatal("AttemptTrail should not be nil")
}
if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got)
if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 1 || got[0] != "weather" {
t.Fatalf("FinalSuccessfulPath = %v, want [weather]", got)
}
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 {
t.Fatalf("SkillContextSnapshots = %v, want 2 snapshots", got)
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 0 {
t.Fatalf("SkillContextSnapshots = %v, want empty", got)
}
}
func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) {
workspace := t.TempDir()
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
TurnID: "turn-skill-chain",
SessionKey: "session-skill-chain",
AgentID: "main",
Status: "completed",
UserMessage: "调用三一定理计算100",
FinalContent: "done",
ToolExecutions: []evolution.ToolExecutionRecord{
{Name: "read_file", Success: true, SkillNames: []string{"three-one"}},
{Name: "read_file", Success: true, SkillNames: []string{"four-two"}},
{Name: "read_file", Success: true, SkillNames: []string{"five-three"}},
},
}); err != nil {
t.Fatalf("FinalizeTurn: %v", err)
}
paths := evolution.NewPaths(workspace, "")
data, err := os.ReadFile(paths.LearningRecords)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 1 {
t.Fatalf("record file line count = %d, want 1", len(lines))
}
var record evolution.LearningRecord
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
t.Fatalf("Unmarshal record: %v", err)
}
if got := record.AddedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" {
t.Fatalf("AddedSkillNames = %v, want [three-one four-two five-three]", got)
}
if got := record.UsedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" {
t.Fatalf("UsedSkillNames = %v, want [three-one four-two five-three]", got)
}
if got := record.AllLoadedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" {
t.Fatalf("AllLoadedSkillNames = %v, want [three-one four-two five-three]", got)
}
}
func TestRuntime_FinalizeTurnPreservesUTF8WhenTruncatingChineseOutput(t *testing.T) {
workspace := t.TempDir()
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
longChinese := strings.Repeat("中文输出", 80)
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
TurnID: "turn-utf8",
SessionKey: "session-utf8",
AgentID: "main",
Status: "completed",
UserMessage: "请处理这段中文输出",
FinalContent: longChinese,
}); err != nil {
t.Fatalf("FinalizeTurn: %v", err)
}
paths := evolution.NewPaths(workspace, "")
data, err := os.ReadFile(paths.LearningRecords)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
if len(lines) != 1 {
t.Fatalf("record file line count = %d, want 1", len(lines))
}
var record evolution.LearningRecord
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
t.Fatalf("Unmarshal record: %v", err)
}
if !utf8.ValidString(record.FinalOutput) {
t.Fatalf("FinalOutput is not valid UTF-8: %q", record.FinalOutput)
}
if strings.ContainsRune(record.FinalOutput, '\uFFFD') {
t.Fatalf("FinalOutput contains replacement rune: %q", record.FinalOutput)
}
if !strings.HasSuffix(record.FinalOutput, "...") {
t.Fatalf("FinalOutput = %q, want truncated suffix ...", record.FinalOutput)
}
}
@ -306,8 +483,14 @@ func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) {
if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got)
}
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 || got[1].Trigger != "context_retry_rebuild" {
t.Fatalf("SkillContextSnapshots = %+v, want explicit snapshots preserved", got)
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 0 {
t.Fatalf("SkillContextSnapshots = %+v, want empty", got)
}
if got := record.InitialSkillNames; len(got) != 1 || got[0] != "weather" {
t.Fatalf("InitialSkillNames = %v, want [weather]", got)
}
if got := record.AddedSkillNames; len(got) != 1 || got[0] != "geocode" {
t.Fatalf("AddedSkillNames = %v, want [geocode]", got)
}
if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" {
t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals)

View file

@ -0,0 +1,168 @@
package evolution
import (
"context"
"encoding/json"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
)
type TaskSuccessDecision struct {
Success bool
Reason string
}
type SuccessJudge interface {
JudgeTaskRecord(ctx context.Context, record LearningRecord) (TaskSuccessDecision, error)
}
type HeuristicSuccessJudge struct{}
func (j *HeuristicSuccessJudge) JudgeTaskRecord(
_ context.Context,
record LearningRecord,
) (TaskSuccessDecision, error) {
if record.Success == nil || !*record.Success {
return TaskSuccessDecision{Success: false, Reason: "task not completed"}, nil
}
if strings.TrimSpace(record.UserGoal) == "" {
return TaskSuccessDecision{Success: false, Reason: "missing user goal"}, nil
}
if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") {
return TaskSuccessDecision{Success: false, Reason: "heartbeat session"}, nil
}
if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") {
return TaskSuccessDecision{Success: false, Reason: "heartbeat output"}, nil
}
if len(record.ToolExecutions) > 0 {
allFailed := true
for _, exec := range record.ToolExecutions {
if exec.Success {
allFailed = false
break
}
}
if allFailed {
return TaskSuccessDecision{Success: false, Reason: "all tools failed"}, nil
}
}
if strings.TrimSpace(record.FinalOutput) == "" && len(record.ToolExecutions) == 0 {
return TaskSuccessDecision{Success: false, Reason: "missing final output"}, nil
}
return TaskSuccessDecision{Success: true, Reason: "heuristic success"}, nil
}
type LLMTaskSuccessJudge struct {
provider providers.LLMProvider
model string
fallback SuccessJudge
}
type llmTaskSuccessResponse struct {
Success bool `json:"success"`
Reason string `json:"reason"`
}
func NewLLMTaskSuccessJudge(provider providers.LLMProvider, model string, fallback SuccessJudge) *LLMTaskSuccessJudge {
if fallback == nil {
fallback = &HeuristicSuccessJudge{}
}
return &LLMTaskSuccessJudge{
provider: provider,
model: strings.TrimSpace(model),
fallback: fallback,
}
}
func (j *LLMTaskSuccessJudge) JudgeTaskRecord(
ctx context.Context,
record LearningRecord,
) (TaskSuccessDecision, error) {
if j == nil || j.provider == nil {
return j.fallbackDecision(ctx, record)
}
model := strings.TrimSpace(j.model)
if model == "" {
model = strings.TrimSpace(j.provider.GetDefaultModel())
}
if model == "" {
return j.fallbackDecision(ctx, record)
}
resp, err := j.provider.Chat(ctx, []providers.Message{
{
Role: "system",
Content: "Return exactly one JSON object with fields success:boolean and reason:string. No markdown fences.",
},
{
Role: "user",
Content: buildTaskSuccessJudgePrompt(record),
},
}, nil, model, map[string]any{"temperature": 0})
if err != nil || resp == nil {
return j.fallbackDecision(ctx, record)
}
content := strings.TrimSpace(resp.Content)
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
if content == "" {
return j.fallbackDecision(ctx, record)
}
var payload llmTaskSuccessResponse
if err := json.Unmarshal([]byte(content), &payload); err != nil {
return j.fallbackDecision(ctx, record)
}
return TaskSuccessDecision{
Success: payload.Success,
Reason: strings.TrimSpace(payload.Reason),
}, nil
}
func (j *LLMTaskSuccessJudge) fallbackDecision(
ctx context.Context,
record LearningRecord,
) (TaskSuccessDecision, error) {
if j == nil || j.fallback == nil {
return TaskSuccessDecision{Success: false, Reason: "no success judge available"}, nil
}
return j.fallback.JudgeTaskRecord(ctx, record)
}
func buildTaskSuccessJudgePrompt(record LearningRecord) string {
lines := []string{
"Decide whether this agent task truly achieved the user's goal.",
"Reject tasks that are only partial reasoning, only describe future steps, or obviously did not complete the requested outcome.",
"",
"User goal: " + fallbackString(record.UserGoal, "none"),
"Summary: " + fallbackString(record.Summary, "none"),
"Final output: " + fallbackString(record.FinalOutput, "none"),
"Used skills: " + joinOrFallback(record.UsedSkillNames, "none"),
"Tool kinds: " + joinOrFallback(record.ToolKinds, "none"),
"Tool executions:",
}
if len(record.ToolExecutions) == 0 {
lines = append(lines, "- none")
} else {
for _, exec := range record.ToolExecutions {
status := "failed"
if exec.Success {
status = "succeeded"
}
line := "- " + exec.Name + ": " + status
if errSummary := strings.TrimSpace(exec.ErrorSummary); errSummary != "" {
line += " (" + errSummary + ")"
}
if len(exec.SkillNames) > 0 {
line += " [skills: " + strings.Join(exec.SkillNames, ", ") + "]"
}
lines = append(lines, line)
}
}
return strings.Join(lines, "\n")
}

View file

@ -62,30 +62,44 @@ type SkillContextSnapshot struct {
SkillNames []string `json:"skill_names,omitempty"`
}
type ToolExecutionRecord struct {
Name string `json:"name"`
Success bool `json:"success"`
ErrorSummary string `json:"error_summary,omitempty"`
SkillNames []string `json:"skill_names,omitempty"`
}
type LearningRecord struct {
ID string `json:"id"`
Kind RecordKind `json:"kind"`
WorkspaceID string `json:"workspace_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SessionKey string `json:"session_key,omitempty"`
TaskHash string `json:"task_hash,omitempty"`
Summary string `json:"summary"`
Source map[string]any `json:"source,omitempty"`
Status RecordStatus `json:"status"`
Success *bool `json:"success,omitempty"`
ToolKinds []string `json:"tool_kinds,omitempty"`
ActiveSkillNames []string `json:"active_skill_names,omitempty"`
AttemptTrail *AttemptTrail `json:"attempt_trail,omitempty"`
Signals []string `json:"signals,omitempty"`
SourceRecordIDs []string `json:"source_record_ids,omitempty"`
EventCount int `json:"event_count,omitempty"`
SuccessRate float64 `json:"success_rate,omitempty"`
MaturityScore float64 `json:"maturity_score,omitempty"`
WinningPath []string `json:"winning_path,omitempty"`
LateAddedSkills []string `json:"late_added_skills,omitempty"`
FinalSnapshotTrigger string `json:"final_snapshot_trigger,omitempty"`
MatchedSkillNames []string `json:"matched_skill_names,omitempty"`
ID string `json:"id"`
Kind RecordKind `json:"kind"`
WorkspaceID string `json:"workspace_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SessionKey string `json:"session_key,omitempty"`
TaskHash string `json:"task_hash,omitempty"`
Summary string `json:"summary"`
UserGoal string `json:"user_goal,omitempty"`
FinalOutput string `json:"final_output,omitempty"`
Source map[string]any `json:"source,omitempty"`
Status RecordStatus `json:"status"`
Success *bool `json:"success,omitempty"`
ToolKinds []string `json:"tool_kinds,omitempty"`
ToolExecutions []ToolExecutionRecord `json:"tool_executions,omitempty"`
InitialSkillNames []string `json:"initial_skill_names,omitempty"`
AddedSkillNames []string `json:"added_skill_names,omitempty"`
UsedSkillNames []string `json:"used_skill_names,omitempty"`
AllLoadedSkillNames []string `json:"all_loaded_skill_names,omitempty"`
ActiveSkillNames []string `json:"active_skill_names,omitempty"`
AttemptTrail *AttemptTrail `json:"attempt_trail,omitempty"`
Signals []string `json:"signals,omitempty"`
SourceRecordIDs []string `json:"source_record_ids,omitempty"`
EventCount int `json:"event_count,omitempty"`
SuccessRate float64 `json:"success_rate,omitempty"`
MaturityScore float64 `json:"maturity_score,omitempty"`
WinningPath []string `json:"winning_path,omitempty"`
LateAddedSkills []string `json:"late_added_skills,omitempty"`
FinalSnapshotTrigger string `json:"final_snapshot_trigger,omitempty"`
MatchedSkillNames []string `json:"matched_skill_names,omitempty"`
}
type SkillDraft struct {