mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b3a7b7ad64
* feat: add agent self-evolution * fix ci * delete unused doc * fix lint * fix evolution review issues
673 lines
21 KiB
Go
673 lines
21 KiB
Go
package evolution_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/evolution"
|
|
)
|
|
|
|
func TestRuntime_FinalizeTurnDisabledDoesNothing(t *testing.T) {
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: false, Mode: "observe"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
workspace := t.TempDir()
|
|
err = rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-1",
|
|
Status: "completed",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("FinalizeTurn: %v", err)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
if _, statErr := os.Stat(paths.TaskRecords); !os.IsNotExist(statErr) {
|
|
t.Fatalf("task records file should not exist, stat err = %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnWithEmptyWorkspaceDoesNothing(t *testing.T) {
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
TurnID: "turn-1",
|
|
Status: "completed",
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnSkipsHeartbeat(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "heartbeat-turn",
|
|
SessionKey: "heartbeat",
|
|
Status: "completed",
|
|
UserMessage: "# Heartbeat Check",
|
|
FinalContent: "HEARTBEAT_OK",
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
if _, statErr := os.Stat(paths.TaskRecords); !os.IsNotExist(statErr) {
|
|
t.Fatalf("heartbeat should not create task records, stat err = %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnWritesRecordWithOverride(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
override := filepath.Join(t.TempDir(), "custom-state")
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{
|
|
Enabled: true,
|
|
Mode: "observe",
|
|
StateDir: override,
|
|
},
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-1",
|
|
SessionKey: "session-1",
|
|
AgentID: "agent-1",
|
|
Status: "completed",
|
|
UserMessage: "summarize the release notes",
|
|
FinalContent: "Here is the summary.",
|
|
ToolKinds: []string{"web", "read_file"},
|
|
ToolExecutions: []evolution.ToolExecutionRecord{
|
|
{Name: "web", Success: true},
|
|
{Name: "read_file", Success: true},
|
|
},
|
|
ActiveSkillNames: []string{"skill-a"},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn first call: %v", finalizeErr)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
WorkspaceID: "ws-explicit",
|
|
TurnID: "turn-2",
|
|
SessionKey: "session-2",
|
|
AgentID: "agent-2",
|
|
Status: "error",
|
|
UserMessage: "run the bash command",
|
|
FinalContent: "bash failed",
|
|
ToolKinds: []string{"bash"},
|
|
ToolExecutions: []evolution.ToolExecutionRecord{
|
|
{Name: "bash", Success: false, ErrorSummary: "exit status 1"},
|
|
},
|
|
ActiveSkillNames: []string{"skill-b"},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn second call: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, override)
|
|
data, err := os.ReadFile(paths.TaskRecords)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) != 2 {
|
|
t.Fatalf("record file line count = %d, want 2", len(lines))
|
|
}
|
|
|
|
var first evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
|
t.Fatalf("Unmarshal first record: %v", err)
|
|
}
|
|
if first.WorkspaceID != workspace {
|
|
t.Fatalf("first WorkspaceID = %q, want %q", first.WorkspaceID, workspace)
|
|
}
|
|
if first.CreatedAt != now {
|
|
t.Fatalf("first CreatedAt = %v, want %v", first.CreatedAt, now)
|
|
}
|
|
if first.SessionKey != "session-1" {
|
|
t.Fatalf("first SessionKey = %q, want %q", first.SessionKey, "session-1")
|
|
}
|
|
if first.Summary != "summarize the release notes" {
|
|
t.Fatalf("first Summary = %q", first.Summary)
|
|
}
|
|
if first.FinalOutput != "Here is the summary." {
|
|
t.Fatalf("first FinalOutput = %q", first.FinalOutput)
|
|
}
|
|
if first.Success == nil || !*first.Success {
|
|
t.Fatalf("first Success = %v, want true", first.Success)
|
|
}
|
|
if len(first.AddedSkillNames) != 0 {
|
|
t.Fatalf("first AddedSkillNames = %v, want empty", first.AddedSkillNames)
|
|
}
|
|
if len(first.UsedSkillNames) != 0 {
|
|
t.Fatalf("first UsedSkillNames = %v, want empty", first.UsedSkillNames)
|
|
}
|
|
if len(first.ToolKinds) != 0 || len(first.ToolExecutions) != 0 || first.Source != nil || first.AttemptTrail != nil {
|
|
t.Fatalf("first record should be slimmed: %+v", first)
|
|
}
|
|
if first.TaskHash != "" || len(first.Signals) != 0 {
|
|
t.Fatalf("first record should not persist task_hash/signals: %+v", first)
|
|
}
|
|
|
|
var second evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
|
t.Fatalf("Unmarshal second record: %v", err)
|
|
}
|
|
if second.WorkspaceID != workspace {
|
|
t.Fatalf("second WorkspaceID = %q, want %q", second.WorkspaceID, workspace)
|
|
}
|
|
if second.SessionKey != "session-2" {
|
|
t.Fatalf("second SessionKey = %q, want %q", second.SessionKey, "session-2")
|
|
}
|
|
if second.Summary != "run the bash command" {
|
|
t.Fatalf("second Summary = %q", second.Summary)
|
|
}
|
|
if second.Success == nil || *second.Success {
|
|
t.Fatalf("second Success = %v, want false", second.Success)
|
|
}
|
|
if len(second.ToolExecutions) != 0 || second.Source != nil || second.AttemptTrail != nil {
|
|
t.Fatalf("second record should be slimmed: %+v", second)
|
|
}
|
|
if second.TaskHash != "" || len(second.Signals) != 0 {
|
|
t.Fatalf("second record should not persist task_hash/signals: %+v", second)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnGeneratesUniqueTaskRecordIDsAcrossRestartedTurnSequence(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
createdAt := time.Unix(1700000000, 0).UTC()
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
|
Now: func() time.Time {
|
|
createdAt = createdAt.Add(time.Second)
|
|
return createdAt
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
input := evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "main-turn-1",
|
|
SessionKey: "session-a",
|
|
AgentID: "main",
|
|
Status: "completed",
|
|
UserMessage: "summarize release notes",
|
|
FinalContent: "done",
|
|
}
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), input); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn first: %v", finalizeErr)
|
|
}
|
|
input.SessionKey = "session-b"
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), input); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn second: %v", finalizeErr)
|
|
}
|
|
|
|
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
|
records, err := store.LoadTaskRecords()
|
|
if err != nil {
|
|
t.Fatalf("LoadTaskRecords: %v", err)
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("len(records) = %d, want 2: %#v", len(records), records)
|
|
}
|
|
if records[0].ID == records[1].ID {
|
|
t.Fatalf("record IDs should be unique across repeated turn IDs: %#v", records)
|
|
}
|
|
for _, record := range records {
|
|
if !strings.HasPrefix(record.ID, "main-turn-1-") {
|
|
t.Fatalf("record ID = %q, want main-turn-1-*", record.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnSharedStateKeepsSkillProfilesScoped(t *testing.T) {
|
|
sharedState := t.TempDir()
|
|
workspaceA := t.TempDir()
|
|
workspaceB := t.TempDir()
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
|
|
storeA := evolution.NewStore(evolution.NewPaths(workspaceA, sharedState))
|
|
if err := storeA.SaveProfile(evolution.SkillProfile{
|
|
SkillName: "weather",
|
|
WorkspaceID: workspaceA,
|
|
CurrentVersion: "draft-a",
|
|
Status: evolution.SkillStatusActive,
|
|
Origin: "evolved",
|
|
HumanSummary: "workspace A weather helper",
|
|
LastUsedAt: now,
|
|
UseCount: 7,
|
|
RetentionScore: 0.9,
|
|
}); err != nil {
|
|
t.Fatalf("storeA.SaveProfile: %v", err)
|
|
}
|
|
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{
|
|
Enabled: true,
|
|
Mode: "observe",
|
|
StateDir: sharedState,
|
|
},
|
|
Now: func() time.Time { return now.Add(time.Minute) },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspaceA,
|
|
TurnID: "turn-a",
|
|
SessionKey: "session-a",
|
|
Status: "completed",
|
|
ActiveSkillNames: []string{"weather"},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn(workspaceA): %v", finalizeErr)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspaceB,
|
|
TurnID: "turn-b",
|
|
SessionKey: "session-b",
|
|
Status: "completed",
|
|
ActiveSkillNames: []string{"weather"},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn(workspaceB): %v", finalizeErr)
|
|
}
|
|
|
|
loadedA, err := storeA.LoadProfile("weather")
|
|
if err != nil {
|
|
t.Fatalf("storeA.LoadProfile: %v", err)
|
|
}
|
|
if loadedA.WorkspaceID != workspaceA {
|
|
t.Fatalf("workspace A profile WorkspaceID = %q, want %q", loadedA.WorkspaceID, workspaceA)
|
|
}
|
|
if loadedA.UseCount != 8 {
|
|
t.Fatalf("workspace A profile UseCount = %d, want 8", loadedA.UseCount)
|
|
}
|
|
|
|
storeB := evolution.NewStore(evolution.NewPaths(workspaceB, sharedState))
|
|
loadedB, err := storeB.LoadProfile("weather")
|
|
if err != nil {
|
|
t.Fatalf("storeB.LoadProfile: %v", err)
|
|
}
|
|
if loadedB.WorkspaceID != workspaceB {
|
|
t.Fatalf("workspace B profile WorkspaceID = %q, want %q", loadedB.WorkspaceID, workspaceB)
|
|
}
|
|
if loadedB.UseCount != 1 {
|
|
t.Fatalf("workspace B profile UseCount = %d, want 1", loadedB.UseCount)
|
|
}
|
|
if loadedB.Origin != "manual" {
|
|
t.Fatalf("workspace B profile Origin = %q, want manual", loadedB.Origin)
|
|
}
|
|
if loadedB.CurrentVersion != "" {
|
|
t.Fatalf("workspace B profile CurrentVersion = %q, want empty", loadedB.CurrentVersion)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnWritesPotentiallyLearnableSignal(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
now := time.Unix(1700003000, 0).UTC()
|
|
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-learnable",
|
|
SessionKey: "session-learnable",
|
|
AgentID: "agent-1",
|
|
Status: "completed",
|
|
ToolKinds: []string{"web", "bash"},
|
|
ActiveSkillNames: []string{"geocode", "weather"},
|
|
FinalContent: "weather workflow completed",
|
|
FinalSuccessfulPath: []string{
|
|
"weather",
|
|
},
|
|
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
|
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"geocode"}},
|
|
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
|
},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
data, err := os.ReadFile(paths.TaskRecords)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) != 1 {
|
|
t.Fatalf("record file line count = %d, want 1", len(lines))
|
|
}
|
|
|
|
var record evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
|
t.Fatalf("Unmarshal record: %v", err)
|
|
}
|
|
if len(record.Signals) != 0 {
|
|
t.Fatalf("Signals = %v, want empty", record.Signals)
|
|
}
|
|
if got := record.InitialSkillNames; len(got) != 0 {
|
|
t.Fatalf("InitialSkillNames = %v, want empty", got)
|
|
}
|
|
if got := record.AddedSkillNames; len(got) != 0 {
|
|
t.Fatalf("AddedSkillNames = %v, want empty", got)
|
|
}
|
|
if got := record.UsedSkillNames; len(got) != 1 || got[0] != "weather" {
|
|
t.Fatalf("UsedSkillNames = %v, want [weather]", got)
|
|
}
|
|
if got := record.AllLoadedSkillNames; len(got) != 0 {
|
|
t.Fatalf("AllLoadedSkillNames = %v, want empty", got)
|
|
}
|
|
if record.AttemptTrail != nil {
|
|
t.Fatalf("AttemptTrail = %+v, want nil", record.AttemptTrail)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnUsesSkillNamesFromToolExecutions(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-skill-chain",
|
|
SessionKey: "session-skill-chain",
|
|
AgentID: "main",
|
|
Status: "completed",
|
|
UserMessage: "调用三一定理计算100",
|
|
FinalContent: "done",
|
|
ToolExecutions: []evolution.ToolExecutionRecord{
|
|
{Name: "read_file", Success: true, SkillNames: []string{"three-one"}},
|
|
{Name: "read_file", Success: true, SkillNames: []string{"four-two"}},
|
|
{Name: "read_file", Success: true, SkillNames: []string{"five-three"}},
|
|
},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
data, err := os.ReadFile(paths.TaskRecords)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) != 1 {
|
|
t.Fatalf("record file line count = %d, want 1", len(lines))
|
|
}
|
|
|
|
var record evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
|
t.Fatalf("Unmarshal record: %v", err)
|
|
}
|
|
if got := record.AddedSkillNames; len(got) != 0 {
|
|
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" {
|
|
t.Fatalf("UsedSkillNames = %v, want [three-one four-two five-three]", got)
|
|
}
|
|
if got := record.AllLoadedSkillNames; len(got) != 0 {
|
|
t.Fatalf("AllLoadedSkillNames = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnPreservesUTF8WhenTruncatingChineseOutput(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "apply"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
longChinese := strings.Repeat("中文输出", 500)
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-utf8",
|
|
SessionKey: "session-utf8",
|
|
AgentID: "main",
|
|
Status: "completed",
|
|
UserMessage: "请处理这段中文输出",
|
|
FinalContent: longChinese,
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
data, err := os.ReadFile(paths.TaskRecords)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) != 1 {
|
|
t.Fatalf("record file line count = %d, want 1", len(lines))
|
|
}
|
|
|
|
var record evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
|
t.Fatalf("Unmarshal record: %v", err)
|
|
}
|
|
if !utf8.ValidString(record.FinalOutput) {
|
|
t.Fatalf("FinalOutput is not valid UTF-8: %q", record.FinalOutput)
|
|
}
|
|
if strings.ContainsRune(record.FinalOutput, '\uFFFD') {
|
|
t.Fatalf("FinalOutput contains replacement rune: %q", record.FinalOutput)
|
|
}
|
|
if !strings.HasSuffix(record.FinalOutput, "...") {
|
|
t.Fatalf("FinalOutput = %q, want truncated suffix ...", record.FinalOutput)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnPrefersExplicitAttemptTrail(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
now := time.Unix(1700003500, 0).UTC()
|
|
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{Enabled: true, Mode: "observe"},
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-explicit-trail",
|
|
SessionKey: "session-explicit-trail",
|
|
AgentID: "agent-1",
|
|
Status: "completed",
|
|
ToolKinds: []string{"web"},
|
|
ActiveSkillNames: []string{"weather"},
|
|
AttemptedSkillNames: []string{"geocode", "weather"},
|
|
FinalSuccessfulPath: []string{"geocode", "weather"},
|
|
SkillContextSnapshots: []evolution.SkillContextSnapshot{
|
|
{Sequence: 1, Trigger: "initial_build", SkillNames: []string{"weather"}},
|
|
{Sequence: 2, Trigger: "context_retry_rebuild", SkillNames: []string{"geocode", "weather"}},
|
|
},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
paths := evolution.NewPaths(workspace, "")
|
|
data, err := os.ReadFile(paths.TaskRecords)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) != 1 {
|
|
t.Fatalf("record file line count = %d, want 1", len(lines))
|
|
}
|
|
|
|
var record evolution.LearningRecord
|
|
if err := json.Unmarshal([]byte(lines[0]), &record); err != nil {
|
|
t.Fatalf("Unmarshal record: %v", err)
|
|
}
|
|
if record.AttemptTrail != nil {
|
|
t.Fatalf("AttemptTrail = %+v, want nil", record.AttemptTrail)
|
|
}
|
|
if got := record.UsedSkillNames; len(got) != 2 || got[0] != "geocode" || got[1] != "weather" {
|
|
t.Fatalf("UsedSkillNames = %v, want [geocode weather]", got)
|
|
}
|
|
if got := record.InitialSkillNames; len(got) != 0 {
|
|
t.Fatalf("InitialSkillNames = %v, want empty", got)
|
|
}
|
|
if got := record.AddedSkillNames; len(got) != 0 {
|
|
t.Fatalf("AddedSkillNames = %v, want empty", got)
|
|
}
|
|
if len(record.Signals) != 0 {
|
|
t.Fatalf("Signals = %v, want empty", record.Signals)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnUpdatesSkillProfileUsage(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
now := time.Unix(1700000000, 0).UTC()
|
|
|
|
rt, err := evolution.NewRuntime(evolution.RuntimeOptions{
|
|
Config: config.EvolutionConfig{
|
|
Enabled: true,
|
|
Mode: "observe",
|
|
},
|
|
Now: func() time.Time { return now },
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewRuntime: %v", err)
|
|
}
|
|
|
|
if finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-1",
|
|
SessionKey: "session-1",
|
|
AgentID: "agent-1",
|
|
Status: "completed",
|
|
ActiveSkillNames: []string{"skill-a", "skill-a"},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
|
profile, err := store.LoadProfile("skill-a")
|
|
if err != nil {
|
|
t.Fatalf("LoadProfile: %v", err)
|
|
}
|
|
if profile.Origin != "manual" {
|
|
t.Fatalf("Origin = %q, want manual", profile.Origin)
|
|
}
|
|
if profile.UseCount != 1 {
|
|
t.Fatalf("UseCount = %d, want 1", profile.UseCount)
|
|
}
|
|
if profile.LastUsedAt != now {
|
|
t.Fatalf("LastUsedAt = %v, want %v", profile.LastUsedAt, now)
|
|
}
|
|
if profile.RetentionScore <= 0.2 {
|
|
t.Fatalf("RetentionScore = %v, want > 0.2", profile.RetentionScore)
|
|
}
|
|
}
|
|
|
|
func TestRuntime_FinalizeTurnReactivatesColdSkill(t *testing.T) {
|
|
assertFinalizeTurnReactivatesSkill(t, "skill-cold", evolution.SkillStatusCold, 2, 0.2, 24*time.Hour)
|
|
}
|
|
|
|
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()
|
|
now := time.Unix(1700002000, 0).UTC()
|
|
store := evolution.NewStore(evolution.NewPaths(workspace, ""))
|
|
|
|
if saveErr := store.SaveProfile(evolution.SkillProfile{
|
|
SkillName: skillName,
|
|
WorkspaceID: workspace,
|
|
Status: initialStatus,
|
|
Origin: "evolved",
|
|
HumanSummary: string(initialStatus) + " skill",
|
|
LastUsedAt: now.Add(-lastUsedAge),
|
|
UseCount: useCount,
|
|
RetentionScore: retentionScore,
|
|
}); saveErr != nil {
|
|
t.Fatalf("SaveProfile: %v", saveErr)
|
|
}
|
|
|
|
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 finalizeErr := rt.FinalizeTurn(context.Background(), evolution.TurnCaseInput{
|
|
Workspace: workspace,
|
|
TurnID: "turn-" + skillName,
|
|
Status: "completed",
|
|
ActiveSkillNames: []string{skillName},
|
|
}); finalizeErr != nil {
|
|
t.Fatalf("FinalizeTurn: %v", finalizeErr)
|
|
}
|
|
|
|
profile, err := store.LoadProfile(skillName)
|
|
if err != nil {
|
|
t.Fatalf("LoadProfile: %v", err)
|
|
}
|
|
if profile.Status != evolution.SkillStatusActive {
|
|
t.Fatalf("Status = %q, want %q", profile.Status, evolution.SkillStatusActive)
|
|
}
|
|
}
|