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
236 lines
7.1 KiB
Go
236 lines
7.1 KiB
Go
package evolution
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
)
|
|
|
|
type LLMDraftGenerator struct {
|
|
provider providers.LLMProvider
|
|
model string
|
|
fallback DraftGenerator
|
|
}
|
|
|
|
type llmDraftResponse struct {
|
|
TargetSkillName string `json:"target_skill_name"`
|
|
DraftType string `json:"draft_type"`
|
|
ChangeKind string `json:"change_kind"`
|
|
HumanSummary string `json:"human_summary"`
|
|
IntendedUseCases []string `json:"intended_use_cases"`
|
|
PreferredEntryPath []string `json:"preferred_entry_path"`
|
|
AvoidPatterns []string `json:"avoid_patterns"`
|
|
BodyOrPatch string `json:"body_or_patch"`
|
|
}
|
|
|
|
func NewLLMDraftGenerator(provider providers.LLMProvider, model string, fallback DraftGenerator) *LLMDraftGenerator {
|
|
return &LLMDraftGenerator{
|
|
provider: provider,
|
|
model: strings.TrimSpace(model),
|
|
fallback: fallback,
|
|
}
|
|
}
|
|
|
|
func (g *LLMDraftGenerator) GenerateDraft(
|
|
ctx context.Context,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
) (SkillDraft, error) {
|
|
return g.GenerateDraftWithEvidence(ctx, rule, matches, DraftEvidence{})
|
|
}
|
|
|
|
func (g *LLMDraftGenerator) GenerateDraftWithEvidence(
|
|
ctx context.Context,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) (SkillDraft, error) {
|
|
rule = enrichRuleWithDraftEvidence(rule, evidence)
|
|
if g == nil || g.provider == nil {
|
|
return g.generateFallback(ctx, rule, matches, evidence)
|
|
}
|
|
|
|
model := g.model
|
|
if model == "" {
|
|
model = strings.TrimSpace(g.provider.GetDefaultModel())
|
|
}
|
|
if model == "" {
|
|
return g.generateFallback(ctx, rule, matches, evidence)
|
|
}
|
|
|
|
callCtx, cancel := withLLMCallTimeout(ctx, llmDraftGenerationTimeout)
|
|
defer cancel()
|
|
resp, err := g.provider.Chat(callCtx, []providers.Message{
|
|
{
|
|
Role: "system",
|
|
Content: "Return exactly one JSON object for a skill draft. Do not use markdown fences.",
|
|
},
|
|
{
|
|
Role: "user",
|
|
Content: g.buildPrompt(rule, matches, evidence),
|
|
},
|
|
}, nil, model, map[string]any{"temperature": 0.2})
|
|
if err != nil || resp == nil {
|
|
return g.generateFallback(ctx, rule, matches, evidence)
|
|
}
|
|
|
|
content := strings.TrimSpace(resp.Content)
|
|
if content == "" {
|
|
return g.generateFallback(ctx, rule, matches, evidence)
|
|
}
|
|
|
|
draft, ok := parseLLMDraft(content)
|
|
if !ok || len(ValidateDraft(draft)) > 0 {
|
|
return g.generateFallback(ctx, rule, matches, evidence)
|
|
}
|
|
|
|
return draft, nil
|
|
}
|
|
|
|
func (g *LLMDraftGenerator) generateFallback(
|
|
ctx context.Context,
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) (SkillDraft, error) {
|
|
if g == nil || g.fallback == nil {
|
|
return SkillDraft{}, nil
|
|
}
|
|
if generator, ok := g.fallback.(EvidenceAwareDraftGenerator); ok {
|
|
return generator.GenerateDraftWithEvidence(ctx, rule, matches, evidence)
|
|
}
|
|
return g.fallback.GenerateDraft(ctx, rule, matches)
|
|
}
|
|
|
|
func (g *LLMDraftGenerator) buildPrompt(
|
|
rule LearningRecord,
|
|
matches []skills.SkillInfo,
|
|
evidence DraftEvidence,
|
|
) string {
|
|
return strings.Join([]string{
|
|
"Generate a skill draft JSON object with these required string fields:",
|
|
`target_skill_name, draft_type, change_kind, human_summary, body_or_patch.`,
|
|
"Optional array fields: intended_use_cases, preferred_entry_path, avoid_patterns.",
|
|
"",
|
|
"Allowed values:",
|
|
"- draft_type: workflow | shortcut",
|
|
"- change_kind: create | append | replace | merge",
|
|
"- target_skill_name: lowercase hyphenated skill name that describes the functional purpose; it must not be numeric-only",
|
|
"",
|
|
"Rule summary: " + strings.TrimSpace(rule.Summary),
|
|
"Winning path: " + joinOrFallback(rule.WinningPath, "none"),
|
|
"Late-added successful skills: " + joinOrFallback(rule.LateAddedSkills, "none"),
|
|
"Final snapshot trigger: " + fallbackString(rule.FinalSnapshotTrigger, "none"),
|
|
fmt.Sprintf("Event count: %d", rule.EventCount),
|
|
fmt.Sprintf("Success rate: %.2f", rule.SuccessRate),
|
|
"Matched skill refs: " + summarizeSkillMatches(matches),
|
|
"Matched skill names: " + joinOrFallback(rule.MatchedSkillNames, "none"),
|
|
"Source task evidence:",
|
|
summarizeDraftTaskEvidence(evidence),
|
|
"Matched skill content excerpts:",
|
|
summarizeMatchedSkillExcerpts(matches),
|
|
"",
|
|
combinedSkillGuidance(rule),
|
|
skillDraftPromptText(),
|
|
}, "\n")
|
|
}
|
|
|
|
func summarizeDraftTaskEvidence(evidence DraftEvidence) string {
|
|
if len(evidence.TaskRecords) == 0 {
|
|
return "none"
|
|
}
|
|
lines := make([]string, 0, minInt(len(evidence.TaskRecords), 5))
|
|
for i, task := range evidence.TaskRecords {
|
|
if i >= 5 {
|
|
break
|
|
}
|
|
parts := []string{
|
|
"- id: " + fallbackString(task.ID, "unknown"),
|
|
" summary: " + fallbackString(task.Summary, "none"),
|
|
" final_output_excerpt: " + fallbackString(summarizeText(task.FinalOutput, 700), "none"),
|
|
" used_skill_names: " + joinOrFallback(task.UsedSkillNames, "none"),
|
|
}
|
|
lines = append(lines, strings.Join(parts, "\n"))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func combinedSkillGuidance(rule LearningRecord) string {
|
|
if target := inferCombinedSkillName(rule); target != "" {
|
|
return strings.Join([]string{
|
|
"This rule represents a stable multi-step successful path.",
|
|
"Prefer creating a new combined shortcut skill instead of modifying one component skill.",
|
|
"Suggested target skill name: " + target,
|
|
}, "\n")
|
|
}
|
|
return "Prefer updating an existing skill only when the learned pattern clearly belongs inside that single skill."
|
|
}
|
|
|
|
func parseLLMDraft(content string) (SkillDraft, bool) {
|
|
normalized := strings.TrimSpace(content)
|
|
normalized = strings.TrimPrefix(normalized, "```json")
|
|
normalized = strings.TrimPrefix(normalized, "```")
|
|
normalized = strings.TrimSuffix(normalized, "```")
|
|
normalized = strings.TrimSpace(normalized)
|
|
|
|
var payload llmDraftResponse
|
|
if err := json.Unmarshal([]byte(normalized), &payload); err != nil {
|
|
return SkillDraft{}, false
|
|
}
|
|
|
|
draft := SkillDraft{
|
|
TargetSkillName: strings.TrimSpace(payload.TargetSkillName),
|
|
DraftType: DraftType(strings.TrimSpace(payload.DraftType)),
|
|
ChangeKind: ChangeKind(strings.TrimSpace(payload.ChangeKind)),
|
|
HumanSummary: strings.TrimSpace(payload.HumanSummary),
|
|
IntendedUseCases: append([]string(nil), payload.IntendedUseCases...),
|
|
PreferredEntryPath: append([]string(nil), payload.PreferredEntryPath...),
|
|
AvoidPatterns: append([]string(nil), payload.AvoidPatterns...),
|
|
BodyOrPatch: strings.TrimSpace(payload.BodyOrPatch),
|
|
}
|
|
return draft, true
|
|
}
|
|
|
|
func summarizeSkillMatches(matches []skills.SkillInfo) string {
|
|
if len(matches) == 0 {
|
|
return "none"
|
|
}
|
|
|
|
parts := make([]string, 0, len(matches))
|
|
for _, match := range matches {
|
|
part := strings.TrimSpace(match.Name)
|
|
if desc := strings.TrimSpace(match.Description); desc != "" {
|
|
part += ": " + desc
|
|
}
|
|
if path := strings.TrimSpace(match.Path); path != "" {
|
|
part += " (" + path + ")"
|
|
}
|
|
if part != "" {
|
|
parts = append(parts, part)
|
|
}
|
|
}
|
|
if len(parts) == 0 {
|
|
return "none"
|
|
}
|
|
return strings.Join(parts, "; ")
|
|
}
|
|
|
|
func joinOrFallback(parts []string, fallback string) string {
|
|
if len(parts) == 0 {
|
|
return fallback
|
|
}
|
|
return strings.Join(parts, " -> ")
|
|
}
|
|
|
|
func fallbackString(value, fallback string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|