package engine // normalize.go -- 消息规范化,确保发送给 API 的消息序列合法. // // 升华改进(ELEVATED): 从硬编码 3 步升级为 9 步可组合 Pipeline. // 早期方案只有 3 步清理(孤立 tool_result → 空消息 → 连续同角色合并), // 现在是完整的规范化管道,每个步骤独立,可测试,可替换. // 替代方案:继续在一个大函数里追加步骤(原始设计,随步骤增多变得不可维护). // // API 对消息格式有严格要求: // - 消息必须交替出现(user/assistant/user/assistant...) // - tool_result 必须有对应的 tool_use(否则 API 报错) // - 不能有空消息 // - 不能有纯 thinking 的 assistant 消息 // - 不能有纯空白的 assistant 消息 // - 图片不能超过 20MB // // NormalizeMessagesForAPI 在发送前清理消息列表,修复这些问题. // 这是防御性编程:即使上层逻辑完美,也在最后一道关卡保证数据合法. import ( "git.flytoex.net/yuanwei/flyto-agent/pkg/query" ) // DefaultNormalizePipeline 返回内置的规范化管道. // // 步骤按 Priority 排序执行 (L1187 修复: 每行后加依赖原因): // // 5: AttachmentReorderer - 附件消息上浮 (暂不启用, 需先引入附件概念) // 8: ToolResultPairingNormalizer - tool_use/tool_result 完整配对修复 (7.3) // [必须早于 orphan_tool_result - 成对后才能检出孤儿] // 10: OrphanToolResultRemover - 孤立 tool_result 移除 // [依赖: tool_result_pairing 先运行] // 15: ErrorContentStripper - 错误内容剥离 [独立] // 18: OrphanThinkingFilter - 孤立 thinking 过滤 [独立] // 20: EmptyMessageFilter - 空消息过滤 // [必须早于 whitespace_assistant - 空消息是空白特例] // 22: WhitespaceAssistantFilter - 空白 assistant 过滤 // [依赖: empty_message 先运行] // 25: ToolUseInputNormalizer - tool_use 输入规范化 [独立] // 30: ConsecutiveRoleMerger - 连续同角色合并 // [必须尾部 - 合并只在所有结构化过滤完成后才有意义] // 50: ImageValidator - 图片验证 // [必须最后 - 验证 pass 不应看到被清理前的脏数据] // // L1187 修复说明 (2026-04-13): Priority 系统本质是**隐式依赖** - 数值定顺序而非 // 显式声明 DependsOn.改成完整 DependsOn + 拓扑排序是过度设计 (没有活跃外部 // 消费者注入自定义 normalizer).改用**文档化依赖图 + 契约测试**作为最小 viable // 方案: 文档告诉读者"为什么这个数值", 测试保证未来改 Priority 不会静默破坏依赖. // // 依赖图 (有向边 = "必须早于"): // // tool_result_pairing (8) ─────────► orphan_tool_result (10) // empty_message (20) ─────────► whitespace_assistant (22) // (所有结构化 normalizer) ─────────► consecutive_role (30) // (所有 transformation) ─────────► image_validator (50) // // 其余 normalizer (error_content_strip / orphan_thinking / tool_input) 独立, // 可以插入任意位置而不破坏语义.未来改 Priority 值必须保持上述 4 条有向边的 // 偏序关系, 否则会静默破坏规范化语义. // // 契约测试: `TestDefaultNormalizePipeline_OrderingContract` 锁定精确顺序, // `TestDefaultNormalizePipeline_DependencyInvariants` 锁定核心偏序 (允许新增 // normalizer 只要保持偏序).两个互补, 破坏任一条偏序会 fail CI. // // 消费者插入自定义 normalizer 的指南: // // - 只读分析 → 任意位置, 不影响语义 // - 结构化过滤 → 插在 empty_message (20) 之前, 或 consecutive_role (30) 之前 // - 验证 pass → 优先级 > 30, 即后于 consecutive_role // - **绝不允许** 插在 tool_result_pairing (8) 和 orphan_tool_result (10) 之间 // // 精妙之处(CLEVER): 最后一道防线--即使上层 runLoop 逻辑完美, // 压缩,恢复,消息合并等操作都可能产生不合法的消息序列. // Pipeline 在发送 API 请求前做最终清理,保证 API 永远不会收到畸形数据. func DefaultNormalizePipeline() *NormalizePipeline { return DefaultNormalizePipelineWithObserver(nil, nil) } // DefaultNormalizePipelineWithObserver 返回内置的规范化管道,并注入 Observer 和 StrictMode. // // 升华改进(ELEVATED): Observer 注入让规范化管道的每次修复都可观测. // 生产中消息配对错误是最常见的 API 400 根因--没有可观测性就无法定位. // 替代方案:规范化管道内部用 fmt.Println 调试(开发时有用,生产中无法消费). func DefaultNormalizePipelineWithObserver(observer EventObserver, strict *StrictMode) *NormalizePipeline { return NewNormalizePipeline( &ToolResultPairingNormalizer{Observer: observer, StrictMode: strict}, &OrphanToolResultRemover{}, &ErrorContentStripper{Patterns: DefaultErrorPatterns}, &OrphanThinkingFilter{}, &EmptyMessageFilter{}, &WhitespaceAssistantFilter{}, &ToolUseInputNormalizer{}, &ConsecutiveRoleMerger{}, &ImageValidator{MaxSizeBytes: DefaultMaxImageSizeBytes}, // AttachmentReorderer 暂不启用(需要先在上层引入附件标记概念) // &AttachmentReorderer{}, ) } // NormalizeMessagesForAPI 规范化消息列表,确保可以安全发送给 Messages API. // // 历史包袱(LEGACY): 保持原有函数签名,向后兼容. // 内部委托给 DefaultNormalizePipeline().Run(). // 新代码应直接使用 NormalizePipeline 以获得更多控制. func NormalizeMessagesForAPI(messages []query.Message) []query.Message { return DefaultNormalizePipeline().Run(messages) } // collectToolUseIDs 遍历消息列表,收集所有 tool_use 内容块的 ID. // // 精妙之处(CLEVER): 返回 map[string]bool 而非 slice-- // 调用方需要 O(1) 成员检查("这个 tool_use ID 存在吗?"), // map 比 slice 的线性扫描快. // 多个 normalizer(OrphanToolResultRemover,ToolResultPairingNormalizer) // 独立实现了相同逻辑;提取为公共函数消除重复. func collectToolUseIDs(messages []query.Message) map[string]bool { ids := make(map[string]bool) for _, msg := range messages { for _, c := range msg.Content { if c.Type == query.ContentToolUse && c.ID != "" { ids[c.ID] = true } } } return ids }