package engine // reminders.go 实现系统提醒注入功能. // // 系统提醒是在对话过程中动态注入的短消息,用 标签包裹. // 原项目在以下场景注入提醒: // - 日期变更:跨日对话时提醒当前日期 // - 工具可用性:当有延迟加载的工具被激活时提醒 // - 记忆提醒:当相关记忆被发现时注入 // - 上下文压缩发生:提醒模型历史已被压缩 // - 文件变更:用户外部修改了 Agent 之前读过的文件时提醒 // - 会话统计:定期提醒 token 用量和成本 // // 设计要点: // - 每轮开始前调用 CollectReminders 收集所有需要注入的提醒 // - 提醒以 system 消息的形式注入到本轮的消息列表中 // - 各检查项互相独立,任意一项失败不影响其他项 import ( "context" "fmt" "strings" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/memory" "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // FileChangeChecker 是文件变更检测接口. // // 升华改进(ELEVATED): 从 *FileStateCache 具体类型提升为接口-- // ReminderSystem 只需要"列出最近文件"和"检查是否变更"两个能力, // 不需要 FileStateCache 的全部实现(LRU 缓存,SHA256 哈希等). // 接口化后: // 1. 测试可以注入 mock 而无需构造真实 FileStateCache; // 2. 未来可以替换为基于 inotify/FSEvents 的实时监听实现; // 3. 其他模块实现相同接口即可复用 ReminderSystem 的文件变更检测. // // 替代方案:<保留 *FileStateCache 具体类型> -- 否决:测试困难,扩展受限. // 对比:memStore memory.Store 已经是接口,与之风格一致. type FileChangeChecker interface { RecentFiles(n int) []string IsModified(path string) bool // Info returns a value snapshot of the cache entry for path so // reminders can tell the model what specifically was read (size, // hash, line count, read timestamp, prior-modified flag) -- not // just "this file changed". Implementations that cannot provide // the snapshot should return (FileCacheEntry{}, false); the // consumer (CheckFileModifications) falls back to path-only output. // // Info 返回 path 对应缓存条目的值快照, 让 reminder 能告诉模型"读 // 的是什么"(大小 / 哈希 / 行数 / 读取时间 / 是否已被标记过) -- // 而不仅仅是"这个文件变了". 实现若无法提供快照应返回 // (FileCacheEntry{}, false); 消费者 (CheckFileModifications) // 会降级为只输出路径. Info(path string) (FileCacheEntry, bool) } // reminderRecentFilesLimit 是文件变更检测时获取最近读过文件的数量上限. const reminderRecentFilesLimit = 50 // reminderMemoryLimit 是记忆相关性检测时最多返回的条数. const reminderMemoryLimit = 3 // reminderTurnInterval 是轮次统计提醒的间隔(每 N 轮注入一次). const reminderTurnInterval = 10 // reminderContentPreview 是记忆内容预览的最大字符数. const reminderContentPreview = 200 // ReminderSystem 管理系统提醒的收集与注入. type ReminderSystem struct { // 精妙之处(CLEVER): 用日期字符串而非 time.Time 做变更检测-- // 只需比较两个字符串是否相等就能判断是否跨日,避免了时区转换和精度问题. // 简单但有效,因为我们只关心"日期是否变了"而非具体时间差. lastDate string lastTokens int // 上次报告的 token 用量 fileCache FileChangeChecker // 文件变更检测(检测外部修改) memStore memory.Store // 记忆存储(查找相关记忆) freshnessConfig *memory.FreshnessConfig // 新鲜度警告配置,nil 时不注入警告(模块 10.4) } // NewReminderSystem 创建一个新的系统提醒管理器. // // 参数: // - fileCache: 文件变更检测器,用于检测外部文件修改.可为 nil(跳过文件检测). // - memStore: 记忆存储,用于查找相关记忆.可为 nil(跳过记忆检测). func NewReminderSystem(fileCache FileChangeChecker, memStore memory.Store) *ReminderSystem { return &ReminderSystem{ lastDate: time.Now().Format("2006-01-02"), fileCache: fileCache, memStore: memStore, } } // CollectReminders 收集当前需要注入的所有系统提醒. // 返回需要注入的提醒列表,每个都是用 包裹的文本. // // 参数: // - ctx: 上下文,用于可取消操作 // - currentMessages: 当前的消息历史(用于提取最近文本做记忆匹配) // - turnNumber: 当前轮次编号(用于决定是否推送统计提醒) // // 升华改进(ELEVATED): 增加轮次间隔提醒--在非编程场景(如长时间咨询,教学辅导), // 定期提醒进度和资源消耗有助于用户做出"继续/停止"的决策, // 而不是在预算用完时才突然中断.每 10 轮注入一次轮次统计提醒. // 替代方案:<原方案 turnNumber 参数未使用,无轮次统计提醒> func (r *ReminderSystem) CollectReminders(ctx context.Context, currentMessages []query.Message, turnNumber int) []string { var reminders []string // 1. 检查日期变更 if dateReminder := r.CheckDateChange(); dateReminder != "" { reminders = append(reminders, dateReminder) } // 2. 检查文件外部修改 fileReminders := r.CheckFileModifications() reminders = append(reminders, fileReminders...) // 3. 检查相关记忆(从最近的用户消息中提取文本) recentText := extractRecentUserText(currentMessages) if recentText != "" { if memReminder := r.CheckMemoryRelevance(ctx, recentText); memReminder != "" { reminders = append(reminders, memReminder) } } // 4. 轮次统计提醒(每 10 轮) if turnNumber > 0 && turnNumber%reminderTurnInterval == 0 { reminders = append(reminders, FormatReminder( fmt.Sprintf("Session progress: %d turns completed. Message count: %d.", turnNumber, len(currentMessages)))) } return reminders } // CheckDateChange 检查日期是否变化. // 如果日期发生了变化,返回格式化的提醒文本;否则返回空字符串. func (r *ReminderSystem) CheckDateChange() string { currentDate := time.Now().Format("2006-01-02") if currentDate == r.lastDate { return "" } r.lastDate = currentDate return FormatReminder(fmt.Sprintf("# currentDate\nToday's date is %s.", currentDate)) } // CheckFileModifications 检查 Agent 读过的文件是否被外部修改. // 返回所有检测到修改的文件的提醒列表. func (r *ReminderSystem) CheckFileModifications() []string { if r.fileCache == nil { return nil } // 获取最近读过的文件列表 recentFiles := r.fileCache.RecentFiles(reminderRecentFilesLimit) if len(recentFiles) == 0 { return nil } var reminders []string type modSnapshot struct { path string size int64 lineCount int contentHash string readAt time.Time alreadyFlagged bool } var modified []modSnapshot for _, path := range recentFiles { if !r.fileCache.IsModified(path) { continue } // Info returns a value snapshot -- read size / hash / line count / // timestamp so the reminder can tell the model WHAT changed (not // just that something did). This activates the previously // unconsumed FileCacheEntry metadata fields (Size / LineCount / // ContentHash / ReadAt / WasModified) as real consumer reads. // // Info 返回值副本 -- 读大小 / 哈希 / 行数 / 读取时间, 让 reminder // 能告诉模型"具体什么变了"(而不仅仅是"变了"). 这把 FileCacheEntry // 之前未消费的 5 个元数据字段 (Size / LineCount / ContentHash / // ReadAt / WasModified) 从"声明未读"激活为真消费点. info, ok := r.fileCache.Info(path) if !ok { // 缓存已淘汰, fallback 到纯路径 modified = append(modified, modSnapshot{path: path}) continue } modified = append(modified, modSnapshot{ path: info.Path, size: info.Size, lineCount: info.LineCount, contentHash: info.ContentHash, readAt: info.ReadAt, alreadyFlagged: info.WasModified, }) } if len(modified) > 0 { var sb strings.Builder sb.WriteString("The following files have been modified externally since you last read them:\n") for _, m := range modified { sb.WriteString(fmt.Sprintf("- %s", m.path)) if m.size > 0 || m.lineCount > 0 { // 读取时的大小 / 行数 / 哈希前 8 位 / 相对读取时间, // 让模型能比对"当时读的 vs 现在磁盘的". // At-read-time size / line count / hash prefix / relative // age, so the model can compare "then vs now". hash := m.contentHash if len(hash) > 8 { hash = hash[:8] } ageStr := "" if !m.readAt.IsZero() { ageStr = fmt.Sprintf(", read %s ago", formatDurationShort(time.Since(m.readAt))) } sb.WriteString(fmt.Sprintf(" (at-read-time: %d bytes / %d lines / hash %s%s)", m.size, m.lineCount, hash, ageStr)) } if m.alreadyFlagged { // WasModified 已由过往 IsModified 调用标记过, 表明这是 // 重复告警, 模型/UI 可以 dedup 或降权. // WasModified means a prior IsModified already flagged // this; consumers can de-duplicate or lower the weight. sb.WriteString(" [already flagged]") } sb.WriteString("\n") } sb.WriteString("You may want to re-read these files before making changes.") reminders = append(reminders, FormatReminder(sb.String())) } return reminders } // formatDurationShort renders a time.Duration as "Ns" / "Nm" / "Nh" / // "Nd" for human-readable reminder text. // // formatDurationShort 把 time.Duration 渲染成 "Ns"/"Nm"/"Nh"/"Nd" 供 // 人类可读的 reminder 文本使用. func formatDurationShort(d time.Duration) string { switch { case d < time.Minute: return fmt.Sprintf("%ds", int(d.Seconds())) case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes())) case d < 24*time.Hour: return fmt.Sprintf("%dh", int(d.Hours())) default: return fmt.Sprintf("%dd", int(d.Hours()/24)) } } // CheckMemoryRelevance 检查是否有相关记忆需要提醒. // 根据最近的对话文本查找相关记忆,如果找到则格式化为提醒. // // 参数: // - ctx: 上下文 // - recentText: 最近的用户输入文本(用于相似度匹配) func (r *ReminderSystem) CheckMemoryRelevance(ctx context.Context, recentText string) string { if r.memStore == nil { return "" } // 查找最多 3 条相关记忆 entries, err := r.memStore.FindRelevant(ctx, recentText, reminderMemoryLimit) if err != nil || len(entries) == 0 { return "" } var sb strings.Builder sb.WriteString("Relevant memories found:\n") for _, entry := range entries { sb.WriteString(fmt.Sprintf("- [%s] %s", entry.Name, entry.Description)) if entry.Content != "" { // 截取前 200 字符 content := entry.Content if len(content) > reminderContentPreview { content = content[:reminderContentPreview] + "..." } sb.WriteString(fmt.Sprintf("\n %s", content)) } // 新鲜度警告(模块 10.4) // 升华改进(ELEVATED): 早期实现 的记忆注入不带任何新鲜度提示, // 模型无法判断某条记忆是否仍然有效. // 我们在每条相关记忆之后注入 FreshnessNote,让模型在接收记忆内容的 // 同时知道"这条记忆已 N 天未更新,请先核实". // 替代方案:<只在 MEMORY.md 里加索引注记> - // 否决原因:MEMORY.md 不一定在当前轮被读取,不能保证覆盖. if r.freshnessConfig != nil { threshold := r.freshnessConfig.ThresholdFor(entry.Type) if note := memory.FreshnessNote(entry.ModTime, threshold); note != "" { sb.WriteString("\n ") sb.WriteString(note) } } sb.WriteString("\n") } return FormatReminder(sb.String()) } // FormatReminder 将文本格式化为 包裹的消息. func FormatReminder(text string) string { return "\n" + text + "\n" } // extractRecentUserText 从消息历史中提取最近的用户输入文本. // 取最后一条用户消息的文本内容. func extractRecentUserText(messages []query.Message) string { // 从后往前查找最近的用户消息 for i := len(messages) - 1; i >= 0; i-- { msg := messages[i] if msg.Role != query.RoleUser { continue } for _, c := range msg.Content { if c.Type == query.ContentText && c.Text != "" { return c.Text } } } return "" }