Files
picoclaw/pkg/evolution/drafts.go
T
lxowalle b3a7b7ad64 feat: agent self evolution (#2847)
* feat: add agent self-evolution

* fix ci

* delete unused doc

* fix lint

* fix evolution review issues
2026-05-11 16:13:27 +08:00

512 lines
14 KiB
Go

package evolution
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
)
type DraftGenerator interface {
GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error)
}
type EvidenceAwareDraftGenerator interface {
GenerateDraftWithEvidence(
ctx context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) (SkillDraft, error)
}
type DraftEvidence struct {
TaskRecords []LearningRecord
}
func ValidateDraft(draft SkillDraft) []string {
findings := make([]string, 0, 5)
if strings.TrimSpace(draft.TargetSkillName) == "" {
findings = append(findings, "target_skill_name is required")
} else if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil {
findings = append(findings, "target_skill_name is invalid: "+err.Error())
} else if isNumericToken(strings.TrimSpace(draft.TargetSkillName)) {
findings = append(findings, "target_skill_name must be descriptive, not numeric-only")
}
if strings.TrimSpace(draft.HumanSummary) == "" {
findings = append(findings, "human_summary is required")
}
if strings.TrimSpace(draft.BodyOrPatch) == "" {
findings = append(findings, "body_or_patch is required")
}
switch draft.DraftType {
case DraftTypeWorkflow, DraftTypeShortcut:
default:
findings = append(findings, "draft_type is invalid")
}
switch draft.ChangeKind {
case ChangeKindCreate, ChangeKindAppend, ChangeKindReplace, ChangeKindMerge:
default:
findings = append(findings, "change_kind is invalid")
}
return findings
}
type DefaultDraftGenerator struct {
loader *skills.SkillsLoader
}
func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
builtinSkillsDir = filepath.Join(wd, "skills")
}
globalSkillsDir := filepath.Join(config.GetHome(), "skills")
return &DefaultDraftGenerator{
loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
}
}
func (g *DefaultDraftGenerator) GenerateDraft(
_ context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
) (SkillDraft, error) {
return g.GenerateDraftWithEvidence(context.Background(), rule, matches, DraftEvidence{})
}
func (g *DefaultDraftGenerator) GenerateDraftWithEvidence(
_ context.Context,
rule LearningRecord,
matches []skills.SkillInfo,
evidence DraftEvidence,
) (SkillDraft, error) {
rule = enrichRuleWithDraftEvidence(rule, evidence)
target := inferTargetSkillName(rule, matches)
if target == "" {
target = "learned-skill"
}
_, hasExisting, err := g.loadBaseSkillContent(target, matches)
if err != nil {
return SkillDraft{}, err
}
draftType := DraftTypeWorkflow
if len(rule.WinningPath) <= 1 {
draftType = DraftTypeShortcut
}
changeKind := ChangeKindCreate
body := g.buildNewSkillBody(target, rule, evidence, matches)
if hasExisting {
changeKind = ChangeKindAppend
body = g.buildAppendBody(rule, evidence, matches)
}
return SkillDraft{
TargetSkillName: target,
DraftType: draftType,
ChangeKind: changeKind,
HumanSummary: g.buildHumanSummary(target, rule, hasExisting),
IntendedUseCases: inferIntendedUseCases(rule),
PreferredEntryPath: inferPreferredEntryPath(rule),
AvoidPatterns: inferAvoidPatterns(rule),
BodyOrPatch: body,
}, nil
}
func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string {
if target := inferCombinedSkillName(rule); target != "" {
return target
}
if label := validSkillNameOrEmpty(rule.Label); label != "" {
return label
}
if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" {
return strings.TrimSpace(matches[0].Name)
}
if len(rule.LateAddedSkills) > 0 && strings.TrimSpace(rule.LateAddedSkills[0]) != "" {
return strings.TrimSpace(rule.LateAddedSkills[0])
}
if len(rule.WinningPath) > 0 && strings.TrimSpace(rule.WinningPath[0]) != "" {
return strings.TrimSpace(rule.WinningPath[0])
}
if len(rule.MatchedSkillNames) > 0 && strings.TrimSpace(rule.MatchedSkillNames[0]) != "" {
return strings.TrimSpace(rule.MatchedSkillNames[0])
}
tokens := tokenizeForEvolution(rule.Summary)
if len(tokens) > 0 {
if len(tokens) == 1 && isNumericToken(tokens[0]) {
return "learned-" + tokens[0]
}
return tokens[0]
}
return ""
}
func enrichRuleWithDraftEvidence(rule LearningRecord, evidence DraftEvidence) LearningRecord {
if len(evidence.TaskRecords) == 0 {
return rule
}
usedSkillNames := make([]string, 0)
pathCounts := make(map[string]int)
pathByKey := make(map[string][]string)
for _, task := range evidence.TaskRecords {
path := uniqueTrimmedNames(task.UsedSkillNames)
if len(path) == 0 {
continue
}
usedSkillNames = append(usedSkillNames, path...)
key := strings.Join(path, "\x00")
pathCounts[key]++
pathByKey[key] = path
}
rule.MatchedSkillNames = appendUniqueStrings(rule.MatchedSkillNames, uniqueTrimmedNames(usedSkillNames)...)
if len(rule.WinningPath) == 0 {
bestKey := ""
bestCount := 0
for key, count := range pathCounts {
if count > bestCount || (count == bestCount && key < bestKey) {
bestKey = key
bestCount = count
}
}
if bestKey != "" {
rule.WinningPath = append([]string(nil), pathByKey[bestKey]...)
}
}
return rule
}
func inferCombinedSkillName(rule LearningRecord) string {
path := normalizePath(rule.WinningPath)
if len(path) < 2 {
return ""
}
tokens := tokenizeForEvolution(rule.Summary)
suffix := commonWinningPathSuffix(path)
if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" {
if candidate := validSkillNameOrEmpty(
"calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix),
); candidate != "" {
return candidate
}
}
if len(tokens) >= 2 {
prefix := strings.Join(tokens[:minInt(len(tokens), 4)], "-")
if suffix != "" {
if candidate := validSkillNameOrEmpty(prefix + "-via-" + pluralizeSuffix(suffix)); candidate != "" {
return candidate
}
}
if candidate := validSkillNameOrEmpty(prefix + "-shortcut"); candidate != "" {
return candidate
}
}
compressedPath := compressedWinningPathName(path)
if candidate := validSkillNameOrEmpty("combined-" + compressedPath); candidate != "" {
return candidate
}
if candidate := validSkillNameOrEmpty(path[0] + "-to-" + path[len(path)-1] + "-shortcut"); candidate != "" {
return candidate
}
return ""
}
func commonWinningPathSuffix(path []string) string {
if len(path) < 2 {
return ""
}
var suffix string
for i, name := range path {
parts := strings.Split(strings.TrimSpace(name), "-")
if len(parts) == 0 {
return ""
}
last := strings.TrimSpace(parts[len(parts)-1])
if last == "" {
return ""
}
if i == 0 {
suffix = last
continue
}
if suffix != last {
return ""
}
}
return suffix
}
func compressedWinningPathName(path []string) string {
suffix := commonWinningPathSuffix(path)
fragments := make([]string, 0, len(path)+1)
for _, name := range path {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if suffix != "" {
trimmed = strings.TrimSuffix(trimmed, "-"+suffix)
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "-")
}
if trimmed != "" {
fragments = append(fragments, trimmed)
}
}
if suffix != "" {
fragments = append(fragments, pluralizeSuffix(suffix))
}
if len(fragments) == 0 {
return strings.Join(path, "-")
}
return strings.Join(fragments, "-")
}
func pluralizeSuffix(suffix string) string {
suffix = strings.TrimSpace(strings.ToLower(suffix))
if suffix == "" {
return ""
}
if strings.HasSuffix(suffix, "s") {
return suffix
}
return suffix + "s"
}
func isNumericToken(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func validSkillNameOrEmpty(candidate string) string {
candidate = strings.Trim(candidate, "-")
candidate = strings.Join(strings.FieldsFunc(candidate, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9')
}), "-")
candidate = strings.ToLower(strings.Trim(candidate, "-"))
if candidate == "" {
return ""
}
if len(candidate) > skills.MaxNameLength {
return ""
}
if err := skills.ValidateSkillName(candidate); err != nil {
return ""
}
return candidate
}
func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) {
for _, match := range matches {
if match.Name != target || strings.TrimSpace(match.Path) == "" {
continue
}
data, err := os.ReadFile(match.Path)
if err != nil {
return "", false, err
}
return string(data), true, nil
}
if g.loader == nil {
return "", false, nil
}
content, ok := g.loader.LoadSkill(target)
if !ok {
return "", false, nil
}
description := fmt.Sprintf("Use this skill to %s when the task requires this workflow.", sentenceFragment(target))
return buildSkillDocument(target, description, content), true, nil
}
func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRecord, hasExisting bool) string {
if hasExisting {
return fmt.Sprintf("Refresh %s with learned pattern: %s", target, rule.Summary)
}
return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary)
}
func (g *DefaultDraftGenerator) buildNewSkillBody(
target string,
rule LearningRecord,
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{
"# " + titleCaseSkillName(target),
"",
"## Start Here",
g.startHereLine(rule),
"",
"## When To Use",
fmt.Sprintf("Use this skill when the task matches `%s`.", strings.TrimSpace(rule.Summary)),
"",
"## Learned Pattern",
g.learnedPatternLine(rule),
"",
"## Procedure",
g.procedureLine(rule, evidence),
"",
"## Expected Result",
g.expectedResultLine(evidence),
"",
"## Source Skills",
synthesizedComponentBreakdown(matches),
"",
"## Source Evidence",
g.evidenceLine(rule, evidence),
}, "\n")
return buildSkillDocument(target, description, body)
}
func (g *DefaultDraftGenerator) buildAppendBody(
rule LearningRecord,
evidence DraftEvidence,
matches []skills.SkillInfo,
) string {
return strings.Join([]string{
"## Learned Evolution",
fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)),
fmt.Sprintf("- Learned pattern: %s", g.learnedPatternLine(rule)),
fmt.Sprintf("- Procedure: %s", g.procedureLine(rule, evidence)),
fmt.Sprintf("- Expected result: %s", g.expectedResultLine(evidence)),
fmt.Sprintf("- Evidence: %s", g.evidenceLine(rule, evidence)),
"",
"### Source Skills",
synthesizedComponentBreakdown(matches),
"",
}, "\n")
}
func buildSkillDocument(name, description, body string) string {
return strings.Join([]string{
"---",
"name: " + strings.TrimSpace(name),
"description: " + strings.TrimSpace(description),
"---",
"",
strings.TrimSpace(body),
"",
}, "\n")
}
func titleCaseSkillName(name string) string {
parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
if len(parts) == 0 {
return "Learned Skill"
}
return strings.Join(parts, " ")
}
func (g *DefaultDraftGenerator) startHereLine(rule LearningRecord) string {
if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Start with `%s` before trying other paths.", strings.Join(rule.WinningPath, " -> "))
}
return fmt.Sprintf("Start from the learned path for `%s`.", strings.TrimSpace(rule.Summary))
}
func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string {
if len(rule.LateAddedSkills) > 0 {
return fmt.Sprintf(
"Late-added skill `%s` was repeatedly introduced immediately before success%s.",
strings.Join(rule.LateAddedSkills, " -> "),
triggerSuffix(rule.FinalSnapshotTrigger),
)
}
if len(rule.WinningPath) > 0 {
return fmt.Sprintf(
"Prefer `%s` because it was the most reliable recent path.",
strings.Join(rule.WinningPath, " -> "),
)
}
return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary))
}
func (g *DefaultDraftGenerator) procedureLine(rule LearningRecord, evidence DraftEvidence) string {
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, " -> "),
)
}
if excerpt := firstFinalOutputExcerpt(evidence, 260); 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),
)
}
func (g *DefaultDraftGenerator) expectedResultLine(evidence DraftEvidence) string {
if excerpt := firstFinalOutputExcerpt(evidence, 320); excerpt != "" {
return excerpt
}
return "Return the completed result for the matched task without restating unrelated discovery steps."
}
func (g *DefaultDraftGenerator) evidenceLine(rule LearningRecord, evidence DraftEvidence) string {
if len(evidence.TaskRecords) > 0 {
ids := make([]string, 0, len(evidence.TaskRecords))
for _, task := range evidence.TaskRecords {
ids = append(ids, task.ID)
}
return fmt.Sprintf("Learned from task records: %s", strings.Join(ids, ", "))
}
if len(rule.TaskRecordIDs) > 0 {
return fmt.Sprintf("Learned from task records: %s", strings.Join(rule.TaskRecordIDs, ", "))
}
return "Learned from the pattern record."
}
func firstFinalOutputExcerpt(evidence DraftEvidence, maxLen int) string {
for _, task := range evidence.TaskRecords {
if excerpt := summarizeText(task.FinalOutput, maxLen); excerpt != "" {
return excerpt
}
}
return ""
}
func triggerSuffix(trigger string) string {
trigger = strings.TrimSpace(trigger)
if trigger == "" {
return ""
}
return fmt.Sprintf(" during `%s`", trigger)
}