refactor(types): request.rs 类型移入 provider/openai.rs

- 删除 types/request.rs(187 行)
- 所有 OpenAI wire-format 类型迁入 provider/openai.rs,可见性 pub(crate):
  StreamOptions / OpenaiTool / AudioParam / PredictionContent / UserLocation /
  Approximate / WebSearchOptions / OpenaiChatRequest
- types/mod.rs 删除 pub mod request; 与对应 re-export
- convert_request 同步降级为 pub(crate) 以匹配 OpenaiChatRequest 可见性
- 公共 re-export 路径 agcore::llm::types::OpenaiChatRequest 等已删除(Breaking Change,见 CHANGELOG)
This commit is contained in:
徐涛
2026-07-08 22:55:17 +08:00
parent f8df6a9421
commit 760de46623
4 changed files with 760 additions and 106 deletions
+640
View File
@@ -0,0 +1,640 @@
# Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **文档编号**19
- **标题**Phase 13 — 热身清理 + ContextSlot fork/merge 实施方案
- **日期**2026-07-08
- **状态**:待实施
- **涉及模块**agent/context、agent/session、llm/types、llm/provider/openai、llm/stream
- **关联文档**roadmap.md(§Phase 13)、17-phase10-contextslot.md
- **对应**Roadmap §Phase 13v0.3.0 第一阶段)
---
## 1. 背景与目标
v0.3.0 是 agcore 从"LLM 调用工具箱"升级为"多 Agent 基础系统"的关键版本。Phase 13 是 v0.3.0 的第一阶段,定位为"热身",包含两大部分:
- **技术债清理**:删除 Phase 0 遗留的旧 types 文件(`request.rs``response.rs``old_stream.rs`),以及已标记 `#[deprecated]``ChatResponse` 结构体
- **ContextSlot fork/merge**:为 ContextSlot 增加分叉和合并能力,为后续 Phase 17 Checkpointer 和 Phase 18 SubAgent Dispatch 打基础
**依赖关系**:无(独立交付)
**优先级**P0
**预估规模**:净减 ~200 行代码(新增 ~505 行,删除 ~704 行)
---
## 2. 需求分析
### 2.1 功能需求
1. **技术债清理**:删除 `src/llm/types/request.rs`187 行)、`response.rs`177 行)、`old_stream.rs`45 行),将其中的 OpenAI wire-format 类型移入 `src/llm/provider/openai.rs`;删除 `types/mod.rs` 中的 `ChatResponse` 废弃结构体
2. **`ContextSlot::fork`**:从现有 context slot 分支出独立的子 slot
3. **`ContextSlot::merge`**:将子 slot 的消息合并回父 slot
4. **`MergeStrategy`** 枚举:Append(追加)/ Replace(替换),`#[non_exhaustive]` 预留 Phase 16 Summarize 扩展
### 2.2 非功能需求
- **每步可编译**:5 个 Step 按物理文件切割,每步 `cargo build --all-targets + cargo test` 验证
- **指定公共 API 路径保持向后兼容**:`agcore::llm::types::ToolChoice`re-export 不变)、`crate::llm::stream::StreamEvent`(重导出保留);其余 wire-format 类型(`OpenaiChatRequest``OpenaiChatResponse/Chunk``StreamOptions` 等)移入 `provider/openai.rs` 后属 Breaking Change,详见 §4.3 CHANGELOG
- **向后兼容的 StreamEvent 路径**`crate::llm::stream::StreamEvent` 重导出保留,不修改 `cycle.rs``session.rs` 的 import
---
## 3. 方案设计
### 3.1 整体架构
Phase 13 分为 5 个 Step,按执行顺序排列:
```
Step 13.5 (fork/merge) → Step 13.4 (ToolChoice) → Step 13.1 (request types) → Step 13.2 (response types) → Step 13.3 (cleanup)
```
这种顺序的好处:
- **先交付价值**:13.5 是唯一有用户功能交付的 Step,先做建立节奏
- **排序约束**13.4 必须先于 13.1ToolChoice 不搬走,request.rs 不能删)
- **13.3 收尾**:删除旧文件和 `ChatResponse` 是 breaking change,放在最后
### 3.2 Step 13.5 — ContextSlot fork/merge
#### MergeStrategy 枚举
定义在 `src/agent/context.rs`
```rust
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MergeStrategy {
/// 子 slot 消息追加到父 slot 末尾。
Append,
/// 用子 slot 消息替换父 slot 内容。
Replace,
}
```
- `#[non_exhaustive]` 保证 Phase 16 加入 `Summarize` 变体时不破坏现有代码
- 不预埋 `Summarize` 占位变体(YAGNI 原则)
#### ContextSlot::fork
```rust
impl ContextSlot {
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
let messages = match &strategy {
DeriveStrategy::Full => self.messages.clone(),
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
};
tracing::debug!(
parent_id = %self.id,
child_id = %child_id,
?strategy,
"ContextSlot::fork"
);
ContextSlot {
id: child_id,
session_id: self.session_id.clone(),
config: SlotConfig {
mode: match &strategy {
DeriveStrategy::Full => SlotMode::Full,
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
},
source: SlotSource::Derived {
parent_id: self.id.clone(),
strategy,
},
budget: self.config.budget.clone(),
compact: self.config.compact,
},
messages,
meta: SlotMeta::new(),
}
}
}
```
设计要点:
- 纯数据层操作,不持久化
- 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 message_count
- 子 slot 的 source 记录 `parent_id`,血缘可追溯
- 添加 `tracing::debug!` 日志,支持多 slot 交互场景的审计追踪
#### ContextSlot::merge
```rust
impl ContextSlot {
/// 将子 slot 的消息合并到当前 slot。
///
/// **注意**:本方法仅操作内存数据,不自动持久化。
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
// 防御性检查
if self.id == child.id {
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
}
if self.session_id != child.session_id {
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
}
if matches!(self.config.mode, SlotMode::Readonly) {
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
}
tracing::debug!(
self_id = %self.id,
child_id = %child.id,
?strategy,
"ContextSlot::merge"
);
match strategy {
MergeStrategy::Append => {
let count = child.messages.len();
self.messages.extend(child.messages);
self.meta.message_count += count;
}
MergeStrategy::Replace => {
self.messages = child.messages;
self.meta.message_count = self.messages.len();
}
}
Ok(())
}
}
```
#### AgentSession::derive_slot 重构
现有 `derive_slot`session.rs:213-260)的手工复制代码改为调用 `parent.fork()`
```rust
pub async fn derive_slot(
&mut self,
id: impl Into<String>,
parent_id: &str,
strategy: DeriveStrategy,
) -> Result<(), AgentError> {
let slot_id = id.into();
if self.slots.contains_key(&slot_id) {
return Err(AgentError::SlotAlreadyExists(slot_id));
}
let parent = self
.slots
.get(parent_id)
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
let child = parent.fork(slot_id.clone(), strategy); // ← 用 fork
child.save(&*self.resolve_store()).await?;
self.slots.insert(slot_id, child);
Ok(())
}
```
重复检查、查找父 slot 的代码不变;消息复制逻辑委托给 `fork()`
#### 测试计划(新增 9 个)
| 测试名 | 验证点 |
|--------|--------|
| `fork_full_copies_messages` | fork Full 策略复制父 slot 全部消息 |
| `fork_focused_filters_messages` | fork Focused 策略按 config 过滤 |
| `fork_preserves_independence` | 父 slot 追加消息不影响子 slot |
| `fork_sets_derived_source` | 子 slot source 正确记录 parent_id |
| `merge_append_appends_messages` | Append 追加到父 slot 末尾,message_count 正确 |
| `merge_replace_replaces_messages` | Replace 替换父 slot 消息,message_count 正确 |
| `merge_self_rejected` | self-merge 返回 `Err` |
| `merge_readonly_rejected` | 合并到 Readonly slot 返回 `Err` |
| `merge_cross_session_rejected` | 跨 session 合并返回 `Err` |
### 3.3 Step 13.4 — ToolChoice 移入 tool.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | 删除 `ToolChoice` 枚举 + serde impl~28-99 行) |
| `src/llm/types/tool.rs` | 新增 `ToolChoice` 枚举 + serde impl(原样搬入) |
| `src/llm/types/mod.rs` | `pub use request::{..., ToolChoice}``pub use tool::ToolChoice` |
| `src/llm/types/request_v2.rs` | import 路径 `request::ToolChoice``tool::ToolChoice` |
**import 路径变化**
| 当前 | 移动后 |
|------|--------|
| `crate::llm::types::request::ToolChoice` | `crate::llm::types::tool::ToolChoice` |
| `crate::llm::types::ToolChoice`(通过 re-export | `crate::llm::types::ToolChoice`(通过 tool.rs re-export,保持不变) |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.4 Step 13.1 — request.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/request.rs` | **整文件删除**187 行) |
| `src/llm/provider/openai.rs` | 新增 `StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions``OpenaiChatRequest` 等类型定义 |
| `src/llm/types/mod.rs` | 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}`;删除 `pub mod request;` |
| `src/llm/provider/openai.rs` import 调整 | 原 `use crate::llm::types::request::{...}` 改为从同级 `use super::super::types::...` 或直接使用本文件内类型 |
**注意**`OpenaiTool` 引用 `OpenaiToolDefinition`(定义在 `tool.rs`),移入 `openai.rs` 后需通过 `crate::llm::types::tool::OpenaiToolDefinition` 引用。`OpenaiChatRequest.messages` 字段引用 `OpenaiChatMessage`(定义在 `openai_message.rs`),路径不变。
**设计决策**:搬入 `openai.rs` 后的类型可见性可降级为 `pub(crate)`。它们是与 OpenAI wire-format 绑定的内部序列化类型,公共 API 消费者不应直接接触。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.5 Step 13.2 — response.rs 类型移入 openai.rs
#### 变更文件
| 文件 | 变更 |
|------|------|
| `src/llm/types/response.rs` | **整文件删除**177 行) |
| `src/llm/provider/openai.rs` | 新增 `TokenLogprob``TopLogprob``Logprobs``URLCitation``Annotation``OpenaiAudio``Choice``OpenaiChatResponse``Delta``ChunkChoice``OpenaiChatChunk` + `From<OpenaiChatMessage> for Delta` + `From<OpenaiChatResponse> for OpenaiChatChunk` |
| `src/llm/types/mod.rs` | 删除 `pub use response::{...}`;删除 `pub mod response;` |
| `src/llm/stream.rs:26` | 将 `use crate::llm::types::{OpenaiChatChunk, OpenaiToolCall}` 中的 `OpenaiChatChunk` 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 保持从 `tool.rs` |
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy`
### 3.6 Step 13.3 — 旧文件清理 + ChatResponse 删除
#### 13.3a — 删除 `old_stream.rs`
> **前置验证**:实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到实施 commit。
| 文件 | 变更 |
|------|------|
| `src/llm/types/old_stream.rs` | **整文件删除**45 行,`LegacyStreamEvent` |
| `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` |
| `src/llm/stream.rs` | 删除 `use crate::llm::types::old_stream::LegacyStreamEvent`;删除 `parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`~160 行死代码) |
**stream.rs 最终形态**
```rust
//! 流式事件系统 —— 重导出 StreamEvent 供向后兼容。
pub use crate::llm::types::response_v2::StreamEvent;
```
**为什么不全删 stream.rs**`cycle.rs``session.rs``use crate::llm::stream::StreamEvent` 路径保持不变。全删 + 改所有 import 路径的改动量 > 收益。保留 1 行重导出就够。
#### 13.3b — 删除 `ChatResponse`
| 文件 | 变更 |
|------|------|
| `src/llm/types/mod.rs` | 删除 `ChatResponse` 结构体定义 + 两个 `#[allow(deprecated)]` `From` impl`From<OpenaiChatResponse> for ChatResponse``From<ChatResponse> for OpenaiChatChunk` |
`ChatResponse` 自 v0.1.0 起标记 `#[deprecated]`v0.2.0-rc.1 阶段直接删除即可。删除前运行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用。
**验证**`cargo build --all-targets` + `cargo test` + `cargo clippy` + `cargo doc --no-deps`
---
## 4. 实现计划
### 4.1 实施顺序总览
```
Step 13.5 ──→ Step 13.4 ──→ Step 13.1 ──→ Step 13.2 ──→ Step 13.3
(fork/merge) (ToolChoice) (request) (response) (cleanup)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
+60 行净增 -0 净增 -0 净增 -0 净增 -260 删除
+9 个测试 import 路径 纯类型搬移 纯类型搬移 +1 行重导出
变更
```
### 4.2 各 Step 文件变更清单
#### Step 13.5 — ContextSlot fork/merge
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 新增 | `src/agent/context.rs` | `MergeStrategy` 枚举 + `ContextSlot::fork()` + `ContextSlot::merge()` |
| 重构 | `src/agent/session.rs` | `derive_slot` 改为调用 `parent.fork()` |
| 新增 | 内联测试 | 9 个新测试(fork, merge, 边界) |
#### Step 13.4 — ToolChoice 移动
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 移除 `ToolChoice` 枚举 + serde impl |
| 新增 | `src/llm/types/tool.rs` | 增加 `ToolChoice` 枚举 + serde impl |
| 修改 | `src/llm/types/mod.rs` | 更新 re-export 路径 |
| 修改 | `src/llm/types/request_v2.rs` | 更新 import 路径 |
#### Step 13.1 — request 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/request.rs` | 整文件删除(187 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
#### Step 13.2 — response 类型搬移
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/response.rs` | 整文件删除(177 行) |
| 新增 | `src/llm/provider/openai.rs` | 增加所有 OpenAI wire-format 类型 + From impl |
| 修改 | `src/llm/types/mod.rs` | 删除 re-export + mod 声明 |
| 修改 | `src/llm/stream.rs` | 更新 `OpenaiChatChunk` import 路径 |
#### Step 13.3 — 旧文件清理
| 操作 | 文件 | 变更说明 |
|------|------|---------|
| 删除 | `src/llm/types/old_stream.rs` | 整文件删除(45 行) |
| 修改 | `src/llm/types/mod.rs` | 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体 + `From` impl |
| 修改 | `src/llm/stream.rs` | 删除所有死代码,仅保留 `pub use` 重导出 |
### 4.3 回滚策略
所有 Step 通过 git commit 管理,回退时 `git revert <commit>` 即可。每个 Step 独立编译,回滚不会级联依赖。若 Step 13.3(`ChatResponse` 删除)导致外部编译失败,单独 revert 该 commit 即可恢复 `ChatResponse` + `old_stream.rs`
### 4.4 CHANGELOG 条目
```markdown
## [0.3.0] - 未发布
### Breaking Changes
**类型路径变更(0.3.0):**
- `agcore::llm::types::request::ToolChoice``agcore::llm::types::tool::ToolChoice`(公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变)
- `agcore::llm::types::request::StreamOptions``agcore::llm::provider::openai::StreamOptions`
- `agcore::llm::types::request::OpenaiChatRequest``agcore::llm::provider::openai::OpenaiChatRequest`
- `agcore::llm::types::response::OpenaiChatResponse``agcore::llm::provider::openai::OpenaiChatResponse`
- `agcore::llm::types::response::OpenaiChatChunk``agcore::llm::provider::openai::OpenaiChatChunk`
- 其余 `request.rs`/`response.rs` 中的 wire-format 类型(`OpenaiTool``AudioParam``Choice``Delta` 等)同步移入 `agcore::llm::provider::openai` 模块
**类型删除:**
- `agcore::llm::types::ChatResponse` 已删除(自 v0.1.0 标记 `#[deprecated]`,请改用 `MessageResponse`
- `agcore::llm::types::old_stream::LegacyStreamEvent` 已删除(内部死代码)
### Features
- `ContextSlot::fork(child_id, strategy)` — 从父槽派生独立的子槽(数据层操作)
- `ContextSlot::merge(child, strategy)` — 将子槽消息合并回父槽(支持 Append/Replace
- `MergeStrategy` 枚举(`#[non_exhaustive]`Phase 16 可扩展 Summarize
```
---
## 5. 风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| `ChatResponse` 被外部 crate 引用 | 编译 break | 中 — `#[deprecated]` 仅产生编译警告,外部 crate 可能通过 `#[allow(deprecated)]` 静默依赖 | CHANGELOG 明确标注语义版本(0.3.0)和迁移指引;Step 13.3 验收加入 `cargo doc --no-deps \| grep ChatResponse` 确认零引用 |
| `StreamOptions` 等 wire-format 类型路径变更影响直接引用消费者 | 编译 break | 低(v0.2.0-rc.1,极少外部消费者使用内部类型) | CHANGELOG 完整列出所有路径变更;编译错误立即可发现 |
| `parse_chunk_stream` 有隐藏调用方 | 编译 break | 极低(实施前执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream' src/` 前置验证) | Step 13.3 前运行 grep 验证并记录结果;`cargo build --all-targets` 可 100% 捕获 |
| `#[allow(deprecated)]` 遗漏 | clippy 警告 | 低 | `cargo clippy --all-targets -- -D warnings` 验证 |
| Step 顺序错误导致编译中间态 | 开发者体验差 | 中 | 严格按 13.5→13.4→13.1→13.2→13.3 执行;每步 `cargo build` 验证 |
| `stream.rs` 简化后 import 断链 | 编译 break | 极低 | 保留 `pub use` 重导出路径,`cycle.rs`/`session.rs` import 不变 |
---
## 6. 验收标准
### M9 里程碑(Phase 13 完成条件)
| # | 条件 | 验证方法 |
|---|------|---------|
| 1 | `request.rs``response.rs``old_stream.rs` 三个旧文件不存在 | `ls src/llm/types/` 确认 |
| 2 | `ChatResponse` 结构体不存在 | 全局搜索 `ChatResponse` 仅保留 `openai.rs``OpenaiChatResponse` 引用 |
| 3 | `ToolChoice``tool.rs` 中定义,公共路径 `agcore::llm::types::ToolChoice` 保持不变 | `cargo doc --no-deps` 确认类型文档 |
| 4 | `OpenaiChatRequest`/`Response`/`Chunk``provider/openai.rs` 中定义 | 编译通过 |
| 5 | `ContextSlot::fork()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 6 | `ContextSlot::merge()` 单元测试通过(P0 条件全部满足) | `cargo test` |
| 7 | `stream.rs` 只保留 `pub use` 重导出 | 文件内容确认 |
| 8 | `cargo build --all-targets` 编译通过 | 编译验证 |
| 9 | `cargo test --all-targets` 全绿(预期 283~285 测试) | 测试验证 |
| 10 | `cargo clippy --all-targets -- -D warnings` 0 警告 | clippy 验证 |
| 11 | CHANGELOG 包含 Phase 13 的 Breaking Changes 和 Features 条目 | 文件确认 |
### fork/merge 详细验收 P0 项
**fork 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `fork("child", Full)` 创建新 slot,消息在 fork 时刻 == 父 slot | P0 |
| 2 | 子 slot 获得独立消息列表——父 slot 后续追加不影响子 slot | P0 |
| 3 | 子 slot 的 source 标记为 `Derived { parent_id, strategy }` | P0 |
| 4 | 子 slot 可独立持久化(fork + save + load roundtrip | P0 |
| 5 | fork 不允许重复 id(返回 `SlotAlreadyExists`)(由 `derive_slot` 编排层保证) | P0 |
**merge 的 5 项 P0 条件:**
| # | 条件 | 优先级 |
|---|------|--------|
| 1 | `parent.merge(child, Append)` 子消息追加到父末尾 | P0 |
| 2 | `parent.merge(child, Replace)` 子消息替换父全量消息 | P0 |
| 3 | merge 后父 slot 的 `meta.message_count` 正确更新 | P0 |
| 4 | merge 不允许合并到 Readonly 目标 slot | P0 |
| 5 | merge 不允许 self-mergechild.id == parent.id | P0 |
---
## 参考来源
- Roadmap`docs/roadmap.md` §Phase 13
- ContextSlot 设计:`docs/17-phase10-contextslot.md`
- 旧 StreamEvent 设计:`src/llm/stream.rs` 文件注释
- 当前代码库:`src/llm/types/request.rs``src/llm/types/response.rs``src/llm/types/old_stream.rs``src/llm/types/mod.rs``src/llm/provider/openai.rs``src/agent/context.rs``src/agent/session.rs`
---
## 7. 实施计划
### 全局说明
**commit 策略**:每个 Step 一个独立 commit。commit message 格式:
```
<type>(<scope>): <中文描述>
```
- Step 13.5 → `feat(agent): 实现 ContextSlot fork/merge`
- Step 13.4 → `refactor(types): ToolChoice 移入 tool.rs`
- Step 13.1 → `refactor(types): request.rs 类型移入 provider/openai.rs`
- Step 13.2 → `refactor(types): response.rs 类型移入 provider/openai.rs`
- Step 13.3 → `refactor(types): 删除旧类型文件和 ChatResponse`
**验证命令(每步通用)**
```bash
cargo build --all-targets && cargo test && cargo clippy --all-targets -- -D warnings
```
**预计测试数量变化**
- 当前基线:277 测试(每个 Step 开始时 `cargo test` 确认)
- Step 13.5 后:286+9
- Step 13.4-13.2 后:286(无变化)
- Step 13.3 后:285-1`ChatResponse``From` impl 无测试直接引用,删除后仅 `types/mod.rs` 中的 `deprecated` 注释行减少,不影响测试计数。实施前执行 `grep -rn 'ChatResponse' src/ --include='*test*' --include='*tests*'` 确认零测试引用)
- 最终范围:285 测试
### Step 13.5 — ContextSlot fork/merge
**前置依赖**:无(纯新增,不依赖前序 Step
**任务描述**:在 `agent/context.rs` 中新增 `MergeStrategy` 枚举、`ContextSlot::fork()` 方法和 `ContextSlot::merge()` 方法;重构 `agent/session.rs` 中的 `derive_slot` 改为调用 `parent.fork()`;新增 9 个内联测试覆盖 fork/merge 的 happy path 和 error path。
**涉及文件**
- `src/agent/context.rs` — 新增枚举和方法
- `src/agent/session.rs` — 重构 derive_slot
- `src/agent.rs` — 追加 `MergeStrategy` re-export
**具体操作**
1.`context.rs` 中新增 `MergeStrategy` 枚举(Append / Replace`#[non_exhaustive]`
2.`context.rs``impl ContextSlot` 块内新增 `fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot` 方法
3.`context.rs``impl ContextSlot` 块内新增 `merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError>` 方法(含 self-merge/cross-session/Readonly 三项防御检查 + `tracing::debug!` 日志)
4.`session.rs``derive_slot` 方法中将手工消息复制代码替换为 `parent.fork(slot_id, strategy)`
5.`agent.rs``pub use context::{...}` 列表中追加 `MergeStrategy`
6.`context.rs``#[cfg(test)] mod tests` 中新增 9 个测试用例
**注意**:重构后 `derive_slot` 的子 slot `budget``ContextBudget::default()` 变为继承父 slot`compact``true` 变为继承父 slot。由于 `ContextBudget` 在 v0.2 无消费逻辑且父 slot 的 `compact` 默认也为 `true`,此变化无实际影响。验收条件中"行为不变"指对外功能行为不变(slot 消息内容、血缘关系不变)。
**预估工作量**M1-4h
**风险等级**:低(纯新增,不修改已有逻辑路径)
**验收条件**
- `MergeStrategy` 枚举存在,`Append``Replace` 两个变体可用,且通过 `agcore::agent::MergeStrategy` 路径可访问
- `ContextSlot::fork` 返回的 child 在 fork 时刻消息等于父 slot
- fork Focused 策略按 `FocusedConfig` 过滤消息
- 父 slot 后续追加消息不影响子 slot
- 子 slot 的 source 正确记录 `Derived { parent_id, strategy }`
- `parent.merge(child, Append)` 追加到父末尾,message_count 正确
- `parent.merge(child, Replace)` 替换父全量消息,message_count 正确
- self-merge 返回 `Err(AgentError::Config)`
- merge 到 Readonly slot 返回 `Err(AgentError::SlotReadonly)`
- 跨 session merge 返回 `Err(AgentError::Config)`
- `derive_slot` 对外行为不变(slot 消息内容、血缘关系、持久化行为均不变;内部 budget/compact 继承差异无实际影响),测试全绿
- `cargo doc --no-deps` 无 warning(验证新增公开 API 的文档注释完整)
**回退方式**`git revert` 该 commit
### Step 13.4 — ToolChoice 移入 tool.rs
**前置依赖**:Step 13.5(顺序约束:必须早于 Step 13.1——若 Step 13.1 先执行会将 `ToolChoice``request.rs` 一同删除,导致本 Step 无可搬移的源)
**任务描述**:将 `ToolChoice` 枚举及其 serde 实现从 `types/request.rs` 搬移到 `types/tool.rs`,更新所有 import/path 引用。公共 re-export 路径 `agcore::llm::types::ToolChoice` 保持不变。
**涉及文件**
- `src/llm/types/request.rs` — 删除 ToolChoice~28-99 行)
- `src/llm/types/tool.rs` — 新增 ToolChoice 枚举 + serde impl
- `src/llm/types/mod.rs` — re-export 路径从 `request` 改为 `tool`
- `src/llm/types/request_v2.rs` — import 路径从 `request::` 改为 `tool::`
**具体操作**
1.`request.rs` 复制 `ToolChoice` 枚举 + `Serialize`/`Deserialize` impl 到 `tool.rs`
2.`request.rs` 中删除 `ToolChoice` 定义
3.`mod.rs` 中将 `pub use request::{..., ToolChoice}` 改为 `pub use tool::ToolChoice`
4.`request_v2.rs` 中将 `use crate::llm::types::request::ToolChoice` 改为 `use crate::llm::types::tool::ToolChoice`
5. 验证 `cycle.rs``use crate::llm::types::ToolChoice`(通过 re-export)路径不变
**预估工作量**S<1h
**风险等级**:低(有限的 import 路径变更,编译立即可发现)
**验收条件**
- `ToolChoice``tool.rs` 中定义
- `pub use tool::ToolChoice``mod.rs`
- `request_v2.rs` 编译通过
- `cycle.rs` 路径不变
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit
### Step 13.1 — request.rs 类型移入 openai.rs
**前置依赖**Step 13.4ToolChoice 已移走,request.rs 剩余内容全是 OpenAI wire-format 专有类型)
**任务描述**:删除 `types/request.rs` 整文件,将所有剩余类型(`OpenaiChatRequest``StreamOptions``OpenaiTool``AudioParam``PredictionContent``UserLocation``Approximate``WebSearchOptions`)搬入 `provider/openai.rs`,更新 `mod.rs` re-export。
**涉及文件**
- `src/llm/types/request.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
**具体操作**
1.`request.rs` 复制所有剩余类型定义到 `openai.rs`,可见性设为 `pub(crate)`
2. `OpenaiTool` 内引用 `OpenaiToolDefinition`(定义在 `tool.rs`),路径改为 `crate::llm::types::tool::OpenaiToolDefinition`
3. 删除 `openai.rs` 中原 `use crate::llm::types::request::{...}` import
4.`mod.rs` 删除 `pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions}``pub mod request;`
5. 删除 `types/request.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(纯搬移 + 删除,文件内无逻辑变更)
**验收条件**
- `request.rs` 文件不存在
- `OpenaiChatRequest` 等类型在 `openai.rs` 中定义,编译通过
- `OpenaiTool` 通过 `crate::llm::types::tool::OpenaiToolDefinition` 正确引用
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.2 也已提交,单独 revert 本 Step 可能因 `provider/openai.rs` 并发修改产生合并冲突。安全回退顺序为逆序:先 revert 13.2,再 revert 13.1。
### Step 13.2 — response.rs 类型移入 openai.rs
**前置依赖**:无(与 Step 13.1 共享 `provider/openai.rs``types/mod.rs`,但本 Step 仅追加类型定义,无覆盖操作;建议在 13.1 之后顺序执行以避免并行时的合并冲突)
**任务描述**:删除 `types/response.rs` 整文件,将所有类型(`OpenaiChatResponse``OpenaiChatChunk``Choice``Delta``ChunkChoice` 等 + 两个 `From` impl)搬入 `provider/openai.rs`,更新 `mod.rs``stream.rs` 的 import 路径。
**涉及文件**
- `src/llm/types/response.rs` — 整文件删除
- `src/llm/provider/openai.rs` — 新增所有类型定义 + From impl
- `src/llm/types/mod.rs` — 删除 re-export + mod 声明
- `src/llm/stream.rs``OpenaiChatChunk` import 路径改为 `provider::openai`
**具体操作**
1.`response.rs` 复制所有类型定义(含 `From` impl)到 `openai.rs`,可见性设为 `pub(crate)`
2. 删除 `openai.rs` 中原 `use crate::llm::types::response::{...}` import
3.`mod.rs` 删除 `pub use response::{...}``pub mod response;`
4.`stream.rs:26``OpenaiChatChunk` 的 import 路径改为 `crate::llm::provider::openai::OpenaiChatChunk``OpenaiToolCall` 路径不变)
5. 删除 `types/response.rs` 文件
**预估工作量**M1-4h
**风险等级**:低(与 Step 13.1 模式完全相同)
**验收条件**
- `response.rs` 文件不存在
- `OpenaiChatResponse`/`Chunk` 等类型在 `openai.rs` 中定义,编译通过
- `stream.rs` import 路径正确
- `cargo build --all-targets` + `cargo test` + `cargo clippy` 全绿
**回退方式**`git revert` 该 commit。若 Step 13.1 和本 Step 均已提交,安全回退顺序为逆序:先 revert 本 Step,再 revert 13.1。
### Step 13.3 — 旧文件清理 + ChatResponse 删除
**前置依赖**Step 13.1`request.rs` 已删)、Step 13.2`response.rs` 已删)
**任务描述**:删除 `old_stream.rs``ChatResponse`,简化 `stream.rs` 为仅保留 `pub use` 重导出。这是 Phase 13 技术风险最高的 Step。
**涉及文件**
- `src/llm/types/old_stream.rs` — 整文件删除
- `src/llm/types/mod.rs` — 删除 `pub mod old_stream;` + 删除 `ChatResponse` 结构体和两个 `From` impl
- `src/llm/stream.rs` — 删除死代码(约 160 行),仅保留 `pub use` 重导出
**具体操作**
1. **前置验证 A**:执行 `grep -rn 'parse_chunk_stream\|map_legacy_to_ir\|LegacyToIrEventStream\|ChunkToLegacyEventStream' src/` 确认零外部调用方,记录结果到 commit message
2. **前置验证 B**:执行 `cargo doc --no-deps 2>&1 | grep -i 'ChatResponse'` 确认零文档引用,记录结果
3.`mod.rs` 删除 `pub mod old_stream;`
4.`mod.rs` 删除 `ChatResponse` 结构体定义 + `#[allow(deprecated)]` `From<OpenaiChatResponse> for ChatResponse` + `From<ChatResponse> for OpenaiChatChunk`
5. 删除 `old_stream.rs` 文件
6.`stream.rs` 删除:`use crate::llm::types::old_stream::LegacyStreamEvent``parse_chunk_stream``parse_chunk_stream_legacy``ChunkToLegacyEventStream``LegacyToIrEventStream``map_legacy_to_ir``empty_message_response`
7. `stream.rs` 最终只保留 module doc comment + `pub use crate::llm::types::response_v2::StreamEvent;`
8. 检查 `cycle.rs:88``#[allow(deprecated)]` 属性是否仍与 `ChatResponse` 相关——若不相关则无需改动;若因 `ChatResponse` 删除而变脏,清理该属性
**预估工作量**S<1hcleanup+ M(需验证过程)
**风险等级**:中(`ChatResponse` 删除是 Breaking Change,外部可能静默依赖)
**验收条件**
- `old_stream.rs` 文件不存在
- `ChatResponse` 结构体不存在(全局搜索仅保留 `OpenaiChatResponse` 引用)
- `stream.rs` 只保留 `pub use` 重导出
- `cargo build --all-targets` 编译通过
- `cargo test --all-targets` 全绿(预期 285 测试)
- `cargo clippy --all-targets -- -D warnings` 0 警告
- `cargo doc --no-deps` 无 warning
**回退方式**`git revert` 该 commit(单独 revert 即可恢复 `ChatResponse` + `old_stream.rs`
+120 -4
View File
@@ -26,14 +26,127 @@ use crate::llm::convert::{from_openai, to_openai};
use crate::llm::error::LlmError;
use crate::llm::types::message::{ContentBlock, ContentBlockType, Message};
use crate::llm::types::openai_message::{ContentField, OpenaiChatMessage};
use crate::llm::types::request::{OpenaiChatRequest, OpenaiTool, StreamOptions};
use crate::llm::types::request_v2::MessageRequest;
use crate::llm::types::response::{OpenaiChatChunk, OpenaiChatResponse};
use crate::llm::types::response_v2::{
MessageResponse, PartialMessageResponse, PartialUsage, StopReason, StreamEvent,
};
use crate::llm::types::shared::FinishReason;
use crate::llm::types::tool::OpenaiToolCall;
use crate::llm::types::shared::{FinishReason, ResponseFormat, ServiceTier, StopSequence};
use crate::llm::types::tool::{OpenaiToolCall, OpenaiToolDefinition, ToolChoice};
use serde::Deserialize;
// =============================================================================
// 0. OpenAI wire-format 类型(Phase 13 从 types::request 迁入)
// =============================================================================
/// 流式响应选项。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StreamOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_obfuscation: Option<bool>,
}
/// OpenAI wire-format 工具定义。
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub(crate) enum OpenaiTool {
Function { function: OpenaiToolDefinition },
}
/// 音频输出参数。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct AudioParam {
pub format: String,
pub voice: String,
}
/// 预测内容(OpenAI `prediction` 字段)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PredictionContent {
#[serde(rename = "type")]
pub pred_type: String,
pub content: String,
}
/// 用户位置(web search 用)。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct UserLocation {
#[serde(rename = "type")]
pub loc_type: String,
pub approximate: Approximate,
}
/// 近似位置。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Approximate {
pub city: String,
pub country: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
/// Web search 选项。
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct WebSearchOptions {
pub search_context_size: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_location: Option<UserLocation>,
}
/// OpenAI Chat Completions 请求体。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct OpenaiChatRequest {
pub model: String,
pub messages: Vec<OpenaiChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<StopSequence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenaiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_body: Option<Value>,
}
// =============================================================================
// 1. GenericOpenaiProvider —— OpenAI-compatible 协议共用实现
@@ -210,7 +323,10 @@ impl GenericOpenaiProvider {
///
/// 实现注意:先在函数顶部抽取出所有 needed 字段(clone 或 move),避免后续
/// 部分移动 `request` 后无法借用其它字段。
pub fn convert_request(&self, request: MessageRequest) -> Result<OpenaiChatRequest, LlmError> {
pub(crate) fn convert_request(
&self,
request: MessageRequest,
) -> Result<OpenaiChatRequest, LlmError> {
// ponytail: 先抽取 / clone 所有 owned 字段,再访问 request.extra
// 避免部分移动导致后续 `&self` borrow 失败。
let model = request.model.clone();
-2
View File
@@ -1,7 +1,6 @@
pub mod message;
pub mod old_stream;
pub mod openai_message;
pub mod request;
pub mod request_v2;
pub mod response;
pub mod response_v2;
@@ -12,7 +11,6 @@ pub mod usage;
pub use openai_message::{
ContentField, FileData, ImageURL, InputAudio, OpenaiChatMessage, OpenaiContentPart,
};
pub use request::{OpenaiChatRequest, OpenaiTool, StreamOptions};
pub use request_v2::{ExtraError, MessageRequest, ThinkingConfig};
pub use response::{
Annotation, Choice, ChunkChoice, Delta, Logprobs, OpenaiAudio, OpenaiChatChunk,
-100
View File
@@ -1,100 +0,0 @@
use crate::llm::types::shared::{ResponseFormat, ServiceTier, StopSequence};
use crate::llm::types::tool::{OpenaiToolDefinition, ToolChoice};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub include_usage: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_obfuscation: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum OpenaiTool {
Function { function: OpenaiToolDefinition },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AudioParam {
pub format: String,
pub voice: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PredictionContent {
#[serde(rename = "type")]
pub pred_type: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserLocation {
#[serde(rename = "type")]
pub loc_type: String,
pub approximate: Approximate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approximate {
pub city: String,
pub country: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchOptions {
pub search_context_size: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_location: Option<UserLocation>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct OpenaiChatRequest {
pub model: String,
pub messages: Vec<crate::llm::types::openai_message::OpenaiChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logit_bias: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<ServiceTier>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<StopSequence>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<OpenaiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_headers: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra_body: Option<Value>,
}