package engine // 大结果磁盘持久化 -- 防止工具长输出撑爆上下文. // // 当工具输出超过阈值时,将完整输出存到磁盘,模型只看到摘要 + 文件路径. // 典型场景: // - grep 搜索到 1000 行结果 // - bash npm install 输出 500 行日志 // - cat 大文件的完整内容 // // 存储路径://.txt // // 设计: // - 只用 Go 标准库 // - 线程安全(无共享可变状态,每次写入独立文件) // - 支持定期清理旧文件 import ( "fmt" "os" "path/filepath" "time" ) const ( // MaxInlineResultChars 是内联结果的最大字符数. // 超过此阈值的工具输出将被存到磁盘. MaxInlineResultChars = 30000 // TruncatedPreviewChars 是截断时保留的预览字符数. // 模型会看到前 N 个字符 + 磁盘路径提示. TruncatedPreviewChars = 5000 ) // ResultStore 管理大结果的磁盘持久化. type ResultStore struct { dir string // 存储基础目录(如 ~/.flyto/tool-results/) sessionID string // 当前会话 ID } // NewResultStore 创建一个结果存储实例. // baseDir 是存储基础目录,sessionID 是当前会话 ID. // 如果目录不存在会在首次写入时自动创建. func NewResultStore(baseDir string, sessionID string) *ResultStore { if sessionID == "" { sessionID = fmt.Sprintf("session-%d", time.Now().UnixNano()) } return &ResultStore{ dir: baseDir, sessionID: sessionID, } } // ProcessResult 处理工具输出. // 如果输出长度 <= MaxInlineResultChars,原样返回. // 否则存到磁盘,返回截断预览 + 磁盘路径. // // 返回值: // - processedOutput: 处理后的输出(可能被截断) // - storedPath: 如果存储到磁盘,返回文件路径;否则为空字符串 func (s *ResultStore) ProcessResult(toolUseID, toolName, output string) (processedOutput string, storedPath string) { // 精妙之处(CLEVER): 大结果分流策略--30K 字符以内内联,超过则存盘只保留 5K 预览. // 这个阈值来自实际观察:grep 搜到 1000 行(约 50K 字符)就会撑爆上下文窗口, // 而 5K 预览足够模型理解结果模式并决定下一步.存盘路径让模型可以用 Read 工具查看完整内容. if len(output) <= MaxInlineResultChars { return output, "" } // 构建存储路径 sessionDir := filepath.Join(s.dir, s.sessionID) filePath := filepath.Join(sessionDir, toolUseID+".txt") // 确保目录存在 if err := os.MkdirAll(sessionDir, 0755); err != nil { // 创建目录失败,降级:只返回截断预览,不存储 preview := truncateString(output, TruncatedPreviewChars) return fmt.Sprintf("[Output too large (%d chars), truncated. Failed to save to disk: %v]\n\n%s", len(output), err, preview), "" } // 写入磁盘 // 升华改进(ELEVATED): 权限 0600 而非 0644--工具输出可含 API key,数据库密码等敏感信息, // 不应对同机其他用户可读.早期方案 0644 遗留自初版实现,当时未考虑多用户服务端部署场景. if err := os.WriteFile(filePath, []byte(output), 0600); err != nil { // 写入失败,降级 preview := truncateString(output, TruncatedPreviewChars) return fmt.Sprintf("[Output too large (%d chars), truncated. Failed to save to disk: %v]\n\n%s", len(output), err, preview), "" } // 构建截断预览 preview := truncateString(output, TruncatedPreviewChars) processedOutput = fmt.Sprintf( "[Output too large (%d chars). Full output saved to: %s]\nShowing first %d chars:\n\n%s", len(output), filePath, TruncatedPreviewChars, preview, ) return processedOutput, filePath } // ReadStoredResult 从磁盘读取之前存储的完整工具输出. func (s *ResultStore) ReadStoredResult(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("读取存储结果失败: %w", err) } return string(data), nil } // Cleanup 清理指定时间之前的旧结果文件. // olderThan 指定清理多久之前的文件(例如 24 * time.Hour 清理一天前的). // 返回清理的文件数量. func (s *ResultStore) Cleanup(olderThan time.Duration) int { cutoff := time.Now().Add(-olderThan) cleaned := 0 // 遍历基础目录下的所有会话目录 entries, err := os.ReadDir(s.dir) if err != nil { return 0 } for _, entry := range entries { if !entry.IsDir() { continue } sessionDir := filepath.Join(s.dir, entry.Name()) files, err := os.ReadDir(sessionDir) if err != nil { continue } removedCount := 0 for _, f := range files { if f.IsDir() { continue } info, err := f.Info() if err != nil { continue } if info.ModTime().Before(cutoff) { filePath := filepath.Join(sessionDir, f.Name()) if os.Remove(filePath) == nil { cleaned++ removedCount++ } } } // 如果会话目录下所有文件都被清理了,尝试移除空目录 if removedCount == len(files) { os.Remove(sessionDir) // 忽略错误(目录可能非空) } } return cleaned } // truncateString 截断字符串到指定 Unicode 码点数. // // 升华改进(ELEVATED): 按 rune 截断而非按字节截断-- // 中文每字 3 字节,字节截断会在字符中间切割,产生无效 UTF-8 序列, // 导致后续 JSON 序列化或 HTTP 传输失败. // 早期方案实现 `s[:maxChars]` 按字节截断,注释承诺"保留完整的最后一行"但未实现. // 替代方案: - 否决:治标不治本, // 修复后字符仍然不完整(最后一个字的前几字节丢失). func truncateString(s string, maxChars int) string { runes := []rune(s) if len(runes) <= maxChars { return s } return string(runes[:maxChars]) }