# 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 13(v0.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.1(ToolChoice 不搬走,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, 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 for Delta` + `From 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 for ChatResponse` 和 `From 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 ` 即可。每个 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-merge(child.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 格式: ``` (): <中文描述> ``` - 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 消息内容、血缘关系不变)。 **预估工作量**:M(1-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.4(ToolChoice 已移走,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` 文件 **预估工作量**:M(1-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` 文件 **预估工作量**:M(1-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 for ChatResponse` + `From 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(<1h,cleanup)+ 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`)