28ca43ccb2
将 note、pdd、prd、roadmap 四类文档分别归入 `design/` 下对应子目录中,并新增 `.gitkeep` 占位文件
641 lines
31 KiB
Markdown
641 lines
31 KiB
Markdown
# 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<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-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 格式:
|
||
```
|
||
<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 消息内容、血缘关系不变)。
|
||
|
||
**预估工作量**: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<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(<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`)
|