This commit is contained in:
lxowalle 2026-05-11 11:42:03 +08:00
parent 83de57c3b8
commit 07be3462df
25 changed files with 815 additions and 495 deletions

View file

@ -34,7 +34,11 @@ type evolutionBridge struct {
const evolutionDirectDeliveryAttr = "evolution_direct_delivery" const evolutionDirectDeliveryAttr = "evolution_direct_delivery"
func newEvolutionBridge(registry *AgentRegistry, cfg *config.Config, provider providers.LLMProvider) (*evolutionBridge, error) { func newEvolutionBridge(
registry *AgentRegistry,
cfg *config.Config,
provider providers.LLMProvider,
) (*evolutionBridge, error) {
if cfg == nil { if cfg == nil {
return nil, nil return nil, nil
} }

View file

@ -355,7 +355,13 @@ func TestEvolutionBridge_ObserveTurnEndPayloadIncludesResolvedAttemptTrail(t *te
sub := al.SubscribeEvents(16) sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID) defer al.UnsubscribeEvents(sub.ID)
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-attempt-trail", "cli", "direct") resp, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-observe-attempt-trail",
"cli",
"direct",
)
if err != nil { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -412,7 +418,13 @@ func TestEvolutionBridge_ObserveTurnEndUsesLatestSkillSnapshotAfterRetry(t *test
sub := al.SubscribeEvents(16) sub := al.SubscribeEvents(16)
defer al.UnsubscribeEvents(sub.ID) defer al.UnsubscribeEvents(sub.ID)
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-observe-retry-snapshot", "cli", "direct") resp, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-observe-retry-snapshot",
"cli",
"direct",
)
if err != nil { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -437,12 +449,21 @@ func TestEvolutionBridge_ObserveTurnEndUsesLatestSkillSnapshotAfterRetry(t *test
t.Fatalf("len(SkillContextSnapshots) = %d, want 2", len(got)) t.Fatalf("len(SkillContextSnapshots) = %d, want 2", len(got))
} }
if turnEndPayload.SkillContextSnapshots[0].Trigger != skillContextTriggerInitialBuild { if turnEndPayload.SkillContextSnapshots[0].Trigger != skillContextTriggerInitialBuild {
t.Fatalf("SkillContextSnapshots[0].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[0].Trigger, skillContextTriggerInitialBuild) t.Fatalf(
"SkillContextSnapshots[0].Trigger = %q, want %q",
turnEndPayload.SkillContextSnapshots[0].Trigger,
skillContextTriggerInitialBuild,
)
} }
if turnEndPayload.SkillContextSnapshots[1].Trigger != skillContextTriggerContextRetryRebuild { if turnEndPayload.SkillContextSnapshots[1].Trigger != skillContextTriggerContextRetryRebuild {
t.Fatalf("SkillContextSnapshots[1].Trigger = %q, want %q", turnEndPayload.SkillContextSnapshots[1].Trigger, skillContextTriggerContextRetryRebuild) t.Fatalf(
"SkillContextSnapshots[1].Trigger = %q, want %q",
turnEndPayload.SkillContextSnapshots[1].Trigger,
skillContextTriggerContextRetryRebuild,
)
} }
if got := turnEndPayload.SkillContextSnapshots[1].SkillNames; len(got) != 2 || got[0] != "base-skill" || got[1] != "late-skill" { if got := turnEndPayload.SkillContextSnapshots[1].SkillNames; len(got) != 2 || got[0] != "base-skill" ||
got[1] != "late-skill" {
t.Fatalf("SkillContextSnapshots[1].SkillNames = %v, want [base-skill late-skill]", got) t.Fatalf("SkillContextSnapshots[1].SkillNames = %v, want [base-skill late-skill]", got)
} }
} }
@ -500,7 +521,13 @@ func TestEvolutionBridge_ScheduledModeDoesNotRunColdPathAfterTurn(t *testing.T)
}, &simpleMockProvider{response: "ok"}) }, &simpleMockProvider{response: "ok"})
defer al.Close() defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-scheduled-cold-path", "cli", "direct") resp, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-scheduled-cold-path",
"cli",
"direct",
)
if err != nil { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -525,7 +552,13 @@ func TestEvolutionBridge_DraftModeUsesProviderBackedDraftGenerator(t *testing.T)
}) })
defer al.Close() defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-llm", "cli", "direct") resp, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-auto-cold-path-llm",
"cli",
"direct",
)
if err != nil { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -567,7 +600,13 @@ func TestEvolutionBridge_DraftModeUsesProviderDefaultModel(t *testing.T) {
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
defer al.Close() defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-model", "cli", "direct"); err != nil { if _, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-auto-cold-path-model",
"cli",
"direct",
); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -606,7 +645,13 @@ func TestEvolutionBridge_DraftModePrefersConfigDefaultModelName(t *testing.T) {
al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) al := NewAgentLoop(cfg, bus.NewMessageBus(), provider)
defer al.Close() defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-auto-cold-path-model-config", "cli", "direct"); err != nil { if _, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-auto-cold-path-model-config",
"cli",
"direct",
); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -629,7 +674,13 @@ func TestEvolutionBridge_DraftModeKeepsCandidateDraft(t *testing.T) {
}) })
defer al.Close() defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-no-auto-apply", "cli", "direct"); err != nil { if _, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-apply-no-auto-apply",
"cli",
"direct",
); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -665,7 +716,13 @@ func TestEvolutionBridge_ApplyModeAutomaticallyRunsColdPathAndAppliesMergeDraft(
}) })
defer al.Close() defer al.Close()
if _, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-apply-merge", "cli", "direct"); err != nil { if _, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-apply-merge",
"cli",
"direct",
); err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -705,7 +762,13 @@ func TestEvolutionBridge_ObserveModeDoesNotRunColdPathOrCreateDraftFile(t *testi
}, &simpleMockProvider{response: "ok"}) }, &simpleMockProvider{response: "ok"})
defer al.Close() defer al.Close()
resp, err := al.ProcessDirectWithChannel(context.Background(), "hello", "session-no-auto-cold-path", "cli", "direct") resp, err := al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-no-auto-cold-path",
"cli",
"direct",
)
if err != nil { if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err) t.Fatalf("ProcessDirectWithChannel failed: %v", err)
} }
@ -784,7 +847,11 @@ func TestEvolutionBridge_TurnEndUsesExplicitAttemptTrail(t *testing.T) {
FinalSuccessfulPath: []string{"geocode", "weather"}, FinalSuccessfulPath: []string{"geocode", "weather"},
SkillContextSnapshots: []SkillContextSnapshot{ SkillContextSnapshots: []SkillContextSnapshot{
{Sequence: 1, Trigger: skillContextTriggerInitialBuild, SkillNames: []string{"weather"}}, {Sequence: 1, Trigger: skillContextTriggerInitialBuild, SkillNames: []string{"weather"}},
{Sequence: 2, Trigger: skillContextTriggerContextRetryRebuild, SkillNames: []string{"geocode", "weather"}}, {
Sequence: 2,
Trigger: skillContextTriggerContextRetryRebuild,
SkillNames: []string{"geocode", "weather"},
},
}, },
ToolKinds: []string{"echo_text"}, ToolKinds: []string{"echo_text"},
}, },
@ -846,8 +913,8 @@ func TestEvolutionBridge_CloseRejectsLateTurnEndEvents(t *testing.T) {
t.Fatalf("newEvolutionBridge: %v", err) t.Fatalf("newEvolutionBridge: %v", err)
} }
if err := bridge.Close(); err != nil { if closeErr := bridge.Close(); closeErr != nil {
t.Fatalf("Close() error = %v", err) t.Fatalf("Close() error = %v", closeErr)
} }
err = bridge.OnEvent(context.Background(), Event{ err = bridge.OnEvent(context.Background(), Event{
@ -1035,7 +1102,12 @@ func seedReadyRule(t *testing.T, workspace string) {
} }
} }
func newEvolutionTestLoop(t *testing.T, workspace string, evo config.EvolutionConfig, provider providers.LLMProvider) *AgentLoop { func newEvolutionTestLoop(
t *testing.T,
workspace string,
evo config.EvolutionConfig,
provider providers.LLMProvider,
) *AgentLoop {
t.Helper() t.Helper()
cfg := &config.Config{ cfg := &config.Config{
@ -1163,15 +1235,15 @@ func assertProfileNotExists(t *testing.T, workspace, skillName string) {
t.Helper() t.Helper()
store := evolution.NewStore(evolution.NewPaths(workspace, "")) store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if _, err := store.LoadProfile(skillName); !os.IsNotExist(err) { if _, loadErr := store.LoadProfile(skillName); !os.IsNotExist(loadErr) {
t.Fatalf("profile %q should not exist, got err = %v", skillName, err) t.Fatalf("profile %q should not exist, got err = %v", skillName, loadErr)
} }
} }
func assertNotExists(t *testing.T, path string) { func assertNotExists(t *testing.T, path string) {
t.Helper() t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) { if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("%s should not exist, stat err = %v", path, err) t.Fatalf("%s should not exist, stat err = %v", path, statErr)
} }
} }

View file

@ -42,7 +42,10 @@ func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
sub, in, err := al.runtimeEvents.Channel().Source("agent").OfKind(legacyAgentEventKinds()...).SubscribeChan(ctx, runtimeevents.SubscribeOptions{ sub, in, err := al.runtimeEvents.Channel().
Source("agent").
OfKind(legacyAgentEventKinds()...).
SubscribeChan(ctx, runtimeevents.SubscribeOptions{
Name: "legacy-agent-events", Name: "legacy-agent-events",
Buffer: buffer, Buffer: buffer,
}) })

View file

@ -219,11 +219,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if finalContent == "" { if finalContent == "" {
finalContent = ts.opts.DefaultResponse finalContent = ts.opts.DefaultResponse
} }
result, err := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) result, finalizeErr := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
if err != nil { if finalizeErr != nil {
turnStatus = TurnEndStatusError turnStatus = TurnEndStatusError
} }
return result, err return result, finalizeErr
case ControlToolLoop: case ControlToolLoop:
// Execute tools via Pipeline // Execute tools via Pipeline
toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration) toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration)
@ -250,11 +250,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if exec.allResponsesHandled { if exec.allResponsesHandled {
finalContent = "" finalContent = ""
} }
result, err := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent) result, finalizeErr := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
if err != nil { if finalizeErr != nil {
turnStatus = TurnEndStatusError turnStatus = TurnEndStatusError
} }
return result, err return result, finalizeErr
} }
} }
} }

View file

@ -835,7 +835,8 @@ func TestTurnState_SkillContextSnapshotsTrackLatestSuccessfulPath(t *testing.T)
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, []string{"skill-a"}) ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, []string{"skill-a"})
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, []string{"skill-b", "skill-c"}) ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, []string{"skill-b", "skill-c"})
if got := ts.attemptedSkillsSnapshot(); len(got) != 3 || got[0] != "skill-a" || got[1] != "skill-b" || got[2] != "skill-c" { if got := ts.attemptedSkillsSnapshot(); len(got) != 3 || got[0] != "skill-a" || got[1] != "skill-b" ||
got[2] != "skill-c" {
t.Fatalf("attemptedSkillsSnapshot = %v, want [skill-a skill-b skill-c]", got) t.Fatalf("attemptedSkillsSnapshot = %v, want [skill-a skill-b skill-c]", got)
} }

View file

@ -170,15 +170,6 @@ func (c EvolutionConfig) EffectiveColdPathTimes() []string {
return out return out
} }
func (c EvolutionConfig) legacyRunsColdPathAutomatically() bool {
switch c.EffectiveMode() {
case "draft", "apply":
return true
default:
return false
}
}
func (c EvolutionConfig) AutoAppliesDrafts() bool { func (c EvolutionConfig) AutoAppliesDrafts() bool {
return c.EffectiveMode() == "apply" return c.EffectiveMode() == "apply"
} }

View file

@ -333,7 +333,12 @@ func TestEvolutionConfig_ColdPathTriggerMode(t *testing.T) {
assert.True(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathAfterTurn()) assert.True(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathAfterTurn())
assert.False(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathScheduled()) assert.False(t, (EvolutionConfig{Enabled: true, Mode: "draft"}).RunsColdPathScheduled())
scheduled := EvolutionConfig{Enabled: true, Mode: "apply", ColdPathTrigger: "scheduled", ColdPathTimes: []string{"03:00"}} scheduled := EvolutionConfig{
Enabled: true,
Mode: "apply",
ColdPathTrigger: "scheduled",
ColdPathTimes: []string{"03:00"},
}
assert.Equal(t, "scheduled", scheduled.ColdPathTriggerMode()) assert.Equal(t, "scheduled", scheduled.ColdPathTriggerMode())
assert.False(t, scheduled.RunsColdPathAfterTurn()) assert.False(t, scheduled.RunsColdPathAfterTurn())
assert.True(t, scheduled.RunsColdPathScheduled()) assert.True(t, scheduled.RunsColdPathScheduled())
@ -448,8 +453,8 @@ func TestSaveConfig_DisabledEvolutionOmitsApplyMode(t *testing.T) {
} }
var raw map[string]any var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil { if unmarshalErr := json.Unmarshal(data, &raw); unmarshalErr != nil {
t.Fatalf("Unmarshal saved config: %v", err) t.Fatalf("Unmarshal saved config: %v", unmarshalErr)
} }
evolutionRaw, ok := raw["evolution"].(map[string]any) evolutionRaw, ok := raw["evolution"].(map[string]any)
if !ok { if !ok {
@ -464,8 +469,8 @@ func TestSaveConfig_DisabledEvolutionOmitsApplyMode(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Marshal edited config: %v", err) t.Fatalf("Marshal edited config: %v", err)
} }
if err := os.WriteFile(configPath, edited, 0o600); err != nil { if writeErr := os.WriteFile(configPath, edited, 0o600); writeErr != nil {
t.Fatalf("WriteFile(configPath): %v", err) t.Fatalf("WriteFile(configPath): %v", writeErr)
} }
loaded, err := LoadConfig(configPath) loaded, err := LoadConfig(configPath)

View file

@ -8,9 +8,10 @@ import (
"strings" "strings"
"time" "time"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"gopkg.in/yaml.v3"
) )
type Applier struct { type Applier struct {
@ -37,14 +38,18 @@ func (a *Applier) ApplyDraft(ctx context.Context, workspace string, draft SkillD
return nil return nil
} }
func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string, draft SkillDraft) (func() error, error) { func (a *Applier) applyDraftWithRollback(
ctx context.Context,
workspace string,
draft SkillDraft,
) (func() error, error) {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
default: default:
} }
if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil { if validateErr := skills.ValidateSkillName(draft.TargetSkillName); validateErr != nil {
return nil, err return nil, validateErr
} }
existingBody, backupPath, hadOriginal, err := a.backupCurrentSkill(workspace, draft.TargetSkillName) existingBody, backupPath, hadOriginal, err := a.backupCurrentSkill(workspace, draft.TargetSkillName)
@ -53,8 +58,8 @@ func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string,
} }
skillDir := filepath.Join(workspace, "skills", draft.TargetSkillName) skillDir := filepath.Join(workspace, "skills", draft.TargetSkillName)
if err := os.MkdirAll(skillDir, 0o755); err != nil { if mkdirErr := os.MkdirAll(skillDir, 0o755); mkdirErr != nil {
return nil, err return nil, mkdirErr
} }
renderedBody, err := renderAppliedBody(draft, existingBody, hadOriginal) renderedBody, err := renderAppliedBody(draft, existingBody, hadOriginal)
@ -67,7 +72,11 @@ func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string,
return nil, err return nil, err
} }
if err := validateAppliedSkillBody(renderedBody, draft.TargetSkillName, allowsExistingFrontmatterFields(draft.ChangeKind, hadOriginal)); err != nil { if err := validateAppliedSkillBody(
renderedBody,
draft.TargetSkillName,
allowsExistingFrontmatterFields(draft.ChangeKind, hadOriginal),
); err != nil {
if rollbackErr := a.rollbackSkill(skillPath, backupPath, hadOriginal); rollbackErr != nil { if rollbackErr := a.rollbackSkill(skillPath, backupPath, hadOriginal); rollbackErr != nil {
return nil, errorsJoin(err, rollbackErr) return nil, errorsJoin(err, rollbackErr)
} }
@ -79,9 +88,11 @@ func (a *Applier) applyDraftWithRollback(ctx context.Context, workspace string,
}, nil }, nil
} }
func (a *Applier) backupCurrentSkill(workspace, skillName string) (currentBody, backupPath string, hadOriginal bool, err error) { func (a *Applier) backupCurrentSkill(
if err := skills.ValidateSkillName(skillName); err != nil { workspace, skillName string,
return "", "", false, err ) (currentBody, backupPath string, hadOriginal bool, err error) {
if validateErr := skills.ValidateSkillName(skillName); validateErr != nil {
return "", "", false, validateErr
} }
skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md") skillPath := filepath.Join(workspace, "skills", skillName, "SKILL.md")
@ -217,7 +228,11 @@ func renderDeployablePatchBody(body, targetSkillName string) (string, error) {
return "", err return "", err
} }
if name := strings.TrimSpace(fields["name"]); name != "" && name != targetSkillName { if name := strings.TrimSpace(fields["name"]); name != "" && name != targetSkillName {
return "", fmt.Errorf("skill patch frontmatter name %q does not match target skill %q", name, targetSkillName) return "", fmt.Errorf(
"skill patch frontmatter name %q does not match target skill %q",
name,
targetSkillName,
)
} }
} }
return strings.TrimSpace(stripLeadingH1(markdownBody)), nil return strings.TrimSpace(stripLeadingH1(markdownBody)), nil

View file

@ -101,7 +101,10 @@ func TestApplier_CreateDraftRendersDeployableSkillWithoutLearningTrace(t *testin
if !strings.Contains(content, "Use native-name query first.") { if !strings.Contains(content, "Use native-name query first.") {
t.Fatalf("deployed skill lost procedure:\n%s", content) t.Fatalf("deployed skill lost procedure:\n%s", content)
} }
if !strings.Contains(content, "description: Perform mathematical calculations by applying specific theorems and their associated rules.") { if !strings.Contains(
content,
"description: Perform mathematical calculations by applying specific theorems and their associated rules.",
) {
t.Fatalf("deployed skill did not clean description:\n%s", content) t.Fatalf("deployed skill did not clean description:\n%s", content)
} }
} }
@ -682,7 +685,9 @@ func TestApplier_BackupsAreScopedByWorkspace(t *testing.T) {
} }
var backupBodies []string var backupBodies []string
if err := filepath.WalkDir(filepath.Join(sharedState, "backups"), func(path string, entry os.DirEntry, err error) error { if err := filepath.WalkDir(
filepath.Join(sharedState, "backups"),
func(path string, entry os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
@ -695,7 +700,8 @@ func TestApplier_BackupsAreScopedByWorkspace(t *testing.T) {
} }
backupBodies = append(backupBodies, string(data)) backupBodies = append(backupBodies, string(data))
return nil return nil
}); err != nil { },
); err != nil {
t.Fatalf("WalkDir(backups): %v", err) t.Fatalf("WalkDir(backups): %v", err)
} }

View file

@ -16,7 +16,12 @@ type DraftGenerator interface {
} }
type EvidenceAwareDraftGenerator interface { type EvidenceAwareDraftGenerator interface {
GenerateDraftWithEvidence(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) (SkillDraft, error) GenerateDraftWithEvidence(
ctx context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) (SkillDraft, error)
} }
type DraftEvidence struct { type DraftEvidence struct {
@ -72,11 +77,20 @@ func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
} }
} }
func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) { func (g *DefaultDraftGenerator) GenerateDraft(
_ context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
) (SkillDraft, error) {
return g.GenerateDraftWithEvidence(context.Background(), rule, matches, DraftEvidence{}) return g.GenerateDraftWithEvidence(context.Background(), rule, matches, DraftEvidence{})
} }
func (g *DefaultDraftGenerator) GenerateDraftWithEvidence(_ context.Context, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) (SkillDraft, error) { func (g *DefaultDraftGenerator) GenerateDraftWithEvidence(
_ context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) (SkillDraft, error) {
rule = enrichRuleWithDraftEvidence(rule, evidence) rule = enrichRuleWithDraftEvidence(rule, evidence)
target := inferTargetSkillName(rule, matches) target := inferTargetSkillName(rule, matches)
if target == "" { if target == "" {
@ -185,7 +199,9 @@ func inferCombinedSkillName(rule LearningRecord) string {
tokens := tokenizeForEvolution(rule.Summary) tokens := tokenizeForEvolution(rule.Summary)
suffix := commonWinningPathSuffix(path) suffix := commonWinningPathSuffix(path)
if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" { if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" {
if candidate := validSkillNameOrEmpty("calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix)); candidate != "" { if candidate := validSkillNameOrEmpty(
"calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix),
); candidate != "" {
return candidate return candidate
} }
} }
@ -334,8 +350,16 @@ func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRe
return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary) return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary)
} }
func (g *DefaultDraftGenerator) buildNewSkillBody(target string, rule LearningRecord, evidence DraftEvidence, matches []skills.SkillInfo) string { func (g *DefaultDraftGenerator) buildNewSkillBody(
description := fmt.Sprintf("Use this skill to %s when the task matches this workflow.", sentenceFragment(fallbackString(rule.Summary, target))) target string,
rule LearningRecord,
evidence DraftEvidence,
matches []skills.SkillInfo,
) string {
description := fmt.Sprintf(
"Use this skill to %s when the task matches this workflow.",
sentenceFragment(fallbackString(rule.Summary, target)),
)
body := strings.Join([]string{ body := strings.Join([]string{
"# " + titleCaseSkillName(target), "# " + titleCaseSkillName(target),
"", "",
@ -363,7 +387,11 @@ func (g *DefaultDraftGenerator) buildNewSkillBody(target string, rule LearningRe
return buildSkillDocument(target, description, body) return buildSkillDocument(target, description, body)
} }
func (g *DefaultDraftGenerator) buildAppendBody(rule LearningRecord, evidence DraftEvidence, matches []skills.SkillInfo) string { func (g *DefaultDraftGenerator) buildAppendBody(
rule LearningRecord,
evidence DraftEvidence,
matches []skills.SkillInfo,
) string {
return strings.Join([]string{ return strings.Join([]string{
"## Learned Evolution", "## Learned Evolution",
fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)), fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)),
@ -420,26 +448,28 @@ func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string {
) )
} }
if len(rule.WinningPath) > 0 { if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Prefer `%s` because it was the most reliable recent path.", strings.Join(rule.WinningPath, " -> ")) return fmt.Sprintf(
"Prefer `%s` because it was the most reliable recent path.",
strings.Join(rule.WinningPath, " -> "),
)
} }
return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary)) return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary))
} }
func (g *DefaultDraftGenerator) winningPathLine(rule LearningRecord) string {
if len(rule.WinningPath) == 0 {
return "No explicit winning path was recorded."
}
return strings.Join(rule.WinningPath, " -> ")
}
func (g *DefaultDraftGenerator) procedureLine(rule LearningRecord, evidence DraftEvidence) string { func (g *DefaultDraftGenerator) procedureLine(rule LearningRecord, evidence DraftEvidence) string {
if len(rule.WinningPath) > 0 { if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Follow `%s`, applying the concrete operation from each source skill, then return the final result directly.", strings.Join(rule.WinningPath, " -> ")) return fmt.Sprintf(
"Follow `%s`, applying the concrete operation from each source skill, then return the final result directly.",
strings.Join(rule.WinningPath, " -> "),
)
} }
if excerpt := firstFinalOutputExcerpt(evidence, 260); excerpt != "" { if excerpt := firstFinalOutputExcerpt(evidence, 260); excerpt != "" {
return "Use the same operation demonstrated by the source task result: " + excerpt return "Use the same operation demonstrated by the source task result: " + excerpt
} }
return fmt.Sprintf("Solve tasks matching `%s` using the learned successful workflow, then return the final result directly.", strings.TrimSpace(rule.Summary)) return fmt.Sprintf(
"Solve tasks matching `%s` using the learned successful workflow, then return the final result directly.",
strings.TrimSpace(rule.Summary),
)
} }
func (g *DefaultDraftGenerator) expectedResultLine(evidence DraftEvidence) string { func (g *DefaultDraftGenerator) expectedResultLine(evidence DraftEvidence) string {

View file

@ -45,9 +45,21 @@ func TestDefaultDraftGenerator_PrefersCombinedSkillForStableMultiSkillPath(t *te
EventCount: 3, EventCount: 3,
SuccessRate: 1, SuccessRate: 1,
}, []skills.SkillInfo{ }, []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: "three-one-theorem",
{Name: "five-three-theorem", Path: filepath.Join(workspace, "skills", "five-three-theorem", "SKILL.md"), Source: "workspace"}, 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 { if err != nil {
t.Fatalf("GenerateDraft: %v", err) t.Fatalf("GenerateDraft: %v", err)
@ -85,7 +97,10 @@ func TestDefaultDraftGenerator_CombinedSkillIncludesEvidenceAndSourceOperations(
if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
matches = append(matches, skills.SkillInfo{Name: source.name, Path: skillPath, Source: "workspace", Description: "theorem helper"}) matches = append(
matches,
skills.SkillInfo{Name: source.name, Path: skillPath, Source: "workspace", Description: "theorem helper"},
)
} }
draft, err := generator.GenerateDraftWithEvidence(context.Background(), evolution.LearningRecord{ draft, err := generator.GenerateDraftWithEvidence(context.Background(), evolution.LearningRecord{

View file

@ -35,11 +35,20 @@ func NewLLMDraftGenerator(provider providers.LLMProvider, model string, fallback
} }
} }
func (g *LLMDraftGenerator) GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) { func (g *LLMDraftGenerator) GenerateDraft(
ctx context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
) (SkillDraft, error) {
return g.GenerateDraftWithEvidence(ctx, rule, matches, DraftEvidence{}) return g.GenerateDraftWithEvidence(ctx, rule, matches, DraftEvidence{})
} }
func (g *LLMDraftGenerator) GenerateDraftWithEvidence(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) (SkillDraft, error) { func (g *LLMDraftGenerator) GenerateDraftWithEvidence(
ctx context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) (SkillDraft, error) {
rule = enrichRuleWithDraftEvidence(rule, evidence) rule = enrichRuleWithDraftEvidence(rule, evidence)
if g == nil || g.provider == nil { if g == nil || g.provider == nil {
return g.generateFallback(ctx, rule, matches, evidence) return g.generateFallback(ctx, rule, matches, evidence)
@ -97,7 +106,11 @@ func (g *LLMDraftGenerator) generateFallback(
return g.fallback.GenerateDraft(ctx, rule, matches) return g.fallback.GenerateDraft(ctx, rule, matches)
} }
func (g *LLMDraftGenerator) buildPrompt(rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) string { func (g *LLMDraftGenerator) buildPrompt(
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) string {
return strings.Join([]string{ return strings.Join([]string{
"Generate a skill draft JSON object with these required string fields:", "Generate a skill draft JSON object with these required string fields:",
`target_skill_name, draft_type, change_kind, human_summary, body_or_patch.`, `target_skill_name, draft_type, change_kind, human_summary, body_or_patch.`,

View file

@ -125,7 +125,13 @@ func TestLLMDraftGenerator_BuildPromptIncludesMatchedSkillContent(t *testing.T)
if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", err)
} }
if err := os.WriteFile(skillPath, []byte("---\nname: three-one-theorem\ndescription: Add 31 then delegate\n---\n# Three One\nAdd 31 to the input, then continue with the next theorem.\n"), 0o644); err != nil { if err := os.WriteFile(
skillPath,
[]byte(
"---\nname: three-one-theorem\ndescription: Add 31 then delegate\n---\n# Three One\nAdd 31 to the input, then continue with the next theorem.\n",
),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
@ -171,13 +177,22 @@ func TestLLMDraftGenerator_BuildPromptIncludesMatchedSkillContent(t *testing.T)
if !strings.Contains(prompt, "The YAML frontmatter must contain only name and description fields") { if !strings.Contains(prompt, "The YAML frontmatter must contain only name and description fields") {
t.Fatalf("prompt missing frontmatter instruction:\n%s", prompt) t.Fatalf("prompt missing frontmatter instruction:\n%s", prompt)
} }
if !strings.Contains(prompt, "The description field must and only describe what this skill can do and when to use it") { if !strings.Contains(
prompt,
"The description field must and only describe what this skill can do and when to use it",
) {
t.Fatalf("prompt missing description field instruction:\n%s", prompt) t.Fatalf("prompt missing description field instruction:\n%s", prompt)
} }
if !strings.Contains(prompt, "The deployable Markdown body should only contain what the skill is useful for and how to use it") { if !strings.Contains(
prompt,
"The deployable Markdown body should only contain what the skill is useful for and how to use it",
) {
t.Fatalf("prompt missing deployable body scope instruction:\n%s", prompt) t.Fatalf("prompt missing deployable body scope instruction:\n%s", prompt)
} }
if !strings.Contains(prompt, "provide detailed step-by-step instructions for the exact operation or execution process") { if !strings.Contains(
prompt,
"provide detailed step-by-step instructions for the exact operation or execution process",
) {
t.Fatalf("prompt missing step-by-step instruction:\n%s", prompt) t.Fatalf("prompt missing step-by-step instruction:\n%s", prompt)
} }
if !strings.Contains(prompt, "body_or_patch is an internal draft and review artifact") { if !strings.Contains(prompt, "body_or_patch is an internal draft and review artifact") {

View file

@ -15,11 +15,23 @@ import (
) )
type PatternClusterer interface { type PatternClusterer interface {
BuildPatterns(ctx context.Context, workspace string, tasks []LearningRecord, existing []LearningRecord) ([]LearningRecord, []string, error) BuildPatterns(
ctx context.Context,
workspace string,
tasks []LearningRecord,
existing []LearningRecord,
) ([]LearningRecord, []string, error)
} }
type evidencePatternClusterer interface { type evidencePatternClusterer interface {
BuildPatternsWithEvidence(ctx context.Context, workspace string, successfulTasks []LearningRecord, evidenceTasks []LearningRecord, existing []LearningRecord, minSuccessRatio float64) ([]LearningRecord, []string, error) BuildPatternsWithEvidence(
ctx context.Context,
workspace string,
successfulTasks []LearningRecord,
evidenceTasks []LearningRecord,
existing []LearningRecord,
minSuccessRatio float64,
) ([]LearningRecord, []string, error)
} }
type HeuristicPatternClusterer struct { type HeuristicPatternClusterer struct {
@ -37,7 +49,12 @@ func NewHeuristicPatternClusterer(minCaseCount int, now func() time.Time) *Heuri
return &HeuristicPatternClusterer{minCaseCount: minCaseCount, now: now} return &HeuristicPatternClusterer{minCaseCount: minCaseCount, now: now}
} }
func (c *HeuristicPatternClusterer) BuildPatterns(_ context.Context, workspace string, tasks []LearningRecord, existing []LearningRecord) ([]LearningRecord, []string, error) { func (c *HeuristicPatternClusterer) BuildPatterns(
_ context.Context,
workspace string,
tasks []LearningRecord,
existing []LearningRecord,
) ([]LearningRecord, []string, error) {
groups := make(map[string][]LearningRecord) groups := make(map[string][]LearningRecord)
keys := make([]string, 0) keys := make([]string, 0)
for _, task := range tasks { for _, task := range tasks {
@ -68,7 +85,15 @@ func (c *HeuristicPatternClusterer) BuildPatterns(_ context.Context, workspace s
if !hasExisting && len(cluster) < c.minCaseCount { if !hasExisting && len(cluster) < c.minCaseCount {
continue continue
} }
pattern := buildPatternFromCluster(workspace, label, heuristicClusterSummary(label, cluster), "heuristic cluster by normalized task summary", cluster, existingPattern, c.now()) pattern := buildPatternFromCluster(
workspace,
label,
heuristicClusterSummary(label, cluster),
"heuristic cluster by normalized task summary",
cluster,
existingPattern,
c.now(),
)
patterns = append(patterns, pattern) patterns = append(patterns, pattern)
clusteredIDs = append(clusteredIDs, collectRecordIDs(cluster)...) clusteredIDs = append(clusteredIDs, collectRecordIDs(cluster)...)
} }
@ -94,7 +119,13 @@ type llmCluster struct {
Reason string `json:"cluster_reason"` Reason string `json:"cluster_reason"`
} }
func NewLLMPatternClusterer(provider providers.LLMProvider, model string, fallback PatternClusterer, minCount int, now func() time.Time) *LLMPatternClusterer { func NewLLMPatternClusterer(
provider providers.LLMProvider,
model string,
fallback PatternClusterer,
minCount int,
now func() time.Time,
) *LLMPatternClusterer {
if fallback == nil { if fallback == nil {
fallback = NewHeuristicPatternClusterer(minCount, now) fallback = NewHeuristicPatternClusterer(minCount, now)
} }
@ -113,7 +144,12 @@ func NewLLMPatternClusterer(provider providers.LLMProvider, model string, fallba
} }
} }
func (c *LLMPatternClusterer) BuildPatterns(ctx context.Context, workspace string, tasks []LearningRecord, existing []LearningRecord) ([]LearningRecord, []string, error) { func (c *LLMPatternClusterer) BuildPatterns(
ctx context.Context,
workspace string,
tasks []LearningRecord,
existing []LearningRecord,
) ([]LearningRecord, []string, error) {
if c == nil { if c == nil {
return NewHeuristicPatternClusterer(0, nil).BuildPatterns(ctx, workspace, tasks, existing) return NewHeuristicPatternClusterer(0, nil).BuildPatterns(ctx, workspace, tasks, existing)
} }
@ -175,14 +211,30 @@ func (c *LLMPatternClusterer) BuildPatternsWithEvidence(
fallback = NewHeuristicPatternClusterer(c.minCount, c.now) fallback = NewHeuristicPatternClusterer(c.minCount, c.now)
} }
if c.provider == nil { if c.provider == nil {
return buildFallbackPatternsWithEvidence(ctx, fallback, workspace, successfulTasks, evidenceTasks, existing, minSuccessRatio) return buildFallbackPatternsWithEvidence(
ctx,
fallback,
workspace,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
} }
model := strings.TrimSpace(c.model) model := strings.TrimSpace(c.model)
if model == "" { if model == "" {
model = strings.TrimSpace(c.provider.GetDefaultModel()) model = strings.TrimSpace(c.provider.GetDefaultModel())
} }
if model == "" { if model == "" {
return buildFallbackPatternsWithEvidence(ctx, fallback, workspace, successfulTasks, evidenceTasks, existing, minSuccessRatio) return buildFallbackPatternsWithEvidence(
ctx,
fallback,
workspace,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
} }
if len(evidenceTasks) == 0 { if len(evidenceTasks) == 0 {
evidenceTasks = successfulTasks evidenceTasks = successfulTasks
@ -201,17 +253,48 @@ func (c *LLMPatternClusterer) BuildPatternsWithEvidence(
}, },
}, nil, model, map[string]any{"temperature": 0}) }, nil, model, map[string]any{"temperature": 0})
if err != nil || resp == nil || strings.TrimSpace(resp.Content) == "" { if err != nil || resp == nil || strings.TrimSpace(resp.Content) == "" {
return buildFallbackPatternsWithEvidence(ctx, fallback, workspace, successfulTasks, evidenceTasks, existing, minSuccessRatio) return buildFallbackPatternsWithEvidence(
ctx,
fallback,
workspace,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
} }
payload, ok := parseLLMClusterResponse(resp.Content) payload, ok := parseLLMClusterResponse(resp.Content)
if !ok { if !ok {
return buildFallbackPatternsWithEvidence(ctx, fallback, workspace, successfulTasks, evidenceTasks, existing, minSuccessRatio) return buildFallbackPatternsWithEvidence(
ctx,
fallback,
workspace,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
} }
if len(payload.Clusters) == 0 { if len(payload.Clusters) == 0 {
return buildFallbackPatternsWithEvidence(ctx, fallback, workspace, successfulTasks, evidenceTasks, existing, minSuccessRatio) return buildFallbackPatternsWithEvidence(
ctx,
fallback,
workspace,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
} }
patterns, clusteredIDs := c.validateAndBuildPatternsWithEvidence(workspace, payload.Clusters, successfulTasks, evidenceTasks, existing, minSuccessRatio) patterns, clusteredIDs := c.validateAndBuildPatternsWithEvidence(
workspace,
payload.Clusters,
successfulTasks,
evidenceTasks,
existing,
minSuccessRatio,
)
return patterns, clusteredIDs, nil return patterns, clusteredIDs, nil
} }
@ -316,7 +399,12 @@ func buildFallbackPatternsWithEvidence(
return filteredPatterns, appendUniqueStrings(nil, clusteredIDs...), nil return filteredPatterns, appendUniqueStrings(nil, clusteredIDs...), nil
} }
func (c *LLMPatternClusterer) validateAndBuildPatterns(workspace string, clusters []llmCluster, tasks []LearningRecord, existing []LearningRecord) ([]LearningRecord, []string) { func (c *LLMPatternClusterer) validateAndBuildPatterns(
workspace string,
clusters []llmCluster,
tasks []LearningRecord,
existing []LearningRecord,
) ([]LearningRecord, []string) {
taskByID := make(map[string]LearningRecord, len(tasks)) taskByID := make(map[string]LearningRecord, len(tasks))
for _, task := range tasks { for _, task := range tasks {
taskByID[task.ID] = task taskByID[task.ID] = task
@ -354,7 +442,15 @@ func (c *LLMPatternClusterer) validateAndBuildPatterns(workspace string, cluster
if len(clusterTasks) == 0 { if len(clusterTasks) == 0 {
continue continue
} }
pattern := buildPatternFromCluster(workspace, label, cluster.Summary, cluster.Reason, clusterTasks, existingPattern, c.now()) pattern := buildPatternFromCluster(
workspace,
label,
cluster.Summary,
cluster.Reason,
clusterTasks,
existingPattern,
c.now(),
)
patterns = append(patterns, pattern) patterns = append(patterns, pattern)
clusteredIDs = append(clusteredIDs, collectRecordIDs(clusterTasks)...) clusteredIDs = append(clusteredIDs, collectRecordIDs(clusterTasks)...)
} }
@ -420,7 +516,15 @@ func (c *LLMPatternClusterer) validateAndBuildPatternsWithEvidence(
if !hasExisting && len(clusterSuccesses) < c.minCount { if !hasExisting && len(clusterSuccesses) < c.minCount {
continue continue
} }
pattern := buildPatternFromCluster(workspace, label, cluster.Summary, cluster.Reason, clusterSuccesses, existingPattern, c.now()) pattern := buildPatternFromCluster(
workspace,
label,
cluster.Summary,
cluster.Reason,
clusterSuccesses,
existingPattern,
c.now(),
)
patterns = append(patterns, pattern) patterns = append(patterns, pattern)
clusteredIDs = append(clusteredIDs, collectRecordIDs(clusterEvidence)...) clusteredIDs = append(clusteredIDs, collectRecordIDs(clusterEvidence)...)
} }
@ -488,7 +592,12 @@ func buildPatternClusterPrompt(workspace string, tasks []LearningRecord, existin
return string(data) return string(data)
} }
func buildPatternFromCluster(workspace, label, summary, reason string, tasks []LearningRecord, existing LearningRecord, now time.Time) LearningRecord { func buildPatternFromCluster(
workspace, label, summary, reason string,
tasks []LearningRecord,
existing LearningRecord,
now time.Time,
) LearningRecord {
taskIDs := append([]string(nil), existing.TaskRecordIDs...) taskIDs := append([]string(nil), existing.TaskRecordIDs...)
taskIDs = appendUniqueStrings(taskIDs, collectRecordIDs(tasks)...) taskIDs = appendUniqueStrings(taskIDs, collectRecordIDs(tasks)...)
if summary = strings.TrimSpace(summary); summary == "" { if summary = strings.TrimSpace(summary); summary == "" {

View file

@ -312,59 +312,14 @@ func TestLLMPatternClusterer_MarksAllAcceptedEvidenceClusteredButStoresSuccessfu
content: `{"clusters":[{"label":"weather-lookup","summary":"lookup weather","task_record_ids":["task-success","task-failed"],"cluster_reason":"same weather lookup goal"}]}`, content: `{"clusters":[{"label":"weather-lookup","summary":"lookup weather","task_record_ids":["task-success","task-failed"],"cluster_reason":"same weather lookup goal"}]}`,
defaultModel: "test-model", defaultModel: "test-model",
} }
clusterer := evolution.NewLLMPatternClusterer( assertClustererMarksAllAcceptedEvidenceClustered(
t,
provider, provider,
"test-model", "weather lookup shanghai",
evolution.NewHeuristicPatternClusterer(1, nil), "forecast for shanghai",
1, "could not complete",
func() time.Time { return time.Unix(1700000000, 0).UTC() }, "1",
) )
success := true
failed := false
successfulTasks := []evolution.LearningRecord{
{
ID: "task-success",
Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-a",
Summary: "weather lookup shanghai",
FinalOutput: "sunny",
Status: evolution.RecordStatus("new"),
Success: &success,
},
}
evidenceTasks := []evolution.LearningRecord{
successfulTasks[0],
{
ID: "task-failed",
Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-a",
Summary: "forecast for shanghai",
FinalOutput: "could not complete",
Status: evolution.RecordStatus("new"),
Success: &failed,
},
}
patterns, clusteredIDs, err := clusterer.BuildPatternsWithEvidence(
context.Background(),
"workspace-a",
successfulTasks,
evidenceTasks,
nil,
0.5,
)
if err != nil {
t.Fatalf("BuildPatternsWithEvidence: %v", err)
}
if len(patterns) != 1 {
t.Fatalf("len(patterns) = %d, want 1: %#v", len(patterns), patterns)
}
if got := strings.Join(patterns[0].TaskRecordIDs, ","); got != "task-success" {
t.Fatalf("pattern TaskRecordIDs = %v, want only successful task", patterns[0].TaskRecordIDs)
}
if got := strings.Join(clusteredIDs, ","); got != "task-success,task-failed" {
t.Fatalf("clusteredIDs = %v, want all accepted evidence IDs", clusteredIDs)
}
} }
func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testing.T) { func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testing.T) {
@ -372,6 +327,25 @@ func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testin
content: `not-json`, content: `not-json`,
defaultModel: "test-model", defaultModel: "test-model",
} }
assertClustererMarksAllAcceptedEvidenceClustered(
t,
provider,
"weather lookup 100",
"weather lookup 200",
"partial result",
"fallback pattern",
)
}
func assertClustererMarksAllAcceptedEvidenceClustered(
t *testing.T,
provider *llmClusterTestProvider,
successSummary string,
failedSummary string,
failedOutput string,
wantPatternDescription string,
) {
t.Helper()
clusterer := evolution.NewLLMPatternClusterer( clusterer := evolution.NewLLMPatternClusterer(
provider, provider,
"test-model", "test-model",
@ -386,7 +360,7 @@ func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testin
ID: "task-success", ID: "task-success",
Kind: evolution.RecordKindTask, Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-a", WorkspaceID: "workspace-a",
Summary: "weather lookup 100", Summary: successSummary,
FinalOutput: "sunny", FinalOutput: "sunny",
Status: evolution.RecordStatus("new"), Status: evolution.RecordStatus("new"),
Success: &success, Success: &success,
@ -398,8 +372,8 @@ func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testin
ID: "task-failed", ID: "task-failed",
Kind: evolution.RecordKindTask, Kind: evolution.RecordKindTask,
WorkspaceID: "workspace-a", WorkspaceID: "workspace-a",
Summary: "weather lookup 200", Summary: failedSummary,
FinalOutput: "partial result", FinalOutput: failedOutput,
Status: evolution.RecordStatus("new"), Status: evolution.RecordStatus("new"),
Success: &failed, Success: &failed,
}, },
@ -417,7 +391,7 @@ func TestLLMPatternClusterer_FallbackMarksAllAcceptedEvidenceClustered(t *testin
t.Fatalf("BuildPatternsWithEvidence: %v", err) t.Fatalf("BuildPatternsWithEvidence: %v", err)
} }
if len(patterns) != 1 { if len(patterns) != 1 {
t.Fatalf("len(patterns) = %d, want fallback pattern: %#v", len(patterns), patterns) t.Fatalf("len(patterns) = %d, want %s: %#v", len(patterns), wantPatternDescription, patterns)
} }
if got := strings.Join(patterns[0].TaskRecordIDs, ","); got != "task-success" { if got := strings.Join(patterns[0].TaskRecordIDs, ","); got != "task-success" {
t.Fatalf("pattern TaskRecordIDs = %v, want only successful task", patterns[0].TaskRecordIDs) t.Fatalf("pattern TaskRecordIDs = %v, want only successful task", patterns[0].TaskRecordIDs)

View file

@ -75,11 +75,17 @@ func buildLineDiffPreview(currentBody, renderedBody string) string {
} }
lines := make([]string, 0, (hunkBeforeEnd-hunkBeforeStart)+(hunkAfterEnd-hunkAfterStart)) lines := make([]string, 0, (hunkBeforeEnd-hunkBeforeStart)+(hunkAfterEnd-hunkAfterStart))
header := []string{ header := make([]string, 0, 3+len(lines))
header = append(header,
"--- current", "--- current",
"+++ rendered", "+++ rendered",
formatUnifiedHunkHeader(hunkBeforeStart, hunkBeforeEnd-hunkBeforeStart, hunkAfterStart, hunkAfterEnd-hunkAfterStart), formatUnifiedHunkHeader(
} hunkBeforeStart,
hunkBeforeEnd-hunkBeforeStart,
hunkAfterStart,
hunkAfterEnd-hunkAfterStart,
),
)
for _, line := range before[hunkBeforeStart:beforeChangeStart] { for _, line := range before[hunkBeforeStart:beforeChangeStart] {
lines = append(lines, " "+line) lines = append(lines, " "+line)
} }
@ -96,7 +102,13 @@ func buildLineDiffPreview(currentBody, renderedBody string) string {
} }
func formatUnifiedHunkHeader(beforeStart, beforeCount, afterStart, afterCount int) string { func formatUnifiedHunkHeader(beforeStart, beforeCount, afterStart, afterCount int) string {
return "@@ -" + formatUnifiedRange(beforeStart+1, beforeCount) + " +" + formatUnifiedRange(afterStart+1, afterCount) + " @@" return "@@ -" + formatUnifiedRange(
beforeStart+1,
beforeCount,
) + " +" + formatUnifiedRange(
afterStart+1,
afterCount,
) + " @@"
} }
func formatUnifiedRange(start, count int) string { func formatUnifiedRange(start, count int) string {

View file

@ -64,6 +64,12 @@ func inferAvoidPatterns(rule LearningRecord) []string {
return nil return nil
} }
return []string{ return []string{
"avoid starting with " + strings.Join(prefix, " -> ") + " before using " + strings.Join(rule.LateAddedSkills, " -> "), "avoid starting with " + strings.Join(
prefix,
" -> ",
) + " before using " + strings.Join(
rule.LateAddedSkills,
" -> ",
),
} }
} }

View file

@ -278,11 +278,19 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
admittedCount := 0 admittedCount := 0
newRuleCount := 0 newRuleCount := 0
if rt.patternClusterer != nil { if rt.patternClusterer != nil {
recordsForOrganizer, evidenceRecordsForOrganizer, err := rt.recordsForColdPathInputs(ctx, workspace, taskRecords) recordsForOrganizer, evidenceRecordsForOrganizer, inputErr := rt.recordsForColdPathInputs(
if err != nil { ctx,
return err workspace,
taskRecords,
)
if inputErr != nil {
return inputErr
} }
recordsForOrganizer = rt.filterRecordsByMinSuccessRatio(workspace, evidenceRecordsForOrganizer, recordsForOrganizer) recordsForOrganizer = rt.filterRecordsByMinSuccessRatio(
workspace,
evidenceRecordsForOrganizer,
recordsForOrganizer,
)
admittedCount = countTaskLearningRecords(recordsForOrganizer) admittedCount = countTaskLearningRecords(recordsForOrganizer)
logger.DebugCF("evolution", "Admitted task records for cold path", map[string]any{ logger.DebugCF("evolution", "Admitted task records for cold path", map[string]any{
"workspace": workspace, "workspace": workspace,
@ -303,7 +311,12 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
rt.cfg.EffectiveMinSuccessRatio(), rt.cfg.EffectiveMinSuccessRatio(),
) )
} else { } else {
rules, clusteredTaskIDs, err = rt.patternClusterer.BuildPatterns(ctx, workspace, recordsForOrganizer, patternRecords) rules, clusteredTaskIDs, err = rt.patternClusterer.BuildPatterns(
ctx,
workspace,
recordsForOrganizer,
patternRecords,
)
} }
if err != nil { if err != nil {
return err return err
@ -319,14 +332,14 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
}) })
if len(rules) > 0 { if len(rules) > 0 {
merged := mergePatternRecords(patternRecords, rules, workspace) merged := mergePatternRecords(patternRecords, rules, workspace)
if err := store.MergePatternRecords(rules); err != nil { if mergeErr := store.MergePatternRecords(rules); mergeErr != nil {
return err return mergeErr
} }
patternRecords = merged patternRecords = merged
} }
if len(clusteredTaskIDs) > 0 { if len(clusteredTaskIDs) > 0 {
if err := markTaskRecordsClustered(store, clusteredTaskIDs); err != nil { if markErr := markTaskRecordsClustered(store, clusteredTaskIDs); markErr != nil {
return err return markErr
} }
} }
} }
@ -371,17 +384,21 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
} }
rule, ok := readyRuleByID[draft.SourceRecordID] rule, ok := readyRuleByID[draft.SourceRecordID]
if !ok { if !ok {
logger.DebugCF("evolution", "Skipped existing candidate draft because its source pattern is not ready", map[string]any{ logger.DebugCF(
"evolution",
"Skipped existing candidate draft because its source pattern is not ready",
map[string]any{
"workspace": workspace, "workspace": workspace,
"draft_id": draft.ID, "draft_id": draft.ID,
"source_record_id": draft.SourceRecordID, "source_record_id": draft.SourceRecordID,
"run_id": runID, "run_id": runID,
}) },
)
continue continue
} }
matches, err := recaller.RecallSimilarSkills(rule) matches, recallErr := recaller.RecallSimilarSkills(rule)
if err != nil { if recallErr != nil {
return err return recallErr
} }
draft.MatchedSkillRefs = collectSkillRefs(matches) draft.MatchedSkillRefs = collectSkillRefs(matches)
var normalizationNotes []string var normalizationNotes []string
@ -393,17 +410,14 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, review.Findings...) draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, review.Findings...)
changedExistingDrafts = true changedExistingDrafts = true
if draft.Status != DraftStatusCandidate || mode != "apply" || applier == nil { if draft.Status != DraftStatusCandidate || mode != "apply" || applier == nil {
if err := store.SaveDrafts([]SkillDraft{draft}); err != nil { if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
return err return saveErr
} }
continue continue
} }
if mode != "apply" || applier == nil { updatedDraft, applyErr := rt.applyCandidateDraft(ctx, workspace, store, applier, draft, runID)
continue if applyErr != nil {
} return applyErr
updatedDraft, err := rt.applyCandidateDraft(ctx, workspace, store, applier, draft, runID)
if err != nil {
return err
} }
if updatedDraft.Status == DraftStatusAccepted { if updatedDraft.Status == DraftStatusAccepted {
appliedExistingDrafts++ appliedExistingDrafts++
@ -436,12 +450,16 @@ func (rt *Runtime) RunColdPathOnce(ctx context.Context, workspace string) error
} }
if _, exists := existingBySource[rule.ID]; exists { if _, exists := existingBySource[rule.ID]; exists {
logger.DebugCF("evolution", "Skipped pattern because a non-quarantined draft already exists", map[string]any{ logger.DebugCF(
"evolution",
"Skipped pattern because a non-quarantined draft already exists",
map[string]any{
"workspace": workspace, "workspace": workspace,
"pattern_id": rule.ID, "pattern_id": rule.ID,
"pattern_info": summarizePatternRecord(rule), "pattern_info": summarizePatternRecord(rule),
"run_id": runID, "run_id": runID,
}) },
)
continue continue
} }
@ -639,35 +657,6 @@ func coldPathSuccessRatioKey(workspace string, record LearningRecord) (string, b
return key, true return key, true
} }
func passesColdPathRuleFilter(record LearningRecord) bool {
return coldPathRuleRejectReason(record) == ""
}
func coldPathRuleRejectReason(record LearningRecord) string {
if !isTaskRecordKind(record.Kind) {
return "not a task record"
}
if record.Success == nil || !*record.Success {
return "task not completed"
}
if record.Status != "" && record.Status != RecordStatus("new") {
return "task already processed"
}
if strings.EqualFold(strings.TrimSpace(record.SessionKey), "heartbeat") {
return "heartbeat session"
}
if strings.EqualFold(strings.TrimSpace(record.FinalOutput), "HEARTBEAT_OK") {
return "heartbeat output"
}
if strings.TrimSpace(record.Summary) == "" {
return "missing summary"
}
if strings.TrimSpace(record.FinalOutput) == "" {
return "missing final output"
}
return ""
}
func coldPathEvidenceRejectReason(record LearningRecord) string { func coldPathEvidenceRejectReason(record LearningRecord) string {
if !isTaskRecordKind(record.Kind) { if !isTaskRecordKind(record.Kind) {
return "not a task record" return "not a task record"
@ -744,7 +733,13 @@ func (rt *Runtime) applierForWorkspace(workspace string) *Applier {
return rt.applier return rt.applier
} }
func (rt *Runtime) finalizeDraft(workspace string, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence, draft SkillDraft) SkillDraft { func (rt *Runtime) finalizeDraft(
workspace string,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
draft SkillDraft,
) SkillDraft {
if draft.ID == "" { if draft.ID == "" {
draft.ID = "draft-" + rule.ID draft.ID = "draft-" + rule.ID
} }
@ -843,7 +838,12 @@ func looksLikeSkillDocument(body string) bool {
return strings.HasPrefix(body, "---\n") && strings.Contains(body, "\n# ") return strings.HasPrefix(body, "---\n") && strings.Contains(body, "\n# ")
} }
func synthesizeSkillDocumentFromPartialDraft(target string, draft SkillDraft, rule LearningRecord, evidence DraftEvidence) string { func synthesizeSkillDocumentFromPartialDraft(
target string,
draft SkillDraft,
rule LearningRecord,
evidence DraftEvidence,
) string {
description := strings.TrimSpace(draft.HumanSummary) description := strings.TrimSpace(draft.HumanSummary)
if description == "" { if description == "" {
description = fmt.Sprintf("Learned workflow for %s.", target) description = fmt.Sprintf("Learned workflow for %s.", target)
@ -876,7 +876,13 @@ func synthesizeSkillDocumentFromPartialDraft(target string, draft SkillDraft, ru
return buildSkillDocument(target, description, body) return buildSkillDocument(target, description, body)
} }
func synthesizeCombinedSkillDocument(target string, draft SkillDraft, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) string { func synthesizeCombinedSkillDocument(
target string,
draft SkillDraft,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) string {
description := strings.TrimSpace(draft.HumanSummary) description := strings.TrimSpace(draft.HumanSummary)
if description == "" { if description == "" {
description = buildCombinedSkillHumanSummary(target, rule, false) description = buildCombinedSkillHumanSummary(target, rule, false)
@ -908,7 +914,13 @@ func synthesizeCombinedSkillDocument(target string, draft SkillDraft, rule Learn
return buildSkillDocument(target, description, body) return buildSkillDocument(target, description, body)
} }
func synthesizeCombinedSkillAppendBody(target string, draft SkillDraft, rule LearningRecord, matches []skills.SkillInfo, evidence DraftEvidence) string { func synthesizeCombinedSkillAppendBody(
target string,
draft SkillDraft,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) string {
lines := []string{ lines := []string{
"## Learned Shortcut Update", "## Learned Shortcut Update",
fmt.Sprintf("- Shortcut skill: `%s`", target), fmt.Sprintf("- Shortcut skill: `%s`", target),
@ -929,7 +941,11 @@ func synthesizeCombinedSkillAppendBody(target string, draft SkillDraft, rule Lea
func synthesizedStartHereLine(rule LearningRecord, target string) string { func synthesizedStartHereLine(rule LearningRecord, target string) string {
if len(rule.WinningPath) > 0 { if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Start with `%s` for tasks like `%s`.", strings.Join(rule.WinningPath, " -> "), strings.TrimSpace(rule.Summary)) return fmt.Sprintf(
"Start with `%s` for tasks like `%s`.",
strings.Join(rule.WinningPath, " -> "),
strings.TrimSpace(rule.Summary),
)
} }
if summary := strings.TrimSpace(rule.Summary); summary != "" { if summary := strings.TrimSpace(rule.Summary); summary != "" {
return fmt.Sprintf("Use `%s` when the task matches `%s`.", target, summary) return fmt.Sprintf("Use `%s` when the task matches `%s`.", target, summary)
@ -945,7 +961,11 @@ func synthesizedCombinedWhenToUseLine(rule LearningRecord, target string) string
if len(rule.WinningPath) == 0 { if len(rule.WinningPath) == 0 {
return fmt.Sprintf("Use `%s` when the learned task pattern appears again.", target) return fmt.Sprintf("Use `%s` when the learned task pattern appears again.", target)
} }
return fmt.Sprintf("Use `%s` as a direct shortcut instead of replaying `%s` step by step.", target, strings.Join(rule.WinningPath, " -> ")) return fmt.Sprintf(
"Use `%s` as a direct shortcut instead of replaying `%s` step by step.",
target,
strings.Join(rule.WinningPath, " -> "),
)
} }
func synthesizedCombinedProcedure(matches []skills.SkillInfo, rule LearningRecord) string { func synthesizedCombinedProcedure(matches []skills.SkillInfo, rule LearningRecord) string {
@ -954,7 +974,10 @@ func synthesizedCombinedProcedure(matches []skills.SkillInfo, rule LearningRecor
if len(rule.WinningPath) == 0 { if len(rule.WinningPath) == 0 {
return "Use the learned shortcut directly and keep the response focused on the requested result." return "Use the learned shortcut directly and keep the response focused on the requested result."
} }
return fmt.Sprintf("Apply the recorded path `%s`, then return the final result with only the necessary explanation.", strings.Join(rule.WinningPath, " -> ")) return fmt.Sprintf(
"Apply the recorded path `%s`, then return the final result with only the necessary explanation.",
strings.Join(rule.WinningPath, " -> "),
)
} }
return "Follow the source skill guidance below as one compact procedure, then return the final result without replaying unnecessary discovery steps." return "Follow the source skill guidance below as one compact procedure, then return the final result without replaying unnecessary discovery steps."
} }
@ -994,12 +1017,18 @@ func synthesizedWrappedPathLine(rule LearningRecord) string {
func synthesizedCombinedLearnedContent(body string, rule LearningRecord) string { func synthesizedCombinedLearnedContent(body string, rule LearningRecord) string {
content := strings.TrimSpace(stripSkillFrontmatter(body)) content := strings.TrimSpace(stripSkillFrontmatter(body))
if content == "" { if content == "" {
return fmt.Sprintf("Learned from `%s`; use this shortcut directly when the same task pattern appears again.", fallbackEvolutionSummary(rule)) return fmt.Sprintf(
"Learned from `%s`; use this shortcut directly when the same task pattern appears again.",
fallbackEvolutionSummary(rule),
)
} }
content = removeVerboseCombinedSections(content) content = removeVerboseCombinedSections(content)
content = strings.Join(strings.Fields(content), " ") content = strings.Join(strings.Fields(content), " ")
if content == "" { if content == "" {
return fmt.Sprintf("Learned from `%s`; use this shortcut directly when the same task pattern appears again.", fallbackEvolutionSummary(rule)) return fmt.Sprintf(
"Learned from `%s`; use this shortcut directly when the same task pattern appears again.",
fallbackEvolutionSummary(rule),
)
} }
content = trimAtReadableBoundary(content, 1200) content = trimAtReadableBoundary(content, 1200)
return "- Learned task: " + fallbackEvolutionSummary(rule) + "\n- Reusable guidance: " + content return "- Learned task: " + fallbackEvolutionSummary(rule) + "\n- Reusable guidance: " + content
@ -1275,7 +1304,8 @@ func markTaskRecordsClustered(store *Store, ids []string) error {
func filterReadyRules(records []LearningRecord, workspace string) []LearningRecord { func filterReadyRules(records []LearningRecord, workspace string) []LearningRecord {
seen := make(map[string]LearningRecord) seen := make(map[string]LearningRecord)
for _, record := range records { for _, record := range records {
if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace || record.Status != RecordStatus("ready") { if !isPatternRecordKind(record.Kind) || record.WorkspaceID != workspace ||
record.Status != RecordStatus("ready") {
continue continue
} }
seen[record.ID] = record seen[record.ID] = record
@ -1341,7 +1371,10 @@ func (rt *Runtime) applyCandidateDraft(
draft.Status = DraftStatusQuarantined draft.Status = DraftStatusQuarantined
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply failed: %v", err)) draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply failed: %v", err))
if auditErr := rt.recordRollbackAudit(store, draft, err); auditErr != nil { if auditErr := rt.recordRollbackAudit(store, draft, err); auditErr != nil {
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("rollback audit failed: %v", auditErr)) draft.ScanFindings = appendUniqueStrings(
draft.ScanFindings,
fmt.Sprintf("rollback audit failed: %v", auditErr),
)
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr) return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), auditErr, saveErr)
} }
@ -1379,7 +1412,10 @@ func (rt *Runtime) applyCandidateDraft(
draft.Status = DraftStatusQuarantined draft.Status = DraftStatusQuarantined
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("profile save failed: %v", err)) draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("profile save failed: %v", err))
if rollbackErr := rollbackApply(); rollbackErr != nil { if rollbackErr := rollbackApply(); rollbackErr != nil {
draft.ScanFindings = appendUniqueStrings(draft.ScanFindings, fmt.Sprintf("apply rollback failed: %v", rollbackErr)) draft.ScanFindings = appendUniqueStrings(
draft.ScanFindings,
fmt.Sprintf("apply rollback failed: %v", rollbackErr),
)
if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil { if saveErr := store.SaveDrafts([]SkillDraft{draft}); saveErr != nil {
return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr) return draft, errorsJoin(fmt.Errorf("%w: %v", ErrApplyDraftFailed, err), rollbackErr, saveErr)
} }
@ -1401,7 +1437,10 @@ func (rt *Runtime) applyCandidateDraft(
func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error { func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr error) error {
now := rt.now() now := rt.now()
return store.UpdateProfile(draft.WorkspaceID, draft.TargetSkillName, func(profile *SkillProfile, exists bool) error { return store.UpdateProfile(
draft.WorkspaceID,
draft.TargetSkillName,
func(profile *SkillProfile, exists bool) error {
if !exists { if !exists {
return nil return nil
} }
@ -1415,7 +1454,8 @@ func (rt *Runtime) recordRollbackAudit(store *Store, draft SkillDraft, applyErr
RollbackReason: applyErr.Error(), RollbackReason: applyErr.Error(),
}) })
return nil return nil
}) },
)
} }
func profileOrigin(origin string) string { func profileOrigin(origin string) string {

View file

@ -62,13 +62,13 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
skillPath := filepath.Join(root, "skills", "weather", "SKILL.md") skillPath := filepath.Join(root, "skills", "weather", "SKILL.md")
if _, err := os.Stat(skillPath); err != nil { if _, statErr := os.Stat(skillPath); statErr != nil {
t.Fatalf("expected skill file: %v", err) t.Fatalf("expected skill file: %v", statErr)
} }
profile, err := store.LoadProfile("weather") profile, err := store.LoadProfile("weather")
@ -90,7 +90,8 @@ func TestRuntime_RunColdPathOnce_ApplyModeWritesSkillAndProfile(t *testing.T) {
if len(profile.PreferredEntryPath) != 1 || profile.PreferredEntryPath[0] != "weather" { if len(profile.PreferredEntryPath) != 1 || profile.PreferredEntryPath[0] != "weather" {
t.Fatalf("PreferredEntryPath = %v, want [weather]", profile.PreferredEntryPath) t.Fatalf("PreferredEntryPath = %v, want [weather]", profile.PreferredEntryPath)
} }
if len(profile.AvoidPatterns) != 1 || profile.AvoidPatterns[0] != "avoid translating city names before querying weather" { if len(profile.AvoidPatterns) != 1 ||
profile.AvoidPatterns[0] != "avoid translating city names before querying weather" {
t.Fatalf("AvoidPatterns = %v, want populated metadata", profile.AvoidPatterns) t.Fatalf("AvoidPatterns = %v, want populated metadata", profile.AvoidPatterns)
} }
@ -149,15 +150,15 @@ func TestRuntime_RunColdPathOnce_DraftModeKeepsCandidateDraft(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if _, err := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); !os.IsNotExist(err) { if _, statErr := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); !os.IsNotExist(statErr) {
t.Fatalf("expected no applied skill file, got err=%v", err) t.Fatalf("expected no applied skill file, got err=%v", statErr)
} }
if _, err := store.LoadProfile("weather"); !os.IsNotExist(err) { if _, loadErr := store.LoadProfile("weather"); !os.IsNotExist(loadErr) {
t.Fatalf("expected no profile, got err=%v", err) t.Fatalf("expected no profile, got err=%v", loadErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -218,7 +219,8 @@ func TestRuntime_RunColdPathOnce_DraftModeRefreshesExistingCandidateWithEvidence
}}); err != nil { }}); err != nil {
t.Fatalf("SavePatternRecords: %v", err) t.Fatalf("SavePatternRecords: %v", err)
} }
if err := store.SaveDrafts([]evolution.SkillDraft{{ if err := store.SaveDrafts([]evolution.SkillDraft{
{
ID: "draft-pattern-1", ID: "draft-pattern-1",
WorkspaceID: root, WorkspaceID: root,
SourceRecordID: "pattern-1", SourceRecordID: "pattern-1",
@ -228,7 +230,8 @@ func TestRuntime_RunColdPathOnce_DraftModeRefreshesExistingCandidateWithEvidence
HumanSummary: "old generic draft", HumanSummary: "old generic draft",
BodyOrPatch: "---\nname: learned-skill\ndescription: old\n---\n# Learned Skill\n\nNo explicit winning path was recorded.\n", BodyOrPatch: "---\nname: learned-skill\ndescription: old\n---\n# Learned Skill\n\nNo explicit winning path was recorded.\n",
Status: evolution.DraftStatusCandidate, Status: evolution.DraftStatusCandidate,
}}); err != nil { },
}); err != nil {
t.Fatalf("SaveDrafts: %v", err) t.Fatalf("SaveDrafts: %v", err)
} }
@ -241,8 +244,8 @@ func TestRuntime_RunColdPathOnce_DraftModeRefreshesExistingCandidateWithEvidence
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -326,15 +329,15 @@ func TestRuntime_RunColdPathOnce_ApplyModeAppliesExistingCandidateDraft(t *testi
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if _, err := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); err != nil { if _, statErr := os.Stat(filepath.Join(root, "skills", "weather", "SKILL.md")); statErr != nil {
t.Fatalf("expected existing candidate to be applied: %v", err) t.Fatalf("expected existing candidate to be applied: %v", statErr)
} }
if _, err := os.Stat(filepath.Join(root, "skills", "unused-weather", "SKILL.md")); !os.IsNotExist(err) { if _, statErr := os.Stat(filepath.Join(root, "skills", "unused-weather", "SKILL.md")); !os.IsNotExist(statErr) {
t.Fatalf("expected source rule to stay skipped after applying existing draft, got err=%v", err) t.Fatalf("expected source rule to stay skipped after applying existing draft, got err=%v", statErr)
} }
profile, err := store.LoadProfile("weather") profile, err := store.LoadProfile("weather")
if err != nil { if err != nil {
@ -414,15 +417,15 @@ func TestRuntime_RunColdPathOnce_ApplyModeSkipsOrphanCandidateDraft(t *testing.T
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if _, err := os.Stat(filepath.Join(root, "skills", "orphan-weather", "SKILL.md")); !os.IsNotExist(err) { if _, statErr := os.Stat(filepath.Join(root, "skills", "orphan-weather", "SKILL.md")); !os.IsNotExist(statErr) {
t.Fatalf("orphan candidate draft should not be applied, got err=%v", err) t.Fatalf("orphan candidate draft should not be applied, got err=%v", statErr)
} }
if _, err := os.Stat(filepath.Join(root, "skills", "valid-weather", "SKILL.md")); err != nil { if _, statErr := os.Stat(filepath.Join(root, "skills", "valid-weather", "SKILL.md")); statErr != nil {
t.Fatalf("expected current ready rule draft to be applied: %v", err) t.Fatalf("expected current ready rule draft to be applied: %v", statErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
if err != nil { if err != nil {
@ -486,7 +489,10 @@ func TestRuntime_RunColdPathOnce_ApplyModeNormalizesExistingCombinedCandidateDra
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store, Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { return time.Unix(1700001000, 0).UTC() }), Applier: evolution.NewApplier(
evolution.NewPaths(root, ""),
func() time.Time { return time.Unix(1700001000, 0).UTC() },
),
DraftGenerator: stubDraftGenerator{}, DraftGenerator: stubDraftGenerator{},
Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}), Organizer: evolution.NewOrganizer(evolution.OrganizerOptions{MinCaseCount: 3, MinSuccessRate: 0.7}),
SkillsRecaller: evolution.NewSkillsRecaller(root), SkillsRecaller: evolution.NewSkillsRecaller(root),
@ -495,8 +501,8 @@ func TestRuntime_RunColdPathOnce_ApplyModeNormalizesExistingCombinedCandidateDra
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
data, err := os.ReadFile(filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md")) data, err := os.ReadFile(filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md"))
@ -510,7 +516,8 @@ func TestRuntime_RunColdPathOnce_ApplyModeNormalizesExistingCombinedCandidateDra
if strings.Contains(content, "Learned") || strings.Contains(content, "Source Evidence") { if strings.Contains(content, "Learned") || strings.Contains(content, "Source Evidence") {
t.Fatalf("deployed skill should not expose learning traces:\n%s", content) t.Fatalf("deployed skill should not expose learning traces:\n%s", content)
} }
if strings.Contains(content, "messy raw component dump") || strings.Contains(content, "## Component Skill Breakdown") { if strings.Contains(content, "messy raw component dump") ||
strings.Contains(content, "## Component Skill Breakdown") {
t.Fatalf("expected old verbose draft content to be cleaned:\n%s", content) t.Fatalf("expected old verbose draft content to be cleaned:\n%s", content)
} }
@ -585,8 +592,8 @@ func TestRuntime_RunColdPathOnce_ApplyModeRetargetsStableMultiSkillPathIntoCombi
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
skillPath := filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md") skillPath := filepath.Join(root, "skills", "calculate-100-via-theorems", "SKILL.md")
@ -613,7 +620,8 @@ func TestRuntime_RunColdPathOnce_ApplyModeRetargetsStableMultiSkillPathIntoCombi
if strings.Contains(content, "Learned") || strings.Contains(content, "Source Evidence") { if strings.Contains(content, "Learned") || strings.Contains(content, "Source Evidence") {
t.Fatalf("deployed skill should not expose learning traces:\n%s", content) t.Fatalf("deployed skill should not expose learning traces:\n%s", content)
} }
if !strings.Contains(content, "Add 31 to the input") || !strings.Contains(content, "Subtract 53 to produce the final result") { if !strings.Contains(content, "Add 31 to the input") ||
!strings.Contains(content, "Subtract 53 to produce the final result") {
t.Fatalf("missing extracted component skill content:\n%s", content) t.Fatalf("missing extracted component skill content:\n%s", content)
} }
if strings.Contains(content, "Extracted guidance") { if strings.Contains(content, "Extracted guidance") {
@ -682,7 +690,10 @@ func TestRuntime_RunColdPathOnce_CombinedShortcutKeepsReadableLongGuidance(t *te
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"}, Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
Now: func() time.Time { return time.Unix(1700001000, 0).UTC() }, Now: func() time.Time { return time.Unix(1700001000, 0).UTC() },
Store: store, Store: store,
Applier: evolution.NewApplier(evolution.NewPaths(root, ""), func() time.Time { return time.Unix(1700001000, 0).UTC() }), Applier: evolution.NewApplier(
evolution.NewPaths(root, ""),
func() time.Time { return time.Unix(1700001000, 0).UTC() },
),
DraftGenerator: stubDraftGenerator{draft: evolution.SkillDraft{ DraftGenerator: stubDraftGenerator{draft: evolution.SkillDraft{
ID: "draft-1", ID: "draft-1",
WorkspaceID: root, WorkspaceID: root,
@ -704,8 +715,8 @@ func TestRuntime_RunColdPathOnce_CombinedShortcutKeepsReadableLongGuidance(t *te
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
data, err := os.ReadFile(filepath.Join(root, "skills", "calculate-with-theorem-chain-via-theorems", "SKILL.md")) data, err := os.ReadFile(filepath.Join(root, "skills", "calculate-with-theorem-chain-via-theorems", "SKILL.md"))
@ -915,8 +926,8 @@ func TestRuntime_RunColdPathOnce_FirstApplyFailureDoesNotCreateGhostProfile(t *t
t.Fatalf("error = %v, want ErrApplyDraftFailed", err) t.Fatalf("error = %v, want ErrApplyDraftFailed", err)
} }
if _, err := store.LoadProfile("weather"); !os.IsNotExist(err) { if _, loadErr := store.LoadProfile("weather"); !os.IsNotExist(loadErr) {
t.Fatalf("expected no profile after first apply failure, got err=%v", err) t.Fatalf("expected no profile after first apply failure, got err=%v", loadErr)
} }
} }
@ -987,8 +998,8 @@ func TestRuntime_RunColdPathOnce_DraftSaveFailureRollsBackAppliedSkill(t *testin
if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
t.Fatalf("expected applied skill to be rolled back, got err=%v", statErr) t.Fatalf("expected applied skill to be rolled back, got err=%v", statErr)
} }
if _, err := store.LoadProfile("weather"); !os.IsNotExist(err) { if _, loadErr := store.LoadProfile("weather"); !os.IsNotExist(loadErr) {
t.Fatalf("expected no profile after draft save failure, got err=%v", err) t.Fatalf("expected no profile after draft save failure, got err=%v", loadErr)
} }
} }
@ -1015,7 +1026,11 @@ func TestRuntime_RunColdPathOnce_AutoRunsLifecycleMaintenance(t *testing.T) {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", err)
} }
skillPath := filepath.Join(skillDir, "SKILL.md") 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 { 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) t.Fatalf("WriteFile: %v", err)
} }
if err := store.SaveProfile(evolution.SkillProfile{ if err := store.SaveProfile(evolution.SkillProfile{
@ -1044,8 +1059,8 @@ func TestRuntime_RunColdPathOnce_AutoRunsLifecycleMaintenance(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
activeProfile, err := store.LoadProfile("stale-active-skill") activeProfile, err := store.LoadProfile("stale-active-skill")
@ -1070,8 +1085,8 @@ func TestRuntime_RunColdPathOnce_AutoRunsLifecycleMaintenance(t *testing.T) {
t.Fatalf("archived profile VersionHistory = %+v, want lifecycle:deleted entry", archivedProfile.VersionHistory) t.Fatalf("archived profile VersionHistory = %+v, want lifecycle:deleted entry", archivedProfile.VersionHistory)
} }
if _, err := os.Stat(skillPath); !os.IsNotExist(err) { if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) {
t.Fatalf("expected lifecycle delete to remove skill file, stat err = %v", err) t.Fatalf("expected lifecycle delete to remove skill file, stat err = %v", statErr)
} }
} }

View file

@ -136,8 +136,8 @@ func TestRuntime_RunColdPathOnce_GeneratesCandidateDraft(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -244,8 +244,8 @@ func TestRuntime_RunColdPathOnce_AdmitsOnlyRecordsApprovedBySuccessJudge(t *test
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if len(judge.calls) != 2 || judge.calls[0] != "task-rejected" || judge.calls[1] != "task-admitted" { if len(judge.calls) != 2 || judge.calls[0] != "task-rejected" || judge.calls[1] != "task-admitted" {
@ -354,8 +354,8 @@ func TestRuntime_RunColdPathOnce_RejectsClusterBelowMinSuccessRatio(t *testing.T
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
patterns, err := store.LoadPatternRecords() patterns, err := store.LoadPatternRecords()
@ -442,8 +442,8 @@ func TestRuntime_RunColdPathOnce_FallbackUsesJudgeAdjustedSuccessRatio(t *testin
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
patterns, err := store.LoadPatternRecords() patterns, err := store.LoadPatternRecords()
@ -530,8 +530,8 @@ func TestRuntime_RunColdPathOnce_FallbackMarksAcceptedFailureEvidenceClustered(t
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
patterns, err := store.LoadPatternRecords() patterns, err := store.LoadPatternRecords()
@ -615,11 +615,15 @@ func TestRuntime_RunColdPathOnce_DraftEvidenceDoesNotCrossWorkspaceWithDuplicate
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), workspaceA); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), workspaceA); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if len(generator.evidence.TaskRecords) != 1 { if len(generator.evidence.TaskRecords) != 1 {
t.Fatalf("evidence task count = %d, want 1: %#v", len(generator.evidence.TaskRecords), generator.evidence.TaskRecords) t.Fatalf(
"evidence task count = %d, want 1: %#v",
len(generator.evidence.TaskRecords),
generator.evidence.TaskRecords,
)
} }
task := generator.evidence.TaskRecords[0] task := generator.evidence.TaskRecords[0]
if task.WorkspaceID != workspaceA { if task.WorkspaceID != workspaceA {
@ -651,7 +655,9 @@ func TestRuntime_RunColdPathOnce_AdmitsSingleSkillTaskButWaitsForMinTaskCount(t
UsedSkillNames: []string{"weather"}, UsedSkillNames: []string{"weather"},
AddedSkillNames: []string{"weather"}, AddedSkillNames: []string{"weather"},
ToolKinds: []string{"read_file"}, ToolKinds: []string{"read_file"},
ToolExecutions: []evolution.ToolExecutionRecord{{Name: "read_file", Success: true, SkillNames: []string{"weather"}}}, ToolExecutions: []evolution.ToolExecutionRecord{
{Name: "read_file", Success: true, SkillNames: []string{"weather"}},
},
AttemptTrail: &evolution.AttemptTrail{ AttemptTrail: &evolution.AttemptTrail{
AttemptedSkills: []string{"weather"}, AttemptedSkills: []string{"weather"},
FinalSuccessfulPath: []string{"weather"}, FinalSuccessfulPath: []string{"weather"},
@ -683,8 +689,8 @@ func TestRuntime_RunColdPathOnce_AdmitsSingleSkillTaskButWaitsForMinTaskCount(t
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
if len(judge.calls) != 1 || judge.calls[0] != "task-simple" { if len(judge.calls) != 1 || judge.calls[0] != "task-simple" {
t.Fatalf("judge calls = %v, want [task-simple]", judge.calls) t.Fatalf("judge calls = %v, want [task-simple]", judge.calls)
@ -757,8 +763,8 @@ func TestRuntime_RunColdPathOnce_RejectsTaskWhenSuccessJudgeRejects(t *testing.T
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
allRecords, err := store.LoadLearningRecords() allRecords, err := store.LoadLearningRecords()
@ -817,8 +823,8 @@ func TestRuntime_RunColdPathOnce_QuarantinesInvalidDraft(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -842,7 +848,11 @@ func TestRuntime_RunColdPathOnce_DoesNotWriteSkillFile(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", err)
} }
if err := os.WriteFile(skillPath, []byte("---\nname: weather\ndescription: test\n---\n# Weather"), 0o644); err != nil { if err := os.WriteFile(
skillPath,
[]byte("---\nname: weather\ndescription: test\n---\n# Weather"),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err) t.Fatalf("WriteFile: %v", err)
} }
@ -886,8 +896,8 @@ func TestRuntime_RunColdPathOnce_DoesNotWriteSkillFile(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
got, err := os.ReadFile(skillPath) got, err := os.ReadFile(skillPath)
@ -926,8 +936,8 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGenerator(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -981,8 +991,8 @@ func TestRuntime_RunColdPathOnce_UsesLLMDraftGeneratorWhenProviderAvailable(t *t
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -1028,8 +1038,8 @@ func TestRuntime_RunColdPathOnce_UsesDefaultDraftGeneratorWhenFactoryHasNoProvid
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -1090,8 +1100,8 @@ func TestRuntime_RunColdPathOnce_UsesGeneratorFactoryWorkspaceForFallback(t *tes
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()
@ -1233,8 +1243,8 @@ func TestRuntime_RunColdPathOnce_RegeneratesAfterQuarantinedDraft(t *testing.T)
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.RunColdPathOnce(context.Background(), root); err != nil { if runErr := rt.RunColdPathOnce(context.Background(), root); runErr != nil {
t.Fatalf("RunColdPathOnce: %v", err) t.Fatalf("RunColdPathOnce: %v", runErr)
} }
drafts, err := store.LoadDrafts() drafts, err := store.LoadDrafts()

View file

@ -33,8 +33,8 @@ func TestRuntime_FinalizeTurnDisabledDoesNothing(t *testing.T) {
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
if _, err := os.Stat(paths.TaskRecords); !os.IsNotExist(err) { if _, statErr := os.Stat(paths.TaskRecords); !os.IsNotExist(statErr) {
t.Fatalf("task records file should not exist, stat err = %v", err) t.Fatalf("task records file should not exist, stat err = %v", statErr)
} }
} }
@ -46,11 +46,11 @@ func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
TurnID: "turn-1", TurnID: "turn-1",
Status: "completed", Status: "completed",
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
} }
@ -63,20 +63,20 @@ func TestRuntime_FinalizeTurnSkipsHeartbeat(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "heartbeat-turn", TurnID: "heartbeat-turn",
SessionKey: "heartbeat", SessionKey: "heartbeat",
Status: "completed", Status: "completed",
UserMessage: "# Heartbeat Check", UserMessage: "# Heartbeat Check",
FinalContent: "HEARTBEAT_OK", FinalContent: "HEARTBEAT_OK",
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
if _, err := os.Stat(paths.TaskRecords); !os.IsNotExist(err) { if _, statErr := os.Stat(paths.TaskRecords); !os.IsNotExist(statErr) {
t.Fatalf("heartbeat should not create task records, stat err = %v", err) t.Fatalf("heartbeat should not create task records, stat err = %v", statErr)
} }
} }
@ -97,7 +97,7 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-1", TurnID: "turn-1",
SessionKey: "session-1", SessionKey: "session-1",
@ -111,11 +111,11 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
{Name: "read_file", Success: true}, {Name: "read_file", Success: true},
}, },
ActiveSkillNames: []string{"skill-a"}, ActiveSkillNames: []string{"skill-a"},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn first call: %v", err) t.Fatalf("FinalizeTurn first call: %v", finalizeErr)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
WorkspaceID: "ws-explicit", WorkspaceID: "ws-explicit",
TurnID: "turn-2", TurnID: "turn-2",
@ -129,8 +129,8 @@ func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
{Name: "bash", Success: false, ErrorSummary: "exit status 1"}, {Name: "bash", Success: false, ErrorSummary: "exit status 1"},
}, },
ActiveSkillNames: []string{"skill-b"}, ActiveSkillNames: []string{"skill-b"},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn second call: %v", err) t.Fatalf("FinalizeTurn second call: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, override) paths := evolution.NewPaths(workspace, override)
@ -226,12 +226,12 @@ func TestRuntime_FinalizeTurnGeneratesUniqueTaskRecordIDsAcrossRestartedTurnSequ
UserMessage: "summarize release notes", UserMessage: "summarize release notes",
FinalContent: "done", FinalContent: "done",
} }
if err := rt.FinalizeTurn(context.Background(), input); err != nil { if finalizeErr := rt.FinalizeTurn(context.Background(), input); finalizeErr != nil {
t.Fatalf("FinalizeTurn first: %v", err) t.Fatalf("FinalizeTurn first: %v", finalizeErr)
} }
input.SessionKey = "session-b" input.SessionKey = "session-b"
if err := rt.FinalizeTurn(context.Background(), input); err != nil { if finalizeErr := rt.FinalizeTurn(context.Background(), input); finalizeErr != nil {
t.Fatalf("FinalizeTurn second: %v", err) t.Fatalf("FinalizeTurn second: %v", finalizeErr)
} }
store := evolution.NewStore(evolution.NewPaths(workspace, "")) store := evolution.NewStore(evolution.NewPaths(workspace, ""))
@ -285,24 +285,24 @@ func TestRuntime_FinalizeTurnSharedStateKeepsSkillProfilesScoped(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspaceA, Workspace: workspaceA,
TurnID: "turn-a", TurnID: "turn-a",
SessionKey: "session-a", SessionKey: "session-a",
Status: "completed", Status: "completed",
ActiveSkillNames: []string{"weather"}, ActiveSkillNames: []string{"weather"},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn(workspaceA): %v", err) t.Fatalf("FinalizeTurn(workspaceA): %v", finalizeErr)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspaceB, Workspace: workspaceB,
TurnID: "turn-b", TurnID: "turn-b",
SessionKey: "session-b", SessionKey: "session-b",
Status: "completed", Status: "completed",
ActiveSkillNames: []string{"weather"}, ActiveSkillNames: []string{"weather"},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn(workspaceB): %v", err) t.Fatalf("FinalizeTurn(workspaceB): %v", finalizeErr)
} }
loadedA, err := storeA.LoadProfile("weather") loadedA, err := storeA.LoadProfile("weather")
@ -347,7 +347,7 @@ func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-learnable", TurnID: "turn-learnable",
SessionKey: "session-learnable", SessionKey: "session-learnable",
@ -363,8 +363,8 @@ func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) {
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}}, {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
}, },
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
@ -411,7 +411,7 @@ func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-skill-chain", TurnID: "turn-skill-chain",
SessionKey: "session-skill-chain", SessionKey: "session-skill-chain",
@ -424,8 +424,8 @@ func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) {
{Name: "read_file", Success: true, SkillNames: []string{"four-two"}}, {Name: "read_file", Success: true, SkillNames: []string{"four-two"}},
{Name: "read_file", Success: true, SkillNames: []string{"five-three"}}, {Name: "read_file", Success: true, SkillNames: []string{"five-three"}},
}, },
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
@ -446,7 +446,8 @@ func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) {
if got := record.AddedSkillNames; len(got) != 0 { if got := record.AddedSkillNames; len(got) != 0 {
t.Fatalf("AddedSkillNames = %v, want empty", got) t.Fatalf("AddedSkillNames = %v, want empty", got)
} }
if got := record.UsedSkillNames; len(got) != 3 || got[0] != "three-one" || got[1] != "four-two" || got[2] != "five-three" { 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) t.Fatalf("UsedSkillNames = %v, want [three-one four-two five-three]", got)
} }
if got := record.AllLoadedSkillNames; len(got) != 0 { if got := record.AllLoadedSkillNames; len(got) != 0 {
@ -464,7 +465,7 @@ func TestRuntime_FinalizeTurnPreservesUTF8WhenTruncatingChineseOutput(t *testing
} }
longChinese := strings.Repeat("中文输出", 500) longChinese := strings.Repeat("中文输出", 500)
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-utf8", TurnID: "turn-utf8",
SessionKey: "session-utf8", SessionKey: "session-utf8",
@ -472,8 +473,8 @@ func TestRuntime_FinalizeTurnPreservesUTF8WhenTruncatingChineseOutput(t *testing
Status: "completed", Status: "completed",
UserMessage: "请处理这段中文输出", UserMessage: "请处理这段中文输出",
FinalContent: longChinese, FinalContent: longChinese,
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
@ -514,7 +515,7 @@ func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-explicit-trail", TurnID: "turn-explicit-trail",
SessionKey: "session-explicit-trail", SessionKey: "session-explicit-trail",
@ -528,8 +529,8 @@ func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) {
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"weather"}}, {Sequence: 1, Trigger: "initial_build", SkillNames: []string{"weather"}},
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}}, {Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
}, },
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
paths := evolution.NewPaths(workspace, "") paths := evolution.NewPaths(workspace, "")
@ -579,15 +580,15 @@ func TestRuntime_FinalizeTurnUpdatesSkillProfileUsage(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-1", TurnID: "turn-1",
SessionKey: "session-1", SessionKey: "session-1",
AgentID: "agent-1", AgentID: "agent-1",
Status: "completed", Status: "completed",
ActiveSkillNames: []string{"skill-a", "skill-a"}, ActiveSkillNames: []string{"skill-a", "skill-a"},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
store := evolution.NewStore(evolution.NewPaths(workspace, "")) store := evolution.NewStore(evolution.NewPaths(workspace, ""))
@ -610,66 +611,37 @@ func TestRuntime_FinalizeTurnUpdatesSkillProfileUsage(t *testing.T) {
} }
func TestRuntime_FinalizeTurnReactivatesColdSkill(t *testing.T) { func TestRuntime_FinalizeTurnReactivatesColdSkill(t *testing.T) {
workspace := t.TempDir() assertFinalizeTurnReactivatesSkill(t, "skill-cold", evolution.SkillStatusCold, 2, 0.2, 24*time.Hour)
now := time.Unix(1700001000, 0).UTC()
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if err := store.SaveProfile(evolution.SkillProfile{
SkillName: "skill-cold",
WorkspaceID: workspace,
Status: evolution.SkillStatusCold,
Origin: "evolved",
HumanSummary: "cold skill",
LastUsedAt: now.Add(-24 * time.Hour),
UseCount: 2,
RetentionScore: 0.2,
}); err != nil {
t.Fatalf("SaveProfile: %v", err)
}
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
Now: func() time.Time { return now },
Store: store,
})
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace,
TurnID: "turn-cold",
Status: "completed",
ActiveSkillNames: []string{"skill-cold"},
}); err != nil {
t.Fatalf("FinalizeTurn: %v", err)
}
profile, err := store.LoadProfile("skill-cold")
if err != nil {
t.Fatalf("LoadProfile: %v", err)
}
if profile.Status != evolution.SkillStatusActive {
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive)
}
} }
func TestRuntime_FinalizeTurnReactivatesArchivedSkill(t *testing.T) { func TestRuntime_FinalizeTurnReactivatesArchivedSkill(t *testing.T) {
assertFinalizeTurnReactivatesSkill(t, "skill-archived", evolution.SkillStatusArchived, 5, 0.1, 48*time.Hour)
}
func assertFinalizeTurnReactivatesSkill(
t *testing.T,
skillName string,
initialStatus evolution.SkillStatus,
useCount int,
retentionScore float64,
lastUsedAge time.Duration,
) {
t.Helper()
workspace := t.TempDir() workspace := t.TempDir()
now := time.Unix(1700002000, 0).UTC() now := time.Unix(1700002000, 0).UTC()
store := evolution.NewStore(evolution.NewPaths(workspace, "")) store := evolution.NewStore(evolution.NewPaths(workspace, ""))
if err := store.SaveProfile(evolution.SkillProfile{ if saveErr := store.SaveProfile(evolution.SkillProfile{
SkillName: "skill-archived", SkillName: skillName,
WorkspaceID: workspace, WorkspaceID: workspace,
Status: evolution.SkillStatusArchived, Status: initialStatus,
Origin: "evolved", Origin: "evolved",
HumanSummary: "archived skill", HumanSummary: string(initialStatus) + " skill",
LastUsedAt: now.Add(-48 * time.Hour), LastUsedAt: now.Add(-lastUsedAge),
UseCount: 5, UseCount: useCount,
RetentionScore: 0.1, RetentionScore: retentionScore,
}); err != nil { }); saveErr != nil {
t.Fatalf("SaveProfile: %v", err) t.Fatalf("SaveProfile: %v", saveErr)
} }
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{ rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
@ -681,16 +653,16 @@ func TestRuntime_FinalizeTurnReactivatesArchivedSkill(t *testing.T) {
t.Fatalf("NewRuntime: %v", err) t.Fatalf("NewRuntime: %v", err)
} }
if err := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{ if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
Workspace: workspace, Workspace: workspace,
TurnID: "turn-archived", TurnID: "turn-" + skillName,
Status: "completed", Status: "completed",
ActiveSkillNames: []string{"skill-archived"}, ActiveSkillNames: []string{skillName},
}); err != nil { }); finalizeErr != nil {
t.Fatalf("FinalizeTurn: %v", err) t.Fatalf("FinalizeTurn: %v", finalizeErr)
} }
profile, err := store.LoadProfile("skill-archived") profile, err := store.LoadProfile(skillName)
if err != nil { if err != nil {
t.Fatalf("LoadProfile: %v", err) t.Fatalf("LoadProfile: %v", err)
} }

View file

@ -28,9 +28,21 @@ func TestRecallSimilarSkills_ReturnsWorkspaceSkillFirst(t *testing.T) {
} }
} }
mustWriteSkill(filepath.Join(workspace, "skills"), "weather", "---\nname: weather\ndescription: weather lookup\n---\n# Weather\nUse weather queries.\n") mustWriteSkill(
mustWriteSkill(filepath.Join(globalHome, ".picoclaw", "skills"), "release", "---\nname: release\ndescription: release flow\n---\n# Release\nRelease build.\n") filepath.Join(workspace, "skills"),
mustWriteSkill(builtinRoot, "weather-fallback", "---\nname: weather-fallback\ndescription: weather backup\n---\n# Weather Fallback\nBackup weather path.\n") "weather",
"---\nname: weather\ndescription: weather lookup\n---\n# Weather\nUse weather queries.\n",
)
mustWriteSkill(
filepath.Join(globalHome, ".picoclaw", "skills"),
"release",
"---\nname: release\ndescription: release flow\n---\n# Release\nRelease build.\n",
)
mustWriteSkill(
builtinRoot,
"weather-fallback",
"---\nname: weather-fallback\ndescription: weather backup\n---\n# Weather Fallback\nBackup weather path.\n",
)
recaller := evolution.NewSkillsRecaller(workspace) recaller := evolution.NewSkillsRecaller(workspace)
matches, err := recaller.RecallSimilarSkills(evolution.LearningRecord{ matches, err := recaller.RecallSimilarSkills(evolution.LearningRecord{

View file

@ -80,8 +80,8 @@ func (s *Store) appendJSONLRecords(ctx context.Context, path string, records []L
unlock := lockStoreFile(path) unlock := lockStoreFile(path)
defer unlock() defer unlock()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o755); mkdirErr != nil {
return err return mkdirErr
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
@ -277,8 +277,8 @@ func (s *Store) saveJSONLRecords(path string, records []LearningRecord) error {
} }
func (s *Store) saveJSONLRecordsLocked(path string, records []LearningRecord) error { func (s *Store) saveJSONLRecordsLocked(path string, records []LearningRecord) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o755); mkdirErr != nil {
return err return mkdirErr
} }
var buf bytes.Buffer var buf bytes.Buffer
@ -383,8 +383,8 @@ func (s *Store) SaveProfile(profile SkillProfile) error {
unlock := lockStoreFile(path) unlock := lockStoreFile(path)
defer unlock() defer unlock()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { if mkdirErr := os.MkdirAll(filepath.Dir(path), 0o755); mkdirErr != nil {
return err return mkdirErr
} }
data, err := json.MarshalIndent(profile, "", " ") data, err := json.MarshalIndent(profile, "", " ")
@ -418,14 +418,14 @@ func (s *Store) UpdateProfile(
return err return err
} }
if err := update(&profile, exists); err != nil { if updateErr := update(&profile, exists); updateErr != nil {
return err return updateErr
} }
if !exists && isZeroSkillProfile(profile) { if !exists && isZeroSkillProfile(profile) {
return nil return nil
} }
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { if mkdirErr := os.MkdirAll(filepath.Dir(targetPath), 0o755); mkdirErr != nil {
return err return mkdirErr
} }
data, err := json.MarshalIndent(profile, "", " ") data, err := json.MarshalIndent(profile, "", " ")

View file

@ -49,14 +49,14 @@ func TestStore_AppendLearningRecordsPersistsCaseAndRule(t *testing.T) {
if loaded[1].Kind != evolution.RecordKindRule { if loaded[1].Kind != evolution.RecordKindRule {
t.Fatalf("loaded[1].Kind = %q, want %q", loaded[1].Kind, evolution.RecordKindRule) t.Fatalf("loaded[1].Kind = %q, want %q", loaded[1].Kind, evolution.RecordKindRule)
} }
if _, err := os.Stat(paths.LearningRecords); !os.IsNotExist(err) { if _, statErr := os.Stat(paths.LearningRecords); !os.IsNotExist(statErr) {
t.Fatalf("legacy learning records file should not be written, stat err = %v", err) t.Fatalf("legacy learning records file should not be written, stat err = %v", statErr)
} }
if _, err := os.Stat(paths.TaskRecords); err != nil { if _, statErr := os.Stat(paths.TaskRecords); statErr != nil {
t.Fatalf("task records file should exist: %v", err) t.Fatalf("task records file should exist: %v", statErr)
} }
if _, err := os.Stat(paths.PatternRecords); err != nil { if _, statErr := os.Stat(paths.PatternRecords); statErr != nil {
t.Fatalf("pattern records file should exist: %v", err) t.Fatalf("pattern records file should exist: %v", statErr)
} }
} }
@ -77,11 +77,11 @@ func TestStore_LoadTaskRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Marshal legacy: %v", err) t.Fatalf("Marshal legacy: %v", err)
} }
if err := os.MkdirAll(paths.RootDir, 0o755); err != nil { if mkdirErr := os.MkdirAll(paths.RootDir, 0o755); mkdirErr != nil {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", mkdirErr)
} }
if err := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); err != nil { if writeErr := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); writeErr != nil {
t.Fatalf("WriteFile legacy: %v", err) t.Fatalf("WriteFile legacy: %v", writeErr)
} }
current := evolution.LearningRecord{ current := evolution.LearningRecord{
@ -92,8 +92,8 @@ func TestStore_LoadTaskRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
Summary: "current task", Summary: "current task",
Status: evolution.RecordStatus("new"), Status: evolution.RecordStatus("new"),
} }
if err := store.AppendTaskRecord(context.Background(), current); err != nil { if appendErr := store.AppendTaskRecord(context.Background(), current); appendErr != nil {
t.Fatalf("AppendTaskRecord: %v", err) t.Fatalf("AppendTaskRecord: %v", appendErr)
} }
records, err := store.LoadTaskRecords() records, err := store.LoadTaskRecords()
@ -126,11 +126,11 @@ func TestStore_LoadPatternRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Marshal legacy: %v", err) t.Fatalf("Marshal legacy: %v", err)
} }
if err := os.MkdirAll(paths.RootDir, 0o755); err != nil { if mkdirErr := os.MkdirAll(paths.RootDir, 0o755); mkdirErr != nil {
t.Fatalf("MkdirAll: %v", err) t.Fatalf("MkdirAll: %v", mkdirErr)
} }
if err := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); err != nil { if writeErr := os.WriteFile(paths.LearningRecords, append(data, '\n'), 0o644); writeErr != nil {
t.Fatalf("WriteFile legacy: %v", err) t.Fatalf("WriteFile legacy: %v", writeErr)
} }
current := evolution.LearningRecord{ current := evolution.LearningRecord{
@ -141,8 +141,8 @@ func TestStore_LoadPatternRecordsMergesLegacyWhenSplitFileExists(t *testing.T) {
Summary: "current pattern", Summary: "current pattern",
Status: evolution.RecordStatus("ready"), Status: evolution.RecordStatus("ready"),
} }
if err := store.AppendPatternRecords([]evolution.LearningRecord{current}); err != nil { if appendErr := store.AppendPatternRecords([]evolution.LearningRecord{current}); appendErr != nil {
t.Fatalf("AppendPatternRecords: %v", err) t.Fatalf("AppendPatternRecords: %v", appendErr)
} }
records, err := store.LoadPatternRecords() records, err := store.LoadPatternRecords()
@ -246,8 +246,8 @@ func TestStore_MergeKeepsSameRecordIDAcrossWorkspaces(t *testing.T) {
t.Fatalf("len(loaded) = %d, want 2: %+v", len(loaded), loaded) t.Fatalf("len(loaded) = %d, want 2: %+v", len(loaded), loaded)
} }
if err := store.MarkTaskRecordsClustered([]string{"main-turn-1"}); err != nil { if markErr := store.MarkTaskRecordsClustered([]string{"main-turn-1"}); markErr != nil {
t.Fatalf("MarkTaskRecordsClustered: %v", err) t.Fatalf("MarkTaskRecordsClustered: %v", markErr)
} }
loaded, err = store.LoadTaskRecords() loaded, err = store.LoadTaskRecords()
if err != nil { if err != nil {
@ -409,12 +409,12 @@ func TestStore_LoadLearningRecordsIgnoresTruncatedTrailingLine(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("OpenFile: %v", err) t.Fatalf("OpenFile: %v", err)
} }
if _, err := f.WriteString("{\"id\":\"broken\""); err != nil { if _, writeErr := f.WriteString("{\"id\":\"broken\""); writeErr != nil {
f.Close() f.Close()
t.Fatalf("WriteString: %v", err) t.Fatalf("WriteString: %v", writeErr)
} }
if err := f.Close(); err != nil { if closeErr := f.Close(); closeErr != nil {
t.Fatalf("Close: %v", err) t.Fatalf("Close: %v", closeErr)
} }
loaded, err := store.LoadLearningRecords() loaded, err := store.LoadLearningRecords()