feat(agent): 经验记忆 - 按任务相关度排序召回,并过期清理待批准记忆
召回按主题、情境和中文双字命中加权;30天未处理的待批准记忆会在记录和压缩调度时过期,成功计数写入运行态以免事件窗口截断后误拦。
This commit is contained in:
@@ -48,6 +48,7 @@ const (
|
||||
cloudAgentLessonSearchTokenMax = 8
|
||||
cloudAgentRememberLessonMaxPerRun = 3
|
||||
cloudAgentRememberLessonPendingPerUser = 50
|
||||
cloudAgentPendingLessonTTL = 30 * 24 * time.Hour
|
||||
cloudAgentRememberLessonApprovedMax = 300
|
||||
cloudAgentMemoryImportMax = 200
|
||||
)
|
||||
@@ -121,12 +122,16 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
|
||||
if cloudAgentRememberLessonCount(state) >= cloudAgentRememberLessonMaxPerRun {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("本轮最多记录 %d 条经验,请合并成更通用的一条", cloudAgentRememberLessonMaxPerRun))
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := repo.ExpirePendingAgentLessons(userID, now.Add(-cloudAgentPendingLessonTTL), now, cloudAgentRememberLessonPendingPerUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pending, err := repo.CountAgentLessonsByAuthor(userID, model.AgentLessonStatusPending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pending >= cloudAgentRememberLessonPendingPerUser {
|
||||
return nil, BadAuthRequest("待你批准的记忆已经比较多,请先到「设置 → Agent 记忆」处理后再记新的")
|
||||
return nil, BadAuthRequest(fmt.Sprintf("当前有 %d 条待批准记忆,已达到上限 %d;请先到「设置 → Agent 记忆」批准或删除后再记新的", pending, cloudAgentRememberLessonPendingPerUser))
|
||||
}
|
||||
var args struct {
|
||||
Topic string `json:"topic"`
|
||||
@@ -170,7 +175,6 @@ func cloudAgentRememberLesson(repo *repository.Repository, userID string, state
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
entry := &model.AgentLesson{
|
||||
ID: newID(), Topic: topic, Situation: situation, Lesson: lesson, Source: source,
|
||||
Category: cloudAgentNormalizeLessonCategory(args.Category),
|
||||
@@ -214,13 +218,23 @@ func cloudAgentLessonEligibleSuccesses(state *cloudAgentRuntime) int {
|
||||
continue
|
||||
}
|
||||
name, _ := event.Payload["toolName"].(string)
|
||||
switch name {
|
||||
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
|
||||
continue
|
||||
if cloudAgentLessonToolEligible(name) {
|
||||
count++
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
if count > state.LessonEligibleToolSuccesses {
|
||||
return count
|
||||
}
|
||||
return state.LessonEligibleToolSuccesses
|
||||
}
|
||||
|
||||
func cloudAgentLessonToolEligible(name string) bool {
|
||||
switch name {
|
||||
case "", "skills_load", "remember_lesson", "recall_lessons", "agent_profile_read", "plan_update", "ask_user":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
|
||||
@@ -236,7 +250,10 @@ func cloudAgentRememberLessonCount(state *cloudAgentRuntime) int {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
if count > state.RememberLessonSuccesses {
|
||||
return count
|
||||
}
|
||||
return state.RememberLessonSuccesses
|
||||
}
|
||||
|
||||
func cloudAgentLessonFingerprint(situation, lesson, stepsJSON string) string {
|
||||
@@ -397,20 +414,34 @@ func (s *Service) cloudAgentLessonsBlock(userID, taskText string) string {
|
||||
}
|
||||
|
||||
func cloudAgentPickLessonIndex(lessons []model.AgentLesson, taskText string, limit int) ([]model.AgentLesson, int) {
|
||||
matched := make([]model.AgentLesson, 0)
|
||||
rest := make([]model.AgentLesson, 0, len(lessons))
|
||||
for _, lesson := range lessons {
|
||||
if cloudAgentLessonMatchesTask(lesson, taskText) {
|
||||
matched = append(matched, lesson)
|
||||
} else {
|
||||
rest = append(rest, lesson)
|
||||
}
|
||||
type rankedLesson struct {
|
||||
lesson model.AgentLesson
|
||||
score int
|
||||
}
|
||||
ranked := make([]rankedLesson, 0, len(lessons))
|
||||
for _, lesson := range lessons {
|
||||
ranked = append(ranked, rankedLesson{lesson: lesson, score: cloudAgentLessonTaskScore(lesson, taskText)})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
if ranked[i].lesson.Hits != ranked[j].lesson.Hits {
|
||||
return ranked[i].lesson.Hits > ranked[j].lesson.Hits
|
||||
}
|
||||
return ranked[i].lesson.UpdatedAt.After(ranked[j].lesson.UpdatedAt)
|
||||
})
|
||||
index := make([]model.AgentLesson, 0, len(ranked))
|
||||
matchedN := 0
|
||||
for _, item := range ranked {
|
||||
if item.score > 0 {
|
||||
matchedN++
|
||||
}
|
||||
index = append(index, item.lesson)
|
||||
}
|
||||
index := append(matched, rest...)
|
||||
if limit > 0 && len(index) > limit {
|
||||
index = index[:limit]
|
||||
}
|
||||
matchedN := len(matched)
|
||||
if matchedN > len(index) {
|
||||
matchedN = len(index)
|
||||
}
|
||||
@@ -476,20 +507,34 @@ func cloudAgentLessonsBlock(view cloudAgentLessonView) string {
|
||||
}
|
||||
|
||||
func cloudAgentLessonMatchesTask(lesson model.AgentLesson, taskText string) bool {
|
||||
return cloudAgentLessonTaskScore(lesson, taskText) > 0
|
||||
}
|
||||
|
||||
func cloudAgentLessonTaskScore(lesson model.AgentLesson, taskText string) int {
|
||||
haystack := cloudAgentLessonNormalize(taskText)
|
||||
if haystack == "" {
|
||||
return false
|
||||
return 0
|
||||
}
|
||||
score := 0
|
||||
for _, needle := range cloudAgentLessonNeedles(lesson) {
|
||||
if utf8.RuneCountInString(needle) >= 3 && strings.Contains(haystack, needle) {
|
||||
return true
|
||||
runes := []rune(needle)
|
||||
isHanPhrase := len(runes) >= 2 && unicode.Is(unicode.Han, runes[0]) && unicode.Is(unicode.Han, runes[1])
|
||||
if (!isHanPhrase && utf8.RuneCountInString(needle) < 3) || !strings.Contains(haystack, needle) {
|
||||
continue
|
||||
}
|
||||
weight := 1
|
||||
if strings.Contains(cloudAgentLessonNormalize(lesson.Topic), needle) {
|
||||
weight = 4
|
||||
} else if strings.Contains(cloudAgentLessonNormalize(lesson.Situation), needle) {
|
||||
weight = 2
|
||||
}
|
||||
score += weight
|
||||
}
|
||||
return false
|
||||
return score
|
||||
}
|
||||
|
||||
func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
needles := make([]string, 0, 8)
|
||||
needles := make([]string, 0, 24)
|
||||
seen := map[string]bool{}
|
||||
push := func(value string) {
|
||||
value = cloudAgentLessonNormalize(value)
|
||||
@@ -499,6 +544,14 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
seen[value] = true
|
||||
needles = append(needles, value)
|
||||
}
|
||||
pushCJKBigrams := func(value string) {
|
||||
runes := []rune(cloudAgentLessonNormalize(value))
|
||||
for index := 0; index+1 < len(runes); index++ {
|
||||
if unicode.Is(unicode.Han, runes[index]) && unicode.Is(unicode.Han, runes[index+1]) {
|
||||
push(string(runes[index : index+2]))
|
||||
}
|
||||
}
|
||||
}
|
||||
var steps []model.AgentLessonStep
|
||||
if json.Unmarshal([]byte(lesson.StepsJSON), &steps) == nil {
|
||||
for _, step := range steps {
|
||||
@@ -509,13 +562,16 @@ func cloudAgentLessonNeedles(lesson model.AgentLesson) []string {
|
||||
return r == '.' || r == '-' || r == '_' || r == ' '
|
||||
}) {
|
||||
push(chunk)
|
||||
pushCJKBigrams(chunk)
|
||||
}
|
||||
pushCJKBigrams(lesson.Situation)
|
||||
return needles
|
||||
}
|
||||
|
||||
func cloudAgentLessonGenericToken(token string) bool {
|
||||
switch token {
|
||||
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the":
|
||||
case "video", "image", "canvas", "storyboard", "shot", "step", "task", "model", "with", "and", "the",
|
||||
"画布", "分镜", "镜头", "视频", "图片", "生成", "任务", "操作", "处理", "使用", "通过", "进行", "一个", "这个", "可以", "需要", "如果", "然后", "之后", "之前", "结果", "内容", "相关":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -736,3 +736,110 @@ func TestAdminAgentLessonsFiltersByUser(t *testing.T) {
|
||||
t.Fatalf("不筛选时应看到全部:%d(%v)", len(all), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonGateSurvivesEventWindowAndCheckpoint(t *testing.T) {
|
||||
state := &cloudAgentRuntime{LessonEligibleToolSuccesses: 1}
|
||||
for i := 0; i < repository.CloudAgentJournalWindow; i++ {
|
||||
state.Events = append(state.Events, CloudAgentEvent{Type: "message_delta"})
|
||||
}
|
||||
if got := cloudAgentLessonEligibleSuccesses(state); got != 1 {
|
||||
t.Fatalf("delta 窗口不得抹掉已记录的合格成功数:got %d", got)
|
||||
}
|
||||
|
||||
state.Events = nil
|
||||
run := &model.CloudAgentExecution{}
|
||||
if err := cloudAgentSave(run, state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := cloudAgentDecode(run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.LessonEligibleToolSuccesses != 1 || cloudAgentLessonEligibleSuccesses(&restored) != 1 {
|
||||
t.Fatalf("checkpoint 恢复后写入门禁必须保持:%+v", restored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingAgentLessonsExpireWithoutTouchingApproved(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
|
||||
entries := []*model.AgentLesson{
|
||||
{ID: newID(), Topic: "old-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
|
||||
{ID: newID(), Topic: "fresh-pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now},
|
||||
{ID: newID(), Topic: "old-approved", Status: model.AgentLessonStatusApproved, AuthorUserID: "user-a", CreatedAt: old, UpdatedAt: old},
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
changed, err := repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500)
|
||||
if err != nil || changed != 1 {
|
||||
t.Fatalf("应只过期一条旧 pending:changed=%d err=%v", changed, err)
|
||||
}
|
||||
for _, want := range []struct{ id, status string }{
|
||||
{entries[0].ID, model.AgentLessonStatusRejected},
|
||||
{entries[1].ID, model.AgentLessonStatusPending},
|
||||
{entries[2].ID, model.AgentLessonStatusApproved},
|
||||
} {
|
||||
got, findErr := repo.AgentLessonForUser("user-a", want.id)
|
||||
if findErr != nil || got.Status != want.status {
|
||||
t.Fatalf("记忆 %s 状态不符:got=%+v err=%v", want.id, got, findErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonExpiresOldPendingBeforeApplyingLimit(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
old := now.Add(-cloudAgentPendingLessonTTL - time.Hour)
|
||||
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
|
||||
created := now
|
||||
if index == 0 {
|
||||
created = old
|
||||
}
|
||||
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: created, UpdatedAt: created}
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := &cloudAgentRuntime{
|
||||
Request: CloudAgentRequest{PermissionMode: "auto"},
|
||||
LessonEligibleToolSuccesses: 1,
|
||||
}
|
||||
call := rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`)
|
||||
if _, err := cloudAgentRememberLesson(repo, "user-a", state, call); err != nil {
|
||||
t.Fatalf("过期一条后应可新建待审记忆:%v", err)
|
||||
}
|
||||
pending, err := repo.CountAgentLessonsByAuthor("user-a", model.AgentLessonStatusPending)
|
||||
if err != nil || pending != cloudAgentRememberLessonPendingPerUser {
|
||||
t.Fatalf("新写入后待审数应回到上限:%d (%v)", pending, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberLessonPendingLimitErrorShowsCountAndSettings(t *testing.T) {
|
||||
repo := agentLessonTestRepo(t)
|
||||
now := time.Now()
|
||||
for index := 0; index < cloudAgentRememberLessonPendingPerUser; index++ {
|
||||
entry := &model.AgentLesson{ID: newID(), Topic: "pending", Status: model.AgentLessonStatusPending, AuthorUserID: "user-a", CreatedAt: now, UpdatedAt: now}
|
||||
if err := repo.Create(entry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto"}, LessonEligibleToolSuccesses: 1}
|
||||
_, err := cloudAgentRememberLesson(repo, "user-a", state, rememberLessonCall(`{"topic":"canvas.snapshot-hash","category":"canvas","situation":"写画布时报快照过期","lesson":"用返回的新 snapshotHash"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "50") || !strings.Contains(err.Error(), "设置 → Agent 记忆") {
|
||||
t.Fatalf("拒绝信息应包含待审数量和处理入口:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessonIndexRanksTaskRelevanceBeforeHitsAndMatchesTwoHanRunes(t *testing.T) {
|
||||
now := time.Now()
|
||||
unrelated := model.AgentLesson{ID: "unrelated", Topic: "video.duration", Situation: "竖屏视频生成时长", Hits: 9000, UpdatedAt: now}
|
||||
relevant := model.AgentLesson{ID: "relevant", Topic: "对象绑定", Situation: "预演对象绑定角色时", Hits: 0, UpdatedAt: now.Add(-time.Hour)}
|
||||
index, matched := cloudAgentPickLessonIndex([]model.AgentLesson{unrelated, relevant}, "请处理对象绑定", 20)
|
||||
if matched != 1 || len(index) != 2 || index[0].ID != "relevant" {
|
||||
t.Fatalf("任务相关记忆应先于高频无关记忆,且两字短语应命中:matched=%d index=%+v", matched, index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,12 +217,15 @@ func (s *Service) startAgentMemoryCompactScheduler() {
|
||||
}
|
||||
|
||||
func (s *Service) dispatchDueAgentMemoryCompacts() {
|
||||
now := time.Now()
|
||||
if _, err := s.repo.ExpirePendingAgentLessons("", now.Add(-cloudAgentPendingLessonTTL), now, 500); err != nil {
|
||||
log.Printf("agent memory pending cleanup: %v", err)
|
||||
}
|
||||
rows, err := s.repo.ScheduledAgentMemorySettings(50)
|
||||
if err != nil {
|
||||
log.Printf("agent memory compact schedule: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
started := 0
|
||||
for index := range rows {
|
||||
setting := rows[index]
|
||||
|
||||
@@ -169,9 +169,11 @@ type cloudAgentRuntime struct {
|
||||
// PiAssistantResponses counts successful assistant message_end events from
|
||||
// the Pi runtime. Completion must not be inferred from a clean Node exit:
|
||||
// a provider/session error can otherwise be reported as a successful run.
|
||||
PiAssistantResponses int `json:"piAssistantResponses,omitempty"`
|
||||
IsGenerating bool `json:"isGenerating,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
PiAssistantResponses int `json:"piAssistantResponses,omitempty"`
|
||||
LessonEligibleToolSuccesses int `json:"lessonEligibleToolSuccesses,omitempty"`
|
||||
RememberLessonSuccesses int `json:"rememberLessonSuccesses,omitempty"`
|
||||
IsGenerating bool `json:"isGenerating,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// EmptyOutputEscalated 记录"空输出已经升级重试过几次"(关思考 + 放大输出预算)。
|
||||
EmptyOutputEscalated int `json:"emptyOutputEscalated,omitempty"`
|
||||
// StepTimeoutEscalated 记录"单步墙钟到点后已经关思考重试过几次"。
|
||||
|
||||
@@ -142,6 +142,9 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
|
||||
if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 {
|
||||
return errors.New("Agent runtime budget or step is invalid")
|
||||
}
|
||||
if state.LessonEligibleToolSuccesses < 0 || state.RememberLessonSuccesses < 0 {
|
||||
return errors.New("Agent lesson success counters are invalid")
|
||||
}
|
||||
if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds {
|
||||
return errors.New("Agent runtime confirmation round is invalid")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ import (
|
||||
)
|
||||
|
||||
func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool {
|
||||
if state != nil && err == nil {
|
||||
if cloudAgentLessonToolEligible(call.Function.Name) {
|
||||
state.LessonEligibleToolSuccesses++
|
||||
}
|
||||
if call.Function.Name == "remember_lesson" {
|
||||
state.RememberLessonSuccesses++
|
||||
}
|
||||
}
|
||||
payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments}
|
||||
if call.Function.Name == "skill_read_file" {
|
||||
var args struct {
|
||||
|
||||
@@ -125,6 +125,28 @@ func (r *Repository) CountAgentLessonsByAuthor(userID, status string) (int64, er
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *Repository) ExpirePendingAgentLessons(userID string, createdBefore, updatedAt time.Time, limit int) (int64, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 500
|
||||
}
|
||||
query := r.db.Model(&model.AgentLesson{}).Select("id").
|
||||
Where("status = ? AND created_at <= ?", model.AgentLessonStatusPending, createdBefore)
|
||||
if userID = strings.TrimSpace(userID); userID != "" {
|
||||
query = query.Where("author_user_id = ?", userID)
|
||||
}
|
||||
var ids []string
|
||||
if err := query.Order("created_at ASC").Limit(limit).Pluck("id", &ids).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
update := r.db.Model(&model.AgentLesson{}).
|
||||
Where("id IN ? AND status = ?", ids, model.AgentLessonStatusPending).
|
||||
Updates(map[string]any{"status": model.AgentLessonStatusRejected, "updated_at": updatedAt})
|
||||
return update.RowsAffected, update.Error
|
||||
}
|
||||
|
||||
func (r *Repository) AdminAgentLessons(status, userID, keyword string, limit int) ([]model.AgentLesson, error) {
|
||||
query := r.db.Model(&model.AgentLesson{})
|
||||
if trimmed := strings.TrimSpace(status); trimmed != "" {
|
||||
|
||||
Reference in New Issue
Block a user