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

641 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`