feat: add modular agent self-evolution foundation
This commit is contained in:
parent
f38debf0d8
commit
f58459e762
67 changed files with 9652 additions and 47 deletions
|
|
@ -43,6 +43,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("error creating provider: %w", err)
|
||||
}
|
||||
provider = providers.WithDefaultModel(provider, modelID)
|
||||
|
||||
// Use the resolved model ID from provider creation
|
||||
if modelID != "" {
|
||||
|
|
|
|||
78
cmd/picoclaw/internal/evolution/apply.go
Normal file
78
cmd/picoclaw/internal/evolution/apply.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func newApplyCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "apply <draft-id>",
|
||||
Short: "Apply one draft to the current workspace",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
|
||||
index, draft, err := findWorkspaceDraft(drafts, paths, workspace, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
applier := evolution.NewApplier(paths, nil)
|
||||
if err := applier.ApplyDraft(cmd.Context(), workspace, draft); err != nil {
|
||||
drafts[index].Status = evolution.DraftStatusQuarantined
|
||||
drafts[index].UpdatedAt = timePtr(now)
|
||||
drafts[index].ScanFindings = appendUniqueStrings(drafts[index].ScanFindings, fmt.Sprintf("manual apply failed: %v", err))
|
||||
if saveErr := store.SaveDrafts(drafts); saveErr != nil {
|
||||
return errors.Join(err, saveErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
drafts[index].Status = evolution.DraftStatusAccepted
|
||||
drafts[index].UpdatedAt = timePtr(now)
|
||||
drafts[index].ReviewNotes = appendUniqueStrings(drafts[index].ReviewNotes, "manually applied via CLI")
|
||||
if err := store.SaveDrafts(drafts); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := evolution.SaveAppliedProfile(store, workspace, draft, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := annotateManualApplyProfile(store, draft.TargetSkillName, draft.ChangeKind); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "applied draft=%s target=%s\n", draft.ID, draft.TargetSkillName)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func annotateManualApplyProfile(store *evolution.Store, skillName string, changeKind evolution.ChangeKind) error {
|
||||
profile, err := store.LoadProfile(skillName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(profile.VersionHistory) == 0 {
|
||||
return nil
|
||||
}
|
||||
last := len(profile.VersionHistory) - 1
|
||||
profile.VersionHistory[last].Action = "manual_apply:" + string(changeKind)
|
||||
profile.VersionHistory[last].Summary = "manual CLI apply: " + profile.VersionHistory[last].Summary
|
||||
return store.SaveProfile(profile)
|
||||
}
|
||||
21
cmd/picoclaw/internal/evolution/command.go
Normal file
21
cmd/picoclaw/internal/evolution/command.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package evolutioncmd
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func NewEvolutionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "evolution",
|
||||
Short: "Manage self-evolving skills",
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newDraftsCommand(),
|
||||
newReviewCommand(),
|
||||
newApplyCommand(),
|
||||
newRollbackCommand(),
|
||||
newStatusCommand(),
|
||||
newRunOnceCommand(),
|
||||
newPruneCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
969
cmd/picoclaw/internal/evolution/command_test.go
Normal file
969
cmd/picoclaw/internal/evolution/command_test.go
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestNewEvolutionCommand(t *testing.T) {
|
||||
cmd := NewEvolutionCommand()
|
||||
if cmd.Use != "evolution" {
|
||||
t.Fatalf("Use = %q, want evolution", cmd.Use)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(cmd.Commands()))
|
||||
for _, sub := range cmd.Commands() {
|
||||
names = append(names, sub.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
want := []string{"apply", "drafts", "prune", "review", "rollback", "run-once", "status"}
|
||||
if !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("commands = %v, want %v", names, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftsListsWorkspaceDraftDetails(t *testing.T) {
|
||||
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
|
||||
workspace := t.TempDir()
|
||||
otherWorkspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, sharedState)
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
|
||||
|
||||
drafts := []evolution.SkillDraft{
|
||||
{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "Prefer native-name query first",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
},
|
||||
{
|
||||
ID: "draft-2",
|
||||
WorkspaceID: otherWorkspace,
|
||||
SourceRecordID: "pattern-2",
|
||||
TargetSkillName: "maps",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "Other workspace draft",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
},
|
||||
}
|
||||
if err := store.SaveDrafts(drafts); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
cmd := newDraftsCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"id=draft-1",
|
||||
"status=candidate",
|
||||
"target=weather",
|
||||
"change=append",
|
||||
"summary=Prefer native-name query first",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Contains(output, "draft-2") {
|
||||
t.Fatalf("output should exclude other workspace draft:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewShowsDraftDetails(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{
|
||||
{
|
||||
ID: "draft-review",
|
||||
WorkspaceID: workspace,
|
||||
CreatedAt: now,
|
||||
SourceRecordID: "pattern-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "Prefer native-name query first",
|
||||
IntendedUseCases: []string{
|
||||
"weather native-name path",
|
||||
},
|
||||
PreferredEntryPath: []string{"weather"},
|
||||
AvoidPatterns: []string{"avoid starting with geocode before using weather"},
|
||||
BodyOrPatch: "## Start Here\nUse native-name query first.\n",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
ReviewNotes: []string{"local structural validation completed"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "skills", "weather"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(workspace, "skills", "weather", "SKILL.md"),
|
||||
[]byte("---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names first.\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspace,
|
||||
CurrentVersion: "draft-stable",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "Weather helper",
|
||||
ChangeReason: "stable weather path",
|
||||
IntendedUseCases: []string{"basic weather lookup"},
|
||||
PreferredEntryPath: []string{"geocode", "weather"},
|
||||
AvoidPatterns: []string{"avoid translating city names twice"},
|
||||
LastUsedAt: now.Add(-time.Hour),
|
||||
UseCount: 5,
|
||||
VersionHistory: []evolution.SkillVersionEntry{
|
||||
{
|
||||
Version: "draft-old",
|
||||
Action: "create",
|
||||
Timestamp: now.Add(-48 * time.Hour),
|
||||
DraftID: "draft-old",
|
||||
Summary: "initial stable version",
|
||||
},
|
||||
{
|
||||
Version: "draft-stable",
|
||||
Action: "manual_apply:append",
|
||||
Timestamp: now.Add(-2 * time.Hour),
|
||||
DraftID: "draft-stable",
|
||||
Summary: "manual CLI apply: stable weather path",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
cmd := newReviewCommand()
|
||||
cmd.SetArgs([]string{"draft-review"})
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"id=draft-review",
|
||||
"source=pattern-1",
|
||||
"target=weather",
|
||||
"type=shortcut",
|
||||
"change=append",
|
||||
"status=candidate",
|
||||
"review_notes=local structural validation completed",
|
||||
"intended_use_cases=weather native-name path",
|
||||
"preferred_entry_path=weather",
|
||||
"avoid_patterns=avoid starting with geocode before using weather",
|
||||
"profile:",
|
||||
"skill=weather status=active version=draft-stable uses=5 reason=stable weather path",
|
||||
"current_preferred_entry_path=geocode -> weather",
|
||||
"recent_history:",
|
||||
"version=draft-stable action=manual_apply:append draft_id=draft-stable summary=manual CLI apply: stable weather path",
|
||||
"impact_preview:",
|
||||
"will_update_existing_skill=true",
|
||||
"expected_effect=append a new section onto the current skill",
|
||||
"current_body:",
|
||||
"Use city names first.",
|
||||
"rendered_body:",
|
||||
"## Start Here",
|
||||
"diff_preview:",
|
||||
"--- current",
|
||||
"+++ rendered",
|
||||
"@@",
|
||||
"+## Start Here",
|
||||
"+Use native-name query first.",
|
||||
"body:",
|
||||
"Use native-name query first.",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommandAppliesDraftAndMarksAccepted(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{
|
||||
{
|
||||
ID: "draft-apply",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "Create weather shortcut",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
cmd := newApplyCommand()
|
||||
cmd.SetArgs([]string{"draft-apply"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusAccepted {
|
||||
t.Fatalf("draft status = %q, want accepted", drafts[0].Status)
|
||||
}
|
||||
if drafts[0].UpdatedAt == nil {
|
||||
t.Fatal("expected UpdatedAt to be set after apply")
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "Use native-name query first.") {
|
||||
t.Fatalf("unexpected skill body:\n%s", string(data))
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.CurrentVersion != "draft-apply" {
|
||||
t.Fatalf("CurrentVersion = %q, want draft-apply", profile.CurrentVersion)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("Status = %q, want active", profile.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackCommandRestoresLatestBackupAndUpdatesProfile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n"
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
|
||||
ID: "draft-new",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "Replace weather body",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyDraft: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{
|
||||
{
|
||||
ID: "draft-new",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "Replace weather body",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nNew risky body.\n",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspace,
|
||||
CurrentVersion: "draft-new",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "Weather skill",
|
||||
LastUsedAt: time.Unix(1700000000, 0).UTC(),
|
||||
UseCount: 3,
|
||||
RetentionScore: 0.8,
|
||||
VersionHistory: []evolution.SkillVersionEntry{
|
||||
{
|
||||
Version: "draft-old",
|
||||
Action: "create",
|
||||
Timestamp: time.Unix(1699990000, 0).UTC(),
|
||||
DraftID: "draft-old",
|
||||
Summary: "old stable",
|
||||
},
|
||||
{
|
||||
Version: "draft-new",
|
||||
Action: "replace",
|
||||
Timestamp: time.Unix(1700000000, 0).UTC(),
|
||||
DraftID: "draft-new",
|
||||
Summary: "new risky",
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
cmd := newRollbackCommand()
|
||||
cmd.SetArgs([]string{"weather"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != original {
|
||||
t.Fatalf("rollback did not restore original body:\n%s", string(data))
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.CurrentVersion != "draft-old" {
|
||||
t.Fatalf("CurrentVersion = %q, want draft-old", profile.CurrentVersion)
|
||||
}
|
||||
if len(profile.VersionHistory) != 3 {
|
||||
t.Fatalf("len(VersionHistory) = %d, want 3", len(profile.VersionHistory))
|
||||
}
|
||||
last := profile.VersionHistory[len(profile.VersionHistory)-1]
|
||||
if last.Action != "manual_rollback" {
|
||||
t.Fatalf("Action = %q, want manual_rollback", last.Action)
|
||||
}
|
||||
if !last.Rollback {
|
||||
t.Fatal("expected rollback history entry")
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("draft status = %q, want quarantined after rollback", drafts[0].Status)
|
||||
}
|
||||
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "rolled back") {
|
||||
t.Fatalf("ReviewNotes = %v, want rollback note", drafts[0].ReviewNotes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftGeneratorForRunOnce_FallsBackWhenProviderCreationFails(t *testing.T) {
|
||||
originalFactory := createEvolutionProvider
|
||||
createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) {
|
||||
return nil, "", errors.New("provider init failed")
|
||||
}
|
||||
defer func() {
|
||||
createEvolutionProvider = originalFactory
|
||||
}()
|
||||
|
||||
workspace := t.TempDir()
|
||||
generator := draftGeneratorForRunOnce(&config.Config{}, workspace)
|
||||
|
||||
draft, err := generator.GenerateDraft(t.Context(), evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Summary: "weather native-name path",
|
||||
EventCount: 2,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
if draft.TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
|
||||
}
|
||||
if draft.BodyOrPatch == "" {
|
||||
t.Fatal("expected fallback template draft body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraftGeneratorForRunOnce_UsesExplicitModelIDFromProviderFactory(t *testing.T) {
|
||||
originalFactory := createEvolutionProvider
|
||||
provider := &runOnceProvider{
|
||||
defaultModel: "",
|
||||
response: &providers.LLMResponse{
|
||||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name query first","body_or_patch":"## Start Here\nUse native-name query first."}`,
|
||||
},
|
||||
}
|
||||
createEvolutionProvider = func(*config.Config) (providers.LLMProvider, string, error) {
|
||||
return provider, "configured-model-id", nil
|
||||
}
|
||||
defer func() {
|
||||
createEvolutionProvider = originalFactory
|
||||
}()
|
||||
|
||||
workspace := t.TempDir()
|
||||
generator := draftGeneratorForRunOnce(&config.Config{}, workspace)
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Summary: "weather native-name path",
|
||||
EventCount: 2,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
if provider.lastModel != "configured-model-id" {
|
||||
t.Fatalf("lastModel = %q, want configured-model-id", provider.lastModel)
|
||||
}
|
||||
if draft.TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
|
||||
}
|
||||
if draft.HumanSummary != "Prefer native-name query first" {
|
||||
t.Fatalf("HumanSummary = %q, want %q", draft.HumanSummary, "Prefer native-name query first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneAppendsLifecycleHistory(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# weather\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspace,
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "weather helper",
|
||||
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
|
||||
RetentionScore: 0.05,
|
||||
VersionHistory: []evolution.SkillVersionEntry{{
|
||||
Version: "v1",
|
||||
Action: "create",
|
||||
Timestamp: time.Now().Add(-400 * 24 * time.Hour),
|
||||
Summary: "initial version",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
cmd := newPruneCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusDeleted {
|
||||
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusDeleted)
|
||||
}
|
||||
if len(profile.VersionHistory) != 2 {
|
||||
t.Fatalf("len(VersionHistory) = %d, want 2", len(profile.VersionHistory))
|
||||
}
|
||||
last := profile.VersionHistory[len(profile.VersionHistory)-1]
|
||||
if last.Action != "lifecycle:deleted" {
|
||||
t.Fatalf("Action = %q, want lifecycle:deleted", last.Action)
|
||||
}
|
||||
if !strings.Contains(last.Summary, "archived -> deleted") {
|
||||
t.Fatalf("Summary = %q, want lifecycle transition summary", last.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneScopesProfilesToCurrentWorkspaceInSharedState(t *testing.T) {
|
||||
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
|
||||
workspace := t.TempDir()
|
||||
otherWorkspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, sharedState)
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "skills", "mine"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll current skill: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, "skills", "mine", "SKILL.md"), []byte("# mine\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile current skill: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(otherWorkspace, "skills", "theirs"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll other skill: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md"), []byte("# theirs\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile other skill: %v", err)
|
||||
}
|
||||
|
||||
profiles := []evolution.SkillProfile{
|
||||
{
|
||||
SkillName: "mine",
|
||||
WorkspaceID: workspace,
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
|
||||
RetentionScore: 0.05,
|
||||
},
|
||||
{
|
||||
SkillName: "theirs",
|
||||
WorkspaceID: otherWorkspace,
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
|
||||
RetentionScore: 0.05,
|
||||
},
|
||||
{
|
||||
SkillName: "legacy",
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
|
||||
RetentionScore: 0.05,
|
||||
},
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := newPruneCommand()
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
mine, err := store.LoadProfile("mine")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile(mine): %v", err)
|
||||
}
|
||||
if mine.Status != evolution.SkillStatusDeleted {
|
||||
t.Fatalf("mine.Status = %q, want %q", mine.Status, evolution.SkillStatusDeleted)
|
||||
}
|
||||
if len(mine.VersionHistory) != 1 || mine.VersionHistory[0].Action != "lifecycle:deleted" {
|
||||
t.Fatalf("mine.VersionHistory = %+v, want lifecycle delete entry", mine.VersionHistory)
|
||||
}
|
||||
|
||||
theirs, err := store.LoadProfile("theirs")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile(theirs): %v", err)
|
||||
}
|
||||
if theirs.Status != evolution.SkillStatusArchived {
|
||||
t.Fatalf("theirs.Status = %q, want archived", theirs.Status)
|
||||
}
|
||||
if len(theirs.VersionHistory) != 0 {
|
||||
t.Fatalf("theirs.VersionHistory = %+v, want unchanged", theirs.VersionHistory)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(otherWorkspace, "skills", "theirs", "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("other workspace skill should remain, stat err = %v", statErr)
|
||||
}
|
||||
|
||||
legacy, err := store.LoadProfile("legacy")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile(legacy): %v", err)
|
||||
}
|
||||
if legacy.Status != evolution.SkillStatusArchived {
|
||||
t.Fatalf("legacy.Status = %q, want archived", legacy.Status)
|
||||
}
|
||||
if len(legacy.VersionHistory) != 0 {
|
||||
t.Fatalf("legacy.VersionHistory = %+v, want unchanged", legacy.VersionHistory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneDoesNotMutateProfileWhenDeleteActionFails(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
paths := evolution.NewPaths(workspace, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
legacyProfile := evolution.SkillProfile{
|
||||
SkillName: "../escape",
|
||||
WorkspaceID: workspace,
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
LastUsedAt: time.Now().Add(-366 * 24 * time.Hour),
|
||||
RetentionScore: 0.05,
|
||||
VersionHistory: []evolution.SkillVersionEntry{{
|
||||
Version: "v1",
|
||||
Action: "create",
|
||||
Timestamp: time.Now().Add(-400 * 24 * time.Hour),
|
||||
Summary: "initial version",
|
||||
}},
|
||||
}
|
||||
if err := os.MkdirAll(paths.ProfilesDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(legacyProfile, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalIndent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(paths.ProfilesDir, "weather.json"), data, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
cmd := newPruneCommand()
|
||||
err = cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected prune to fail when delete action cannot resolve skill path")
|
||||
}
|
||||
|
||||
profile, loadErr := store.LoadProfile("weather")
|
||||
if loadErr != nil {
|
||||
t.Fatalf("LoadProfile: %v", loadErr)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusArchived {
|
||||
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusArchived)
|
||||
}
|
||||
if len(profile.VersionHistory) != 1 {
|
||||
t.Fatalf("len(VersionHistory) = %d, want 1", len(profile.VersionHistory))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusShowsDraftAndProfileDistributions(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
drafts := []evolution.SkillDraft{
|
||||
{ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", HumanSummary: "first candidate", ChangeKind: evolution.ChangeKindAppend, Status: evolution.DraftStatusCandidate, UpdatedAt: timePtr(now.Add(-2 * time.Hour))},
|
||||
{ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", HumanSummary: "middle quarantine", ChangeKind: evolution.ChangeKindReplace, Status: evolution.DraftStatusQuarantined, UpdatedAt: timePtr(now.Add(-1 * time.Hour))},
|
||||
{ID: "d3", WorkspaceID: workspace, TargetSkillName: "skill-c", HumanSummary: "latest accepted", ChangeKind: evolution.ChangeKindCreate, Status: evolution.DraftStatusAccepted, UpdatedAt: timePtr(now)},
|
||||
}
|
||||
if err := store.SaveDrafts(drafts); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
profiles := []evolution.SkillProfile{
|
||||
{SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive, CurrentVersion: "v-a", UseCount: 5, ChangeReason: "active reason", LastUsedAt: now.Add(-3 * time.Hour)},
|
||||
{SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold, CurrentVersion: "v-b", UseCount: 2, ChangeReason: "cold reason", LastUsedAt: now.Add(-2 * time.Hour)},
|
||||
{SkillName: "skill-c", WorkspaceID: workspace, Status: evolution.SkillStatusArchived, CurrentVersion: "v-c", UseCount: 1, ChangeReason: "archived reason", LastUsedAt: now},
|
||||
{SkillName: "skill-d", WorkspaceID: workspace, Status: evolution.SkillStatusDeleted, CurrentVersion: "v-d", UseCount: 0, ChangeReason: "deleted reason", LastUsedAt: now.Add(-4 * time.Hour)},
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := newStatusCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"drafts=3",
|
||||
"drafts_by_status=candidate:1 quarantined:1 accepted:1",
|
||||
"profiles=4",
|
||||
"profiles_by_status=active:1 cold:1 archived:1 deleted:1",
|
||||
"draft_items:",
|
||||
"id=d3 status=accepted target=skill-c change=create summary=latest accepted",
|
||||
"profile_items:",
|
||||
"skill=skill-c status=archived version=v-c uses=1 reason=archived reason",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusScopesCountsToCurrentWorkspaceInSharedState(t *testing.T) {
|
||||
sharedState := filepath.Join(t.TempDir(), "shared-evolution")
|
||||
workspace := t.TempDir()
|
||||
otherWorkspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, sharedState)
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, sharedState))
|
||||
|
||||
drafts := []evolution.SkillDraft{
|
||||
{ID: "d1", WorkspaceID: workspace, TargetSkillName: "skill-a", Status: evolution.DraftStatusCandidate},
|
||||
{ID: "d2", WorkspaceID: workspace, TargetSkillName: "skill-b", Status: evolution.DraftStatusAccepted},
|
||||
{ID: "d3", WorkspaceID: otherWorkspace, TargetSkillName: "skill-c", Status: evolution.DraftStatusQuarantined},
|
||||
{ID: "d4", TargetSkillName: "legacy-draft", Status: evolution.DraftStatusCandidate},
|
||||
}
|
||||
if err := store.SaveDrafts(drafts); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
profiles := []evolution.SkillProfile{
|
||||
{SkillName: "skill-a", WorkspaceID: workspace, Status: evolution.SkillStatusActive},
|
||||
{SkillName: "skill-b", WorkspaceID: workspace, Status: evolution.SkillStatusCold},
|
||||
{SkillName: "skill-c", WorkspaceID: otherWorkspace, Status: evolution.SkillStatusDeleted},
|
||||
{SkillName: "legacy", Status: evolution.SkillStatusArchived},
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
t.Fatalf("SaveProfile(%s): %v", profile.SkillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := newStatusCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"drafts=2",
|
||||
"drafts_by_status=candidate:1 quarantined:0 accepted:1",
|
||||
"profiles=2",
|
||||
"profiles_by_status=active:1 cold:1 archived:0 deleted:0",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCommandAddsExplicitManualAuditInfo(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{
|
||||
{
|
||||
ID: "draft-apply-audit",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "Create weather shortcut",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
cmd := newApplyCommand()
|
||||
cmd.SetArgs([]string{"draft-apply-audit"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "manually applied via CLI") {
|
||||
t.Fatalf("ReviewNotes = %v, want manual apply note", drafts[0].ReviewNotes)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
last := profile.VersionHistory[len(profile.VersionHistory)-1]
|
||||
if last.Action != "manual_apply:create" {
|
||||
t.Fatalf("Action = %q, want manual_apply:create", last.Action)
|
||||
}
|
||||
if !strings.Contains(last.Summary, "manual CLI apply") {
|
||||
t.Fatalf("Summary = %q, want manual CLI apply summary", last.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackCommandAddsExplicitManualAuditInfo(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
configureEvolutionCommandTest(t, workspace, "")
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\nOld stable body.\n"
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
|
||||
ID: "draft-risky",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "Replace weather body",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
}); err != nil {
|
||||
t.Fatalf("ApplyDraft: %v", err)
|
||||
}
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{{
|
||||
ID: "draft-risky",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "pattern-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "Replace weather body",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nRisky body.\n",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspace,
|
||||
CurrentVersion: "draft-risky",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "Weather skill",
|
||||
LastUsedAt: time.Unix(1700000000, 0).UTC(),
|
||||
VersionHistory: []evolution.SkillVersionEntry{
|
||||
{Version: "draft-old", Action: "create", Timestamp: time.Unix(1699990000, 0).UTC(), DraftID: "draft-old", Summary: "old stable"},
|
||||
{Version: "draft-risky", Action: "replace", Timestamp: time.Unix(1700000000, 0).UTC(), DraftID: "draft-risky", Summary: "new risky"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
cmd := newRollbackCommand()
|
||||
cmd.SetArgs([]string{"weather"})
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
last := profile.VersionHistory[len(profile.VersionHistory)-1]
|
||||
if last.Action != "manual_rollback" {
|
||||
t.Fatalf("Action = %q, want manual_rollback", last.Action)
|
||||
}
|
||||
if !strings.Contains(last.Summary, "manual CLI rollback") {
|
||||
t.Fatalf("Summary = %q, want manual rollback summary", last.Summary)
|
||||
}
|
||||
if last.DraftID != "draft-risky" {
|
||||
t.Fatalf("DraftID = %q, want draft-risky", last.DraftID)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if len(drafts[0].ReviewNotes) == 0 || !strings.Contains(strings.Join(drafts[0].ReviewNotes, " "), "backup=") {
|
||||
t.Fatalf("ReviewNotes = %v, want backup path note", drafts[0].ReviewNotes)
|
||||
}
|
||||
}
|
||||
|
||||
func configureEvolutionCommandTest(t *testing.T, workspace, stateDir string) {
|
||||
t.Helper()
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
t.Setenv(config.EnvConfig, configPath)
|
||||
t.Setenv(config.EnvHome, t.TempDir())
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
},
|
||||
},
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
StateDir: stateDir,
|
||||
},
|
||||
}
|
||||
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type runOnceProvider struct {
|
||||
response *providers.LLMResponse
|
||||
err error
|
||||
defaultModel string
|
||||
lastModel string
|
||||
}
|
||||
|
||||
func (p *runOnceProvider) Chat(
|
||||
_ context.Context,
|
||||
_ []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
model string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.lastModel = model
|
||||
return p.response, p.err
|
||||
}
|
||||
|
||||
func (p *runOnceProvider) GetDefaultModel() string {
|
||||
return p.defaultModel
|
||||
}
|
||||
56
cmd/picoclaw/internal/evolution/draft_helpers.go
Normal file
56
cmd/picoclaw/internal/evolution/draft_helpers.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func loadWorkspaceDraft(store *evolution.Store, workspace, stateDir, id string) (evolution.SkillDraft, error) {
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return evolution.SkillDraft{}, err
|
||||
}
|
||||
paths := evolution.NewPaths(workspace, stateDir)
|
||||
_, draft, err := findWorkspaceDraft(drafts, paths, workspace, id)
|
||||
return draft, err
|
||||
}
|
||||
|
||||
func findWorkspaceDraft(
|
||||
drafts []evolution.SkillDraft,
|
||||
paths evolution.Paths,
|
||||
workspace, id string,
|
||||
) (int, evolution.SkillDraft, error) {
|
||||
for i, draft := range drafts {
|
||||
if !draftBelongsToWorkspace(paths, workspace, draft) {
|
||||
continue
|
||||
}
|
||||
if draft.ID == id {
|
||||
return i, draft, nil
|
||||
}
|
||||
}
|
||||
return -1, evolution.SkillDraft{}, fmt.Errorf("draft %q not found for workspace", id)
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, values ...string) []string {
|
||||
seen := make(map[string]struct{}, len(existing))
|
||||
for _, value := range existing {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func timePtr(v time.Time) *time.Time {
|
||||
return &v
|
||||
}
|
||||
58
cmd/picoclaw/internal/evolution/drafts.go
Normal file
58
cmd/picoclaw/internal/evolution/drafts.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func newDraftsCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "drafts",
|
||||
Short: "List skill drafts for the current workspace",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([]evolution.SkillDraft, 0, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draftBelongsToWorkspace(paths, workspace, draft) {
|
||||
filtered = append(filtered, draft)
|
||||
}
|
||||
}
|
||||
sort.Slice(filtered, func(i, j int) bool {
|
||||
if filtered[i].CreatedAt.Equal(filtered[j].CreatedAt) {
|
||||
return filtered[i].ID < filtered[j].ID
|
||||
}
|
||||
return filtered[i].CreatedAt.Before(filtered[j].CreatedAt)
|
||||
})
|
||||
|
||||
for _, draft := range filtered {
|
||||
if _, err := fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"id=%s status=%s target=%s type=%s change=%s summary=%s\n",
|
||||
draft.ID,
|
||||
draft.Status,
|
||||
draft.TargetSkillName,
|
||||
draft.DraftType,
|
||||
draft.ChangeKind,
|
||||
draft.HumanSummary,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
18
cmd/picoclaw/internal/evolution/helpers.go
Normal file
18
cmd/picoclaw/internal/evolution/helpers.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func loadEvolutionDeps() (*config.Config, *evolution.Store, string, error) {
|
||||
cfg, err := internal.LoadConfig()
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, cfg.Evolution.StateDir))
|
||||
return cfg, store, workspace, nil
|
||||
}
|
||||
54
cmd/picoclaw/internal/evolution/prune.go
Normal file
54
cmd/picoclaw/internal/evolution/prune.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func newPruneCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Recompute lifecycle states for learned skills",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profiles, err := store.LoadProfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
|
||||
for _, profile := range profiles {
|
||||
if !profileBelongsToWorkspace(paths, workspace, profile) {
|
||||
continue
|
||||
}
|
||||
|
||||
next := evolution.NextLifecycleState(profile, now)
|
||||
if next != profile.Status {
|
||||
if err := evolution.ApplyLifecycleState(paths, profile, next); err != nil {
|
||||
return err
|
||||
}
|
||||
profile.VersionHistory = append(profile.VersionHistory, evolution.SkillVersionEntry{
|
||||
Version: profile.CurrentVersion,
|
||||
Action: "lifecycle:" + string(next),
|
||||
Timestamp: now,
|
||||
Summary: fmt.Sprintf("lifecycle transition: %s -> %s", profile.Status, next),
|
||||
})
|
||||
profile.Status = next
|
||||
}
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
127
cmd/picoclaw/internal/evolution/review.go
Normal file
127
cmd/picoclaw/internal/evolution/review.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func newReviewCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "review <draft-id>",
|
||||
Short: "Show detailed information for one draft",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
draft, err := loadWorkspaceDraft(store, workspace, cfg.Evolution.StateDir, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, profileErr := store.LoadProfile(draft.TargetSkillName)
|
||||
hasProfile := profileErr == nil
|
||||
if profileErr != nil && !os.IsNotExist(profileErr) {
|
||||
return profileErr
|
||||
}
|
||||
preview, err := evolution.BuildDraftPreview(workspace, draft)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reviewNotes := strings.Join(draft.ReviewNotes, ", ")
|
||||
scanFindings := strings.Join(draft.ScanFindings, ", ")
|
||||
intendedUseCases := strings.Join(draft.IntendedUseCases, ", ")
|
||||
preferredEntryPath := strings.Join(draft.PreferredEntryPath, " -> ")
|
||||
avoidPatterns := strings.Join(draft.AvoidPatterns, ", ")
|
||||
if _, err := fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"id=%s\nsource=%s\ntarget=%s\ntype=%s\nchange=%s\nstatus=%s\nsummary=%s\nreview_notes=%s\nscan_findings=%s\nintended_use_cases=%s\npreferred_entry_path=%s\navoid_patterns=%s\n%s%scurrent_body:\n%s\nrendered_body:\n%s\ndiff_preview:\n%s\nbody:\n%s\n",
|
||||
draft.ID,
|
||||
draft.SourceRecordID,
|
||||
draft.TargetSkillName,
|
||||
draft.DraftType,
|
||||
draft.ChangeKind,
|
||||
draft.Status,
|
||||
draft.HumanSummary,
|
||||
reviewNotes,
|
||||
scanFindings,
|
||||
intendedUseCases,
|
||||
preferredEntryPath,
|
||||
avoidPatterns,
|
||||
formatReviewProfileSection(profile, hasProfile),
|
||||
formatImpactPreview(draft, hasProfile),
|
||||
preview.CurrentBody,
|
||||
preview.RenderedBody,
|
||||
preview.DiffPreview,
|
||||
draft.BodyOrPatch,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func formatReviewProfileSection(profile evolution.SkillProfile, hasProfile bool) string {
|
||||
if !hasProfile {
|
||||
return "profile:\nmissing=true\nrecent_history:\n"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"profile:",
|
||||
fmt.Sprintf(
|
||||
"skill=%s status=%s version=%s uses=%d reason=%s",
|
||||
profile.SkillName,
|
||||
profile.Status,
|
||||
profile.CurrentVersion,
|
||||
profile.UseCount,
|
||||
profile.ChangeReason,
|
||||
),
|
||||
"current_preferred_entry_path=" + strings.Join(profile.PreferredEntryPath, " -> "),
|
||||
"recent_history:",
|
||||
}
|
||||
|
||||
history := profile.VersionHistory
|
||||
for i := len(history) - 1; i >= 0 && i >= len(history)-3; i-- {
|
||||
entry := history[i]
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"version=%s action=%s draft_id=%s summary=%s",
|
||||
entry.Version,
|
||||
entry.Action,
|
||||
entry.DraftID,
|
||||
entry.Summary,
|
||||
))
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func formatImpactPreview(draft evolution.SkillDraft, hasProfile bool) string {
|
||||
lines := []string{
|
||||
"impact_preview:",
|
||||
fmt.Sprintf("will_update_existing_skill=%t", hasProfile),
|
||||
"expected_effect=" + expectedDraftEffect(draft.ChangeKind),
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func expectedDraftEffect(changeKind evolution.ChangeKind) string {
|
||||
switch changeKind {
|
||||
case evolution.ChangeKindCreate:
|
||||
return "create a brand-new skill file"
|
||||
case evolution.ChangeKindAppend:
|
||||
return "append a new section onto the current skill"
|
||||
case evolution.ChangeKindReplace:
|
||||
return "replace the current skill body with the drafted body"
|
||||
case evolution.ChangeKindMerge:
|
||||
return "merge the draft into the current skill with an extra merged section"
|
||||
default:
|
||||
return "apply the drafted change to the target skill"
|
||||
}
|
||||
}
|
||||
160
cmd/picoclaw/internal/evolution/rollback.go
Normal file
160
cmd/picoclaw/internal/evolution/rollback.go
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
)
|
||||
|
||||
func newRollbackCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rollback <skill-name>",
|
||||
Short: "Restore the latest backup for one skill",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
|
||||
backupPath, err := latestBackupPath(paths, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", args[0], "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteFileAtomic(skillPath, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rolledBackDraftID, err := saveRollbackProfile(store, workspace, args[0], backupPath, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := markDraftRolledBack(store, workspace, cfg.Evolution.StateDir, rolledBackDraftID, backupPath, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(cmd.OutOrStdout(), "rolled back skill=%s backup=%s\n", args[0], backupPath)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func latestBackupPath(paths evolution.Paths, skillName string) (string, error) {
|
||||
backupRoot := filepath.Join(paths.BackupsDir, skillName)
|
||||
entries, err := os.ReadDir(backupRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||||
for _, name := range names {
|
||||
candidate := filepath.Join(backupRoot, name, "SKILL.md")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no backup found for skill %q", skillName)
|
||||
}
|
||||
|
||||
func saveRollbackProfile(store *evolution.Store, workspace, skillName, backupPath string, now time.Time) (string, error) {
|
||||
profile, err := store.LoadProfile(skillName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
rolledBackDraftID := profile.CurrentVersion
|
||||
previousVersion := previousStableVersion(profile.VersionHistory, profile.CurrentVersion)
|
||||
profile.CurrentVersion = previousVersion
|
||||
profile.Status = evolution.SkillStatusActive
|
||||
profile.LastUsedAt = now
|
||||
profile.VersionHistory = append(profile.VersionHistory, evolution.SkillVersionEntry{
|
||||
Version: previousVersion,
|
||||
Action: "manual_rollback",
|
||||
Timestamp: now,
|
||||
DraftID: rolledBackDraftID,
|
||||
Summary: "manual CLI rollback to latest backup: " + backupPath,
|
||||
Rollback: true,
|
||||
RollbackReason: "manual CLI rollback",
|
||||
})
|
||||
return rolledBackDraftID, store.SaveProfile(profile)
|
||||
}
|
||||
|
||||
func previousStableVersion(history []evolution.SkillVersionEntry, currentVersion string) string {
|
||||
if len(history) == 0 {
|
||||
return currentVersion
|
||||
}
|
||||
|
||||
currentIndex := -1
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
if history[i].Version == currentVersion {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if currentIndex <= 0 {
|
||||
return currentVersion
|
||||
}
|
||||
for i := currentIndex - 1; i >= 0; i-- {
|
||||
if history[i].Rollback {
|
||||
continue
|
||||
}
|
||||
if history[i].Version != "" {
|
||||
return history[i].Version
|
||||
}
|
||||
}
|
||||
return currentVersion
|
||||
}
|
||||
|
||||
func markDraftRolledBack(store *evolution.Store, workspace, stateDir, draftID, backupPath string, now time.Time) error {
|
||||
if draftID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := evolution.NewPaths(workspace, stateDir)
|
||||
for i, draft := range drafts {
|
||||
if !draftBelongsToWorkspace(paths, workspace, draft) {
|
||||
continue
|
||||
}
|
||||
if draft.ID != draftID {
|
||||
continue
|
||||
}
|
||||
drafts[i].Status = evolution.DraftStatusQuarantined
|
||||
drafts[i].UpdatedAt = timePtr(now)
|
||||
drafts[i].ReviewNotes = appendUniqueStrings(
|
||||
drafts[i].ReviewNotes,
|
||||
"manually rolled back after apply",
|
||||
"backup="+backupPath,
|
||||
)
|
||||
return store.SaveDrafts(drafts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
cmd/picoclaw/internal/evolution/run_once.go
Normal file
55
cmd/picoclaw/internal/evolution/run_once.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
var createEvolutionProvider = providers.CreateProvider
|
||||
|
||||
func newRunOnceCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "run-once",
|
||||
Short: "Run evolution cold path once",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: cfg.Evolution,
|
||||
Store: store,
|
||||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{
|
||||
MinCaseCount: cfg.Evolution.MinCaseCount,
|
||||
MinSuccessRate: cfg.Evolution.MinSuccessRate,
|
||||
}),
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(workspace),
|
||||
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
|
||||
return draftGeneratorForRunOnce(cfg, workspace)
|
||||
},
|
||||
Applier: evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rt.RunColdPathOnce(cmd.Context(), workspace)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func draftGeneratorForRunOnce(cfg *config.Config, workspace string) evolution.DraftGenerator {
|
||||
if cfg == nil {
|
||||
return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "")
|
||||
}
|
||||
|
||||
provider, modelID, err := createEvolutionProvider(cfg)
|
||||
if err != nil {
|
||||
return evolution.NewDraftGeneratorForWorkspace(workspace, nil, "")
|
||||
}
|
||||
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID)
|
||||
}
|
||||
173
cmd/picoclaw/internal/evolution/status.go
Normal file
173
cmd/picoclaw/internal/evolution/status.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func newStatusCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show evolution status",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, store, workspace, err := loadEvolutionDeps()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paths := evolution.NewPaths(workspace, cfg.Evolution.StateDir)
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profiles, err := store.LoadProfiles()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
drafts = filterDraftsForWorkspace(paths, workspace, drafts)
|
||||
profiles = filterProfilesForWorkspace(paths, workspace, profiles)
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"workspace=%s\nenabled=%v\nmode=%s\ndrafts=%d\ndrafts_by_status=%s\nprofiles=%d\nprofiles_by_status=%s\n%s%s",
|
||||
workspace,
|
||||
cfg.Evolution.Enabled,
|
||||
cfg.Evolution.EffectiveMode(),
|
||||
len(drafts),
|
||||
formatDraftStatusCounts(drafts),
|
||||
len(profiles),
|
||||
formatProfileStatusCounts(profiles),
|
||||
formatDraftItems(drafts),
|
||||
formatProfileItems(profiles),
|
||||
)
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func filterDraftsForWorkspace(paths evolution.Paths, workspace string, drafts []evolution.SkillDraft) []evolution.SkillDraft {
|
||||
filtered := make([]evolution.SkillDraft, 0, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draftBelongsToWorkspace(paths, workspace, draft) {
|
||||
filtered = append(filtered, draft)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func filterProfilesForWorkspace(paths evolution.Paths, workspace string, profiles []evolution.SkillProfile) []evolution.SkillProfile {
|
||||
filtered := make([]evolution.SkillProfile, 0, len(profiles))
|
||||
for _, profile := range profiles {
|
||||
if profileBelongsToWorkspace(paths, workspace, profile) {
|
||||
filtered = append(filtered, profile)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func formatDraftStatusCounts(drafts []evolution.SkillDraft) string {
|
||||
counts := map[evolution.DraftStatus]int{
|
||||
evolution.DraftStatusCandidate: 0,
|
||||
evolution.DraftStatusQuarantined: 0,
|
||||
evolution.DraftStatusAccepted: 0,
|
||||
}
|
||||
for _, draft := range drafts {
|
||||
counts[draft.Status]++
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
fmt.Sprintf("%s:%d", evolution.DraftStatusCandidate, counts[evolution.DraftStatusCandidate]),
|
||||
fmt.Sprintf("%s:%d", evolution.DraftStatusQuarantined, counts[evolution.DraftStatusQuarantined]),
|
||||
fmt.Sprintf("%s:%d", evolution.DraftStatusAccepted, counts[evolution.DraftStatusAccepted]),
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func formatProfileStatusCounts(profiles []evolution.SkillProfile) string {
|
||||
counts := map[evolution.SkillStatus]int{
|
||||
evolution.SkillStatusActive: 0,
|
||||
evolution.SkillStatusCold: 0,
|
||||
evolution.SkillStatusArchived: 0,
|
||||
evolution.SkillStatusDeleted: 0,
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
counts[profile.Status]++
|
||||
}
|
||||
|
||||
parts := []string{
|
||||
fmt.Sprintf("%s:%d", evolution.SkillStatusActive, counts[evolution.SkillStatusActive]),
|
||||
fmt.Sprintf("%s:%d", evolution.SkillStatusCold, counts[evolution.SkillStatusCold]),
|
||||
fmt.Sprintf("%s:%d", evolution.SkillStatusArchived, counts[evolution.SkillStatusArchived]),
|
||||
fmt.Sprintf("%s:%d", evolution.SkillStatusDeleted, counts[evolution.SkillStatusDeleted]),
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func formatDraftItems(drafts []evolution.SkillDraft) string {
|
||||
if len(drafts) == 0 {
|
||||
return "draft_items:\n"
|
||||
}
|
||||
|
||||
items := append([]evolution.SkillDraft(nil), drafts...)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
left := draftSortTime(items[i])
|
||||
right := draftSortTime(items[j])
|
||||
if !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
|
||||
lines := []string{"draft_items:"}
|
||||
for _, draft := range items {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"id=%s status=%s target=%s change=%s summary=%s",
|
||||
draft.ID,
|
||||
draft.Status,
|
||||
draft.TargetSkillName,
|
||||
draft.ChangeKind,
|
||||
draft.HumanSummary,
|
||||
))
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func formatProfileItems(profiles []evolution.SkillProfile) string {
|
||||
if len(profiles) == 0 {
|
||||
return "profile_items:\n"
|
||||
}
|
||||
|
||||
items := append([]evolution.SkillProfile(nil), profiles...)
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if !items[i].LastUsedAt.Equal(items[j].LastUsedAt) {
|
||||
return items[i].LastUsedAt.After(items[j].LastUsedAt)
|
||||
}
|
||||
return items[i].SkillName < items[j].SkillName
|
||||
})
|
||||
|
||||
lines := []string{"profile_items:"}
|
||||
for _, profile := range items {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"skill=%s status=%s version=%s uses=%d reason=%s",
|
||||
profile.SkillName,
|
||||
profile.Status,
|
||||
profile.CurrentVersion,
|
||||
profile.UseCount,
|
||||
profile.ChangeReason,
|
||||
))
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n"
|
||||
}
|
||||
|
||||
func draftSortTime(draft evolution.SkillDraft) time.Time {
|
||||
if draft.UpdatedAt != nil {
|
||||
return *draft.UpdatedAt
|
||||
}
|
||||
return draft.CreatedAt
|
||||
}
|
||||
37
cmd/picoclaw/internal/evolution/workspace_scope.go
Normal file
37
cmd/picoclaw/internal/evolution/workspace_scope.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package evolutioncmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func draftBelongsToWorkspace(paths evolution.Paths, workspace string, draft evolution.SkillDraft) bool {
|
||||
if draft.WorkspaceID == workspace {
|
||||
return true
|
||||
}
|
||||
return draft.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace)
|
||||
}
|
||||
|
||||
func profileBelongsToWorkspace(paths evolution.Paths, workspace string, profile evolution.SkillProfile) bool {
|
||||
if profile.WorkspaceID == workspace {
|
||||
return true
|
||||
}
|
||||
return profile.WorkspaceID == "" && usesDefaultWorkspaceState(paths, workspace)
|
||||
}
|
||||
|
||||
func usesDefaultWorkspaceState(paths evolution.Paths, workspace string) bool {
|
||||
return paths.RootDir == evolution.NewPaths(workspace, "").RootDir
|
||||
}
|
||||
|
||||
func inferWorkspaceFromPaths(paths evolution.Paths) string {
|
||||
root := filepath.Clean(paths.RootDir)
|
||||
if filepath.Base(root) != "evolution" {
|
||||
return ""
|
||||
}
|
||||
stateDir := filepath.Dir(root)
|
||||
if filepath.Base(stateDir) != "state" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(stateDir)
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import (
|
|||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||
evolutioncmd "github.com/sipeed/picoclaw/cmd/picoclaw/internal/evolution"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||
|
|
@ -89,6 +90,7 @@ picoclaw --no-color status`,
|
|||
cron.NewCronCommand(),
|
||||
migrate.NewMigrateCommand(),
|
||||
skills.NewSkillsCommand(),
|
||||
evolutioncmd.NewEvolutionCommand(),
|
||||
model.NewModelCommand(),
|
||||
updater.NewUpdateCommand("picoclaw"),
|
||||
version.NewVersionCommand(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,409 @@
|
|||
# Agent Self-Evolution Implementation Status and PR Summary
|
||||
|
||||
## 1. Goal
|
||||
|
||||
This implementation connects the self-evolving skill design into PicoClaw and focuses on a minimal, safe, and modular loop:
|
||||
|
||||
1. the agent records learnable signals after a task finishes
|
||||
2. the system organizes those signals in the background or on demand
|
||||
3. the system generates candidate skill drafts
|
||||
4. a human reviews and decides whether to apply them
|
||||
5. applied skills keep version history, support rollback, and can cool down over time
|
||||
|
||||
The goal of this batch is to complete the data flow, lifecycle, and operational entry points, not to jump straight to a fully automatic self-updating system.
|
||||
|
||||
## 2. Scope Implemented in This Batch
|
||||
|
||||
The current code covers the main objects and execution paths defined by the design:
|
||||
|
||||
1. `Learning Record`
|
||||
2. `Skill Draft`
|
||||
3. `Skill Profile`
|
||||
4. hot-path `task` record writing
|
||||
5. cold-path `pattern` aggregation
|
||||
6. draft generation
|
||||
7. draft review and quarantine
|
||||
8. skill apply, backup, and rollback
|
||||
9. learned skill lifecycle maintenance
|
||||
10. CLI operations
|
||||
|
||||
Against the design document's implementation scope, this batch completes the core self-evolution loop. Remaining items are mostly automation or UX improvements.
|
||||
|
||||
## 3. How the System Connects to PicoClaw
|
||||
|
||||
### 3.1 Integration Points
|
||||
|
||||
The feature is integrated as a separate module with three layers:
|
||||
|
||||
1. an event bridge in `pkg/agent`
|
||||
2. the self-evolution core in `pkg/evolution`
|
||||
3. CLI management commands in `cmd/picoclaw/internal/evolution`
|
||||
|
||||
This keeps the integration modular:
|
||||
|
||||
1. runtime switches are centralized in `config.EvolutionConfig`
|
||||
2. evolution state is stored separately and does not directly pollute the regular skill layout
|
||||
3. drafts, rollback, and lifecycle actions can be managed independently through the CLI
|
||||
4. the whole feature can be disabled with `enabled: false`
|
||||
|
||||
### 3.2 Runtime Flow
|
||||
|
||||
The current runtime flow is:
|
||||
|
||||
1. the agent finishes a task turn
|
||||
2. `pkg/agent/evolution_bridge.go` listens for the `TurnEnd` event
|
||||
3. the hot path writes a `Learning Record(kind=task)`
|
||||
4. if `auto_run_cold_path` is enabled, it schedules one cold-path run
|
||||
5. the cold path aggregates multiple `task` records into a `pattern`
|
||||
6. the draft generator creates a `Skill Draft`
|
||||
7. draft review performs structural validation and sensitive-content scanning
|
||||
8. valid drafts move to `candidate`, invalid drafts move to `quarantined`
|
||||
9. a human uses CLI `review` and `apply` to decide whether to publish the skill change
|
||||
10. after apply, the system updates the `Skill Profile`, keeps backups, and supports later `rollback` or `prune`
|
||||
|
||||
### 3.3 Disable Path
|
||||
|
||||
The feature now uses a single `enabled` switch. There is no separate `off` mode.
|
||||
|
||||
When `evolution.enabled = false`:
|
||||
|
||||
1. the agent does not write evolution records
|
||||
2. the cold path does not run
|
||||
3. normal skill loading and normal task execution remain unaffected
|
||||
|
||||
## 4. Core Capabilities Already Completed
|
||||
|
||||
### 4.1 Hot-Path Recording
|
||||
|
||||
Completed:
|
||||
|
||||
1. a `Learning Record(kind=task)` is written after each task turn
|
||||
2. it records workspace, turn, session, tool kinds, active skills, attempted skills, and related context
|
||||
3. it records skill attempt trajectory signals:
|
||||
`AttemptedSkills`
|
||||
`FinalSuccessfulPath`
|
||||
`SkillContextSnapshots`
|
||||
4. it can capture the pattern where many skills were tried and the final successful path came from a later-added skill
|
||||
5. the hot path only writes records and does not modify formal skills
|
||||
|
||||
This gives the system the basic ability to remember which skill path actually solved the task.
|
||||
|
||||
### 4.2 Cold-Path Organization
|
||||
|
||||
Completed:
|
||||
|
||||
1. multiple `task` records can be aggregated into `pattern` records
|
||||
2. the system uses a minimum sample count and success-rate threshold before learning
|
||||
3. it prefers the final successful path when extracting a stable winning path
|
||||
4. it captures late-added skill hints and the final snapshot trigger
|
||||
|
||||
The cold path is responsible for turning task-level observations into reusable patterns without slowing down the user's live interaction.
|
||||
|
||||
### 4.3 Draft Generation
|
||||
|
||||
Completed:
|
||||
|
||||
1. LLM-based draft generation
|
||||
2. provider-backed generation is used first when a provider is available
|
||||
3. local fallback generation is used automatically when the provider is unavailable, errors, or returns invalid content
|
||||
4. support for `create`, `append`, `replace`, and `merge`
|
||||
5. fallback generation now prefers `append` when extending an existing skill
|
||||
|
||||
`merge` is used to merge newly learned stable knowledge into an existing skill instead of replacing it too aggressively.
|
||||
|
||||
### 4.4 Draft Review and Quarantine
|
||||
|
||||
Completed:
|
||||
|
||||
1. draft states:
|
||||
`candidate`
|
||||
`quarantined`
|
||||
`accepted`
|
||||
2. structurally invalid drafts are quarantined
|
||||
3. secret-like or sensitive content is quarantined
|
||||
4. invalid skill names are quarantined
|
||||
5. quarantined drafts never go directly into formal skills
|
||||
|
||||
This keeps generation separate from publication and prevents direct promotion of bad drafts.
|
||||
|
||||
### 4.5 Skill Apply and Rollback
|
||||
|
||||
Completed:
|
||||
|
||||
1. backups are saved before formal apply
|
||||
2. failed applies can roll back automatically
|
||||
3. `Skill Profile.version_history` records version changes
|
||||
4. `rollback <skill-name>` restores the latest backup
|
||||
5. if apply fails or profile persistence fails, the skill file is restored and audit information is recorded
|
||||
|
||||
Current rollback is structure-level and file-level rollback, with the primary goal of preventing broken skill files from being left behind.
|
||||
|
||||
### 4.6 Lifecycle Maintenance
|
||||
|
||||
Completed:
|
||||
|
||||
1. lifecycle states:
|
||||
`active`
|
||||
`cold`
|
||||
`archived`
|
||||
`deleted`
|
||||
2. usage count, last-used time, and retention score tracking
|
||||
3. `prune` recomputes lifecycle states
|
||||
4. recently reused cold skills can become active again
|
||||
5. profiles and statistics remain workspace-scoped even when using a shared `state_dir`
|
||||
|
||||
### 4.7 Human Review CLI
|
||||
|
||||
Completed CLI commands:
|
||||
|
||||
1. `picoclaw evolution drafts`
|
||||
2. `picoclaw evolution review <draft-id>`
|
||||
3. `picoclaw evolution apply <draft-id>`
|
||||
4. `picoclaw evolution rollback <skill-name>`
|
||||
5. `picoclaw evolution status`
|
||||
6. `picoclaw evolution run-once`
|
||||
7. `picoclaw evolution prune`
|
||||
|
||||
`review` now shows:
|
||||
|
||||
1. draft metadata
|
||||
2. target skill profile summary
|
||||
3. recent version history
|
||||
4. impact preview
|
||||
5. current body
|
||||
6. rendered body
|
||||
7. `diff_preview`
|
||||
|
||||
`diff_preview` uses a unified diff style so human review is easier.
|
||||
|
||||
## 5. Current Configuration
|
||||
|
||||
The feature is configured through `EvolutionConfig` in `pkg/config/config.go`:
|
||||
|
||||
```json
|
||||
{
|
||||
"evolution": {
|
||||
"enabled": false,
|
||||
"mode": "observe",
|
||||
"state_dir": "",
|
||||
"min_case_count": 3,
|
||||
"min_success_rate": 0.7,
|
||||
"auto_run_cold_path": false,
|
||||
"auto_apply": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field meanings:
|
||||
|
||||
1. `enabled`
|
||||
The master switch. When false, the whole self-evolution system is off.
|
||||
2. `mode`
|
||||
Currently supports `observe`, `review`, and `apply`.
|
||||
`observe` focuses on recording and candidate generation.
|
||||
`review` keeps the flow in the human review path.
|
||||
`apply` allows the cold path to enter apply logic, while actual automatic apply is still controlled by `auto_apply`.
|
||||
3. `state_dir`
|
||||
Stores self-evolution state separately from the skill working tree.
|
||||
4. `min_case_count`
|
||||
Minimum task sample count before a reusable pattern is considered mature enough.
|
||||
5. `min_success_rate`
|
||||
Minimum success rate required before a pattern is considered stable enough to learn from.
|
||||
6. `auto_run_cold_path`
|
||||
Whether to trigger cold-path processing automatically after each task turn.
|
||||
7. `auto_apply`
|
||||
Whether qualified drafts should be written into formal skills automatically when apply mode is active.
|
||||
|
||||
## 6. How “Human Confirmation Before Apply” Works Today
|
||||
|
||||
There is currently no interactive approval popup inside the agent runtime.
|
||||
|
||||
Human confirmation is implemented through the CLI flow:
|
||||
|
||||
1. the cold path first generates a `candidate` draft
|
||||
2. a human runs `picoclaw evolution review <draft-id>` to inspect it
|
||||
3. a human runs `picoclaw evolution apply <draft-id>` to publish it
|
||||
|
||||
In other words, manual confirmation exists today as an explicit CLI review flow, not as a live turn-time prompt.
|
||||
|
||||
## 7. What Is Done vs. What Is Still Deferred
|
||||
|
||||
### 7.1 Design Goals Already Covered
|
||||
|
||||
The current implementation already covers:
|
||||
|
||||
1. the full `Learning Record -> Skill Draft -> Skill Profile -> formal Skill` chain
|
||||
2. hot path writes only `task`
|
||||
3. cold path aggregates `pattern`
|
||||
4. cold-path LLM draft generation
|
||||
5. shortcut and winning-path learning
|
||||
6. structural validation
|
||||
7. sensitive-content scanning
|
||||
8. backup
|
||||
9. rollback
|
||||
10. candidate and quarantined states
|
||||
11. lifecycle state maintenance
|
||||
|
||||
### 7.2 Items Not Included in This PR
|
||||
|
||||
The following are still future improvements and are not blockers for this PR:
|
||||
|
||||
1. cross-workspace self-evolution
|
||||
2. fully automatic merge into formal skills without human review
|
||||
3. behavior-level rollback
|
||||
4. full UI
|
||||
5. hot-path LLM rerank
|
||||
|
||||
Clarifications:
|
||||
|
||||
1. “fully automatic merge”
|
||||
means the system would directly merge a qualified draft into the formal skill without a human running `review` and `apply`.
|
||||
The current implementation still prioritizes human review.
|
||||
2. “behavior-level rollback”
|
||||
means rollback would be triggered by later evidence of degraded real task behavior, reduced hit rate, or regression, not only by structural apply failures.
|
||||
The current implementation mainly handles structure-level rollback and apply-failure rollback.
|
||||
3. “hot-path LLM rerank”
|
||||
means using an LLM before execution to rerank recalled skills and choose the most likely successful one first.
|
||||
This is intentionally not part of the current hot path, so it does not add that extra inference cost yet.
|
||||
|
||||
## 8. Best PR Boundary for the Current State
|
||||
|
||||
This PR is best framed as the first complete batch of:
|
||||
|
||||
1. self-evolution infrastructure
|
||||
2. hot-path task learning loop
|
||||
3. cold-path draft generation
|
||||
4. lifecycle and operations CLI
|
||||
|
||||
That boundary works well because:
|
||||
|
||||
1. the feature is runnable, testable, and rollback-safe
|
||||
2. the master switch is clear and easy to disable
|
||||
3. evolution state and formal skill ownership stay clearly separated
|
||||
4. the human review path is already complete
|
||||
5. future automation improvements can land in later PRs independently
|
||||
|
||||
## 9. Verification
|
||||
|
||||
The following test commands passed:
|
||||
|
||||
```bash
|
||||
/usr/local/go/bin/go test ./cmd/picoclaw/internal/evolution -count=1
|
||||
/usr/local/go/bin/go test ./pkg/evolution -count=1
|
||||
/usr/local/go/bin/go test ./pkg/agent -count=1
|
||||
```
|
||||
|
||||
This batch also includes test coverage for:
|
||||
|
||||
1. hot-path record writing
|
||||
2. attempted-skill and final-success-path extraction
|
||||
3. cold-path aggregation and draft generation
|
||||
4. LLM generator vs. fallback generator selection
|
||||
5. draft quarantine
|
||||
6. apply and rollback
|
||||
7. lifecycle and prune
|
||||
8. workspace isolation
|
||||
9. CLI output and audit details
|
||||
|
||||
## 10. Main Change Areas
|
||||
|
||||
### 10.1 Core Implementation
|
||||
|
||||
1. `pkg/evolution/`
|
||||
Runtime, storage, aggregation, drafts, review, rollback, lifecycle, and preview logic.
|
||||
|
||||
### 10.2 Agent Integration
|
||||
|
||||
1. `pkg/agent/evolution_bridge.go`
|
||||
Connects agent turn-end events to the self-evolution runtime.
|
||||
2. `pkg/agent/events.go`
|
||||
3. `pkg/agent/turn_state.go`
|
||||
4. `pkg/agent/turn_coord.go`
|
||||
|
||||
### 10.3 CLI
|
||||
|
||||
1. `cmd/picoclaw/internal/evolution/`
|
||||
Operational commands for self-evolution.
|
||||
|
||||
### 10.4 Config and Supporting Integration
|
||||
|
||||
1. `pkg/config/config.go`
|
||||
2. `pkg/config/defaults.go`
|
||||
3. `pkg/config/config_test.go`
|
||||
4. `cmd/picoclaw/main.go`
|
||||
5. `pkg/skills/loader.go`
|
||||
6. `pkg/gateway/gateway.go`
|
||||
|
||||
## 11. Suggested PR Title
|
||||
|
||||
Recommended title:
|
||||
|
||||
`feat: add modular agent self-evolution foundation, drafts, lifecycle, and CLI`
|
||||
|
||||
Alternative if you want a shorter “first loop” framing:
|
||||
|
||||
`feat: add first self-evolution loop for learned skills`
|
||||
|
||||
## 12. Suggested PR Description
|
||||
|
||||
The following text can be used directly as the PR body:
|
||||
|
||||
~~~md
|
||||
## Summary
|
||||
|
||||
This PR adds the first modular self-evolution loop for PicoClaw skills.
|
||||
|
||||
It introduces:
|
||||
|
||||
1. hot-path task learning records
|
||||
2. cold-path pattern aggregation
|
||||
3. LLM-backed and fallback draft generation
|
||||
4. draft review states (`candidate`, `quarantined`, `accepted`)
|
||||
5. skill apply / backup / rollback
|
||||
6. skill lifecycle maintenance (`active`, `cold`, `archived`, `deleted`)
|
||||
7. workspace-scoped evolution state isolation
|
||||
8. CLI commands for review and operations
|
||||
|
||||
## What is included
|
||||
|
||||
1. `Learning Record`, `Skill Draft`, and `Skill Profile`
|
||||
2. `task -> pattern -> draft -> skill` main pipeline
|
||||
3. learning from attempted skills and final successful paths
|
||||
4. diff-based review output for human inspection
|
||||
5. modular config gate via `evolution.enabled`
|
||||
6. cold-path auto-run and optional auto-apply controls
|
||||
|
||||
## CLI
|
||||
|
||||
1. `picoclaw evolution drafts`
|
||||
2. `picoclaw evolution review <draft-id>`
|
||||
3. `picoclaw evolution apply <draft-id>`
|
||||
4. `picoclaw evolution rollback <skill-name>`
|
||||
5. `picoclaw evolution status`
|
||||
6. `picoclaw evolution run-once`
|
||||
7. `picoclaw evolution prune`
|
||||
|
||||
## Safety
|
||||
|
||||
1. no skill file is modified on the hot path
|
||||
2. drafts are validated before acceptance
|
||||
3. suspicious or invalid drafts are quarantined
|
||||
4. apply keeps backups and supports rollback
|
||||
5. evolution can be fully disabled with `evolution.enabled = false`
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
/usr/local/go/bin/go test ./cmd/picoclaw/internal/evolution -count=1
|
||||
/usr/local/go/bin/go test ./pkg/evolution -count=1
|
||||
/usr/local/go/bin/go test ./pkg/agent -count=1
|
||||
```
|
||||
|
||||
## Not included in this PR
|
||||
|
||||
1. cross-workspace evolution
|
||||
2. behavior-level rollback
|
||||
3. fully automatic merge without human review
|
||||
4. full UI
|
||||
5. hot-path LLM rerank
|
||||
~~~
|
||||
|
|
@ -50,6 +50,7 @@ type AgentLoop struct {
|
|||
transcriber asr.Transcriber
|
||||
cmdRegistry *commands.Registry
|
||||
mcp mcpRuntime
|
||||
evolution *evolutionBridge
|
||||
hookRuntime hookRuntime
|
||||
steering *steeringQueue
|
||||
pendingSkills sync.Map
|
||||
|
|
@ -278,6 +279,14 @@ func (al *AgentLoop) Close() {
|
|||
})
|
||||
}
|
||||
}
|
||||
if al.evolution != nil {
|
||||
if err := al.evolution.Close(); err != nil {
|
||||
logger.ErrorCF("agent", "Failed to close evolution bridge",
|
||||
map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
al.GetRegistry().Close()
|
||||
if al.hooks != nil {
|
||||
|
|
@ -362,14 +371,22 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
// Ensure shared tools are re-registered on the new registry
|
||||
registerSharedTools(al, cfg, al.bus, registry, provider)
|
||||
|
||||
newEvolution, evolutionErr := newEvolutionBridge(registry, cfg, provider)
|
||||
if evolutionErr != nil {
|
||||
logger.WarnCF("agent", "Failed to reinitialize evolution bridge during reload",
|
||||
map[string]any{"error": evolutionErr.Error()})
|
||||
}
|
||||
|
||||
// Atomically swap the config and registry under write lock
|
||||
// This ensures readers see a consistent pair
|
||||
al.mu.Lock()
|
||||
oldRegistry := al.registry
|
||||
oldEvolution := al.evolution
|
||||
|
||||
// Store new values
|
||||
al.cfg = cfg
|
||||
al.registry = registry
|
||||
al.evolution = newEvolution
|
||||
|
||||
// Also update fallback chain with new config; rebuild rate limiter registry.
|
||||
newRL := providers.NewRateLimiterRegistry()
|
||||
|
|
@ -386,6 +403,14 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
oldMCPManager := al.mcp.reset()
|
||||
al.hookRuntime.reset(al)
|
||||
configureHookManagerFromConfig(al.hooks, cfg)
|
||||
if newEvolution != nil {
|
||||
if err := al.MountHook(NamedHook(evolutionObserverHookName, newEvolution)); err != nil {
|
||||
logger.WarnCF("agent", "Failed to remount evolution observer during reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
} else {
|
||||
al.UnmountHook(evolutionObserverHookName)
|
||||
}
|
||||
if err := al.ensureHooksInitialized(ctx); err != nil {
|
||||
logger.WarnCF("agent", "Configured hooks failed to reinitialize after reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
|
|
@ -396,6 +421,12 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
|||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
if oldEvolution != nil {
|
||||
if err := oldEvolution.Close(); err != nil {
|
||||
logger.WarnCF("agent", "Failed to close previous evolution bridge during reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||
logger.WarnCF("agent", "MCP failed to reinitialize after reload",
|
||||
map[string]any{"error": err.Error()})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ func NewAgentLoop(
|
|||
}
|
||||
|
||||
eventBus := NewEventBus()
|
||||
bridge, err := newEvolutionBridge(registry, cfg, provider)
|
||||
if err != nil {
|
||||
logger.WarnCF("agent", "Failed to initialize evolution bridge", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// Determine worker pool size from config (default: 1 = sequential)
|
||||
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
|
||||
|
|
@ -63,6 +69,7 @@ func NewAgentLoop(
|
|||
eventBus: eventBus,
|
||||
fallback: fallbackChain,
|
||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||
evolution: bridge,
|
||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||
workerSem: make(chan struct{}, workerPoolSize),
|
||||
}
|
||||
|
|
@ -70,6 +77,13 @@ func NewAgentLoop(
|
|||
al.hooks = NewHookManager(eventBus)
|
||||
configureHookManagerFromConfig(al.hooks, cfg)
|
||||
al.contextManager = al.resolveContextManager()
|
||||
if bridge != nil {
|
||||
if err := al.MountHook(NamedHook(evolutionObserverHookName, bridge)); err != nil {
|
||||
logger.WarnCF("agent", "Failed to mount evolution observer", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register shared tools to all agents (now that al is created)
|
||||
registerSharedTools(al, cfg, msgBus, registry, provider)
|
||||
|
|
|
|||
|
|
@ -837,10 +837,28 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
|||
}
|
||||
|
||||
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
||||
ordered := cb.ResolveActiveSkillsForContext(skillNames)
|
||||
if len(ordered) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Active Skills
|
||||
|
||||
The following skills are active for this request. Follow them when relevant.
|
||||
|
||||
%s`, content)
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ResolveActiveSkillsForContext(skillNames []string) []string {
|
||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ordered []string
|
||||
seen := make(map[string]struct{}, len(skillNames))
|
||||
for _, name := range skillNames {
|
||||
|
|
@ -855,19 +873,9 @@ func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
|||
ordered = append(ordered, canonical)
|
||||
}
|
||||
if len(ordered) == 0 {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
|
||||
content := cb.skillsLoader.LoadSkillsForContext(ordered)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`# Active Skills
|
||||
|
||||
The following skills are active for this request. Follow them when relevant.
|
||||
|
||||
%s`, content)
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (cb *ContextBuilder) ListSkillNames() []string {
|
||||
|
|
|
|||
|
|
@ -120,12 +120,29 @@ type TurnStartPayload struct {
|
|||
MediaCount int
|
||||
}
|
||||
|
||||
const (
|
||||
skillContextTriggerInitialBuild = "initial_build"
|
||||
skillContextTriggerContextRetryRebuild = "context_retry_rebuild"
|
||||
)
|
||||
|
||||
type SkillContextSnapshot struct {
|
||||
Sequence int `json:"sequence"`
|
||||
Trigger string `json:"trigger"`
|
||||
SkillNames []string `json:"skill_names,omitempty"`
|
||||
}
|
||||
|
||||
// TurnEndPayload describes the completion of a turn.
|
||||
type TurnEndPayload struct {
|
||||
Status TurnEndStatus
|
||||
Iterations int
|
||||
Duration time.Duration
|
||||
FinalContentLen int
|
||||
Status TurnEndStatus
|
||||
Workspace string
|
||||
Iterations int
|
||||
Duration time.Duration
|
||||
FinalContentLen int
|
||||
ActiveSkills []string
|
||||
AttemptedSkills []string
|
||||
FinalSuccessfulPath []string
|
||||
SkillContextSnapshots []SkillContextSnapshot
|
||||
ToolKinds []string
|
||||
}
|
||||
|
||||
// LLMRequestPayload describes an outbound LLM request.
|
||||
|
|
|
|||
115
pkg/agent/evolution_bridge.go
Normal file
115
pkg/agent/evolution_bridge.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
const evolutionObserverHookName = "evolution-observer"
|
||||
|
||||
type evolutionBridge struct {
|
||||
cfg config.EvolutionConfig
|
||||
registry *AgentRegistry
|
||||
runtime *evolution.Runtime
|
||||
coldPathRunner *evolution.ColdPathRunner
|
||||
}
|
||||
|
||||
func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider providers.LLMProvider) (*evolutionBridge, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
modelID := ""
|
||||
if provider != nil {
|
||||
modelID = provider.GetDefaultModel()
|
||||
}
|
||||
runtime, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: cfg.Evolution,
|
||||
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
|
||||
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID)
|
||||
},
|
||||
ApplierFactory: func(workspace string) *evolution.Applier {
|
||||
return evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bridge := &evolutionBridge{
|
||||
cfg: cfg.Evolution,
|
||||
registry: registry,
|
||||
runtime: runtime,
|
||||
}
|
||||
if cfg.Evolution.AutoRunColdPath {
|
||||
bridge.coldPathRunner = evolution.NewColdPathRunnerWithErrorHandler(runtime, func(err error) {
|
||||
logger.WarnCF("agent", "Cold path run failed", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
func (b *evolutionBridge) Close() error {
|
||||
if b == nil || b.coldPathRunner == nil {
|
||||
return nil
|
||||
}
|
||||
return b.coldPathRunner.Close()
|
||||
}
|
||||
|
||||
func (b *evolutionBridge) OnEvent(ctx context.Context, evt Event) error {
|
||||
if b == nil || !b.cfg.Enabled || b.runtime == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch evt.Kind {
|
||||
case EventKindTurnEnd:
|
||||
payload, ok := evt.Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := b.runtime.FinalizeTurn(ctx, evolution.TurnCaseInput{
|
||||
Workspace: payload.Workspace,
|
||||
WorkspaceID: payload.Workspace,
|
||||
TurnID: evt.Meta.TurnID,
|
||||
SessionKey: evt.Meta.SessionKey,
|
||||
AgentID: evt.Meta.AgentID,
|
||||
Status: string(payload.Status),
|
||||
ToolKinds: append([]string(nil), payload.ToolKinds...),
|
||||
ActiveSkillNames: append([]string(nil), payload.ActiveSkills...),
|
||||
AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...),
|
||||
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
|
||||
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if b.coldPathRunner != nil {
|
||||
b.coldPathRunner.Trigger(payload.Workspace)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.SkillContextSnapshot {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]evolution.SkillContextSnapshot, 0, len(input))
|
||||
for _, snapshot := range input {
|
||||
out = append(out, evolution.SkillContextSnapshot{
|
||||
Sequence: snapshot.Sequence,
|
||||
Trigger: snapshot.Trigger,
|
||||
SkillNames: append([]string(nil), snapshot.SkillNames...),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
802
pkg/agent/evolution_bridge_test.go
Normal file
802
pkg/agent/evolution_bridge_test.go
Normal file
|
|
@ -0,0 +1,802 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestEvolutionBridge_DisabledWritesNothing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: false,
|
||||
Mode: "observe",
|
||||
}, &simpleMockProvider{response: "ok"})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-disabled", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
|
||||
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_ObserveWritesCaseRecord(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
provider := &toolCallRespProvider{
|
||||
toolName: "echo_text",
|
||||
toolArgs: map[string]any{"text": "bridge"},
|
||||
response: "done",
|
||||
}
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
}, provider)
|
||||
defer al.Close()
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
defaultAgent.SkillsFilter = []string{"observe-skill"}
|
||||
al.RegisterTool(&echoTextTool{})
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "done" {
|
||||
t.Fatalf("response = %q, want %q", resp, "done")
|
||||
}
|
||||
|
||||
record := waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
|
||||
if got := record["kind"]; got != string(evolution.RecordKindCase) {
|
||||
t.Fatalf("kind = %v, want %q", got, evolution.RecordKindCase)
|
||||
}
|
||||
if got := record["workspace_id"]; got != tmpDir {
|
||||
t.Fatalf("workspace_id = %v, want %q", got, tmpDir)
|
||||
}
|
||||
if got := record["status"]; got != "new" {
|
||||
t.Fatalf("status = %v, want %q", got, "new")
|
||||
}
|
||||
|
||||
toolKinds, ok := record["tool_kinds"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("tool_kinds missing or wrong type: %#v", record["tool_kinds"])
|
||||
}
|
||||
if len(toolKinds) != 1 || toolKinds[0] != "echo_text" {
|
||||
t.Fatalf("tool_kinds = %#v, want [echo_text]", toolKinds)
|
||||
}
|
||||
|
||||
activeSkillsRaw, exists := record["active_skill_names"]
|
||||
if !exists {
|
||||
t.Fatal("active_skill_names field missing")
|
||||
}
|
||||
activeSkills, ok := activeSkillsRaw.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("active_skill_names wrong type: %#v", activeSkillsRaw)
|
||||
}
|
||||
if len(activeSkills) != 1 || activeSkills[0] != "observe-skill" {
|
||||
t.Fatalf("active_skill_names = %#v, want [observe-skill]", activeSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_ObserveTurnEndPayloadIncludesResolvedAttemptTrail(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
skillDir := filepath.Join(tmpDir, "skills", "observe-skill")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: observe-skill\ndescription: observe test skill\n---\n# Observe Skill\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
}, &simpleMockProvider{response: "ok"})
|
||||
defer al.Close()
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
defaultAgent.SkillsFilter = []string{"missing-skill", "observe-skill", "observe-skill"}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-attempt-trail", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
|
||||
turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool {
|
||||
return evt.Kind == EventKindTurnEnd
|
||||
})
|
||||
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
|
||||
}
|
||||
if got := turnEndPayload.AttemptedSkills; len(got) != 1 || got[0] != "observe-skill" {
|
||||
t.Fatalf("AttemptedSkills = %v, want [observe-skill]", got)
|
||||
}
|
||||
if got := turnEndPayload.FinalSuccessfulPath; len(got) != 1 || got[0] != "observe-skill" {
|
||||
t.Fatalf("FinalSuccessfulPath = %v, want [observe-skill]", got)
|
||||
}
|
||||
if got := turnEndPayload.SkillContextSnapshots; len(got) != 1 || got[0].Trigger != skillContextTriggerInitialBuild {
|
||||
t.Fatalf("SkillContextSnapshots = %+v, want single initial_build snapshot", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_ObserveTurnEndUsesLatestSkillSnapshotAfterRetry(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
baseSkillDir := filepath.Join(tmpDir, "skills", "base-skill")
|
||||
if err := os.MkdirAll(baseSkillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(baseSkillDir): %v", err)
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
filepath.Join(baseSkillDir, "SKILL.md"),
|
||||
[]byte("---\nname: base-skill\ndescription: base test skill\n---\n# Base Skill\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
t.Fatalf("WriteFile(base-skill): %v", err)
|
||||
}
|
||||
|
||||
lateSkillPath := filepath.Join(tmpDir, "skills", "late-skill", "SKILL.md")
|
||||
provider := &lateSkillOnRetryProvider{lateSkillPath: lateSkillPath}
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
}, provider)
|
||||
defer al.Close()
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
defaultAgent.SkillsFilter = []string{"base-skill", "late-skill"}
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-retry-snapshot", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "Recovered after retry" {
|
||||
t.Fatalf("response = %q, want %q", resp, "Recovered after retry")
|
||||
}
|
||||
|
||||
turnEndEvt := waitForEvent(t, sub.C, 2*time.Second, func(evt Event) bool {
|
||||
return evt.Kind == EventKindTurnEnd
|
||||
})
|
||||
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
|
||||
}
|
||||
if got := turnEndPayload.AttemptedSkills; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
|
||||
t.Fatalf("AttemptedSkills = %v, want [base-skill late-skill]", got)
|
||||
}
|
||||
if got := turnEndPayload.FinalSuccessfulPath; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
|
||||
t.Fatalf("FinalSuccessfulPath = %v, want [base-skill late-skill]", got)
|
||||
}
|
||||
if got := turnEndPayload.SkillContextSnapshots; len(got) != 2 {
|
||||
t.Fatalf("len(SkillContextSnapshots) = %d, want 2", len(got))
|
||||
}
|
||||
if turnEndPayload.SkillContextSnapshots[0].Trigger != skillContextTriggerInitialBuild {
|
||||
t.Fatalf("SkillContextSnapshots[0].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[0].Trigger, skillContextTriggerInitialBuild)
|
||||
}
|
||||
if turnEndPayload.SkillContextSnapshots[1].Trigger != skillContextTriggerContextRetryRebuild {
|
||||
t.Fatalf("SkillContextSnapshots[1].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[1].Trigger, skillContextTriggerContextRetryRebuild)
|
||||
}
|
||||
if got := turnEndPayload.SkillContextSnapshots[1].SkillNames; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" {
|
||||
t.Fatalf("SkillContextSnapshots[1].SkillNames = %v, want [base-skill late-skill]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_ObserveDoesNotCreateDraftFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
}, &simpleMockProvider{response: "ok"})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-no-draft", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathCreatesDraftFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
AutoRunColdPath: true,
|
||||
}, &simpleMockProvider{response: "ok"})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathUsesProviderBackedDraftGenerator(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
AutoRunColdPath: true,
|
||||
}, &simpleMockProvider{
|
||||
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`,
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-llm", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp == "" {
|
||||
t.Fatal("expected non-empty response")
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||
if drafts[0].HumanSummary != "Prefer native-name path first" {
|
||||
t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathUsesProviderDefaultModel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
provider := &capturingEvolutionDraftProvider{
|
||||
defaultModel: "provider-explicit-model",
|
||||
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`,
|
||||
}
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
AutoRunColdPath: true,
|
||||
}, provider)
|
||||
defer al.Close()
|
||||
|
||||
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-model", "cli", "direct"); err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||
if provider.lastModel != "provider-explicit-model" {
|
||||
t.Fatalf("lastModel = %q, want provider-explicit-model", provider.lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "apply",
|
||||
AutoRunColdPath: true,
|
||||
AutoApply: false,
|
||||
}, &simpleMockProvider{
|
||||
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"create","human_summary":"Create weather helper","body_or_patch":"---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n"}`,
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-no-auto-apply", "cli", "direct"); err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||||
}
|
||||
|
||||
assertNotExists(t, filepath.Join(tmpDir, "skills", "weather", "SKILL.md"))
|
||||
assertProfileNotExists(t, tmpDir, "weather")
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathApplyModeAutoAppliesMergeDraft(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
skillDir := filepath.Join(tmpDir, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
original := "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names.\n"
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "apply",
|
||||
AutoRunColdPath: true,
|
||||
AutoApply: true,
|
||||
}, &simpleMockProvider{
|
||||
response: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"merge","human_summary":"Merge native-name path","body_or_patch":"Prefer native-name query first."}`,
|
||||
})
|
||||
defer al.Close()
|
||||
|
||||
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-merge", "cli", "direct"); err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
drafts := waitForDrafts(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"), 1)
|
||||
if drafts[0].Status != evolution.DraftStatusAccepted {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted)
|
||||
}
|
||||
|
||||
merged := waitForSkillBody(t, skillPath)
|
||||
if !strings.Contains(merged, "Use city names.") {
|
||||
t.Fatalf("merged skill lost original content:\n%s", merged)
|
||||
}
|
||||
if !strings.Contains(merged, "## Merged Knowledge") {
|
||||
t.Fatalf("merged skill missing merged section:\n%s", merged)
|
||||
}
|
||||
if !strings.Contains(merged, "Prefer native-name query first.") {
|
||||
t.Fatalf("merged skill missing learned knowledge:\n%s", merged)
|
||||
}
|
||||
|
||||
profile := waitForProfile(t, tmpDir, "weather")
|
||||
if profile.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("profile status = %q, want %q", profile.Status, evolution.SkillStatusActive)
|
||||
}
|
||||
if profile.CurrentVersion == "" {
|
||||
t.Fatal("expected applied profile current version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_AutoRunColdPathDisabledDoesNotCreateDraftFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
seedReadyRule(t, tmpDir)
|
||||
|
||||
al := newEvolutionTestLoop(t, tmpDir, config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
AutoRunColdPath: false,
|
||||
}, &simpleMockProvider{response: "ok"})
|
||||
defer al.Close()
|
||||
|
||||
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-no-auto-cold-path", "cli", "direct")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
|
||||
}
|
||||
if resp != "ok" {
|
||||
t.Fatalf("response = %q, want %q", resp, "ok")
|
||||
}
|
||||
|
||||
waitForEvolutionRecord(t, filepath.Join(tmpDir, "state", "evolution", "learning-records.jsonl"))
|
||||
assertNotExists(t, filepath.Join(tmpDir, "state", "evolution", "skill-drafts.json"))
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_TurnEndUsesPayloadWorkspace(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
},
|
||||
}
|
||||
|
||||
bridge, err := newEvolutionBridge(nil, cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newEvolutionBridge: %v", err)
|
||||
}
|
||||
|
||||
err = bridge.OnEvent(context.Background(), Event{
|
||||
Kind: EventKindTurnEnd,
|
||||
Meta: EventMeta{
|
||||
AgentID: "main",
|
||||
TurnID: "turn-1",
|
||||
SessionKey: "session-1",
|
||||
},
|
||||
Payload: TurnEndPayload{
|
||||
Status: TurnEndStatusCompleted,
|
||||
Workspace: workspace,
|
||||
ActiveSkills: []string{"observe-skill"},
|
||||
ToolKinds: []string{"echo_text"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnEvent: %v", err)
|
||||
}
|
||||
|
||||
record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl"))
|
||||
if got := record["workspace_id"]; got != workspace {
|
||||
t.Fatalf("workspace_id = %v, want %q", got, workspace)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
},
|
||||
}
|
||||
|
||||
bridge, err := newEvolutionBridge(nil, cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newEvolutionBridge: %v", err)
|
||||
}
|
||||
|
||||
err = bridge.OnEvent(context.Background(), Event{
|
||||
Kind: EventKindTurnEnd,
|
||||
Meta: EventMeta{
|
||||
AgentID: "main",
|
||||
TurnID: "turn-1",
|
||||
SessionKey: "session-1",
|
||||
},
|
||||
Payload: TurnEndPayload{
|
||||
Status: TurnEndStatusCompleted,
|
||||
Workspace: workspace,
|
||||
ActiveSkills: []string{"weather"},
|
||||
AttemptedSkills: []string{"geocode", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: skillContextTriggerInitialBuild, SkillNames: []string{"weather"}},
|
||||
{Sequence: 2, Trigger: skillContextTriggerContextRetryRebuild, SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
ToolKinds: []string{"echo_text"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("OnEvent: %v", err)
|
||||
}
|
||||
|
||||
record := waitForEvolutionRecord(t, filepath.Join(workspace, "state", "evolution", "learning-records.jsonl"))
|
||||
attemptTrailRaw, ok := record["attempt_trail"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("attempt_trail missing or wrong type: %#v", record["attempt_trail"])
|
||||
}
|
||||
attemptedSkills, ok := attemptTrailRaw["attempted_skills"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("attempted_skills wrong type: %#v", attemptTrailRaw["attempted_skills"])
|
||||
}
|
||||
if len(attemptedSkills) != 2 || attemptedSkills[0] != "geocode" || attemptedSkills[1] != "weather" {
|
||||
t.Fatalf("attempted_skills = %#v, want [geocode weather]", attemptedSkills)
|
||||
}
|
||||
finalPath, ok := attemptTrailRaw["final_successful_path"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("final_successful_path wrong type: %#v", attemptTrailRaw["final_successful_path"])
|
||||
}
|
||||
if len(finalPath) != 2 || finalPath[0] != "geocode" || finalPath[1] != "weather" {
|
||||
t.Fatalf("final_successful_path = %#v, want [geocode weather]", finalPath)
|
||||
}
|
||||
skillSnapshots, ok := attemptTrailRaw["skill_context_snapshots"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("skill_context_snapshots wrong type: %#v", attemptTrailRaw["skill_context_snapshots"])
|
||||
}
|
||||
if len(skillSnapshots) != 2 {
|
||||
t.Fatalf("len(skill_context_snapshots) = %d, want 2", len(skillSnapshots))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvolutionBridge_CloseStopsColdPathRunnerIdempotently(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
AutoRunColdPath: true,
|
||||
},
|
||||
}
|
||||
|
||||
bridge, err := newEvolutionBridge(nil, cfg, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newEvolutionBridge: %v", err)
|
||||
}
|
||||
if bridge.coldPathRunner == nil {
|
||||
t.Fatal("expected cold path runner")
|
||||
}
|
||||
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("first Close() error = %v", err)
|
||||
}
|
||||
if err := bridge.Close(); err != nil {
|
||||
t.Fatalf("second Close() error = %v", err)
|
||||
}
|
||||
if bridge.coldPathRunner.Trigger(t.TempDir()) {
|
||||
t.Fatal("expected closed bridge runner to reject new work")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_ReloadProviderAndConfig_RebuildsEvolutionBridge(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: t.TempDir(),
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
},
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: false,
|
||||
Mode: "observe",
|
||||
},
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||
defer al.Close()
|
||||
|
||||
oldBridge := al.evolution
|
||||
if oldBridge == nil {
|
||||
t.Fatal("expected initial evolution bridge")
|
||||
}
|
||||
|
||||
reloadCfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: t.TempDir(),
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
},
|
||||
Evolution: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "apply",
|
||||
StateDir: filepath.Join(t.TempDir(), "evolution-state"),
|
||||
},
|
||||
}
|
||||
|
||||
if err := al.ReloadProviderAndConfig(context.Background(), &mockProvider{}, reloadCfg); err != nil {
|
||||
t.Fatalf("ReloadProviderAndConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if al.evolution == nil {
|
||||
t.Fatal("expected evolution bridge after reload")
|
||||
}
|
||||
if al.evolution == oldBridge {
|
||||
t.Fatal("expected evolution bridge to be rebuilt on reload")
|
||||
}
|
||||
if al.evolution.cfg.Enabled != reloadCfg.Evolution.Enabled {
|
||||
t.Fatalf("reloaded evolution enabled = %v, want %v", al.evolution.cfg.Enabled, reloadCfg.Evolution.Enabled)
|
||||
}
|
||||
if al.evolution.cfg.Mode != reloadCfg.Evolution.Mode {
|
||||
t.Fatalf("reloaded evolution mode = %q, want %q", al.evolution.cfg.Mode, reloadCfg.Evolution.Mode)
|
||||
}
|
||||
if al.evolution.cfg.StateDir != reloadCfg.Evolution.StateDir {
|
||||
t.Fatalf("reloaded evolution state_dir = %q, want %q", al.evolution.cfg.StateDir, reloadCfg.Evolution.StateDir)
|
||||
}
|
||||
}
|
||||
|
||||
func seedReadyRule(t *testing.T, workspace string) {
|
||||
t.Helper()
|
||||
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: workspace,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newEvolutionTestLoop(t *testing.T, workspace string, evo config.EvolutionConfig, provider providers.LLMProvider) *AgentLoop {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: workspace,
|
||||
ModelName: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 3,
|
||||
},
|
||||
},
|
||||
Evolution: evo,
|
||||
}
|
||||
|
||||
return NewAgentLoop(cfg, bus.NewMessageBus(), provider)
|
||||
}
|
||||
|
||||
func waitForEvolutionRecord(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) == 1 && lines[0] != "" {
|
||||
var record map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s): %v", path, err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("timed out waiting for evolution record at %s", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForDrafts(t *testing.T, path string, want int) []evolution.SkillDraft {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var drafts []evolution.SkillDraft
|
||||
if err := json.Unmarshal(data, &drafts); err != nil {
|
||||
t.Fatalf("json.Unmarshal(%s): %v", path, err)
|
||||
}
|
||||
if len(drafts) == want {
|
||||
return drafts
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("timed out waiting for %d drafts at %s", want, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForSkillBody(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("timed out waiting for skill file at %s", path)
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForProfile(t *testing.T, workspace, skillName string) evolution.SkillProfile {
|
||||
t.Helper()
|
||||
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
profile, err := store.LoadProfile(skillName)
|
||||
if err == nil {
|
||||
return profile
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("timed out waiting for profile %q in %s", skillName, workspace)
|
||||
return evolution.SkillProfile{}
|
||||
}
|
||||
|
||||
func assertProfileNotExists(t *testing.T, workspace, skillName string) {
|
||||
t.Helper()
|
||||
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
if _, err := store.LoadProfile(skillName); !os.IsNotExist(err) {
|
||||
t.Fatalf("profile %q should not exist, got err = %v", skillName, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotExists(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s should not exist, stat err = %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
type capturingEvolutionDraftProvider struct {
|
||||
response string
|
||||
defaultModel string
|
||||
lastModel string
|
||||
}
|
||||
|
||||
type lateSkillOnRetryProvider struct {
|
||||
calls int
|
||||
lateSkillPath string
|
||||
}
|
||||
|
||||
func (p *lateSkillOnRetryProvider) Chat(
|
||||
_ context.Context,
|
||||
_ []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
_ string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.calls++
|
||||
if p.calls == 1 {
|
||||
if err := os.MkdirAll(filepath.Dir(p.lateSkillPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(
|
||||
p.lateSkillPath,
|
||||
[]byte("---\nname: late-skill\ndescription: late test skill\n---\n# Late Skill\n"),
|
||||
0o644,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("context_window_exceeded")
|
||||
}
|
||||
|
||||
return &providers.LLMResponse{Content: "Recovered after retry"}, nil
|
||||
}
|
||||
|
||||
func (p *lateSkillOnRetryProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
func (p *capturingEvolutionDraftProvider) Chat(
|
||||
_ context.Context,
|
||||
_ []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
model string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.lastModel = model
|
||||
return &providers.LLMResponse{Content: p.response}, nil
|
||||
}
|
||||
|
||||
func (p *capturingEvolutionDraftProvider) GetDefaultModel() string {
|
||||
return p.defaultModel
|
||||
}
|
||||
|
|
@ -198,6 +198,7 @@ toolLoop:
|
|||
Async: hookResult.Async,
|
||||
},
|
||||
)
|
||||
ts.recordToolKind(toolName)
|
||||
|
||||
messages = append(messages, toolResultMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
|
|
@ -570,6 +571,7 @@ toolLoop:
|
|||
Async: toolResult.Async,
|
||||
},
|
||||
)
|
||||
ts.recordToolKind(toolName)
|
||||
messages = append(messages, toolResultMsg)
|
||||
if !ts.opts.NoHistory {
|
||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
||||
|
|
|
|||
|
|
@ -319,10 +319,15 @@ func (p *Pipeline) CallLLM(
|
|||
exec.history = asmResp.History
|
||||
exec.summary = asmResp.Summary
|
||||
}
|
||||
contextualSkills := ts.activeSkills
|
||||
if ts.agent.ContextBuilder != nil {
|
||||
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
||||
}
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
|
||||
exec.messages = ts.agent.ContextBuilder.BuildMessages(
|
||||
exec.history, exec.summary, "",
|
||||
nil, ts.channel, ts.chatID, ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
contextualSkills...,
|
||||
)
|
||||
exec.callMessages = exec.messages
|
||||
if exec.gracefulTerminal {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||
}
|
||||
ts.captureRestorePoint(history, summary)
|
||||
|
||||
contextualSkills := ts.activeSkills
|
||||
if ts.agent.ContextBuilder != nil {
|
||||
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
|
||||
}
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills)
|
||||
|
||||
messages := ts.agent.ContextBuilder.BuildMessages(
|
||||
history,
|
||||
summary,
|
||||
|
|
@ -40,7 +46,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||
ts.chatID,
|
||||
ts.opts.Dispatch.SenderID(),
|
||||
ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
contextualSkills...,
|
||||
)
|
||||
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
|
|
@ -73,7 +79,7 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
|||
history, summary, ts.userMessage,
|
||||
ts.media, ts.channel, ts.chatID,
|
||||
ts.opts.Dispatch.SenderID(), ts.opts.SenderDisplayName,
|
||||
activeSkillNames(ts.agent, ts.opts)...,
|
||||
contextualSkills...,
|
||||
)
|
||||
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,14 +27,30 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
|||
|
||||
turnStatus := TurnEndStatusCompleted
|
||||
defer func() {
|
||||
attemptedSkills := ts.attemptedSkillsSnapshot()
|
||||
skillContextSnapshots := ts.skillContextSnapshotsSnapshot()
|
||||
finalSuccessfulPath := []string(nil)
|
||||
if turnStatus == TurnEndStatusCompleted {
|
||||
if latest := ts.latestSkillContextSnapshot(); len(latest) > 0 {
|
||||
finalSuccessfulPath = latest
|
||||
} else {
|
||||
finalSuccessfulPath = append([]string(nil), attemptedSkills...)
|
||||
}
|
||||
}
|
||||
al.emitEvent(
|
||||
EventKindTurnEnd,
|
||||
ts.eventMeta("runTurn", "turn.end"),
|
||||
TurnEndPayload{
|
||||
Status: turnStatus,
|
||||
Iterations: ts.currentIteration(),
|
||||
Duration: time.Since(ts.startedAt),
|
||||
FinalContentLen: ts.finalContentLen(),
|
||||
Status: turnStatus,
|
||||
Workspace: ts.workspace,
|
||||
Iterations: ts.currentIteration(),
|
||||
Duration: time.Since(ts.startedAt),
|
||||
FinalContentLen: ts.finalContentLen(),
|
||||
ActiveSkills: append([]string(nil), ts.activeSkills...),
|
||||
AttemptedSkills: attemptedSkills,
|
||||
FinalSuccessfulPath: finalSuccessfulPath,
|
||||
SkillContextSnapshots: skillContextSnapshots,
|
||||
ToolKinds: ts.toolKindsSnapshot(),
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -613,3 +613,29 @@ func TestTurnState_HardAbortRequested(t *testing.T) {
|
|||
t.Error("expected hard abort to be requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnState_SkillContextSnapshotsTrackLatestSuccessfulPath(t *testing.T) {
|
||||
ts := &turnState{}
|
||||
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, []string{"skill-a"})
|
||||
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, []string{"skill-b", "skill-c"})
|
||||
|
||||
if got := ts.attemptedSkillsSnapshot(); len(got) != 3 || got[0] != "skill-a" || got[1] != "skill-b" || got[2] != "skill-c" {
|
||||
t.Fatalf("attemptedSkillsSnapshot = %v, want [skill-a skill-b skill-c]", got)
|
||||
}
|
||||
|
||||
if got := ts.latestSkillContextSnapshot(); len(got) != 2 || got[0] != "skill-b" || got[1] != "skill-c" {
|
||||
t.Fatalf("latestSkillContextSnapshot = %v, want [skill-b skill-c]", got)
|
||||
}
|
||||
|
||||
snapshots := ts.skillContextSnapshotsSnapshot()
|
||||
if len(snapshots) != 2 {
|
||||
t.Fatalf("len(skillContextSnapshotsSnapshot()) = %d, want 2", len(snapshots))
|
||||
}
|
||||
if snapshots[0].Sequence != 1 || snapshots[0].Trigger != skillContextTriggerInitialBuild {
|
||||
t.Fatalf("snapshots[0] = %+v, want sequence=1 trigger=%q", snapshots[0], skillContextTriggerInitialBuild)
|
||||
}
|
||||
if snapshots[1].Sequence != 2 || snapshots[1].Trigger != skillContextTriggerContextRetryRebuild {
|
||||
t.Fatalf("snapshots[1] = %+v, want sequence=2 trigger=%q", snapshots[1], skillContextTriggerContextRetryRebuild)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ package agent
|
|||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
|
@ -176,13 +177,18 @@ type turnState struct {
|
|||
opts processOptions
|
||||
scope turnEventScope
|
||||
|
||||
turnID string
|
||||
agentID string
|
||||
sessionKey string
|
||||
turnCtx *TurnContext
|
||||
turnID string
|
||||
agentID string
|
||||
sessionKey string
|
||||
activeSkills []string
|
||||
attemptedSkills []string
|
||||
skillContextTrace []SkillContextSnapshot
|
||||
toolKinds []string
|
||||
turnCtx *TurnContext
|
||||
|
||||
channel string
|
||||
chatID string
|
||||
workspace string
|
||||
userMessage string
|
||||
media []string
|
||||
|
||||
|
|
@ -238,19 +244,21 @@ type turnState struct {
|
|||
|
||||
func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScope) *turnState {
|
||||
ts := &turnState{
|
||||
agent: agent,
|
||||
opts: opts,
|
||||
scope: scope,
|
||||
turnID: scope.turnID,
|
||||
agentID: agent.ID,
|
||||
sessionKey: opts.Dispatch.SessionKey,
|
||||
turnCtx: cloneTurnContext(scope.context),
|
||||
channel: opts.Dispatch.Channel(),
|
||||
chatID: opts.Dispatch.ChatID(),
|
||||
userMessage: opts.Dispatch.UserMessage,
|
||||
media: append([]string(nil), opts.Dispatch.Media...),
|
||||
phase: TurnPhaseSetup,
|
||||
startedAt: time.Now(),
|
||||
agent: agent,
|
||||
opts: opts,
|
||||
scope: scope,
|
||||
turnID: scope.turnID,
|
||||
agentID: agent.ID,
|
||||
sessionKey: opts.Dispatch.SessionKey,
|
||||
activeSkills: activeSkillNames(agent, opts),
|
||||
turnCtx: cloneTurnContext(scope.context),
|
||||
channel: opts.Dispatch.Channel(),
|
||||
chatID: opts.Dispatch.ChatID(),
|
||||
workspace: agent.Workspace,
|
||||
userMessage: opts.Dispatch.UserMessage,
|
||||
media: append([]string(nil), opts.Dispatch.Media...),
|
||||
phase: TurnPhaseSetup,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Bind session store and capture initial history length for rollback logic
|
||||
|
|
@ -375,6 +383,117 @@ func (ts *turnState) finalContentLen() int {
|
|||
return len(ts.finalContent)
|
||||
}
|
||||
|
||||
func (ts *turnState) recordToolKind(tool string) {
|
||||
tool = strings.TrimSpace(tool)
|
||||
if tool == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
||||
for _, existing := range ts.toolKinds {
|
||||
if existing == tool {
|
||||
return
|
||||
}
|
||||
}
|
||||
ts.toolKinds = append(ts.toolKinds, tool)
|
||||
}
|
||||
|
||||
func (ts *turnState) toolKindsSnapshot() []string {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return append([]string(nil), ts.toolKinds...)
|
||||
}
|
||||
|
||||
func (ts *turnState) recordAttemptedSkills(skillNames []string) {
|
||||
if len(skillNames) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
||||
for _, skillName := range skillNames {
|
||||
skillName = strings.TrimSpace(skillName)
|
||||
if skillName == "" {
|
||||
continue
|
||||
}
|
||||
seen := false
|
||||
for _, existing := range ts.attemptedSkills {
|
||||
if existing == skillName {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
continue
|
||||
}
|
||||
ts.attemptedSkills = append(ts.attemptedSkills, skillName)
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *turnState) attemptedSkillsSnapshot() []string {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return append([]string(nil), ts.attemptedSkills...)
|
||||
}
|
||||
|
||||
func (ts *turnState) recordSkillContextSnapshot(trigger string, skillNames []string) {
|
||||
if len(skillNames) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(skillNames))
|
||||
for _, skillName := range skillNames {
|
||||
skillName = strings.TrimSpace(skillName)
|
||||
if skillName == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, skillName)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ts.recordAttemptedSkills(filtered)
|
||||
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.skillContextTrace = append(ts.skillContextTrace, SkillContextSnapshot{
|
||||
Sequence: len(ts.skillContextTrace) + 1,
|
||||
Trigger: trigger,
|
||||
SkillNames: append([]string(nil), filtered...),
|
||||
})
|
||||
}
|
||||
|
||||
func (ts *turnState) latestSkillContextSnapshot() []string {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
if len(ts.skillContextTrace) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), ts.skillContextTrace[len(ts.skillContextTrace)-1].SkillNames...)
|
||||
}
|
||||
|
||||
func (ts *turnState) skillContextSnapshotsSnapshot() []SkillContextSnapshot {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
if len(ts.skillContextTrace) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
snapshots := make([]SkillContextSnapshot, 0, len(ts.skillContextTrace))
|
||||
for _, snapshot := range ts.skillContextTrace {
|
||||
snapshots = append(snapshots, SkillContextSnapshot{
|
||||
Sequence: snapshot.Sequence,
|
||||
Trigger: snapshot.Trigger,
|
||||
SkillNames: append([]string(nil), snapshot.SkillNames...),
|
||||
})
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func (ts *turnState) setTurnCancel(cancel context.CancelFunc) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ type Config struct {
|
|||
Isolation IsolationConfig `json:"isolation,omitempty" yaml:"-"`
|
||||
Agents AgentsConfig `json:"agents" yaml:"-"`
|
||||
Session SessionConfig `json:"session,omitempty" yaml:"-"`
|
||||
Evolution EvolutionConfig `json:"evolution,omitempty" yaml:"-"`
|
||||
Channels ChannelsConfig `json:"channel_list" yaml:"channel_list"`
|
||||
ModelList SecureModelList `json:"model_list" yaml:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway" yaml:"-"`
|
||||
|
|
@ -51,6 +52,32 @@ type Config struct {
|
|||
sensitiveCache *SensitiveDataCache
|
||||
}
|
||||
|
||||
type EvolutionConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
StateDir string `json:"state_dir,omitempty"`
|
||||
MinCaseCount int `json:"min_case_count,omitempty"`
|
||||
MinSuccessRate float64 `json:"min_success_rate,omitempty"`
|
||||
AutoRunColdPath bool `json:"auto_run_cold_path,omitempty"`
|
||||
AutoApply bool `json:"auto_apply,omitempty"`
|
||||
}
|
||||
|
||||
func (c EvolutionConfig) EffectiveMode() string {
|
||||
if !c.Enabled {
|
||||
return ""
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(c.Mode)) {
|
||||
case "review":
|
||||
return "review"
|
||||
case "apply":
|
||||
return "apply"
|
||||
case "", "observe":
|
||||
return "observe"
|
||||
default:
|
||||
return "observe"
|
||||
}
|
||||
}
|
||||
|
||||
// IsolationConfig controls subprocess isolation for commands started by PicoClaw.
|
||||
// It is applied by the isolation package rather than by sandboxing the main process.
|
||||
type IsolationConfig struct {
|
||||
|
|
|
|||
|
|
@ -171,6 +171,96 @@ func TestDefaultConfig_MCPMaxInlineTextChars(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_EvolutionDefaults(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
assert.False(t, cfg.Evolution.Enabled)
|
||||
assert.Equal(t, "observe", cfg.Evolution.Mode)
|
||||
assert.Equal(t, "", cfg.Evolution.StateDir)
|
||||
assert.Equal(t, 3, cfg.Evolution.MinCaseCount)
|
||||
assert.Equal(t, 0.7, cfg.Evolution.MinSuccessRate)
|
||||
assert.False(t, cfg.Evolution.AutoRunColdPath)
|
||||
assert.False(t, cfg.Evolution.AutoApply)
|
||||
}
|
||||
|
||||
func TestEvolutionConfig_EffectiveMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg EvolutionConfig
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "disabled returns empty",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: false,
|
||||
Mode: "apply",
|
||||
},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "enabled empty mode defaults to observe",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
want: "observe",
|
||||
},
|
||||
{
|
||||
name: "enabled whitespace mode defaults to observe",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: " \t\n ",
|
||||
},
|
||||
want: "observe",
|
||||
},
|
||||
{
|
||||
name: "enabled returns configured mode",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "review",
|
||||
},
|
||||
want: "review",
|
||||
},
|
||||
{
|
||||
name: "enabled trims and normalizes mode",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: " Review ",
|
||||
},
|
||||
want: "review",
|
||||
},
|
||||
{
|
||||
name: "enabled returns apply mode",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "apply",
|
||||
},
|
||||
want: "apply",
|
||||
},
|
||||
{
|
||||
name: "enabled normalizes uppercase apply",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "APPLY",
|
||||
},
|
||||
want: "apply",
|
||||
},
|
||||
{
|
||||
name: "enabled unknown mode falls back to observe",
|
||||
cfg: EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "propose",
|
||||
},
|
||||
want: "observe",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.cfg.EffectiveMode())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_MCPMaxInlineTextChars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
configPath := filepath.Join(dir, "config.json")
|
||||
|
|
|
|||
|
|
@ -44,6 +44,14 @@ func DefaultConfig() *Config {
|
|||
Session: SessionConfig{
|
||||
Dimensions: []string{"chat"},
|
||||
},
|
||||
Evolution: EvolutionConfig{
|
||||
Enabled: false,
|
||||
Mode: "observe",
|
||||
MinCaseCount: 3,
|
||||
MinSuccessRate: 0.7,
|
||||
AutoRunColdPath: false,
|
||||
AutoApply: false,
|
||||
},
|
||||
Channels: defaultChannels(),
|
||||
Hooks: HooksConfig{
|
||||
Enabled: true,
|
||||
|
|
|
|||
178
pkg/evolution/apply.go
Normal file
178
pkg/evolution/apply.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type Applier struct {
|
||||
paths Paths
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewApplier(paths Paths, now func() time.Time) *Applier {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Applier{
|
||||
paths: paths,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Applier) ApplyDraft(ctx context.Context, workspace string, draft SkillDraft) error {
|
||||
rollback, err := a.applyDraftWithRollback(ctx, workspace, draft)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = rollback
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string, draft SkillDraft) (func() error, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existingBody, backupPath, hadOriginal, err := a.backupCurrentSkill(workspace, draft.TargetSkillName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(workspace, "skills", draft.TargetSkillName)
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renderedBody, err := renderAppliedBody(draft, existingBody, hadOriginal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := fileutil.WriteFileAtomic(skillPath, []byte(renderedBody), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateAppliedSkillBody(renderedBody); err != nil {
|
||||
if rollbackErr := a.rollbackSkill(skillPath, backupPath, hadOriginal); rollbackErr != nil {
|
||||
return nil, errorsJoin(err, rollbackErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return func() error {
|
||||
return a.rollbackSkill(skillPath, backupPath, hadOriginal)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Applier) backupCurrentSkill(workspace, skillName string) (currentBody, backupPath string, hadOriginal bool, err error) {
|
||||
if err := skills.ValidateSkillName(skillName); err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
backupDir := filepath.Join(a.paths.BackupsDir, skillName, a.now().Format("20060102-150405"))
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
|
||||
backupPath = filepath.Join(backupDir, "SKILL.md")
|
||||
if err := fileutil.WriteFileAtomic(backupPath, data, 0o644); err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return string(data), backupPath, true, nil
|
||||
}
|
||||
|
||||
func (a *Applier) rollbackSkill(skillPath, backupPath string, hadOriginal bool) error {
|
||||
if hadOriginal {
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteFileAtomic(skillPath, data, 0o644)
|
||||
}
|
||||
if err := os.Remove(skillPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAppliedSkillBody(body string) error {
|
||||
body = strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(body, "---\n") {
|
||||
return fmt.Errorf("skill frontmatter is required")
|
||||
}
|
||||
if !strings.Contains(body, "\n# ") {
|
||||
return fmt.Errorf("skill heading is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderAppliedBody(draft SkillDraft, existingBody string, hadOriginal bool) (string, error) {
|
||||
switch draft.ChangeKind {
|
||||
case ChangeKindCreate:
|
||||
if hadOriginal {
|
||||
return "", fmt.Errorf("cannot create skill %q: skill already exists", draft.TargetSkillName)
|
||||
}
|
||||
return draft.BodyOrPatch, nil
|
||||
case ChangeKindReplace:
|
||||
if !hadOriginal {
|
||||
return "", fmt.Errorf("cannot replace skill %q: skill does not exist", draft.TargetSkillName)
|
||||
}
|
||||
return draft.BodyOrPatch, nil
|
||||
case ChangeKindAppend:
|
||||
if !hadOriginal || strings.TrimSpace(existingBody) == "" {
|
||||
return draft.BodyOrPatch, nil
|
||||
}
|
||||
return strings.TrimRight(existingBody, "\n") + "\n\n" + strings.TrimLeft(draft.BodyOrPatch, "\n"), nil
|
||||
case ChangeKindMerge:
|
||||
if !hadOriginal || strings.TrimSpace(existingBody) == "" {
|
||||
return draft.BodyOrPatch, nil
|
||||
}
|
||||
mergedSection := strings.Join([]string{
|
||||
"",
|
||||
"## Merged Knowledge",
|
||||
strings.TrimSpace(draft.BodyOrPatch),
|
||||
"",
|
||||
}, "\n")
|
||||
return strings.TrimRight(existingBody, "\n") + mergedSection, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported change_kind %q", draft.ChangeKind)
|
||||
}
|
||||
}
|
||||
|
||||
func errorsJoin(errs ...error) error {
|
||||
var first error
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if first == nil {
|
||||
first = err
|
||||
continue
|
||||
}
|
||||
first = fmt.Errorf("%w; %v", first, err)
|
||||
}
|
||||
return first
|
||||
}
|
||||
275
pkg/evolution/apply_test.go
Normal file
275
pkg/evolution/apply_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestApplier_CreateDraftWritesSkillFile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "weather helper",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
}
|
||||
|
||||
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
|
||||
t.Fatalf("ApplyDraft: %v", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "# Weather") {
|
||||
t.Fatalf("unexpected content: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_CreateDraftFailsWhenSkillAlreadyExists(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n"
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-create-existing",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-create-existing",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "weather helper",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n",
|
||||
}
|
||||
|
||||
err := applier.ApplyDraft(context.Background(), workspace, draft)
|
||||
if err == nil {
|
||||
t.Fatal("expected ApplyDraft to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("error = %v, want already exists", err)
|
||||
}
|
||||
|
||||
got, readErr := os.ReadFile(skillPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile: %v", readErr)
|
||||
}
|
||||
if string(got) != original {
|
||||
t.Fatalf("skill content changed unexpectedly:\n%s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n"
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-2",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "broken draft",
|
||||
BodyOrPatch: "invalid-frontmatter",
|
||||
Status: evolution.DraftStatusAccepted,
|
||||
}
|
||||
|
||||
err := applier.ApplyDraft(context.Background(), workspace, draft)
|
||||
if err == nil {
|
||||
t.Fatal("expected ApplyDraft to fail")
|
||||
}
|
||||
|
||||
got, readErr := os.ReadFile(skillPath)
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile: %v", readErr)
|
||||
}
|
||||
if string(got) != original {
|
||||
t.Fatalf("skill content changed after rollback:\n%s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-replace-missing",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-replace-missing",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "replace missing skill",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n",
|
||||
}
|
||||
|
||||
err := applier.ApplyDraft(context.Background(), workspace, draft)
|
||||
if err == nil {
|
||||
t.Fatal("expected ApplyDraft to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not exist") {
|
||||
t.Fatalf("error = %v, want does not exist", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
|
||||
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected no skill file, got err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_AppendDraftPreservesOriginalBody(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n"
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-append",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-append",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "append draft",
|
||||
BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n",
|
||||
}
|
||||
|
||||
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
|
||||
t.Fatalf("ApplyDraft: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
content := string(got)
|
||||
if !strings.Contains(content, "Use city names.") {
|
||||
t.Fatalf("appended content lost original body:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "Prefer native-name query first.") {
|
||||
t.Fatalf("appended content missing new body:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_MergeDraftAddsMergedKnowledgeSection(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n"
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
draft := evolution.SkillDraft{
|
||||
ID: "draft-merge",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-merge",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindMerge,
|
||||
HumanSummary: "merge draft",
|
||||
BodyOrPatch: "Prefer native-name query first.",
|
||||
}
|
||||
|
||||
if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil {
|
||||
t.Fatalf("ApplyDraft: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
content := string(got)
|
||||
if !strings.Contains(content, "Use city names.") {
|
||||
t.Fatalf("merged content lost original body:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "## Merged Knowledge") {
|
||||
t.Fatalf("merged content missing merged section:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "Prefer native-name query first.") {
|
||||
t.Fatalf("merged content missing new knowledge:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplier_RejectsInvalidSkillName(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time {
|
||||
return time.Unix(1700000000, 0).UTC()
|
||||
})
|
||||
|
||||
for _, name := range []string{"../escape", "/tmp/escape"} {
|
||||
err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{
|
||||
ID: "draft-invalid-name",
|
||||
WorkspaceID: workspace,
|
||||
SourceRecordID: "rule-invalid-name",
|
||||
TargetSkillName: name,
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "bad name",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nbody\n",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("TargetSkillName %q expected error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
pkg/evolution/case_writer.go
Normal file
21
pkg/evolution/case_writer.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type CaseWriter struct {
|
||||
paths Paths
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewCaseWriter(paths Paths) *CaseWriter {
|
||||
return &CaseWriter{
|
||||
paths: paths,
|
||||
store: NewStore(paths),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *CaseWriter) AppendCase(ctx context.Context, record LearningRecord) error {
|
||||
return w.store.AppendLearningRecord(ctx, record)
|
||||
}
|
||||
77
pkg/evolution/case_writer_test.go
Normal file
77
pkg/evolution/case_writer_test.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestCaseWriter_AppendsOneRecord(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
writer := evolution.NewCaseWriter(paths)
|
||||
|
||||
record1 := testRecord("rec-1", "ws-1", true)
|
||||
record2 := testRecord("rec-2", "ws-2", false)
|
||||
|
||||
if err := writer.AppendCase(context.Background(), record1); err != nil {
|
||||
t.Fatalf("AppendCase: %v", err)
|
||||
}
|
||||
if err := writer.AppendCase(context.Background(), record2); err != nil {
|
||||
t.Fatalf("AppendCase second record: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(paths.LearningRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
text := string(data)
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
t.Fatalf("record file should end with newline, got %q", text)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("record file line count = %d, want 2", len(lines))
|
||||
}
|
||||
|
||||
records := []evolution.LearningRecord{record1, record2}
|
||||
for i, line := range lines {
|
||||
var got evolution.LearningRecord
|
||||
if err := json.Unmarshal([]byte(line), &got); err != nil {
|
||||
t.Fatalf("Unmarshal line %d: %v", i, err)
|
||||
}
|
||||
|
||||
want := records[i]
|
||||
if got.ID != want.ID {
|
||||
t.Fatalf("record %d ID = %q, want %q", i, got.ID, want.ID)
|
||||
}
|
||||
if got.Kind != evolution.RecordKindCase {
|
||||
t.Fatalf("record %d kind = %q, want %q", i, got.Kind, evolution.RecordKindCase)
|
||||
}
|
||||
if got.Summary != want.Summary {
|
||||
t.Fatalf("record %d summary = %q, want %q", i, got.Summary, want.Summary)
|
||||
}
|
||||
if got.Success == nil || *got.Success != *want.Success {
|
||||
t.Fatalf("record %d success = %v, want %v", i, got.Success, want.Success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRecord(id, workspaceID string, success bool) evolution.LearningRecord {
|
||||
return evolution.LearningRecord{
|
||||
ID: id,
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: workspaceID,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "cli turn completed",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &success,
|
||||
}
|
||||
}
|
||||
121
pkg/evolution/cold_path_runner.go
Normal file
121
pkg/evolution/cold_path_runner.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type coldPathRuntime interface {
|
||||
RunColdPathOnce(ctx context.Context, workspace string) error
|
||||
}
|
||||
|
||||
type ColdPathRunner struct {
|
||||
runtime coldPathRuntime
|
||||
async func(func())
|
||||
onError func(error)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
closeOnce sync.Once
|
||||
closed bool
|
||||
running map[string]workspaceRunState
|
||||
}
|
||||
|
||||
func NewColdPathRunner(runtime coldPathRuntime) *ColdPathRunner {
|
||||
return NewColdPathRunnerWithErrorHandler(runtime, nil)
|
||||
}
|
||||
|
||||
func NewColdPathRunnerWithErrorHandler(runtime coldPathRuntime, onError func(error)) *ColdPathRunner {
|
||||
if onError == nil {
|
||||
onError = func(error) {}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &ColdPathRunner{
|
||||
runtime: runtime,
|
||||
async: func(run func()) {
|
||||
go run()
|
||||
},
|
||||
onError: onError,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
running: make(map[string]workspaceRunState),
|
||||
}
|
||||
}
|
||||
|
||||
type workspaceRunState struct {
|
||||
running bool
|
||||
pending bool
|
||||
}
|
||||
|
||||
func (r *ColdPathRunner) Trigger(workspace string) bool {
|
||||
if r == nil || r.runtime == nil || workspace == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
if r.closed {
|
||||
r.mu.Unlock()
|
||||
return false
|
||||
}
|
||||
state, exists := r.running[workspace]
|
||||
if exists && state.running {
|
||||
state.pending = true
|
||||
r.running[workspace] = state
|
||||
r.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
r.running[workspace] = workspaceRunState{running: true}
|
||||
r.wg.Add(1)
|
||||
r.mu.Unlock()
|
||||
|
||||
r.async(func() {
|
||||
defer r.wg.Done()
|
||||
r.runWorkspace(workspace)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *ColdPathRunner) runWorkspace(workspace string) {
|
||||
for {
|
||||
if err := r.runtime.RunColdPathOnce(r.ctx, workspace); err != nil && !errors.Is(err, context.Canceled) {
|
||||
r.onError(err)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
state, exists := r.running[workspace]
|
||||
if !exists || r.closed {
|
||||
delete(r.running, workspace)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if state.pending {
|
||||
state.pending = false
|
||||
r.running[workspace] = state
|
||||
r.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
delete(r.running, workspace)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ColdPathRunner) Close() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r.closeOnce.Do(func() {
|
||||
r.mu.Lock()
|
||||
r.closed = true
|
||||
r.mu.Unlock()
|
||||
r.cancel()
|
||||
})
|
||||
r.wg.Wait()
|
||||
return nil
|
||||
}
|
||||
142
pkg/evolution/cold_path_runner_test.go
Normal file
142
pkg/evolution/cold_path_runner_test.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type blockingColdPathRuntime struct {
|
||||
runCount atomic.Int32
|
||||
cancelCount atomic.Int32
|
||||
started chan string
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r *blockingColdPathRuntime) RunColdPathOnce(ctx context.Context, workspace string) error {
|
||||
r.runCount.Add(1)
|
||||
r.started <- workspace
|
||||
select {
|
||||
case <-r.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
r.cancelCount.Add(1)
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestColdPathRunner_QueuesPendingRunForWorkspace(t *testing.T) {
|
||||
runtime := &blockingColdPathRuntime{
|
||||
started: make(chan string, 4),
|
||||
release: make(chan struct{}, 4),
|
||||
}
|
||||
runner := NewColdPathRunner(runtime)
|
||||
defer runner.Close()
|
||||
|
||||
if scheduled := runner.Trigger("workspace-a"); !scheduled {
|
||||
t.Fatal("expected first trigger to be scheduled")
|
||||
}
|
||||
|
||||
select {
|
||||
case workspace := <-runtime.started:
|
||||
if workspace != "workspace-a" {
|
||||
t.Fatalf("workspace = %q, want workspace-a", workspace)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first cold path run")
|
||||
}
|
||||
|
||||
if scheduled := runner.Trigger("workspace-a"); !scheduled {
|
||||
t.Fatal("expected second trigger to queue a pending run")
|
||||
}
|
||||
|
||||
select {
|
||||
case workspace := <-runtime.started:
|
||||
t.Fatalf("unexpected early pending cold path run for %q", workspace)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
runtime.release <- struct{}{}
|
||||
|
||||
select {
|
||||
case workspace := <-runtime.started:
|
||||
if workspace != "workspace-a" {
|
||||
t.Fatalf("workspace = %q, want workspace-a", workspace)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for pending cold path run")
|
||||
}
|
||||
|
||||
runtime.release <- struct{}{}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.runCount.Load() == 2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("runCount = %d, want 2", runtime.runCount.Load())
|
||||
}
|
||||
|
||||
func TestColdPathRunner_CloseCancelsActiveRunAndDropsPendingWork(t *testing.T) {
|
||||
runtime := &blockingColdPathRuntime{
|
||||
started: make(chan string, 4),
|
||||
release: make(chan struct{}, 4),
|
||||
}
|
||||
runner := NewColdPathRunner(runtime)
|
||||
|
||||
if scheduled := runner.Trigger("workspace-a"); !scheduled {
|
||||
t.Fatal("expected first trigger to be scheduled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-runtime.started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first cold path run")
|
||||
}
|
||||
|
||||
if scheduled := runner.Trigger("workspace-a"); !scheduled {
|
||||
t.Fatal("expected second trigger to mark pending work")
|
||||
}
|
||||
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(closeDone)
|
||||
if err := runner.Close(); err != nil {
|
||||
t.Errorf("Close() error = %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !runner.Trigger("workspace-a") {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if runner.Trigger("workspace-a") {
|
||||
t.Fatal("expected Trigger to reject new work after Close")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-closeDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for Close to finish")
|
||||
}
|
||||
|
||||
select {
|
||||
case workspace := <-runtime.started:
|
||||
t.Fatalf("unexpected pending cold path run after Close for %q", workspace)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
}
|
||||
|
||||
if got := runtime.runCount.Load(); got != 1 {
|
||||
t.Fatalf("runCount = %d, want 1", got)
|
||||
}
|
||||
if got := runtime.cancelCount.Load(); got != 1 {
|
||||
t.Fatalf("cancelCount = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
38
pkg/evolution/draft_review.go
Normal file
38
pkg/evolution/draft_review.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package evolution
|
||||
|
||||
import "strings"
|
||||
|
||||
type DraftReviewResult struct {
|
||||
Status DraftStatus
|
||||
Findings []string
|
||||
ReviewNotes []string
|
||||
}
|
||||
|
||||
func ReviewDraft(draft SkillDraft) DraftReviewResult {
|
||||
findings := append([]string(nil), ValidateDraft(draft)...)
|
||||
findings = append(findings, scanDraftContent(draft)...)
|
||||
|
||||
result := DraftReviewResult{
|
||||
Status: DraftStatusCandidate,
|
||||
Findings: findings,
|
||||
ReviewNotes: []string{"local structural validation completed"},
|
||||
}
|
||||
if len(findings) > 0 {
|
||||
result.Status = DraftStatusQuarantined
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func scanDraftContent(draft SkillDraft) []string {
|
||||
body := strings.ToLower(draft.BodyOrPatch)
|
||||
findings := make([]string, 0, 2)
|
||||
|
||||
if strings.Contains(body, "sk-live-") || strings.Contains(body, "sk_test_") || strings.Contains(body, "api_key=") {
|
||||
findings = append(findings, "secret-like token detected in body_or_patch")
|
||||
}
|
||||
if strings.Contains(body, "-----begin private key-----") {
|
||||
findings = append(findings, "private key material detected in body_or_patch")
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
67
pkg/evolution/draft_review_test.go
Normal file
67
pkg/evolution/draft_review_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestReviewDraft_QuarantinesInvalidDraft(t *testing.T) {
|
||||
result := evolution.ReviewDraft(evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
TargetSkillName: "",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "broken",
|
||||
BodyOrPatch: "",
|
||||
})
|
||||
|
||||
if result.Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("Status = %q, want %q", result.Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(result.Findings) == 0 {
|
||||
t.Fatal("expected findings for invalid draft")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewDraft_QuarantinesSecretLikeContent(t *testing.T) {
|
||||
result := evolution.ReviewDraft(evolution.SkillDraft{
|
||||
ID: "draft-2",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "contains credentials",
|
||||
BodyOrPatch: "Use token sk-live-secret for direct calls.",
|
||||
})
|
||||
|
||||
if result.Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("Status = %q, want %q", result.Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(result.Findings) == 0 {
|
||||
t.Fatal("expected findings for secret-like content")
|
||||
}
|
||||
if !strings.Contains(strings.Join(result.Findings, "\n"), "secret-like") {
|
||||
t.Fatalf("findings = %v, want secret-like finding", result.Findings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewDraft_QuarantinesInvalidTargetSkillName(t *testing.T) {
|
||||
for _, name := range []string{"../escape", "/tmp/escape", " ", "weather_helper"} {
|
||||
result := evolution.ReviewDraft(evolution.SkillDraft{
|
||||
ID: "draft-invalid-name",
|
||||
TargetSkillName: name,
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "bad name",
|
||||
BodyOrPatch: "body",
|
||||
})
|
||||
|
||||
if result.Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("TargetSkillName %q status = %q, want %q", name, result.Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(result.Findings) == 0 {
|
||||
t.Fatalf("TargetSkillName %q expected findings", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
249
pkg/evolution/drafts.go
Normal file
249
pkg/evolution/drafts.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type DraftGenerator interface {
|
||||
GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error)
|
||||
}
|
||||
|
||||
func ValidateDraft(draft SkillDraft) []string {
|
||||
findings := make([]string, 0, 5)
|
||||
|
||||
if strings.TrimSpace(draft.TargetSkillName) == "" {
|
||||
findings = append(findings, "target_skill_name is required")
|
||||
} else if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil {
|
||||
findings = append(findings, "target_skill_name is invalid: "+err.Error())
|
||||
}
|
||||
if strings.TrimSpace(draft.HumanSummary) == "" {
|
||||
findings = append(findings, "human_summary is required")
|
||||
}
|
||||
if strings.TrimSpace(draft.BodyOrPatch) == "" {
|
||||
findings = append(findings, "body_or_patch is required")
|
||||
}
|
||||
|
||||
switch draft.DraftType {
|
||||
case DraftTypeWorkflow, DraftTypeShortcut:
|
||||
default:
|
||||
findings = append(findings, "draft_type is invalid")
|
||||
}
|
||||
|
||||
switch draft.ChangeKind {
|
||||
case ChangeKindCreate, ChangeKindAppend, ChangeKindReplace, ChangeKindMerge:
|
||||
default:
|
||||
findings = append(findings, "change_kind is invalid")
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
type DefaultDraftGenerator struct {
|
||||
loader *skills.SkillsLoader
|
||||
}
|
||||
|
||||
func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
|
||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||
if builtinSkillsDir == "" {
|
||||
wd, _ := os.Getwd()
|
||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||
}
|
||||
|
||||
globalSkillsDir := filepath.Join(config.GetHome(), "skills")
|
||||
return &DefaultDraftGenerator{
|
||||
loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) {
|
||||
target := inferTargetSkillName(rule, matches)
|
||||
if target == "" {
|
||||
target = "learned-skill"
|
||||
}
|
||||
|
||||
_, hasExisting, err := g.loadBaseSkillContent(target, matches)
|
||||
if err != nil {
|
||||
return SkillDraft{}, err
|
||||
}
|
||||
|
||||
draftType := DraftTypeWorkflow
|
||||
if len(rule.WinningPath) <= 1 {
|
||||
draftType = DraftTypeShortcut
|
||||
}
|
||||
|
||||
changeKind := ChangeKindCreate
|
||||
body := g.buildNewSkillBody(target, rule)
|
||||
if hasExisting {
|
||||
changeKind = ChangeKindAppend
|
||||
body = g.buildAppendBody(rule)
|
||||
}
|
||||
|
||||
return SkillDraft{
|
||||
TargetSkillName: target,
|
||||
DraftType: draftType,
|
||||
ChangeKind: changeKind,
|
||||
HumanSummary: g.buildHumanSummary(target, rule, hasExisting),
|
||||
IntendedUseCases: inferIntendedUseCases(rule),
|
||||
PreferredEntryPath: inferPreferredEntryPath(rule),
|
||||
AvoidPatterns: inferAvoidPatterns(rule),
|
||||
BodyOrPatch: body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string {
|
||||
if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" {
|
||||
return strings.TrimSpace(matches[0].Name)
|
||||
}
|
||||
if len(rule.LateAddedSkills) > 0 && strings.TrimSpace(rule.LateAddedSkills[0]) != "" {
|
||||
return strings.TrimSpace(rule.LateAddedSkills[0])
|
||||
}
|
||||
if len(rule.WinningPath) > 0 && strings.TrimSpace(rule.WinningPath[0]) != "" {
|
||||
return strings.TrimSpace(rule.WinningPath[0])
|
||||
}
|
||||
if len(rule.MatchedSkillNames) > 0 && strings.TrimSpace(rule.MatchedSkillNames[0]) != "" {
|
||||
return strings.TrimSpace(rule.MatchedSkillNames[0])
|
||||
}
|
||||
|
||||
tokens := tokenizeForEvolution(rule.Summary)
|
||||
if len(tokens) > 0 {
|
||||
return tokens[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) {
|
||||
for _, match := range matches {
|
||||
if match.Name != target || strings.TrimSpace(match.Path) == "" {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(match.Path)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return string(data), true, nil
|
||||
}
|
||||
|
||||
if g.loader == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
content, ok := g.loader.LoadSkill(target)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
description := fmt.Sprintf("Learned workflow for %s.", target)
|
||||
return buildSkillDocument(target, description, content), true, nil
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRecord, hasExisting bool) string {
|
||||
if hasExisting {
|
||||
return fmt.Sprintf("Refresh %s with learned pattern: %s", target, rule.Summary)
|
||||
}
|
||||
return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary)
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) buildNewSkillBody(target string, rule LearningRecord) string {
|
||||
description := fmt.Sprintf("Learned workflow for %s.", target)
|
||||
body := strings.Join([]string{
|
||||
"# " + titleCaseSkillName(target),
|
||||
"",
|
||||
"## Start Here",
|
||||
g.startHereLine(rule),
|
||||
"",
|
||||
"## When To Use",
|
||||
fmt.Sprintf("Use this skill when the task matches `%s`.", strings.TrimSpace(rule.Summary)),
|
||||
"",
|
||||
"## Learned Pattern",
|
||||
g.learnedPatternLine(rule),
|
||||
"",
|
||||
"## Winning Path",
|
||||
g.winningPathLine(rule),
|
||||
"",
|
||||
"## Evidence",
|
||||
g.evidenceLine(rule),
|
||||
}, "\n")
|
||||
return buildSkillDocument(target, description, body)
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) buildAppendBody(rule LearningRecord) string {
|
||||
return strings.Join([]string{
|
||||
"## Learned Evolution",
|
||||
fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)),
|
||||
fmt.Sprintf("- Learned pattern: %s", g.learnedPatternLine(rule)),
|
||||
fmt.Sprintf("- Winning path: %s", g.winningPathLine(rule)),
|
||||
fmt.Sprintf("- Evidence: %s", g.evidenceLine(rule)),
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func buildSkillDocument(name, description, body string) string {
|
||||
return strings.Join([]string{
|
||||
"---",
|
||||
"name: " + strings.TrimSpace(name),
|
||||
"description: " + strings.TrimSpace(description),
|
||||
"---",
|
||||
"",
|
||||
strings.TrimSpace(body),
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func titleCaseSkillName(name string) string {
|
||||
parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Learned Skill"
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) startHereLine(rule LearningRecord) string {
|
||||
if len(rule.WinningPath) > 0 {
|
||||
return fmt.Sprintf("Start with `%s` before trying other paths.", strings.Join(rule.WinningPath, " -> "))
|
||||
}
|
||||
return fmt.Sprintf("Start from the learned path for `%s`.", strings.TrimSpace(rule.Summary))
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string {
|
||||
if len(rule.LateAddedSkills) > 0 {
|
||||
return fmt.Sprintf(
|
||||
"Late-added skill `%s` was repeatedly introduced immediately before success%s.",
|
||||
strings.Join(rule.LateAddedSkills, " -> "),
|
||||
triggerSuffix(rule.FinalSnapshotTrigger),
|
||||
)
|
||||
}
|
||||
if len(rule.WinningPath) > 0 {
|
||||
return fmt.Sprintf("Prefer `%s` because it was the most reliable recent path.", strings.Join(rule.WinningPath, " -> "))
|
||||
}
|
||||
return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary))
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) winningPathLine(rule LearningRecord) string {
|
||||
if len(rule.WinningPath) == 0 {
|
||||
return "No explicit winning path was recorded."
|
||||
}
|
||||
return strings.Join(rule.WinningPath, " -> ")
|
||||
}
|
||||
|
||||
func (g *DefaultDraftGenerator) evidenceLine(rule LearningRecord) string {
|
||||
return fmt.Sprintf("%d cases, success rate %.2f", rule.EventCount, rule.SuccessRate)
|
||||
}
|
||||
|
||||
func triggerSuffix(trigger string) string {
|
||||
trigger = strings.TrimSpace(trigger)
|
||||
if trigger == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" during `%s`", trigger)
|
||||
}
|
||||
110
pkg/evolution/drafts_test.go
Normal file
110
pkg/evolution/drafts_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *testing.T) {
|
||||
generator := evolution.NewDefaultDraftGenerator(t.TempDir())
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
|
||||
Summary: "weather native-name path",
|
||||
WinningPath: []string{"geocode", "weather"},
|
||||
LateAddedSkills: []string{"weather"},
|
||||
FinalSnapshotTrigger: "context_retry_rebuild",
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
if draft.TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
|
||||
}
|
||||
if !strings.Contains(draft.BodyOrPatch, "Late-added skill") {
|
||||
t.Fatalf("BodyOrPatch = %q, want late-added skill guidance", draft.BodyOrPatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDraftGenerator_UsesAppendWhenExtendingExistingSkill(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
generator := evolution.NewDefaultDraftGenerator(workspace)
|
||||
|
||||
existingPath := filepath.Join(workspace, "skills", "weather", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(existingPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
existing := "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names.\n"
|
||||
if err := os.WriteFile(existingPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
|
||||
Summary: "weather native-name path",
|
||||
WinningPath: []string{"weather"},
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
}, []skills.SkillInfo{
|
||||
{Name: "weather", Path: existingPath, Source: "workspace", Description: "Weather helper"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
if draft.ChangeKind != evolution.ChangeKindAppend {
|
||||
t.Fatalf("ChangeKind = %q, want append", draft.ChangeKind)
|
||||
}
|
||||
if strings.Contains(draft.BodyOrPatch, "---\nname: weather") {
|
||||
t.Fatalf("BodyOrPatch should contain only appended section, got full document:\n%s", draft.BodyOrPatch)
|
||||
}
|
||||
if !strings.Contains(draft.BodyOrPatch, "## Learned Evolution") {
|
||||
t.Fatalf("BodyOrPatch = %q, want learned evolution section", draft.BodyOrPatch)
|
||||
}
|
||||
if len(draft.IntendedUseCases) != 1 || draft.IntendedUseCases[0] != "weather native-name path" {
|
||||
t.Fatalf("IntendedUseCases = %v, want [weather native-name path]", draft.IntendedUseCases)
|
||||
}
|
||||
if len(draft.PreferredEntryPath) != 1 || draft.PreferredEntryPath[0] != "weather" {
|
||||
t.Fatalf("PreferredEntryPath = %v, want [weather]", draft.PreferredEntryPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMDraftGenerator_BuildPromptIncludesLateAddedSkillHint(t *testing.T) {
|
||||
provider := &llmDraftTestProvider{
|
||||
defaultModel: "test-model",
|
||||
response: &providers.LLMResponse{
|
||||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`,
|
||||
},
|
||||
}
|
||||
generator := evolution.NewLLMDraftGenerator(provider, "", &recordingDraftGenerator{})
|
||||
|
||||
_, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Summary: "weather native-name path",
|
||||
EventCount: 7,
|
||||
SuccessRate: 0.86,
|
||||
WinningPath: []string{"geocode", "weather"},
|
||||
MatchedSkillNames: []string{"weather"},
|
||||
LateAddedSkills: []string{"weather"},
|
||||
FinalSnapshotTrigger: "context_retry_rebuild",
|
||||
}, []skills.SkillInfo{
|
||||
{Name: "weather", Path: "/tmp/weather/SKILL.md", Source: "workspace", Description: "Find weather details."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
|
||||
prompt := provider.lastMessages[1].Content
|
||||
if !strings.Contains(prompt, "Late-added successful skills: weather") {
|
||||
t.Fatalf("prompt missing late-added skill hint:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Final snapshot trigger: context_retry_rebuild") {
|
||||
t.Fatalf("prompt missing final snapshot trigger:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
11
pkg/evolution/generator_factory.go
Normal file
11
pkg/evolution/generator_factory.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package evolution
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/providers"
|
||||
|
||||
func NewDraftGeneratorForWorkspace(workspace string, provider providers.LLMProvider, modelID string) DraftGenerator {
|
||||
fallback := NewDefaultDraftGenerator(workspace)
|
||||
if provider == nil {
|
||||
return fallback
|
||||
}
|
||||
return NewLLMDraftGenerator(provider, modelID, fallback)
|
||||
}
|
||||
71
pkg/evolution/lifecycle.go
Normal file
71
pkg/evolution/lifecycle.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
func NextLifecycleState(profile SkillProfile, now time.Time) SkillStatus {
|
||||
if profile.Origin == "manual" || profile.LastUsedAt.IsZero() {
|
||||
return profile.Status
|
||||
}
|
||||
|
||||
idle := now.Sub(profile.LastUsedAt)
|
||||
switch profile.Status {
|
||||
case SkillStatusActive:
|
||||
if idle > 90*24*time.Hour && profile.RetentionScore < 0.3 {
|
||||
return SkillStatusCold
|
||||
}
|
||||
case SkillStatusCold:
|
||||
if idle > 180*24*time.Hour && profile.RetentionScore < 0.2 {
|
||||
return SkillStatusArchived
|
||||
}
|
||||
case SkillStatusArchived:
|
||||
if idle > 365*24*time.Hour && profile.RetentionScore < 0.1 {
|
||||
return SkillStatusDeleted
|
||||
}
|
||||
}
|
||||
|
||||
return profile.Status
|
||||
}
|
||||
|
||||
func ApplyLifecycleState(paths Paths, profile SkillProfile, next SkillStatus) error {
|
||||
if next != SkillStatusDeleted {
|
||||
return nil
|
||||
}
|
||||
|
||||
workspace := profile.WorkspaceID
|
||||
if workspace == "" {
|
||||
workspace = inferWorkspaceFromPaths(paths)
|
||||
}
|
||||
if workspace == "" {
|
||||
return fmt.Errorf("resolve lifecycle delete workspace for skill %q: workspace is required", profile.SkillName)
|
||||
}
|
||||
if err := skills.ValidateSkillName(profile.SkillName); err != nil {
|
||||
return fmt.Errorf("resolve lifecycle delete skill name: %w", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(workspace, "skills", profile.SkillName, "SKILL.md")
|
||||
err := os.Remove(skillPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func inferWorkspaceFromPaths(paths Paths) string {
|
||||
root := filepath.Clean(paths.RootDir)
|
||||
if filepath.Base(root) != "evolution" {
|
||||
return ""
|
||||
}
|
||||
stateDir := filepath.Dir(root)
|
||||
if filepath.Base(stateDir) != "state" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Dir(stateDir)
|
||||
}
|
||||
72
pkg/evolution/lifecycle_actions_test.go
Normal file
72
pkg/evolution/lifecycle_actions_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestApplyLifecycleStateDeletedRemovesSkillFile(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
skillDir := filepath.Join(workspace, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
if err := os.WriteFile(skillPath, []byte("# weather\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
err := evolution.ApplyLifecycleState(
|
||||
evolution.NewPaths(workspace, ""),
|
||||
evolution.SkillProfile{SkillName: "weather"},
|
||||
evolution.SkillStatusDeleted,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyLifecycleState: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(skillPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("skill file should be removed, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLifecycleStateDeletedRequiresResolvedWorkspace(t *testing.T) {
|
||||
err := evolution.ApplyLifecycleState(
|
||||
evolution.Paths{RootDir: filepath.Join(t.TempDir(), "shared-evolution")},
|
||||
evolution.SkillProfile{SkillName: "weather"},
|
||||
evolution.SkillStatusDeleted,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when workspace cannot be resolved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLifecycleStateDeletedRequiresSkillName(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
err := evolution.ApplyLifecycleState(
|
||||
evolution.NewPaths(workspace, ""),
|
||||
evolution.SkillProfile{WorkspaceID: workspace},
|
||||
evolution.SkillStatusDeleted,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when skill name is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLifecycleStateDeletedRejectsTraversalSkillName(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
|
||||
err := evolution.ApplyLifecycleState(
|
||||
evolution.NewPaths(workspace, ""),
|
||||
evolution.SkillProfile{WorkspaceID: workspace, SkillName: "../escape"},
|
||||
evolution.SkillStatusDeleted,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for traversal skill name")
|
||||
}
|
||||
}
|
||||
170
pkg/evolution/lifecycle_test.go
Normal file
170
pkg/evolution/lifecycle_test.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestStore_SaveAndLoadProfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
profile := evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: root,
|
||||
CurrentVersion: "v2",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "weather lookup helper",
|
||||
LastUsedAt: time.Unix(1700000000, 0).UTC(),
|
||||
UseCount: 3,
|
||||
RetentionScore: 0.8,
|
||||
VersionHistory: []evolution.SkillVersionEntry{
|
||||
{
|
||||
Version: "v1",
|
||||
Action: "create",
|
||||
Timestamp: time.Unix(1699990000, 0).UTC(),
|
||||
Summary: "initial learned version",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if loaded.SkillName != "weather" {
|
||||
t.Fatalf("SkillName = %q, want weather", loaded.SkillName)
|
||||
}
|
||||
if loaded.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("Status = %q, want %q", loaded.Status, evolution.SkillStatusActive)
|
||||
}
|
||||
if len(loaded.VersionHistory) != 1 {
|
||||
t.Fatalf("len(VersionHistory) = %d, want 1", len(loaded.VersionHistory))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextLifecycleState_ActiveToCold(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
profile := evolution.SkillProfile{
|
||||
SkillName: "release-flow",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
LastUsedAt: now.AddDate(0, -6, 0),
|
||||
RetentionScore: 0.1,
|
||||
}
|
||||
|
||||
got := evolution.NextLifecycleState(profile, now)
|
||||
if got != evolution.SkillStatusCold {
|
||||
t.Fatalf("NextLifecycleState = %q, want %q", got, evolution.SkillStatusCold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextLifecycleState_ManualSkillStaysActive(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
profile := evolution.SkillProfile{
|
||||
SkillName: "manual-weather",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "manual",
|
||||
LastUsedAt: now.AddDate(-1, 0, 0),
|
||||
RetentionScore: 0,
|
||||
}
|
||||
|
||||
got := evolution.NextLifecycleState(profile, now)
|
||||
if got != evolution.SkillStatusActive {
|
||||
t.Fatalf("NextLifecycleState = %q, want %q", got, evolution.SkillStatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveProfileRejectsInvalidSkillName(t *testing.T) {
|
||||
store := evolution.NewStore(evolution.NewPaths(t.TempDir(), ""))
|
||||
|
||||
err := store.SaveProfile(evolution.SkillProfile{SkillName: "../escape"})
|
||||
if err == nil {
|
||||
t.Fatal("expected SaveProfile to reject invalid skill name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LoadProfileRejectsInvalidSkillName(t *testing.T) {
|
||||
store := evolution.NewStore(evolution.NewPaths(t.TempDir(), ""))
|
||||
|
||||
_, err := store.LoadProfile("/tmp/escape")
|
||||
if err == nil {
|
||||
t.Fatal("expected LoadProfile to reject invalid skill name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SharedStateProfilesRemainIsolatedPerWorkspace(t *testing.T) {
|
||||
sharedState := t.TempDir()
|
||||
workspaceA := t.TempDir()
|
||||
workspaceB := t.TempDir()
|
||||
|
||||
storeA := evolution.NewStore(evolution.NewPaths(workspaceA, sharedState))
|
||||
storeB := evolution.NewStore(evolution.NewPaths(workspaceB, sharedState))
|
||||
|
||||
profileA := evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspaceA,
|
||||
CurrentVersion: "v-a",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "workspace A weather helper",
|
||||
LastUsedAt: time.Unix(1700000000, 0).UTC(),
|
||||
UseCount: 2,
|
||||
RetentionScore: 0.6,
|
||||
}
|
||||
profileB := evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: workspaceB,
|
||||
CurrentVersion: "v-b",
|
||||
Status: evolution.SkillStatusCold,
|
||||
Origin: "manual",
|
||||
HumanSummary: "workspace B weather helper",
|
||||
LastUsedAt: time.Unix(1700000500, 0).UTC(),
|
||||
UseCount: 9,
|
||||
RetentionScore: 0.2,
|
||||
}
|
||||
|
||||
if err := storeA.SaveProfile(profileA); err != nil {
|
||||
t.Fatalf("storeA.SaveProfile: %v", err)
|
||||
}
|
||||
if err := storeB.SaveProfile(profileB); err != nil {
|
||||
t.Fatalf("storeB.SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
loadedA, err := storeA.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("storeA.LoadProfile: %v", err)
|
||||
}
|
||||
if loadedA.WorkspaceID != workspaceA {
|
||||
t.Fatalf("storeA workspace = %q, want %q", loadedA.WorkspaceID, workspaceA)
|
||||
}
|
||||
if loadedA.CurrentVersion != "v-a" {
|
||||
t.Fatalf("storeA CurrentVersion = %q, want v-a", loadedA.CurrentVersion)
|
||||
}
|
||||
|
||||
loadedB, err := storeB.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("storeB.LoadProfile: %v", err)
|
||||
}
|
||||
if loadedB.WorkspaceID != workspaceB {
|
||||
t.Fatalf("storeB workspace = %q, want %q", loadedB.WorkspaceID, workspaceB)
|
||||
}
|
||||
if loadedB.CurrentVersion != "v-b" {
|
||||
t.Fatalf("storeB CurrentVersion = %q, want v-b", loadedB.CurrentVersion)
|
||||
}
|
||||
|
||||
allProfiles, err := storeA.LoadProfiles()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles: %v", err)
|
||||
}
|
||||
if len(allProfiles) != 2 {
|
||||
t.Fatalf("len(LoadProfiles()) = %d, want 2", len(allProfiles))
|
||||
}
|
||||
}
|
||||
174
pkg/evolution/llm_draft_generator.go
Normal file
174
pkg/evolution/llm_draft_generator.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type LLMDraftGenerator struct {
|
||||
provider providers.LLMProvider
|
||||
model string
|
||||
fallback DraftGenerator
|
||||
}
|
||||
|
||||
type llmDraftResponse struct {
|
||||
TargetSkillName string `json:"target_skill_name"`
|
||||
DraftType string `json:"draft_type"`
|
||||
ChangeKind string `json:"change_kind"`
|
||||
HumanSummary string `json:"human_summary"`
|
||||
IntendedUseCases []string `json:"intended_use_cases"`
|
||||
PreferredEntryPath []string `json:"preferred_entry_path"`
|
||||
AvoidPatterns []string `json:"avoid_patterns"`
|
||||
BodyOrPatch string `json:"body_or_patch"`
|
||||
}
|
||||
|
||||
func NewLLMDraftGenerator(provider providers.LLMProvider, model string, fallback DraftGenerator) *LLMDraftGenerator {
|
||||
return &LLMDraftGenerator{
|
||||
provider: provider,
|
||||
model: strings.TrimSpace(model),
|
||||
fallback: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *LLMDraftGenerator) GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) {
|
||||
if g == nil || g.provider == nil {
|
||||
return g.generateFallback(ctx, rule, matches)
|
||||
}
|
||||
|
||||
model := g.model
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(g.provider.GetDefaultModel())
|
||||
}
|
||||
if model == "" {
|
||||
return g.generateFallback(ctx, rule, matches)
|
||||
}
|
||||
|
||||
resp, err := g.provider.Chat(ctx, []providers.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "Return exactly one JSON object for a skill draft. Do not use markdown fences.",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: g.buildPrompt(rule, matches),
|
||||
},
|
||||
}, nil, model, map[string]any{"temperature": 0.2})
|
||||
if err != nil || resp == nil {
|
||||
return g.generateFallback(ctx, rule, matches)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(resp.Content)
|
||||
if content == "" {
|
||||
return g.generateFallback(ctx, rule, matches)
|
||||
}
|
||||
|
||||
draft, ok := parseLLMDraft(content)
|
||||
if !ok || len(ValidateDraft(draft)) > 0 {
|
||||
return g.generateFallback(ctx, rule, matches)
|
||||
}
|
||||
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
func (g *LLMDraftGenerator) generateFallback(
|
||||
ctx context.Context,
|
||||
rule LearningRecord,
|
||||
matches []skills.SkillInfo,
|
||||
) (SkillDraft, error) {
|
||||
if g == nil || g.fallback == nil {
|
||||
return SkillDraft{}, nil
|
||||
}
|
||||
return g.fallback.GenerateDraft(ctx, rule, matches)
|
||||
}
|
||||
|
||||
func (g *LLMDraftGenerator) buildPrompt(rule LearningRecord, matches []skills.SkillInfo) string {
|
||||
return strings.Join([]string{
|
||||
"Generate a skill draft JSON object with these required string fields:",
|
||||
`target_skill_name, draft_type, change_kind, human_summary, body_or_patch.`,
|
||||
"Optional array fields: intended_use_cases, preferred_entry_path, avoid_patterns.",
|
||||
"",
|
||||
"Allowed values:",
|
||||
"- draft_type: workflow | shortcut",
|
||||
"- change_kind: create | append | replace | merge",
|
||||
"",
|
||||
"Rule summary: " + strings.TrimSpace(rule.Summary),
|
||||
"Winning path: " + joinOrFallback(rule.WinningPath, "none"),
|
||||
"Late-added successful skills: " + joinOrFallback(rule.LateAddedSkills, "none"),
|
||||
"Final snapshot trigger: " + fallbackString(rule.FinalSnapshotTrigger, "none"),
|
||||
fmt.Sprintf("Event count: %d", rule.EventCount),
|
||||
fmt.Sprintf("Success rate: %.2f", rule.SuccessRate),
|
||||
"Matched skill refs: " + summarizeSkillMatches(matches),
|
||||
"Matched skill names: " + joinOrFallback(rule.MatchedSkillNames, "none"),
|
||||
"",
|
||||
"body_or_patch should contain the full draft body or patch content as plain text.",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func parseLLMDraft(content string) (SkillDraft, bool) {
|
||||
normalized := strings.TrimSpace(content)
|
||||
normalized = strings.TrimPrefix(normalized, "```json")
|
||||
normalized = strings.TrimPrefix(normalized, "```")
|
||||
normalized = strings.TrimSuffix(normalized, "```")
|
||||
normalized = strings.TrimSpace(normalized)
|
||||
|
||||
var payload llmDraftResponse
|
||||
if err := json.Unmarshal([]byte(normalized), &payload); err != nil {
|
||||
return SkillDraft{}, false
|
||||
}
|
||||
|
||||
draft := SkillDraft{
|
||||
TargetSkillName: strings.TrimSpace(payload.TargetSkillName),
|
||||
DraftType: DraftType(strings.TrimSpace(payload.DraftType)),
|
||||
ChangeKind: ChangeKind(strings.TrimSpace(payload.ChangeKind)),
|
||||
HumanSummary: strings.TrimSpace(payload.HumanSummary),
|
||||
IntendedUseCases: append([]string(nil), payload.IntendedUseCases...),
|
||||
PreferredEntryPath: append([]string(nil), payload.PreferredEntryPath...),
|
||||
AvoidPatterns: append([]string(nil), payload.AvoidPatterns...),
|
||||
BodyOrPatch: strings.TrimSpace(payload.BodyOrPatch),
|
||||
}
|
||||
return draft, true
|
||||
}
|
||||
|
||||
func summarizeSkillMatches(matches []skills.SkillInfo) string {
|
||||
if len(matches) == 0 {
|
||||
return "none"
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
part := strings.TrimSpace(match.Name)
|
||||
if desc := strings.TrimSpace(match.Description); desc != "" {
|
||||
part += ": " + desc
|
||||
}
|
||||
if path := strings.TrimSpace(match.Path); path != "" {
|
||||
part += " (" + path + ")"
|
||||
}
|
||||
if part != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func joinOrFallback(parts []string, fallback string) string {
|
||||
if len(parts) == 0 {
|
||||
return fallback
|
||||
}
|
||||
return strings.Join(parts, " -> ")
|
||||
}
|
||||
|
||||
func fallbackString(value, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
203
pkg/evolution/llm_draft_generator_test.go
Normal file
203
pkg/evolution/llm_draft_generator_test.go
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type recordingDraftGenerator struct {
|
||||
draft evolution.SkillDraft
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (g *recordingDraftGenerator) GenerateDraft(
|
||||
_ context.Context,
|
||||
_ evolution.LearningRecord,
|
||||
_ []skills.SkillInfo,
|
||||
) (evolution.SkillDraft, error) {
|
||||
g.calls++
|
||||
return g.draft, g.err
|
||||
}
|
||||
|
||||
type llmDraftTestProvider struct {
|
||||
response *providers.LLMResponse
|
||||
err error
|
||||
defaultModel string
|
||||
lastModel string
|
||||
lastMessages []providers.Message
|
||||
chatCallCount int
|
||||
}
|
||||
|
||||
func (p *llmDraftTestProvider) Chat(
|
||||
_ context.Context,
|
||||
messages []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
model string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.chatCallCount++
|
||||
p.lastModel = model
|
||||
p.lastMessages = append([]providers.Message(nil), messages...)
|
||||
return p.response, p.err
|
||||
}
|
||||
|
||||
func (p *llmDraftTestProvider) GetDefaultModel() string {
|
||||
return p.defaultModel
|
||||
}
|
||||
|
||||
func testLearningRule() evolution.LearningRecord {
|
||||
return evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Summary: "weather native-name path",
|
||||
EventCount: 7,
|
||||
SuccessRate: 0.86,
|
||||
WinningPath: []string{"weather", "native-name"},
|
||||
MatchedSkillNames: []string{"weather"},
|
||||
}
|
||||
}
|
||||
|
||||
func testSkillMatches() []skills.SkillInfo {
|
||||
return []skills.SkillInfo{
|
||||
{
|
||||
Name: "weather",
|
||||
Path: "/tmp/weather/SKILL.md",
|
||||
Source: "workspace",
|
||||
Description: "Find weather details.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMDraftGenerator_GenerateDraft_ParsesJSONResponse(t *testing.T) {
|
||||
provider := &llmDraftTestProvider{
|
||||
defaultModel: "test-model",
|
||||
response: &providers.LLMResponse{
|
||||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`,
|
||||
},
|
||||
}
|
||||
fallback := &recordingDraftGenerator{
|
||||
draft: evolution.SkillDraft{TargetSkillName: "fallback"},
|
||||
}
|
||||
generator := evolution.NewLLMDraftGenerator(provider, "", fallback)
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
|
||||
if provider.chatCallCount != 1 {
|
||||
t.Fatalf("chatCallCount = %d, want 1", provider.chatCallCount)
|
||||
}
|
||||
if provider.lastModel != "test-model" {
|
||||
t.Fatalf("lastModel = %q, want test-model", provider.lastModel)
|
||||
}
|
||||
if len(provider.lastMessages) == 0 {
|
||||
t.Fatal("expected prompt messages")
|
||||
}
|
||||
if fallback.calls != 0 {
|
||||
t.Fatalf("fallback.calls = %d, want 0", fallback.calls)
|
||||
}
|
||||
if draft.TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
|
||||
}
|
||||
if draft.DraftType != evolution.DraftTypeShortcut {
|
||||
t.Fatalf("DraftType = %q, want %q", draft.DraftType, evolution.DraftTypeShortcut)
|
||||
}
|
||||
if draft.ChangeKind != evolution.ChangeKindAppend {
|
||||
t.Fatalf("ChangeKind = %q, want %q", draft.ChangeKind, evolution.ChangeKindAppend)
|
||||
}
|
||||
if draft.HumanSummary == "" || draft.BodyOrPatch == "" {
|
||||
t.Fatal("expected non-empty draft content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMDraftGenerator_GenerateDraft_PrefersExplicitModelIDOverProviderDefault(t *testing.T) {
|
||||
provider := &llmDraftTestProvider{
|
||||
defaultModel: "provider-default-model",
|
||||
response: &providers.LLMResponse{
|
||||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`,
|
||||
},
|
||||
}
|
||||
generator := evolution.NewLLMDraftGenerator(provider, "explicit-model-id", &recordingDraftGenerator{})
|
||||
|
||||
_, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
if provider.lastModel != "explicit-model-id" {
|
||||
t.Fatalf("lastModel = %q, want explicit-model-id", provider.lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMDraftGenerator_GenerateDraft_FallsBackOnProviderError(t *testing.T) {
|
||||
fallback := &recordingDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
TargetSkillName: "weather-fallback",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "fallback summary",
|
||||
BodyOrPatch: "fallback body",
|
||||
},
|
||||
}
|
||||
generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{
|
||||
defaultModel: "test-model",
|
||||
err: errors.New("provider unavailable"),
|
||||
}, "", fallback)
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
|
||||
if fallback.calls != 1 {
|
||||
t.Fatalf("fallback.calls = %d, want 1", fallback.calls)
|
||||
}
|
||||
if draft.TargetSkillName != "weather-fallback" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMDraftGenerator_GenerateDraft_FallsBackOnInvalidOrEmptyContent(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "invalid json", content: `not-json`},
|
||||
{name: "empty content", content: ``},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fallback := &recordingDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
TargetSkillName: "weather-fallback",
|
||||
DraftType: evolution.DraftTypeWorkflow,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "fallback summary",
|
||||
BodyOrPatch: "fallback body",
|
||||
},
|
||||
}
|
||||
generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{
|
||||
defaultModel: "test-model",
|
||||
response: &providers.LLMResponse{Content: tt.content},
|
||||
}, "", fallback)
|
||||
|
||||
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraft: %v", err)
|
||||
}
|
||||
|
||||
if fallback.calls != 1 {
|
||||
t.Fatalf("fallback.calls = %d, want 1", fallback.calls)
|
||||
}
|
||||
if draft.TargetSkillName != "weather-fallback" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
367
pkg/evolution/organizer.go
Normal file
367
pkg/evolution/organizer.go
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OrganizerOptions struct {
|
||||
MinCaseCount int
|
||||
MinSuccessRate float64
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Organizer struct {
|
||||
minCaseCount int
|
||||
minSuccessRate float64
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewOrganizer(opts OrganizerOptions) *Organizer {
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
minCaseCount := opts.MinCaseCount
|
||||
if minCaseCount <= 0 {
|
||||
minCaseCount = 3
|
||||
}
|
||||
|
||||
minSuccessRate := opts.MinSuccessRate
|
||||
if minSuccessRate <= 0 {
|
||||
minSuccessRate = 0.7
|
||||
}
|
||||
|
||||
return &Organizer{
|
||||
minCaseCount: minCaseCount,
|
||||
minSuccessRate: minSuccessRate,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, error) {
|
||||
clusters := make(map[string][]LearningRecord)
|
||||
keys := make([]string, 0)
|
||||
|
||||
for _, record := range records {
|
||||
if !isTaskRecordKind(record.Kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := normalizeRuleKey(record)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
clusterKey := record.WorkspaceID + "\x00" + key
|
||||
if _, ok := clusters[clusterKey]; !ok {
|
||||
keys = append(keys, clusterKey)
|
||||
}
|
||||
clusters[clusterKey] = append(clusters[clusterKey], record)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
rules := make([]LearningRecord, 0, len(keys))
|
||||
for _, clusterKey := range keys {
|
||||
cluster := append([]LearningRecord(nil), clusters[clusterKey]...)
|
||||
sortCaseCluster(cluster)
|
||||
|
||||
if len(cluster) < o.minCaseCount {
|
||||
continue
|
||||
}
|
||||
|
||||
successRate := clusterSuccessRate(cluster)
|
||||
if successRate < o.minSuccessRate {
|
||||
continue
|
||||
}
|
||||
|
||||
ruleKey := clusterKey[strings.Index(clusterKey, "\x00")+1:]
|
||||
winningPath := clusterWinningPath(cluster)
|
||||
lateAddedSkills, finalSnapshotTrigger := clusterLateAddedSkills(cluster, winningPath)
|
||||
matchedSkillNames := append([]string(nil), winningPath...)
|
||||
|
||||
rules = append(rules, LearningRecord{
|
||||
ID: stableRuleID(cluster[0].WorkspaceID, ruleKey),
|
||||
Kind: RecordKindPattern,
|
||||
WorkspaceID: cluster[0].WorkspaceID,
|
||||
CreatedAt: o.now(),
|
||||
Summary: buildRuleSummary(ruleKey, winningPath),
|
||||
Source: map[string]any{"cluster_key": ruleKey},
|
||||
Status: RecordStatus("ready"),
|
||||
SourceRecordIDs: collectRecordIDs(cluster),
|
||||
EventCount: len(cluster),
|
||||
SuccessRate: successRate,
|
||||
MaturityScore: computeMaturityScore(len(cluster), successRate),
|
||||
WinningPath: winningPath,
|
||||
LateAddedSkills: lateAddedSkills,
|
||||
FinalSnapshotTrigger: finalSnapshotTrigger,
|
||||
MatchedSkillNames: matchedSkillNames,
|
||||
})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func normalizeRuleKey(record LearningRecord) string {
|
||||
if path := normalizeFinalSuccessfulPath(record); len(path) > 0 {
|
||||
return strings.Join(path, " ")
|
||||
}
|
||||
if path := normalizeAttemptedSkills(record); len(path) > 0 {
|
||||
return strings.Join(path, " ")
|
||||
}
|
||||
if path := normalizePath(record.ActiveSkillNames); len(path) > 0 {
|
||||
return strings.Join(path, " ")
|
||||
}
|
||||
if path := normalizePath(record.MatchedSkillNames); len(path) > 0 {
|
||||
return strings.Join(path, " ")
|
||||
}
|
||||
if path := normalizePath(record.ToolKinds); len(path) > 0 {
|
||||
return strings.Join(path, " ")
|
||||
}
|
||||
|
||||
tokens := tokenizeForEvolution(record.Summary)
|
||||
if len(tokens) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(tokens) > 6 {
|
||||
tokens = tokens[:6]
|
||||
}
|
||||
return strings.Join(tokens, " ")
|
||||
}
|
||||
|
||||
func normalizePath(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeFinalSuccessfulPath(record LearningRecord) []string {
|
||||
if record.AttemptTrail == nil {
|
||||
return nil
|
||||
}
|
||||
return normalizePath(record.AttemptTrail.FinalSuccessfulPath)
|
||||
}
|
||||
|
||||
func normalizeAttemptedSkills(record LearningRecord) []string {
|
||||
if record.AttemptTrail == nil {
|
||||
return nil
|
||||
}
|
||||
return normalizePath(record.AttemptTrail.AttemptedSkills)
|
||||
}
|
||||
|
||||
func sortCaseCluster(cluster []LearningRecord) {
|
||||
sort.Slice(cluster, func(i, j int) bool {
|
||||
if !cluster[i].CreatedAt.Equal(cluster[j].CreatedAt) {
|
||||
return cluster[i].CreatedAt.Before(cluster[j].CreatedAt)
|
||||
}
|
||||
return cluster[i].ID < cluster[j].ID
|
||||
})
|
||||
}
|
||||
|
||||
func clusterSuccessRate(cluster []LearningRecord) float64 {
|
||||
if len(cluster) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
successes := 0
|
||||
for _, record := range cluster {
|
||||
if record.Success != nil && *record.Success {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
return float64(successes) / float64(len(cluster))
|
||||
}
|
||||
|
||||
func clusterWinningPath(cluster []LearningRecord) []string {
|
||||
type pathScore struct {
|
||||
path []string
|
||||
count int
|
||||
}
|
||||
|
||||
bestKey := ""
|
||||
best := pathScore{}
|
||||
paths := make(map[string]pathScore)
|
||||
order := make([]string, 0)
|
||||
|
||||
for _, record := range cluster {
|
||||
path := normalizeFinalSuccessfulPath(record)
|
||||
if len(path) == 0 {
|
||||
path = normalizeAttemptedSkills(record)
|
||||
}
|
||||
if len(path) == 0 {
|
||||
path = normalizePath(record.ActiveSkillNames)
|
||||
}
|
||||
if len(path) == 0 {
|
||||
path = normalizePath(record.ToolKinds)
|
||||
}
|
||||
if len(path) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.Join(path, "\x00")
|
||||
score := paths[key]
|
||||
if score.path == nil {
|
||||
score.path = append([]string(nil), path...)
|
||||
order = append(order, key)
|
||||
}
|
||||
score.count++
|
||||
paths[key] = score
|
||||
}
|
||||
|
||||
for _, key := range order {
|
||||
score := paths[key]
|
||||
if score.count > best.count {
|
||||
best = score
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
|
||||
if bestKey == "" {
|
||||
return nil
|
||||
}
|
||||
return best.path
|
||||
}
|
||||
|
||||
func clusterLateAddedSkills(cluster []LearningRecord, winningPath []string) ([]string, string) {
|
||||
type lateAddedScore struct {
|
||||
skills []string
|
||||
trigger string
|
||||
count int
|
||||
}
|
||||
|
||||
bestKey := ""
|
||||
best := lateAddedScore{}
|
||||
scores := make(map[string]lateAddedScore)
|
||||
order := make([]string, 0)
|
||||
|
||||
for _, record := range cluster {
|
||||
skills, trigger := lateAddedSkillsFromRecord(record)
|
||||
if len(skills) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(winningPath) > 0 && !pathsEqual(skills, tailAddedWithinWinningPath(winningPath, skills)) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := trigger + "\x00" + strings.Join(skills, "\x00")
|
||||
score := scores[key]
|
||||
if score.skills == nil {
|
||||
score.skills = append([]string(nil), skills...)
|
||||
score.trigger = trigger
|
||||
order = append(order, key)
|
||||
}
|
||||
score.count++
|
||||
scores[key] = score
|
||||
}
|
||||
|
||||
for _, key := range order {
|
||||
score := scores[key]
|
||||
if score.count > best.count {
|
||||
bestKey = key
|
||||
best = score
|
||||
}
|
||||
}
|
||||
|
||||
if bestKey == "" {
|
||||
return nil, ""
|
||||
}
|
||||
return best.skills, best.trigger
|
||||
}
|
||||
|
||||
func lateAddedSkillsFromRecord(record LearningRecord) ([]string, string) {
|
||||
if record.AttemptTrail == nil || len(record.AttemptTrail.SkillContextSnapshots) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
snapshots := record.AttemptTrail.SkillContextSnapshots
|
||||
last := snapshots[len(snapshots)-1]
|
||||
if len(last.SkillNames) == 0 {
|
||||
return nil, ""
|
||||
}
|
||||
if len(snapshots) == 1 {
|
||||
return nil, strings.TrimSpace(last.Trigger)
|
||||
}
|
||||
|
||||
prev := snapshots[len(snapshots)-2]
|
||||
prevSet := make(map[string]struct{}, len(prev.SkillNames))
|
||||
for _, skill := range normalizePath(prev.SkillNames) {
|
||||
prevSet[skill] = struct{}{}
|
||||
}
|
||||
|
||||
added := make([]string, 0, len(last.SkillNames))
|
||||
for _, skill := range normalizePath(last.SkillNames) {
|
||||
if _, ok := prevSet[skill]; ok {
|
||||
continue
|
||||
}
|
||||
added = append(added, skill)
|
||||
}
|
||||
if len(added) == 0 {
|
||||
return nil, strings.TrimSpace(last.Trigger)
|
||||
}
|
||||
return added, strings.TrimSpace(last.Trigger)
|
||||
}
|
||||
|
||||
func tailAddedWithinWinningPath(winningPath, lateAdded []string) []string {
|
||||
if len(winningPath) == 0 || len(lateAdded) == 0 || len(lateAdded) > len(winningPath) {
|
||||
return nil
|
||||
}
|
||||
tail := winningPath[len(winningPath)-len(lateAdded):]
|
||||
if !pathsEqual(tail, lateAdded) {
|
||||
return nil
|
||||
}
|
||||
return tail
|
||||
}
|
||||
|
||||
func pathsEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func collectRecordIDs(cluster []LearningRecord) []string {
|
||||
ids := make([]string, 0, len(cluster))
|
||||
for _, record := range cluster {
|
||||
ids = append(ids, record.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func computeMaturityScore(caseCount int, successRate float64) float64 {
|
||||
return float64(caseCount) * successRate
|
||||
}
|
||||
|
||||
func stableRuleID(workspaceID, key string) string {
|
||||
sum := sha1.Sum([]byte(workspaceID + "\x00" + key))
|
||||
return "rule-" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
func buildRuleSummary(key string, winningPath []string) string {
|
||||
if len(winningPath) > 0 {
|
||||
return strings.Join(winningPath, " -> ")
|
||||
}
|
||||
return key
|
||||
}
|
||||
245
pkg/evolution/organizer_test.go
Normal file
245
pkg/evolution/organizer_test.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestOrganizer_BuildRulesCreatesRuleRecord(t *testing.T) {
|
||||
ok := true
|
||||
cases := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather shanghai",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
ActiveSkillNames: []string{"weather"},
|
||||
},
|
||||
{
|
||||
ID: "case-2",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
Summary: "weather beijing",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
ActiveSkillNames: []string{"weather"},
|
||||
},
|
||||
{
|
||||
ID: "case-3",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||||
Summary: "weather hangzhou",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
ActiveSkillNames: []string{"weather"},
|
||||
},
|
||||
}
|
||||
|
||||
org := evolution.NewOrganizer(evolution.OrganizerOptions{
|
||||
MinCaseCount: 3,
|
||||
MinSuccessRate: 0.7,
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
})
|
||||
|
||||
rules, err := org.BuildRules(cases)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("len(rules) = %d, want 1", len(rules))
|
||||
}
|
||||
|
||||
rule := rules[0]
|
||||
if rule.Kind != evolution.RecordKindRule {
|
||||
t.Fatalf("Kind = %q, want %q", rule.Kind, evolution.RecordKindRule)
|
||||
}
|
||||
if rule.EventCount != 3 {
|
||||
t.Fatalf("EventCount = %d, want 3", rule.EventCount)
|
||||
}
|
||||
if len(rule.SourceRecordIDs) != 3 {
|
||||
t.Fatalf("SourceRecordIDs = %v", rule.SourceRecordIDs)
|
||||
}
|
||||
if rule.MaturityScore <= 0 {
|
||||
t.Fatalf("MaturityScore = %v, want > 0", rule.MaturityScore)
|
||||
}
|
||||
if len(rule.WinningPath) != 1 || rule.WinningPath[0] != "weather" {
|
||||
t.Fatalf("WinningPath = %v, want [weather]", rule.WinningPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizer_BuildRulesSkipsImmatureCluster(t *testing.T) {
|
||||
ok := true
|
||||
cases := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "release build linux",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
},
|
||||
}
|
||||
|
||||
org := evolution.NewOrganizer(evolution.OrganizerOptions{
|
||||
MinCaseCount: 3,
|
||||
MinSuccessRate: 0.7,
|
||||
})
|
||||
|
||||
rules, err := org.BuildRules(cases)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRules: %v", err)
|
||||
}
|
||||
if len(rules) != 0 {
|
||||
t.Fatalf("len(rules) = %d, want 0", len(rules))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizer_BuildRulesPrefersFinalSuccessfulPathFromAttemptTrail(t *testing.T) {
|
||||
ok := true
|
||||
cases := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather shanghai",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"geocode", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
},
|
||||
ActiveSkillNames: []string{"geocode", "weather"},
|
||||
},
|
||||
{
|
||||
ID: "case-2",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
Summary: "weather beijing",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"browser", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
},
|
||||
ActiveSkillNames: []string{"browser", "weather"},
|
||||
},
|
||||
{
|
||||
ID: "case-3",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||||
Summary: "weather hangzhou",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"maps", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
},
|
||||
ActiveSkillNames: []string{"maps", "weather"},
|
||||
},
|
||||
}
|
||||
|
||||
org := evolution.NewOrganizer(evolution.OrganizerOptions{
|
||||
MinCaseCount: 3,
|
||||
MinSuccessRate: 0.7,
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
})
|
||||
|
||||
rules, err := org.BuildRules(cases)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("len(rules) = %d, want 1", len(rules))
|
||||
}
|
||||
if got := rules[0].WinningPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
|
||||
t.Fatalf("WinningPath = %v, want [geocode weather]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrganizer_BuildRulesCapturesLateAddedSkillHintFromSnapshots(t *testing.T) {
|
||||
ok := true
|
||||
cases := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather shanghai",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"geocode", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
|
||||
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "case-2",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
Summary: "weather beijing",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"browser", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
|
||||
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "case-3",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000200, 0).UTC(),
|
||||
Summary: "weather hangzhou",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
Success: &ok,
|
||||
AttemptTrail: &evolution.AttemptTrail{
|
||||
AttemptedSkills: []string{"maps", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
|
||||
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
org := evolution.NewOrganizer(evolution.OrganizerOptions{
|
||||
MinCaseCount: 3,
|
||||
MinSuccessRate: 0.7,
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
})
|
||||
|
||||
rules, err := org.BuildRules(cases)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("len(rules) = %d, want 1", len(rules))
|
||||
}
|
||||
if got := rules[0].LateAddedSkills; len(got) != 1 || got[0] != "weather" {
|
||||
t.Fatalf("LateAddedSkills = %v, want [weather]", got)
|
||||
}
|
||||
if got := rules[0].FinalSnapshotTrigger; got != "context_retry_rebuild" {
|
||||
t.Fatalf("FinalSnapshotTrigger = %q, want context_retry_rebuild", got)
|
||||
}
|
||||
}
|
||||
31
pkg/evolution/paths.go
Normal file
31
pkg/evolution/paths.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Paths struct {
|
||||
Workspace string
|
||||
RootDir string
|
||||
LearningRecords string
|
||||
SkillDrafts string
|
||||
ProfilesDir string
|
||||
BackupsDir string
|
||||
}
|
||||
|
||||
func NewPaths(workspace, override string) Paths {
|
||||
root := strings.TrimSpace(override)
|
||||
if root == "" {
|
||||
root = filepath.Join(workspace, "state", "evolution")
|
||||
}
|
||||
|
||||
return Paths{
|
||||
Workspace: workspace,
|
||||
RootDir: root,
|
||||
LearningRecords: filepath.Join(root, "learning-records.jsonl"),
|
||||
SkillDrafts: filepath.Join(root, "skill-drafts.json"),
|
||||
ProfilesDir: filepath.Join(root, "profiles"),
|
||||
BackupsDir: filepath.Join(root, "backups"),
|
||||
}
|
||||
}
|
||||
74
pkg/evolution/paths_test.go
Normal file
74
pkg/evolution/paths_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewPaths_DefaultRoot(t *testing.T) {
|
||||
workspace := "/tmp/workspace"
|
||||
|
||||
paths := NewPaths(workspace, "")
|
||||
|
||||
wantRoot := filepath.Join(workspace, "state", "evolution")
|
||||
if paths.RootDir != wantRoot {
|
||||
t.Fatalf("RootDir = %q, want %q", paths.RootDir, wantRoot)
|
||||
}
|
||||
if paths.LearningRecords != filepath.Join(wantRoot, "learning-records.jsonl") {
|
||||
t.Fatalf("LearningRecords = %q", paths.LearningRecords)
|
||||
}
|
||||
if paths.SkillDrafts != filepath.Join(wantRoot, "skill-drafts.json") {
|
||||
t.Fatalf("SkillDrafts = %q", paths.SkillDrafts)
|
||||
}
|
||||
if paths.ProfilesDir != filepath.Join(wantRoot, "profiles") {
|
||||
t.Fatalf("ProfilesDir = %q", paths.ProfilesDir)
|
||||
}
|
||||
if paths.BackupsDir != filepath.Join(wantRoot, "backups") {
|
||||
t.Fatalf("BackupsDir = %q", paths.BackupsDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPaths_UsesOverride(t *testing.T) {
|
||||
workspace := "/tmp/workspace"
|
||||
override := "/tmp/custom-evolution"
|
||||
|
||||
paths := NewPaths(workspace, override)
|
||||
|
||||
if paths.RootDir != override {
|
||||
t.Fatalf("RootDir = %q, want %q", paths.RootDir, override)
|
||||
}
|
||||
if paths.LearningRecords != filepath.Join(override, "learning-records.jsonl") {
|
||||
t.Fatalf("LearningRecords = %q", paths.LearningRecords)
|
||||
}
|
||||
if paths.SkillDrafts != filepath.Join(override, "skill-drafts.json") {
|
||||
t.Fatalf("SkillDrafts = %q", paths.SkillDrafts)
|
||||
}
|
||||
if paths.ProfilesDir != filepath.Join(override, "profiles") {
|
||||
t.Fatalf("ProfilesDir = %q", paths.ProfilesDir)
|
||||
}
|
||||
if paths.BackupsDir != filepath.Join(override, "backups") {
|
||||
t.Fatalf("BackupsDir = %q", paths.BackupsDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPaths_BlankOverrideFallsBackToDefaultRoot(t *testing.T) {
|
||||
workspace := "/tmp/workspace"
|
||||
|
||||
paths := NewPaths(workspace, " \t\n ")
|
||||
|
||||
wantRoot := filepath.Join(workspace, "state", "evolution")
|
||||
if paths.RootDir != wantRoot {
|
||||
t.Fatalf("RootDir = %q, want %q", paths.RootDir, wantRoot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewPaths_TrimmedOverrideIsUsed(t *testing.T) {
|
||||
workspace := "/tmp/workspace"
|
||||
override := " /tmp/custom-evolution "
|
||||
|
||||
paths := NewPaths(workspace, override)
|
||||
|
||||
if paths.RootDir != "/tmp/custom-evolution" {
|
||||
t.Fatalf("RootDir = %q, want %q", paths.RootDir, "/tmp/custom-evolution")
|
||||
}
|
||||
}
|
||||
142
pkg/evolution/preview.go
Normal file
142
pkg/evolution/preview.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DraftPreview struct {
|
||||
CurrentBody string
|
||||
RenderedBody string
|
||||
DiffPreview string
|
||||
}
|
||||
|
||||
func BuildDraftPreview(workspace string, draft SkillDraft) (DraftPreview, error) {
|
||||
currentBody, hadOriginal, err := loadCurrentSkillBody(workspace, draft.TargetSkillName)
|
||||
if err != nil {
|
||||
return DraftPreview{}, err
|
||||
}
|
||||
|
||||
renderedBody, err := renderAppliedBody(draft, currentBody, hadOriginal)
|
||||
if err != nil {
|
||||
return DraftPreview{}, err
|
||||
}
|
||||
|
||||
return DraftPreview{
|
||||
CurrentBody: currentBody,
|
||||
RenderedBody: renderedBody,
|
||||
DiffPreview: buildLineDiffPreview(currentBody, renderedBody),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadCurrentSkillBody(workspace, skillName string) (string, bool, error) {
|
||||
skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return string(data), true, nil
|
||||
}
|
||||
|
||||
func buildLineDiffPreview(currentBody, renderedBody string) string {
|
||||
before := strings.Split(strings.TrimRight(currentBody, "\n"), "\n")
|
||||
after := strings.Split(strings.TrimRight(renderedBody, "\n"), "\n")
|
||||
|
||||
if len(before) == 1 && before[0] == "" {
|
||||
before = nil
|
||||
}
|
||||
if len(after) == 1 && after[0] == "" {
|
||||
after = nil
|
||||
}
|
||||
|
||||
prefixLen := sharedPrefixLen(before, after)
|
||||
suffixLen := sharedSuffixLen(before[prefixLen:], after[prefixLen:])
|
||||
const contextRadius = 2
|
||||
|
||||
beforeChangeStart := prefixLen
|
||||
beforeChangeEnd := len(before) - suffixLen
|
||||
afterChangeStart := prefixLen
|
||||
afterChangeEnd := len(after) - suffixLen
|
||||
|
||||
hunkBeforeStart := previewMaxInt(0, beforeChangeStart-contextRadius)
|
||||
hunkAfterStart := previewMaxInt(0, afterChangeStart-contextRadius)
|
||||
hunkBeforeEnd := previewMinInt(len(before), beforeChangeEnd+contextRadius)
|
||||
hunkAfterEnd := previewMinInt(len(after), afterChangeEnd+contextRadius)
|
||||
|
||||
removed := before[prefixLen : len(before)-suffixLen]
|
||||
added := after[prefixLen : len(after)-suffixLen]
|
||||
if len(removed) == 0 && len(added) == 0 {
|
||||
return "(no content change)"
|
||||
}
|
||||
|
||||
lines := make([]string, 0, (hunkBeforeEnd-hunkBeforeStart)+(hunkAfterEnd-hunkAfterStart))
|
||||
header := []string{
|
||||
"--- current",
|
||||
"+++ rendered",
|
||||
formatUnifiedHunkHeader(hunkBeforeStart, hunkBeforeEnd-hunkBeforeStart, hunkAfterStart, hunkAfterEnd-hunkAfterStart),
|
||||
}
|
||||
for _, line := range before[hunkBeforeStart:beforeChangeStart] {
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
for _, line := range removed {
|
||||
lines = append(lines, "-"+line)
|
||||
}
|
||||
for _, line := range added {
|
||||
lines = append(lines, "+"+line)
|
||||
}
|
||||
for _, line := range after[afterChangeEnd:hunkAfterEnd] {
|
||||
lines = append(lines, " "+line)
|
||||
}
|
||||
return strings.Join(append(header, lines...), "\n")
|
||||
}
|
||||
|
||||
func formatUnifiedHunkHeader(beforeStart, beforeCount, afterStart, afterCount int) string {
|
||||
return "@@ -" + formatUnifiedRange(beforeStart+1, beforeCount) + " +" + formatUnifiedRange(afterStart+1, afterCount) + " @@"
|
||||
}
|
||||
|
||||
func formatUnifiedRange(start, count int) string {
|
||||
return strconv.Itoa(start) + "," + strconv.Itoa(count)
|
||||
}
|
||||
|
||||
func sharedPrefixLen(left, right []string) int {
|
||||
limit := len(left)
|
||||
if len(right) < limit {
|
||||
limit = len(right)
|
||||
}
|
||||
n := 0
|
||||
for n < limit && left[n] == right[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sharedSuffixLen(left, right []string) int {
|
||||
limit := len(left)
|
||||
if len(right) < limit {
|
||||
limit = len(right)
|
||||
}
|
||||
n := 0
|
||||
for n < limit && left[len(left)-1-n] == right[len(right)-1-n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func previewMinInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func previewMaxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
111
pkg/evolution/preview_test.go
Normal file
111
pkg/evolution/preview_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildLineDiffPreview_UsesUnifiedDiffStyle(t *testing.T) {
|
||||
current := strings.Join([]string{
|
||||
"---",
|
||||
"name: weather",
|
||||
"description: weather helper",
|
||||
"---",
|
||||
"# Weather",
|
||||
"## Start Here",
|
||||
"Use city names first.",
|
||||
"",
|
||||
}, "\n")
|
||||
rendered := strings.Join([]string{
|
||||
"---",
|
||||
"name: weather",
|
||||
"description: weather helper",
|
||||
"---",
|
||||
"# Weather",
|
||||
"## Start Here",
|
||||
"Use city names first.",
|
||||
"",
|
||||
"## Start Here",
|
||||
"Use native-name query first.",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
diff := buildLineDiffPreview(current, rendered)
|
||||
|
||||
for _, want := range []string{
|
||||
"--- current",
|
||||
"+++ rendered",
|
||||
"@@",
|
||||
"+## Start Here",
|
||||
"+Use native-name query first.",
|
||||
} {
|
||||
if !strings.Contains(diff, want) {
|
||||
t.Fatalf("diff missing %q:\n%s", want, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLineDiffPreview_NoContentChange(t *testing.T) {
|
||||
body := "---\nname: weather\n---\n# Weather\n"
|
||||
diff := buildLineDiffPreview(body, body)
|
||||
if diff != "(no content change)" {
|
||||
t.Fatalf("diff = %q, want no-content marker", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLineDiffPreview_LimitsContextAroundChanges(t *testing.T) {
|
||||
current := strings.Join([]string{
|
||||
"line-01",
|
||||
"line-02",
|
||||
"line-03",
|
||||
"line-04",
|
||||
"line-05",
|
||||
"line-06",
|
||||
"line-07",
|
||||
"line-08",
|
||||
"line-09",
|
||||
"line-10",
|
||||
"",
|
||||
}, "\n")
|
||||
rendered := strings.Join([]string{
|
||||
"line-01",
|
||||
"line-02",
|
||||
"line-03",
|
||||
"line-04",
|
||||
"line-05",
|
||||
"line-06",
|
||||
"inserted-a",
|
||||
"inserted-b",
|
||||
"line-07",
|
||||
"line-08",
|
||||
"line-09",
|
||||
"line-10",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
diff := buildLineDiffPreview(current, rendered)
|
||||
|
||||
for _, want := range []string{
|
||||
"@@",
|
||||
" line-05",
|
||||
" line-06",
|
||||
"+inserted-a",
|
||||
"+inserted-b",
|
||||
" line-07",
|
||||
" line-08",
|
||||
} {
|
||||
if !strings.Contains(diff, want) {
|
||||
t.Fatalf("diff missing %q:\n%s", want, diff)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{
|
||||
"line-01",
|
||||
"line-02",
|
||||
"line-09",
|
||||
"line-10",
|
||||
} {
|
||||
if strings.Contains(diff, unwanted) {
|
||||
t.Fatalf("diff should omit distant context %q:\n%s", unwanted, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
73
pkg/evolution/profile_sync.go
Normal file
73
pkg/evolution/profile_sync.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func SaveAppliedProfile(store *Store, workspace string, draft SkillDraft, now time.Time) error {
|
||||
profile, err := store.LoadProfile(draft.TargetSkillName)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
profile = SkillProfile{
|
||||
SkillName: draft.TargetSkillName,
|
||||
WorkspaceID: workspace,
|
||||
Origin: "evolved",
|
||||
}
|
||||
}
|
||||
|
||||
profile.SkillName = draft.TargetSkillName
|
||||
profile.WorkspaceID = workspace
|
||||
profile.CurrentVersion = draft.ID
|
||||
profile.Status = SkillStatusActive
|
||||
profile.Origin = profileOrigin(profile.Origin)
|
||||
profile.HumanSummary = draft.HumanSummary
|
||||
profile.ChangeReason = draft.HumanSummary
|
||||
profile.IntendedUseCases = append([]string(nil), draft.IntendedUseCases...)
|
||||
profile.PreferredEntryPath = append([]string(nil), draft.PreferredEntryPath...)
|
||||
profile.AvoidPatterns = append([]string(nil), draft.AvoidPatterns...)
|
||||
profile.LastUsedAt = now
|
||||
if profile.RetentionScore <= 0 {
|
||||
profile.RetentionScore = 1
|
||||
}
|
||||
profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{
|
||||
Version: draft.ID,
|
||||
Action: string(draft.ChangeKind),
|
||||
Timestamp: now,
|
||||
DraftID: draft.ID,
|
||||
Summary: draft.HumanSummary,
|
||||
})
|
||||
return store.SaveProfile(profile)
|
||||
}
|
||||
|
||||
func inferIntendedUseCases(rule LearningRecord) []string {
|
||||
summary := strings.TrimSpace(rule.Summary)
|
||||
if summary == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{summary}
|
||||
}
|
||||
|
||||
func inferPreferredEntryPath(rule LearningRecord) []string {
|
||||
if len(rule.WinningPath) == 0 {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), rule.WinningPath...)
|
||||
}
|
||||
|
||||
func inferAvoidPatterns(rule LearningRecord) []string {
|
||||
if len(rule.LateAddedSkills) == 0 || len(rule.WinningPath) <= len(rule.LateAddedSkills) {
|
||||
return nil
|
||||
}
|
||||
prefix := rule.WinningPath[:len(rule.WinningPath)-len(rule.LateAddedSkills)]
|
||||
if len(prefix) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
"avoid starting with " + strings.Join(prefix, " -> ") + " before using " + strings.Join(rule.LateAddedSkills, " -> "),
|
||||
}
|
||||
}
|
||||
9
pkg/evolution/record_kinds.go
Normal file
9
pkg/evolution/record_kinds.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package evolution
|
||||
|
||||
func isTaskRecordKind(kind RecordKind) bool {
|
||||
return kind == RecordKindTask || kind == legacyRecordKindCase
|
||||
}
|
||||
|
||||
func isPatternRecordKind(kind RecordKind) bool {
|
||||
return kind == RecordKindPattern || kind == legacyRecordKindRule
|
||||
}
|
||||
615
pkg/evolution/runtime.go
Normal file
615
pkg/evolution/runtime.go
Normal file
|
|
@ -0,0 +1,615 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
var ErrApplyDraftFailed = errors.New("apply draft failed")
|
||||
|
||||
type RuntimeOptions struct {
|
||||
Config config.EvolutionConfig
|
||||
Now func() time.Time
|
||||
Store *Store
|
||||
Organizer *Organizer
|
||||
SkillsRecaller *SkillsRecaller
|
||||
DraftGenerator DraftGenerator
|
||||
GeneratorFactory func(workspace string) DraftGenerator
|
||||
Applier *Applier
|
||||
ApplierFactory func(workspace string) *Applier
|
||||
}
|
||||
|
||||
type Runtime struct {
|
||||
cfg config.EvolutionConfig
|
||||
mu sync.Mutex
|
||||
now func() time.Time
|
||||
writer *CaseWriter
|
||||
store *Store
|
||||
organizer *Organizer
|
||||
skillsRecaller *SkillsRecaller
|
||||
draftGenerator DraftGenerator
|
||||
generatorFactory func(workspace string) DraftGenerator
|
||||
applier *Applier
|
||||
applierFactory func(workspace string) *Applier
|
||||
}
|
||||
|
||||
type TurnCaseInput struct {
|
||||
Workspace string
|
||||
WorkspaceID string
|
||||
TurnID string
|
||||
SessionKey string
|
||||
AgentID string
|
||||
Status string
|
||||
ToolKinds []string
|
||||
ActiveSkillNames []string
|
||||
AttemptedSkillNames []string
|
||||
FinalSuccessfulPath []string
|
||||
SkillContextSnapshots []SkillContextSnapshot
|
||||
}
|
||||
|
||||
func NewRuntime(opts RuntimeOptions) (*Runtime, error) {
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
|
||||
organizer := opts.Organizer
|
||||
if organizer == nil {
|
||||
organizer = NewOrganizer(OrganizerOptions{
|
||||
MinCaseCount: opts.Config.MinCaseCount,
|
||||
MinSuccessRate: opts.Config.MinSuccessRate,
|
||||
Now: now,
|
||||
})
|
||||
}
|
||||
|
||||
return &Runtime{
|
||||
cfg: opts.Config,
|
||||
now: now,
|
||||
store: opts.Store,
|
||||
organizer: organizer,
|
||||
skillsRecaller: opts.SkillsRecaller,
|
||||
draftGenerator: opts.DraftGenerator,
|
||||
generatorFactory: opts.GeneratorFactory,
|
||||
applier: opts.Applier,
|
||||
applierFactory: opts.ApplierFactory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (rt *Runtime) FinalizeTurn(ctx context.Context, input TurnCaseInput) error {
|
||||
if rt == nil || !rt.cfg.Enabled || input.Workspace == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
success := input.Status == "completed"
|
||||
workspaceID := input.WorkspaceID
|
||||
if workspaceID == "" {
|
||||
workspaceID = input.Workspace
|
||||
}
|
||||
|
||||
record := LearningRecord{
|
||||
ID: input.TurnID,
|
||||
Kind: RecordKindTask,
|
||||
WorkspaceID: workspaceID,
|
||||
CreatedAt: rt.now(),
|
||||
SessionKey: input.SessionKey,
|
||||
TaskHash: buildTaskHash(input),
|
||||
Summary: fmt.Sprintf("turn %s finished with status=%s", input.TurnID, input.Status),
|
||||
Source: map[string]any{"turn_id": input.TurnID, "session_key": input.SessionKey, "agent_id": input.AgentID},
|
||||
Status: RecordStatus("new"),
|
||||
Success: &success,
|
||||
ToolKinds: append([]string(nil), input.ToolKinds...),
|
||||
ActiveSkillNames: append([]string(nil), input.ActiveSkillNames...),
|
||||
AttemptTrail: buildAttemptTrail(input, success),
|
||||
Signals: buildLearningSignals(input, success),
|
||||
}
|
||||
|
||||
paths := NewPaths(input.Workspace, rt.cfg.StateDir)
|
||||
|
||||
rt.mu.Lock()
|
||||
if rt.writer == nil || rt.writer.paths.RootDir != paths.RootDir {
|
||||
rt.writer = NewCaseWriter(paths)
|
||||
}
|
||||
writer := rt.writer
|
||||
rt.mu.Unlock()
|
||||
|
||||
if err := writer.AppendCase(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rt.recordSkillUsage(input, success)
|
||||
}
|
||||
|
||||
func buildAttemptTrail(input TurnCaseInput, success bool) *AttemptTrail {
|
||||
attemptedInput := input.AttemptedSkillNames
|
||||
if len(attemptedInput) == 0 {
|
||||
attemptedInput = input.ActiveSkillNames
|
||||
}
|
||||
|
||||
attempted := make([]string, 0, len(attemptedInput))
|
||||
for _, skillName := range attemptedInput {
|
||||
skillName = strings.TrimSpace(skillName)
|
||||
if skillName == "" {
|
||||
continue
|
||||
}
|
||||
attempted = append(attempted, skillName)
|
||||
}
|
||||
if len(attempted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
trail := &AttemptTrail{
|
||||
AttemptedSkills: attempted,
|
||||
}
|
||||
if len(input.SkillContextSnapshots) > 0 {
|
||||
trail.SkillContextSnapshots = cloneSkillContextSnapshots(input.SkillContextSnapshots)
|
||||
}
|
||||
if success {
|
||||
finalPathInput := input.FinalSuccessfulPath
|
||||
if len(finalPathInput) == 0 {
|
||||
finalPathInput = attempted
|
||||
}
|
||||
finalPath := make([]string, 0, len(finalPathInput))
|
||||
for _, skillName := range finalPathInput {
|
||||
skillName = strings.TrimSpace(skillName)
|
||||
if skillName == "" {
|
||||
continue
|
||||
}
|
||||
finalPath = append(finalPath, skillName)
|
||||
}
|
||||
trail.FinalSuccessfulPath = finalPath
|
||||
}
|
||||
return trail
|
||||
}
|
||||
|
||||
func cloneSkillContextSnapshots(input []SkillContextSnapshot) []SkillContextSnapshot {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]SkillContextSnapshot, 0, len(input))
|
||||
for _, snapshot := range input {
|
||||
out = append(out, SkillContextSnapshot{
|
||||
Sequence: snapshot.Sequence,
|
||||
Trigger: snapshot.Trigger,
|
||||
SkillNames: append([]string(nil), snapshot.SkillNames...),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTaskHash(input TurnCaseInput) string {
|
||||
skillValues := input.AttemptedSkillNames
|
||||
if len(skillValues) == 0 {
|
||||
skillValues = input.ActiveSkillNames
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(skillValues)+len(input.ToolKinds)+2)
|
||||
parts = append(parts, normalizedValues(skillValues)...)
|
||||
parts = append(parts, normalizedValues(input.ToolKinds)...)
|
||||
if strings.TrimSpace(input.Status) != "" {
|
||||
parts = append(parts, strings.ToLower(strings.TrimSpace(input.Status)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = append(parts, "empty")
|
||||
}
|
||||
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "\x00")))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func buildLearningSignals(input TurnCaseInput, success bool) []string {
|
||||
if !success {
|
||||
return nil
|
||||
}
|
||||
skillValues := input.AttemptedSkillNames
|
||||
if len(skillValues) == 0 {
|
||||
skillValues = input.ActiveSkillNames
|
||||
}
|
||||
if len(normalizedValues(skillValues)) > 1 {
|
||||
return []string{"potentially_learnable"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedValues(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error {
|
||||
if rt == nil || !rt.cfg.Enabled || workspace == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
mode := rt.cfg.EffectiveMode()
|
||||
if mode == "" || mode == "observe" {
|
||||
return nil
|
||||
}
|
||||
|
||||
store := rt.storeForWorkspace(workspace)
|
||||
records, err := store.LoadLearningRecords()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rt.organizer != nil {
|
||||
rules, err := rt.organizer.BuildRules(records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRules := filterNewRules(records, rules, workspace)
|
||||
if len(newRules) > 0 {
|
||||
if err := store.AppendLearningRecords(newRules); err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, newRules...)
|
||||
}
|
||||
}
|
||||
|
||||
generator := rt.draftGeneratorForWorkspace(workspace)
|
||||
if generator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
recaller := rt.skillsRecallerForWorkspace(workspace)
|
||||
applier := rt.applierForWorkspace(workspace)
|
||||
readyRules := filterReadyRules(records, workspace)
|
||||
if len(readyRules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
existingDrafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingBySource := existingDraftSourceSet(existingDrafts, workspace)
|
||||
|
||||
for _, rule := range readyRules {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
if _, exists := existingBySource[rule.ID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
matches, err := recaller.RecallSimilarSkills(rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
draft, err := generator.GenerateDraft(ctx, rule, matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
draft = rt.finalizeDraft(workspace, rule, matches, draft)
|
||||
if mode == "apply" && rt.cfg.AutoApply && applier != nil && draft.Status == DraftStatusCandidate {
|
||||
rollbackApply, err := applier.applyDraftWithRollback(ctx, workspace, draft)
|
||||
if err != nil {
|
||||
draft.Status = DraftStatusQuarantined
|
||||
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply failed: %v", err))
|
||||
if auditErr := rt.recordRollbackAudit(store, draft, err); auditErr != nil {
|
||||
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("rollback audit failed: %v", auditErr))
|
||||
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr)
|
||||
}
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr)
|
||||
}
|
||||
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
||||
}
|
||||
draft.Status = DraftStatusAccepted
|
||||
if err := rt.saveAppliedProfile(store, workspace, draft); err != nil {
|
||||
draft.Status = DraftStatusQuarantined
|
||||
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("profile save failed: %v", err))
|
||||
if rollbackErr := rollbackApply(); rollbackErr != nil {
|
||||
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply rollback failed: %v", rollbackErr))
|
||||
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr)
|
||||
}
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr)
|
||||
}
|
||||
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
|
||||
return errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), saveErr)
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrApplyDraftFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := store.SaveDrafts([]SkillDraft{draft}); err != nil {
|
||||
return err
|
||||
}
|
||||
existingBySource[rule.ID] = struct{}{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rt *Runtime) storeForWorkspace(workspace string) *Store {
|
||||
paths := NewPaths(workspace, rt.cfg.StateDir)
|
||||
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
if rt.store == nil || rt.store.paths.RootDir != paths.RootDir {
|
||||
rt.store = NewStore(paths)
|
||||
}
|
||||
return rt.store
|
||||
}
|
||||
|
||||
func (rt *Runtime) skillsRecallerForWorkspace(workspace string) *SkillsRecaller {
|
||||
rt.mu.Lock()
|
||||
defer rt.mu.Unlock()
|
||||
|
||||
if rt.skillsRecaller == nil || rt.skillsRecaller.workspace != workspace {
|
||||
rt.skillsRecaller = NewSkillsRecaller(workspace)
|
||||
}
|
||||
return rt.skillsRecaller
|
||||
}
|
||||
|
||||
func (rt *Runtime) draftGeneratorForWorkspace(workspace string) DraftGenerator {
|
||||
if rt.generatorFactory != nil {
|
||||
if generator := rt.generatorFactory(workspace); generator != nil {
|
||||
return generator
|
||||
}
|
||||
}
|
||||
if rt.draftGenerator != nil {
|
||||
return rt.draftGenerator
|
||||
}
|
||||
return NewDefaultDraftGenerator(workspace)
|
||||
}
|
||||
|
||||
func (rt *Runtime) applierForWorkspace(workspace string) *Applier {
|
||||
if rt.applierFactory != nil {
|
||||
if applier := rt.applierFactory(workspace); applier != nil {
|
||||
return applier
|
||||
}
|
||||
}
|
||||
return rt.applier
|
||||
}
|
||||
|
||||
func (rt *Runtime) finalizeDraft(workspace string, rule LearningRecord, matches []skills.SkillInfo, draft SkillDraft) SkillDraft {
|
||||
if draft.ID == "" {
|
||||
draft.ID = "draft-" + rule.ID
|
||||
}
|
||||
if draft.CreatedAt.IsZero() {
|
||||
draft.CreatedAt = rt.now()
|
||||
}
|
||||
draft.WorkspaceID = workspace
|
||||
draft.SourceRecordID = rule.ID
|
||||
if len(draft.MatchedSkillRefs) == 0 {
|
||||
draft.MatchedSkillRefs = collectSkillRefs(matches)
|
||||
}
|
||||
|
||||
review := ReviewDraft(draft)
|
||||
draft.Status = review.Status
|
||||
draft.ReviewNotes = append([]string(nil), review.ReviewNotes...)
|
||||
if len(review.Findings) == 0 {
|
||||
draft.ScanFindings = nil
|
||||
return draft
|
||||
}
|
||||
draft.ScanFindings = append([]string(nil), review.Findings...)
|
||||
return draft
|
||||
}
|
||||
|
||||
func collectSkillRefs(matches []skills.SkillInfo) []string {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs := make([]string, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
if strings := match.Path; strings != "" {
|
||||
refs = append(refs, strings)
|
||||
continue
|
||||
}
|
||||
refs = append(refs, match.Source+":"+match.Name)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func filterNewRules(records []LearningRecord, rules []LearningRecord, workspace string) []LearningRecord {
|
||||
existing := make(map[string]struct{}, len(records))
|
||||
for _, record := range records {
|
||||
if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace {
|
||||
continue
|
||||
}
|
||||
existing[record.ID] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]LearningRecord, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule.WorkspaceID != workspace {
|
||||
continue
|
||||
}
|
||||
if _, ok := existing[rule.ID]; ok {
|
||||
continue
|
||||
}
|
||||
existing[rule.ID] = struct{}{}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func filterReadyRules(records []LearningRecord, workspace string) []LearningRecord {
|
||||
seen := make(map[string]LearningRecord)
|
||||
for _, record := range records {
|
||||
if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace || record.Status != RecordStatus("ready") {
|
||||
continue
|
||||
}
|
||||
seen[record.ID] = record
|
||||
}
|
||||
|
||||
out := make([]LearningRecord, 0, len(seen))
|
||||
for _, record := range seen {
|
||||
out = append(out, record)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func existingDraftSourceSet(drafts []SkillDraft, workspace string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(drafts))
|
||||
for _, draft := range drafts {
|
||||
if draft.WorkspaceID != workspace || draft.SourceRecordID == "" {
|
||||
continue
|
||||
}
|
||||
if draft.Status == DraftStatusQuarantined {
|
||||
continue
|
||||
}
|
||||
out[draft.SourceRecordID] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (rt *Runtime) saveAppliedProfile(store *Store, workspace string, draft SkillDraft) error {
|
||||
now := rt.now()
|
||||
|
||||
return SaveAppliedProfile(store, workspace, draft, now)
|
||||
}
|
||||
|
||||
func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error {
|
||||
profile, err := store.LoadProfile(draft.TargetSkillName)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := rt.now()
|
||||
profile.VersionHistory = append(profile.VersionHistory, SkillVersionEntry{
|
||||
Version: profile.CurrentVersion,
|
||||
Action: "rollback",
|
||||
Timestamp: now,
|
||||
DraftID: draft.ID,
|
||||
Summary: fmt.Sprintf("Rolled back failed draft apply: %s", draft.HumanSummary),
|
||||
Rollback: true,
|
||||
RollbackReason: applyErr.Error(),
|
||||
})
|
||||
return store.SaveProfile(profile)
|
||||
}
|
||||
|
||||
func profileOrigin(origin string) string {
|
||||
if origin == "manual" {
|
||||
return origin
|
||||
}
|
||||
return "evolved"
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, values ...string) []string {
|
||||
seen := make(map[string]struct{}, len(existing))
|
||||
for _, value := range existing {
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
existing = append(existing, value)
|
||||
seen[value] = struct{}{}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func (rt *Runtime) recordSkillUsage(input TurnCaseInput, success bool) error {
|
||||
if len(input.ActiveSkillNames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
store := rt.storeForWorkspace(input.Workspace)
|
||||
seen := make(map[string]struct{}, len(input.ActiveSkillNames))
|
||||
for _, skillName := range input.ActiveSkillNames {
|
||||
skillName = strings.TrimSpace(skillName)
|
||||
if skillName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[skillName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[skillName] = struct{}{}
|
||||
|
||||
if err := rt.touchSkillProfile(store, input, skillName, success); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rt *Runtime) touchSkillProfile(store *Store, input TurnCaseInput, skillName string, success bool) error {
|
||||
now := rt.now()
|
||||
|
||||
profile, err := store.LoadProfile(skillName)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
profile = SkillProfile{
|
||||
SkillName: skillName,
|
||||
WorkspaceID: input.Workspace,
|
||||
Status: SkillStatusActive,
|
||||
Origin: "manual",
|
||||
HumanSummary: skillName,
|
||||
RetentionScore: 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
profile.SkillName = skillName
|
||||
profile.WorkspaceID = input.Workspace
|
||||
if profile.Status == SkillStatusCold || profile.Status == SkillStatusArchived || profile.Status == "" {
|
||||
profile.Status = SkillStatusActive
|
||||
}
|
||||
if profile.Origin == "" {
|
||||
profile.Origin = "manual"
|
||||
}
|
||||
if strings.TrimSpace(profile.HumanSummary) == "" {
|
||||
profile.HumanSummary = skillName
|
||||
}
|
||||
profile.LastUsedAt = now
|
||||
profile.UseCount++
|
||||
profile.RetentionScore = nextRetentionScore(profile.RetentionScore, success)
|
||||
return store.SaveProfile(profile)
|
||||
}
|
||||
|
||||
func nextRetentionScore(current float64, success bool) float64 {
|
||||
increment := 0.05
|
||||
if success {
|
||||
increment = 0.1
|
||||
}
|
||||
current += increment
|
||||
if current > 1 {
|
||||
return 1
|
||||
}
|
||||
return current
|
||||
}
|
||||
372
pkg/evolution/runtime_apply_test.go
Normal file
372
pkg/evolution/runtime_apply_test.go
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
Store: store,
|
||||
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
|
||||
return time.Unix(1700001000, 0).UTC()
|
||||
}),
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "weather helper",
|
||||
IntendedUseCases: []string{
|
||||
"weather native-name path",
|
||||
},
|
||||
PreferredEntryPath: []string{"weather"},
|
||||
AvoidPatterns: []string{"avoid translating city names before querying weather"},
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
},
|
||||
},
|
||||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(root, "skills", "weather", "SKILL.md")
|
||||
if _, err := os.Stat(skillPath); err != nil {
|
||||
t.Fatalf("expected skill file: %v", err)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive)
|
||||
}
|
||||
if profile.CurrentVersion == "" {
|
||||
t.Fatal("CurrentVersion should not be empty")
|
||||
}
|
||||
if profile.ChangeReason != "weather helper" {
|
||||
t.Fatalf("ChangeReason = %q, want weather helper", profile.ChangeReason)
|
||||
}
|
||||
if len(profile.IntendedUseCases) != 1 || profile.IntendedUseCases[0] != "weather native-name path" {
|
||||
t.Fatalf("IntendedUseCases = %v, want [weather native-name path]", profile.IntendedUseCases)
|
||||
}
|
||||
if len(profile.PreferredEntryPath) != 1 || profile.PreferredEntryPath[0] != "weather" {
|
||||
t.Fatalf("PreferredEntryPath = %v, want [weather]", profile.PreferredEntryPath)
|
||||
}
|
||||
if len(profile.AvoidPatterns) != 1 || profile.AvoidPatterns[0] != "avoid translating city names before querying weather" {
|
||||
t.Fatalf("AvoidPatterns = %v, want populated metadata", profile.AvoidPatterns)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusAccepted {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_ApplyModeWithoutAutoApplyKeepsCandidateDraft(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: false},
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
Store: store,
|
||||
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
|
||||
return time.Unix(1700001000, 0).UTC()
|
||||
}),
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "weather helper",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
},
|
||||
},
|
||||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no applied skill file, got err=%v", err)
|
||||
}
|
||||
if _, err := store.LoadProfile("weather"); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected no profile, got err=%v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_ApplyFailureQuarantinesDraftAndWritesRollbackAudit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
profile := evolution.SkillProfile{
|
||||
SkillName: "weather",
|
||||
WorkspaceID: root,
|
||||
CurrentVersion: "v1",
|
||||
Status: evolution.SkillStatusActive,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "weather helper",
|
||||
LastUsedAt: time.Unix(1700000000, 0).UTC(),
|
||||
RetentionScore: 1,
|
||||
VersionHistory: []evolution.SkillVersionEntry{
|
||||
{
|
||||
Version: "v1",
|
||||
Action: "create",
|
||||
Timestamp: time.Unix(1700000000, 0).UTC(),
|
||||
DraftID: "draft-old",
|
||||
Summary: "initial",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := store.SaveProfile(profile); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
skillDir := filepath.Join(root, "skills", "weather")
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
skillPath := filepath.Join(skillDir, "SKILL.md")
|
||||
original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n"
|
||||
if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
Store: store,
|
||||
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time {
|
||||
return time.Unix(1700001000, 0).UTC()
|
||||
}),
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-rollback",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindReplace,
|
||||
HumanSummary: "broken weather helper",
|
||||
BodyOrPatch: "invalid-frontmatter",
|
||||
},
|
||||
},
|
||||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
err = rt.RunColdPathOnce(context.Background(), root)
|
||||
if err == nil {
|
||||
t.Fatal("expected RunColdPathOnce to fail")
|
||||
}
|
||||
if !errors.Is(err, evolution.ErrApplyDraftFailed) {
|
||||
t.Fatalf("error = %v, want ErrApplyDraftFailed", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(drafts[0].ScanFindings) == 0 {
|
||||
t.Fatal("expected apply error in ScanFindings")
|
||||
}
|
||||
|
||||
loadedProfile, err := store.LoadProfile("weather")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if len(loadedProfile.VersionHistory) != 2 {
|
||||
t.Fatalf("len(VersionHistory) = %d, want 2", len(loadedProfile.VersionHistory))
|
||||
}
|
||||
last := loadedProfile.VersionHistory[len(loadedProfile.VersionHistory)-1]
|
||||
if !last.Rollback {
|
||||
t.Fatal("expected rollback audit entry")
|
||||
}
|
||||
if last.DraftID != "draft-rollback" {
|
||||
t.Fatalf("DraftID = %q, want draft-rollback", last.DraftID)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(got) != original {
|
||||
t.Fatalf("skill content changed after runtime rollback:\n%s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_ProfileSaveFailureRollsBackSkillAndQuarantinesDraft(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(paths.ProfilesDir), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(paths.ProfilesDir, []byte("not-a-directory"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(profiles): %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply", AutoApply: true},
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
Store: store,
|
||||
Applier: evolution.NewApplier(paths, func() time.Time {
|
||||
return time.Unix(1700001000, 0).UTC()
|
||||
}),
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-profile-fail",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindCreate,
|
||||
HumanSummary: "weather helper",
|
||||
BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n",
|
||||
},
|
||||
},
|
||||
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
err = rt.RunColdPathOnce(context.Background(), root)
|
||||
if err == nil {
|
||||
t.Fatal("expected RunColdPathOnce to fail")
|
||||
}
|
||||
if !errors.Is(err, evolution.ErrApplyDraftFailed) {
|
||||
t.Fatalf("error = %v, want ErrApplyDraftFailed", err)
|
||||
}
|
||||
|
||||
skillPath := filepath.Join(root, "skills", "weather", "SKILL.md")
|
||||
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected rolled back skill file, got err=%v", statErr)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("draft status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(drafts[0].ScanFindings) == 0 {
|
||||
t.Fatal("expected scan findings for profile save failure")
|
||||
}
|
||||
}
|
||||
603
pkg/evolution/runtime_cold_path_test.go
Normal file
603
pkg/evolution/runtime_cold_path_test.go
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type stubDraftGenerator struct {
|
||||
draft evolution.SkillDraft
|
||||
err error
|
||||
}
|
||||
|
||||
func (g stubDraftGenerator) GenerateDraft(
|
||||
_ context.Context,
|
||||
_ evolution.LearningRecord,
|
||||
_ []skills.SkillInfo,
|
||||
) (evolution.SkillDraft, error) {
|
||||
return g.draft, g.err
|
||||
}
|
||||
|
||||
type sequenceDraftGenerator struct {
|
||||
results []draftGenerationResult
|
||||
index int
|
||||
}
|
||||
|
||||
type draftGenerationResult struct {
|
||||
draft evolution.SkillDraft
|
||||
err error
|
||||
}
|
||||
|
||||
func (g *sequenceDraftGenerator) GenerateDraft(
|
||||
_ context.Context,
|
||||
_ evolution.LearningRecord,
|
||||
_ []skills.SkillInfo,
|
||||
) (evolution.SkillDraft, error) {
|
||||
if g.index >= len(g.results) {
|
||||
return evolution.SkillDraft{}, nil
|
||||
}
|
||||
result := g.results[g.index]
|
||||
g.index++
|
||||
return result.draft, result.err
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "prefer native-name path first",
|
||||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||||
},
|
||||
},
|
||||
Store: store,
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "release path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "broken",
|
||||
BodyOrPatch: "",
|
||||
},
|
||||
},
|
||||
Store: store,
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusQuarantined {
|
||||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusQuarantined)
|
||||
}
|
||||
if len(drafts[0].ScanFindings) == 0 {
|
||||
t.Fatal("expected scan findings for invalid draft")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_DoesNotWriteSkillFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
skillPath := filepath.Join(root, "skills", "weather", "SKILL.md")
|
||||
if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(skillPath, []byte("---\nname: weather\ndescription: test\n---\n# Weather"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
original, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(original): %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: root,
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "prefer native-name path first",
|
||||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||||
},
|
||||
},
|
||||
Store: store,
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(after): %v", err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("skill file changed unexpectedly:\n%s", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName)
|
||||
}
|
||||
if drafts[0].Status != evolution.DraftStatusCandidate {
|
||||
t.Fatalf("Status = %q, want %q", drafts[0].Status, evolution.DraftStatusCandidate)
|
||||
}
|
||||
if drafts[0].BodyOrPatch == "" {
|
||||
t.Fatal("expected generated draft body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
provider := &llmDraftRuntimeProvider{
|
||||
response: &providers.LLMResponse{
|
||||
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name path first","body_or_patch":"## Start Here\nUse native-name query first."}`,
|
||||
},
|
||||
}
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, provider, "runtime-explicit-model"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if provider.calls != 1 {
|
||||
t.Fatalf("provider.calls = %d, want 1", provider.calls)
|
||||
}
|
||||
if drafts[0].HumanSummary != "Prefer native-name path first" {
|
||||
t.Fatalf("HumanSummary = %q, want %q", drafts[0].HumanSummary, "Prefer native-name path first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvider(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
DraftGenerator: evolution.NewDraftGeneratorForWorkspace(root, nil, ""),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].TargetSkillName != "weather" {
|
||||
t.Fatalf("TargetSkillName = %q, want weather", drafts[0].TargetSkillName)
|
||||
}
|
||||
if drafts[0].BodyOrPatch == "" {
|
||||
t.Fatal("expected generated draft body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "skills", "weather"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
skillBody := "---\nname: weather\ndescription: workspace weather helper\n---\n# Weather\n## Start Here\nUse the workspace-specific path.\n"
|
||||
if err := os.WriteFile(filepath.Join(root, "skills", "weather", "SKILL.md"), []byte(skillBody), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
SuccessRate: 1,
|
||||
WinningPath: []string{"weather"},
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
provider := &llmDraftRuntimeProvider{
|
||||
response: &providers.LLMResponse{Content: `not-json`},
|
||||
defaultModel: "runtime-test-model",
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
|
||||
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, "runtime-explicit-model")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].ChangeKind != evolution.ChangeKindAppend {
|
||||
t.Fatalf("ChangeKind = %q, want %q", drafts[0].ChangeKind, evolution.ChangeKindAppend)
|
||||
}
|
||||
if !strings.Contains(drafts[0].BodyOrPatch, "## Learned Evolution") {
|
||||
t.Fatalf("BodyOrPatch = %q, want appended learned evolution section", drafts[0].BodyOrPatch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_PersistsEarlierDraftWhenLaterRuleFails(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rules := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
},
|
||||
{
|
||||
ID: "rule-2",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
Summary: "release path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
},
|
||||
}
|
||||
if err := store.AppendLearningRecords(rules); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
generator := &sequenceDraftGenerator{
|
||||
results: []draftGenerationResult{
|
||||
{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "prefer native-name path first",
|
||||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||||
},
|
||||
},
|
||||
{
|
||||
err: context.DeadlineExceeded,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
DraftGenerator: generator,
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
err = rt.RunColdPathOnce(context.Background(), root)
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("RunColdPathOnce error = %v, want %v", err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
drafts, loadErr := store.LoadDrafts()
|
||||
if loadErr != nil {
|
||||
t.Fatalf("LoadDrafts: %v", loadErr)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("len(drafts) = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].SourceRecordID != "rule-1" {
|
||||
t.Fatalf("SourceRecordID = %q, want rule-1", drafts[0].SourceRecordID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := evolution.NewStore(evolution.NewPaths(root, ""))
|
||||
|
||||
rule := evolution.LearningRecord{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather native-name path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
EventCount: 4,
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{rule}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{{
|
||||
ID: "draft-old",
|
||||
WorkspaceID: root,
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "broken attempt",
|
||||
BodyOrPatch: "## Start Here\nBroken content.",
|
||||
Status: evolution.DraftStatusQuarantined,
|
||||
ScanFindings: []string{"apply failed"},
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveDrafts: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "review"},
|
||||
Store: store,
|
||||
DraftGenerator: stubDraftGenerator{
|
||||
draft: evolution.SkillDraft{
|
||||
ID: "draft-new",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "fixed attempt",
|
||||
BodyOrPatch: "## Start Here\nUse native-name query first.",
|
||||
},
|
||||
},
|
||||
SkillsRecaller: evolution.NewSkillsRecaller(root),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.RunColdPathOnce(context.Background(), root); err != nil {
|
||||
t.Fatalf("RunColdPathOnce: %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 {
|
||||
t.Fatalf("len(drafts) = %d, want 2", len(drafts))
|
||||
}
|
||||
if drafts[1].ID != "draft-new" {
|
||||
t.Fatalf("drafts[1].ID = %q, want draft-new", drafts[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
type llmDraftRuntimeProvider struct {
|
||||
response *providers.LLMResponse
|
||||
err error
|
||||
calls int
|
||||
defaultModel string
|
||||
}
|
||||
|
||||
func (p *llmDraftRuntimeProvider) Chat(
|
||||
_ context.Context,
|
||||
_ []providers.Message,
|
||||
_ []providers.ToolDefinition,
|
||||
_ string,
|
||||
_ map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.calls++
|
||||
return p.response, p.err
|
||||
}
|
||||
|
||||
func (p *llmDraftRuntimeProvider) GetDefaultModel() string {
|
||||
if p.defaultModel != "" {
|
||||
return p.defaultModel
|
||||
}
|
||||
return "runtime-test-model"
|
||||
}
|
||||
450
pkg/evolution/runtime_test.go
Normal file
450
pkg/evolution/runtime_test.go
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestRuntime_FinalizeTurnDisabledDoesNothing(t *testing.T) {
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: false, Mode: "observe"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
err = rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-1",
|
||||
Status: "completed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, "")
|
||||
if _, err := os.Stat(paths.LearningRecords); !os.IsNotExist(err) {
|
||||
t.Fatalf("learning records file should not exist, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) {
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
TurnID: "turn-1",
|
||||
Status: "completed",
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
override := filepath.Join(t.TempDir(), "custom-state")
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
StateDir: override,
|
||||
},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-1",
|
||||
SessionKey: "session-1",
|
||||
AgentID: "agent-1",
|
||||
Status: "completed",
|
||||
ToolKinds: []string{"web", "read_file"},
|
||||
ActiveSkillNames: []string{"skill-a"},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn first call: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
WorkspaceID: "ws-explicit",
|
||||
TurnID: "turn-2",
|
||||
SessionKey: "session-2",
|
||||
AgentID: "agent-2",
|
||||
Status: "error",
|
||||
ToolKinds: []string{"bash"},
|
||||
ActiveSkillNames: []string{"skill-b"},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn second call: %v", err)
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, override)
|
||||
data, err := os.ReadFile(paths.LearningRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("record file line count = %d, want 2", len(lines))
|
||||
}
|
||||
|
||||
var first evolution.LearningRecord
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("Unmarshal first record: %v", err)
|
||||
}
|
||||
if first.WorkspaceID != workspace {
|
||||
t.Fatalf("first WorkspaceID = %q, want %q", first.WorkspaceID, workspace)
|
||||
}
|
||||
if first.CreatedAt != now {
|
||||
t.Fatalf("first CreatedAt = %v, want %v", first.CreatedAt, now)
|
||||
}
|
||||
if first.SessionKey != "session-1" {
|
||||
t.Fatalf("first SessionKey = %q, want %q", first.SessionKey, "session-1")
|
||||
}
|
||||
if first.Summary != "turn turn-1 finished with status=completed" {
|
||||
t.Fatalf("first Summary = %q", first.Summary)
|
||||
}
|
||||
if first.Success == nil || !*first.Success {
|
||||
t.Fatalf("first Success = %v, want true", first.Success)
|
||||
}
|
||||
if len(first.ToolKinds) != 2 || first.ToolKinds[0] != "web" || first.ToolKinds[1] != "read_file" {
|
||||
t.Fatalf("first ToolKinds = %v", first.ToolKinds)
|
||||
}
|
||||
if len(first.ActiveSkillNames) != 1 || first.ActiveSkillNames[0] != "skill-a" {
|
||||
t.Fatalf("first ActiveSkillNames = %v", first.ActiveSkillNames)
|
||||
}
|
||||
if first.AttemptTrail == nil {
|
||||
t.Fatal("first AttemptTrail should not be nil")
|
||||
}
|
||||
if len(first.AttemptTrail.AttemptedSkills) != 1 || first.AttemptTrail.AttemptedSkills[0] != "skill-a" {
|
||||
t.Fatalf("first AttemptTrail.AttemptedSkills = %v, want [skill-a]", first.AttemptTrail.AttemptedSkills)
|
||||
}
|
||||
if len(first.AttemptTrail.FinalSuccessfulPath) != 1 || first.AttemptTrail.FinalSuccessfulPath[0] != "skill-a" {
|
||||
t.Fatalf(
|
||||
"first AttemptTrail.FinalSuccessfulPath = %v, want [skill-a]",
|
||||
first.AttemptTrail.FinalSuccessfulPath,
|
||||
)
|
||||
}
|
||||
if got := first.Source["turn_id"]; got != "turn-1" {
|
||||
t.Fatalf("first Source.turn_id = %v", got)
|
||||
}
|
||||
if got := first.Source["session_key"]; got != "session-1" {
|
||||
t.Fatalf("first Source.session_key = %v", got)
|
||||
}
|
||||
if got := first.Source["agent_id"]; got != "agent-1" {
|
||||
t.Fatalf("first Source.agent_id = %v", got)
|
||||
}
|
||||
if first.TaskHash == "" {
|
||||
t.Fatal("first TaskHash should not be empty")
|
||||
}
|
||||
if len(first.Signals) != 0 {
|
||||
t.Fatalf("first Signals = %v, want empty for single-skill success", first.Signals)
|
||||
}
|
||||
|
||||
var second evolution.LearningRecord
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("Unmarshal second record: %v", err)
|
||||
}
|
||||
if second.WorkspaceID != "ws-explicit" {
|
||||
t.Fatalf("second WorkspaceID = %q, want %q", second.WorkspaceID, "ws-explicit")
|
||||
}
|
||||
if second.SessionKey != "session-2" {
|
||||
t.Fatalf("second SessionKey = %q, want %q", second.SessionKey, "session-2")
|
||||
}
|
||||
if second.Success == nil || *second.Success {
|
||||
t.Fatalf("second Success = %v, want false", second.Success)
|
||||
}
|
||||
if second.AttemptTrail == nil {
|
||||
t.Fatal("second AttemptTrail should not be nil")
|
||||
}
|
||||
if len(second.AttemptTrail.AttemptedSkills) != 1 || second.AttemptTrail.AttemptedSkills[0] != "skill-b" {
|
||||
t.Fatalf("second AttemptTrail.AttemptedSkills = %v, want [skill-b]", second.AttemptTrail.AttemptedSkills)
|
||||
}
|
||||
if len(second.AttemptTrail.FinalSuccessfulPath) != 0 {
|
||||
t.Fatalf(
|
||||
"second AttemptTrail.FinalSuccessfulPath = %v, want empty for failed turn",
|
||||
second.AttemptTrail.FinalSuccessfulPath,
|
||||
)
|
||||
}
|
||||
if second.TaskHash == "" {
|
||||
t.Fatal("second TaskHash should not be empty")
|
||||
}
|
||||
if second.TaskHash == first.TaskHash {
|
||||
t.Fatal("TaskHash should differ for different skill/tool signatures")
|
||||
}
|
||||
if len(second.Signals) != 0 {
|
||||
t.Fatalf("second Signals = %v, want empty for failed turn", second.Signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
now := time.Unix(1700003000, 0).UTC()
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-learnable",
|
||||
SessionKey: "session-learnable",
|
||||
AgentID: "agent-1",
|
||||
Status: "completed",
|
||||
ToolKinds: []string{"web", "bash"},
|
||||
ActiveSkillNames: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
|
||||
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, "")
|
||||
data, err := os.ReadFile(paths.LearningRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("record file line count = %d, want 1", len(lines))
|
||||
}
|
||||
|
||||
var record evolution.LearningRecord
|
||||
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
||||
t.Fatalf("Unmarshal record: %v", err)
|
||||
}
|
||||
if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" {
|
||||
t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals)
|
||||
}
|
||||
if record.AttemptTrail == nil {
|
||||
t.Fatal("AttemptTrail should not be nil")
|
||||
}
|
||||
if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
|
||||
t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got)
|
||||
}
|
||||
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 {
|
||||
t.Fatalf("SkillContextSnapshots = %v, want 2 snapshots", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
now := time.Unix(1700003500, 0).UTC()
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-explicit-trail",
|
||||
SessionKey: "session-explicit-trail",
|
||||
AgentID: "agent-1",
|
||||
Status: "completed",
|
||||
ToolKinds: []string{"web"},
|
||||
ActiveSkillNames: []string{"weather"},
|
||||
AttemptedSkillNames: []string{"geocode", "weather"},
|
||||
FinalSuccessfulPath: []string{"geocode", "weather"},
|
||||
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
||||
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"weather"}},
|
||||
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
paths := evolution.NewPaths(workspace, "")
|
||||
data, err := os.ReadFile(paths.LearningRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Fatalf("record file line count = %d, want 1", len(lines))
|
||||
}
|
||||
|
||||
var record evolution.LearningRecord
|
||||
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
||||
t.Fatalf("Unmarshal record: %v", err)
|
||||
}
|
||||
if record.AttemptTrail == nil {
|
||||
t.Fatal("AttemptTrail should not be nil")
|
||||
}
|
||||
if got := record.AttemptTrail.AttemptedSkills; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
|
||||
t.Fatalf("AttemptedSkills = %v, want [geocode weather]", got)
|
||||
}
|
||||
if got := record.AttemptTrail.FinalSuccessfulPath; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
|
||||
t.Fatalf("FinalSuccessfulPath = %v, want [geocode weather]", got)
|
||||
}
|
||||
if got := record.AttemptTrail.SkillContextSnapshots; len(got) != 2 || got[1].Trigger != "context_retry_rebuild" {
|
||||
t.Fatalf("SkillContextSnapshots = %+v, want explicit snapshots preserved", got)
|
||||
}
|
||||
if len(record.Signals) != 1 || record.Signals[0] != "potentially_learnable" {
|
||||
t.Fatalf("Signals = %v, want [potentially_learnable]", record.Signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnUpdatesSkillProfileUsage(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
now := time.Unix(1700000000, 0).UTC()
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{
|
||||
Enabled: true,
|
||||
Mode: "observe",
|
||||
},
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-1",
|
||||
SessionKey: "session-1",
|
||||
AgentID: "agent-1",
|
||||
Status: "completed",
|
||||
ActiveSkillNames: []string{"skill-a", "skill-a"},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
profile, err := store.LoadProfile("skill-a")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.Origin != "manual" {
|
||||
t.Fatalf("Origin = %q, want manual", profile.Origin)
|
||||
}
|
||||
if profile.UseCount != 1 {
|
||||
t.Fatalf("UseCount = %d, want 1", profile.UseCount)
|
||||
}
|
||||
if profile.LastUsedAt != now {
|
||||
t.Fatalf("LastUsedAt = %v, want %v", profile.LastUsedAt, now)
|
||||
}
|
||||
if profile.RetentionScore <= 0.2 {
|
||||
t.Fatalf("RetentionScore = %v, want > 0.2", profile.RetentionScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnReactivatesColdSkill(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
now := time.Unix(1700001000, 0).UTC()
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "skill-cold",
|
||||
WorkspaceID: workspace,
|
||||
Status: evolution.SkillStatusCold,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "cold skill",
|
||||
LastUsedAt: now.Add(-24 * time.Hour),
|
||||
UseCount: 2,
|
||||
RetentionScore: 0.2,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
||||
Now: func() time.Time { return now },
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-cold",
|
||||
Status: "completed",
|
||||
ActiveSkillNames: []string{"skill-cold"},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("skill-cold")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntime_FinalizeTurnReactivatesArchivedSkill(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
now := time.Unix(1700002000, 0).UTC()
|
||||
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
||||
|
||||
if err := store.SaveProfile(evolution.SkillProfile{
|
||||
SkillName: "skill-archived",
|
||||
WorkspaceID: workspace,
|
||||
Status: evolution.SkillStatusArchived,
|
||||
Origin: "evolved",
|
||||
HumanSummary: "archived skill",
|
||||
LastUsedAt: now.Add(-48 * time.Hour),
|
||||
UseCount: 5,
|
||||
RetentionScore: 0.1,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfile: %v", err)
|
||||
}
|
||||
|
||||
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
||||
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
||||
Now: func() time.Time { return now },
|
||||
Store: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
|
||||
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
||||
Workspace: workspace,
|
||||
TurnID: "turn-archived",
|
||||
Status: "completed",
|
||||
ActiveSkillNames: []string{"skill-archived"},
|
||||
}); err != nil {
|
||||
t.Fatalf("FinalizeTurn: %v", err)
|
||||
}
|
||||
|
||||
profile, err := store.LoadProfile("skill-archived")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfile: %v", err)
|
||||
}
|
||||
if profile.Status != evolution.SkillStatusActive {
|
||||
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive)
|
||||
}
|
||||
}
|
||||
179
pkg/evolution/skills_recall.go
Normal file
179
pkg/evolution/skills_recall.go
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type SkillsRecaller struct {
|
||||
workspace string
|
||||
loader *skills.SkillsLoader
|
||||
}
|
||||
|
||||
func NewSkillsRecaller(workspace string) *SkillsRecaller {
|
||||
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
||||
if builtinSkillsDir == "" {
|
||||
wd, _ := os.Getwd()
|
||||
builtinSkillsDir = filepath.Join(wd, "skills")
|
||||
}
|
||||
|
||||
globalSkillsDir := filepath.Join(config.GetHome(), "skills")
|
||||
return &SkillsRecaller{
|
||||
workspace: workspace,
|
||||
loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SkillsRecaller) RecallSimilarSkills(rule LearningRecord) ([]skills.SkillInfo, error) {
|
||||
if r == nil || r.loader == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
all := r.loader.ListSkills()
|
||||
type scored struct {
|
||||
info skills.SkillInfo
|
||||
score int
|
||||
sourceRank int
|
||||
}
|
||||
|
||||
scoredList := make([]scored, 0, len(all))
|
||||
for _, skill := range all {
|
||||
score := scoreSkillMatch(rule, skill)
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if body, ok := r.loader.LoadSkill(skill.Name); ok {
|
||||
score += scoreSkillBody(rule, body)
|
||||
}
|
||||
|
||||
scoredList = append(scoredList, scored{
|
||||
info: skill,
|
||||
score: score,
|
||||
sourceRank: skillSourceRank(skill.Source),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(scoredList, func(i, j int) bool {
|
||||
if scoredList[i].score != scoredList[j].score {
|
||||
return scoredList[i].score > scoredList[j].score
|
||||
}
|
||||
if scoredList[i].sourceRank != scoredList[j].sourceRank {
|
||||
return scoredList[i].sourceRank < scoredList[j].sourceRank
|
||||
}
|
||||
return scoredList[i].info.Name < scoredList[j].info.Name
|
||||
})
|
||||
|
||||
out := make([]skills.SkillInfo, 0, len(scoredList))
|
||||
for _, item := range scoredList {
|
||||
out = append(out, item.info)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scoreSkillMatch(rule LearningRecord, skill skills.SkillInfo) int {
|
||||
score := 0
|
||||
skillName := strings.ToLower(strings.TrimSpace(skill.Name))
|
||||
ruleSummary := strings.ToLower(rule.Summary)
|
||||
|
||||
if skillName != "" {
|
||||
if containsNormalized(rule.WinningPath, skillName) {
|
||||
score += 8
|
||||
}
|
||||
if containsNormalized(rule.MatchedSkillNames, skillName) {
|
||||
score += 6
|
||||
}
|
||||
if strings.Contains(ruleSummary, skillName) {
|
||||
score += 4
|
||||
}
|
||||
}
|
||||
|
||||
score += 2 * tokenOverlap(ruleTokens(rule), tokenizeForEvolution(skill.Name+" "+skill.Description))
|
||||
return score
|
||||
}
|
||||
|
||||
func scoreSkillBody(rule LearningRecord, body string) int {
|
||||
return minInt(tokenOverlap(ruleTokens(rule), tokenizeForEvolution(body)), 3)
|
||||
}
|
||||
|
||||
func skillSourceRank(source string) int {
|
||||
switch source {
|
||||
case "workspace":
|
||||
return 0
|
||||
case "global":
|
||||
return 1
|
||||
case "builtin":
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func ruleTokens(rule LearningRecord) []string {
|
||||
parts := make([]string, 0, len(rule.WinningPath)+len(rule.MatchedSkillNames)+4)
|
||||
parts = append(parts, normalizePath(rule.WinningPath)...)
|
||||
parts = append(parts, normalizePath(rule.MatchedSkillNames)...)
|
||||
parts = append(parts, tokenizeForEvolution(rule.Summary)...)
|
||||
return parts
|
||||
}
|
||||
|
||||
func containsNormalized(values []string, target string) bool {
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
for _, value := range values {
|
||||
if strings.ToLower(strings.TrimSpace(value)) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tokenOverlap(left, right []string) int {
|
||||
if len(left) == 0 || len(right) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
leftSet := make(map[string]struct{}, len(left))
|
||||
for _, token := range left {
|
||||
leftSet[token] = struct{}{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(right))
|
||||
count := 0
|
||||
for _, token := range right {
|
||||
if _, ok := seen[token]; ok {
|
||||
continue
|
||||
}
|
||||
seen[token] = struct{}{}
|
||||
if _, ok := leftSet[token]; ok {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func tokenizeForEvolution(text string) []string {
|
||||
fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9')
|
||||
})
|
||||
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, field)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
49
pkg/evolution/skills_recall_test.go
Normal file
49
pkg/evolution/skills_recall_test.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestRecallSimilarSkills_ReturnsWorkspaceSkillFirst(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalHome := t.TempDir()
|
||||
builtinRoot := t.TempDir()
|
||||
|
||||
t.Setenv("HOME", globalHome)
|
||||
t.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot)
|
||||
|
||||
mustWriteSkill := func(root, name, content string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(%s): %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustWriteSkill(filepath.Join(workspace, "skills"), "weather", "---\nname: weather\ndescription: weather lookup\n---\n# Weather\nUse weather queries.\n")
|
||||
mustWriteSkill(filepath.Join(globalHome, ".picoclaw", "skills"), "release", "---\nname: release\ndescription: release flow\n---\n# Release\nRelease build.\n")
|
||||
mustWriteSkill(builtinRoot, "weather-fallback", "---\nname: weather-fallback\ndescription: weather backup\n---\n# Weather Fallback\nBackup weather path.\n")
|
||||
|
||||
recaller := evolution.NewSkillsRecaller(workspace)
|
||||
matches, err := recaller.RecallSimilarSkills(evolution.LearningRecord{
|
||||
Kind: evolution.RecordKindRule,
|
||||
Summary: "weather native-name path",
|
||||
EventCount: 4,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RecallSimilarSkills: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected at least one match")
|
||||
}
|
||||
if matches[0].Name != "weather" {
|
||||
t.Fatalf("first match = %q, want weather", matches[0].Name)
|
||||
}
|
||||
}
|
||||
384
pkg/evolution/store.go
Normal file
384
pkg/evolution/store.go
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
package evolution
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
paths Paths
|
||||
}
|
||||
|
||||
func NewStore(paths Paths) *Store {
|
||||
return &Store{paths: paths}
|
||||
}
|
||||
|
||||
var storeFileLocks sync.Map
|
||||
|
||||
func (s *Store) AppendLearningRecord(ctx context.Context, record LearningRecord) error {
|
||||
return s.appendLearningRecords(ctx, []LearningRecord{record})
|
||||
}
|
||||
|
||||
func (s *Store) AppendLearningRecords(records []LearningRecord) error {
|
||||
return s.appendLearningRecords(context.Background(), records)
|
||||
}
|
||||
|
||||
func (s *Store) appendLearningRecords(ctx context.Context, records []LearningRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
unlock := lockStoreFile(s.paths.LearningRecords)
|
||||
defer unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.paths.LearningRecords), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(s.paths.LearningRecords, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := json.NewEncoder(f)
|
||||
for _, record := range records {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
if err := enc.Encode(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) LoadLearningRecords() ([]LearningRecord, error) {
|
||||
var records []LearningRecord
|
||||
if err := decodeJSONLLines(s.paths.LearningRecords, func(line []byte) error {
|
||||
var record LearningRecord
|
||||
if err := json.Unmarshal(line, &record); err != nil {
|
||||
return err
|
||||
}
|
||||
records = append(records, record)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveDrafts(drafts []SkillDraft) error {
|
||||
unlock := lockStoreFile(s.paths.SkillDrafts)
|
||||
defer unlock()
|
||||
|
||||
existing, err := s.LoadDrafts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexByKey := make(map[string]int, len(existing))
|
||||
for i, draft := range existing {
|
||||
indexByKey[draftKey(draft.WorkspaceID, draft.ID)] = i
|
||||
}
|
||||
|
||||
for _, draft := range drafts {
|
||||
key := draftKey(draft.WorkspaceID, draft.ID)
|
||||
if idx, ok := indexByKey[key]; ok {
|
||||
existing[idx] = draft
|
||||
continue
|
||||
}
|
||||
indexByKey[key] = len(existing)
|
||||
existing = append(existing, draft)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(existing, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteFileAtomic(s.paths.SkillDrafts, data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) LoadDrafts() ([]SkillDraft, error) {
|
||||
data, err := os.ReadFile(s.paths.SkillDrafts)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bytes.TrimSpace(data)) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var drafts []SkillDraft
|
||||
if err := json.Unmarshal(data, &drafts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return drafts, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveProfile(profile SkillProfile) error {
|
||||
path, err := s.profilePath(profile.WorkspaceID, profile.SkillName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unlock := lockStoreFile(path)
|
||||
defer unlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(profile, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteFileAtomic(path, data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) LoadProfile(skillName string) (SkillProfile, error) {
|
||||
paths, err := s.profileLookupPaths(skillName)
|
||||
if err != nil {
|
||||
return SkillProfile{}, err
|
||||
}
|
||||
for _, path := range paths {
|
||||
profile, loadErr := s.loadProfileFromPath(path)
|
||||
if errors.Is(loadErr, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if loadErr != nil {
|
||||
return SkillProfile{}, loadErr
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
return SkillProfile{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
func (s *Store) LoadProfiles() ([]SkillProfile, error) {
|
||||
entries, err := os.ReadDir(s.paths.ProfilesDir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles := make([]SkillProfile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(s.paths.ProfilesDir, entry.Name())
|
||||
if entry.IsDir() {
|
||||
nestedProfiles, loadErr := s.loadProfilesFromDir(entryPath)
|
||||
if loadErr != nil {
|
||||
return nil, loadErr
|
||||
}
|
||||
profiles = append(profiles, nestedProfiles...)
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
profile, err := s.loadProfileFromPath(entryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
|
||||
sort.Slice(profiles, func(i, j int) bool {
|
||||
if profiles[i].SkillName != profiles[j].SkillName {
|
||||
return profiles[i].SkillName < profiles[j].SkillName
|
||||
}
|
||||
return profiles[i].WorkspaceID < profiles[j].WorkspaceID
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func decodeJSONLLines(path string, decode func(line []byte) error) error {
|
||||
f, err := os.Open(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
var lines [][]byte
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, append([]byte(nil), line...))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, line := range lines {
|
||||
if err := decode(line); err != nil {
|
||||
if i == len(lines)-1 && isInvalidJSON(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func draftKey(workspaceID, id string) string {
|
||||
return workspaceID + "\x00" + id
|
||||
}
|
||||
|
||||
func isInvalidJSON(err error) bool {
|
||||
var syntaxErr *json.SyntaxError
|
||||
return errors.As(err, &syntaxErr)
|
||||
}
|
||||
|
||||
func lockStoreFile(path string) func() {
|
||||
actual, _ := storeFileLocks.LoadOrStore(path, &sync.Mutex{})
|
||||
mu := actual.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
func (s *Store) profilePath(workspaceID, skillName string) (string, error) {
|
||||
if err := skills.ValidateSkillName(skillName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
workspaceID = strings.TrimSpace(workspaceID)
|
||||
if workspaceID == "" {
|
||||
return filepath.Join(s.paths.ProfilesDir, skillName+".json"), nil
|
||||
}
|
||||
return filepath.Join(s.paths.ProfilesDir, workspaceScopeDir(workspaceID), skillName+".json"), nil
|
||||
}
|
||||
|
||||
func (s *Store) loadProfilesFromDir(dir string) ([]SkillProfile, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profiles := make([]SkillProfile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
profile, err := s.loadProfileFromPath(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func (s *Store) loadProfileFromPath(path string) (SkillProfile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return SkillProfile{}, err
|
||||
}
|
||||
|
||||
var profile SkillProfile
|
||||
if err := json.Unmarshal(data, &profile); err != nil {
|
||||
return SkillProfile{}, err
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *Store) profileLookupPaths(skillName string) ([]string, error) {
|
||||
if err := skills.ValidateSkillName(skillName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
paths := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
appendPath := func(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[path]; ok {
|
||||
return
|
||||
}
|
||||
paths = append(paths, path)
|
||||
seen[path] = struct{}{}
|
||||
}
|
||||
|
||||
if workspaceID := strings.TrimSpace(s.paths.Workspace); workspaceID != "" {
|
||||
path, err := s.profilePath(workspaceID, skillName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendPath(path)
|
||||
}
|
||||
|
||||
legacyPath, err := s.profilePath("", skillName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendPath(legacyPath)
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(s.paths.ProfilesDir, "*", skillName+".json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches)
|
||||
for _, match := range matches {
|
||||
appendPath(match)
|
||||
}
|
||||
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func workspaceScopeDir(workspaceID string) string {
|
||||
sum := sha1.Sum([]byte(workspaceID))
|
||||
base := filepath.Base(filepath.Clean(workspaceID))
|
||||
base = sanitizeWorkspaceComponent(base)
|
||||
if base == "" || base == "." {
|
||||
base = "workspace"
|
||||
}
|
||||
return base + "-" + hex.EncodeToString(sum[:6])
|
||||
}
|
||||
|
||||
func sanitizeWorkspaceComponent(value string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= 'A' && r <= 'Z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_' || r == '.':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
178
pkg/evolution/store_test.go
Normal file
178
pkg/evolution/store_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package evolution_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/evolution"
|
||||
)
|
||||
|
||||
func TestStore_AppendLearningRecordsPersistsCaseAndRule(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
records := []evolution.LearningRecord{
|
||||
{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather task completed",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
},
|
||||
{
|
||||
ID: "rule-1",
|
||||
Kind: evolution.RecordKindRule,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000100, 0).UTC(),
|
||||
Summary: "prefer native-name weather path",
|
||||
Status: evolution.RecordStatus("ready"),
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.AppendLearningRecords(records); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadLearningRecords()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLearningRecords: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 {
|
||||
t.Fatalf("len(loaded) = %d, want 2", len(loaded))
|
||||
}
|
||||
if loaded[1].Kind != evolution.RecordKindRule {
|
||||
t.Fatalf("loaded[1].Kind = %q, want %q", loaded[1].Kind, evolution.RecordKindRule)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveDraftsOverwritesByID(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
first := evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "prefer native-name path first",
|
||||
BodyOrPatch: "## Start Here",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
}
|
||||
second := first
|
||||
second.HumanSummary = "updated summary"
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil {
|
||||
t.Fatalf("SaveDrafts(first): %v", err)
|
||||
}
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil {
|
||||
t.Fatalf("SaveDrafts(second): %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 {
|
||||
t.Fatalf("len(loaded) = %d, want 1", len(loaded))
|
||||
}
|
||||
if loaded[0].HumanSummary != "updated summary" {
|
||||
t.Fatalf("HumanSummary = %q, want %q", loaded[0].HumanSummary, "updated summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_SaveDraftsKeepsSameIDDifferentWorkspace(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
first := evolution.SkillDraft{
|
||||
ID: "draft-1",
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
SourceRecordID: "rule-1",
|
||||
TargetSkillName: "weather",
|
||||
DraftType: evolution.DraftTypeShortcut,
|
||||
ChangeKind: evolution.ChangeKindAppend,
|
||||
HumanSummary: "workspace one",
|
||||
BodyOrPatch: "## Start Here",
|
||||
Status: evolution.DraftStatusCandidate,
|
||||
}
|
||||
second := first
|
||||
second.WorkspaceID = "ws-2"
|
||||
second.HumanSummary = "workspace two"
|
||||
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{first}); err != nil {
|
||||
t.Fatalf("SaveDrafts(first): %v", err)
|
||||
}
|
||||
if err := store.SaveDrafts([]evolution.SkillDraft{second}); err != nil {
|
||||
t.Fatalf("SaveDrafts(second): %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadDrafts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDrafts: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 {
|
||||
t.Fatalf("len(loaded) = %d, want 2", len(loaded))
|
||||
}
|
||||
if loaded[0].WorkspaceID == loaded[1].WorkspaceID {
|
||||
t.Fatalf("loaded drafts should keep distinct workspace IDs: %+v", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStore_LoadLearningRecordsIgnoresTruncatedTrailingLine(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
paths := evolution.NewPaths(root, "")
|
||||
store := evolution.NewStore(paths)
|
||||
|
||||
record := evolution.LearningRecord{
|
||||
ID: "case-1",
|
||||
Kind: evolution.RecordKindCase,
|
||||
WorkspaceID: "ws-1",
|
||||
CreatedAt: time.Unix(1700000000, 0).UTC(),
|
||||
Summary: "weather task completed",
|
||||
Status: evolution.RecordStatus("new"),
|
||||
}
|
||||
if err := store.AppendLearningRecords([]evolution.LearningRecord{record}); err != nil {
|
||||
t.Fatalf("AppendLearningRecords: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(paths.LearningRecords, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenFile: %v", err)
|
||||
}
|
||||
if _, err := f.WriteString("{\"id\":\"broken\""); err != nil {
|
||||
f.Close()
|
||||
t.Fatalf("WriteString: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.LoadLearningRecords()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLearningRecords: %v", err)
|
||||
}
|
||||
if len(loaded) != 1 {
|
||||
t.Fatalf("len(loaded) = %d, want 1", len(loaded))
|
||||
}
|
||||
if loaded[0].ID != "case-1" {
|
||||
t.Fatalf("loaded[0].ID = %q, want %q", loaded[0].ID, "case-1")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(paths.LearningRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "\"broken\"") {
|
||||
t.Fatalf("expected test fixture to include broken trailing line")
|
||||
}
|
||||
}
|
||||
136
pkg/evolution/types.go
Normal file
136
pkg/evolution/types.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package evolution
|
||||
|
||||
import "time"
|
||||
|
||||
type RecordKind string
|
||||
|
||||
const (
|
||||
RecordKindTask RecordKind = "task"
|
||||
RecordKindPattern RecordKind = "pattern"
|
||||
legacyRecordKindCase RecordKind = "case"
|
||||
legacyRecordKindRule RecordKind = "rule"
|
||||
// Deprecated: use RecordKindTask.
|
||||
RecordKindCase = RecordKindTask
|
||||
// Deprecated: use RecordKindPattern.
|
||||
RecordKindRule = RecordKindPattern
|
||||
)
|
||||
|
||||
type RecordStatus string
|
||||
|
||||
type DraftType string
|
||||
|
||||
const (
|
||||
DraftTypeWorkflow DraftType = "workflow"
|
||||
DraftTypeShortcut DraftType = "shortcut"
|
||||
)
|
||||
|
||||
type ChangeKind string
|
||||
|
||||
const (
|
||||
ChangeKindCreate ChangeKind = "create"
|
||||
ChangeKindAppend ChangeKind = "append"
|
||||
ChangeKindReplace ChangeKind = "replace"
|
||||
ChangeKindMerge ChangeKind = "merge"
|
||||
)
|
||||
|
||||
type DraftStatus string
|
||||
|
||||
const (
|
||||
DraftStatusCandidate DraftStatus = "candidate"
|
||||
DraftStatusQuarantined DraftStatus = "quarantined"
|
||||
DraftStatusAccepted DraftStatus = "accepted"
|
||||
)
|
||||
|
||||
type SkillStatus string
|
||||
|
||||
const (
|
||||
SkillStatusActive SkillStatus = "active"
|
||||
SkillStatusCold SkillStatus = "cold"
|
||||
SkillStatusArchived SkillStatus = "archived"
|
||||
SkillStatusDeleted SkillStatus = "deleted"
|
||||
)
|
||||
|
||||
type AttemptTrail struct {
|
||||
AttemptedSkills []string `json:"attempted_skills,omitempty"`
|
||||
FinalSuccessfulPath []string `json:"final_successful_path,omitempty"`
|
||||
SkillContextSnapshots []SkillContextSnapshot `json:"skill_context_snapshots,omitempty"`
|
||||
}
|
||||
|
||||
type SkillContextSnapshot struct {
|
||||
Sequence int `json:"sequence"`
|
||||
Trigger string `json:"trigger"`
|
||||
SkillNames []string `json:"skill_names,omitempty"`
|
||||
}
|
||||
|
||||
type LearningRecord struct {
|
||||
ID string `json:"id"`
|
||||
Kind RecordKind `json:"kind"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SessionKey string `json:"session_key,omitempty"`
|
||||
TaskHash string `json:"task_hash,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Source map[string]any `json:"source,omitempty"`
|
||||
Status RecordStatus `json:"status"`
|
||||
Success *bool `json:"success,omitempty"`
|
||||
ToolKinds []string `json:"tool_kinds,omitempty"`
|
||||
ActiveSkillNames []string `json:"active_skill_names,omitempty"`
|
||||
AttemptTrail *AttemptTrail `json:"attempt_trail,omitempty"`
|
||||
Signals []string `json:"signals,omitempty"`
|
||||
SourceRecordIDs []string `json:"source_record_ids,omitempty"`
|
||||
EventCount int `json:"event_count,omitempty"`
|
||||
SuccessRate float64 `json:"success_rate,omitempty"`
|
||||
MaturityScore float64 `json:"maturity_score,omitempty"`
|
||||
WinningPath []string `json:"winning_path,omitempty"`
|
||||
LateAddedSkills []string `json:"late_added_skills,omitempty"`
|
||||
FinalSnapshotTrigger string `json:"final_snapshot_trigger,omitempty"`
|
||||
MatchedSkillNames []string `json:"matched_skill_names,omitempty"`
|
||||
}
|
||||
|
||||
type SkillDraft struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||
SourceRecordID string `json:"source_record_id"`
|
||||
TargetSkillName string `json:"target_skill_name"`
|
||||
MatchedSkillRefs []string `json:"matched_skill_refs,omitempty"`
|
||||
DraftType DraftType `json:"draft_type"`
|
||||
ChangeKind ChangeKind `json:"change_kind"`
|
||||
HumanSummary string `json:"human_summary"`
|
||||
IntendedUseCases []string `json:"intended_use_cases,omitempty"`
|
||||
PreferredEntryPath []string `json:"preferred_entry_path,omitempty"`
|
||||
AvoidPatterns []string `json:"avoid_patterns,omitempty"`
|
||||
BodyOrPatch string `json:"body_or_patch"`
|
||||
Status DraftStatus `json:"status"`
|
||||
ReviewNotes []string `json:"review_notes,omitempty"`
|
||||
ScanFindings []string `json:"scan_findings,omitempty"`
|
||||
}
|
||||
|
||||
type SkillVersionEntry struct {
|
||||
Version string `json:"version"`
|
||||
Action string `json:"action"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
DraftID string `json:"draft_id,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
Rollback bool `json:"rollback,omitempty"`
|
||||
RollbackReason string `json:"rollback_reason,omitempty"`
|
||||
}
|
||||
|
||||
type SkillProfile struct {
|
||||
SkillName string `json:"skill_name"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
CurrentVersion string `json:"current_version"`
|
||||
Status SkillStatus `json:"status"`
|
||||
Origin string `json:"origin"`
|
||||
HumanSummary string `json:"human_summary"`
|
||||
ChangeReason string `json:"change_reason,omitempty"`
|
||||
IntendedUseCases []string `json:"intended_use_cases,omitempty"`
|
||||
PreferredEntryPath []string `json:"preferred_entry_path,omitempty"`
|
||||
AvoidPatterns []string `json:"avoid_patterns,omitempty"`
|
||||
LastUsedAt time.Time `json:"last_used_at"`
|
||||
UseCount int `json:"use_count"`
|
||||
RetentionScore float64 `json:"retention_score"`
|
||||
VersionHistory []SkillVersionEntry `json:"version_history"`
|
||||
}
|
||||
|
|
@ -332,7 +332,11 @@ func createStartupProvider(
|
|||
return &startupBlockedProvider{reason: reason}, "", nil
|
||||
}
|
||||
|
||||
return providers.CreateProvider(cfg)
|
||||
provider, modelID, err := providers.CreateProvider(cfg)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return providers.WithDefaultModel(provider, modelID), modelID, nil
|
||||
}
|
||||
|
||||
func setupAndStartServices(
|
||||
|
|
|
|||
70
pkg/providers/fixed_model_provider.go
Normal file
70
pkg/providers/fixed_model_provider.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type fixedModelProvider struct {
|
||||
inner LLMProvider
|
||||
model string
|
||||
}
|
||||
|
||||
func WithDefaultModel(provider LLMProvider, model string) LLMProvider {
|
||||
model = strings.TrimSpace(model)
|
||||
if provider == nil || model == "" {
|
||||
return provider
|
||||
}
|
||||
return &fixedModelProvider{
|
||||
inner: provider,
|
||||
model: model,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
) (*LLMResponse, error) {
|
||||
return p.inner.Chat(ctx, messages, tools, model, options)
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) GetDefaultModel() string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return p.model
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) Close() {
|
||||
if inner, ok := p.inner.(StatefulProvider); ok {
|
||||
inner.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) ChatStream(
|
||||
ctx context.Context,
|
||||
messages []Message,
|
||||
tools []ToolDefinition,
|
||||
model string,
|
||||
options map[string]any,
|
||||
onChunk func(accumulated string),
|
||||
) (*LLMResponse, error) {
|
||||
streaming, ok := p.inner.(StreamingProvider)
|
||||
if !ok {
|
||||
return p.inner.Chat(ctx, messages, tools, model, options)
|
||||
}
|
||||
return streaming.ChatStream(ctx, messages, tools, model, options, onChunk)
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) SupportsThinking() bool {
|
||||
thinking, ok := p.inner.(ThinkingCapable)
|
||||
return ok && thinking.SupportsThinking()
|
||||
}
|
||||
|
||||
func (p *fixedModelProvider) SupportsNativeSearch() bool {
|
||||
nativeSearch, ok := p.inner.(NativeSearchCapable)
|
||||
return ok && nativeSearch.SupportsNativeSearch()
|
||||
}
|
||||
|
|
@ -42,11 +42,8 @@ func (info SkillInfo) validate() error {
|
|||
if info.Name == "" {
|
||||
errs = errors.Join(errs, errors.New("name is required"))
|
||||
} else {
|
||||
if len(info.Name) > MaxNameLength {
|
||||
errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
|
||||
}
|
||||
if !namePattern.MatchString(info.Name) {
|
||||
errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens"))
|
||||
if err := ValidateSkillName(info.Name); err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +145,10 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
|
|||
}
|
||||
|
||||
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
|
||||
if err := ValidateSkillName(name); err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 1. load from workspace skills first (project-level)
|
||||
if sl.workspaceSkills != "" {
|
||||
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
|
||||
|
|
|
|||
29
pkg/skills/validation.go
Normal file
29
pkg/skills/validation.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
func ValidateSkillName(name string) error {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("skill name is required")
|
||||
}
|
||||
if filepath.IsAbs(trimmed) {
|
||||
return fmt.Errorf("skill name must not be an absolute path")
|
||||
}
|
||||
if err := utils.ValidateSkillIdentifier(trimmed); err != nil {
|
||||
return fmt.Errorf("skill name is invalid: %w", err)
|
||||
}
|
||||
if len(trimmed) > MaxNameLength {
|
||||
return fmt.Errorf("skill name exceeds %d characters", MaxNameLength)
|
||||
}
|
||||
if !namePattern.MatchString(trimmed) {
|
||||
return fmt.Errorf("skill name must be alphanumeric with hyphens")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue