docs(phase0): 新增 Phase 0 实施计划文档

定义类型层落地计划,涵盖新类型定义、Trait 签名切换及上游适配方案
This commit is contained in:
徐涛
2026-06-30 22:22:12 +08:00
parent 000cd2022d
commit 832ebf2665
3 changed files with 3179 additions and 0 deletions
+997
View File
@@ -0,0 +1,997 @@
# Phase 0 实施计划:类型层落地 + Trait 签名切换
> **所属方案**[10-llm-provider-refinement.md](10-llm-provider-refinement.md)
>
> **前置条件**:无(首个实施阶段)
>
> **产出依赖**Phase 1Provider 适配)依赖本阶段的类型定义和 trait 签名
---
## 目标
新增新类型系统 + 切换 `LlmProvider` trait 签名,使全链路使用新类型。Phase 0 结束时 `cargo test` 全部通过。
## 原则
1. **新类型定义放入新文件**`message.rs``request_v2.rs``response_v2.rs`),不堆积到已有类型文件
2. 已有的 `request.rs``OpenaiChatRequest`)、`response.rs``OpenaiChatResponse`)、`stream.rs`(旧 `StreamEvent`**保留原样**,后续 Provider 实现可能作为内部转换目标继续引用
3. `LlmProvider` trait 签名由 `chat(ChatRequest) → ChatResponse` 切换为 `chat(MessageRequest) → MessageResponse`**在同一个 Phase 内完成**
4. trait 签名变更导致的编译错误(`StubProvider``LlmCycle` 调用点)**在 Phase 0 内全部修复**,不留到 Phase 1
5. `AgentSession` 等上游中对 `LlmCycle.submit()` 返回值的引用同步适配
6. 不使用任何新依赖,只在现有 crate 范围内完成
---
## 涉及文件
| 操作 | 文件 | 说明 |
|------|------|------|
| 新增 | `src/llm/types/message.rs` | Message 扁平大枚举 + ContentBlock + 辅助类型 |
| 新增 | `src/llm/types/request_v2.rs` | MessageRequest + ExtraError + extra 访问方法 |
| 新增 | `src/llm/types/response_v2.rs` | MessageResponse + StreamEvent(新) + PartialMessageResponse |
| 追加 | `src/llm/types/mod.rs` | 追加 `pub mod` 声明和 `pub use` 重导出 |
| 修改 | `src/llm/provider.rs` | LlmProvider trait 签名切换 |
| 修改 | `src/agent/builder.rs` | StubProvider 适配新 trait 签名 |
| 修改 | `src/llm/cycle.rs` | build_request/submit/submit_stream/submit_messages/submit_request 适配 |
| 修改 | `src/llm/cycle/retry.rs` | 如有对新 LlmError 类型的引用,同步适配 |
| 修改 | `src/agent/session.rs` | ChatResponse → MessageResponse 引用适配 |
| 修改 | `src/memory/conversation.rs` | 如有对旧类型别名的引用,同步适配 |
## 实施前基线确认
实施者在开始 Phase 0 前应确认:
1. `git status` — 工作区干净,无未提交的修改
2. `cargo build` — 编译通过,无已有错误
3. `cargo test` — 所有测试通过
4. `grep -r "pub type Message = OpenaiChatMessage" src/` — 确认旧类型别名的引用面(供命名冲突处理参考)
如果基线已有问题,在修复基线后再开始 Phase 0,以免干扰对 Phase 0 改动的判断。
---
## 任务依赖关系
```
任务 1-4(新类型定义) ← 可并行
├──→ 任务 5(新类型单元测试)← 可并行,不阻塞下游
└──→ 任务 6(trait 签名切换)
└──→ 任务 7StubProvider 适配)
└──→ 任务 8(LlmCycle 适配)
└──→ 任务 9(上游适配)
```
**关键路径**:任务 1 → 6 → 7 → 8 → 9
**可并行**:任务 5 可与任务 6-9 并行编写,但在执行任务 6 前需确认类型定义已就绪
---
## 任务 1:新增 `src/llm/types/message.rs`
定义 IR 层消息类型,基于 10 号文档 §2.1 Decision-01。
### 类型定义
```rust
use crate::llm::types::shared::ImageDetail;
/// 跨 Provider 统一的消息类型(扁平大枚举)。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(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 },
}
```
**注意**
- 9b 文档中原有 `Tool` 变体(对应 OpenAI `tool` role),本设计改为 `ToolResult`
- `ImageDetail` 类型已在 `shared.rs` 中定义(`enum { Auto, Low, High }`,已派生 `Serialize/Deserialize`),此处直接引用
### ContentBlock 类型
从 9b §3.1 移植,参考 10 号文档 §4 任务 2:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentBlock {
Text { text: String },
Image { source: ImageSource },
Audio { source: AudioSource },
File { source: FileSource },
ToolUse { id: String, name: String, input: serde_json::Value },
ToolResult { tool_use_id: String, content: Vec<ContentBlock>, is_error: bool },
Thinking { text: String, signature: Option<String> },
Extension { kind: String, data: serde_json::Value },
}
```
### ContentBlockType(用于 StreamEvent.ContentBlockStart
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentBlockType {
Text,
Thinking,
Refusal,
ToolUse { id: String, name: String },
}
```
### 辅助类型
从 9b §3.1 移植:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageSource {
pub data: String,
pub mime_type: String,
pub is_url: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioSource {
pub data: String,
pub format: AudioFormat, // 复用现有 shared.rs 中的 AudioFormat(已派生 Serialize/Deserialize
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSource {
pub data: String,
pub filename: Option<String>,
pub mime_type: Option<String>,
}
```
### 便捷构造函数
基于 10 号文档 §2.1 设计:
```rust
impl Message {
pub fn user_text(text: impl Into<String>) -> Self { ... }
pub fn user_image(data: impl Into<String>, mime_type: impl Into<String>, detail: ImageDetail) -> Self { ... }
pub fn assistant(text: impl Into<String>) -> Self { ... }
pub fn system(text: impl Into<String>) -> Self { ... }
pub fn tool_result(tool_call_id: impl Into<String>, text: impl Into<String>, is_error: bool) -> Self { ... }
}
```
**注意**:这里的 `tool_result` 签名与 9b 文档不同——增加了 `is_error: bool` 参数,与扁平大枚举的 `ToolResult` 变体一致。
### 关于 serde 策略
所有新类型统一规则:
- 使用 `#[derive(Serialize, Deserialize)]`serde 已是项目依赖)
- 枚举使用 `#[serde(rename_all = "snake_case")]`(与现有项目中 `AudioFormat``ImageDetail` 等保持一致)
- 结构体使用默认命名(不额外标注 rename)
- `ContentBlock` 等复合枚举使用外部标记格式(externally taggedserde 默认行为),后续如需调整 tagging 策略(如 internally tagged)可在实施时按需修改
### 验证点
- 所有枚举变体 match 穷举性验证(编译器保证)
- `Message` 实现 `Debug + Clone + Serialize + Deserialize`
- `ContentBlock` 实现 `Debug + Clone + Serialize + Deserialize`
---
## 任务 2:移植 ContentBlock 辅助类型
### ImageSource
```rust
pub struct ImageSource {
pub data: String, // base64 或 URL
pub mime_type: String, // "image/png", "image/jpeg", "image/webp"
pub is_url: bool, // true = URL, false = base64
}
```
### AudioSource
复用 `shared.rs` 中的 `AudioFormat`
```rust
pub struct AudioSource {
pub data: String,
pub format: AudioFormat,
}
```
### FileSource
```rust
pub struct FileSource {
pub data: String,
pub filename: Option<String>,
pub mime_type: Option<String>,
}
```
### 与 10 号文档的差异说明
10 号文档 §4 任务 2 要求从 `9b` 移植 `ImageSource``AudioSource``FileSource`。其中 `ImageSource``ImageURL` 简化而来(去掉 `detail` 字段,增加 `is_url` 标记)。`AudioSource` 复用现有 `AudioFormat``FileSource` 与现有 `FileData` 结构相似但字段名简化。
---
## 任务 3:新增 `src/llm/types/request_v2.rs`
基于 9b §3.6 MessageRequest + §3.7 ExtraError。
### MessageRequest
```rust
use std::collections::HashMap;
use crate::llm::types::request::ToolChoice;
// ToolDefinition 已在 types/mod.rs 中定义为 pub type ToolDefinition = OpenaiToolDefinition
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessageRequest {
pub model: String,
pub messages: Vec<Message>,
pub tools: Vec<ToolDefinition>,
pub tool_choice: ToolChoice,
pub max_tokens: Option<u32>,
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub stop_sequences: Vec<String>,
pub stream: bool,
pub thinking: Option<ThinkingConfig>,
pub extra: HashMap<String, serde_json::Value>,
}
```
**说明**
- `system` 字段不存在——系统提示通过 `Message::System { content }``messages` 列表中表达
- `ToolDefinition` 复用现有的 `OpenaiToolDefinition` 类型别名(`src/llm/types/mod.rs` 中已有 `pub type ToolDefinition = OpenaiToolDefinition`;如果 `message.rs``request_v2.rs` 不在 `types/` module 内,通过 `use crate::llm::types::ToolDefinition;` 引入)
- `ToolChoice` 复用现有的 `ToolChoice` 枚举(`src/llm/types/request.rs`;通过 `use crate::llm::types::request::ToolChoice;` 引入)
- `#[derive(Default)]` 确保 `..Default::default()` 可用(如 `build_request` 中使用)
- `MessageRequest` 要求 `Message` 满足 `Serialize + Deserialize`(任务 1 已派生)
### ⚠️ 命名冲突:旧 `type Message` 与新 `enum Message`
`types/mod.rs` 中现有 `pub type Message = OpenaiChatMessage;` 别名。Phase 0 新增 `message.rs` 并导出 `pub enum Message` 后,在 `types::` 命名空间下产生重定义冲突。
**解决方案(三选一,推荐选项 A):**
| 选项 | 操作 | 影响 |
|------|------|------|
| **A(推荐)** | 在 `types/mod.rs` 中移除 `pub type Message = OpenaiChatMessage;`,所有仍引用 `types::Message` 的地方改为直接使用 `OpenaiChatMessage` | 旧别名已不必要(新代码都引用 `Message`),移除后无外部使用者依赖此别名 |
| B | 将旧别名重命名为 `pub type OpenaiMessage = OpenaiChatMessage;` | 需同步更新所有引用点,Phase 2 清理时再删除 |
| C | `message.rs` 中不直接 `pub use` `Message`,而是通过 `MessageEnum` 等中间名称导出 | 对外接口不干净,不推荐 |
**建议 Phase 0 实施时采用选项 A**,因为 `types::Message` 作为 `OpenaiChatMessage` 的别名在现有代码中引用面很小(`grep "types::Message" src/ -r` 确认),且 Phase 2 最终会完全淘汰 `OpenaiChatMessage`
### ThinkingConfig
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThinkingConfig {
pub budget_tokens: u32,
}
```
### ExtraError 枚举
基于 9b §3.7 ExtraError
```rust
#[derive(thiserror::Error, Debug)]
pub enum ExtraError {
#[error("extra 字段 `{key}` 类型不匹配: {details}")]
TypeMismatch { key: String, details: String },
#[error("extra 反序列化失败: {0}")]
Deserialize(String),
}
```
### extra 访问方法
```rust
impl MessageRequest {
pub fn get_extra<T: DeserializeOwned>(&self, key: &str) -> Result<Option<T>, ExtraError> { ... }
pub fn get_extra_opt<T: DeserializeOwned>(&self, key: &str) -> Option<T> { ... }
pub fn get_extra_as<T: DeserializeOwned>(&self) -> Result<T, ExtraError> { ... }
pub fn set_extra(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) { ... }
}
```
### 验证点
- `MessageRequest` 可构造
- `set_extra` / `get_extra` 基本路径:设置后能正确读取
- `get_extra` 类型不匹配时返回 `Err(ExtraError::TypeMismatch)`
---
## 任务 4:新增 `src/llm/types/response_v2.rs`
基于 10 号文档 §2.3 Decision-03 修订后的定义。
### StopReason 枚举
基于 9b §3.3
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StopReason {
Stop, Length, ToolUse, ContentFilter, MaxTokens, StopSequence, Other,
}
```
### MessageResponse
```rust
use std::collections::HashMap;
use crate::llm::types::Usage;
/// 复用现有 Usage 类型(已在 types/mod.rs 中定义,已派生 Serialize/Deserialize)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageResponse {
pub id: String,
pub model: String,
pub message: Message,
pub usage: Usage,
pub stop_reason: StopReason,
pub extra: HashMap<String, serde_json::Value>,
}
impl MessageResponse {
pub fn text(&self) -> String { ... }
}
```
### PartialUsage
```rust
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PartialUsage {
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
pub completion_tokens_details: Option<CompletionTokensDetails>,
pub prompt_tokens_details: Option<PromptTokensDetails>,
}
```
### StreamEvent(高精度版)
基于 10 号文档 §2.3 Decision-03
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StreamEvent {
// Meta
MessageStart { id: String, model: String },
// Content Block 边界
ContentBlockStart { index: u32, block_type: ContentBlockType },
ContentBlockEnd { index: u32 },
// 块内增量
TextDelta { text: String },
ThinkingDelta { text: String },
RefusalDelta { text: String },
ToolCallArgumentsDelta { index: u32, arguments: String },
ToolCallEnd { index: u32 },
// 汇总
CostUpdate { usage: PartialUsage },
/// 消息完成——唯一可靠的完整响应来源。full_response 携带完整的 MessageResponse。
MessageComplete { full_response: MessageResponse },
// 错误
Error { message: String },
}
```
**说明**:与 9c 原有设计不同,`MessageComplete` 不再携带独立的 `stop_reason``thinking_signature` 字段——这些信息已在 `full_response` 中。
### PartialMessageResponse
基于 9c §4.4,包含 `ContentBlockBuilder` 定义和 `apply_to` / `finalize` 方法。
**关键变更**(对应 10 号文档 §2.3 修订):
- `thinking_signature` 改为由 Provider 直接调用 `set_thinking_signature()` 写入内部状态,不再经过事件层
- `MessageComplete.apply_to` 不再处理 `stop_reason``thinking_signature` 顶层字段
```rust
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct PartialMessageResponse {
pub id: Option<String>,
pub model: Option<String>,
pub blocks: BTreeMap<u32, ContentBlockBuilder>,
pub block_completion: HashSet<u32>,
pub last_open_index: Option<u32>,
/// 流式累积中的部分用量信息。
/// 使用 PartialUsage(字段为 Option)而非 Usage(字段为 u32),
/// 因为流式场景中 prompt_tokens 和 completion_tokens 可能分多次到达
/// (如 Anthropic 的 message_delta 事件可多次下发 usage 增量)。
pub usage: PartialUsage,
pub stop_reason: Option<StopReason>,
pub thinking_signature: Option<String>,
pub is_errored: bool,
pub is_complete: bool,
}
```
### ContentBlockBuilder
```rust
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentBlockBuilder {
Text(String),
Thinking { buffer: String, signature: Option<String> },
Refusal(String),
/// 内聚设计:ToolUse 直接保存 arguments 字符串,
/// 而非依赖外部 PartialMessageResponse.tool_call_args HashMap。
/// ToolCallArgumentsDelta 事件直接追加到此字段。
ToolUse { id: String, name: String, arguments: String },
}
```
### apply_to 算法
`StreamEvent::apply_to(&self, state: &mut PartialMessageResponse) -> bool`
算法逻辑(基于 9c §4.4,按本次审查修订):
- `MessageStart` → 设置 id / model
- `ContentBlockStart` → 创建对应类型的 ContentBlockBuilder,按 index 分桶
- `ContentBlockEnd` → 标记该 index 的 block 已完成
- `TextDelta` / `ThinkingDelta` / `RefusalDelta` → 追加到最后打开的 block 缓冲区
- `ToolCallArgumentsDelta` → 按 index 查找对应 ContentBlockBuilder::ToolUse,将 arguments 追加到其 `arguments` 字段
- `ToolCallEnd` → 标记该 index 的 block 已完成
- `CostUpdate` → 字段级合并 PartialUsage(只覆盖 Some 字段)
- `MessageComplete` → 设置 `is_complete = true`
- `Error` → 设置 `is_errored = true`,返回 false 终止处理
### finalize 算法
`PartialMessageResponse::finalize(self) -> Result<MessageResponse, LlmError>`
按 index 升序遍历 blocks,转换为 ContentBlock
- Text → ContentBlock::Text
- Thinking → 如有 signature 未填充,用 `self.thinking_signature` 回填
- Refusal → ContentBlock::TextOpenAI refusal 合并为 Text
- ToolUse → 从 builder 的 `arguments` 字段解析为 JSON,构造 ContentBlock::ToolUse
最终将 `self.usage: PartialUsage` 转换为 `Usage`(缺失字段默认为 0),然后返回 MessageResponse,含 content / usage / stop_reason。
**关于错误处理**:如果 finalize 被调用但 `is_complete == false` 或关键字段缺失,应新增 `LlmError::Finalize(String)` 变体(在 `src/llm/error.rs` 中追加),或直接使用 `LlmError::Other(String)`。建议新增专用变体以增强错误可追溯性。
### 验证点
- `StreamEvent` 所有变体 match 穷举性
- `PartialMessageResponse` 默认构造后状态正确
- `apply_to(MessageStart)` → id/model 被设置
- `apply_to` 事件序列 → `finalize()` 产生正确的 MessageResponse
- `CostUpdate` 字段级合并正确(先设置 prompt_tokens,再设置 completion_tokens,结果两者都存在)
---
## 任务 5:新类型侧单元测试
测试按类型归属分散到对应文件:
- Message 构造 + ContentBlock roundtrip → `src/llm/types/message.rs``#[cfg(test)]`
- MessageRequest + ExtraError → `src/llm/types/request_v2.rs``#[cfg(test)]`
- MessageResponse + StreamEvent + PartialMessageResponse + JSON roundtrip → `src/llm/types/response_v2.rs``#[cfg(test)]`
### 测试清单
1. **Message 构造测试**
- `Message::user_text("hello")` 产生 `Message::User { content: [ContentBlock::Text { text: "hello" }] }`
- `Message::assistant("hi")` 产生 `Message::Assistant { content: [ContentBlock::Text { text: "hi" }] }`
- `Message::system("sys")` 产生 `Message::System { content: [ContentBlock::Text { text: "sys" }] }`
- `Message::tool_result("id", "result", false)` 产生正确的 ToolResult 变体
2. **Message match 穷举性**(编译器验证,但显式写 match 确保不会漏变体)
3. **PartialMessageResponse.apply_to + finalize 整合测试**
- 模拟完整的 OpenAI 流式响应(text only
- 模拟完整的 Anthropic 流式响应(thinking + text + tool_use
- 模拟 CostUpdate 分多次到达(字段级合并正确性)
4. **apply_to 事件序列测试**
- MessageStart → finalize 产生正确 id/model
- ContentBlockStart(Text) → TextDelta → ContentBlockEnd → finalize 产生正确 Text block
- ContentBlockStart(ToolUse{id, name}) → ToolCallArgumentsDelta → ToolCallEnd → finalize 产生正确 ToolUse block
- ContentBlockStart(Thinking) → ThinkingDelta → ContentBlockEnd → finalize 产生正确 Thinking block
5. **MessageComplete 事件测试**
- apply_to(MessageComplete{full_response}) 设置 `is_complete = true`
- finalize 后产生的 MessageResponse 与设置一致
6. **Error 事件测试**
- apply_to(Error) 设置 `is_errored = true`,返回 false
- finalize 仍然返回 Ok(即使有错误)
7. **JSON roundtrip 测试**(对应 10 号文档 §4 任务 5 要求)
- `Message → JSON → Message`:对每个 Message 变体(System/User/UserImage/Assistant/ToolResult)分别构造实例,序列化后反序列化,验证往返不变
- `ContentBlock → JSON → ContentBlock`:对每个 ContentBlock 变体(Text/Image/Audio/File/ToolUse/ToolResult/Thinking/Extension)分别构造实例,验证往返不变
- `MessageRequest → JSON → MessageRequest`:构造含各种字段的完整请求,验证往返不变
- `MessageResponse → JSON → MessageResponse`:构造含完整嵌套的响应,验证往返不变
- `StreamEvent → JSON → StreamEvent`:对每个 event 变体验证往返不变
8. **ContentBlock::ToolResult 构造和序列化测试**
- 构造 `ContentBlock::ToolResult` 实例,验证字段正确性
- 序列化后反序列化,验证 `tool_use_id``content``is_error` 不变
---
## 任务 6:修改 `src/llm/provider.rs` —— LlmProvider trait 签名切换
### 当前签名
```rust
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, LlmError>;
async fn chat_stream(
&self, request: ChatRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<OpenaiChatChunk, LlmError>> + Send>>, LlmError>;
}
```
### 目标签名
```rust
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{StreamEvent, MessageResponse};
pub trait LlmProvider: Send + Sync {
async fn chat(&self, request: MessageRequest) -> Result<MessageResponse, LlmError>;
async fn chat_stream(
&self, request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError>;
fn capabilities(&self) -> ProviderCapabilities;
}
```
**注意**
- 新增的 `capabilities` 方法是新 trait 的一部分(参考 9c §4.2)。10 号文档 §4 实施步骤中未显式列出此方法的添加(§4 任务 6 只要求签名切换),但作为 trait 完整性要求,在 Phase 0 一并引入。此偏差已在设计评审中确认合理。
- Phase 0 不要求实现细节——各 Provider 可以先返回默认值。
- `ProviderCapabilities` 类型定义放在 `provider.rs` 中(与 trait 定义同文件),不分散到类型目录。
### ProviderCapabilities
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderCapabilities {
pub provider_name: &'static str,
pub supported_models: Option<Vec<String>>,
pub features: ProviderFeatures,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderFeatures {
pub streaming: bool,
pub thinking: bool,
pub vision: bool,
pub audio_input: bool,
pub tool_use: bool,
pub parallel_tool_calls: bool,
pub system_prompt_in_messages: bool,
pub max_context_window: u32,
}
```
### import 变更
```rust
// 移除旧导入
// use crate::llm::types::{ChatRequest, ChatResponse, OpenaiChatChunk};
// 新增新导入
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{StreamEvent, MessageResponse};
```
---
## 任务 7:修改 `src/agent/builder.rs` —— StubProvider 适配
### 当前 `StubProvider`
```rust
struct StubProvider;
#[async_trait]
impl LlmProvider for StubProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
unimplemented!()
}
}
```
### 目标实现
```rust
struct StubProvider;
#[async_trait]
impl LlmProvider for StubProvider {
async fn chat(&self, _request: MessageRequest) -> Result<MessageResponse, LlmError> {
unimplemented!()
}
async fn chat_stream(
&self, _request: MessageRequest,
) -> Result<Pin<Box<dyn Stream<Item = Result<StreamEvent, LlmError>> + Send>>, LlmError> {
unimplemented!()
}
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
provider_name: "stub",
supported_models: None,
features: ProviderFeatures::default(),
}
}
}
```
---
## 任务 8:修改 `src/llm/cycle.rs` —— LlmCycle 适配
### 8a:修改 import
```rust
// 新类型导入
use crate::llm::types::message::Message;
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response_v2::{MessageResponse, StreamEvent as NewStreamEvent};
use crate::llm::provider::ProviderCapabilities;
// 旧类型导入(仍用于内部消息存储和 submit_stream 处理)
use crate::llm::types::{OpenaiChatMessage, OpenaiChatChunk};
use crate::llm::stream::StreamEvent as OldStreamEvent;
// ⚠️ StreamEvent 命名消歧:
// - 旧 StreamEventsrc/llm/stream.rs)对应 OpenaiChatChunk 解析事件
// - 新 StreamEventsrc/llm/types/response_v2.rs)对应高精度 IR 流式事件
// - 本文件中,通过 as 别名区分:NewStreamEvent / OldStreamEvent
// - 实际使用 `submit_stream` 调用 provider.chat_stream 返回的是 NewStreamEvent
// - Phase 2 移除旧 StreamEvent 后,可去掉别名直接使用 StreamEvent
```
### 8b:修改 `build_request(&self, tools) → MessageRequest`
将原有的 `Vec<OpenaiChatMessage>` 通过转换函数转为 `Vec<Message>`
```rust
fn build_request(&self, tools: &[ToolDefinition]) -> MessageRequest {
let mut ir_messages: Vec<Message> = self.messages.iter()
.map(|m| chat_message_to_message(m))
.collect();
if let Some(sys_prompt) = &self.system_prompt
&& !ir_messages.iter().any(|m| matches!(m, Message::System { .. }))
{
ir_messages.insert(0, Message::system_text(sys_prompt));
}
MessageRequest {
model: self.config.model.clone(),
messages: ir_messages,
tools: tools.to_vec(),
tool_choice: ToolChoice::Auto,
max_tokens: self.config.max_tokens,
temperature: self.config.temperature,
..Default::default()
}
}
```
### 8c:转换函数 `chat_message_to_message`
```rust
fn chat_message_to_message(msg: &OpenaiChatMessage) -> Message {
match msg {
OpenaiChatMessage::Developer { content, .. }
| OpenaiChatMessage::System { content, .. } => {
Message::System { content: content_to_blocks(content) }
}
OpenaiChatMessage::User { content, .. } => {
Message::User { content: content_to_blocks(content) }
}
OpenaiChatMessage::Assistant { content, tool_calls, .. } => {
let mut blocks = content_to_blocks(content);
// OpenAI 的 tool_calls 转为 ContentBlock::ToolUse
if let Some(calls) = tool_calls {
for call in calls {
match call {
OpenaiToolCall::Function { id, function } => {
let input: serde_json::Value =
serde_json::from_str(&function.arguments).unwrap_or_default();
blocks.push(ContentBlock::ToolUse {
id: id.clone(),
name: function.name.clone(),
input,
});
}
}
}
}
Message::Assistant { content: blocks }
}
OpenaiChatMessage::Tool { content, tool_call_id } => {
Message::ToolResult {
tool_call_id: tool_call_id.clone(),
content: content_to_blocks(content),
is_error: false,
}
}
// ponytail: OpenAI function_call 的 name 是函数名而非调用 ID。
// 在 OpenAI 的实现中,旧的 function_call API 始终单次调用,name 可作为唯一标识。
// 如需支持并行 function_callname 重复),需在 Phase 1 Provider 实现中
// 使用 tool_call_id 替代 name。
OpenaiChatMessage::Function { content, name } => {
Message::ToolResult {
tool_call_id: name.clone(),
content: content_to_blocks(content),
is_error: false,
}
}
}
}
```
### 8d:修改 `submit()` / `submit_messages()` 返回类型
```rust
// 当前签名
pub async fn submit(&mut self, prompt: String, tools: Vec<ToolDefinition>) -> Result<ChatResponse, LlmError>;
// 目标签名
pub async fn submit(&mut self, prompt: String, tools: Vec<ToolDefinition>) -> Result<MessageResponse, LlmError>;
```
**`submit_messages` 单独说明**:该方法接收 `&[OpenaiChatMessage]`(不经过 `build_request`),Phase 0 中保持输入参数类型不变。内部构造 `MessageRequest` 时,通过 `chat_message_to_message` 将传入的 `OpenaiChatMessage` 切片逐条转换为 `Vec<Message>`,然后构造 `MessageRequest { messages, ... }`。返回类型同样改为 `MessageResponse`
### 8e:修改 `submit_stream()` 返回类型和逻辑
```rust
// 当前签名(返回 OpenaiChatChunk 流 + 旧 StreamEvent
pub async fn submit_stream(&mut self, prompt: String, tools: Vec<ToolDefinition>)
-> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError>;
// 目标签名(返回新 StreamEvent 流)
// 注意:此处使用 NewStreamEvent 与 8a 中 import 别名保持一致
pub async fn submit_stream(&mut self, prompt: String, tools: Vec<ToolDefinition>)
-> Result<Pin<Box<dyn Stream<Item = NewStreamEvent> + Send>>, LlmError>;
```
**内部逻辑变更**
- 调用 `self.provider.chat_stream(request)` 直接获得 `Result<NewStreamEvent>`
- 流处理:从消费 `OpenaiChatChunk` 改为消费 `NewStreamEvent`
- 流结束处提取 `full_response`(Phase 2 才真正使用,当前先解构 MessageComplete 拿到 MessageResponse 用于消息历史追加和 usage 统计)
### 8f:修改 `submit_request()` 返回类型
```rust
// 当前
async fn submit_request(&mut self, tools: &[ToolDefinition]) -> Result<ChatResponse, LlmError>;
// 目标
async fn submit_request(&mut self, tools: &[ToolDefinition]) -> Result<MessageResponse, LlmError>;
```
### 8g:修改 `submit_with_tools()` 返回类型和内部逻辑
```rust
// 当前
pub async fn submit_with_tools(&mut self, prompt: String, registry: &ToolRegistry)
-> Result<ChatResponse, LlmError>;
// 目标
pub async fn submit_with_tools(&mut self, prompt: String, registry: &ToolRegistry)
-> Result<MessageResponse, LlmError>;
```
**tool 循环内部变更**
- `has_tool_calls_in_message``extract_tool_calls_from_message` 需要适配 `MessageResponse` 类型
- tool 结果回传时 `OpenaiChatMessage::tool_result(...)` → 但内部消息存储仍是 `Vec<OpenaiChatMessage>`Phase 2 才切换),所以 tool 循环仍使用旧消息类型
- MessageResponse 中的 `message` 字段是 `Message` 类型(新枚举),需要从中提取 tool_calls
**对应调整**:当前 `submit_with_tools` 中处理的是 `response.message`(原为 `OpenaiChatMessage`),现在改为 `Message`。需要从 `Message::Assistant { content }` 中提取 `ContentBlock::ToolUse`
```rust
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 {
Some((id.clone(), name.clone(), serde_json::to_string(input).unwrap_or_default()))
} else {
None
}
}).collect()
}
_ => vec![],
}
}
```
### 8h:修改 `submit_with_tools()` 中 tool 结果回传
当前使用 `OpenaiChatMessage::tool_result(...)`——因为内部 `self.messages` 仍是 `Vec<OpenaiChatMessage>`,所以保持使用旧类型。MessageResponse 的 tool_calls 提取改用 8g 中的新函数。
### 8i:修改 `build_request` 中 hooks context 的类型
`HookContext::with_request` 目前接受 `&ChatRequest`。改为接受 `&MessageRequest`。相应修改 `src/llm/hooks.rs` 中的 `HookContext` 类型定义。
### 8j:修改 `submit_stream()` 后处理
流结束后:
- 监听 `MessageComplete` 事件提取 `full_response: MessageResponse`
-`full_response.message` 转为 `OpenaiChatMessage`(新→旧)推入 `self.messages`
- 累加 usage
这是 Phase 0 的临时逻辑——Phase 2 会移除这个转换。
### 8k:转换函数 `message_to_chat_message`
在 Phase 0 中需要将新 `Message` 转回 `OpenaiChatMessage`(用于内部消息存储):
```rust
fn message_to_chat_message(msg: &Message) -> OpenaiChatMessage {
match msg {
Message::System { content } => OpenaiChatMessage::System {
content: blocks_to_content_field(content),
name: None,
},
Message::User { content } => OpenaiChatMessage::User {
content: blocks_to_content_field(content),
name: None,
},
// TODO(Phase2): UserImage → User 消息 + Image content block 的真实映射。
// Phase 0 中内部消息存储仍为 Vec<OpenaiChatMessage>,不支持图片 content block
// 因此 UserImage 转换为空消息。此阶段任何涉及 UserImage 回流的测试/功能
// 将丢失图片数据。Phase 2 切换为 Vec<Message> 后此函数整体移除。
Message::UserImage { data: _, mime_type: _, detail: _ } => {
OpenaiChatMessage::User {
content: ContentField::Array(vec![]),
name: None,
}
}
Message::Assistant { content } => {
// 从 content 中提取 tool_use 块
let mut text_blocks = vec![];
let mut tool_call_blocks = vec![];
for block in content {
match block {
ContentBlock::Text { text } => text_blocks.push(text.clone()),
ContentBlock::ToolUse { id, name, input } => {
tool_call_blocks.push(OpenaiToolCall::Function {
id: id.clone(),
function: FunctionCall {
name: name.clone(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
});
}
// ponytail: 静默跳过 thinking/refusal/image/audio/file 等非 OpenAI 原生 block。
// Phase 0 内部消息存储仍使用 Vec<OpenaiChatMessage>,这些 block 在回传时被截断。
// 影响范围:多轮对话中,thinking block 不会出现在后续请求的上下文中。
// Phase 2 切换为 Vec<Message> 后此函数整体移除,自然解决。
_ => {}
}
}
let text = text_blocks.join("");
OpenaiChatMessage::Assistant {
content: if text.is_empty() {
ContentField::Array(vec![])
} else {
ContentField::String(text)
},
refusal: None,
name: None,
tool_calls: if tool_call_blocks.is_empty() { None } else { Some(tool_call_blocks) },
}
}
Message::ToolResult { tool_call_id, content, is_error: _ } => {
OpenaiChatMessage::Tool {
content: blocks_to_content_field(content),
tool_call_id: tool_call_id.clone(),
}
}
}
}
```
---
## 任务 9:编译驱动适配上游
### 9a`src/agent/session.rs`
- `submit_turn()` 返回类型从 `Result<ChatResponse, AgentError>` 改为 `Result<MessageResponse, AgentError>`
- `response.usage` 引用适配(`MessageResponse.usage` 字段与 `ChatResponse.usage` 类型同为 `Usage`,无需修改)
- `response.message``OpenaiChatMessage` 变为 `Message`——测试代码中 `match response.message` 的分支需要适配
- 测试模块(`src/agent/session.rs` 中的 `#[cfg(test)]`)中的 `MockProvider` 需要适配新 trait 签名(返回 `MessageResponse` 而非 `ChatResponse`),并新增 `chat_stream``capabilities` 方法实现
- `assistant_text()` 辅助函数改为构造 `MessageResponse` 而非 `ChatResponse`
### 9b`src/agent/runtime.rs`
- 无直接修改(只引用 `Arc<dyn LlmProvider>`trait 定义变更不影响持有者)
- 但需验证 `RuntimeBundle` 中的 `provider: Arc<dyn LlmProvider>` 在 trait 签名变更后编译通过
### 9c`src/agent/error.rs`
- 无预期变更(`AgentError` 不直接引用 `ChatResponse` 或相关类型)
### 9d`src/memory/conversation.rs`
- `messages: Vec<OpenaiChatMessage>` 保持不动(Phase 2 才切换为 `Vec<Message>`
-`ConversationMemory``add_message()``get_history()` 方法签名需要确认是否需要适配
- `serde_json::from_str::<OpenaiChatMessage>` 反序列化保持使用旧类型
### 9e`src/prompt/composer.rs`
- `build_request()` 返回 `OpenaiChatRequest` 保持不变(旧类型仍存在)
- `validate_messages()` 继续使用 `&[OpenaiChatMessage]` 保持不变
### 9f`src/llm/cycle/retry.rs`
- 检查是否有对 `ChatRequest` / `ChatResponse` 的引用,如有则更新为 `MessageRequest` / `MessageResponse`
- 具体检查点:`retry_with_backoff` 函数的请求参数类型、`should_retry` 函数的响应/错误类型、以及 `retry_policy` 配置中涉及的类型引用
### 9g`src/llm/hooks.rs`
- `HookContext::with_request` 参数类型从 `&ChatRequest` 改为 `&MessageRequest`
- **决策锁定**:直接修改 `with_request` 签名,不做双方法兼容。理由:Phase 0 完成后旧 `ChatRequest` 只在旧 Provider 内部使用(`src/llm/provider/openai.rs`),hook 层不应引用旧 request 类型。
- 同步更新 `HookContext` 中所有引用 `ChatRequest` 的字段和方法
### 9h`src/llm/compact.rs`
- 暂时不修改(`estimate_message_tokens` / `microcompact` 继续使用 `OpenaiChatMessage`
- Phase 2 才做切换
### 9i`examples/` 目录
- 运行 `grep -r "ChatResponse\|ChatRequest" examples/` 预检
- 如现有 example 直接引用旧类型,同步适配为 `MessageResponse` / `MessageRequest`
- 如果 example 中构造了旧类型实例(`OpenaiChatMessage` 等),由于旧类型本身仍存在,只需更新引用新类型的部分
---
## 验证方式
1. **编译检查**`cargo build` 通过,无类型错误
2. **单元测试**`cargo test` 全部通过
3. **clippy 检查**`cargo clippy` 无新增警告
4. **diff 确认**`git diff --stat` 确认修改范围符合预期
## 回滚方案
| 触发条件 | 操作 |
|---------|------|
| 新类型设计发现重大缺陷(如 Message 枚举扁平度不足) | 回退 git 到 Phase 0 开始前的 commit,保留 9 系文档作为参照,重启设计评审 |
| 临时转换层(`chat_message_to_message` / `message_to_chat_message`)逻辑错误 | 修复转换函数逻辑,不修改类型定义 |
| 单个 Provider 的适配不合理 | 将该 Provider 回退为 `unimplemented!()`(当前状态),不影响其他组件 |
| Phase 0 完成时发现未预见的设计问题 | **不打乱整体进度**:在不动已有文件的前提下,直接原地修改新类型文件重新迭代,不需要整个回退到 Phase 0 之前。Phase 0 结束时打 tag `types-v2-prototype` 作为 checkpoint |
**风险储备**
- `ChatRequest` / `ChatResponse` 等旧类型保留不动,不主动删除。Phase 2 切换后通过 `#[deprecated]` 标记引导迁移
- 如果 `OpenaiProvider` 保留旧实现,旧 trait 方法名不变、只是签名变,不影响共存
## 开放事项
以下事项已在 10 号文档中讨论,Phase 0 实施前需确认:
- [x] `CompletionTokensDetails``PromptTokensDetails` — 已确认存在于 `src/llm/types/usage.rs:14-32`,通过 `types/mod.rs:21` 重导出,无需新增
- [ ] Phase 0 完成后确认 `git diff --stat` 只涉及预期文件,无意外修改
- [ ] `types/mod.rs` 中旧 `pub type Message = OpenaiChatMessage` 的引用面——执行 `grep "types::Message" src/ -r` 确认是否无处引用(实施前基线确认中已包含此步骤)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff