package engine // scratchpad.go 实现引擎级别的键值暂存区(Scratchpad). // // 设计动机: // // LLM 对话历史只能向后追加,无法"原地更新"中间计算结果. // Scratchpad 提供一个独立于对话历史的持久 KV 区-- // Agent 可以在多轮之间存储规划步骤,中间变量,状态标志, // 而无需把临时数据污染进对话历史(否则会撑大上下文,模糊边界). // // 升华改进(ELEVATED): 早期实现 无 Scratchpad 概念,Agent 只能把临时结果 // // 写进 MEMORY.md 或留在对话历史里--两种方式都有代价: // MEMORY.md 是长期持久化(临时结果不应该长期留着), // 对话历史只增不减(大量临时计算撑爆上下文). // Scratchpad 是第三种选择:生命周期与 Engine 实例绑定, // 引擎关闭即清空,不需要主动清理,零配置. // 跨行业扩展:仓储盘点的"当前批次统计",医疗场景的"本次问诊待确认事项". // 替代方案:<让 Agent 把临时结果存进对话历史> // - 否决原因:临时数据和正式推理混在一起,模型后续轮次难以区分"什么是决策依据". // // 精妙之处(CLEVER): TTL=0 表示永不过期,而非"立即过期". // // 0 是 time.Duration 的零值,与 Go 惯例(0 = 禁用/不限制)一致. // "永不过期" 是最常见的用法,零值命中默认路径,不需要显式设置. // 替代方案:-1 表示永不过期(违反 Go 惯例,调用方容易误用). import ( "sort" "sync" "time" ) // Scratchpad 是引擎级别的键值暂存区. // // 用于跨轮次保存中间结果,Agent 可通过 scratchpad_write/scratchpad_read 工具访问. // 线程安全,支持 TTL 过期(可选). // // 生命周期:与 Engine 实例绑定--引擎创建时初始化,引擎关闭时自动丢弃. // 不持久化到磁盘,不跨引擎实例共享. type Scratchpad struct { mu sync.RWMutex entries map[string]*scratchEntry } // scratchEntry 是 Scratchpad 中的一个条目. type scratchEntry struct { value string expiresAt time.Time // zero value = 永不过期 } // isExpired 检查条目是否已过期. // 精妙之处(CLEVER): time.Time 零值是"公元 1 年 1 月 1 日", // 远小于任何合理的 now,所以 zero.IsZero() 才是正确的"永不过期"检测, // 而不是 expiresAt.Before(now)--零值满足 Before(now),会错误地判为"已过期". func (e *scratchEntry) isExpired() bool { if e.expiresAt.IsZero() { return false // 永不过期 } return time.Now().After(e.expiresAt) } // NewScratchpad 创建一个空的 Scratchpad 实例. func NewScratchpad() *Scratchpad { return &Scratchpad{ entries: make(map[string]*scratchEntry), } } // Set 设置键值对,可选 TTL. // // ttl=0 表示永不过期;ttl>0 表示过期时长(相对于调用时刻). // 对同一 key 多次 Set 会覆盖旧值,并刷新 TTL. func (s *Scratchpad) Set(key, value string, ttl time.Duration) { s.mu.Lock() defer s.mu.Unlock() entry := &scratchEntry{value: value} if ttl > 0 { entry.expiresAt = time.Now().Add(ttl) } s.entries[key] = entry } // Get 获取键对应的值. // // 返回 (value, true) 如果 key 存在且未过期; // 返回 ("", false) 如果 key 不存在或已过期. // // 精妙之处(CLEVER): 惰性删除(lazy eviction)--过期条目在首次 Get 时才清除, // 不启动后台清理 goroutine(避免 Engine 生命周期管理复杂化). // 代价:过期条目会短暂占用内存,直到被 Get/Keys 触发清理. // 对 Scratchpad 的典型规模(数十条)来说,内存影响可忽略. // 替代方案:<后台 goroutine 定时清理> - 否决:增加 Engine 关闭协调复杂度. func (s *Scratchpad) Get(key string) (string, bool) { // 先用读锁快速检查 s.mu.RLock() entry, ok := s.entries[key] s.mu.RUnlock() if !ok { return "", false } if entry.isExpired() { // 升级为写锁,惰性删除过期条目 s.mu.Lock() // double-check:可能已被其他 goroutine 删除 if e, still := s.entries[key]; still && e.isExpired() { delete(s.entries, key) } s.mu.Unlock() return "", false } return entry.value, true } // Delete 删除指定键(如果存在). func (s *Scratchpad) Delete(key string) { s.mu.Lock() defer s.mu.Unlock() delete(s.entries, key) } // Clear 清空所有条目(包括未过期的). // // 用于 Agent 主动重置暂存区,或 Engine 关闭清理(GC 也会自动回收,调用 Clear 是可选的). func (s *Scratchpad) Clear() { s.mu.Lock() // 历史包袱(LEGACY): 原方案 range delete,改为重新分配--与 SectionRegistry.Reset 一致, // 让 GC 直接回收整个旧 map,而非逐个清除 slot. s.entries = make(map[string]*scratchEntry) s.mu.Unlock() } // Keys 返回所有未过期 key 的有序列表. // // 升华改进(ELEVATED): 返回有序列表而非 map 的随机顺序-- // Agent 通过 scratchpad_list 工具获取 key 列表时, // 有序结果让模型更容易理解"当前暂存了什么", // 也让测试可重复(不依赖 map 迭代顺序). // 替代方案:<直接返回 []string 无序> - 否决:模型推理受输入顺序影响,无序时容易遗漏. func (s *Scratchpad) Keys() []string { s.mu.Lock() defer s.mu.Unlock() keys := make([]string, 0, len(s.entries)) for k, entry := range s.entries { if !entry.isExpired() { keys = append(keys, k) } else { // 顺便惰性清理过期条目 delete(s.entries, k) } } sort.Strings(keys) return keys } // Len 返回未过期条目数量(不含已过期但未清除的条目). // 主要用于测试和监控. func (s *Scratchpad) Len() int { return len(s.Keys()) }