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