Files
agcore/design/pdd/10c-phase2-llm-cycle-simplify.md
徐涛 28ca43ccb2 chore(docs): 将设计文档从 docs 移至 design 目录
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
2026-07-23 05:45:53 +08:00

45 KiB
Raw Permalink Blame History

Phase 2 实施计划:LlmCycle 简化(逻辑重构)

所属方案10-llm-provider-refinement.md

前置条件Phase 0 + Phase 1 已完成——新类型已就位,所有 Provider 已适配新 trait 签名

⚠️ 交叉阶段修复:审查发现部分关键修复需在 Phase 0 期间处理,否则 Phase 2 无法直接实施。详见下文"审查发现与交叉阶段修复计划"。

目标:移除 Phase 0 遗留的临时转换层,将 LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换为 Vec<Message>,利用新类型的表达能力重写核心逻辑


目标

  1. LlmCycle 内部消息存储从 Vec<OpenaiChatMessage> 切换为 Vec<Message>移除 Phase 0 引入的 OpenaiChatMessage ←→ Message 转换层
  2. 流处理循环重构:利用 MessageComplete.full_response 直接拿到完整响应,去掉手动 delta 累积
  3. 工具循环清洗:从 MessageResponse.message 的 content 中直接提取 ContentBlock::ToolUse
  4. compact.rs 适配新 Message 类型
  5. ConversationMemory 的消息类型同步切换

审查发现与交叉阶段修复计划

本方案于 2026-06-30 经架构审查发现 4 个必须修复的问题和多个需在 Phase 0 提前处理的依赖项。以下修复计划按"所属阶段"分类,标注责任文件和建议修复方式。

🔴 交叉阶段修复(Phase 0 期间完成,否则 Phase 2 受阻)

FIX-AToolInvocation 增加 tool_call_id 字段

属性
严重程度 🔴 阻碍性
影响 工具结果回传时 tool_call_id 丢失,Anthropic Provider 上必然失败
文件 src/tools/registry.rsToolInvocation 结构体)、src/llm/cycle.rs(调用点)

问题跟踪

  • ToolInvocationregistry.rs:17-35)只有 tool_nameinputoutput 三个字段,没有 tool_call_id
  • invoke_all() 签名(registry.rs:149-151)接收 Vec<(String, Value)>name, args),未传递 id
  • invoke() 签名(registry.rs:131)是 (&self, name: &str, args: Value)未传递 id
  • 当前代码用 result.tool_name 冒充 tool_call_idcycle.rs:617)——对 OpenAI 碰巧可用,Anthropic 必然失败
  • has_tool_calls_in_messagecycle.rs:645)基于旧 OpenaiChatMessage::Assistant { tool_calls } 判断,Phase 2 需同步改为检查 ContentBlock::ToolUse

修复方案

// ToolInvocation 增加 tool_call_id(必选,每次调用必有真实值)
pub struct ToolInvocation {
    pub tool_call_id: String,    // 新增
    pub tool_name: String,
    pub input: Value,
    pub output: Result<Value, ToolError>,
}

// ToolContext 不增加 tool_call_id
// 当前 ToolContext::new(name, "") 中 trace_id 已被设为空串,
// tool_call_id 通过 invoke_all→invoke→ToolInvocation 传递即可,
// 工具实现方不需要感知 tool_call_id。

关于 ToolContext 的决策:不向 ToolContext 增加 tool_call_id。理由是 ToolContext 目前通过 invoke() 内部构造(registry.rs:140ToolContext::new(name, "")),传递的是 tool_name 而非 tool_call_id。若增加 tool_call_id 字段会要求所有 invoke 调用链都感知 id——这增加了复杂度,而工具实现方实际并不需要 tool_call_id(结果是按调用顺序返回的,工具通过在消息历史中搜索 ToolUse 来关联上下文)。tool_call_id 仅在 ToolInvocation 中保存,供 submit_with_tools 回传时使用。

数据流extract_tool_calls_from_message() 返回 (id, name, args)invoke_all 签名改为 Vec<(String, String, Value)>id, name, args)→ ToolInvocation.tool_call_idsubmit_with_tools() 回传工具结果时使用 result.tool_call_idinvoke() 内部在构造 ToolInvocation 时即可获得 tool_call_id。

建议归属Phase 2.2(随 Task 4 统一改 registry.rs + cycle.rs,避免切割到 Phase 0 导致 scope 漂移)

影响范围

  • ToolInvocation::new() 签名从 3 参数变为 4 参数(registry.rs:28
  • invoke() 签名从 (&self, name, args) 改为 (&self, tool_call_id, name, args) 或内部通过包装传递(registry.rs:131
  • invoke_all() 签名从 Vec<(String, Value)> 改为 Vec<(String, String, Value)>registry.rs:149-151
  • cycle.rs:584-594|(_id, name, args)||(id, name, args)| 不再丢弃 id
  • 测试代码中的 ToolInvocationinvoke_all 调用点同步更新

FIX-BMessage + ContentBlock 的 serde 派生在 Phase 0 添加

属性
严重程度 🔴 阻碍性
影响 Phase 0→Phase 2 过渡期 ConversationMemory 无法使用新类型

问题Phase 2 的 Task 7ConversationMemory 适配)依赖 Message 实现 Serialize/Deserialize。如果 Phase 0 定义 Message 时未添加 serde 派生,Phase 2 需要回头修改 Phase 0 创建的文件,造成"谁创建谁维护"责任混乱。

修复方案:Phase 0 定义新类型时直接添加 serde 派生:

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Message {
    System { content: Vec<ContentBlock> },
    User { content: Vec<ContentBlock> },
    UserImage { data: String, mime_type: String, detail: ImageDetail },
    Assistant { content: Vec<ContentBlock> },
    ToolResult { tool_call_id: String, content: Vec<ContentBlock>, is_error: bool },
}

ContentBlock 同样添加 serde 派生(tag 策略在 Phase 0 设计时决定,建议 #[serde(rename_all = "snake_case")] + tag = "type" 或 untagged 视用法而定)。

此序列化格式仅用于内部存储ConversationMemory 持久化),不与任何 Provider 协议交互,故无需考虑外部兼容性。UserImage 的 JSON 形如 {"type": "user_image", "data": "...", "mime_type": "image/png", "detail": "auto"}

同步添加构造方法impl Message 块中的便捷构造函数,FIX-D 的 deprecated shim 和 Phase 2 各 Task 都依赖它们):

impl Message {
    pub fn system_text(text: impl Into<String>) -> Self {
        Message::System { content: vec![ContentBlock::Text { text: text.into() }] }
    }
    pub fn user_text(text: impl Into<String>) -> Self {
        Message::User { content: vec![ContentBlock::Text { text: text.into() }] }
    }
    pub fn assistant(text: impl Into<String>) -> Self {
        Message::Assistant { content: vec![ContentBlock::Text { text: text.into() }] }
    }
    pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self {
        Message::ToolResult {
            tool_call_id: tool_call_id.into(),
            content: vec![ContentBlock::Text { text: text.into() }],
            is_error,
        }
    }
}

建议归属:Phase 0,作为类型定义的一部分(零成本加法)


FIX-CContentBlock 变体列表在 Phase 0 设计时明确

属性
严重程度 🔴 阻碍性
影响 方案中 estimate_block_tokens 的 match 分支引用了未定义的 ContentBlock::ToolResult,导致 Phase 2 实施时类型不匹配

问题:方案 §6b 的 estimate_block_tokens 中出现 ContentBlock::ToolResult { content, .. } 分支(第 374 行),但父方案 10-llm-provider-refinement.md §2.1 定义 ContentBlock 时并未包含 ToolResult 变体。ToolResultMessage 的变体(消息级角色),不是 ContentBlock 的变体(内容块级角色)。

分析:在 Anthropic 协议中,Assistant 消息的 content 可以直接包含 tool_result block——这对应 ContentBlock::ToolResult 的应用场景(嵌套在其他消息的 content 中);而 Message::ToolResult 对应 OpenAI 的 role: "tool" 消息(消息列表的顶层变体)。二者语义层级不同,可以合理共存

修复方案Phase 0 设计 ContentBlock 时明确是否包含 ToolResult 变体。推荐包含,因为:

  1. Anthropic 协议支持 Assistant 消息内嵌 tool_result block
  2. 简化 Phase 2 的 estimate_block_tokens 实现(不需要在 match 中分流)
  3. compact 估算函数能递归处理嵌套的 tool result content

Phase 2 的 estimate_block_tokens 改为穷尽 match(不再使用 _ => 50 兜底),编译器强制检查所有变体覆盖。

建议归属Phase 0 设计决策,Phase 2 代码编写时确认


FIX-Dsystem_prompt 字段彻底移除

属性
严重程度 🔴 阻碍性
影响 与父方案 §2.4 设计意图矛盾,"两条路径"(字段 + messages 中的 System 消息)可能导致重复/覆盖

问题:方案 §2a 保留 system_prompt: Option<String> 字段,在 build_request() 中转为 Message::System 插入 messages 开头。但父方案 §2.4 明确意图:"system prompt 通过 Message::System 在 messages 中表达,Provider 映射层自行处理差异"。保留字段导致两条路径:

  • 路径 1:调用方通过 with_system_prompt() 设置纯文本
  • 路径 2:调用方直接通过 with_messages() 传入 Message::System

build_request() 中通过 !messages.iter().any(|m| matches!(m, Message::System { .. })) 尝试检测"是否已有系统消息",但本质上无法区分"用户主动传入的 System"和"之前阶段插入的 System"。

修复方案

  1. 彻底移除 system_prompt: Option<String> 字段
  2. 移除 with_system_prompt() 方法
  3. 调用方改为使用 Message::system_text() + with_messages() 或 builder 模式
  4. build_request() 直接使用 self.messages,不做任何 System 消息插入
pub struct LlmCycle {
    provider: Arc<dyn LlmProvider>,
    config: CycleConfig,
    usage: CostTracker,
    messages: Vec<Message>,        // 调用方自行管理所有消息类型
    // system_prompt 已移除
    hook_executor: Option<Arc<HookExecutor>>,
    compact_config: Option<CompactConfig>,
    compact_state: CompactState,
}

如需向后兼容(有已有调用方使用 with_system_prompt),可在 LlmCycle::new()submit() 前设置一个短暂的弃用期:

#[deprecated(since = "0.x.0", note = "请改用 Message::system_text() + with_messages()")]
pub fn with_system_prompt(mut self, prompt: String) -> Self {
    self.messages.insert(0, Message::system_text(prompt));
    self
}

建议归属Phase 2.0(与 Task 1 消息切换一步到位,杜绝过渡期。不在 Phase 2.1 留存已废弃字段)

调用方迁移(需在 Phase 2.0 实施前确认):

调用位置 代码 迁移方式
src/agent/session.rs:141-143 if let Some(prompt) = self.agent.system_prompt() { cycle = cycle.with_system_prompt(prompt.to_string()); } 改为 cycle = cycle.with_messages(vec![Message::system_text(prompt)]),或在 submit_turn 内部直接 push

依赖Message::system_text() 构造方法必须在 Phase 0 定义 Message 类型时同步添加(见 FIX-B)。#[deprecated] shim 中调用的 Message::system_text(prompt) 依赖于它。


🟡 Phase 2 内部修复

FIX-Esubmit_stream 返回类型修正 + 闭包借用问题解决

属性
严重程度 🟡 必须修复(编译错误 + API 语义断裂)
影响 方案代码存在编译错误(yield Ok(event) vs 签名不匹配);&mut self 借用冲突;post_request hook 消息不完整

三个问题

  1. 返回类型不匹配(§3a 第 159 行):yield Ok(event.clone()) 产生 Result<StreamEvent, LlmError>,但函数签名声明 Item = StreamEvent

  2. &mut self 借用冲突(§3a 第 163 行):self.messages.push(...)stream! 闭包中需要 &mut self,但闭包只能捕获共享引用。

  3. post_request hook 消息不完整(§3a 第 149 行):let post_request = self.build_request(&tools) 在流启动前执行,此时 self.messages 只有 user 消息,不包括即将到来的 Assistant 响应。

⚠️ 审查结论:mpsc 方案不可行,采用简化方案作为主方案

为什么 mpsc 方案不成立(两次审查确认):

问题 描述 根本原因
rx 竞争 mpsc rx 只能有一个消费者,但代码中两个 async move 块都试图消费 编译错误
阻塞破坏流契约 response_rx.await 在返回流前阻塞等待整个流结束 不是流式 API
StreamEvent::StreamEnd 不存在 引用了未定义的变体 新旧 StreamEvent 均无此变体
tokio::spawn task leak JoinHandle 被丢弃,调用方 drop Stream 后 task 可能永久挂起 资源泄漏

核心矛盾stream! 宏产生的异步生成器无法持有 &mut self(Pin 限制),且流是"延迟求值"的——submit_stream 返回流对象时流尚未执行。这意味着没有任何通道方案能让 submit_stream 在返回流之前就拿到完整响应。如果想在流结束后更新 self.messages,必须让调用方在流结束后显式调用一个方法。

采用简化方案作为主方案

pub async fn submit_stream(
    &mut self,
    prompt: String,
    tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
    self.messages.push(Message::user_text(prompt));
    self.maybe_compact();

    let request = self.build_request(&tools);

    // PreRequest hook
    if let Some(ref executor) = self.hook_executor {
        let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request);
        let results = executor.execute(HookEvent::PreRequest, &ctx).await;
        if results.iter().any(|r| r.should_block) {
            return Err(LlmError::Other("Blocked by pre-request hook".to_string()));
        }
    }

    let event_stream = self.provider.chat_stream(request).await?;
    let hook_executor = self.hook_executor.clone();

    Ok(Box::pin(stream! {
        use futures_util::StreamExt;
        let mut event_stream = event_stream;

        while let Some(result) = event_stream.next().await {
            match result {
                Ok(event) => {
                    yield event;
                }
                Err(e) => {
                    if let Some(ref executor) = hook_executor {
                        let ctx = HookContext::new(HookEvent::OnError).with_error(&e);
                        executor.execute(HookEvent::OnError, &ctx).await;
                    }
                    yield StreamEvent::Error { message: e.to_string() };
                    break;
                }
            }
        }

        // PostRequest hook:注意此处的消息列表不包含本次 Assistant 响应
        // (因为在流启动前 request 就已构建完成)
        if let Some(ref executor) = hook_executor {
            let ctx = HookContext::new(HookEvent::PostRequest);
            executor.execute(HookEvent::PostRequest, &ctx).await;
        }
    }))
}

变更

  1. yield event 直接产生 StreamEvent(与签名 Item = StreamEvent 一致)——移除 Ok() 包装
  2. 不再在闭包中操作 self.messages——调用方在收到 MessageComplete 事件后自行调用 cycle.push_message(full_response.message)
  3. post_request hook 明确文档化限制

调用方使用模式

let mut stream = cycle.submit_stream(prompt, tools).await?;
let mut final_response: Option<MessageResponse> = None;

while let Some(event) = stream.next().await {
    match &event {
        StreamEvent::MessageComplete { full_response } => {
            final_response = Some(full_response.clone());
        }
        // 处理其他事件...
        _ => {}
    }
}

// 流结束后更新消息历史
if let Some(resp) = final_response {
    cycle.push_message(resp.message.clone());
    // usage update via cycle.usage().add(&resp.usage);
}

建议归属Phase 2.2Task 3 实施时


FIX-Fmicrocompact 跳过 is_error: true 的工具结果

属性
严重程度 🟡 建议修复
影响 错误结果包含对 LLM 理解失败原因至关重要的信息,压缩后 LLM 无法理解

问题:§6c 的 microcompact() 对所有 Message::ToolResult 无条件压缩,包括 is_error: true 的结果。

修复方案:只压缩 is_error: false 的结果:

// microcompact 压缩阶段
for msg in &messages[..prune_start] {
    if matches!(msg, Message::ToolResult { is_error: false, .. }) {
        freed_tokens += estimate_single_message_tokens(msg);
    }
}

// 替换内容
for msg in &mut messages[..prune_start] {
    if let Message::ToolResult { content, is_error: false } = msg {
        *content = vec![ContentBlock::Text { text: "[pruned]".to_string() }];
    }
}

建议归属Phase 2Task 6 实施时一并处理


FIX-Gsubmit_with_tools()extract_tool_calls_from_responseinput 类型修正

属性
严重程度 🟡 编译类型安全(serde_json::to_string&Value 上调用)
影响 编译通过,但 serde_json::to_string(&input) 在 &Value 上调用可能产生意外格式

问题:§4a 第 231-233 行 extract_tool_calls_from_response 中的 input&Value(来自 ContentBlock::ToolUse { input, .. }),调用 serde_json::to_string(input) 可能序列化为多余的引号包裹格式。

修复方案:使用 serde_json::to_string(input).unwrap_or_default() 没问题(Value 实现了 Serialize),但建议改为 input.to_string()Value 的 Display 实现生成紧凑 JSON):

let args = input.to_string(); // 或 serde_json::to_string(input).unwrap_or_default()

建议归属Phase 2Task 4 实施时处理


FIX-H:测试基础设施迁移清单

属性
严重程度 🟡 必须覆盖(否则测试无法编译)
影响 测试代码使用旧类型,Phase 2 实施后编译失败

需迁移的测试辅助代码(按文件列出):

文件 行号 实体 当前类型 目标类型
src/llm/cycle.rs 700-725 MockProvider ChatRequest/ChatResponse MessageRequest/MessageResponse
src/llm/cycle.rs 731-737 assistant_text_response() 返回 ChatResponse 返回 MessageResponse
src/llm/cycle.rs 739-765 assistant_tool_call_response() OpenaiToolCall 构造 ContentBlock::ToolUse 构造
src/agent/session.rs 202-224 MockProvider ChatRequest/ChatResponse MessageRequest/MessageResponse
src/agent/session.rs 241-247 assistant_text() 返回 ChatResponse 返回 MessageResponse
src/agent/session.rs 141-143 with_system_prompt() 调用点 OpenaiChatMessage Message::system_text()
src/agent/builder.rs 126-132 StubProvider ChatRequest/ChatResponse MessageRequest/MessageResponse
src/tools/registry.rs 28 ToolInvocation::new() 3 参数 4 参数(含 tool_call_id

建议归属:Phase 2 尾部,作为一个单独的"测试适配 PR"集中修改(不在各 Task 中随改),避免业务代码和测试代码混杂。Phase 2 业务代码合入后,紧接着提交测试适配 PR。


🔄 实施顺序调整

原方案的线性顺序(1→2→3→4→5→6→7→8→9)考虑了依赖关系,但存在优化空间。结合交叉修复后,推荐以下实施顺序:

[Phase 0] 类型层(含交叉修复)
  FIX-B  Message + ContentBlock serde 派生 + 构造方法      ← 类型定义时添加
  FIX-C  ContentBlock 变体列表明确                          ← 设计决策

[Phase 2.0] 基础设施切换
  Task 1    LlmCycle 消息切换 (Vec<OpenaiChatMessage> → Vec<Message>)
          含 FIX-D  system_prompt 一步到位移除(与消息类型切换同时做)
  Task 7    ConversationMemory 切换                        ← 可并行,依赖 FIX-B

[Phase 2.1] 核心逻辑简化
  Task 5    submit/submit_messages/submit_request           ← 依赖 Phase 2.0,最简单先做
  Task 2    build_request 简化                              ← 依赖 Phase 2.0system_prompt 已在 Phase 2.0 移除)

[Phase 2.2] 流处理 + 工具循环
  Task 3    submit_stream 简化                              ← 简化方案(非 mpsc),含 FIX-E
  Task 4    工具循环清洗 + FIX-Atool_call_id             ← 统一改 registry + cycle

[Phase 2.3] 附属适配 + 清理
  Task 6    compact.rs 适配                                ← 可并行,含 FIX-F
  Task 9    prompt/composer.rs 适配
  Task 8    清理临时转换函数(含 has_tool_calls_in_message

[Phase 2.4] 收尾
  FIX-H     测试基础设施迁移                                 ← 单独 PR 集中处理
  (可选) FIX-G  代码风格优化

操作 文件 说明
修改 src/llm/cycle.rs 主要修改:消息类型切换 + 逻辑简化
修改 src/llm/compact.rs 适配 Message 类型(microcompact / should_compact / 估算函数)
修改 src/memory/conversation.rs 消息类型从 OpenaiChatMessage 切换为 Message
修改 src/prompt/composer.rs validate_messages() 适配 Message 类型
清理 各处 删除 Phase 0 引入的临时转换函数

不涉及

  • src/llm/cycle/usage.rsUsage 类型不变
  • src/llm/cycle/retry.rsRetryConfigshould_retry 不引用消息类型
  • src/llm/error.rsLlmError 不引用消息类型

Phase 2 对外影响(breaking changes 汇总)

以下变更影响所有使用 LlmCycle 的上游代码。Phase 2 实施前需确认调用方已适配。

变更 旧方式 新方式 影响文件
messages() 返回类型 &[OpenaiChatMessage] &[Message] 所有读取消息历史的上游代码
with_messages() / extend_messages() 参数 Vec<OpenaiChatMessage> Vec<Message> 使用 builder 模式的代码
with_system_prompt() 设置纯文本,build_request() 隐式插入 已废弃#[deprecated]),调用方使用 Message::system_text() + with_messages() src/agent/session.rs:141-143
submit() 返回类型 Result<ChatResponse, LlmError> Result<MessageResponse, LlmError> 所有调用 submit() 的代码
submit_with_tools() 返回类型 Result<ChatResponse, LlmError> Result<MessageResponse, LlmError> AgentSession
submit_messages() 签名 Vec<OpenaiChatMessage> 参数 Vec<Message> 参数 直接调用方
submit_stream() 调用方式 流内回传 StreamEvent(旧),submit_stream 负责 self.messages.push StreamEvent(新)透传,调用方需在 MessageComplete 后手动调用 cycle.push_message() 调用流式 API 的代码
compact 模块接收 &[OpenaiChatMessage] 参数 &[Message] 参数 仅在 LlmCycleConversationMemory 内部调用,对外无影响

迁移建议:所有上游变更集中在 src/agent/session.rs 和测试代码中。建议 Phase 2.0 实施前先在 session.rs 中完成消息类型对齐,然后随 Phase 2 各阶段逐步适配。


任务 1:LlmCycle 内部消息切换

1a:结构体字段变更

pub struct LlmCycle {
    provider: Arc<dyn LlmProvider>,
    config: CycleConfig,
    usage: CostTracker,
    // 从 Vec<OpenaiChatMessage> 改为 Vec<Message>
    messages: Vec<Message>,
    // system_prompt 字段已移除(审查 FIX-D):
    // 调用方通过 Message::system_text() + with_messages() 管理系统提示
    // 向后兼容:with_system_prompt() 标记为 #[deprecated],内部转为 Message::System 后插入
    hook_executor: Option<Arc<HookExecutor>>,
    compact_config: Option<CompactConfig>,
    compact_state: CompactState,
}

1bmessages() 方法签名变更

// 当前
pub fn messages(&self) -> &[OpenaiChatMessage] { &self.messages }
// 目标
pub fn messages(&self) -> &[Message] { &self.messages }

1cwith_messages() 方法签名变更

// 当前
pub fn with_messages(mut self, messages: Vec<OpenaiChatMessage>) -> Self
// 目标
pub fn with_messages(mut self, messages: Vec<Message>) -> Self

1dextend_messages() 方法签名变更

// 当前
pub fn extend_messages(&mut self, messages: Vec<OpenaiChatMessage>)
// 目标
pub fn extend_messages(&mut self, messages: Vec<Message>)

1e:新增 push_message() 公共方法

submit_stream() 消费方在收到 MessageComplete 事件后手动追加消息:

/// 追加单条消息到历史尾部(供 submit_stream 消费方使用)。
pub fn push_message(&mut self, msg: Message) {
    self.messages.push(msg);
}

任务 2:简化 build_request()

2a:移除转换步骤 + 移除 system_prompt 逻辑(审查 FIX-D

Phase 0 中的 build_request() 需要调用 chat_message_to_messageself.messagesVec<OpenaiChatMessage>)转为 Vec<Message>。Phase 2 中 self.messages 本身就是 Vec<Message>,所以这个转换步骤被移除。

同时,system_prompt 字段已移除(审查 FIX-D),调用方通过 Message::system_text() + with_messages() 自主管理系统提示消息。

fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
    // 直接使用 self.messages,不做任何 System 消息隐式插入
    let messages = self.messages.clone();

    MessageRequest {
        model: self.config.model.clone(),
        messages,
        tools: tools.to_vec(),
        tool_choice: ToolChoice::Auto,
        max_tokens: self.config.max_tokens,
        temperature: self.config.temperature,
        top_p: None,
        stop_sequences: vec![],
        stream: false,
        thinking: None,
        extra: HashMap::new(),
    }
}

注意tools 的类型从 Option<Vec<OpenaiTool>>OpenAI 特定包装)变为 Vec<ToolDefinition>MessageRequest 中使用的新类型)。Provider 映射层负责将 ToolDefinition 转换为协议特定的工具定义格式。build_request() 不再需要 OpenaiTool::Function 包装。

2bwith_system_prompt() 弃用过渡

#[deprecated(since = "0.x.0", note = "请改用 Message::system_text() + with_messages()")]
pub fn with_system_prompt(mut self, prompt: String) -> Self {
    self.messages.insert(0, Message::system_text(prompt));
    self
}

此方法保留一个过渡周期(标注 #[deprecated]),编译器发出弃用警告但不阻止编译。后续版本中移除。

2cwith_messages() 中忽略 system_prompt 冲突

with_messages() 不再做去重检查或 System 消息过滤——调用方传入什么就是什么。build_request() 直接使用 self.messages,不插入任何额外消息。


任务 3:简化 submit_stream()

3a:流处理循环变更(审查 FIX-E — 简化方案)

Phase 0 中,submit_stream()provider.chat_stream() 直接获得 Result<StreamEvent> 流。但为了在流结束后仍能向 self.messagesVec<OpenaiChatMessage>)追加响应,需要通过 message_to_chat_message 转换。

Phase 2 中,self.messages 已经是 Vec<Message>,所以不需要转换了。

⚠️ 审查结论mpsc 通道方案因 Rust 的 Pin/借用限制不可行(&mut self 无法进入闭包,流是延迟求值的),采用简化方案——submit_stream 内部不管理消息历史,由调用方在流结束后自行调用 push_message()

详情见"审查发现与交叉阶段修复计划"中 FIX-E 的分析。

pub async fn submit_stream(
    &mut self,
    prompt: String,
    tools: Vec<ToolDefinition>,
) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
    self.messages.push(Message::user_text(prompt));
    self.maybe_compact();

    let request = self.build_request(&tools);

    // PreRequest hook
    if let Some(ref executor) = self.hook_executor {
        let ctx = HookContext::new(HookEvent::PreRequest).with_request(&request);
        let results = executor.execute(HookEvent::PreRequest, &ctx).await;
        if results.iter().any(|r| r.should_block) {
            return Err(LlmError::Other("Blocked by pre-request hook".to_string()));
        }
    }

    let event_stream = self.provider.chat_stream(request).await?;
    let hook_executor = self.hook_executor.clone();

    Ok(Box::pin(stream! {
        use futures_util::StreamExt;
        let mut event_stream = event_stream;

        while let Some(result) = event_stream.next().await {
            match result {
                Ok(event) => {
                    yield event;
                }
                Err(e) => {
                    if let Some(ref executor) = hook_executor {
                        let ctx = HookContext::new(HookEvent::OnError).with_error(&e);
                        executor.execute(HookEvent::OnError, &ctx).await;
                    }
                    yield StreamEvent::Error { message: e.to_string() };
                    break;
                }
            }
        }

        // PostRequest hook:注意此处的消息列表不包含本次 Assistant 响应
        if let Some(ref executor) = hook_executor {
            let ctx = HookContext::new(HookEvent::PostRequest);
            executor.execute(HookEvent::PostRequest, &ctx).await;
        }
    }))
}

调用方使用模式

let mut stream = cycle.submit_stream(prompt, tools).await?;
let mut final_response: Option<MessageResponse> = None;

while let Some(event) = stream.next().await {
    match &event {
        StreamEvent::MessageComplete { full_response } => {
            final_response = Some(full_response.clone());
        }
        // 其他事件(TextDelta, ContentBlockStart 等)透传给 UI/消费方
        _ => {}
    }
}

// 流结束后更新消息历史
if let Some(resp) = final_response {
    cycle.push_message(resp.message.clone());
    // usage 更新:cycle.usage() 返回 &CostTracker,调用方自行 add
}

变更

  • yield 直接产生 StreamEvent(签名 Item = StreamEvent),不再包裹 Ok()
  • self.messages.push(response.message) 由调用方在流结束后通过 push_message() 完成
  • self.usage.add(&response.usage) 由调用方通过 cycle.usage().add() 完成
  • 无需 message_to_chat_message 转换

3b:设计决策记录

决策:采用简化方案(已验证 mpsc 方案因 Rust 语言限制不可行)。

已知限制(调用方需知晓):

  • post_request hook 收到的消息列表中不包含本次响应——如需完整消息列表,调用方应在流结束后手动触发 Hook

判断标准(如后续需切换方案):

  • 如果未来有需求要求 submit_stream 在流结束后自动推消息到 self.messages,可考虑将 self.messages 包裹为 Arc<Mutex<Vec<Message>>> 并在流闭包内安全修改——但这是 API 层面的 breaking change

任务 4:清洗工具循环

4asubmit_with_tools() 中的 tool 循环

当前 submit_with_tools() 使用 Phase 0 的临时逻辑:

  • response.messageMessage 类型(新枚举)
  • 使用 has_tool_calls_in_responseextract_tool_calls_from_response 解析 tool calls
  • 使用 OpenaiChatMessage::tool_result() 推送 tool 结果到 self.messages

Phase 2 中:

  • self.messages.push(response.message.clone()) —— 直接推送 Message(不需要转换)
  • tool 结果回传:self.messages.push(Message::tool_result(id, content, is_error))
  • response.message 提取 tool calls:遍历 content 中的 ContentBlock::ToolUse
fn has_tool_calls_in_response(response: &MessageResponse) -> bool {
    match &response.message {
        Message::Assistant { content } => {
            content.iter().any(|b| matches!(b, ContentBlock::ToolUse { .. }))
        }
        _ => false,
    }
}

fn extract_tool_calls_from_response(response: &MessageResponse) -> Vec<(String, String, String)> {
    match &response.message {
        Message::Assistant { content } => {
            content.iter().filter_map(|b| {
                if let ContentBlock::ToolUse { id, name, input } = b {
                    // input 是 &ValueDisplay 实现生成紧凑 JSON
                    let args = input.to_string();
                    Some((id.clone(), name.clone(), args))
                } else {
                    None
                }
            }).collect()
        }
        _ => vec![],
    }
}

4b:工具结果回传(依赖 FIX-A

⚠️ 前置依赖ToolInvocation 必须在 Phase 0/1 期间增加 tool_call_id 字段(见 FIX-A),否则以下代码无法编译。

for result in results {
    let is_error = result.output.is_err();
    let content = match result.output {
        Ok(ref value) => {
            let serialized = serde_json::to_string(value).unwrap_or_default();
            truncate_tool_result(&serialized, max_bytes)
        }
        Err(ref e) if e.is_recoverable() => format!("错误: {}", e),
        Err(ref e) => return Err(LlmError::Other(format!("工具不可恢复错误: {}", e))),
    };
    self.messages.push(Message::tool_result(
        result.tool_call_id.clone(),   // ← 来自 FIX-A 新增字段
        content,
        is_error,
    ));
}

数据流FIX-A 修复后):

  1. extract_tool_calls_from_response() 返回 Vec<(String, String, String)>id, name, args
  2. invoke_all() 接收 Vec<(String, String, Value)>id, name, args
  3. ToolInvocation.tool_call_id 保存原始 id
  4. 回传时使用 result.tool_call_id 关联原始调用

任务 5:简化 submit() / submit_messages() / submit_request()

5asubmit()

pub async fn submit(
    &mut self,
    prompt: String,
    tools: Vec<ToolDefinition>,
) -> Result<MessageResponse, LlmError> {
    self.messages.push(Message::user_text(prompt));
    self.maybe_compact();

    let mut attempts = 0;
    loop {
        let request = self.build_request(&tools);
        // ... hook 处理 ...
        match self.provider.chat(request).await {
            Ok(response) => {
                // ... hook 处理 ...
                self.messages.push(response.message.clone());
                self.usage.add(&response.usage);
                return Ok(response);
            }
            Err(e) => { /* 重试逻辑 */ }
        }
    }
}

变更

  • self.messages.push(OpenaiChatMessage::user_text(prompt))self.messages.push(Message::user_text(prompt))
  • self.messages.push(response.message.clone())response.message 已经是 Message 类型
  • 无需 message_to_chat_message 转换

5bsubmit_messages()

pub async fn submit_messages(
    &mut self,
    messages: Vec<Message>,
    tools: Vec<ToolDefinition>,
) -> Result<MessageResponse, LlmError> {
    let request = MessageRequest {
        model: self.config.model.clone(),
        messages,
        tools: tools.to_vec(),
        tool_choice: ToolChoice::Auto,
        max_tokens: self.config.max_tokens,
        temperature: self.config.temperature,
        ..Default::default()
    };
    // ... hook + provider.chat(request) ...
    match self.provider.chat(request).await {
        Ok(response) => {
            self.usage.add(&response.usage);
            Ok(response)
        }
        Err(e) => Err(e),
    }
}

5csubmit_request()

async fn submit_request(
    &mut self,
    tools: &[ToolDefinition],
) -> Result<MessageResponse, LlmError> {
    // 逻辑与之前一致,但返回类型改为 MessageResponse
    // ...
}

任务 6:适配 compact.rs

6aestimate_single_message_tokens()

fn estimate_single_message_tokens(msg: &Message) -> u32 {
    let role_overhead: u32 = 4;
    let content_tokens = match msg {
        Message::System { content }
        | Message::User { content }
        | Message::Assistant { content } => estimate_content_blocks_tokens(content),
        Message::UserImage { .. } => {
            // UserImage 固定估算
            50
        }
        Message::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
    };
    role_overhead + content_tokens
}

变更:当前使用 ContentField(旧类型,区分 String 和 Array),新类型统一为 &[ContentBlock]

6bestimate_content_blocks_tokens()

fn estimate_content_blocks_tokens(blocks: &[ContentBlock]) -> u32 {
    blocks.iter().map(estimate_block_tokens).sum()
}

fn estimate_block_tokens(block: &ContentBlock) -> u32 {
    match block {
        ContentBlock::Text { text } => estimate_text_tokens(text),
        ContentBlock::ToolResult { content, .. } => estimate_content_blocks_tokens(content),
        ContentBlock::Thinking { text, .. } => estimate_text_tokens(text),
        ContentBlock::ToolUse { input, .. } => {
            // tool_use 的 input 是 JSON Value
            estimate_text_tokens(&serde_json::to_string(input).unwrap_or_default())
        }
        _ => 50, // Image/Audio/File/Extension 固定估算
    }
}

6cmicrocompact()(审查 FIX-F

⚠️ FIX-F:只压缩 is_error: false 的 ToolResult。错误结果包含失败原因,压缩后 LLM 无法理解。

pub fn microcompact(messages: &mut [Message], keep_recent: usize) -> u32 {
    if messages.len() <= keep_recent {
        return 0;
    }
    let prune_start = messages.len() - keep_recent;
    let mut freed_tokens: u32 = 0;

    // 第一遍:计算可释放的 token(仅非错误结果)
    for msg in &messages[..prune_start] {
        if matches!(msg, Message::ToolResult { is_error: false, .. }) {
            freed_tokens += estimate_single_message_tokens(msg);
        }
    }

    // 第二遍:替换内容(仅非错误结果)
    for msg in &mut messages[..prune_start] {
        if let Message::ToolResult { content, is_error: false } = msg {
            *content = vec![ContentBlock::Text { text: "[pruned]".to_string() }];
        }
    }

    freed_tokens
}

变更

  • OpenaiChatMessage::ToolMessage::ToolResult
  • ContentField::Array(vec![OpenaiContentPart::Text { text: "[pruned]" }])vec![ContentBlock::Text { text: "[pruned]".to_string() }]
  • 新增条件:is_error: false 时才压缩(保留错误诊断信息)

6dshould_compact()

pub fn should_compact(
    messages: &[Message],
    config: &CompactConfig,
    state: &CompactState,
) -> bool {
    // 逻辑不变,只是消息类型改为 &[Message]
    if state.consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
        return false;
    }
    let tokens = estimate_message_tokens(messages);
    tokens >= config.threshold()
}

任务 7:同步适配 ConversationMemory

src/memory/conversation.rs 的消息类型从 OpenaiChatMessage 切换为 Message

7a:结构体字段

pub struct ConversationMemory {
    store: Arc<dyn MemoryStore>,
    session_id: String,
    config: ConversationMemoryConfig,
    messages: Vec<Message>,           // 从 OpenaiChatMessage 改为 Message
    message_ids: Vec<String>,
    compact_state: CompactState,
}

7b:序列化/反序列化

// 当前
serde_json::from_str::<OpenaiChatMessage>(&item.content)

// 目标
serde_json::from_str::<Message>(&item.content)

注意Message 枚举需要实现 Serialize / Deserialize

已在 Phase 0 处理(审查 FIX-B):Phase 0 定义 MessageContentBlock 类型时直接添加 #[derive(Serialize, Deserialize)]#[serde(tag = "type", rename_all = "snake_case")]。Phase 2 直接使用即可。

序列化格式示例(仅用于内部存储,不与 Provider 协议交互):

{"type": "user", "content": [{"type": "text", "text": "hello"}]}
{"type": "user_image", "data": "...", "mime_type": "image/png", "detail": "auto"}
{"type": "assistant", "content": [{"type": "text", "text": "Hi!"}, {"type": "tool_use", "id": "call_1", "name": "get_weather", "input": {"city": "Beijing"}}]}
{"type": "tool_result", "tool_call_id": "call_1", "content": [{"type": "text", "text": "Sunny, 25°C"}], "is_error": false}

7c:消息 ID 前缀兼容

fn session_prefix(&self) -> String {
    format!("conv:{}:", self.session_id) // 格式不变
}

序列化格式变化后,旧消息无法反序列化为新类型。由于项目尚未 release,可以接受这个不兼容。


任务 8:清理临时转换函数

删除 Phase 0 引入的以下函数(确认不再被引用后删除):

  1. chat_message_to_message() — 在 build_request() 中已不再需要(self.messages 已是 Vec<Message>
  2. message_to_chat_message() — 在 submit_stream() 中已不再需要(流结束后直接使用 full_response.message
  3. content_to_blocks() — 如果只在上述两个函数中使用,一并删除
  4. blocks_to_content_field() — 同上
  5. has_tool_calls_in_message()cycle.rs:645,基于 OpenaiChatMessage::Assistant { tool_calls } 判断。Phase 2 中由 has_tool_calls_in_response()(基于 ContentBlock::ToolUse)替代
  6. extract_tool_calls_from_message()cycle.rs:658,基于 OpenaiToolCall 提取。Phase 2 中由 extract_tool_calls_from_response()(基于 ContentBlock::ToolUse)替代

验证方法

  • 全局搜索函数名(git grep),确认在所有文件中的引用计数归零
  • cargo build 通过(编译器对非 pub 的未使用函数报 warningpub 函数不报 warning。用 git grep 做精准确认)
  • cargo clippydead_code 级别警告

任务 9:适配 prompt/composer.rs(如需要)

validate_messages() 当前检查 OpenaiChatMessage 序列中的 role 顺序规则。如果该函数被 AgentSessionLlmCycle 调用,需要适配新 Message 类型。

检查步骤:

  1. 搜索 validate_messages 的调用者
  2. 如果被上游调用且上游已切换为 Vec<Message>,则适配
  3. 适配内容:match 分支从 OpenaiChatMessage::Tool { .. } 改为 Message::ToolResult { .. },从 OpenaiChatMessage::Assistant { tool_calls: Some(..) } 改为检查 ContentBlock::ToolUse

验证方式

  1. 编译检查cargo build 通过
  2. 单元测试cargo test 全部通过
  3. 集成测试
    • 多轮对话(submit + submit_with_tools)端到端正常
    • 工具调用循环正常(含 tool_call_id 正确关联)
    • 流式响应(submit_stream)正常
    • 上下文压缩(compact)超过 token 阈值后正确压缩
    • 错误路径provider.chat() 返回错误时,消息历史不被污染(self.messages.push 在确认成功后才执行)
    • 序列化 roundtripMessage → JSON → Message 保持所有字段(含 UserImageToolResult.is_error
    • 边界情况submit_with_tools 收到空 content 的 ContentBlock、包含 UserImage 的消息
    • hook 集成PreRequest hook 正确收到 MessageRequest(而非旧的 ChatRequest
  4. clippy 检查cargo clippy 无新增警告
  5. diff 确认git diff 确认 Phase 0 引入的临时转换函数已被删除

回滚方案

⚠️ 审查评估feature flag 方案在两套 StreamEvent 类型共存时工程量大,实际安全网依赖 checkpoint 打 tag。

回退对象 可行策略 复杂度 说明
submit_stream() feature flag legacy_stream 🔴 需要同时保留旧 StreamEvent 类型和旧 chat_stream Provider 调用路径。除非新旧 StreamEvent 完全兼容,否则工程量大。不推荐
compact.rs 模块同步回退 🟢 should_compact/microcompact 只在 LlmCycleConversationMemory 中调用,签名变化后可整体回退
全局 git checkout + 重做 🟢 极低 项目尚未 release,无外部消费者。每个子阶段完成后打 tag(phase2-taskX-checkpoint),允许跳跃回退

推荐回退策略:不在代码层面做 feature flag,而是:

  1. 每个子阶段(Phase 2.0/2.1/2.2/2.3)完成后创建 git tag
  2. 如果某一子阶段出现问题,git revert 该子阶段的所有 commit
  3. 修复后重新提交,不保留两套实现共存

开放事项

  • [已解决] MessageSerialize / Deserialize 派生设计 → 移至 Phase 0FIX-B
  • [已解决] system_prompt 字段的最终去留 → Phase 2 彻底移除FIX-D
  • ConversationMemory 序列化格式变更后的数据兼容性(项目未 release,可接受)
  • submit_with_tools_stream() 未来设计:流式响应 + 自动工具循环的组合场景
  • ToolContext 是否增加 tool_call_id 字段(FIX-A 方案二中可选)
  • ContentBlock::Extension 逃生舱的具体使用场景
  • Thinking signature 的端到端测试